From f87a64cb12dccfdaf75626b4350867c2187dcbb8 Mon Sep 17 00:00:00 2001 From: William Boles Date: Wed, 9 Sep 2026 16:28:27 +0100 Subject: [PATCH 01/16] Updated .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index d534044..71be855 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ xcuserdata/ timeline.xctimeline playground.xcworkspace +##Config +Secrets.xcconfig + # Swift Package Manager # # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. From 286a45fd936c9a62cb7d67fb65484c01664440ca Mon Sep 17 00:00:00 2001 From: William Boles Date: Wed, 9 Sep 2026 17:34:00 +0100 Subject: [PATCH 02/16] Added secret file to store api key --- .../project.pbxproj | 2 ++ .../Requests/Abstract/RequestConfig.swift | 14 +++++++++++--- .../Data/Managers/GalleryDataManager.swift | 6 ++++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/PausableDownloads-Example.xcodeproj/project.pbxproj b/PausableDownloads-Example.xcodeproj/project.pbxproj index b09a6a3..216ab1c 100644 --- a/PausableDownloads-Example.xcodeproj/project.pbxproj +++ b/PausableDownloads-Example.xcodeproj/project.pbxproj @@ -82,6 +82,7 @@ 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSessionFactory.swift; sourceTree = ""; }; 4399D38C3050B4DB009D2CEB /* TestError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestError.swift; sourceTree = ""; }; 4399D38E3050B4DB009D2CEB /* AssetDownloadsSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetDownloadsSessionTests.swift; sourceTree = ""; }; + 43DF70D53051B477004E9EEA /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -306,6 +307,7 @@ 3DE07FC01FFF0F31003C95C0 = { isa = PBXGroup; children = ( + 43DF70D53051B477004E9EEA /* Secrets.xcconfig */, 3D63CC2B204B554700797A82 /* PausableDownloads-Example */, 3D63CC6E204B555300797A82 /* PausableDownloads-ExampleTests */, 3DE07FCA1FFF0F31003C95C0 /* Products */, diff --git a/PausableDownloads-Example/Data/Factories/Requests/Abstract/RequestConfig.swift b/PausableDownloads-Example/Data/Factories/Requests/Abstract/RequestConfig.swift index b993016..d31a77d 100644 --- a/PausableDownloads-Example/Data/Factories/Requests/Abstract/RequestConfig.swift +++ b/PausableDownloads-Example/Data/Factories/Requests/Abstract/RequestConfig.swift @@ -30,13 +30,21 @@ class RequestConfig { // MARK: - Init init() { - self.clientID = "REPLACE_ME" //TODO: Added your clientID here + self.clientID = Bundle.main.object(forInfoDictionaryKey: "ClientID") as? String ?? "" // Add your API key from: https://api.imgur.com/oauth2/addclient //"REPLACE_ME" //TODO: Added your clientID here self.APIHost = "https://api.imgur.com/3" self.timeInterval = 45 self.cachePolicy = .useProtocolCachePolicy - if clientID == "REPLACE_ME" { - os_log(.info, "You need to provide a clientID hash, you get this from: https://api.imgur.com/oauth2/addclient") + if clientID.isEmpty { + os_log(.error, """ + ******************************************************************************* + ******************************************************************************* + ******************************************************************************* + ******************************* MISSING API KEY ******************************* + ******************************************************************************* + ******************************************************************************* + ******************************************************************************* + """) } } } diff --git a/PausableDownloads-Example/Data/Managers/GalleryDataManager.swift b/PausableDownloads-Example/Data/Managers/GalleryDataManager.swift index da9ce6c..f0e3f8f 100644 --- a/PausableDownloads-Example/Data/Managers/GalleryDataManager.swift +++ b/PausableDownloads-Example/Data/Managers/GalleryDataManager.swift @@ -15,14 +15,16 @@ class GalleryDataManager { // MARK: - Init - init(session: URLSession = URLSession.shared, urlRequestFactory: GalleryURLRequestFactory = GalleryURLRequestFactory()) { + init(session: URLSession = URLSession.shared, + urlRequestFactory: GalleryURLRequestFactory = GalleryURLRequestFactory()) { self.session = session self.urlRequestFactory = urlRequestFactory } // MARK: - List - func retrieveGallery(forSearchTerms searchTerms: String, completionHandler: @escaping ((_ searchTerms: String, _ result: Result<[GalleryAlbum], Error>) -> ())) { + func retrieveGallery(forSearchTerms searchTerms: String, + completionHandler: @escaping ((_ searchTerms: String, _ result: Result<[GalleryAlbum], Error>) -> ())) { let request = urlRequestFactory.requestToRetrieveGallerySearchResults(for: searchTerms) let task = session.dataTask(with: request) { (data: Data?, response: URLResponse?, error: Error?) in From f8313d4771709b0f2c884515b9a5feeaab0976c0 Mon Sep 17 00:00:00 2001 From: William Boles Date: Wed, 9 Sep 2026 18:06:38 +0100 Subject: [PATCH 03/16] Switched from Imgur to TheCatAPI --- .../project.pbxproj | 58 +++------- .../Application/Info.plist | 2 + .../Requests/Abstract/RequestConfig.swift | 8 +- .../Requests/Abstract/URLRequestFactory.swift | 2 +- .../Requests/CatImagesURLRequestFactory.swift | 23 ++++ .../Requests/GalleryURLRequestFactory.swift | 21 ---- .../Data/Managers/AssetDataManager.swift | 49 ++++---- .../Data/Managers/CatImagesDataManager.swift | 63 +++++++++++ .../Data/Managers/GalleryDataManager.swift | 58 ---------- .../Data/Model/CatImage.swift | 33 ++++++ .../Data/Model/GalleryAlbum.swift | 15 --- .../Data/Model/GalleryAsset.swift | 31 ------ .../Data/Model/GalleryItem.swift | 22 ---- .../Data/Parsers/Abstract/Parser.swift | 18 --- .../Data/Parsers/GalleryAlbumParser.swift | 105 ------------------ .../AlbumViewerViewController.swift | 103 ----------------- .../GalleryAlbumViewerViewController.swift | 48 ++++---- .../GalleryAlbumCollectionViewCell.swift | 14 +-- .../Albums/GalleryAlbumsViewController.swift | 38 +++---- README.md | 2 +- 20 files changed, 213 insertions(+), 500 deletions(-) create mode 100644 PausableDownloads-Example/Data/Factories/Requests/CatImagesURLRequestFactory.swift delete mode 100644 PausableDownloads-Example/Data/Factories/Requests/GalleryURLRequestFactory.swift create mode 100644 PausableDownloads-Example/Data/Managers/CatImagesDataManager.swift delete mode 100644 PausableDownloads-Example/Data/Managers/GalleryDataManager.swift create mode 100644 PausableDownloads-Example/Data/Model/CatImage.swift delete mode 100644 PausableDownloads-Example/Data/Model/GalleryAlbum.swift delete mode 100644 PausableDownloads-Example/Data/Model/GalleryAsset.swift delete mode 100644 PausableDownloads-Example/Data/Model/GalleryItem.swift delete mode 100644 PausableDownloads-Example/Data/Parsers/Abstract/Parser.swift delete mode 100644 PausableDownloads-Example/Data/Parsers/GalleryAlbumParser.swift delete mode 100644 PausableDownloads-Example/ViewControllers/AlbumViewer/AlbumViewerViewController.swift diff --git a/PausableDownloads-Example.xcodeproj/project.pbxproj b/PausableDownloads-Example.xcodeproj/project.pbxproj index 216ab1c..106f63a 100644 --- a/PausableDownloads-Example.xcodeproj/project.pbxproj +++ b/PausableDownloads-Example.xcodeproj/project.pbxproj @@ -17,19 +17,15 @@ 3D63CC5D204B554700797A82 /* GalleryAlbumViewerTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D63CC3D204B554700797A82 /* GalleryAlbumViewerTitleView.swift */; }; 3D63CC5E204B554700797A82 /* GalleryAlbumViewerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D63CC3E204B554700797A82 /* GalleryAlbumViewerViewController.swift */; }; 3D63CC5F204B554700797A82 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D63CC40204B554700797A82 /* AppDelegate.swift */; }; - 3DB20F0A23B8283C00B5B6AD /* Parser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20EF423B8283C00B5B6AD /* Parser.swift */; }; - 3DB20F0B23B8283C00B5B6AD /* GalleryAlbumParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20EF523B8283C00B5B6AD /* GalleryAlbumParser.swift */; }; 3DB20F0C23B8283C00B5B6AD /* AssetDownloadsSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20EF823B8283C00B5B6AD /* AssetDownloadsSession.swift */; }; 3DB20F0F23B8283C00B5B6AD /* AssetDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20EFB23B8283C00B5B6AD /* AssetDataManager.swift */; }; - 3DB20F1023B8283C00B5B6AD /* GalleryDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20EFC23B8283C00B5B6AD /* GalleryDataManager.swift */; }; + 3DB20F1023B8283C00B5B6AD /* CatImagesDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20EFC23B8283C00B5B6AD /* CatImagesDataManager.swift */; }; 3DB20F1123B8283C00B5B6AD /* URLRequest+HTTPBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20F0023B8283C00B5B6AD /* URLRequest+HTTPBody.swift */; }; 3DB20F1223B8283C00B5B6AD /* RequestConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20F0123B8283C00B5B6AD /* RequestConfig.swift */; }; 3DB20F1323B8283C00B5B6AD /* URLRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20F0223B8283C00B5B6AD /* URLRequestFactory.swift */; }; - 3DB20F1423B8283C00B5B6AD /* GalleryURLRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20F0323B8283C00B5B6AD /* GalleryURLRequestFactory.swift */; }; + 3DB20F1423B8283C00B5B6AD /* CatImagesURLRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20F0323B8283C00B5B6AD /* CatImagesURLRequestFactory.swift */; }; 3DB20F1523B8283C00B5B6AD /* URLSessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20F0523B8283C00B5B6AD /* URLSessionFactory.swift */; }; - 3DB20F1623B8283C00B5B6AD /* GalleryAsset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20F0723B8283C00B5B6AD /* GalleryAsset.swift */; }; - 3DB20F1723B8283C00B5B6AD /* GalleryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20F0823B8283C00B5B6AD /* GalleryItem.swift */; }; - 3DB20F1823B8283C00B5B6AD /* GalleryAlbum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20F0923B8283C00B5B6AD /* GalleryAlbum.swift */; }; + 3DB20F1623B8283C00B5B6AD /* CatImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20F0723B8283C00B5B6AD /* CatImage.swift */; }; 4399D3903050B4DB009D2CEB /* StubURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D3893050B4DB009D2CEB /* StubURLSession.swift */; }; 4399D3913050B4DB009D2CEB /* StubURLSessionDownloadTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */; }; 4399D3923050B4DB009D2CEB /* StubURLSessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */; }; @@ -61,19 +57,15 @@ 3D63CC40204B554700797A82 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 3D63CC41204B554700797A82 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 3D63CC75204B555300797A82 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 3DB20EF423B8283C00B5B6AD /* Parser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Parser.swift; sourceTree = ""; }; - 3DB20EF523B8283C00B5B6AD /* GalleryAlbumParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GalleryAlbumParser.swift; sourceTree = ""; }; 3DB20EF823B8283C00B5B6AD /* AssetDownloadsSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssetDownloadsSession.swift; sourceTree = ""; }; 3DB20EFB23B8283C00B5B6AD /* AssetDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssetDataManager.swift; sourceTree = ""; }; - 3DB20EFC23B8283C00B5B6AD /* GalleryDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GalleryDataManager.swift; sourceTree = ""; }; + 3DB20EFC23B8283C00B5B6AD /* CatImagesDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CatImagesDataManager.swift; sourceTree = ""; }; 3DB20F0023B8283C00B5B6AD /* URLRequest+HTTPBody.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URLRequest+HTTPBody.swift"; sourceTree = ""; }; 3DB20F0123B8283C00B5B6AD /* RequestConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RequestConfig.swift; sourceTree = ""; }; 3DB20F0223B8283C00B5B6AD /* URLRequestFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLRequestFactory.swift; sourceTree = ""; }; - 3DB20F0323B8283C00B5B6AD /* GalleryURLRequestFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GalleryURLRequestFactory.swift; sourceTree = ""; }; + 3DB20F0323B8283C00B5B6AD /* CatImagesURLRequestFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CatImagesURLRequestFactory.swift; sourceTree = ""; }; 3DB20F0523B8283C00B5B6AD /* URLSessionFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLSessionFactory.swift; sourceTree = ""; }; - 3DB20F0723B8283C00B5B6AD /* GalleryAsset.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GalleryAsset.swift; sourceTree = ""; }; - 3DB20F0823B8283C00B5B6AD /* GalleryItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GalleryItem.swift; sourceTree = ""; }; - 3DB20F0923B8283C00B5B6AD /* GalleryAlbum.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GalleryAlbum.swift; sourceTree = ""; }; + 3DB20F0723B8283C00B5B6AD /* CatImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CatImage.swift; sourceTree = ""; }; 3DE07FC91FFF0F31003C95C0 /* PausableDownloads-Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "PausableDownloads-Example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 3DE07FE11FFF0F31003C95C0 /* PausableDownloads-ExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "PausableDownloads-ExampleTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 4399D3883050B4DB009D2CEB /* StubNotificationCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubNotificationCenter.swift; sourceTree = ""; }; @@ -215,7 +207,6 @@ 3DB20EF123B8283C00B5B6AD /* Data */ = { isa = PBXGroup; children = ( - 3DB20EF223B8283C00B5B6AD /* Parsers */, 3DB20EF623B8283C00B5B6AD /* Managers */, 3DB20EFD23B8283C00B5B6AD /* Factories */, 3DB20F0623B8283C00B5B6AD /* Model */, @@ -223,29 +214,12 @@ path = Data; sourceTree = ""; }; - 3DB20EF223B8283C00B5B6AD /* Parsers */ = { - isa = PBXGroup; - children = ( - 3DB20EF323B8283C00B5B6AD /* Abstract */, - 3DB20EF523B8283C00B5B6AD /* GalleryAlbumParser.swift */, - ); - path = Parsers; - sourceTree = ""; - }; - 3DB20EF323B8283C00B5B6AD /* Abstract */ = { - isa = PBXGroup; - children = ( - 3DB20EF423B8283C00B5B6AD /* Parser.swift */, - ); - path = Abstract; - sourceTree = ""; - }; 3DB20EF623B8283C00B5B6AD /* Managers */ = { isa = PBXGroup; children = ( 3DB20EF723B8283C00B5B6AD /* Asset */, 3DB20EFB23B8283C00B5B6AD /* AssetDataManager.swift */, - 3DB20EFC23B8283C00B5B6AD /* GalleryDataManager.swift */, + 3DB20EFC23B8283C00B5B6AD /* CatImagesDataManager.swift */, ); path = Managers; sourceTree = ""; @@ -271,7 +245,7 @@ isa = PBXGroup; children = ( 3DB20EFF23B8283C00B5B6AD /* Abstract */, - 3DB20F0323B8283C00B5B6AD /* GalleryURLRequestFactory.swift */, + 3DB20F0323B8283C00B5B6AD /* CatImagesURLRequestFactory.swift */, ); path = Requests; sourceTree = ""; @@ -297,9 +271,7 @@ 3DB20F0623B8283C00B5B6AD /* Model */ = { isa = PBXGroup; children = ( - 3DB20F0723B8283C00B5B6AD /* GalleryAsset.swift */, - 3DB20F0823B8283C00B5B6AD /* GalleryItem.swift */, - 3DB20F0923B8283C00B5B6AD /* GalleryAlbum.swift */, + 3DB20F0723B8283C00B5B6AD /* CatImage.swift */, ); path = Model; sourceTree = ""; @@ -449,25 +421,21 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 3DB20F1623B8283C00B5B6AD /* GalleryAsset.swift in Sources */, + 3DB20F1623B8283C00B5B6AD /* CatImage.swift in Sources */, 3DB20F1123B8283C00B5B6AD /* URLRequest+HTTPBody.swift in Sources */, 3D63CC5F204B554700797A82 /* AppDelegate.swift in Sources */, 3DB20F1523B8283C00B5B6AD /* URLSessionFactory.swift in Sources */, - 3DB20F0B23B8283C00B5B6AD /* GalleryAlbumParser.swift in Sources */, 3D63CC5B204B554700797A82 /* GalleryAlbumCollectionViewCell.swift in Sources */, - 3DB20F1423B8283C00B5B6AD /* GalleryURLRequestFactory.swift in Sources */, + 3DB20F1423B8283C00B5B6AD /* CatImagesURLRequestFactory.swift in Sources */, 3DB20F1223B8283C00B5B6AD /* RequestConfig.swift in Sources */, 3DB20F1323B8283C00B5B6AD /* URLRequestFactory.swift in Sources */, 3D63CC5E204B554700797A82 /* GalleryAlbumViewerViewController.swift in Sources */, 3DB20F0C23B8283C00B5B6AD /* AssetDownloadsSession.swift in Sources */, 3DB20F0F23B8283C00B5B6AD /* AssetDataManager.swift in Sources */, 3D63CC5D204B554700797A82 /* GalleryAlbumViewerTitleView.swift in Sources */, - 3DB20F1723B8283C00B5B6AD /* GalleryItem.swift in Sources */, 3D63CC5A204B554700797A82 /* NSObject+Name.swift in Sources */, - 3DB20F1823B8283C00B5B6AD /* GalleryAlbum.swift in Sources */, 3D63CC5C204B554700797A82 /* GalleryAlbumsViewController.swift in Sources */, - 3DB20F0A23B8283C00B5B6AD /* Parser.swift in Sources */, - 3DB20F1023B8283C00B5B6AD /* GalleryDataManager.swift in Sources */, + 3DB20F1023B8283C00B5B6AD /* CatImagesDataManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -516,6 +484,7 @@ /* Begin XCBuildConfiguration section */ 3DE07FE81FFF0F31003C95C0 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 43DF70D53051B477004E9EEA /* Secrets.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -576,6 +545,7 @@ }; 3DE07FE91FFF0F31003C95C0 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 43DF70D53051B477004E9EEA /* Secrets.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; diff --git a/PausableDownloads-Example/Application/Info.plist b/PausableDownloads-Example/Application/Info.plist index f87da23..4888e83 100644 --- a/PausableDownloads-Example/Application/Info.plist +++ b/PausableDownloads-Example/Application/Info.plist @@ -18,6 +18,8 @@ 1.0 CFBundleVersion 1 + CatAPIKey + $(CAT_API_KEY) LSRequiresIPhoneOS NSAppTransportSecurity diff --git a/PausableDownloads-Example/Data/Factories/Requests/Abstract/RequestConfig.swift b/PausableDownloads-Example/Data/Factories/Requests/Abstract/RequestConfig.swift index d31a77d..6a00286 100644 --- a/PausableDownloads-Example/Data/Factories/Requests/Abstract/RequestConfig.swift +++ b/PausableDownloads-Example/Data/Factories/Requests/Abstract/RequestConfig.swift @@ -18,7 +18,7 @@ enum HTTPRequestMethod: String { class RequestConfig { - let clientID: String + let apiKey: String let APIHost: String let timeInterval: TimeInterval let cachePolicy: NSURLRequest.CachePolicy @@ -30,12 +30,12 @@ class RequestConfig { // MARK: - Init init() { - self.clientID = Bundle.main.object(forInfoDictionaryKey: "ClientID") as? String ?? "" // Add your API key from: https://api.imgur.com/oauth2/addclient //"REPLACE_ME" //TODO: Added your clientID here - self.APIHost = "https://api.imgur.com/3" + self.apiKey = Bundle.main.object(forInfoDictionaryKey: "CatAPIKey") as? String ?? "" // Add your API key from: https://thecatapi.com/ + self.APIHost = "https://api.thecatapi.com/v1" self.timeInterval = 45 self.cachePolicy = .useProtocolCachePolicy - if clientID.isEmpty { + if apiKey.isEmpty { os_log(.error, """ ******************************************************************************* ******************************************************************************* diff --git a/PausableDownloads-Example/Data/Factories/Requests/Abstract/URLRequestFactory.swift b/PausableDownloads-Example/Data/Factories/Requests/Abstract/URLRequestFactory.swift index ad8e467..3d01aed 100644 --- a/PausableDownloads-Example/Data/Factories/Requests/Abstract/URLRequestFactory.swift +++ b/PausableDownloads-Example/Data/Factories/Requests/Abstract/URLRequestFactory.swift @@ -32,7 +32,7 @@ class URLRequestFactory { let url = URL(string: encodedStringURL!)! var request = URLRequest(url: url) - request.addValue("Client-ID \(config.clientID)", forHTTPHeaderField: "Authorization") + request.addValue(config.apiKey, forHTTPHeaderField: "x-api-key") return request } diff --git a/PausableDownloads-Example/Data/Factories/Requests/CatImagesURLRequestFactory.swift b/PausableDownloads-Example/Data/Factories/Requests/CatImagesURLRequestFactory.swift new file mode 100644 index 0000000..9218aee --- /dev/null +++ b/PausableDownloads-Example/Data/Factories/Requests/CatImagesURLRequestFactory.swift @@ -0,0 +1,23 @@ +// +// CatImagesURLRequestFactory.swift +// PausableDownloads-Example +// +// Created by William Boles on 07/01/2018. +// Copyright © 2018 William Boles. All rights reserved. +// + +import Foundation + +class CatImagesURLRequestFactory: URLRequestFactory { + + // MARK: - Retrieval + + //`order=RANDOM` as TheCatAPI has no chronological ordering - `ASC`/`DESC` sort by id, + //which always surfaces the same legacy images + func requestToRetrieveImages(limit: Int = 30) -> URLRequest { + var request = jsonRequest(endPoint: "images/search?limit=\(limit)&order=RANDOM") + request.httpMethod = HTTPRequestMethod.get.rawValue + + return request + } +} diff --git a/PausableDownloads-Example/Data/Factories/Requests/GalleryURLRequestFactory.swift b/PausableDownloads-Example/Data/Factories/Requests/GalleryURLRequestFactory.swift deleted file mode 100644 index c019432..0000000 --- a/PausableDownloads-Example/Data/Factories/Requests/GalleryURLRequestFactory.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// GalleryURLRequestFactory.swift -// DownloadStack-Example -// -// Created by William Boles on 07/01/2018. -// Copyright © 2018 William Boles. All rights reserved. -// - -import Foundation - -class GalleryURLRequestFactory: URLRequestFactory { - - // MARK: - Retrieval - - func requestToRetrieveGallerySearchResults(for searchTerms: String) -> URLRequest { - var request = jsonRequest(endPoint: "gallery/search/?q_all=\(searchTerms)&q_type=jpg") - request.httpMethod = HTTPRequestMethod.get.rawValue - - return request - } -} diff --git a/PausableDownloads-Example/Data/Managers/AssetDataManager.swift b/PausableDownloads-Example/Data/Managers/AssetDataManager.swift index 186504e..55f658a 100644 --- a/PausableDownloads-Example/Data/Managers/AssetDataManager.swift +++ b/PausableDownloads-Example/Data/Managers/AssetDataManager.swift @@ -9,8 +9,8 @@ import Foundation import UIKit -struct LoadAssetResult: Equatable { - let asset: GalleryAsset +struct LoadImageResult: Equatable { + let catImage: CatImage let image: UIImage } @@ -19,55 +19,45 @@ class AssetDataManager { private let assetDownloadSession = AssetDownloadsSession.shared private let fileManager = FileManager.default - // MARK: - GalleryAlbum + // MARK: - CatImage - func loadAlbumThumbnailAsset(_ asset: GalleryAsset, completionHandler: @escaping ((_ result: Result) -> ())) { - if fileManager.fileExists(atPath: asset.cachedLocalAssetURL().path) { - locallyLoadAsset(asset, completionHandler: completionHandler) + func loadImage(_ catImage: CatImage, completionHandler: @escaping ((_ result: Result) -> ())) { + if fileManager.fileExists(atPath: catImage.cachedLocalAssetURL().path) { + locallyLoadImage(catImage, completionHandler: completionHandler) } else { - remotelyLoadAsset(asset, completionHandler: completionHandler) + remotelyLoadImage(catImage, completionHandler: completionHandler) } } - // MARK: - GalleryItem - - func loadGalleryItemAsset(_ asset: GalleryAsset, completionHandler: @escaping ((_ result: Result) -> ())) { - if fileManager.fileExists(atPath: asset.cachedLocalAssetURL().path) { - locallyLoadAsset(asset, completionHandler: completionHandler) - } else { - remotelyLoadAsset(asset, completionHandler: completionHandler) - } - } - - func cancelLoadingGalleryItemAsset(_ asset: GalleryAsset) { - assetDownloadSession.cancelDownload(url: asset.url) + func cancelLoadingImage(_ catImage: CatImage) { + assetDownloadSession.cancelDownload(url: catImage.url) } // MARK: - Asset - private func locallyLoadAsset(_ asset: GalleryAsset, completionHandler: @escaping ((_ result: Result) -> ())) { + private func locallyLoadImage(_ catImage: CatImage, completionHandler: @escaping ((_ result: Result) -> ())) { do { - let data = try Data(contentsOf: URL(fileURLWithPath: asset.cachedLocalAssetURL().path)) + let data = try Data(contentsOf: URL(fileURLWithPath: catImage.cachedLocalAssetURL().path)) guard let image = UIImage(data: data) else { completionHandler(.failure(NetworkingError.invalidData(underlyingError: nil))) return } - let loadResult = LoadAssetResult(asset: asset, image: image) - let dataRequestResult = Result.success(loadResult) + let loadResult = LoadImageResult(catImage: catImage, image: image) + let dataRequestResult = Result.success(loadResult) DispatchQueue.main.async { completionHandler(dataRequestResult) } } catch { - remotelyLoadAsset(asset, completionHandler: completionHandler) + remotelyLoadImage(catImage, completionHandler: completionHandler) } } - private func remotelyLoadAsset(_ asset: GalleryAsset, completionHandler: @escaping ((_ result: Result) -> ())) { + private func remotelyLoadImage(_ catImage: CatImage, completionHandler: @escaping ((_ result: Result) -> ())) { - assetDownloadSession.scheduleDownload(url: asset.url) { (result) in + assetDownloadSession.scheduleDownload(url: catImage.url) { (result) in switch result { case .success(let data): guard let image = UIImage(data: data) else { @@ -76,13 +66,14 @@ class AssetDataManager { } do { - try data.write(to: asset.cachedLocalAssetURL(), options: .atomic) + try data.write(to: catImage.cachedLocalAssetURL(), options: .atomic) } catch let error { completionHandler(.failure(NetworkingError.invalidData(underlyingError: error))) + return } - let loadResult = LoadAssetResult(asset: asset, image: image) - let dataRequestResult = Result.success(loadResult) + let loadResult = LoadImageResult(catImage: catImage, image: image) + let dataRequestResult = Result.success(loadResult) DispatchQueue.main.async { completionHandler(dataRequestResult) diff --git a/PausableDownloads-Example/Data/Managers/CatImagesDataManager.swift b/PausableDownloads-Example/Data/Managers/CatImagesDataManager.swift new file mode 100644 index 0000000..ed4378b --- /dev/null +++ b/PausableDownloads-Example/Data/Managers/CatImagesDataManager.swift @@ -0,0 +1,63 @@ +// +// CatImagesDataManager.swift +// PausableDownloads-Example +// +// Created by William Boles on 15/01/2018. +// Copyright © 2018 William Boles. All rights reserved. +// + +import Foundation + +class CatImagesDataManager { + + let urlRequestFactory: CatImagesURLRequestFactory + let session: URLSession + + // MARK: - Init + + init(session: URLSession = URLSession.shared, + urlRequestFactory: CatImagesURLRequestFactory = CatImagesURLRequestFactory()) { + self.session = session + self.urlRequestFactory = urlRequestFactory + } + + // MARK: - List + + func retrieveImages(completionHandler: @escaping ((_ result: Result<[CatImage], Error>) -> ())) { + let request = urlRequestFactory.requestToRetrieveImages() + + let task = session.dataTask(with: request) { (data: Data?, response: URLResponse?, error: Error?) in + guard let data = data else { + DispatchQueue.main.async { + let retrievalError = NetworkingError.retrieval(underlyingError: error) + completionHandler(Result.failure(retrievalError)) + } + return + } + + guard let statusCode = (response as? HTTPURLResponse)?.statusCode, + (200..<300).contains(statusCode) else { + DispatchQueue.main.async { + let retrievalError = NetworkingError.retrieval(underlyingError: error) + completionHandler(Result.failure(retrievalError)) + } + return + } + + do { + let catImages = try JSONDecoder().decode([CatImage].self, from: data) + + DispatchQueue.main.async { + completionHandler(Result.success(catImages)) + } + } catch let error { + DispatchQueue.main.async { + let invalidError = NetworkingError.invalidData(underlyingError: error) + completionHandler(Result.failure(invalidError)) + } + } + } + + task.resume() + } +} diff --git a/PausableDownloads-Example/Data/Managers/GalleryDataManager.swift b/PausableDownloads-Example/Data/Managers/GalleryDataManager.swift deleted file mode 100644 index f0e3f8f..0000000 --- a/PausableDownloads-Example/Data/Managers/GalleryDataManager.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// GalleryDataManager.swift -// PausableDownloads-Example -// -// Created by William Boles on 07/01/2018. -// Copyright © 2018 William Boles. All rights reserved. -// - -import Foundation - -class GalleryDataManager { - - let urlRequestFactory: GalleryURLRequestFactory - let session: URLSession - - // MARK: - Init - - init(session: URLSession = URLSession.shared, - urlRequestFactory: GalleryURLRequestFactory = GalleryURLRequestFactory()) { - self.session = session - self.urlRequestFactory = urlRequestFactory - } - - // MARK: - List - - func retrieveGallery(forSearchTerms searchTerms: String, - completionHandler: @escaping ((_ searchTerms: String, _ result: Result<[GalleryAlbum], Error>) -> ())) { - let request = urlRequestFactory.requestToRetrieveGallerySearchResults(for: searchTerms) - - let task = session.dataTask(with: request) { (data: Data?, response: URLResponse?, error: Error?) in - guard let data = data else { - DispatchQueue.main.async { - let retrievalError = NetworkingError.retrieval(underlyingError: error) - completionHandler(searchTerms, Result.failure(retrievalError)) - } - return - } - - do { - let json = try JSONSerialization.jsonObject(with: data, options: .mutableContainers) as! [String: Any] - - let parser = GalleryAlbumParser() - let galleryAlbums = parser.parseResponse(json) - - DispatchQueue.main.async { - completionHandler(searchTerms, Result.success(galleryAlbums)) - } - } catch let error { - DispatchQueue.main.async { - let invalidError = NetworkingError.invalidData(underlyingError: error) - completionHandler(searchTerms, Result.failure(invalidError)) - } - } - } - - task.resume() - } -} diff --git a/PausableDownloads-Example/Data/Model/CatImage.swift b/PausableDownloads-Example/Data/Model/CatImage.swift new file mode 100644 index 0000000..059f281 --- /dev/null +++ b/PausableDownloads-Example/Data/Model/CatImage.swift @@ -0,0 +1,33 @@ +// +// CatImage.swift +// PausableDownloads-Example +// +// Created by William Boles on 17/01/2018. +// Copyright © 2018 William Boles. All rights reserved. +// + +import Foundation + +struct CatImage: Decodable, Equatable { + + let identifier: String + let url: URL + let width: Int + let height: Int + + private enum CodingKeys: String, CodingKey { + case identifier = "id" + case url + case width + case height + } + + // MARK: - Cache + + func cachedLocalAssetURL() -> URL { + let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).last! + let fileName = "\(identifier).\(url.pathExtension)" + + return cacheURL.appendingPathComponent(fileName) + } +} diff --git a/PausableDownloads-Example/Data/Model/GalleryAlbum.swift b/PausableDownloads-Example/Data/Model/GalleryAlbum.swift deleted file mode 100644 index 56bb69c..0000000 --- a/PausableDownloads-Example/Data/Model/GalleryAlbum.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// GalleryAlbum.swift -// PausableDownloads-Example -// -// Created by William Boles on 17/01/2018. -// Copyright © 2018 William Boles. All rights reserved. -// - -import Foundation - -struct GalleryAlbum: Equatable { - - let thumbnailAsset: GalleryAsset - let items: [GalleryItem] -} diff --git a/PausableDownloads-Example/Data/Model/GalleryAsset.swift b/PausableDownloads-Example/Data/Model/GalleryAsset.swift deleted file mode 100644 index 4450e08..0000000 --- a/PausableDownloads-Example/Data/Model/GalleryAsset.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// GalleryAsset.swift -// PausableDownloads-Example -// -// Created by William Boles on 17/01/2018. -// Copyright © 2018 William Boles. All rights reserved. -// - -import Foundation - -struct GalleryAsset { - - let id: String - let url: URL - - // MARK: - Location - - func cachedLocalAssetURL() -> URL { - let cacheURL = FileManager.default.urls(for: FileManager.SearchPathDirectory.cachesDirectory, in: FileManager.SearchPathDomainMask.userDomainMask).last! - let fileName = url.deletingPathExtension().lastPathComponent - return cacheURL.appendingPathComponent(fileName) - } - -} - -extension GalleryAsset: Equatable { - static func ==(lhs: GalleryAsset, rhs: GalleryAsset) -> Bool { - return lhs.id == rhs.id && - lhs.url == rhs.url - } -} diff --git a/PausableDownloads-Example/Data/Model/GalleryItem.swift b/PausableDownloads-Example/Data/Model/GalleryItem.swift deleted file mode 100644 index 2f7c2d1..0000000 --- a/PausableDownloads-Example/Data/Model/GalleryItem.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// GalleryImageAsset.swift -// PausableDownloads-Example -// -// Created by William Boles on 07/01/2018. -// Copyright © 2018 William Boles. All rights reserved. -// - -import Foundation - -struct GalleryItem { - - let title: String - let asset: GalleryAsset -} - -extension GalleryItem: Equatable { - static func ==(lhs: GalleryItem, rhs: GalleryItem) -> Bool { - return lhs.title == rhs.title && - lhs.asset == rhs.asset - } -} diff --git a/PausableDownloads-Example/Data/Parsers/Abstract/Parser.swift b/PausableDownloads-Example/Data/Parsers/Abstract/Parser.swift deleted file mode 100644 index 74ee1b8..0000000 --- a/PausableDownloads-Example/Data/Parsers/Abstract/Parser.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Parser.swift -// PausableDownloads-Example -// -// Created by William Boles on 12/11/2017. -// Copyright © 2017 William Boles. All rights reserved. -// - -import Foundation - -class Parser { - - // MARK: - Parse - - func parseResponse(_ response: [String: Any]) -> T { - fatalError("Subclass needs to override this method") - } -} diff --git a/PausableDownloads-Example/Data/Parsers/GalleryAlbumParser.swift b/PausableDownloads-Example/Data/Parsers/GalleryAlbumParser.swift deleted file mode 100644 index db8c4b5..0000000 --- a/PausableDownloads-Example/Data/Parsers/GalleryAlbumParser.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// GalleryAlbumParser.swift -// PausableDownloads-Example -// -// Created by William Boles on 12/11/2017. -// Copyright © 2017 William Boles. All rights reserved. -// - -import Foundation - -class GalleryAlbumParser: Parser<[GalleryAlbum]> { - - // MARK: - Parse - - override func parseResponse(_ response: [String: Any]) -> [GalleryAlbum] { - var galleryAlbums = [GalleryAlbum]() - - guard let itemsResponse = response["data"] as? [[String: Any]] else { - return galleryAlbums - } - - for itemResponse in itemsResponse { - if let galleryItems = parseItem(itemResponse) { - if galleryItems.count > 0 { - let galleryItem = galleryItems[0] - let galleryAlbumThumbnailURL = generateThumbnailURL(from: galleryItem) - let thumbnailAsset = GalleryAsset(id: fileName(forURL: galleryAlbumThumbnailURL), url: galleryAlbumThumbnailURL) - - let galleryAlbum = GalleryAlbum(thumbnailAsset: thumbnailAsset, items: galleryItems) - - galleryAlbums.append(galleryAlbum) - } - } - } - - return galleryAlbums - } - - private func parseItem(_ itemResponse: [String: Any]) -> [GalleryItem]? { - guard let isAlbum = itemResponse["is_album"] as? Bool else { - return nil - } - - if isAlbum { - return parseItemAlbum(itemResponse) - } else { - return parseItemImage(itemResponse) - } - } - - func parseItemImage(_ itemResponse: [String: Any]) -> [GalleryItem]? { - guard let itemTitle = itemResponse["title"] as? String, - let imageURLString = itemResponse["link"] as? String, - let imageURL = URL(string: imageURLString) - else { - return nil - } - - let asset = GalleryAsset(id: fileName(forURL: imageURL), url: imageURL) - - return [GalleryItem(title: itemTitle, asset: asset)] - } - - func parseItemAlbum(_ itemResponse: [String: Any]) -> [GalleryItem]? { - guard let itemTitle = itemResponse["title"] as? String, - let imageResponses = itemResponse["images"] as? [[String: Any]] - else { - return nil - } - - var galleryItems = [GalleryItem]() - - for imageResponse in imageResponses { - if let linkURLString = imageResponse["link"] as? String { - if let linkURL = URL(string: linkURLString) { - var title = itemTitle - - if let imageTitle = imageResponse["description"] as? String { - title = imageTitle - } - - let asset = GalleryAsset(id: fileName(forURL: linkURL), url: linkURL) - - let galleryItem = GalleryItem(title: title, asset: asset) - galleryItems.append(galleryItem) - } - } - } - - return galleryItems - } - - func generateThumbnailURL(from galleryItem: GalleryItem) -> URL { - let pathExtension = galleryItem.asset.url.pathExtension - let linkWithoutPathExtension = galleryItem.asset.url.deletingPathExtension() - - let thumbnailURLString = "\(linkWithoutPathExtension)t.\(pathExtension)" - - return URL(string: thumbnailURLString)! - } - - func fileName(forURL url: URL) -> String { - return url.deletingPathExtension().lastPathComponent - } -} diff --git a/PausableDownloads-Example/ViewControllers/AlbumViewer/AlbumViewerViewController.swift b/PausableDownloads-Example/ViewControllers/AlbumViewer/AlbumViewerViewController.swift deleted file mode 100644 index 517ed7a..0000000 --- a/PausableDownloads-Example/ViewControllers/AlbumViewer/AlbumViewerViewController.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// AlbumViewerViewController.swift -// PausableDownloads-Example -// -// Created by William Boles on 07/01/2018. -// Copyright © 2018 William Boles. All rights reserved. -// - -import UIKit - -class GalleryAlbumViewerViewController: UIViewController { - - @IBOutlet weak var assetImageView: UIImageView! - @IBOutlet weak var descriptionLabel: UILabel! - @IBOutlet weak var loadingActivityIndicator: UIActivityIndicatorView! - - @IBOutlet weak var tapGstureRecognizer: UITapGestureRecognizer! - - private let assetDataManager = AssetDataManager() - - var galleryItems = [GalleryItem]() - - var index = 0 - - // MARK: - ViewLifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - retrieveAsset() - updateTitle() - navigationItem.hidesBackButton = true - } - - // MARK: - Title - - func updateTitle() { - guard let titleView = navigationItem.titleView as? GalleryAlbumViewerTitleView else { - return - } - - titleView.titleLabel.text = "\(index+1) of \(galleryItems.count)" - - if index+1 == galleryItems.count { - titleView.subtitleLabel.text = "Tap to close album" - } - } - - // MARK: - GestureRecognizer - - @IBAction func didTap(_ sender: Any) { - cancelAssertRetrieval() - index += 1 - - if index < galleryItems.count { - retrieveAsset() - updateTitle() - } else { - navigationController?.popViewController(animated: true) - } - } - - // MARK: - Reuse - - func prepareForReuse() { - loadingActivityIndicator.startAnimating() - assetImageView.image = nil - } - - // MARK: - Asset - - func retrieveAsset() { - let galleryItem = galleryItems[index] - prepareForReuse() - descriptionLabel.text = "\(galleryItem.asset.url.absoluteString)" - assetDataManager.loadGalleryItemAsset(galleryItem.asset) { [weak self] (result) in - guard let strongSelf = self else { - return - } - - guard strongSelf.index <= strongSelf.galleryItems.count else { - return - } - - switch result { - case .success(let loadResult): - let currentGalleryItem = strongSelf.galleryItems[strongSelf.index] - if loadResult.asset == currentGalleryItem.asset { - strongSelf.loadingActivityIndicator.stopAnimating() - strongSelf.assetImageView.image = loadResult.image - } - case .failure(_): - //TODO: Handle - break - } - } - } - - func cancelAssertRetrieval() { - let galleryItem = galleryItems[index] - assetDataManager.cancelLoadingGalleryItemAsset(galleryItem.asset) - } -} diff --git a/PausableDownloads-Example/ViewControllers/AlbumViewer/GalleryAlbumViewerViewController.swift b/PausableDownloads-Example/ViewControllers/AlbumViewer/GalleryAlbumViewerViewController.swift index 793cc0d..d76a596 100644 --- a/PausableDownloads-Example/ViewControllers/AlbumViewer/GalleryAlbumViewerViewController.swift +++ b/PausableDownloads-Example/ViewControllers/AlbumViewer/GalleryAlbumViewerViewController.swift @@ -2,7 +2,7 @@ // GalleryAlbumViewerViewController.swift // PausableDownloads-Example // -// Created by William Boles on 07/01/2018. +// Created by William Boles on 15/01/2018. // Copyright © 2018 William Boles. All rights reserved. // @@ -14,11 +14,9 @@ class GalleryAlbumViewerViewController: UIViewController { @IBOutlet weak var descriptionLabel: UILabel! @IBOutlet weak var loadingActivityIndicator: UIActivityIndicatorView! - @IBOutlet weak var tapGstureRecognizer: UITapGestureRecognizer! - private let assetDataManager = AssetDataManager() - var galleryItems = [GalleryItem]() + var catImages = [CatImage]() var index = 0 @@ -27,7 +25,11 @@ class GalleryAlbumViewerViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - retrieveAsset() + guard index < catImages.count else { + return + } + + retrieveImage() updateTitle() navigationItem.hidesBackButton = true } @@ -39,21 +41,21 @@ class GalleryAlbumViewerViewController: UIViewController { return } - titleView.titleLabel.text = "\(index+1) of \(galleryItems.count)" + titleView.titleLabel.text = "\(index+1) of \(catImages.count)" - if index+1 == galleryItems.count { - titleView.subtitleLabel.text = "Tap to close album" + if index+1 == catImages.count { + titleView.subtitleLabel.text = "Tap to close" } } // MARK: - GestureRecognizer @IBAction func didTap(_ sender: Any) { - cancelAssertRetrieval() + cancelImageRetrieval() index += 1 - if index < galleryItems.count { - retrieveAsset() + if index < catImages.count { + retrieveImage() updateTitle() } else { navigationController?.popViewController(animated: true) @@ -69,23 +71,23 @@ class GalleryAlbumViewerViewController: UIViewController { // MARK: - Asset - func retrieveAsset() { - let galleryItem = galleryItems[index] + func retrieveImage() { + let catImage = catImages[index] prepareForReuse() - descriptionLabel.text = "\(galleryItem.asset.url.absoluteString)" - assetDataManager.loadGalleryItemAsset(galleryItem.asset) { [weak self] (result) in + descriptionLabel.text = "\(catImage.url.absoluteString)" + assetDataManager.loadImage(catImage) { [weak self] (result) in guard let strongSelf = self else { return } - guard strongSelf.index <= strongSelf.galleryItems.count else { + guard strongSelf.index < strongSelf.catImages.count else { return } switch result { case .success(let loadResult): - let currentGalleryItem = strongSelf.galleryItems[strongSelf.index] - if loadResult.asset == currentGalleryItem.asset { + let currentCatImage = strongSelf.catImages[strongSelf.index] + if loadResult.catImage == currentCatImage { strongSelf.loadingActivityIndicator.stopAnimating() strongSelf.assetImageView.image = loadResult.image } @@ -96,8 +98,12 @@ class GalleryAlbumViewerViewController: UIViewController { } } - func cancelAssertRetrieval() { - let galleryItem = galleryItems[index] - assetDataManager.cancelLoadingGalleryItemAsset(galleryItem.asset) + func cancelImageRetrieval() { + guard index < catImages.count else { + return + } + + let catImage = catImages[index] + assetDataManager.cancelLoadingImage(catImage) } } diff --git a/PausableDownloads-Example/ViewControllers/Albums/Cells/GalleryAlbumCollectionViewCell.swift b/PausableDownloads-Example/ViewControllers/Albums/Cells/GalleryAlbumCollectionViewCell.swift index a555a57..9f125f0 100644 --- a/PausableDownloads-Example/ViewControllers/Albums/Cells/GalleryAlbumCollectionViewCell.swift +++ b/PausableDownloads-Example/ViewControllers/Albums/Cells/GalleryAlbumCollectionViewCell.swift @@ -2,7 +2,7 @@ // GalleryAlbumCollectionViewCell.swift // PausableDownloads-Example // -// Created by William Boles on 17/01/2018. +// Created by William Boles on 15/01/2018. // Copyright © 2018 William Boles. All rights reserved. // @@ -14,7 +14,7 @@ class GalleryAlbumCollectionViewCell: UICollectionViewCell { @IBOutlet weak var thumbnailImageView: UIImageView! private var assetDataManager = AssetDataManager() - private var thumbnailAsset: GalleryAsset? + private var catImage: CatImage? // MARK: - Reuse @@ -26,14 +26,14 @@ class GalleryAlbumCollectionViewCell: UICollectionViewCell { // MARK: - Configure - func configure(galleryAlbum: GalleryAlbum) { - informationalLabel.text = "\(galleryAlbum.thumbnailAsset.url.absoluteString)" - thumbnailAsset = galleryAlbum.thumbnailAsset + func configure(catImage: CatImage) { + informationalLabel.text = "\(catImage.url.absoluteString)" + self.catImage = catImage - assetDataManager.loadAlbumThumbnailAsset(galleryAlbum.thumbnailAsset) { [weak self] (result) in + assetDataManager.loadImage(catImage) { [weak self] (result) in switch result { case .success(let loadResult): - if loadResult.asset == self?.thumbnailAsset { + if loadResult.catImage == self?.catImage { self?.thumbnailImageView.image = loadResult.image } case .failure(_): diff --git a/PausableDownloads-Example/ViewControllers/Albums/GalleryAlbumsViewController.swift b/PausableDownloads-Example/ViewControllers/Albums/GalleryAlbumsViewController.swift index ee83c98..68e1a84 100644 --- a/PausableDownloads-Example/ViewControllers/Albums/GalleryAlbumsViewController.swift +++ b/PausableDownloads-Example/ViewControllers/Albums/GalleryAlbumsViewController.swift @@ -2,7 +2,7 @@ // GalleryAlbumsViewController.swift // PausableDownloads-Example // -// Created by William Boles on 17/01/2018. +// Created by William Boles on 15/01/2018. // Copyright © 2018 William Boles. All rights reserved. // @@ -13,8 +13,8 @@ class GalleryAlbumsViewController: UIViewController { @IBOutlet weak var collectionView: UICollectionView! @IBOutlet weak var loadingActivityIndicatorView: UIActivityIndicatorView! - let dataManager = GalleryDataManager() - var galleryAlbums = [GalleryAlbum]() + let dataManager = CatImagesDataManager() + var catImages = [CatImage]() let fileManager = FileManager.default // MARK: - Lifecycle @@ -22,20 +22,20 @@ class GalleryAlbumsViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - retrieveAlbums() + retrieveImages() } - // MARK: - Albums + // MARK: - Images - func retrieveAlbums() { + func retrieveImages() { loadingActivityIndicatorView.startAnimating() - dataManager.retrieveGallery(forSearchTerms: "cats") { (searchTerms, result) in + dataManager.retrieveImages { (result) in self.loadingActivityIndicatorView.stopAnimating() switch result { - case .success(let galleryAlbums): - self.galleryAlbums = galleryAlbums + case .success(let catImages): + self.catImages = catImages self.collectionView.reloadData() case .failure(_): //TODO: Handle error @@ -54,7 +54,8 @@ class GalleryAlbumsViewController: UIViewController { return } - viewController.galleryItems = galleryAlbums[indexPath.item].items + viewController.catImages = catImages + viewController.index = indexPath.item } } @@ -63,23 +64,20 @@ class GalleryAlbumsViewController: UIViewController { @IBAction func resetButtonPressed(_ sender: Any) { loadingActivityIndicatorView.startAnimating() - for galleryAlbum in galleryAlbums { - try? fileManager.removeItem(at: galleryAlbum.thumbnailAsset.cachedLocalAssetURL()) - for galleryItem in galleryAlbum.items { - try? fileManager.removeItem(at: galleryItem.asset.cachedLocalAssetURL()) - } + for catImage in catImages { + try? fileManager.removeItem(at: catImage.cachedLocalAssetURL()) } - galleryAlbums.removeAll() + catImages.removeAll() collectionView.reloadData() - retrieveAlbums() + retrieveImages() } } extension GalleryAlbumsViewController: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return galleryAlbums.count + return catImages.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { @@ -87,9 +85,9 @@ extension GalleryAlbumsViewController: UICollectionViewDataSource { fatalError("Expected cell of type: \(GalleryAlbumCollectionViewCell.className)") } - let galleryAlbum = galleryAlbums[indexPath.row] + let catImage = catImages[indexPath.item] - cell.configure(galleryAlbum: galleryAlbum) + cell.configure(catImage: catImage) return cell } diff --git a/README.md b/README.md index 2d338ba..cb3e513 100644 --- a/README.md +++ b/README.md @@ -5,4 +5,4 @@ # PausableDownloads-Example An example project about pausing and resuming download requests, https://williamboles.com/not-all-downloads-are-born-equal/ -In order to run this project, you will need to register with [Imgur](https://api.imgur.com/oauth2/addclient) to get a `client-id` token to access Imgur's API (which the project uses to get its example content). Once you have your `client-id`, add it to the project as the value of the `clientID` property in the `RequestConfig` class and the project should now run. If you have any trouble getting the project to run, please create an issue or get in touch with me on Twitter at [wibosco](https://twitter.com/wibosco). +In order to run this project, you will need to register with [TheCatAPI](https://thecatapi.com/) to get an API key to access TheCatAPI's API (which the project uses to get its example content). Once you have your key, add an `xcconfig` file called `Secrets` to the top directory with your key as the value of `CAT_API_KEY` and the project should now run. If you have any trouble getting the project to run, please create an issue or get in touch with me on Twitter at [wibosco](https://twitter.com/wibosco). From 95c2fdc6c7d00029e1bba469586aa9cc56e3e66e Mon Sep 17 00:00:00 2001 From: William Boles Date: Wed, 9 Sep 2026 21:06:58 +0100 Subject: [PATCH 04/16] Introduced services --- .../project.pbxproj | 214 +++++++----------- .../Extensions/NSObject/NSObject+Name.swift | 20 -- .../Abstract/RequestConfig.swift | 0 .../Abstract/URLRequest+HTTPBody.swift | 0 .../Abstract/URLRequestFactory.swift | 0 .../CatImagesURLRequestFactory.swift | 0 .../URLSessionFactory.swift | 0 .../Repositories/CatImages/ImageDTO.swift | 127 +++++++++++ .../CatImages/ImagesRepository.swift} | 15 +- .../Asset/AssetDownloadsSession.swift | 0 .../Asset/AssetService.swift} | 40 ++-- .../Images/ImageDomainModel.swift} | 16 +- .../Images/ImagesDomainModelFactory.swift | 21 ++ .../Services/Images/ImagesService.swift | 36 +++ .../Storyboards/Base.lproj/Main.storyboard | 165 +------------- .../GalleryAlbumViewerTitleView.swift | 15 -- .../GalleryAlbumViewerViewController.swift | 109 --------- .../GalleryAlbumCollectionViewCell.swift | 45 ---- .../Albums/GalleryAlbumsViewController.swift | 102 --------- .../ImageViewerViewController.swift | 103 +++++++++ 20 files changed, 416 insertions(+), 612 deletions(-) delete mode 100644 PausableDownloads-Example/Extensions/NSObject/NSObject+Name.swift rename PausableDownloads-Example/{Data/Factories/Requests => Networking}/Abstract/RequestConfig.swift (100%) rename PausableDownloads-Example/{Data/Factories/Requests => Networking}/Abstract/URLRequest+HTTPBody.swift (100%) rename PausableDownloads-Example/{Data/Factories/Requests => Networking}/Abstract/URLRequestFactory.swift (100%) rename PausableDownloads-Example/{Data/Factories/Requests => Networking}/CatImagesURLRequestFactory.swift (100%) rename PausableDownloads-Example/{Data/Factories/Sessions => Networking}/URLSessionFactory.swift (100%) create mode 100644 PausableDownloads-Example/Repositories/CatImages/ImageDTO.swift rename PausableDownloads-Example/{Data/Managers/CatImagesDataManager.swift => Repositories/CatImages/ImagesRepository.swift} (84%) rename PausableDownloads-Example/{Data/Managers => Services}/Asset/AssetDownloadsSession.swift (100%) rename PausableDownloads-Example/{Data/Managers/AssetDataManager.swift => Services/Asset/AssetService.swift} (53%) rename PausableDownloads-Example/{Data/Model/CatImage.swift => Services/Images/ImageDomainModel.swift} (58%) create mode 100644 PausableDownloads-Example/Services/Images/ImagesDomainModelFactory.swift create mode 100644 PausableDownloads-Example/Services/Images/ImagesService.swift delete mode 100644 PausableDownloads-Example/ViewControllers/AlbumViewer/GalleryAlbumViewerTitleView.swift delete mode 100644 PausableDownloads-Example/ViewControllers/AlbumViewer/GalleryAlbumViewerViewController.swift delete mode 100644 PausableDownloads-Example/ViewControllers/Albums/Cells/GalleryAlbumCollectionViewCell.swift delete mode 100644 PausableDownloads-Example/ViewControllers/Albums/GalleryAlbumsViewController.swift create mode 100644 PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift diff --git a/PausableDownloads-Example.xcodeproj/project.pbxproj b/PausableDownloads-Example.xcodeproj/project.pbxproj index 106f63a..b5d4c13 100644 --- a/PausableDownloads-Example.xcodeproj/project.pbxproj +++ b/PausableDownloads-Example.xcodeproj/project.pbxproj @@ -11,21 +11,20 @@ 3D2AFA1F23BB9BA000A6D999 /* square.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 3D2AFA1E23BB9BA000A6D999 /* square.pdf */; }; 3D63CC57204B554700797A82 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3D63CC2D204B554700797A82 /* LaunchScreen.storyboard */; }; 3D63CC58204B554700797A82 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3D63CC2F204B554700797A82 /* Main.storyboard */; }; - 3D63CC5A204B554700797A82 /* NSObject+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D63CC36204B554700797A82 /* NSObject+Name.swift */; }; - 3D63CC5B204B554700797A82 /* GalleryAlbumCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D63CC3A204B554700797A82 /* GalleryAlbumCollectionViewCell.swift */; }; - 3D63CC5C204B554700797A82 /* GalleryAlbumsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D63CC3B204B554700797A82 /* GalleryAlbumsViewController.swift */; }; - 3D63CC5D204B554700797A82 /* GalleryAlbumViewerTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D63CC3D204B554700797A82 /* GalleryAlbumViewerTitleView.swift */; }; - 3D63CC5E204B554700797A82 /* GalleryAlbumViewerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D63CC3E204B554700797A82 /* GalleryAlbumViewerViewController.swift */; }; + 3D63CC5E204B554700797A82 /* ImageViewerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D63CC3E204B554700797A82 /* ImageViewerViewController.swift */; }; 3D63CC5F204B554700797A82 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D63CC40204B554700797A82 /* AppDelegate.swift */; }; - 3DB20F0C23B8283C00B5B6AD /* AssetDownloadsSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20EF823B8283C00B5B6AD /* AssetDownloadsSession.swift */; }; - 3DB20F0F23B8283C00B5B6AD /* AssetDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20EFB23B8283C00B5B6AD /* AssetDataManager.swift */; }; - 3DB20F1023B8283C00B5B6AD /* CatImagesDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20EFC23B8283C00B5B6AD /* CatImagesDataManager.swift */; }; - 3DB20F1123B8283C00B5B6AD /* URLRequest+HTTPBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20F0023B8283C00B5B6AD /* URLRequest+HTTPBody.swift */; }; - 3DB20F1223B8283C00B5B6AD /* RequestConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20F0123B8283C00B5B6AD /* RequestConfig.swift */; }; - 3DB20F1323B8283C00B5B6AD /* URLRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20F0223B8283C00B5B6AD /* URLRequestFactory.swift */; }; - 3DB20F1423B8283C00B5B6AD /* CatImagesURLRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20F0323B8283C00B5B6AD /* CatImagesURLRequestFactory.swift */; }; - 3DB20F1523B8283C00B5B6AD /* URLSessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20F0523B8283C00B5B6AD /* URLSessionFactory.swift */; }; - 3DB20F1623B8283C00B5B6AD /* CatImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB20F0723B8283C00B5B6AD /* CatImage.swift */; }; + 437C0CA63051EC1A009529DF /* CatImagesURLRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CA33051EC1A009529DF /* CatImagesURLRequestFactory.swift */; }; + 437C0CA73051EC1A009529DF /* RequestConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0C9F3051EC1A009529DF /* RequestConfig.swift */; }; + 437C0CA83051EC1A009529DF /* URLRequest+HTTPBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CA03051EC1A009529DF /* URLRequest+HTTPBody.swift */; }; + 437C0CA93051EC1A009529DF /* URLRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CA13051EC1A009529DF /* URLRequestFactory.swift */; }; + 437C0CAA3051EC1A009529DF /* URLSessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CA43051EC1A009529DF /* URLSessionFactory.swift */; }; + 437C0CB33051EC36009529DF /* AssetDownloadsSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CAF3051EC36009529DF /* AssetDownloadsSession.swift */; }; + 437C0CB43051EC36009529DF /* ImageDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CAB3051EC36009529DF /* ImageDTO.swift */; }; + 437C0CB63051EC36009529DF /* ImagesRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CAC3051EC36009529DF /* ImagesRepository.swift */; }; + 437C0D3B3051ED69009529DF /* ImagesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D3A3051ED69009529DF /* ImagesService.swift */; }; + 437C0D4B3051ED90009529DF /* ImagesDomainModelFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D4A3051ED90009529DF /* ImagesDomainModelFactory.swift */; }; + 437C0D3D3051ED80009529DF /* ImageDomainModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D3C3051ED80009529DF /* ImageDomainModel.swift */; }; + 437C0D3F3051EDCC009529DF /* AssetService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D3E3051EDCC009529DF /* AssetService.swift */; }; 4399D3903050B4DB009D2CEB /* StubURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D3893050B4DB009D2CEB /* StubURLSession.swift */; }; 4399D3913050B4DB009D2CEB /* StubURLSessionDownloadTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */; }; 4399D3923050B4DB009D2CEB /* StubURLSessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */; }; @@ -49,25 +48,24 @@ 3D2AFA1E23BB9BA000A6D999 /* square.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = square.pdf; sourceTree = ""; }; 3D63CC2E204B554700797A82 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 3D63CC30204B554700797A82 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 3D63CC36204B554700797A82 /* NSObject+Name.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSObject+Name.swift"; sourceTree = ""; }; - 3D63CC3A204B554700797A82 /* GalleryAlbumCollectionViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GalleryAlbumCollectionViewCell.swift; sourceTree = ""; }; - 3D63CC3B204B554700797A82 /* GalleryAlbumsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GalleryAlbumsViewController.swift; sourceTree = ""; }; - 3D63CC3D204B554700797A82 /* GalleryAlbumViewerTitleView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GalleryAlbumViewerTitleView.swift; sourceTree = ""; }; - 3D63CC3E204B554700797A82 /* GalleryAlbumViewerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GalleryAlbumViewerViewController.swift; sourceTree = ""; }; + 3D63CC3E204B554700797A82 /* ImageViewerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageViewerViewController.swift; sourceTree = ""; }; 3D63CC40204B554700797A82 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 3D63CC41204B554700797A82 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 3D63CC75204B555300797A82 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 3DB20EF823B8283C00B5B6AD /* AssetDownloadsSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssetDownloadsSession.swift; sourceTree = ""; }; - 3DB20EFB23B8283C00B5B6AD /* AssetDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssetDataManager.swift; sourceTree = ""; }; - 3DB20EFC23B8283C00B5B6AD /* CatImagesDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CatImagesDataManager.swift; sourceTree = ""; }; - 3DB20F0023B8283C00B5B6AD /* URLRequest+HTTPBody.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URLRequest+HTTPBody.swift"; sourceTree = ""; }; - 3DB20F0123B8283C00B5B6AD /* RequestConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RequestConfig.swift; sourceTree = ""; }; - 3DB20F0223B8283C00B5B6AD /* URLRequestFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLRequestFactory.swift; sourceTree = ""; }; - 3DB20F0323B8283C00B5B6AD /* CatImagesURLRequestFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CatImagesURLRequestFactory.swift; sourceTree = ""; }; - 3DB20F0523B8283C00B5B6AD /* URLSessionFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLSessionFactory.swift; sourceTree = ""; }; - 3DB20F0723B8283C00B5B6AD /* CatImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CatImage.swift; sourceTree = ""; }; 3DE07FC91FFF0F31003C95C0 /* PausableDownloads-Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "PausableDownloads-Example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 3DE07FE11FFF0F31003C95C0 /* PausableDownloads-ExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "PausableDownloads-ExampleTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + 437C0C9F3051EC1A009529DF /* RequestConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestConfig.swift; sourceTree = ""; }; + 437C0CA03051EC1A009529DF /* URLRequest+HTTPBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest+HTTPBody.swift"; sourceTree = ""; }; + 437C0CA13051EC1A009529DF /* URLRequestFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLRequestFactory.swift; sourceTree = ""; }; + 437C0CA33051EC1A009529DF /* CatImagesURLRequestFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatImagesURLRequestFactory.swift; sourceTree = ""; }; + 437C0CA43051EC1A009529DF /* URLSessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionFactory.swift; sourceTree = ""; }; + 437C0CAB3051EC36009529DF /* ImageDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDTO.swift; sourceTree = ""; }; + 437C0CAC3051EC36009529DF /* ImagesRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesRepository.swift; sourceTree = ""; }; + 437C0CAF3051EC36009529DF /* AssetDownloadsSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetDownloadsSession.swift; sourceTree = ""; }; + 437C0D3A3051ED69009529DF /* ImagesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesService.swift; sourceTree = ""; }; + 437C0D4A3051ED90009529DF /* ImagesDomainModelFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesDomainModelFactory.swift; sourceTree = ""; }; + 437C0D3C3051ED80009529DF /* ImageDomainModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDomainModel.swift; sourceTree = ""; }; + 437C0D3E3051EDCC009529DF /* AssetService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetService.swift; sourceTree = ""; }; 4399D3883050B4DB009D2CEB /* StubNotificationCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubNotificationCenter.swift; sourceTree = ""; }; 4399D3893050B4DB009D2CEB /* StubURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSession.swift; sourceTree = ""; }; 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSessionDownloadTask.swift; sourceTree = ""; }; @@ -107,9 +105,10 @@ isa = PBXGroup; children = ( 3D63CC3F204B554700797A82 /* Application */, - 3DB20EF123B8283C00B5B6AD /* Data */, - 3D63CC34204B554700797A82 /* Extensions */, + 437C0CA53051EC1A009529DF /* Networking */, + 437C0CAE3051EC36009529DF /* Repositories */, 3D63CC31204B554700797A82 /* Resources */, + 437C0CB23051EC36009529DF /* Services */, 3D63CC2C204B554700797A82 /* Storyboards */, 3D63CC37204B554700797A82 /* ViewControllers */, ); @@ -133,55 +132,20 @@ path = Resources; sourceTree = ""; }; - 3D63CC34204B554700797A82 /* Extensions */ = { - isa = PBXGroup; - children = ( - 3D63CC35204B554700797A82 /* NSObject */, - ); - path = Extensions; - sourceTree = ""; - }; - 3D63CC35204B554700797A82 /* NSObject */ = { - isa = PBXGroup; - children = ( - 3D63CC36204B554700797A82 /* NSObject+Name.swift */, - ); - path = NSObject; - sourceTree = ""; - }; 3D63CC37204B554700797A82 /* ViewControllers */ = { isa = PBXGroup; children = ( - 3D63CC38204B554700797A82 /* Albums */, - 3D63CC3C204B554700797A82 /* AlbumViewer */, + 3D63CC3C204B554700797A82 /* ImageViewer */, ); path = ViewControllers; sourceTree = ""; }; - 3D63CC38204B554700797A82 /* Albums */ = { - isa = PBXGroup; - children = ( - 3D63CC39204B554700797A82 /* Cells */, - 3D63CC3B204B554700797A82 /* GalleryAlbumsViewController.swift */, - ); - path = Albums; - sourceTree = ""; - }; - 3D63CC39204B554700797A82 /* Cells */ = { - isa = PBXGroup; - children = ( - 3D63CC3A204B554700797A82 /* GalleryAlbumCollectionViewCell.swift */, - ); - path = Cells; - sourceTree = ""; - }; - 3D63CC3C204B554700797A82 /* AlbumViewer */ = { + 3D63CC3C204B554700797A82 /* ImageViewer */ = { isa = PBXGroup; children = ( - 3D63CC3D204B554700797A82 /* GalleryAlbumViewerTitleView.swift */, - 3D63CC3E204B554700797A82 /* GalleryAlbumViewerViewController.swift */, + 3D63CC3E204B554700797A82 /* ImageViewerViewController.swift */, ); - path = AlbumViewer; + path = ImageViewer; sourceTree = ""; }; 3D63CC3F204B554700797A82 /* Application */ = { @@ -204,95 +168,88 @@ path = "PausableDownloads-ExampleTests"; sourceTree = ""; }; - 3DB20EF123B8283C00B5B6AD /* Data */ = { - isa = PBXGroup; - children = ( - 3DB20EF623B8283C00B5B6AD /* Managers */, - 3DB20EFD23B8283C00B5B6AD /* Factories */, - 3DB20F0623B8283C00B5B6AD /* Model */, - ); - path = Data; - sourceTree = ""; - }; - 3DB20EF623B8283C00B5B6AD /* Managers */ = { + 3DE07FC01FFF0F31003C95C0 = { isa = PBXGroup; children = ( - 3DB20EF723B8283C00B5B6AD /* Asset */, - 3DB20EFB23B8283C00B5B6AD /* AssetDataManager.swift */, - 3DB20EFC23B8283C00B5B6AD /* CatImagesDataManager.swift */, + 43DF70D53051B477004E9EEA /* Secrets.xcconfig */, + 3D63CC2B204B554700797A82 /* PausableDownloads-Example */, + 3D63CC6E204B555300797A82 /* PausableDownloads-ExampleTests */, + 3DE07FCA1FFF0F31003C95C0 /* Products */, ); - path = Managers; sourceTree = ""; }; - 3DB20EF723B8283C00B5B6AD /* Asset */ = { + 3DE07FCA1FFF0F31003C95C0 /* Products */ = { isa = PBXGroup; children = ( - 3DB20EF823B8283C00B5B6AD /* AssetDownloadsSession.swift */, + 3DE07FC91FFF0F31003C95C0 /* PausableDownloads-Example.app */, + 3DE07FE11FFF0F31003C95C0 /* PausableDownloads-ExampleTests.xctest */, ); - path = Asset; + name = Products; sourceTree = ""; }; - 3DB20EFD23B8283C00B5B6AD /* Factories */ = { + 437C0CA23051EC1A009529DF /* Abstract */ = { isa = PBXGroup; children = ( - 3DB20EFE23B8283C00B5B6AD /* Requests */, - 3DB20F0423B8283C00B5B6AD /* Sessions */, + 437C0C9F3051EC1A009529DF /* RequestConfig.swift */, + 437C0CA03051EC1A009529DF /* URLRequest+HTTPBody.swift */, + 437C0CA13051EC1A009529DF /* URLRequestFactory.swift */, ); - path = Factories; + path = Abstract; sourceTree = ""; }; - 3DB20EFE23B8283C00B5B6AD /* Requests */ = { + 437C0CA53051EC1A009529DF /* Networking */ = { isa = PBXGroup; children = ( - 3DB20EFF23B8283C00B5B6AD /* Abstract */, - 3DB20F0323B8283C00B5B6AD /* CatImagesURLRequestFactory.swift */, + 437C0CA23051EC1A009529DF /* Abstract */, + 437C0CA33051EC1A009529DF /* CatImagesURLRequestFactory.swift */, + 437C0CA43051EC1A009529DF /* URLSessionFactory.swift */, ); - path = Requests; + path = Networking; sourceTree = ""; }; - 3DB20EFF23B8283C00B5B6AD /* Abstract */ = { + 437C0CAD3051EC36009529DF /* CatImages */ = { isa = PBXGroup; children = ( - 3DB20F0023B8283C00B5B6AD /* URLRequest+HTTPBody.swift */, - 3DB20F0123B8283C00B5B6AD /* RequestConfig.swift */, - 3DB20F0223B8283C00B5B6AD /* URLRequestFactory.swift */, + 437C0CAB3051EC36009529DF /* ImageDTO.swift */, + 437C0CAC3051EC36009529DF /* ImagesRepository.swift */, ); - path = Abstract; + path = CatImages; sourceTree = ""; }; - 3DB20F0423B8283C00B5B6AD /* Sessions */ = { + 437C0CAE3051EC36009529DF /* Repositories */ = { isa = PBXGroup; children = ( - 3DB20F0523B8283C00B5B6AD /* URLSessionFactory.swift */, + 437C0CAD3051EC36009529DF /* CatImages */, ); - path = Sessions; + path = Repositories; sourceTree = ""; }; - 3DB20F0623B8283C00B5B6AD /* Model */ = { + 437C0CB03051EC36009529DF /* Asset */ = { isa = PBXGroup; children = ( - 3DB20F0723B8283C00B5B6AD /* CatImage.swift */, + 437C0CAF3051EC36009529DF /* AssetDownloadsSession.swift */, + 437C0D3E3051EDCC009529DF /* AssetService.swift */, ); - path = Model; + path = Asset; sourceTree = ""; }; - 3DE07FC01FFF0F31003C95C0 = { + 437C0CB23051EC36009529DF /* Services */ = { isa = PBXGroup; children = ( - 43DF70D53051B477004E9EEA /* Secrets.xcconfig */, - 3D63CC2B204B554700797A82 /* PausableDownloads-Example */, - 3D63CC6E204B555300797A82 /* PausableDownloads-ExampleTests */, - 3DE07FCA1FFF0F31003C95C0 /* Products */, + 437C0D393051ED57009529DF /* Images */, + 437C0CB03051EC36009529DF /* Asset */, ); + path = Services; sourceTree = ""; }; - 3DE07FCA1FFF0F31003C95C0 /* Products */ = { + 437C0D393051ED57009529DF /* Images */ = { isa = PBXGroup; children = ( - 3DE07FC91FFF0F31003C95C0 /* PausableDownloads-Example.app */, - 3DE07FE11FFF0F31003C95C0 /* PausableDownloads-ExampleTests.xctest */, + 437C0D3A3051ED69009529DF /* ImagesService.swift */, + 437C0D3C3051ED80009529DF /* ImageDomainModel.swift */, + 437C0D4A3051ED90009529DF /* ImagesDomainModelFactory.swift */, ); - name = Products; + path = Images; sourceTree = ""; }; 4399D38D3050B4DB009D2CEB /* Doubles */ = { @@ -421,21 +378,20 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 3DB20F1623B8283C00B5B6AD /* CatImage.swift in Sources */, - 3DB20F1123B8283C00B5B6AD /* URLRequest+HTTPBody.swift in Sources */, + 437C0CA63051EC1A009529DF /* CatImagesURLRequestFactory.swift in Sources */, + 437C0CB33051EC36009529DF /* AssetDownloadsSession.swift in Sources */, + 437C0CB43051EC36009529DF /* ImageDTO.swift in Sources */, + 437C0D3F3051EDCC009529DF /* AssetService.swift in Sources */, + 437C0CB63051EC36009529DF /* ImagesRepository.swift in Sources */, + 437C0CA73051EC1A009529DF /* RequestConfig.swift in Sources */, + 437C0D3B3051ED69009529DF /* ImagesService.swift in Sources */, + 437C0D4B3051ED90009529DF /* ImagesDomainModelFactory.swift in Sources */, + 437C0CA83051EC1A009529DF /* URLRequest+HTTPBody.swift in Sources */, + 437C0CA93051EC1A009529DF /* URLRequestFactory.swift in Sources */, + 437C0CAA3051EC1A009529DF /* URLSessionFactory.swift in Sources */, + 437C0D3D3051ED80009529DF /* ImageDomainModel.swift in Sources */, 3D63CC5F204B554700797A82 /* AppDelegate.swift in Sources */, - 3DB20F1523B8283C00B5B6AD /* URLSessionFactory.swift in Sources */, - 3D63CC5B204B554700797A82 /* GalleryAlbumCollectionViewCell.swift in Sources */, - 3DB20F1423B8283C00B5B6AD /* CatImagesURLRequestFactory.swift in Sources */, - 3DB20F1223B8283C00B5B6AD /* RequestConfig.swift in Sources */, - 3DB20F1323B8283C00B5B6AD /* URLRequestFactory.swift in Sources */, - 3D63CC5E204B554700797A82 /* GalleryAlbumViewerViewController.swift in Sources */, - 3DB20F0C23B8283C00B5B6AD /* AssetDownloadsSession.swift in Sources */, - 3DB20F0F23B8283C00B5B6AD /* AssetDataManager.swift in Sources */, - 3D63CC5D204B554700797A82 /* GalleryAlbumViewerTitleView.swift in Sources */, - 3D63CC5A204B554700797A82 /* NSObject+Name.swift in Sources */, - 3D63CC5C204B554700797A82 /* GalleryAlbumsViewController.swift in Sources */, - 3DB20F1023B8283C00B5B6AD /* CatImagesDataManager.swift in Sources */, + 3D63CC5E204B554700797A82 /* ImageViewerViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/PausableDownloads-Example/Extensions/NSObject/NSObject+Name.swift b/PausableDownloads-Example/Extensions/NSObject/NSObject+Name.swift deleted file mode 100644 index 7834332..0000000 --- a/PausableDownloads-Example/Extensions/NSObject/NSObject+Name.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// NSObject+Name.swift -// PausableDownloads-Example -// -// Created by William Boles on 29/06/2017. -// Copyright © 2017 William Boles. All rights reserved. -// - -import Foundation - -extension NSObject { - - var className: String { - return String(describing: type(of: self)) - } - - class var className: String { - return String(describing: self) - } -} diff --git a/PausableDownloads-Example/Data/Factories/Requests/Abstract/RequestConfig.swift b/PausableDownloads-Example/Networking/Abstract/RequestConfig.swift similarity index 100% rename from PausableDownloads-Example/Data/Factories/Requests/Abstract/RequestConfig.swift rename to PausableDownloads-Example/Networking/Abstract/RequestConfig.swift diff --git a/PausableDownloads-Example/Data/Factories/Requests/Abstract/URLRequest+HTTPBody.swift b/PausableDownloads-Example/Networking/Abstract/URLRequest+HTTPBody.swift similarity index 100% rename from PausableDownloads-Example/Data/Factories/Requests/Abstract/URLRequest+HTTPBody.swift rename to PausableDownloads-Example/Networking/Abstract/URLRequest+HTTPBody.swift diff --git a/PausableDownloads-Example/Data/Factories/Requests/Abstract/URLRequestFactory.swift b/PausableDownloads-Example/Networking/Abstract/URLRequestFactory.swift similarity index 100% rename from PausableDownloads-Example/Data/Factories/Requests/Abstract/URLRequestFactory.swift rename to PausableDownloads-Example/Networking/Abstract/URLRequestFactory.swift diff --git a/PausableDownloads-Example/Data/Factories/Requests/CatImagesURLRequestFactory.swift b/PausableDownloads-Example/Networking/CatImagesURLRequestFactory.swift similarity index 100% rename from PausableDownloads-Example/Data/Factories/Requests/CatImagesURLRequestFactory.swift rename to PausableDownloads-Example/Networking/CatImagesURLRequestFactory.swift diff --git a/PausableDownloads-Example/Data/Factories/Sessions/URLSessionFactory.swift b/PausableDownloads-Example/Networking/URLSessionFactory.swift similarity index 100% rename from PausableDownloads-Example/Data/Factories/Sessions/URLSessionFactory.swift rename to PausableDownloads-Example/Networking/URLSessionFactory.swift diff --git a/PausableDownloads-Example/Repositories/CatImages/ImageDTO.swift b/PausableDownloads-Example/Repositories/CatImages/ImageDTO.swift new file mode 100644 index 0000000..606c2fc --- /dev/null +++ b/PausableDownloads-Example/Repositories/CatImages/ImageDTO.swift @@ -0,0 +1,127 @@ +// +// ImageDTO.swift +// PausableDownloads-Example +// +// Created by William Boles on 17/01/2018. +// Copyright © 2018 William Boles. All rights reserved. +// + +import Foundation + +struct ImageDTO: Decodable, Equatable { + let id: String + let url: URL + let width: Int + let height: Int + let mimeType: String? + let breeds: [BreedDTO]? + let categories: [CategoryDTO]? + + enum CodingKeys: String, CodingKey { + case id + case url + case width + case height + case mimeType = "mime_type" + case breeds + case categories + } + + struct BreedDTO: Decodable, Equatable { + let id: String + let name: String + let altNames: String? + let description: String + let temperament: String + let origin: String + let countryCode: String? + let countryCodes: String? + + let weight: WeightDTO? + let lifeSpan: String? + + let referenceImageID: String? + let wikipediaURL: String? + let cfaURL: String? + let vetstreetURL: String? + let vcahospitalsURL: String? + + // Ratings, 1 - 5 + let adaptability: Int? + let affectionLevel: Int? + let childFriendly: Int? + let catFriendly: Int? + let dogFriendly: Int? + let strangerFriendly: Int? + let energyLevel: Int? + let grooming: Int? + let healthIssues: Int? + let intelligence: Int? + let sheddingLevel: Int? + let socialNeeds: Int? + let vocalisation: Int? + + // Flags, 0 or 1 + let experimental: Int? + let hairless: Int? + let natural: Int? + let rare: Int? + let rex: Int? + let suppressedTail: Int? + let shortLegs: Int? + let hypoallergenic: Int? + let indoor: Int? + let lap: Int? + + enum CodingKeys: String, CodingKey { + case id + case name + case altNames = "alt_names" + case description + case temperament + case origin + case countryCode = "country_code" + case countryCodes = "country_codes" + case weight + case lifeSpan = "life_span" + case referenceImageID = "reference_image_id" + case wikipediaURL = "wikipedia_url" + case cfaURL = "cfa_url" + case vetstreetURL = "vetstreet_url" + case vcahospitalsURL = "vcahospitals_url" + case adaptability + case affectionLevel = "affection_level" + case childFriendly = "child_friendly" + case catFriendly = "cat_friendly" + case dogFriendly = "dog_friendly" + case strangerFriendly = "stranger_friendly" + case energyLevel = "energy_level" + case grooming + case healthIssues = "health_issues" + case intelligence + case sheddingLevel = "shedding_level" + case socialNeeds = "social_needs" + case vocalisation + case experimental + case hairless + case natural + case rare + case rex + case suppressedTail = "suppressed_tail" + case shortLegs = "short_legs" + case hypoallergenic + case indoor + case lap + } + } + + struct WeightDTO: Decodable, Equatable { + let imperial: String + let metric: String + } + + struct CategoryDTO: Decodable, Equatable { + let id: Int + let name: String + } +} diff --git a/PausableDownloads-Example/Data/Managers/CatImagesDataManager.swift b/PausableDownloads-Example/Repositories/CatImages/ImagesRepository.swift similarity index 84% rename from PausableDownloads-Example/Data/Managers/CatImagesDataManager.swift rename to PausableDownloads-Example/Repositories/CatImages/ImagesRepository.swift index ed4378b..a1aabbe 100644 --- a/PausableDownloads-Example/Data/Managers/CatImagesDataManager.swift +++ b/PausableDownloads-Example/Repositories/CatImages/ImagesRepository.swift @@ -1,5 +1,5 @@ // -// CatImagesDataManager.swift +// ImagesRepository.swift // PausableDownloads-Example // // Created by William Boles on 15/01/2018. @@ -8,10 +8,9 @@ import Foundation -class CatImagesDataManager { - - let urlRequestFactory: CatImagesURLRequestFactory - let session: URLSession +class ImagesRepository { + private let urlRequestFactory: CatImagesURLRequestFactory + private let session: URLSession // MARK: - Init @@ -23,7 +22,7 @@ class CatImagesDataManager { // MARK: - List - func retrieveImages(completionHandler: @escaping ((_ result: Result<[CatImage], Error>) -> ())) { + func retrieveImages(completionHandler: @escaping ((_ result: Result<[ImageDTO], Error>) -> ())) { let request = urlRequestFactory.requestToRetrieveImages() let task = session.dataTask(with: request) { (data: Data?, response: URLResponse?, error: Error?) in @@ -45,10 +44,10 @@ class CatImagesDataManager { } do { - let catImages = try JSONDecoder().decode([CatImage].self, from: data) + let dtos = try JSONDecoder().decode([ImageDTO].self, from: data) DispatchQueue.main.async { - completionHandler(Result.success(catImages)) + completionHandler(Result.success(dtos)) } } catch let error { DispatchQueue.main.async { diff --git a/PausableDownloads-Example/Data/Managers/Asset/AssetDownloadsSession.swift b/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift similarity index 100% rename from PausableDownloads-Example/Data/Managers/Asset/AssetDownloadsSession.swift rename to PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift diff --git a/PausableDownloads-Example/Data/Managers/AssetDataManager.swift b/PausableDownloads-Example/Services/Asset/AssetService.swift similarity index 53% rename from PausableDownloads-Example/Data/Managers/AssetDataManager.swift rename to PausableDownloads-Example/Services/Asset/AssetService.swift index 55f658a..6987f6f 100644 --- a/PausableDownloads-Example/Data/Managers/AssetDataManager.swift +++ b/PausableDownloads-Example/Services/Asset/AssetService.swift @@ -1,5 +1,5 @@ // -// AssetDataManager.swift +// AssetService.swift // PausableDownloads-Example // // Created by William Boles on 15/01/2018. @@ -10,54 +10,56 @@ import Foundation import UIKit struct LoadImageResult: Equatable { - let catImage: CatImage + let imageDomainModel: ImageDomainModel let image: UIImage } -class AssetDataManager { - +class AssetService { private let assetDownloadSession = AssetDownloadsSession.shared private let fileManager = FileManager.default - // MARK: - CatImage + // MARK: - imageDomainModel - func loadImage(_ catImage: CatImage, completionHandler: @escaping ((_ result: Result) -> ())) { - if fileManager.fileExists(atPath: catImage.cachedLocalAssetURL().path) { - locallyLoadImage(catImage, completionHandler: completionHandler) + func loadImage(_ imageDomainModel: ImageDomainModel, + completionHandler: @escaping ((_ result: Result) -> ())) { + if fileManager.fileExists(atPath: imageDomainModel.cachedLocalAssetURL().path) { + locallyLoadImage(imageDomainModel, completionHandler: completionHandler) } else { - remotelyLoadImage(catImage, completionHandler: completionHandler) + remotelyLoadImage(imageDomainModel, completionHandler: completionHandler) } } - func cancelLoadingImage(_ catImage: CatImage) { - assetDownloadSession.cancelDownload(url: catImage.url) + func cancelLoadingImage(_ imageDomainModel: ImageDomainModel) { + assetDownloadSession.cancelDownload(url: imageDomainModel.url) } // MARK: - Asset - private func locallyLoadImage(_ catImage: CatImage, completionHandler: @escaping ((_ result: Result) -> ())) { + private func locallyLoadImage(_ imageDomainModel: ImageDomainModel, + completionHandler: @escaping ((_ result: Result) -> ())) { do { - let data = try Data(contentsOf: URL(fileURLWithPath: catImage.cachedLocalAssetURL().path)) + let data = try Data(contentsOf: URL(fileURLWithPath: imageDomainModel.cachedLocalAssetURL().path)) guard let image = UIImage(data: data) else { completionHandler(.failure(NetworkingError.invalidData(underlyingError: nil))) return } - let loadResult = LoadImageResult(catImage: catImage, image: image) + let loadResult = LoadImageResult(imageDomainModel: imageDomainModel, image: image) let dataRequestResult = Result.success(loadResult) DispatchQueue.main.async { completionHandler(dataRequestResult) } } catch { - remotelyLoadImage(catImage, completionHandler: completionHandler) + remotelyLoadImage(imageDomainModel, completionHandler: completionHandler) } } - private func remotelyLoadImage(_ catImage: CatImage, completionHandler: @escaping ((_ result: Result) -> ())) { + private func remotelyLoadImage(_ imageDomainModel: ImageDomainModel, + completionHandler: @escaping ((_ result: Result) -> ())) { - assetDownloadSession.scheduleDownload(url: catImage.url) { (result) in + assetDownloadSession.scheduleDownload(url: imageDomainModel.url) { (result) in switch result { case .success(let data): guard let image = UIImage(data: data) else { @@ -66,13 +68,13 @@ class AssetDataManager { } do { - try data.write(to: catImage.cachedLocalAssetURL(), options: .atomic) + try data.write(to: imageDomainModel.cachedLocalAssetURL(), options: .atomic) } catch let error { completionHandler(.failure(NetworkingError.invalidData(underlyingError: error))) return } - let loadResult = LoadImageResult(catImage: catImage, image: image) + let loadResult = LoadImageResult(imageDomainModel: imageDomainModel, image: image) let dataRequestResult = Result.success(loadResult) DispatchQueue.main.async { diff --git a/PausableDownloads-Example/Data/Model/CatImage.swift b/PausableDownloads-Example/Services/Images/ImageDomainModel.swift similarity index 58% rename from PausableDownloads-Example/Data/Model/CatImage.swift rename to PausableDownloads-Example/Services/Images/ImageDomainModel.swift index 059f281..0e144e7 100644 --- a/PausableDownloads-Example/Data/Model/CatImage.swift +++ b/PausableDownloads-Example/Services/Images/ImageDomainModel.swift @@ -1,27 +1,19 @@ // -// CatImage.swift +// ImageDomainModel.swift // PausableDownloads-Example // -// Created by William Boles on 17/01/2018. -// Copyright © 2018 William Boles. All rights reserved. +// Created by William Boles on 09/09/2026. +// Copyright © 2026 William Boles. All rights reserved. // import Foundation -struct CatImage: Decodable, Equatable { - +struct ImageDomainModel: Equatable { let identifier: String let url: URL let width: Int let height: Int - private enum CodingKeys: String, CodingKey { - case identifier = "id" - case url - case width - case height - } - // MARK: - Cache func cachedLocalAssetURL() -> URL { diff --git a/PausableDownloads-Example/Services/Images/ImagesDomainModelFactory.swift b/PausableDownloads-Example/Services/Images/ImagesDomainModelFactory.swift new file mode 100644 index 0000000..ebd0626 --- /dev/null +++ b/PausableDownloads-Example/Services/Images/ImagesDomainModelFactory.swift @@ -0,0 +1,21 @@ +// +// ImagesDomainModelFactory.swift +// PausableDownloads-Example +// +// Created by William Boles on 09/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import Foundation + +struct ImagesDomainModelFactory { + + // MARK: - Build + + func buildImage(from dto: ImageDTO) -> ImageDomainModel { + return ImageDomainModel(identifier: dto.id, + url: dto.url, + width: dto.width, + height: dto.height) + } +} diff --git a/PausableDownloads-Example/Services/Images/ImagesService.swift b/PausableDownloads-Example/Services/Images/ImagesService.swift new file mode 100644 index 0000000..55385b3 --- /dev/null +++ b/PausableDownloads-Example/Services/Images/ImagesService.swift @@ -0,0 +1,36 @@ +// +// ImagesService.swift +// PausableDownloads-Example +// +// Created by William Boles on 09/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import Foundation + +class ImagesService { + private let repository: ImagesRepository + private let domainModelFactory: ImagesDomainModelFactory + + // MARK: - Init + + init(repository: ImagesRepository = ImagesRepository(), + domainModelFactory: ImagesDomainModelFactory = ImagesDomainModelFactory()) { + self.repository = repository + self.domainModelFactory = domainModelFactory + } + + // MARK: - Retrieval + + func retrieveImages(completionHandler: @escaping ((_ result: Result<[ImageDomainModel], Error>) -> ())) { + repository.retrieveImages { [domainModelFactory] (result) in + switch result { + case .success(let dtos): + let images = dtos.map { domainModelFactory.buildImage(from: $0) } + completionHandler(.success(images)) + case .failure(let error): + completionHandler(.failure(error)) + } + } + } +} diff --git a/PausableDownloads-Example/Storyboards/Base.lproj/Main.storyboard b/PausableDownloads-Example/Storyboards/Base.lproj/Main.storyboard index 315e53b..f0c8609 100644 --- a/PausableDownloads-Example/Storyboards/Base.lproj/Main.storyboard +++ b/PausableDownloads-Example/Storyboards/Base.lproj/Main.storyboard @@ -1,8 +1,8 @@ - + - + @@ -13,135 +13,34 @@ - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + - + - + - + + @@ -174,47 +74,9 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -229,10 +91,7 @@ - + - - - diff --git a/PausableDownloads-Example/ViewControllers/AlbumViewer/GalleryAlbumViewerTitleView.swift b/PausableDownloads-Example/ViewControllers/AlbumViewer/GalleryAlbumViewerTitleView.swift deleted file mode 100644 index e33accc..0000000 --- a/PausableDownloads-Example/ViewControllers/AlbumViewer/GalleryAlbumViewerTitleView.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// GalleryAlbumViewerTitleView.swift -// PausableDownloads-Example -// -// Created by William Boles on 20/01/2018. -// Copyright © 2018 William Boles. All rights reserved. -// - -import UIKit - -class GalleryAlbumViewerTitleView: UIView { - - @IBOutlet weak var titleLabel: UILabel! - @IBOutlet weak var subtitleLabel: UILabel! -} diff --git a/PausableDownloads-Example/ViewControllers/AlbumViewer/GalleryAlbumViewerViewController.swift b/PausableDownloads-Example/ViewControllers/AlbumViewer/GalleryAlbumViewerViewController.swift deleted file mode 100644 index d76a596..0000000 --- a/PausableDownloads-Example/ViewControllers/AlbumViewer/GalleryAlbumViewerViewController.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// GalleryAlbumViewerViewController.swift -// PausableDownloads-Example -// -// Created by William Boles on 15/01/2018. -// Copyright © 2018 William Boles. All rights reserved. -// - -import UIKit - -class GalleryAlbumViewerViewController: UIViewController { - - @IBOutlet weak var assetImageView: UIImageView! - @IBOutlet weak var descriptionLabel: UILabel! - @IBOutlet weak var loadingActivityIndicator: UIActivityIndicatorView! - - private let assetDataManager = AssetDataManager() - - var catImages = [CatImage]() - - var index = 0 - - // MARK: - ViewLifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - guard index < catImages.count else { - return - } - - retrieveImage() - updateTitle() - navigationItem.hidesBackButton = true - } - - // MARK: - Title - - func updateTitle() { - guard let titleView = navigationItem.titleView as? GalleryAlbumViewerTitleView else { - return - } - - titleView.titleLabel.text = "\(index+1) of \(catImages.count)" - - if index+1 == catImages.count { - titleView.subtitleLabel.text = "Tap to close" - } - } - - // MARK: - GestureRecognizer - - @IBAction func didTap(_ sender: Any) { - cancelImageRetrieval() - index += 1 - - if index < catImages.count { - retrieveImage() - updateTitle() - } else { - navigationController?.popViewController(animated: true) - } - } - - // MARK: - Reuse - - func prepareForReuse() { - loadingActivityIndicator.startAnimating() - assetImageView.image = nil - } - - // MARK: - Asset - - func retrieveImage() { - let catImage = catImages[index] - prepareForReuse() - descriptionLabel.text = "\(catImage.url.absoluteString)" - assetDataManager.loadImage(catImage) { [weak self] (result) in - guard let strongSelf = self else { - return - } - - guard strongSelf.index < strongSelf.catImages.count else { - return - } - - switch result { - case .success(let loadResult): - let currentCatImage = strongSelf.catImages[strongSelf.index] - if loadResult.catImage == currentCatImage { - strongSelf.loadingActivityIndicator.stopAnimating() - strongSelf.assetImageView.image = loadResult.image - } - case .failure(_): - //TODO: Handle - break - } - } - } - - func cancelImageRetrieval() { - guard index < catImages.count else { - return - } - - let catImage = catImages[index] - assetDataManager.cancelLoadingImage(catImage) - } -} diff --git a/PausableDownloads-Example/ViewControllers/Albums/Cells/GalleryAlbumCollectionViewCell.swift b/PausableDownloads-Example/ViewControllers/Albums/Cells/GalleryAlbumCollectionViewCell.swift deleted file mode 100644 index 9f125f0..0000000 --- a/PausableDownloads-Example/ViewControllers/Albums/Cells/GalleryAlbumCollectionViewCell.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// GalleryAlbumCollectionViewCell.swift -// PausableDownloads-Example -// -// Created by William Boles on 15/01/2018. -// Copyright © 2018 William Boles. All rights reserved. -// - -import UIKit - -class GalleryAlbumCollectionViewCell: UICollectionViewCell { - - @IBOutlet weak var informationalLabel: UILabel! - @IBOutlet weak var thumbnailImageView: UIImageView! - - private var assetDataManager = AssetDataManager() - private var catImage: CatImage? - - // MARK: - Reuse - - override func prepareForReuse() { - super.prepareForReuse() - - thumbnailImageView.image = UIImage(named: "icon-placeholder") - } - - // MARK: - Configure - - func configure(catImage: CatImage) { - informationalLabel.text = "\(catImage.url.absoluteString)" - self.catImage = catImage - - assetDataManager.loadImage(catImage) { [weak self] (result) in - switch result { - case .success(let loadResult): - if loadResult.catImage == self?.catImage { - self?.thumbnailImageView.image = loadResult.image - } - case .failure(_): - //TODO: Handle - break - } - } - } -} diff --git a/PausableDownloads-Example/ViewControllers/Albums/GalleryAlbumsViewController.swift b/PausableDownloads-Example/ViewControllers/Albums/GalleryAlbumsViewController.swift deleted file mode 100644 index 68e1a84..0000000 --- a/PausableDownloads-Example/ViewControllers/Albums/GalleryAlbumsViewController.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// GalleryAlbumsViewController.swift -// PausableDownloads-Example -// -// Created by William Boles on 15/01/2018. -// Copyright © 2018 William Boles. All rights reserved. -// - -import UIKit - -class GalleryAlbumsViewController: UIViewController { - - @IBOutlet weak var collectionView: UICollectionView! - @IBOutlet weak var loadingActivityIndicatorView: UIActivityIndicatorView! - - let dataManager = CatImagesDataManager() - var catImages = [CatImage]() - let fileManager = FileManager.default - - // MARK: - Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - retrieveImages() - } - - // MARK: - Images - - func retrieveImages() { - loadingActivityIndicatorView.startAnimating() - - dataManager.retrieveImages { (result) in - self.loadingActivityIndicatorView.stopAnimating() - - switch result { - case .success(let catImages): - self.catImages = catImages - self.collectionView.reloadData() - case .failure(_): - //TODO: Handle error - break - } - } - } - - // MARK: - SegueWay - - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - if segue.identifier == "showAlbum" { - guard let viewController = segue.destination as? GalleryAlbumViewerViewController, - let cell = sender as? GalleryAlbumCollectionViewCell, - let indexPath = collectionView.indexPath(for: cell) else { - return - } - - viewController.catImages = catImages - viewController.index = indexPath.item - } - } - - // MARK: - Reset - - @IBAction func resetButtonPressed(_ sender: Any) { - loadingActivityIndicatorView.startAnimating() - - for catImage in catImages { - try? fileManager.removeItem(at: catImage.cachedLocalAssetURL()) - } - - catImages.removeAll() - collectionView.reloadData() - retrieveImages() - } -} - -extension GalleryAlbumsViewController: UICollectionViewDataSource { - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return catImages.count - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: GalleryAlbumCollectionViewCell.className, for: indexPath) as? GalleryAlbumCollectionViewCell else { - fatalError("Expected cell of type: \(GalleryAlbumCollectionViewCell.className)") - } - - let catImage = catImages[indexPath.item] - - cell.configure(catImage: catImage) - - return cell - } -} - -extension GalleryAlbumsViewController: UICollectionViewDelegateFlowLayout { - - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - let cellWidth = (view.frame.size.width - 12.0)/3.0 - return CGSize(width: cellWidth, height: cellWidth) - } -} diff --git a/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift b/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift new file mode 100644 index 0000000..450a755 --- /dev/null +++ b/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift @@ -0,0 +1,103 @@ +// +// ImageViewerViewController.swift +// PausableDownloads-Example +// +// Created by William Boles on 15/01/2018. +// Copyright © 2018 William Boles. All rights reserved. +// + +import UIKit + +class ImageViewerViewController: UIViewController { + @IBOutlet weak var assetImageView: UIImageView! + @IBOutlet weak var descriptionLabel: UILabel! + @IBOutlet weak var loadingActivityIndicator: UIActivityIndicatorView! + + private let assetService = AssetService() + private let imagesService = ImagesService() + + private var images = [ImageDomainModel]() + private var index = 0 + + // MARK: - ViewLifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + retrieveImages() + } + + // MARK: - GestureRecognizer + + @IBAction func didTap(_ sender: Any) { + cancelImageRetrieval() + index += 1 + retrieveImage() + } + + // MARK: - Reuse + + func prepareForReuse() { + loadingActivityIndicator.startAnimating() + assetImageView.image = nil + } + + // MARK: - Images + + func retrieveImages() { + loadingActivityIndicator.startAnimating() + + imagesService.retrieveImages { (result) in + self.loadingActivityIndicator.stopAnimating() + + switch result { + case .success(let images): + self.images = images + self.retrieveImage() + case .failure(_): + //TODO: Handle error + break + } + } + } + + func retrieveImage() { + guard index < images.count else { return } + + let image = images[index] + + prepareForReuse() + descriptionLabel.text = "\(image.url.absoluteString)" + + assetService.loadImage(image) { [weak self] (result) in + guard let self = self else { + return + } + + guard self.index < self.images.count else { + return + } + + switch result { + case .success(let loadResult): + let currentImage = self.images[self.index] + if loadResult.imageDomainModel == currentImage { + self.loadingActivityIndicator.stopAnimating() + self.assetImageView.image = loadResult.image + } + case .failure(_): + //TODO: Handle + break + } + } + } + + func cancelImageRetrieval() { + guard index < images.count else { + return + } + + let image = images[index] + assetService.cancelLoadingImage(image) + } +} From c54a8fe7c03969e10806ab149e7def253772a2ad Mon Sep 17 00:00:00 2001 From: William Boles Date: Wed, 9 Sep 2026 21:37:11 +0100 Subject: [PATCH 05/16] Extracted view model --- .../project.pbxproj | 24 +- .../Asset/AssetDownloadsSession.swift | 30 +- .../Services/Asset/AssetService.swift | 38 ++- .../Services/Images/ImagesService.swift | 6 +- .../ImageViewerViewController.swift | 103 ++---- .../ImageViewer/ImageViewerViewModel.swift | 127 +++++++ .../Doubles/StubAssetService.swift | 29 ++ .../StubImageViewerViewModelDelegate.swift | 24 ++ .../Doubles/StubImagesService.swift | 23 ++ .../TestData/ImageDomainModel+TestData.swift | 9 + .../Tests/ImageViewerViewModelTests.swift | 323 ++++++++++++++++++ 11 files changed, 630 insertions(+), 106 deletions(-) create mode 100644 PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewModel.swift create mode 100644 PausableDownloads-ExampleTests/Doubles/StubAssetService.swift create mode 100644 PausableDownloads-ExampleTests/Doubles/StubImageViewerViewModelDelegate.swift create mode 100644 PausableDownloads-ExampleTests/Doubles/StubImagesService.swift create mode 100644 PausableDownloads-ExampleTests/TestData/ImageDomainModel+TestData.swift create mode 100644 PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift diff --git a/PausableDownloads-Example.xcodeproj/project.pbxproj b/PausableDownloads-Example.xcodeproj/project.pbxproj index b5d4c13..24ad920 100644 --- a/PausableDownloads-Example.xcodeproj/project.pbxproj +++ b/PausableDownloads-Example.xcodeproj/project.pbxproj @@ -22,14 +22,19 @@ 437C0CB43051EC36009529DF /* ImageDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CAB3051EC36009529DF /* ImageDTO.swift */; }; 437C0CB63051EC36009529DF /* ImagesRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CAC3051EC36009529DF /* ImagesRepository.swift */; }; 437C0D3B3051ED69009529DF /* ImagesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D3A3051ED69009529DF /* ImagesService.swift */; }; - 437C0D4B3051ED90009529DF /* ImagesDomainModelFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D4A3051ED90009529DF /* ImagesDomainModelFactory.swift */; }; 437C0D3D3051ED80009529DF /* ImageDomainModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D3C3051ED80009529DF /* ImageDomainModel.swift */; }; 437C0D3F3051EDCC009529DF /* AssetService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D3E3051EDCC009529DF /* AssetService.swift */; }; + 437C0D413051F9F3009529DF /* StubImagesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D403051F9F3009529DF /* StubImagesService.swift */; }; + 437C0D433051FA65009529DF /* StubAssetService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D423051FA65009529DF /* StubAssetService.swift */; }; + 437C0D453051FB2E009529DF /* ImageViewerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D443051FB2E009529DF /* ImageViewerViewModelTests.swift */; }; + 437C0D4B3051ED90009529DF /* ImagesDomainModelFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D4A3051ED90009529DF /* ImagesDomainModelFactory.swift */; }; + 437C0D5B3051EDA0009529DF /* ImageViewerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D5A3051EDA0009529DF /* ImageViewerViewModel.swift */; }; 4399D3903050B4DB009D2CEB /* StubURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D3893050B4DB009D2CEB /* StubURLSession.swift */; }; 4399D3913050B4DB009D2CEB /* StubURLSessionDownloadTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */; }; 4399D3923050B4DB009D2CEB /* StubURLSessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */; }; 4399D3933050B4DB009D2CEB /* TestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38C3050B4DB009D2CEB /* TestError.swift */; }; 4399D3943050B4DB009D2CEB /* StubNotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D3883050B4DB009D2CEB /* StubNotificationCenter.swift */; }; + 437C0D6B3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D6A3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift */; }; 4399D3953050B4DB009D2CEB /* AssetDownloadsSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38E3050B4DB009D2CEB /* AssetDownloadsSessionTests.swift */; }; /* End PBXBuildFile section */ @@ -63,10 +68,15 @@ 437C0CAC3051EC36009529DF /* ImagesRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesRepository.swift; sourceTree = ""; }; 437C0CAF3051EC36009529DF /* AssetDownloadsSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetDownloadsSession.swift; sourceTree = ""; }; 437C0D3A3051ED69009529DF /* ImagesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesService.swift; sourceTree = ""; }; - 437C0D4A3051ED90009529DF /* ImagesDomainModelFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesDomainModelFactory.swift; sourceTree = ""; }; 437C0D3C3051ED80009529DF /* ImageDomainModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDomainModel.swift; sourceTree = ""; }; 437C0D3E3051EDCC009529DF /* AssetService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetService.swift; sourceTree = ""; }; + 437C0D403051F9F3009529DF /* StubImagesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubImagesService.swift; sourceTree = ""; }; + 437C0D423051FA65009529DF /* StubAssetService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubAssetService.swift; sourceTree = ""; }; + 437C0D443051FB2E009529DF /* ImageViewerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewerViewModelTests.swift; sourceTree = ""; }; + 437C0D4A3051ED90009529DF /* ImagesDomainModelFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesDomainModelFactory.swift; sourceTree = ""; }; + 437C0D5A3051EDA0009529DF /* ImageViewerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewerViewModel.swift; sourceTree = ""; }; 4399D3883050B4DB009D2CEB /* StubNotificationCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubNotificationCenter.swift; sourceTree = ""; }; + 437C0D6A3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubImageViewerViewModelDelegate.swift; sourceTree = ""; }; 4399D3893050B4DB009D2CEB /* StubURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSession.swift; sourceTree = ""; }; 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSessionDownloadTask.swift; sourceTree = ""; }; 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSessionFactory.swift; sourceTree = ""; }; @@ -144,6 +154,7 @@ isa = PBXGroup; children = ( 3D63CC3E204B554700797A82 /* ImageViewerViewController.swift */, + 437C0D5A3051EDA0009529DF /* ImageViewerViewModel.swift */, ); path = ImageViewer; sourceTree = ""; @@ -256,10 +267,13 @@ isa = PBXGroup; children = ( 4399D3883050B4DB009D2CEB /* StubNotificationCenter.swift */, + 437C0D6A3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift */, 4399D3893050B4DB009D2CEB /* StubURLSession.swift */, 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */, 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */, 4399D38C3050B4DB009D2CEB /* TestError.swift */, + 437C0D403051F9F3009529DF /* StubImagesService.swift */, + 437C0D423051FA65009529DF /* StubAssetService.swift */, ); path = Doubles; sourceTree = ""; @@ -268,6 +282,7 @@ isa = PBXGroup; children = ( 4399D38E3050B4DB009D2CEB /* AssetDownloadsSessionTests.swift */, + 437C0D443051FB2E009529DF /* ImageViewerViewModelTests.swift */, ); path = Tests; sourceTree = ""; @@ -392,6 +407,7 @@ 437C0D3D3051ED80009529DF /* ImageDomainModel.swift in Sources */, 3D63CC5F204B554700797A82 /* AppDelegate.swift in Sources */, 3D63CC5E204B554700797A82 /* ImageViewerViewController.swift in Sources */, + 437C0D5B3051EDA0009529DF /* ImageViewerViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -399,11 +415,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 437C0D433051FA65009529DF /* StubAssetService.swift in Sources */, 4399D3903050B4DB009D2CEB /* StubURLSession.swift in Sources */, 4399D3913050B4DB009D2CEB /* StubURLSessionDownloadTask.swift in Sources */, + 437C0D413051F9F3009529DF /* StubImagesService.swift in Sources */, 4399D3923050B4DB009D2CEB /* StubURLSessionFactory.swift in Sources */, 4399D3933050B4DB009D2CEB /* TestError.swift in Sources */, 4399D3943050B4DB009D2CEB /* StubNotificationCenter.swift in Sources */, + 437C0D6B3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift in Sources */, + 437C0D453051FB2E009529DF /* ImageViewerViewModelTests.swift in Sources */, 4399D3953050B4DB009D2CEB /* AssetDownloadsSessionTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift b/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift index 5193a15..0156958 100644 --- a/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift +++ b/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift @@ -16,8 +16,7 @@ protocol NotificationCenterType { extension NotificationCenter: NotificationCenterType { } -class AssetDownloadsSession: NSObject, AssetDownloadItemDelegate, URLSessionDownloadDelegate { - +class AssetDownloadsSession: NSObject { private var assetDownloadItems = [AssetDownloadItem]() private let accessQueue = DispatchQueue(label: "com.williamboles.downloadssession") private var session: URLSessionType! @@ -97,9 +96,9 @@ class AssetDownloadsSession: NSObject, AssetDownloadItemDelegate, URLSessionDown assetDownloadItem.pause() } } - - // MARK: - AssetDownloadItemDelegate - +} + +extension AssetDownloadsSession: AssetDownloadItemDelegate { fileprivate func assetDownloadItemCompleted(_ assetDownloadItem: AssetDownloadItem) { accessQueue.sync { os_log(.info, "Completed download of: %{public}@", assetDownloadItem.description) @@ -109,9 +108,9 @@ class AssetDownloadsSession: NSObject, AssetDownloadItemDelegate, URLSessionDown } } } - - // MARK: - URLSessionDownloadDelegate - +} + +extension AssetDownloadsSession: URLSessionDownloadDelegate { func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { /*no-op*/ } func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) { @@ -123,14 +122,6 @@ class AssetDownloadsSession: NSObject, AssetDownloadItemDelegate, URLSessionDown } } -fileprivate enum State: String { - case ready - case downloading - case paused - case cancelled - case completed -} - fileprivate protocol AssetDownloadItemDelegate { func assetDownloadItemCompleted(_ assetDownloadItem: AssetDownloadItem) } @@ -138,6 +129,13 @@ fileprivate protocol AssetDownloadItemDelegate { typealias DownloadCompletionHandler = ((_ result: Result) -> ()) fileprivate class AssetDownloadItem { + fileprivate enum State: String { + case ready + case downloading + case paused + case cancelled + case completed + } private let session: URLSessionType private var resumptionData: Data? diff --git a/PausableDownloads-Example/Services/Asset/AssetService.swift b/PausableDownloads-Example/Services/Asset/AssetService.swift index 6987f6f..f5b3b77 100644 --- a/PausableDownloads-Example/Services/Asset/AssetService.swift +++ b/PausableDownloads-Example/Services/Asset/AssetService.swift @@ -14,11 +14,17 @@ struct LoadImageResult: Equatable { let image: UIImage } -class AssetService { - private let assetDownloadSession = AssetDownloadsSession.shared +protocol AssetService { + func loadImage(_ imageDomainModel: ImageDomainModel, + completionHandler: @escaping ((_ result: Result) -> ())) + func cancelLoadingImage(_ imageDomainModel: ImageDomainModel) +} + +final class DefaultAssetService: AssetService { + private let session = AssetDownloadsSession.shared private let fileManager = FileManager.default - // MARK: - imageDomainModel + // MARK: - Load func loadImage(_ imageDomainModel: ImageDomainModel, completionHandler: @escaping ((_ result: Result) -> ())) { @@ -29,12 +35,6 @@ class AssetService { } } - func cancelLoadingImage(_ imageDomainModel: ImageDomainModel) { - assetDownloadSession.cancelDownload(url: imageDomainModel.url) - } - - // MARK: - Asset - private func locallyLoadImage(_ imageDomainModel: ImageDomainModel, completionHandler: @escaping ((_ result: Result) -> ())) { do { @@ -59,18 +59,22 @@ class AssetService { private func remotelyLoadImage(_ imageDomainModel: ImageDomainModel, completionHandler: @escaping ((_ result: Result) -> ())) { - assetDownloadSession.scheduleDownload(url: imageDomainModel.url) { (result) in + session.scheduleDownload(url: imageDomainModel.url) { (result) in switch result { case .success(let data): guard let image = UIImage(data: data) else { - completionHandler(.failure(NetworkingError.invalidData(underlyingError: nil))) + DispatchQueue.main.async { + completionHandler(.failure(NetworkingError.invalidData(underlyingError: nil))) + } return } do { try data.write(to: imageDomainModel.cachedLocalAssetURL(), options: .atomic) } catch let error { - completionHandler(.failure(NetworkingError.invalidData(underlyingError: error))) + DispatchQueue.main.async { + completionHandler(.failure(NetworkingError.invalidData(underlyingError: error))) + } return } @@ -81,8 +85,16 @@ class AssetService { completionHandler(dataRequestResult) } case .failure(let error): - completionHandler(.failure(error)) + DispatchQueue.main.async { + completionHandler(.failure(error)) + } } } } + + // MARK: - Cancel + + func cancelLoadingImage(_ imageDomainModel: ImageDomainModel) { + session.cancelDownload(url: imageDomainModel.url) + } } diff --git a/PausableDownloads-Example/Services/Images/ImagesService.swift b/PausableDownloads-Example/Services/Images/ImagesService.swift index 55385b3..4bf1bd8 100644 --- a/PausableDownloads-Example/Services/Images/ImagesService.swift +++ b/PausableDownloads-Example/Services/Images/ImagesService.swift @@ -8,7 +8,11 @@ import Foundation -class ImagesService { +protocol ImagesService { + func retrieveImages(completionHandler: @escaping ((_ result: Result<[ImageDomainModel], Error>) -> ())) +} + +final class DefaultImagesService: ImagesService { private let repository: ImagesRepository private let domainModelFactory: ImagesDomainModelFactory diff --git a/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift b/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift index 450a755..5980cf7 100644 --- a/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift +++ b/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift @@ -13,91 +13,46 @@ class ImageViewerViewController: UIViewController { @IBOutlet weak var descriptionLabel: UILabel! @IBOutlet weak var loadingActivityIndicator: UIActivityIndicatorView! - private let assetService = AssetService() - private let imagesService = ImagesService() - - private var images = [ImageDomainModel]() - private var index = 0 + private let viewModel = ImageViewerViewModel() // MARK: - ViewLifecycle override func viewDidLoad() { super.viewDidLoad() - - retrieveImages() + + viewModel.delegate = self + viewModel.load() } // MARK: - GestureRecognizer @IBAction func didTap(_ sender: Any) { - cancelImageRetrieval() - index += 1 - retrieveImage() - } - - // MARK: - Reuse - - func prepareForReuse() { - loadingActivityIndicator.startAnimating() - assetImageView.image = nil - } - - // MARK: - Images - - func retrieveImages() { - loadingActivityIndicator.startAnimating() - - imagesService.retrieveImages { (result) in - self.loadingActivityIndicator.stopAnimating() - - switch result { - case .success(let images): - self.images = images - self.retrieveImage() - case .failure(_): - //TODO: Handle error - break - } - } + viewModel.advance() } - - func retrieveImage() { - guard index < images.count else { return } - - let image = images[index] - - prepareForReuse() - descriptionLabel.text = "\(image.url.absoluteString)" - - assetService.loadImage(image) { [weak self] (result) in - guard let self = self else { - return - } - - guard self.index < self.images.count else { - return - } - - switch result { - case .success(let loadResult): - let currentImage = self.images[self.index] - if loadResult.imageDomainModel == currentImage { - self.loadingActivityIndicator.stopAnimating() - self.assetImageView.image = loadResult.image - } - case .failure(_): - //TODO: Handle - break - } - } - } - - func cancelImageRetrieval() { - guard index < images.count else { - return +} + +extension ImageViewerViewController: ImageViewerViewModelDelegate { + + // MARK: - ImageViewerViewModelDelegate + + func viewModel(_ viewModel: ImageViewerViewModel, + didChangeTo state: ImageViewerViewModel.State) { + switch state { + case .loadingImages: + loadingActivityIndicator.startAnimating() + assetImageView.image = nil + case .loadingAsset(let description): + loadingActivityIndicator.startAnimating() + assetImageView.image = nil + descriptionLabel.text = description + case .loadedAsset(let image, let description): + loadingActivityIndicator.stopAnimating() + assetImageView.image = image + descriptionLabel.text = description + case .failed: + loadingActivityIndicator.stopAnimating() + //TODO: Handle error + break } - - let image = images[index] - assetService.cancelLoadingImage(image) } } diff --git a/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewModel.swift b/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewModel.swift new file mode 100644 index 0000000..c01fcc9 --- /dev/null +++ b/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewModel.swift @@ -0,0 +1,127 @@ +// +// ImageViewerViewModel.swift +// PausableDownloads-Example +// +// Created by William Boles on 09/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import UIKit + +protocol ImageViewerViewModelDelegate: AnyObject { + func viewModel(_ viewModel: ImageViewerViewModel, + didChangeTo state: ImageViewerViewModel.State) +} + +class ImageViewerViewModel { + enum State: Equatable { + case loadingImages + case loadingAsset(description: String) + case loadedAsset(UIImage, description: String) + case failed + } + + weak var delegate: ImageViewerViewModelDelegate? + + private(set) var state: State = .loadingImages + + private let imagesService: ImagesService + private let assetService: AssetService + + private var images = [ImageDomainModel]() + private var index = 0 + + // MARK: - Init + + init(imagesService: ImagesService = DefaultImagesService(), + assetService: AssetService = DefaultAssetService()) { + self.imagesService = imagesService + self.assetService = assetService + } + + // MARK: - Current + + private var currentImage: ImageDomainModel? { + guard index < images.count else { + return nil + } + + return images[index] + } + + // MARK: - Load + + func load() { + transition(to: .loadingImages) + + imagesService.retrieveImages { [weak self] (result) in + guard let self = self else { + return + } + + switch result { + case .success(let images): + self.images = images + self.loadCurrentAsset() + case .failure(_): + self.transition(to: .failed) + } + } + } + + // MARK: - Advance + + func advance() { + cancelCurrentAssetLoad() + index += 1 + loadCurrentAsset() + } + + // MARK: - Asset + + private func loadCurrentAsset() { + guard let image = currentImage else { + return + } + + transition(to: .loadingAsset(description: image.url.absoluteString)) + + assetService.loadImage(image) { [weak self] (result) in + guard let self = self else { + return + } + + guard let currentImage = self.currentImage else { + return + } + + switch result { + case .success(let loadResult): + //a stale download for an image we have already moved past + guard loadResult.imageDomainModel == currentImage else { + return + } + + self.transition(to: .loadedAsset(loadResult.image, description: currentImage.url.absoluteString)) + case .failure(_): + self.transition(to: .failed) + } + } + } + + private func cancelCurrentAssetLoad() { + guard let image = currentImage else { + return + } + + assetService.cancelLoadingImage(image) + } + + // MARK: - State + + private func transition(to state: State) { + self.state = state + + delegate?.viewModel(self, didChangeTo: state) + } +} diff --git a/PausableDownloads-ExampleTests/Doubles/StubAssetService.swift b/PausableDownloads-ExampleTests/Doubles/StubAssetService.swift new file mode 100644 index 0000000..0f863c9 --- /dev/null +++ b/PausableDownloads-ExampleTests/Doubles/StubAssetService.swift @@ -0,0 +1,29 @@ +// +// StubAssetService.swift +// PausableDownloads-ExampleTests +// +// Created by William Boles on 09/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import Foundation + +@testable import PausableDownloads_Example + +final class StubAssetService: AssetService { + enum Event { + case loadImage(ImageDomainModel, (Result) -> ()) + case cancelLoadingImage(ImageDomainModel) + } + + private(set) var events = [Event]() + + func loadImage(_ imageDomainModel: ImageDomainModel, + completionHandler: @escaping (Result) -> ()) { + events.append(.loadImage(imageDomainModel, completionHandler)) + } + + func cancelLoadingImage(_ imageDomainModel: ImageDomainModel) { + events.append(.cancelLoadingImage(imageDomainModel)) + } +} diff --git a/PausableDownloads-ExampleTests/Doubles/StubImageViewerViewModelDelegate.swift b/PausableDownloads-ExampleTests/Doubles/StubImageViewerViewModelDelegate.swift new file mode 100644 index 0000000..0f1a9b0 --- /dev/null +++ b/PausableDownloads-ExampleTests/Doubles/StubImageViewerViewModelDelegate.swift @@ -0,0 +1,24 @@ +// +// StubImageViewerViewModelDelegate.swift +// PausableDownloads-ExampleTests +// +// Created by William Boles on 09/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import Foundation + +@testable import PausableDownloads_Example + +final class StubImageViewerViewModelDelegate: ImageViewerViewModelDelegate { + enum Event { + case didChangeTo(ImageViewerViewModel.State) + } + + private(set) var events = [Event]() + + func viewModel(_ viewModel: ImageViewerViewModel, + didChangeTo state: ImageViewerViewModel.State) { + events.append(.didChangeTo(state)) + } +} diff --git a/PausableDownloads-ExampleTests/Doubles/StubImagesService.swift b/PausableDownloads-ExampleTests/Doubles/StubImagesService.swift new file mode 100644 index 0000000..e53268c --- /dev/null +++ b/PausableDownloads-ExampleTests/Doubles/StubImagesService.swift @@ -0,0 +1,23 @@ +// +// StubImagesService.swift +// PausableDownloads-ExampleTests +// +// Created by William Boles on 09/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import Foundation + +@testable import PausableDownloads_Example + +final class StubImagesService: ImagesService { + enum Event { + case retrieveImages(((_ result: Result<[ImageDomainModel], Error>) -> ())) + } + + private(set) var events = [Event]() + + func retrieveImages(completionHandler: @escaping ((_ result: Result<[ImageDomainModel], Error>) -> ())) { + events.append(.retrieveImages(completionHandler)) + } +} diff --git a/PausableDownloads-ExampleTests/TestData/ImageDomainModel+TestData.swift b/PausableDownloads-ExampleTests/TestData/ImageDomainModel+TestData.swift new file mode 100644 index 0000000..5f1ca4e --- /dev/null +++ b/PausableDownloads-ExampleTests/TestData/ImageDomainModel+TestData.swift @@ -0,0 +1,9 @@ +// +// ImageDomainModel+TestData.swift +// PausableDownloads-ExampleTests +// +// Created by William Boles on 09/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import Foundation diff --git a/PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift b/PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift new file mode 100644 index 0000000..ca1ca26 --- /dev/null +++ b/PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift @@ -0,0 +1,323 @@ +// +// ImageViewerViewModelTests.swift +// PausableDownloads-ExampleTests +// +// Created by William Boles on 09/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import XCTest + +@testable import PausableDownloads_Example + +final class ImageViewerViewModelTests: XCTestCase { + + // MARK: - Tests + + // MARK: Load + + func test_givenViewModel_whenLoadIsCalled_thenImagesAreRetrieved() { + let imagesService = StubImagesService() + + let sut = createSUT(imagesService: imagesService) + + sut.load() + + XCTAssertEqual(imagesService.events.count, 1) + + guard case .retrieveImages = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + } + + func test_givenViewModel_whenLoadIsCalled_thenDelegateIsNotifiedOfLoadingImages() { + let delegate = StubImageViewerViewModelDelegate() + + let sut = createSUT() + sut.delegate = delegate + + sut.load() + + XCTAssertEqual(delegate.events.count, 1) + + guard case let .didChangeTo(state) = delegate.events.first else { + XCTFail("Unexpected event") + return + } + + XCTAssertEqual(state, .loadingImages) + XCTAssertEqual(sut.state, .loadingImages) + } + + func test_givenLoadInProgress_whenImagesAreRetrieved_thenTheFirstAssetIsLoaded() { + let imagesService = StubImagesService() + let assetService = StubAssetService() + + let sut = createSUT(imagesService: imagesService, + assetService: assetService) + + sut.load() + + guard case let .retrieveImages(completionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + let imageA = createImage(identifier: "a") + let imageB = createImage(identifier: "b") + + completionHandler(.success([imageA, imageB])) + + XCTAssertEqual(assetService.events.count, 1) + + guard case let .loadImage(loadedImage, _) = assetService.events.first else { + XCTFail("Unexpected event") + return + } + + XCTAssertEqual(loadedImage, imageA) + XCTAssertEqual(sut.state, .loadingAsset(description: imageA.url.absoluteString)) + } + + func test_givenLoadInProgress_whenImageRetrievalFails_thenStateTransitionsToFailed() { + let imagesService = StubImagesService() + let assetService = StubAssetService() + + let sut = createSUT(imagesService: imagesService, + assetService: assetService) + + sut.load() + + guard case let .retrieveImages(completionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.failure(TestError.test)) + + XCTAssertEqual(sut.state, .failed) + XCTAssertTrue(assetService.events.isEmpty) + } + + func test_givenLoadInProgress_whenNoImagesAreRetrieved_thenNoAssetIsLoaded() { + let imagesService = StubImagesService() + let assetService = StubAssetService() + + let sut = createSUT(imagesService: imagesService, + assetService: assetService) + + sut.load() + + guard case let .retrieveImages(completionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.success([])) + + XCTAssertTrue(assetService.events.isEmpty) + XCTAssertEqual(sut.state, .loadingImages) + } + + // MARK: Asset + + func test_givenAssetLoadInProgress_whenTheAssetLoads_thenStateTransitionsToLoadedAsset() { + let imagesService = StubImagesService() + let assetService = StubAssetService() + + let sut = createSUT(imagesService: imagesService, + assetService: assetService) + + let imageA = createImage(identifier: "a") + + loadImages([imageA], into: sut, using: imagesService) + + guard case let .loadImage(_, completionHandler) = assetService.events.first else { + XCTFail("Unexpected event") + return + } + + let image = UIImage() + completionHandler(.success(LoadImageResult(imageDomainModel: imageA, image: image))) + + XCTAssertEqual(sut.state, .loadedAsset(image, description: imageA.url.absoluteString)) + } + + func test_givenAssetLoadInProgress_whenTheAssetFailsToLoad_thenStateTransitionsToFailed() { + let imagesService = StubImagesService() + let assetService = StubAssetService() + + let sut = createSUT(imagesService: imagesService, + assetService: assetService) + + loadImages([createImage(identifier: "a")], into: sut, using: imagesService) + + guard case let .loadImage(_, completionHandler) = assetService.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.failure(TestError.test)) + + XCTAssertEqual(sut.state, .failed) + } + + func test_givenAdvancedPastAnImage_whenTheStaleAssetLoads_thenStateIsUnchanged() { + let imagesService = StubImagesService() + let assetService = StubAssetService() + let delegate = StubImageViewerViewModelDelegate() + + let sut = createSUT(imagesService: imagesService, + assetService: assetService) + sut.delegate = delegate + + let imageA = createImage(identifier: "a") + let imageB = createImage(identifier: "b") + + loadImages([imageA, imageB], into: sut, using: imagesService) + + guard case let .loadImage(_, staleCompletionHandler) = assetService.events.first else { + XCTFail("Unexpected event") + return + } + + sut.advance() + + let eventCountBeforeStaleResult = delegate.events.count + + staleCompletionHandler(.success(LoadImageResult(imageDomainModel: imageA, image: UIImage()))) + + XCTAssertEqual(delegate.events.count, eventCountBeforeStaleResult) + XCTAssertEqual(sut.state, .loadingAsset(description: imageB.url.absoluteString)) + } + + // MARK: Advance + + func test_givenLoadedImages_whenAdvanceIsCalled_thenTheCurrentAssetLoadIsCancelled() { + let imagesService = StubImagesService() + let assetService = StubAssetService() + + let sut = createSUT(imagesService: imagesService, + assetService: assetService) + + let imageA = createImage(identifier: "a") + let imageB = createImage(identifier: "b") + + loadImages([imageA, imageB], into: sut, using: imagesService) + + sut.advance() + + XCTAssertEqual(assetService.events.count, 3) + + guard case let .cancelLoadingImage(cancelledImage) = assetService.events[1] else { + XCTFail("Unexpected event") + return + } + + XCTAssertEqual(cancelledImage, imageA) + } + + func test_givenLoadedImages_whenAdvanceIsCalled_thenTheNextAssetIsLoaded() { + let imagesService = StubImagesService() + let assetService = StubAssetService() + + let sut = createSUT(imagesService: imagesService, + assetService: assetService) + + let imageA = createImage(identifier: "a") + let imageB = createImage(identifier: "b") + + loadImages([imageA, imageB], into: sut, using: imagesService) + + sut.advance() + + guard case let .loadImage(loadedImage, _) = assetService.events.last else { + XCTFail("Unexpected event") + return + } + + XCTAssertEqual(loadedImage, imageB) + XCTAssertEqual(sut.state, .loadingAsset(description: imageB.url.absoluteString)) + } + + func test_givenTheLastImage_whenAdvanceIsCalled_thenNoFurtherAssetIsLoaded() { + let imagesService = StubImagesService() + let assetService = StubAssetService() + + let sut = createSUT(imagesService: imagesService, + assetService: assetService) + + loadImages([createImage(identifier: "a")], into: sut, using: imagesService) + + let eventCountBeforeAdvance = assetService.events.count + + sut.advance() + + XCTAssertEqual(assetService.events.count, eventCountBeforeAdvance + 1) + + guard case .cancelLoadingImage = assetService.events.last else { + XCTFail("Unexpected event") + return + } + } + + func test_givenTheLastImage_whenAdvanceIsCalled_thenStateIsUnchanged() { + let imagesService = StubImagesService() + let assetService = StubAssetService() + let delegate = StubImageViewerViewModelDelegate() + + let sut = createSUT(imagesService: imagesService, + assetService: assetService) + sut.delegate = delegate + + let imageA = createImage(identifier: "a") + + loadImages([imageA], into: sut, using: imagesService) + + let eventCountBeforeAdvance = delegate.events.count + + sut.advance() + + XCTAssertEqual(delegate.events.count, eventCountBeforeAdvance) + XCTAssertEqual(sut.state, .loadingAsset(description: imageA.url.absoluteString)) + } + + func test_givenNoImages_whenAdvanceIsCalled_thenNoAssetIsCancelledOrLoaded() { + let assetService = StubAssetService() + + let sut = createSUT(assetService: assetService) + + sut.advance() + + XCTAssertTrue(assetService.events.isEmpty) + } +} + +extension ImageViewerViewModelTests { + func createSUT(imagesService: ImagesService = StubImagesService(), + assetService: AssetService = StubAssetService()) -> ImageViewerViewModel { + ImageViewerViewModel(imagesService: imagesService, + assetService: assetService) + } + + func createImage(identifier: String) -> ImageDomainModel { + ImageDomainModel(identifier: identifier, + url: URL(string: "http://test.com/\(identifier).jpg")!, + width: 100, + height: 100) + } + + func loadImages(_ images: [ImageDomainModel], + into sut: ImageViewerViewModel, + using imagesService: StubImagesService) { + sut.load() + + guard case let .retrieveImages(completionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.success(images)) + } +} From 0ad10a0b4a35a54a80c732c38ff8c366a8fc4878 Mon Sep 17 00:00:00 2001 From: William Boles Date: Wed, 9 Sep 2026 22:58:31 +0100 Subject: [PATCH 06/16] Adjusted access --- .../project.pbxproj | 16 +- .../CatImages/ImagesRepository.swift | 22 +-- .../Asset/AssetDownloadsSession.swift | 40 ++-- .../Services/Asset/AssetService.swift | 35 +++- .../Services/Images/ImageDomainModel.swift | 9 - .../Services/Images/ImagesService.swift | 15 +- .../ImageViewer/ImageViewerViewModel.swift | 6 +- .../Doubles/StubAssetService.swift | 5 +- .../Doubles/StubImagesService.swift | 7 +- .../TestData/ImageDomainModel+TestData.swift | 15 ++ .../Tests/ImageViewerViewModelTests.swift | 177 +++++++++++++----- 11 files changed, 242 insertions(+), 105 deletions(-) diff --git a/PausableDownloads-Example.xcodeproj/project.pbxproj b/PausableDownloads-Example.xcodeproj/project.pbxproj index 24ad920..a1549e1 100644 --- a/PausableDownloads-Example.xcodeproj/project.pbxproj +++ b/PausableDownloads-Example.xcodeproj/project.pbxproj @@ -27,14 +27,15 @@ 437C0D413051F9F3009529DF /* StubImagesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D403051F9F3009529DF /* StubImagesService.swift */; }; 437C0D433051FA65009529DF /* StubAssetService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D423051FA65009529DF /* StubAssetService.swift */; }; 437C0D453051FB2E009529DF /* ImageViewerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D443051FB2E009529DF /* ImageViewerViewModelTests.swift */; }; + 437C0D4830520236009529DF /* ImageDomainModel+TestData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D4730520236009529DF /* ImageDomainModel+TestData.swift */; }; 437C0D4B3051ED90009529DF /* ImagesDomainModelFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D4A3051ED90009529DF /* ImagesDomainModelFactory.swift */; }; 437C0D5B3051EDA0009529DF /* ImageViewerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D5A3051EDA0009529DF /* ImageViewerViewModel.swift */; }; + 437C0D6B3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D6A3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift */; }; 4399D3903050B4DB009D2CEB /* StubURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D3893050B4DB009D2CEB /* StubURLSession.swift */; }; 4399D3913050B4DB009D2CEB /* StubURLSessionDownloadTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */; }; 4399D3923050B4DB009D2CEB /* StubURLSessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */; }; 4399D3933050B4DB009D2CEB /* TestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38C3050B4DB009D2CEB /* TestError.swift */; }; 4399D3943050B4DB009D2CEB /* StubNotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D3883050B4DB009D2CEB /* StubNotificationCenter.swift */; }; - 437C0D6B3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D6A3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift */; }; 4399D3953050B4DB009D2CEB /* AssetDownloadsSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38E3050B4DB009D2CEB /* AssetDownloadsSessionTests.swift */; }; /* End PBXBuildFile section */ @@ -73,10 +74,11 @@ 437C0D403051F9F3009529DF /* StubImagesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubImagesService.swift; sourceTree = ""; }; 437C0D423051FA65009529DF /* StubAssetService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubAssetService.swift; sourceTree = ""; }; 437C0D443051FB2E009529DF /* ImageViewerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewerViewModelTests.swift; sourceTree = ""; }; + 437C0D4730520236009529DF /* ImageDomainModel+TestData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageDomainModel+TestData.swift"; sourceTree = ""; }; 437C0D4A3051ED90009529DF /* ImagesDomainModelFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesDomainModelFactory.swift; sourceTree = ""; }; 437C0D5A3051EDA0009529DF /* ImageViewerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewerViewModel.swift; sourceTree = ""; }; - 4399D3883050B4DB009D2CEB /* StubNotificationCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubNotificationCenter.swift; sourceTree = ""; }; 437C0D6A3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubImageViewerViewModelDelegate.swift; sourceTree = ""; }; + 4399D3883050B4DB009D2CEB /* StubNotificationCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubNotificationCenter.swift; sourceTree = ""; }; 4399D3893050B4DB009D2CEB /* StubURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSession.swift; sourceTree = ""; }; 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSessionDownloadTask.swift; sourceTree = ""; }; 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSessionFactory.swift; sourceTree = ""; }; @@ -171,6 +173,7 @@ 3D63CC6E204B555300797A82 /* PausableDownloads-ExampleTests */ = { isa = PBXGroup; children = ( + 437C0D463052022A009529DF /* TestData */, 4399D38D3050B4DB009D2CEB /* Doubles */, 4399D38F3050B4DB009D2CEB /* Tests */, 3D2AF9E923BB5D4B00A6D999 /* Resources */, @@ -263,6 +266,14 @@ path = Images; sourceTree = ""; }; + 437C0D463052022A009529DF /* TestData */ = { + isa = PBXGroup; + children = ( + 437C0D4730520236009529DF /* ImageDomainModel+TestData.swift */, + ); + path = TestData; + sourceTree = ""; + }; 4399D38D3050B4DB009D2CEB /* Doubles */ = { isa = PBXGroup; children = ( @@ -418,6 +429,7 @@ 437C0D433051FA65009529DF /* StubAssetService.swift in Sources */, 4399D3903050B4DB009D2CEB /* StubURLSession.swift in Sources */, 4399D3913050B4DB009D2CEB /* StubURLSessionDownloadTask.swift in Sources */, + 437C0D4830520236009529DF /* ImageDomainModel+TestData.swift in Sources */, 437C0D413051F9F3009529DF /* StubImagesService.swift in Sources */, 4399D3923050B4DB009D2CEB /* StubURLSessionFactory.swift in Sources */, 4399D3933050B4DB009D2CEB /* TestError.swift in Sources */, diff --git a/PausableDownloads-Example/Repositories/CatImages/ImagesRepository.swift b/PausableDownloads-Example/Repositories/CatImages/ImagesRepository.swift index a1aabbe..0147769 100644 --- a/PausableDownloads-Example/Repositories/CatImages/ImagesRepository.swift +++ b/PausableDownloads-Example/Repositories/CatImages/ImagesRepository.swift @@ -27,33 +27,25 @@ class ImagesRepository { let task = session.dataTask(with: request) { (data: Data?, response: URLResponse?, error: Error?) in guard let data = data else { - DispatchQueue.main.async { - let retrievalError = NetworkingError.retrieval(underlyingError: error) - completionHandler(Result.failure(retrievalError)) - } + let retrievalError = NetworkingError.retrieval(underlyingError: error) + completionHandler(Result.failure(retrievalError)) return } guard let statusCode = (response as? HTTPURLResponse)?.statusCode, (200..<300).contains(statusCode) else { - DispatchQueue.main.async { - let retrievalError = NetworkingError.retrieval(underlyingError: error) - completionHandler(Result.failure(retrievalError)) - } + let retrievalError = NetworkingError.retrieval(underlyingError: error) + completionHandler(Result.failure(retrievalError)) return } do { let dtos = try JSONDecoder().decode([ImageDTO].self, from: data) - DispatchQueue.main.async { - completionHandler(Result.success(dtos)) - } + completionHandler(Result.success(dtos)) } catch let error { - DispatchQueue.main.async { - let invalidError = NetworkingError.invalidData(underlyingError: error) - completionHandler(Result.failure(invalidError)) - } + let invalidError = NetworkingError.invalidData(underlyingError: error) + completionHandler(Result.failure(invalidError)) } } diff --git a/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift b/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift index 0156958..be69936 100644 --- a/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift +++ b/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift @@ -11,12 +11,17 @@ import os protocol NotificationCenterType { @discardableResult - func addObserver(forName name: NSNotification.Name?, object obj: Any?, queue: OperationQueue?, using block: @escaping (Notification) -> Void) -> NSObjectProtocol + func addObserver(forName name: NSNotification.Name?, + object obj: Any?, + queue: OperationQueue?, + using block: @escaping (Notification) -> Void) -> NSObjectProtocol } extension NotificationCenter: NotificationCenterType { } -class AssetDownloadsSession: NSObject { +typealias DownloadCompletionHandler = ((_ result: Result) -> ()) + +final class AssetDownloadsSession: NSObject { private var assetDownloadItems = [AssetDownloadItem]() private let accessQueue = DispatchQueue(label: "com.williamboles.downloadssession") private var session: URLSessionType! @@ -60,10 +65,11 @@ class AssetDownloadsSession: NSObject { // MARK: - Schedule - func scheduleDownload(url: URL, completionHandler: @escaping DownloadCompletionHandler) { + func scheduleDownload(url: URL, + completionHandler: @escaping DownloadCompletionHandler) { accessQueue.sync { if let assetDownloadItem = assetDownloadItems.first(where: { $0.url == url && $0.isCoalescable }) { - os_log(.info, "Found existing %{public}@ download so coalescing them for: %{public}@", assetDownloadItem.state.rawValue, assetDownloadItem.description) + os_log(.info, "Found existing %{public}@ download so coalescing them for: %{public}@", assetDownloadItem.stateDescription, assetDownloadItem.description) assetDownloadItem.coalesceDownloadCompletionHandler(completionHandler) @@ -111,25 +117,29 @@ extension AssetDownloadsSession: AssetDownloadItemDelegate { } extension AssetDownloadsSession: URLSessionDownloadDelegate { - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { /*no-op*/ } - - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) { + func urlSession(_ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL) { /*no-op*/ } + + func urlSession(_ session: URLSession, + downloadTask: URLSessionDownloadTask, + didResumeAtOffset fileOffset: Int64, + expectedTotalBytes: Int64) { guard let url = downloadTask.currentRequest?.url else { return } + let resumptionPercentage = (Double(fileOffset)/Double(expectedTotalBytes)) * 100 os_log(.info, "Resuming download: %{public}@ from: %{public}.02f%%", url.absoluteString, resumptionPercentage) } } -fileprivate protocol AssetDownloadItemDelegate { +private protocol AssetDownloadItemDelegate { func assetDownloadItemCompleted(_ assetDownloadItem: AssetDownloadItem) } -typealias DownloadCompletionHandler = ((_ result: Result) -> ()) - -fileprivate class AssetDownloadItem { - fileprivate enum State: String { +private class AssetDownloadItem { + private enum State: String { case ready case downloading case paused @@ -145,12 +155,16 @@ fileprivate class AssetDownloadItem { var delegate: AssetDownloadItemDelegate? var downloadCompletionHandler: DownloadCompletionHandler? let url: URL - private(set) var state: State = .ready + private var state: State = .ready var description: String { return url.absoluteString } + var stateDescription: String { + return state.rawValue + } + var isCoalescable: Bool { return (state == .ready) || (state == .downloading) || diff --git a/PausableDownloads-Example/Services/Asset/AssetService.swift b/PausableDownloads-Example/Services/Asset/AssetService.swift index f5b3b77..190c34b 100644 --- a/PausableDownloads-Example/Services/Asset/AssetService.swift +++ b/PausableDownloads-Example/Services/Asset/AssetService.swift @@ -16,6 +16,7 @@ struct LoadImageResult: Equatable { protocol AssetService { func loadImage(_ imageDomainModel: ImageDomainModel, + callbackQueue: DispatchQueue, completionHandler: @escaping ((_ result: Result) -> ())) func cancelLoadingImage(_ imageDomainModel: ImageDomainModel) } @@ -27,43 +28,48 @@ final class DefaultAssetService: AssetService { // MARK: - Load func loadImage(_ imageDomainModel: ImageDomainModel, + callbackQueue: DispatchQueue, completionHandler: @escaping ((_ result: Result) -> ())) { if fileManager.fileExists(atPath: imageDomainModel.cachedLocalAssetURL().path) { - locallyLoadImage(imageDomainModel, completionHandler: completionHandler) + locallyLoadImage(imageDomainModel, callbackQueue: callbackQueue, completionHandler: completionHandler) } else { - remotelyLoadImage(imageDomainModel, completionHandler: completionHandler) + remotelyLoadImage(imageDomainModel, callbackQueue: callbackQueue, completionHandler: completionHandler) } } private func locallyLoadImage(_ imageDomainModel: ImageDomainModel, + callbackQueue: DispatchQueue, completionHandler: @escaping ((_ result: Result) -> ())) { do { let data = try Data(contentsOf: URL(fileURLWithPath: imageDomainModel.cachedLocalAssetURL().path)) guard let image = UIImage(data: data) else { - completionHandler(.failure(NetworkingError.invalidData(underlyingError: nil))) + callbackQueue.async { + completionHandler(.failure(NetworkingError.invalidData(underlyingError: nil))) + } return } let loadResult = LoadImageResult(imageDomainModel: imageDomainModel, image: image) let dataRequestResult = Result.success(loadResult) - DispatchQueue.main.async { + callbackQueue.async { completionHandler(dataRequestResult) } } catch { - remotelyLoadImage(imageDomainModel, completionHandler: completionHandler) + remotelyLoadImage(imageDomainModel, callbackQueue: callbackQueue, completionHandler: completionHandler) } } private func remotelyLoadImage(_ imageDomainModel: ImageDomainModel, + callbackQueue: DispatchQueue, completionHandler: @escaping ((_ result: Result) -> ())) { session.scheduleDownload(url: imageDomainModel.url) { (result) in switch result { case .success(let data): guard let image = UIImage(data: data) else { - DispatchQueue.main.async { + callbackQueue.async { completionHandler(.failure(NetworkingError.invalidData(underlyingError: nil))) } return @@ -72,7 +78,7 @@ final class DefaultAssetService: AssetService { do { try data.write(to: imageDomainModel.cachedLocalAssetURL(), options: .atomic) } catch let error { - DispatchQueue.main.async { + callbackQueue.async { completionHandler(.failure(NetworkingError.invalidData(underlyingError: error))) } return @@ -81,11 +87,11 @@ final class DefaultAssetService: AssetService { let loadResult = LoadImageResult(imageDomainModel: imageDomainModel, image: image) let dataRequestResult = Result.success(loadResult) - DispatchQueue.main.async { + callbackQueue.async { completionHandler(dataRequestResult) } case .failure(let error): - DispatchQueue.main.async { + callbackQueue.async { completionHandler(.failure(error)) } } @@ -98,3 +104,14 @@ final class DefaultAssetService: AssetService { session.cancelDownload(url: imageDomainModel.url) } } + +private extension ImageDomainModel { + // MARK: - Cache + + func cachedLocalAssetURL() -> URL { + let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).last! + let fileName = "\(identifier).\(url.pathExtension)" + + return cacheURL.appendingPathComponent(fileName) + } +} diff --git a/PausableDownloads-Example/Services/Images/ImageDomainModel.swift b/PausableDownloads-Example/Services/Images/ImageDomainModel.swift index 0e144e7..5aef1be 100644 --- a/PausableDownloads-Example/Services/Images/ImageDomainModel.swift +++ b/PausableDownloads-Example/Services/Images/ImageDomainModel.swift @@ -13,13 +13,4 @@ struct ImageDomainModel: Equatable { let url: URL let width: Int let height: Int - - // MARK: - Cache - - func cachedLocalAssetURL() -> URL { - let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).last! - let fileName = "\(identifier).\(url.pathExtension)" - - return cacheURL.appendingPathComponent(fileName) - } } diff --git a/PausableDownloads-Example/Services/Images/ImagesService.swift b/PausableDownloads-Example/Services/Images/ImagesService.swift index 4bf1bd8..55ac415 100644 --- a/PausableDownloads-Example/Services/Images/ImagesService.swift +++ b/PausableDownloads-Example/Services/Images/ImagesService.swift @@ -9,7 +9,8 @@ import Foundation protocol ImagesService { - func retrieveImages(completionHandler: @escaping ((_ result: Result<[ImageDomainModel], Error>) -> ())) + func retrieveImages(callbackQueue: DispatchQueue, + completionHandler: @escaping ((_ result: Result<[ImageDomainModel], Error>) -> ())) } final class DefaultImagesService: ImagesService { @@ -26,14 +27,20 @@ final class DefaultImagesService: ImagesService { // MARK: - Retrieval - func retrieveImages(completionHandler: @escaping ((_ result: Result<[ImageDomainModel], Error>) -> ())) { + func retrieveImages(callbackQueue: DispatchQueue, + completionHandler: @escaping ((_ result: Result<[ImageDomainModel], Error>) -> ())) { repository.retrieveImages { [domainModelFactory] (result) in switch result { case .success(let dtos): let images = dtos.map { domainModelFactory.buildImage(from: $0) } - completionHandler(.success(images)) + + callbackQueue.async { + completionHandler(.success(images)) + } case .failure(let error): - completionHandler(.failure(error)) + callbackQueue.async { + completionHandler(.failure(error)) + } } } } diff --git a/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewModel.swift b/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewModel.swift index c01fcc9..64a7840 100644 --- a/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewModel.swift +++ b/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewModel.swift @@ -13,7 +13,7 @@ protocol ImageViewerViewModelDelegate: AnyObject { didChangeTo state: ImageViewerViewModel.State) } -class ImageViewerViewModel { +final class ImageViewerViewModel { enum State: Equatable { case loadingImages case loadingAsset(description: String) @@ -54,7 +54,7 @@ class ImageViewerViewModel { func load() { transition(to: .loadingImages) - imagesService.retrieveImages { [weak self] (result) in + imagesService.retrieveImages(callbackQueue: .main) { [weak self] (result) in guard let self = self else { return } @@ -86,7 +86,7 @@ class ImageViewerViewModel { transition(to: .loadingAsset(description: image.url.absoluteString)) - assetService.loadImage(image) { [weak self] (result) in + assetService.loadImage(image, callbackQueue: .main) { [weak self] (result) in guard let self = self else { return } diff --git a/PausableDownloads-ExampleTests/Doubles/StubAssetService.swift b/PausableDownloads-ExampleTests/Doubles/StubAssetService.swift index 0f863c9..a6fcb58 100644 --- a/PausableDownloads-ExampleTests/Doubles/StubAssetService.swift +++ b/PausableDownloads-ExampleTests/Doubles/StubAssetService.swift @@ -12,15 +12,16 @@ import Foundation final class StubAssetService: AssetService { enum Event { - case loadImage(ImageDomainModel, (Result) -> ()) + case loadImage(ImageDomainModel, DispatchQueue, (Result) -> ()) case cancelLoadingImage(ImageDomainModel) } private(set) var events = [Event]() func loadImage(_ imageDomainModel: ImageDomainModel, + callbackQueue: DispatchQueue, completionHandler: @escaping (Result) -> ()) { - events.append(.loadImage(imageDomainModel, completionHandler)) + events.append(.loadImage(imageDomainModel, callbackQueue, completionHandler)) } func cancelLoadingImage(_ imageDomainModel: ImageDomainModel) { diff --git a/PausableDownloads-ExampleTests/Doubles/StubImagesService.swift b/PausableDownloads-ExampleTests/Doubles/StubImagesService.swift index e53268c..dc7f942 100644 --- a/PausableDownloads-ExampleTests/Doubles/StubImagesService.swift +++ b/PausableDownloads-ExampleTests/Doubles/StubImagesService.swift @@ -12,12 +12,13 @@ import Foundation final class StubImagesService: ImagesService { enum Event { - case retrieveImages(((_ result: Result<[ImageDomainModel], Error>) -> ())) + case retrieveImages(DispatchQueue, ((_ result: Result<[ImageDomainModel], Error>) -> ())) } private(set) var events = [Event]() - func retrieveImages(completionHandler: @escaping ((_ result: Result<[ImageDomainModel], Error>) -> ())) { - events.append(.retrieveImages(completionHandler)) + func retrieveImages(callbackQueue: DispatchQueue, + completionHandler: @escaping ((_ result: Result<[ImageDomainModel], Error>) -> ())) { + events.append(.retrieveImages(callbackQueue, completionHandler)) } } diff --git a/PausableDownloads-ExampleTests/TestData/ImageDomainModel+TestData.swift b/PausableDownloads-ExampleTests/TestData/ImageDomainModel+TestData.swift index 5f1ca4e..c75c50f 100644 --- a/PausableDownloads-ExampleTests/TestData/ImageDomainModel+TestData.swift +++ b/PausableDownloads-ExampleTests/TestData/ImageDomainModel+TestData.swift @@ -7,3 +7,18 @@ // import Foundation + +@testable import PausableDownloads_Example + +extension ImageDomainModel { + + static func testData(identifier: String = "test_example", + url: URL = URL(string: "http://test.com/example.jpg")!, + width: Int = 100, + height: Int = 200) -> ImageDomainModel { + ImageDomainModel(identifier: identifier, + url: url, + width: width, + height: height) + } +} diff --git a/PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift b/PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift index ca1ca26..d9bc5b9 100644 --- a/PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift +++ b/PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift @@ -59,19 +59,21 @@ final class ImageViewerViewModelTests: XCTestCase { sut.load() - guard case let .retrieveImages(completionHandler) = imagesService.events.first else { + guard case let .retrieveImages(_, completionHandler) = imagesService.events.first else { XCTFail("Unexpected event") return } - let imageA = createImage(identifier: "a") - let imageB = createImage(identifier: "b") + let imageA = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + let imageB = ImageDomainModel.testData(identifier: "b", + url: URL(string: "http://test.com/b.jpg")!) completionHandler(.success([imageA, imageB])) XCTAssertEqual(assetService.events.count, 1) - guard case let .loadImage(loadedImage, _) = assetService.events.first else { + guard case let .loadImage(loadedImage, _, _) = assetService.events.first else { XCTFail("Unexpected event") return } @@ -89,7 +91,7 @@ final class ImageViewerViewModelTests: XCTestCase { sut.load() - guard case let .retrieveImages(completionHandler) = imagesService.events.first else { + guard case let .retrieveImages(_, completionHandler) = imagesService.events.first else { XCTFail("Unexpected event") return } @@ -109,7 +111,7 @@ final class ImageViewerViewModelTests: XCTestCase { sut.load() - guard case let .retrieveImages(completionHandler) = imagesService.events.first else { + guard case let .retrieveImages(_, completionHandler) = imagesService.events.first else { XCTFail("Unexpected event") return } @@ -129,11 +131,19 @@ final class ImageViewerViewModelTests: XCTestCase { let sut = createSUT(imagesService: imagesService, assetService: assetService) - let imageA = createImage(identifier: "a") + let imageA = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) - loadImages([imageA], into: sut, using: imagesService) + sut.load() + + guard case let .retrieveImages(_, imagesCompletionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + imagesCompletionHandler(.success([imageA])) - guard case let .loadImage(_, completionHandler) = assetService.events.first else { + guard case let .loadImage(_, _, completionHandler) = assetService.events.first else { XCTFail("Unexpected event") return } @@ -151,9 +161,19 @@ final class ImageViewerViewModelTests: XCTestCase { let sut = createSUT(imagesService: imagesService, assetService: assetService) - loadImages([createImage(identifier: "a")], into: sut, using: imagesService) + let imageA = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + + sut.load() + + guard case let .retrieveImages(_, imagesCompletionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + imagesCompletionHandler(.success([imageA])) - guard case let .loadImage(_, completionHandler) = assetService.events.first else { + guard case let .loadImage(_, _, completionHandler) = assetService.events.first else { XCTFail("Unexpected event") return } @@ -172,12 +192,21 @@ final class ImageViewerViewModelTests: XCTestCase { assetService: assetService) sut.delegate = delegate - let imageA = createImage(identifier: "a") - let imageB = createImage(identifier: "b") + let imageA = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + let imageB = ImageDomainModel.testData(identifier: "b", + url: URL(string: "http://test.com/b.jpg")!) - loadImages([imageA, imageB], into: sut, using: imagesService) + sut.load() - guard case let .loadImage(_, staleCompletionHandler) = assetService.events.first else { + guard case let .retrieveImages(_, imagesCompletionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + imagesCompletionHandler(.success([imageA, imageB])) + + guard case let .loadImage(_, _, staleCompletionHandler) = assetService.events.first else { XCTFail("Unexpected event") return } @@ -201,10 +230,19 @@ final class ImageViewerViewModelTests: XCTestCase { let sut = createSUT(imagesService: imagesService, assetService: assetService) - let imageA = createImage(identifier: "a") - let imageB = createImage(identifier: "b") + let imageA = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + let imageB = ImageDomainModel.testData(identifier: "b", + url: URL(string: "http://test.com/b.jpg")!) - loadImages([imageA, imageB], into: sut, using: imagesService) + sut.load() + + guard case let .retrieveImages(_, imagesCompletionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + imagesCompletionHandler(.success([imageA, imageB])) sut.advance() @@ -225,14 +263,23 @@ final class ImageViewerViewModelTests: XCTestCase { let sut = createSUT(imagesService: imagesService, assetService: assetService) - let imageA = createImage(identifier: "a") - let imageB = createImage(identifier: "b") + let imageA = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + let imageB = ImageDomainModel.testData(identifier: "b", + url: URL(string: "http://test.com/b.jpg")!) - loadImages([imageA, imageB], into: sut, using: imagesService) + sut.load() + + guard case let .retrieveImages(_, imagesCompletionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + imagesCompletionHandler(.success([imageA, imageB])) sut.advance() - guard case let .loadImage(loadedImage, _) = assetService.events.last else { + guard case let .loadImage(loadedImage, _, _) = assetService.events.last else { XCTFail("Unexpected event") return } @@ -248,7 +295,17 @@ final class ImageViewerViewModelTests: XCTestCase { let sut = createSUT(imagesService: imagesService, assetService: assetService) - loadImages([createImage(identifier: "a")], into: sut, using: imagesService) + let imageA = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + + sut.load() + + guard case let .retrieveImages(_, imagesCompletionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + imagesCompletionHandler(.success([imageA])) let eventCountBeforeAdvance = assetService.events.count @@ -271,9 +328,17 @@ final class ImageViewerViewModelTests: XCTestCase { assetService: assetService) sut.delegate = delegate - let imageA = createImage(identifier: "a") + let imageA = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + + sut.load() + + guard case let .retrieveImages(_, imagesCompletionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } - loadImages([imageA], into: sut, using: imagesService) + imagesCompletionHandler(.success([imageA])) let eventCountBeforeAdvance = delegate.events.count @@ -283,6 +348,48 @@ final class ImageViewerViewModelTests: XCTestCase { XCTAssertEqual(sut.state, .loadingAsset(description: imageA.url.absoluteString)) } + // MARK: Callback queue + + func test_givenViewModel_whenLoadIsCalled_thenImagesAreRequestedOnTheMainQueue() { + let imagesService = StubImagesService() + + let sut = createSUT(imagesService: imagesService) + + sut.load() + + guard case let .retrieveImages(callbackQueue, _) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + XCTAssertTrue(callbackQueue === DispatchQueue.main) + } + + func test_givenRetrievedImages_whenAnAssetIsLoaded_thenItIsRequestedOnTheMainQueue() { + let imagesService = StubImagesService() + let assetService = StubAssetService() + + let sut = createSUT(imagesService: imagesService, + assetService: assetService) + + sut.load() + + guard case let .retrieveImages(_, imagesCompletionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + imagesCompletionHandler(.success([ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!)])) + + guard case let .loadImage(_, callbackQueue, _) = assetService.events.first else { + XCTFail("Unexpected event") + return + } + + XCTAssertTrue(callbackQueue === DispatchQueue.main) + } + func test_givenNoImages_whenAdvanceIsCalled_thenNoAssetIsCancelledOrLoaded() { let assetService = StubAssetService() @@ -300,24 +407,4 @@ extension ImageViewerViewModelTests { ImageViewerViewModel(imagesService: imagesService, assetService: assetService) } - - func createImage(identifier: String) -> ImageDomainModel { - ImageDomainModel(identifier: identifier, - url: URL(string: "http://test.com/\(identifier).jpg")!, - width: 100, - height: 100) - } - - func loadImages(_ images: [ImageDomainModel], - into sut: ImageViewerViewModel, - using imagesService: StubImagesService) { - sut.load() - - guard case let .retrieveImages(completionHandler) = imagesService.events.first else { - XCTFail("Unexpected event") - return - } - - completionHandler(.success(images)) - } } From 8ebb3e6a2d399acabeea09ff65975507a33167d4 Mon Sep 17 00:00:00 2001 From: William Boles Date: Thu, 10 Sep 2026 12:55:19 +0100 Subject: [PATCH 07/16] Session downloads, coalesces and reports on downloads in a thread safe manner --- .../project.pbxproj | 30 +- .../Networking/Abstract/RequestConfig.swift | 1 - .../Abstract/URLRequestFactory.swift | 1 - .../CatImagesURLRequestFactory.swift | 8 +- .../Networking/URLSessionFactory.swift | 14 +- .../{CatImages => Images}/ImageDTO.swift | 0 .../ImagesRepository.swift | 0 .../Asset/AssetDownloadsSession.swift | 457 ++++++----- .../Services/Asset/AssetService.swift | 35 +- .../Storyboards/Base.lproj/Main.storyboard | 44 +- .../ImageGalleryViewController.swift | 142 ++++ .../ImageGallery/ImageGalleryViewModel.swift | 112 +++ .../ImageViewerViewController.swift | 55 +- .../ImageViewer/ImageViewerViewModel.swift | 104 ++- .../Doubles/StubAssetService.swift | 17 +- .../StubImageGalleryViewModelDelegate.swift | 24 + .../Doubles/StubNotificationCenter.swift | 2 +- .../Doubles/StubURLSession.swift | 29 +- .../Doubles/StubURLSessionDownloadTask.swift | 21 +- .../Tests/AssetDownloadsSessionTests.swift | 732 +++++++++++++----- .../Tests/ImageGalleryViewModelTests.swift | 262 +++++++ .../Tests/ImageViewerViewModelTests.swift | 342 ++------ README.md | 8 + 23 files changed, 1637 insertions(+), 803 deletions(-) rename PausableDownloads-Example/Repositories/{CatImages => Images}/ImageDTO.swift (100%) rename PausableDownloads-Example/Repositories/{CatImages => Images}/ImagesRepository.swift (100%) create mode 100644 PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewController.swift create mode 100644 PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewModel.swift create mode 100644 PausableDownloads-ExampleTests/Doubles/StubImageGalleryViewModelDelegate.swift create mode 100644 PausableDownloads-ExampleTests/Tests/ImageGalleryViewModelTests.swift diff --git a/PausableDownloads-Example.xcodeproj/project.pbxproj b/PausableDownloads-Example.xcodeproj/project.pbxproj index a1549e1..6fb1764 100644 --- a/PausableDownloads-Example.xcodeproj/project.pbxproj +++ b/PausableDownloads-Example.xcodeproj/project.pbxproj @@ -37,6 +37,10 @@ 4399D3933050B4DB009D2CEB /* TestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38C3050B4DB009D2CEB /* TestError.swift */; }; 4399D3943050B4DB009D2CEB /* StubNotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D3883050B4DB009D2CEB /* StubNotificationCenter.swift */; }; 4399D3953050B4DB009D2CEB /* AssetDownloadsSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38E3050B4DB009D2CEB /* AssetDownloadsSessionTests.swift */; }; + 43A1000030600011009529DF /* ImageGalleryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600001009529DF /* ImageGalleryViewController.swift */; }; + 43A1000030600012009529DF /* ImageGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600002009529DF /* ImageGalleryViewModel.swift */; }; + 43A1000030600013009529DF /* ImageGalleryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600003009529DF /* ImageGalleryViewModelTests.swift */; }; + 43A1000030600014009529DF /* StubImageGalleryViewModelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600004009529DF /* StubImageGalleryViewModelDelegate.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -84,6 +88,10 @@ 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSessionFactory.swift; sourceTree = ""; }; 4399D38C3050B4DB009D2CEB /* TestError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestError.swift; sourceTree = ""; }; 4399D38E3050B4DB009D2CEB /* AssetDownloadsSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetDownloadsSessionTests.swift; sourceTree = ""; }; + 43A1000030600001009529DF /* ImageGalleryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryViewController.swift; sourceTree = ""; }; + 43A1000030600002009529DF /* ImageGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryViewModel.swift; sourceTree = ""; }; + 43A1000030600003009529DF /* ImageGalleryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryViewModelTests.swift; sourceTree = ""; }; + 43A1000030600004009529DF /* StubImageGalleryViewModelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubImageGalleryViewModelDelegate.swift; sourceTree = ""; }; 43DF70D53051B477004E9EEA /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ @@ -147,6 +155,7 @@ 3D63CC37204B554700797A82 /* ViewControllers */ = { isa = PBXGroup; children = ( + 43A1000030600021009529DF /* ImageGallery */, 3D63CC3C204B554700797A82 /* ImageViewer */, ); path = ViewControllers; @@ -221,19 +230,19 @@ path = Networking; sourceTree = ""; }; - 437C0CAD3051EC36009529DF /* CatImages */ = { + 437C0CAD3051EC36009529DF /* Images */ = { isa = PBXGroup; children = ( 437C0CAB3051EC36009529DF /* ImageDTO.swift */, 437C0CAC3051EC36009529DF /* ImagesRepository.swift */, ); - path = CatImages; + path = Images; sourceTree = ""; }; 437C0CAE3051EC36009529DF /* Repositories */ = { isa = PBXGroup; children = ( - 437C0CAD3051EC36009529DF /* CatImages */, + 437C0CAD3051EC36009529DF /* Images */, ); path = Repositories; sourceTree = ""; @@ -279,6 +288,7 @@ children = ( 4399D3883050B4DB009D2CEB /* StubNotificationCenter.swift */, 437C0D6A3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift */, + 43A1000030600004009529DF /* StubImageGalleryViewModelDelegate.swift */, 4399D3893050B4DB009D2CEB /* StubURLSession.swift */, 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */, 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */, @@ -294,10 +304,20 @@ children = ( 4399D38E3050B4DB009D2CEB /* AssetDownloadsSessionTests.swift */, 437C0D443051FB2E009529DF /* ImageViewerViewModelTests.swift */, + 43A1000030600003009529DF /* ImageGalleryViewModelTests.swift */, ); path = Tests; sourceTree = ""; }; + 43A1000030600021009529DF /* ImageGallery */ = { + isa = PBXGroup; + children = ( + 43A1000030600001009529DF /* ImageGalleryViewController.swift */, + 43A1000030600002009529DF /* ImageGalleryViewModel.swift */, + ); + path = ImageGallery; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -419,6 +439,8 @@ 3D63CC5F204B554700797A82 /* AppDelegate.swift in Sources */, 3D63CC5E204B554700797A82 /* ImageViewerViewController.swift in Sources */, 437C0D5B3051EDA0009529DF /* ImageViewerViewModel.swift in Sources */, + 43A1000030600011009529DF /* ImageGalleryViewController.swift in Sources */, + 43A1000030600012009529DF /* ImageGalleryViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -435,7 +457,9 @@ 4399D3933050B4DB009D2CEB /* TestError.swift in Sources */, 4399D3943050B4DB009D2CEB /* StubNotificationCenter.swift in Sources */, 437C0D6B3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift in Sources */, + 43A1000030600014009529DF /* StubImageGalleryViewModelDelegate.swift in Sources */, 437C0D453051FB2E009529DF /* ImageViewerViewModelTests.swift in Sources */, + 43A1000030600013009529DF /* ImageGalleryViewModelTests.swift in Sources */, 4399D3953050B4DB009D2CEB /* AssetDownloadsSessionTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/PausableDownloads-Example/Networking/Abstract/RequestConfig.swift b/PausableDownloads-Example/Networking/Abstract/RequestConfig.swift index 6a00286..149dae9 100644 --- a/PausableDownloads-Example/Networking/Abstract/RequestConfig.swift +++ b/PausableDownloads-Example/Networking/Abstract/RequestConfig.swift @@ -17,7 +17,6 @@ enum HTTPRequestMethod: String { } class RequestConfig { - let apiKey: String let APIHost: String let timeInterval: TimeInterval diff --git a/PausableDownloads-Example/Networking/Abstract/URLRequestFactory.swift b/PausableDownloads-Example/Networking/Abstract/URLRequestFactory.swift index 3d01aed..472c186 100644 --- a/PausableDownloads-Example/Networking/Abstract/URLRequestFactory.swift +++ b/PausableDownloads-Example/Networking/Abstract/URLRequestFactory.swift @@ -15,7 +15,6 @@ enum NetworkingError: Error { } class URLRequestFactory { - let config: RequestConfig // MARK: - Init diff --git a/PausableDownloads-Example/Networking/CatImagesURLRequestFactory.swift b/PausableDownloads-Example/Networking/CatImagesURLRequestFactory.swift index 9218aee..cecac81 100644 --- a/PausableDownloads-Example/Networking/CatImagesURLRequestFactory.swift +++ b/PausableDownloads-Example/Networking/CatImagesURLRequestFactory.swift @@ -13,9 +13,11 @@ class CatImagesURLRequestFactory: URLRequestFactory { // MARK: - Retrieval //`order=RANDOM` as TheCatAPI has no chronological ordering - `ASC`/`DESC` sort by id, - //which always surfaces the same legacy images - func requestToRetrieveImages(limit: Int = 30) -> URLRequest { - var request = jsonRequest(endPoint: "images/search?limit=\(limit)&order=RANDOM") + //which always surfaces the same legacy images. + //`size=full` returns the originals rather than resized copies - small assets finish + //downloading before there's any chance to pause one, which is the whole point here + func requestToRetrieveImages(limit: Int = 10) -> URLRequest { + var request = jsonRequest(endPoint: "images/search?limit=\(limit)&order=RANDOM&size=full") request.httpMethod = HTTPRequestMethod.get.rawValue return request diff --git a/PausableDownloads-Example/Networking/URLSessionFactory.swift b/PausableDownloads-Example/Networking/URLSessionFactory.swift index 62b66bd..5ef107b 100644 --- a/PausableDownloads-Example/Networking/URLSessionFactory.swift +++ b/PausableDownloads-Example/Networking/URLSessionFactory.swift @@ -19,22 +19,22 @@ extension URLSessionFactoryType { } protocol URLSessionType { - func downloadTask(with url: URL, completionHandler: @escaping (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTaskType - func downloadTask(withResumeData resumeData: Data, completionHandler: @escaping (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTaskType + func downloadTask(with url: URL) -> URLSessionDownloadTaskType + func downloadTask(withResumeData resumeData: Data) -> URLSessionDownloadTaskType } extension URLSession: URLSessionType { - func downloadTask(with url: URL, completionHandler: @escaping (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTaskType { - return downloadTask(with: url, completionHandler: completionHandler) as URLSessionDownloadTask + func downloadTask(with url: URL) -> URLSessionDownloadTaskType { + return downloadTask(with: url) as URLSessionDownloadTask } - func downloadTask(withResumeData resumeData: Data, completionHandler: @escaping (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTaskType { - return downloadTask(withResumeData: resumeData, completionHandler: completionHandler) as URLSessionDownloadTask + func downloadTask(withResumeData resumeData: Data) -> URLSessionDownloadTaskType { + return downloadTask(withResumeData: resumeData) as URLSessionDownloadTask } } protocol URLSessionDownloadTaskType { - var progress: Progress { get } + var taskIdentifier: Int { get } func resume() func cancel() diff --git a/PausableDownloads-Example/Repositories/CatImages/ImageDTO.swift b/PausableDownloads-Example/Repositories/Images/ImageDTO.swift similarity index 100% rename from PausableDownloads-Example/Repositories/CatImages/ImageDTO.swift rename to PausableDownloads-Example/Repositories/Images/ImageDTO.swift diff --git a/PausableDownloads-Example/Repositories/CatImages/ImagesRepository.swift b/PausableDownloads-Example/Repositories/Images/ImagesRepository.swift similarity index 100% rename from PausableDownloads-Example/Repositories/CatImages/ImagesRepository.swift rename to PausableDownloads-Example/Repositories/Images/ImagesRepository.swift diff --git a/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift b/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift index be69936..9b3b69b 100644 --- a/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift +++ b/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift @@ -21,9 +21,44 @@ extension NotificationCenter: NotificationCenterType { } typealias DownloadCompletionHandler = ((_ result: Result) -> ()) +/* Identifies one caller's interest in a URL rather than one download, so several callers + can share a single download of that URL and each pause and be answered independently. The + URL comes back with the token so a pause can go straight to the download it belongs to. + */ +struct DownloadToken: Hashable { + let url: URL + + private let rawValue = UUID() + + init(url: URL) { + self.url = url + } +} + final class AssetDownloadsSession: NSObject { - private var assetDownloadItems = [AssetDownloadItem]() - private let accessQueue = DispatchQueue(label: "com.williamboles.downloadssession") + private struct Download { + var handlers: [DownloadToken: DownloadCompletionHandler] + var stage: DownloadStage + } + + private enum DownloadStage { + case running(task: URLSessionDownloadTaskType) + case pausing //cancel issued, resumption data hasn't landed yet + case paused(resumptionData: Data) + + var isPaused: Bool { + guard case .paused = self else { + return false + } + + return true + } + } + + //one entry per URL - everybody who wants it shares the one download + private var downloads = [URL: Download]() + private let queue = DispatchQueue(label: "com.williamboles.downloadssession") + private var session: URLSessionType! // MARK: - Singleton @@ -40,256 +75,300 @@ final class AssetDownloadsSession: NSObject { registerForNotifications(on: notificationCenter) } + // MARK: - State + + //`downloads` is only ever reached from inside here, so a read-modify-write of it + //stays indivisible. + private func sync(_ body: () -> T) -> T { + //`sync` isn't reentrant - trap on a nested call rather than deadlock + dispatchPrecondition(condition: .notOnQueue(queue)) + + return queue.sync(execute: body) + } + + //only a running download owns a task, so only a running download can be matched by one + private func runningDownload(withTaskIdentifier taskIdentifier: Int) -> (url: URL, download: Download)? { + dispatchPrecondition(condition: .onQueue(queue)) + + return downloads.first { entry in + guard case let .running(task) = entry.value.stage else { + return false + } + + return task.taskIdentifier == taskIdentifier + }.map { (url: $0.key, download: $0.value) } + } + // MARK: - Notification private func registerForNotifications(on notificationCenter: NotificationCenterType) { - notificationCenter.addObserver(forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: .main) { [weak self] _ in + notificationCenter.addObserver(forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: .main) { [weak self] _ in self?.purgePausedDownloads() } } private func purgePausedDownloads() { - accessQueue.sync { - os_log(.info, "Cancelling paused items") + sync { + os_log(.info, "Purging paused items") - assetDownloadItems = assetDownloadItems.filter { (assetDownloadItem) -> Bool in - let isPaused = assetDownloadItem.isPaused - if isPaused { - assetDownloadItem.cancel() - } - - return !isPaused - } + //Only a paused download occupies memory without anybody waiting on it. Dropping + //one that's still pausing would strand whoever joined it and lose the record of a + //cancel we've already issued, so the next schedule would start a second task for a + //URL that already has one winding down. + downloads = downloads.filter { !$0.value.stage.isPaused } } } // MARK: - Schedule + @discardableResult func scheduleDownload(url: URL, - completionHandler: @escaping DownloadCompletionHandler) { - accessQueue.sync { - if let assetDownloadItem = assetDownloadItems.first(where: { $0.url == url && $0.isCoalescable }) { - os_log(.info, "Found existing %{public}@ download so coalescing them for: %{public}@", assetDownloadItem.stateDescription, assetDownloadItem.description) - - assetDownloadItem.coalesceDownloadCompletionHandler(completionHandler) - - if assetDownloadItem.isResumable { - assetDownloadItem.resume() - } - } else { - let assetDownloadItem = AssetDownloadItem(session: session, url: url) - assetDownloadItem.downloadCompletionHandler = completionHandler - assetDownloadItem.delegate = self - - os_log(.info, "Created a new download: %{public}@", assetDownloadItem.description) - - assetDownloadItems.append(assetDownloadItem) + completionHandler: @escaping DownloadCompletionHandler) -> DownloadToken { + let token = DownloadToken(url: url) + + sync { + guard var download = downloads[url] else { + startDownload(for: url, + resumingFrom: nil, + handlers: [token: completionHandler]) + return + } + + //a download for `url` already exists so coalescing this new request with it + download.handlers[token] = completionHandler + + //a paused download is the only one with nothing already on its way + guard case let .paused(resumptionData) = download.stage else { + os_log(.info, "Joining an existing download of: %{public}@", url.absoluteString) - assetDownloadItem.resume() + downloads[url] = download + return } + + startDownload(for: url, + resumingFrom: resumptionData, + handlers: download.handlers) } + + return token } - // MARK: - Cancel + //Deciding to start a task and recording it both happen on the queue, so they can't be + //split apart by another caller. Replacing the whole entry is what makes resumption data + //single use - starting a task overwrites the stage holding it. + private func startDownload(for url: URL, + resumingFrom resumptionData: Data?, + handlers: [DownloadToken: DownloadCompletionHandler]) { + dispatchPrecondition(condition: .onQueue(queue)) + + let task: URLSessionDownloadTaskType + if let resumptionData = resumptionData { + os_log(.info, "Resuming an existing download: %{public}@", url.absoluteString) + task = session.downloadTask(withResumeData: resumptionData) + } else { + os_log(.info, "Creating a new download: %{public}@", url.absoluteString) + task = session.downloadTask(with: url) + } + + downloads[url] = Download(handlers: handlers, + stage: .running(task: task)) + + //`URLSession` delivers its callbacks on its own queue, never synchronously on this + //thread, so holding the downloads queue here can't deadlock the way a cancel would + task.resume() + } - func cancelDownload(url: URL) { - accessQueue.sync { - guard let assetDownloadItem = assetDownloadItems.first(where: { $0.url == url }) else { - return + // MARK: - Pause + + func pauseDownload(_ token: DownloadToken) { + let url = token.url + + let taskToPause = sync { () -> URLSessionDownloadTaskType? in + //Pausing drops the caller - it isn't a result anybody is waiting to hear. A + //token that isn't in there has already been dropped, so there's nothing to do. + guard var download = downloads[url], + download.handlers.removeValue(forKey: token) != nil else { + return nil } - os_log(.info, "Download: %{public}@ going to paused", assetDownloadItem.description) - assetDownloadItem.pause() - } - } -} - -extension AssetDownloadsSession: AssetDownloadItemDelegate { - fileprivate func assetDownloadItemCompleted(_ assetDownloadItem: AssetDownloadItem) { - accessQueue.sync { - os_log(.info, "Completed download of: %{public}@", assetDownloadItem.description) + //write the entry back whichever way we leave, so no return can half-update it + defer { downloads[url] = download } - if let index = assetDownloadItems.firstIndex(where: { $0.url == assetDownloadItem.url && $0.isCompleted }) { - assetDownloadItems.remove(at: index) + //somebody else still wants this URL, so the download carries on + guard download.handlers.isEmpty else { + os_log(.info, "Dropping a caller from a download others still want: %{public}@", url.absoluteString) + return nil } + + //a cancel that's already in flight will produce the resumption data on its own + guard case let .running(task) = download.stage else { + return nil + } + + os_log(.info, "Pausing download: %{public}@", url.absoluteString) + + download.stage = .pausing + + return task } - } -} - -extension AssetDownloadsSession: URLSessionDownloadDelegate { - func urlSession(_ session: URLSession, - downloadTask: URLSessionDownloadTask, - didFinishDownloadingTo location: URL) { /*no-op*/ } - - func urlSession(_ session: URLSession, - downloadTask: URLSessionDownloadTask, - didResumeAtOffset fileOffset: Int64, - expectedTotalBytes: Int64) { - guard let url = downloadTask.currentRequest?.url else { + + guard let taskToPause = taskToPause else { return } - let resumptionPercentage = (Double(fileOffset)/Double(expectedTotalBytes)) * 100 - os_log(.info, "Resuming download: %{public}@ from: %{public}.02f%%", url.absoluteString, resumptionPercentage) - } -} - -private protocol AssetDownloadItemDelegate { - func assetDownloadItemCompleted(_ assetDownloadItem: AssetDownloadItem) -} - -private class AssetDownloadItem { - private enum State: String { - case ready - case downloading - case paused - case cancelled - case completed - } - - private let session: URLSessionType - private var resumptionData: Data? - private var downloadTask: URLSessionDownloadTaskType? - private var observation: NSKeyValueObservation? - - var delegate: AssetDownloadItemDelegate? - var downloadCompletionHandler: DownloadCompletionHandler? - let url: URL - private var state: State = .ready - - var description: String { - return url.absoluteString - } - - var stateDescription: String { - return state.rawValue - } - - var isCoalescable: Bool { - return (state == .ready) || - (state == .downloading) || - (state == .paused) + //`URLSession` can answer on the thread that cancelled, so the downloads queue + //mustn't be held here + taskToPause.cancel(byProducingResumeData: { [weak self] data in + self?.handleResumptionData(data, + for: url) + }) } - var isResumable: Bool { - return (state == . ready) || - (state == .paused) + private func handleResumptionData(_ data: Data?, + for url: URL) { + sync { + //only a pause we issued can be answered here, and only once + guard var download = downloads[url], + case .pausing = download.stage else { + os_log(.info, "Ignoring resumption data for a download that is no longer pausing: %{public}@", url.absoluteString) + return + } + + guard !download.handlers.isEmpty else { + //nobody's waiting, so park the data if there is any and forget the download if there isn't + if let data = data { + os_log(.info, "Cancelled download task has produced resumption data of: %{public}@ for %{public}@", data.description, url.absoluteString) + + download.stage = .paused(resumptionData: data) + downloads[url] = download + } else { + downloads[url] = nil + } + + return + } + + os_log(.info, "Resumption data has landed so starting the download somebody joined: %{public}@", url.absoluteString) + + //somebody asked for this URL whilst the pause was in flight + startDownload(for: url, + resumingFrom: data, + handlers: download.handlers) + } } +} + +extension AssetDownloadsSession { - var isPaused: Bool { - return state == .paused - } + // MARK: - Handling - var isCompleted: Bool { - return state == .completed + func handleProgress(for url: URL, + totalBytesWritten: Int64, + expectedTotalBytes: Int64) { + let downloadedPercentage = (Double(totalBytesWritten)/Double(expectedTotalBytes)) * 100 + os_log(.info, "Downloaded %{public}.02f%% of %{public}@", downloadedPercentage, url.absoluteString) } - // MARK: - Init - - init(session: URLSessionType, url: URL) { - self.session = session - self.url = url + func handleResumption(for url: URL, + fileOffset: Int64, + expectedTotalBytes: Int64) { + let resumptionPercentage = (Double(fileOffset)/Double(expectedTotalBytes)) * 100 + os_log(.info, "Resuming download: %{public}@ from: %{public}.02f%%", url.absoluteString, resumptionPercentage) } - deinit { - observation?.invalidate() + func handleFinishedDownloading(forTaskWith taskIdentifier: Int, + to location: URL) { + deliverResult(forTaskWith: taskIdentifier) { + do { + return .success(try Data(contentsOf: location)) + } catch let error { + return .failure(NetworkingError.invalidData(underlyingError: error)) + } + } } - // MARK: - Lifecycle - - func resume() { - state = .downloading - - if let resumptionData = resumptionData { - os_log(.info, "Attempting to resume download task") - downloadTask = session.downloadTask(withResumeData: resumptionData, completionHandler: handleDownloadTaskComplete) - } else { - os_log(.info, "Creating a new download task") - downloadTask = session.downloadTask(with: url, completionHandler: handleDownloadTaskComplete) + func handleComplete(forTaskWith taskIdentifier: Int, + error: Error?) { + //a pause or a purge cancels the task; that isn't a failure anybody asked about + if let error = error as? URLError, error.code == .cancelled { + os_log(.info, "Ignoring the cancellation of task: %{public}d", taskIdentifier) + return } - observation = downloadTask?.progress.observe(\.fractionCompleted, options: [.new]) { [weak self] (progress, change) in - os_log(.info, "Downloaded %{public}.02f%% of %{public}@", (progress.fractionCompleted * 100), self?.url.absoluteString ?? "") + deliverResult(forTaskWith: taskIdentifier) { + .failure(NetworkingError.retrieval(underlyingError: error)) } - - downloadTask?.resume() } - private func handleDownloadTaskComplete(_ fileLocationURL: URL?, _ response: URLResponse?, _ error: Error?) { - var result: Result - defer { - downloadCompletionHandler?(result) - - /* A paused download triggers its URLSessionDownloadTask instances - completion closure but we don't consider this AssetDownloadItem - instance complete until it has either finished downloading (data or - error) or been cancelled. So we need to suppress the call to - `complete()` here for paused downloads. - */ - if !isPaused { - complete() + private func deliverResult(forTaskWith taskIdentifier: Int, + _ makeResult: () -> Result) { + let handlers = sync { () -> [DownloadCompletionHandler] in + //a download that has already been delivered went with the entry that held it + guard let running = runningDownload(withTaskIdentifier: taskIdentifier) else { + return [] } - cleanup() + os_log(.info, "Finished download of: %{public}@", running.url.absoluteString) + + //the download is over for everybody who asked for it, so the entry goes with it + downloads[running.url] = nil + + return Array(running.download.handlers.values) } - guard let fileLocationURL = fileLocationURL else { - result = .failure(NetworkingError.retrieval(underlyingError: error)) + guard !handlers.isEmpty else { return } - do { - let data = try Data(contentsOf: fileLocationURL) - result = .success(data) - } catch let error { - result = .failure(NetworkingError.invalidData(underlyingError: error)) - } - } - - func pause() { - state = .paused + //made once and handed to everybody who coalesced onto this download + let result = makeResult() - cancelAndSaveData() + handlers.forEach { $0(result) } } +} + +extension AssetDownloadsSession: URLSessionDownloadDelegate { - private func cancelAndSaveData() { - downloadTask?.cancel(byProducingResumeData: { [weak self] (data) in - guard let data = data else { - return - } - - os_log(.info, "Cancelled download task has produced resumption data of: %{public}@ for %{public}@", data.description, self?.url.absoluteString ?? "unknown url") - self?.resumptionData = data - }) - } + // MARK: - URLSessionDownloadDelegate - func cancel() { - state = .cancelled + func urlSession(_ session: URLSession, + downloadTask: URLSessionDownloadTask, + didWriteData bytesWritten: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64) { + guard let url = downloadTask.originalRequest?.url else { + return + } - downloadTask?.cancel() + handleProgress(for: url, + totalBytesWritten: totalBytesWritten, + expectedTotalBytes: totalBytesExpectedToWrite) } - private func complete() { - state = .completed + func urlSession(_ session: URLSession, + downloadTask: URLSessionDownloadTask, + didResumeAtOffset fileOffset: Int64, + expectedTotalBytes: Int64) { + guard let url = downloadTask.originalRequest?.url else { + return + } - delegate?.assetDownloadItemCompleted(self) + handleResumption(for: url, + fileOffset: fileOffset, + expectedTotalBytes: expectedTotalBytes) } - private func cleanup() { - observation?.invalidate() - downloadTask = nil - downloadCompletionHandler = nil + func urlSession(_ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL) { + handleFinishedDownloading(forTaskWith: downloadTask.taskIdentifier, to: location) } - //MARK: - Coalesce - - func coalesceDownloadCompletionHandler(_ otherDownloadCompletionHandler: @escaping DownloadCompletionHandler) { - let initialDownloadCompletionHandler = downloadCompletionHandler - - downloadCompletionHandler = { result in - initialDownloadCompletionHandler?(result) - otherDownloadCompletionHandler(result) - } + func urlSession(_ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error?) { + handleComplete(forTaskWith: task.taskIdentifier, error: error) } } - diff --git a/PausableDownloads-Example/Services/Asset/AssetService.swift b/PausableDownloads-Example/Services/Asset/AssetService.swift index 190c34b..6368acc 100644 --- a/PausableDownloads-Example/Services/Asset/AssetService.swift +++ b/PausableDownloads-Example/Services/Asset/AssetService.swift @@ -15,10 +15,14 @@ struct LoadImageResult: Equatable { } protocol AssetService { + /* Returns the id of the download it started, or nil when the asset was already + cached locally and there's nothing to pause. + */ + @discardableResult func loadImage(_ imageDomainModel: ImageDomainModel, callbackQueue: DispatchQueue, - completionHandler: @escaping ((_ result: Result) -> ())) - func cancelLoadingImage(_ imageDomainModel: ImageDomainModel) + completionHandler: @escaping ((_ result: Result) -> ())) -> DownloadToken? + func cancelLoadingImage(_ downloadToken: DownloadToken) } final class DefaultAssetService: AssetService { @@ -27,19 +31,20 @@ final class DefaultAssetService: AssetService { // MARK: - Load + @discardableResult func loadImage(_ imageDomainModel: ImageDomainModel, callbackQueue: DispatchQueue, - completionHandler: @escaping ((_ result: Result) -> ())) { + completionHandler: @escaping ((_ result: Result) -> ())) -> DownloadToken? { if fileManager.fileExists(atPath: imageDomainModel.cachedLocalAssetURL().path) { - locallyLoadImage(imageDomainModel, callbackQueue: callbackQueue, completionHandler: completionHandler) + return locallyLoadImage(imageDomainModel, callbackQueue: callbackQueue, completionHandler: completionHandler) } else { - remotelyLoadImage(imageDomainModel, callbackQueue: callbackQueue, completionHandler: completionHandler) + return remotelyLoadImage(imageDomainModel, callbackQueue: callbackQueue, completionHandler: completionHandler) } } private func locallyLoadImage(_ imageDomainModel: ImageDomainModel, callbackQueue: DispatchQueue, - completionHandler: @escaping ((_ result: Result) -> ())) { + completionHandler: @escaping ((_ result: Result) -> ())) -> DownloadToken? { do { let data = try Data(contentsOf: URL(fileURLWithPath: imageDomainModel.cachedLocalAssetURL().path)) @@ -47,7 +52,7 @@ final class DefaultAssetService: AssetService { callbackQueue.async { completionHandler(.failure(NetworkingError.invalidData(underlyingError: nil))) } - return + return nil } let loadResult = LoadImageResult(imageDomainModel: imageDomainModel, image: image) @@ -56,14 +61,17 @@ final class DefaultAssetService: AssetService { callbackQueue.async { completionHandler(dataRequestResult) } + + return nil } catch { - remotelyLoadImage(imageDomainModel, callbackQueue: callbackQueue, completionHandler: completionHandler) + return remotelyLoadImage(imageDomainModel, callbackQueue: callbackQueue, completionHandler: completionHandler) } } + @discardableResult private func remotelyLoadImage(_ imageDomainModel: ImageDomainModel, callbackQueue: DispatchQueue, - completionHandler: @escaping ((_ result: Result) -> ())) { + completionHandler: @escaping ((_ result: Result) -> ())) -> DownloadToken { session.scheduleDownload(url: imageDomainModel.url) { (result) in switch result { @@ -76,6 +84,11 @@ final class DefaultAssetService: AssetService { } do { + /* Callers that coalesced onto one download each write these same bytes to + the same path. The writes are atomic, sequential and identical, so the + redundancy costs a little disk churn and nothing else - deduplicating it + would mean moving caching down into the download session. + */ try data.write(to: imageDomainModel.cachedLocalAssetURL(), options: .atomic) } catch let error { callbackQueue.async { @@ -100,8 +113,8 @@ final class DefaultAssetService: AssetService { // MARK: - Cancel - func cancelLoadingImage(_ imageDomainModel: ImageDomainModel) { - session.cancelDownload(url: imageDomainModel.url) + func cancelLoadingImage(_ downloadToken: DownloadToken) { + session.pauseDownload(downloadToken) } } diff --git a/PausableDownloads-Example/Storyboards/Base.lproj/Main.storyboard b/PausableDownloads-Example/Storyboards/Base.lproj/Main.storyboard index f0c8609..fa27499 100644 --- a/PausableDownloads-Example/Storyboards/Base.lproj/Main.storyboard +++ b/PausableDownloads-Example/Storyboards/Base.lproj/Main.storyboard @@ -2,8 +2,10 @@ + + @@ -17,30 +19,36 @@ - + + + + + + + + + + + - + - - - - - + - + - @@ -58,11 +65,11 @@ - @@ -75,9 +82,6 @@ - - - @@ -85,13 +89,13 @@ - - - - - - + + + + + + diff --git a/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewController.swift b/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewController.swift new file mode 100644 index 0000000..fdd96c0 --- /dev/null +++ b/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewController.swift @@ -0,0 +1,142 @@ +// +// ImageGalleryViewController.swift +// PausableDownloads-Example +// +// Created by William Boles on 09/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import UIKit + +class ImageGalleryViewController: UIPageViewController { + private let galleryViewModel = ImageGalleryViewModel() + private let loadingActivityIndicator = UIActivityIndicatorView(style: .large) + + // MARK: - ViewLifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + + configureNavigationBar() + configureLoadingActivityIndicator() + + dataSource = self + delegate = self + + galleryViewModel.delegate = self + galleryViewModel.load() + } + + private func configureNavigationBar() { + /* Paging in `.scroll` style puts a scroll view behind the bar, so without + this it adopts its transparent scroll-edge appearance and the position + indicator disappears against the black background. + */ + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + + navigationController?.navigationBar.standardAppearance = appearance + navigationController?.navigationBar.scrollEdgeAppearance = appearance + } + + private func configureLoadingActivityIndicator() { + loadingActivityIndicator.hidesWhenStopped = true + loadingActivityIndicator.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(loadingActivityIndicator) + + NSLayoutConstraint.activate([loadingActivityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + loadingActivityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)]) + } + + // MARK: - Pages + + private func imageViewerViewController(at index: Int) -> ImageViewerViewController? { + guard let viewModel = galleryViewModel.viewModel(at: index) else { + return nil + } + + return ImageViewerViewController.instantiate(viewModel: viewModel, index: index) + } + + private func showFirstImage() { + guard let viewController = imageViewerViewController(at: 0) else { + return + } + + setViewControllers([viewController], direction: .forward, animated: false) + + updateTitle(for: 0) + } + + private func updateTitle(for index: Int) { + title = "\(index + 1) of \(galleryViewModel.numberOfImages)" + } +} + +extension ImageGalleryViewController: UIPageViewControllerDataSource { + + // MARK: - UIPageViewControllerDataSource + + func pageViewController(_ pageViewController: UIPageViewController, + viewControllerBefore viewController: UIViewController) -> UIViewController? { + guard let imageViewerViewController = viewController as? ImageViewerViewController else { + return nil + } + + return self.imageViewerViewController(at: imageViewerViewController.index - 1) + } + + func pageViewController(_ pageViewController: UIPageViewController, + viewControllerAfter viewController: UIViewController) -> UIViewController? { + guard let imageViewerViewController = viewController as? ImageViewerViewController else { + return nil + } + + return self.imageViewerViewController(at: imageViewerViewController.index + 1) + } +} + +extension ImageGalleryViewController: UIPageViewControllerDelegate { + + // MARK: - UIPageViewControllerDelegate + + func pageViewController(_ pageViewController: UIPageViewController, + didFinishAnimating finished: Bool, + previousViewControllers: [UIViewController], + transitionCompleted completed: Bool) { + /* Only a transition the user actually landed on should pause what came + before it - a cancelled swipe hasn't moved anywhere. + */ + guard completed, + let imageViewerViewController = viewControllers?.first as? ImageViewerViewController else { + return + } + + galleryViewModel.moveTo(index: imageViewerViewController.index) + + updateTitle(for: imageViewerViewController.index) + } +} + +extension ImageGalleryViewController: ImageGalleryViewModelDelegate { + + // MARK: - ImageGalleryViewModelDelegate + + func viewModel(_ viewModel: ImageGalleryViewModel, + didChangeTo state: ImageGalleryViewModel.State) { + switch state { + case .loadingImages: + loadingActivityIndicator.startAnimating() + case .loadedImages: + loadingActivityIndicator.stopAnimating() + showFirstImage() + case .failed: + loadingActivityIndicator.stopAnimating() + //TODO: Handle error + break + } + } +} diff --git a/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewModel.swift b/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewModel.swift new file mode 100644 index 0000000..6acf202 --- /dev/null +++ b/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewModel.swift @@ -0,0 +1,112 @@ +// +// ImageGalleryViewModel.swift +// PausableDownloads-Example +// +// Created by William Boles on 09/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import Foundation + +protocol ImageGalleryViewModelDelegate: AnyObject { + func viewModel(_ viewModel: ImageGalleryViewModel, + didChangeTo state: ImageGalleryViewModel.State) +} + +final class ImageGalleryViewModel { + enum State: Equatable { + case loadingImages + case loadedImages + case failed + } + + weak var delegate: ImageGalleryViewModelDelegate? + + private(set) var state: State = .loadingImages + private(set) var currentIndex = 0 + + private let imagesService: ImagesService + private let assetService: AssetService + + private var images = [ImageDomainModel]() + private var imageViewerViewModels = [Int: ImageViewerViewModel]() + + // MARK: - Init + + init(imagesService: ImagesService = DefaultImagesService(), + assetService: AssetService = DefaultAssetService()) { + self.imagesService = imagesService + self.assetService = assetService + } + + // MARK: - Load + + func load() { + transition(to: .loadingImages) + + imagesService.retrieveImages(callbackQueue: .main) { [weak self] (result) in + guard let self = self else { + return + } + + switch result { + case .success(let images): + self.images = images + self.imageViewerViewModels.removeAll() + self.currentIndex = 0 + + self.transition(to: .loadedImages) + + self.viewModel(at: self.currentIndex)?.load() + case .failure(_): + self.transition(to: .failed) + } + } + } + + // MARK: - Pages + + var numberOfImages: Int { + return images.count + } + + func viewModel(at index: Int) -> ImageViewerViewModel? { + guard index >= 0 && index < images.count else { + return nil + } + + if let existingViewModel = imageViewerViewModels[index] { + return existingViewModel + } + + let viewModel = ImageViewerViewModel(imageDomainModel: images[index], + assetService: assetService) + imageViewerViewModels[index] = viewModel + + return viewModel + } + + // MARK: - Move + + func moveTo(index: Int) { + guard index != currentIndex, + index >= 0, + index < images.count else { + return + } + + imageViewerViewModels[currentIndex]?.pause() + + currentIndex = index + + viewModel(at: index)?.load() + } + + // MARK: - State + + private func transition(to state: State) { + self.state = state + + delegate?.viewModel(self, didChangeTo: state) + } +} diff --git a/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift b/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift index 5980cf7..f45e412 100644 --- a/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift +++ b/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift @@ -13,7 +13,25 @@ class ImageViewerViewController: UIViewController { @IBOutlet weak var descriptionLabel: UILabel! @IBOutlet weak var loadingActivityIndicator: UIActivityIndicatorView! - private let viewModel = ImageViewerViewModel() + private(set) var index = 0 + private var viewModel: ImageViewerViewModel! + + // MARK: - Instantiation + + static func instantiate(viewModel: ImageViewerViewModel, + index: Int) -> ImageViewerViewController { + let storyboard = UIStoryboard(name: "Main", bundle: nil) + let identifier = String(describing: ImageViewerViewController.self) + + guard let viewController = storyboard.instantiateViewController(withIdentifier: identifier) as? ImageViewerViewController else { + fatalError("Expected \(identifier) to be in Main.storyboard") + } + + viewController.viewModel = viewModel + viewController.index = index + + return viewController + } // MARK: - ViewLifecycle @@ -21,26 +39,21 @@ class ImageViewerViewController: UIViewController { super.viewDidLoad() viewModel.delegate = self - viewModel.load() - } - - // MARK: - GestureRecognizer - - @IBAction func didTap(_ sender: Any) { - viewModel.advance() + + /* This page may well be being rebuilt around a view model that is already + loading or loaded, so render what is there rather than waiting for a change. + */ + render(viewModel.state) } -} - -extension ImageViewerViewController: ImageViewerViewModelDelegate { - // MARK: - ImageViewerViewModelDelegate + // MARK: - Render - func viewModel(_ viewModel: ImageViewerViewModel, - didChangeTo state: ImageViewerViewModel.State) { + private func render(_ state: ImageViewerViewModel.State) { switch state { - case .loadingImages: - loadingActivityIndicator.startAnimating() + case .ready(let description): + loadingActivityIndicator.stopAnimating() assetImageView.image = nil + descriptionLabel.text = description case .loadingAsset(let description): loadingActivityIndicator.startAnimating() assetImageView.image = nil @@ -56,3 +69,13 @@ extension ImageViewerViewController: ImageViewerViewModelDelegate { } } } + +extension ImageViewerViewController: ImageViewerViewModelDelegate { + + // MARK: - ImageViewerViewModelDelegate + + func viewModel(_ viewModel: ImageViewerViewModel, + didChangeTo state: ImageViewerViewModel.State) { + render(state) + } +} diff --git a/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewModel.swift b/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewModel.swift index 64a7840..d127120 100644 --- a/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewModel.swift +++ b/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewModel.swift @@ -15,7 +15,7 @@ protocol ImageViewerViewModelDelegate: AnyObject { final class ImageViewerViewModel { enum State: Equatable { - case loadingImages + case ready(description: String) case loadingAsset(description: String) case loadedAsset(UIImage, description: String) case failed @@ -23,105 +23,93 @@ final class ImageViewerViewModel { weak var delegate: ImageViewerViewModelDelegate? - private(set) var state: State = .loadingImages + private(set) var state: State + + let imageDomainModel: ImageDomainModel - private let imagesService: ImagesService private let assetService: AssetService - private var images = [ImageDomainModel]() - private var index = 0 + //the download this view model started, so a pause targets its own and nobody else's + private var downloadToken: DownloadToken? // MARK: - Init - init(imagesService: ImagesService = DefaultImagesService(), + init(imageDomainModel: ImageDomainModel, assetService: AssetService = DefaultAssetService()) { - self.imagesService = imagesService + self.imageDomainModel = imageDomainModel self.assetService = assetService - } - - // MARK: - Current - - private var currentImage: ImageDomainModel? { - guard index < images.count else { - return nil - } - - return images[index] + self.state = .ready(description: imageDomainModel.url.absoluteString) } // MARK: - Load func load() { - transition(to: .loadingImages) - - imagesService.retrieveImages(callbackQueue: .main) { [weak self] (result) in - guard let self = self else { - return - } - - switch result { - case .success(let images): - self.images = images - self.loadCurrentAsset() - case .failure(_): - self.transition(to: .failed) - } - } - } - - // MARK: - Advance - - func advance() { - cancelCurrentAssetLoad() - index += 1 - loadCurrentAsset() - } - - // MARK: - Asset - - private func loadCurrentAsset() { - guard let image = currentImage else { + /* Returning to an image that has already downloaded shouldn't tear the + asset back off screen, and one that is already in flight is being taken + care of by the download session. + */ + guard !isLoaded && !isLoading else { return } - transition(to: .loadingAsset(description: image.url.absoluteString)) + transition(to: .loadingAsset(description: imageDomainModel.url.absoluteString)) - assetService.loadImage(image, callbackQueue: .main) { [weak self] (result) in + downloadToken = assetService.loadImage(imageDomainModel, callbackQueue: .main) { [weak self] (result) in guard let self = self else { return } - guard let currentImage = self.currentImage else { - return - } - switch result { case .success(let loadResult): - //a stale download for an image we have already moved past - guard loadResult.imageDomainModel == currentImage else { + //a stale download for an image this view model no longer represents + guard loadResult.imageDomainModel == self.imageDomainModel else { return } - self.transition(to: .loadedAsset(loadResult.image, description: currentImage.url.absoluteString)) + self.transition(to: .loadedAsset(loadResult.image, description: self.imageDomainModel.url.absoluteString)) case .failure(_): self.transition(to: .failed) } } } - private func cancelCurrentAssetLoad() { - guard let image = currentImage else { + // MARK: - Pause + + func pause() { + guard isLoading, + let downloadToken = downloadToken else { return } - assetService.cancelLoadingImage(image) + assetService.cancelLoadingImage(downloadToken) + + self.downloadToken = nil + + transition(to: .ready(description: imageDomainModel.url.absoluteString)) } // MARK: - State + private var isLoaded: Bool { + guard case .loadedAsset = state else { + return false + } + + return true + } + + private var isLoading: Bool { + guard case .loadingAsset = state else { + return false + } + + return true + } + private func transition(to state: State) { self.state = state delegate?.viewModel(self, didChangeTo: state) } + } diff --git a/PausableDownloads-ExampleTests/Doubles/StubAssetService.swift b/PausableDownloads-ExampleTests/Doubles/StubAssetService.swift index a6fcb58..dd0eeeb 100644 --- a/PausableDownloads-ExampleTests/Doubles/StubAssetService.swift +++ b/PausableDownloads-ExampleTests/Doubles/StubAssetService.swift @@ -13,18 +13,27 @@ import Foundation final class StubAssetService: AssetService { enum Event { case loadImage(ImageDomainModel, DispatchQueue, (Result) -> ()) - case cancelLoadingImage(ImageDomainModel) + case cancelLoadingImage(DownloadToken) } private(set) var events = [Event]() + //a fresh id per load, in issue order, so a test can say which download was cancelled + private(set) var issuedDownloadIDs = [DownloadToken]() + + @discardableResult func loadImage(_ imageDomainModel: ImageDomainModel, callbackQueue: DispatchQueue, - completionHandler: @escaping (Result) -> ()) { + completionHandler: @escaping (Result) -> ()) -> DownloadToken? { events.append(.loadImage(imageDomainModel, callbackQueue, completionHandler)) + + let downloadID = DownloadToken(url: imageDomainModel.url) + issuedDownloadIDs.append(downloadID) + + return downloadID } - func cancelLoadingImage(_ imageDomainModel: ImageDomainModel) { - events.append(.cancelLoadingImage(imageDomainModel)) + func cancelLoadingImage(_ downloadID: DownloadToken) { + events.append(.cancelLoadingImage(downloadID)) } } diff --git a/PausableDownloads-ExampleTests/Doubles/StubImageGalleryViewModelDelegate.swift b/PausableDownloads-ExampleTests/Doubles/StubImageGalleryViewModelDelegate.swift new file mode 100644 index 0000000..dfd8599 --- /dev/null +++ b/PausableDownloads-ExampleTests/Doubles/StubImageGalleryViewModelDelegate.swift @@ -0,0 +1,24 @@ +// +// StubImageGalleryViewModelDelegate.swift +// PausableDownloads-ExampleTests +// +// Created by William Boles on 09/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import Foundation + +@testable import PausableDownloads_Example + +final class StubImageGalleryViewModelDelegate: ImageGalleryViewModelDelegate { + enum Event { + case didChangeTo(ImageGalleryViewModel.State) + } + + private(set) var events = [Event]() + + func viewModel(_ viewModel: ImageGalleryViewModel, + didChangeTo state: ImageGalleryViewModel.State) { + events.append(.didChangeTo(state)) + } +} diff --git a/PausableDownloads-ExampleTests/Doubles/StubNotificationCenter.swift b/PausableDownloads-ExampleTests/Doubles/StubNotificationCenter.swift index 3620106..58eeeeb 100644 --- a/PausableDownloads-ExampleTests/Doubles/StubNotificationCenter.swift +++ b/PausableDownloads-ExampleTests/Doubles/StubNotificationCenter.swift @@ -17,7 +17,7 @@ class StubNotificationCenter: NotificationCenterType { private(set) var events = [Event]() - var objectToReturn: NSObjectProtocol! + var objectToReturn: NSObjectProtocol! = NSObject() func addObserver(forName name: NSNotification.Name?, object obj: Any?, diff --git a/PausableDownloads-ExampleTests/Doubles/StubURLSession.swift b/PausableDownloads-ExampleTests/Doubles/StubURLSession.swift index 9f1826e..bc653b1 100644 --- a/PausableDownloads-ExampleTests/Doubles/StubURLSession.swift +++ b/PausableDownloads-ExampleTests/Doubles/StubURLSession.swift @@ -12,8 +12,8 @@ import Foundation class StubURLSession: URLSessionType { enum Event { - case downloadTask(URL, (URL?, URLResponse?, Error?) -> Void) - case downloadTaskWithResumeData(Data, (URL?, URLResponse?, Error?) -> Void) + case downloadTask(URL) + case downloadTaskWithResumeData(Data) } private(set) var events = [Event]() @@ -21,16 +21,27 @@ class StubURLSession: URLSessionType { var downloadTaskToReturn: StubURLSessionDownloadTask! var downloadTaskWithResumeDataToReturn: StubURLSessionDownloadTask! - func downloadTask(with url: URL, completionHandler: @escaping (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTaskType { - events.append(.downloadTask(url, completionHandler)) + //when several downloads are in flight at once they need distinct tasks - each call + //takes the next one from here before falling back to the single stubs above + var downloadTasksToReturn = [StubURLSessionDownloadTask]() + + func downloadTask(with url: URL) -> URLSessionDownloadTaskType { + events.append(.downloadTask(url)) + + return nextDownloadTask() ?? downloadTaskToReturn + } + + func downloadTask(withResumeData resumeData: Data) -> URLSessionDownloadTaskType { + events.append(.downloadTaskWithResumeData(resumeData)) - return downloadTaskToReturn + return nextDownloadTask() ?? downloadTaskWithResumeDataToReturn } - func downloadTask(withResumeData resumeData: Data, - completionHandler: @escaping (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTaskType { - events.append(.downloadTaskWithResumeData(resumeData, completionHandler)) + private func nextDownloadTask() -> StubURLSessionDownloadTask? { + guard !downloadTasksToReturn.isEmpty else { + return nil + } - return downloadTaskWithResumeDataToReturn + return downloadTasksToReturn.removeFirst() } } diff --git a/PausableDownloads-ExampleTests/Doubles/StubURLSessionDownloadTask.swift b/PausableDownloads-ExampleTests/Doubles/StubURLSessionDownloadTask.swift index 788c702..7b0f9ac 100644 --- a/PausableDownloads-ExampleTests/Doubles/StubURLSessionDownloadTask.swift +++ b/PausableDownloads-ExampleTests/Doubles/StubURLSessionDownloadTask.swift @@ -17,9 +17,24 @@ class StubURLSessionDownloadTask: URLSessionDownloadTaskType { case cancelByProducingResumeData((Data?) -> Void) } + private static var lastTaskIdentifier = 0 + private(set) var events = [Event]() - var progress: Progress = Progress() + let taskIdentifier: Int + + //set to report resumption data back on the thread that cancelled, rather than + //handing the closure back to the test to call later + var resumptionDataToProduceSynchronously: Data? + + // MARK: - Init + + init() { + StubURLSessionDownloadTask.lastTaskIdentifier += 1 + taskIdentifier = StubURLSessionDownloadTask.lastTaskIdentifier + } + + // MARK: - Task func resume() { events.append(.resume) @@ -31,5 +46,9 @@ class StubURLSessionDownloadTask: URLSessionDownloadTaskType { func cancel(byProducingResumeData completionHandler: @escaping (Data?) -> Void) { events.append(.cancelByProducingResumeData(completionHandler)) + + if let resumptionDataToProduceSynchronously = resumptionDataToProduceSynchronously { + completionHandler(resumptionDataToProduceSynchronously) + } } } diff --git a/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift b/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift index bf56580..00e8ed1 100644 --- a/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift +++ b/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift @@ -17,14 +17,10 @@ class AssetDownloadsSessionTests: XCTestCase { // MARK: Init func test_givenURLSessionFactory_whenInitialised_thenDefaultSessionIsCreatedWithSelfAsDelegateAndNoQueue() { - let notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() - let sessionFactory = StubURLSessionFactory() sessionFactory.sessionToReturn = StubURLSession() - let sut = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) + let sut = createSUT(urlSessionFactory: sessionFactory) XCTAssertEqual(sessionFactory.events.count, 1) @@ -43,11 +39,7 @@ class AssetDownloadsSessionTests: XCTestCase { let notificationCenter = StubNotificationCenter() notificationCenter.objectToReturn = NSObject() - let sessionFactory = StubURLSessionFactory() - sessionFactory.sessionToReturn = StubURLSession() - - _ = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) + _ = createSUT(notificationCenter: notificationCenter) XCTAssertEqual(notificationCenter.events.count, 1) @@ -61,18 +53,14 @@ class AssetDownloadsSessionTests: XCTestCase { XCTAssertTrue(queue === OperationQueue.main) } - func test_givenPausedDownload_whenMemoryWarningNotificationIsReceived_thenDownloadTaskIsCancelled() { + func test_givenPausedDownload_whenMemoryWarningNotificationIsReceived_thenTheDownloadIsDiscarded() { let url = URL(string: "http://test.com/example")! let notificationCenter = StubNotificationCenter() notificationCenter.objectToReturn = NSObject() - let sessionFactory = StubURLSessionFactory() let session = StubURLSession() - sessionFactory.sessionToReturn = session - - let sut = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) + let sut = createSUT(session: session, notificationCenter: notificationCenter) guard case let .addObserver(_, _, _, notificationBlock) = notificationCenter.events.first else { XCTFail("Unexpected event") @@ -82,26 +70,33 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - sut.scheduleDownload(url: url) { _ in } + let downloadID = sut.scheduleDownload(url: url) { _ in } XCTAssertEqual(session.events.count, 1) - sut.cancelDownload(url: url) + sut.pauseDownload(downloadID) XCTAssertEqual(downloadTask.events.count, 2) - guard case .cancelByProducingResumeData = downloadTask.events.last else { + guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { XCTFail("Unexpected event") return } + resumeDataHandler(Data("resumption".utf8)) + let notification = Notification(name: UIApplication.didReceiveMemoryWarningNotification) notificationBlock(notification) - XCTAssertEqual(downloadTask.events.count, 3) + //the purged item took its resumption data with it, so the next schedule starts over + session.downloadTaskWithResumeDataToReturn = StubURLSessionDownloadTask() - guard case .cancel = downloadTask.events.last else { - XCTFail("Unexpected event") + sut.scheduleDownload(url: url) { _ in } + + XCTAssertEqual(session.events.count, 2) + + guard case .downloadTask = session.events.last else { + XCTFail("Expected a new download task rather than a resumed one") return } } @@ -112,12 +107,8 @@ class AssetDownloadsSessionTests: XCTestCase { let notificationCenter = StubNotificationCenter() notificationCenter.objectToReturn = NSObject() - let sessionFactory = StubURLSessionFactory() let session = StubURLSession() - sessionFactory.sessionToReturn = session - - let sut = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) + let sut = createSUT(session: session, notificationCenter: notificationCenter) let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask @@ -147,15 +138,8 @@ class AssetDownloadsSessionTests: XCTestCase { func test_givenNoExistingDownload_whenScheduleDownloadIsCalled_thenDownloadTaskIsCreatedForURLAndResumed() { let url = URL(string: "http://test.com/example")! - let notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() - - let sessionFactory = StubURLSessionFactory() let session = StubURLSession() - sessionFactory.sessionToReturn = session - - let sut = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) + let sut = createSUT(session: session) let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask @@ -171,7 +155,7 @@ class AssetDownloadsSessionTests: XCTestCase { XCTAssertEqual(session.events.count, 1) - guard case let .downloadTask(downloadTaskURL, _) = session.events.first else { + guard case let .downloadTask(downloadTaskURL) = session.events.first else { XCTFail("Unexpected event") return } @@ -180,15 +164,8 @@ class AssetDownloadsSessionTests: XCTestCase { } func test_givenNoExistingDownloads_whenScheduleDownloadIsCalledForTwoDifferentURLs_thenBothDownloadTasksAreResumed() { - let notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() - - let sessionFactory = StubURLSessionFactory() let session = StubURLSession() - sessionFactory.sessionToReturn = session - - let sut = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) + let sut = createSUT(session: session) let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask @@ -198,7 +175,7 @@ class AssetDownloadsSessionTests: XCTestCase { sut.scheduleDownload(url: urlA) { _ in } sut.scheduleDownload(url: urlB) { _ in } - + XCTAssertEqual(downloadTask.events.count, 2) guard case .resume = downloadTask.events.first, @@ -207,168 +184,264 @@ class AssetDownloadsSessionTests: XCTestCase { return } } - - func test_givenInFlightDownload_whenScheduleDownloadIsCalledForTheSameURL_thenNoSecondTaskIsCreatedAndBothCompletionHandlersAreCalled() { + + func test_givenInFlightDownload_whenScheduleDownloadIsCalledForTheSameURL_thenOneDownloadIsSharedAndBothCompletionHandlersAreCalled() { let url = URL(string: "http://test.com/example")! - let notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() + let session = StubURLSession() + let sut = createSUT(session: session) + + let downloadTask = StubURLSessionDownloadTask() + session.downloadTaskToReturn = downloadTask + + var firstResults = [Result]() + sut.scheduleDownload(url: url) { firstResults.append($0) } + + var secondResults = [Result]() + sut.scheduleDownload(url: url) { secondResults.append($0) } + + //a second caller coalesces onto the download that's already running + XCTAssertEqual(session.events.count, 1) + XCTAssertEqual(downloadTask.events.count, 1) + + sut.handleComplete(forTaskWith: downloadTask.taskIdentifier, error: TestError.test) + + XCTAssertEqual(firstResults.count, 1) + XCTAssertEqual(secondResults.count, 1) + } + + func test_givenTwoCallersForTheSameURL_whenOneIsPaused_thenTheSharedTaskIsNotCancelledAndTheOtherIsStillAnswered() { + let url = URL(string: "http://test.com/example")! - let sessionFactory = StubURLSessionFactory() let session = StubURLSession() - sessionFactory.sessionToReturn = session + let sut = createSUT(session: session) + + let downloadTask = StubURLSessionDownloadTask() + session.downloadTaskToReturn = downloadTask + + var firstResults = [Result]() + let firstDownloadToken = sut.scheduleDownload(url: url) { firstResults.append($0) } + + var secondResults = [Result]() + sut.scheduleDownload(url: url) { secondResults.append($0) } + + sut.pauseDownload(firstDownloadToken) + + //the second caller still wants this URL, so the shared task keeps running + XCTAssertEqual(downloadTask.events.count, 1) + + guard case .resume = downloadTask.events.last else { + XCTFail("Expected the shared download not to be cancelled") + return + } + + sut.handleComplete(forTaskWith: downloadTask.taskIdentifier, error: TestError.test) + + XCTAssertEqual(secondResults.count, 1) + XCTAssertTrue(firstResults.isEmpty) + } + + func test_givenPausedDownloadThatProducedNoResumptionData_whenScheduleDownloadIsCalledForTheSameURL_thenTheDownloadRestarts() { + let url = URL(string: "http://test.com/example")! - let sut = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) + let session = StubURLSession() + let sut = createSUT(session: session) let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - let firstCompletionExpectation = expectation(description: "firstCompletionExpectation") - sut.scheduleDownload(url: url) { (_) in - firstCompletionExpectation.fulfill() + let downloadID = sut.scheduleDownload(url: url) { _ in } + sut.pauseDownload(downloadID) + + XCTAssertEqual(downloadTask.events.count, 2) + + guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { + XCTFail("Unexpected event") + return + } + + //a server that can't resume hands back no data, so starting over is all that's left + resumeDataHandler(nil) + + sut.scheduleDownload(url: url) { _ in } + + XCTAssertEqual(session.events.count, 2) + + guard case .downloadTask = session.events.last else { + XCTFail("Unexpected event") + return } - let secondCompletionExpectation = expectation(description: "secondCompletionExpectation") - sut.scheduleDownload(url: url) { (_) in - secondCompletionExpectation.fulfill() + XCTAssertEqual(downloadTask.events.count, 3) + + guard case .resume = downloadTask.events.last else { + XCTFail("Unexpected event") + return } + } + + func test_givenPauseStillProducingResumptionData_whenScheduleDownloadIsCalledForTheSameURL_thenTheResumeWaitsForTheResumptionData() { + let url = URL(string: "http://test.com/example")! + let resumptionData = Data("resumption".utf8) - XCTAssertEqual(downloadTask.events.count, 1) + let session = StubURLSession() + let sut = createSUT(session: session) - guard case .resume = downloadTask.events.first else { + let downloadTask = StubURLSessionDownloadTask() + session.downloadTaskToReturn = downloadTask + + let downloadID = sut.scheduleDownload(url: url) { _ in } + sut.pauseDownload(downloadID) + + guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { XCTFail("Unexpected event") return } + let resumedDownloadTask = StubURLSessionDownloadTask() + session.downloadTaskWithResumeDataToReturn = resumedDownloadTask + + //rescheduling whilst the resumption data is still in flight - the fast swipe back + sut.scheduleDownload(url: url) { _ in } + XCTAssertEqual(session.events.count, 1) - guard case let .downloadTask(_, completionHandler) = session.events.first else { + resumeDataHandler(resumptionData) + + XCTAssertEqual(session.events.count, 2) + + guard case let .downloadTaskWithResumeData(data) = session.events.last else { XCTFail("Unexpected event") return } - completionHandler(nil, nil, nil) + XCTAssertEqual(data, resumptionData) - waitForExpectations(timeout: 3, handler: nil) + XCTAssertEqual(resumedDownloadTask.events.count, 1) + + guard case .resume = resumedDownloadTask.events.first else { + XCTFail("Unexpected event") + return + } } - func test_givenPausedDownload_whenScheduleDownloadIsCalledForTheSameURL_thenExistingDownloadTaskIsResumed() { + func test_givenACallerThatJoinedAPauseInFlight_whenItPausesBeforeTheResumptionDataLands_thenNoTaskIsEverStarted() { let url = URL(string: "http://test.com/example")! - let notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() - - let sessionFactory = StubURLSessionFactory() let session = StubURLSession() - sessionFactory.sessionToReturn = session - - let sut = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) + let sut = createSUT(session: session) let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - - sut.scheduleDownload(url: url) { _ in } - sut.cancelDownload(url: url) + session.downloadTaskWithResumeDataToReturn = StubURLSessionDownloadTask() - XCTAssertEqual(downloadTask.events.count, 2) + let firstDownloadID = sut.scheduleDownload(url: url) { _ in } + sut.pauseDownload(firstDownloadID) - guard case .cancelByProducingResumeData = downloadTask.events.last else { + guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { XCTFail("Unexpected event") return } - sut.scheduleDownload(url: url) { _ in } + //scheduled whilst the pause is still in flight, so it joins rather than starting a task + let joinedDownloadToken = sut.scheduleDownload(url: url) { _ in } - XCTAssertEqual(downloadTask.events.count, 3) + XCTAssertEqual(session.events.count, 1) - guard case .resume = downloadTask.events.last else { + sut.pauseDownload(joinedDownloadToken) + + resumeDataHandler(Data("resumption".utf8)) + + //nothing was waiting on the data by the time it landed + XCTAssertEqual(session.events.count, 1) + } + + func test_givenPausedDownload_whenTheCancelledDownloadTaskCompletes_thenTheCompletionHandlerIsNotCalled() { + let url = URL(string: "http://test.com/example")! + + let session = StubURLSession() + let sut = createSUT(session: session) + + let downloadTask = StubURLSessionDownloadTask() + session.downloadTaskToReturn = downloadTask + + var results = [Result]() + let downloadID = sut.scheduleDownload(url: url) { results.append($0) } + + guard case .downloadTask = session.events.first else { XCTFail("Unexpected event") return } + + sut.pauseDownload(downloadID) + + //pausing cancels the underlying task, which reports back as a cancellation error + sut.handleComplete(forTaskWith: downloadTask.taskIdentifier, error: URLError(.cancelled)) + + XCTAssertTrue(results.isEmpty) } - + func test_givenCompletedDownload_whenScheduleDownloadIsCalledForTheSameURL_thenANewDownloadTaskIsCreated() { let url = URL(string: "http://test.com/example")! - let notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() - - let sessionFactory = StubURLSessionFactory() let session = StubURLSession() - sessionFactory.sessionToReturn = session - - let sut = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) + let sut = createSUT(session: session) - session.downloadTaskToReturn = StubURLSessionDownloadTask() + let downloadTask = StubURLSessionDownloadTask() + session.downloadTaskToReturn = downloadTask - sut.scheduleDownload(url: url) { (_) in } + sut.scheduleDownload(url: url) { _ in } XCTAssertEqual(session.events.count, 1) - guard case let .downloadTask(_, completionHandler) = session.events.first else { + guard case .downloadTask = session.events.first else { XCTFail("Unexpected event") return } - completionHandler(nil, nil, nil) + sut.handleComplete(forTaskWith: downloadTask.taskIdentifier, error: nil) - sut.scheduleDownload(url: url) { (_) in } + sut.scheduleDownload(url: url) { _ in } XCTAssertEqual(session.events.count, 2) } - + func test_givenScheduledDownload_whenTheDownloadTaskCompletes_thenTheCompletionHandlerIsCalled() { let url = URL(string: "http://test.com/example")! - let notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() - - let sessionFactory = StubURLSessionFactory() let session = StubURLSession() - sessionFactory.sessionToReturn = session - - let sut = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) + let sut = createSUT(session: session) - session.downloadTaskToReturn = StubURLSessionDownloadTask() + let downloadTask = StubURLSessionDownloadTask() + session.downloadTaskToReturn = downloadTask let completionExpectation = expectation(description: "completionExpectation") - sut.scheduleDownload(url: url) { (_) in + sut.scheduleDownload(url: url) { _ in completionExpectation.fulfill() } - - guard case let .downloadTask(_, completionHandler) = session.events.first else { + + guard case .downloadTask = session.events.first else { XCTFail("Unexpected event") return } - - completionHandler(nil, nil, nil) - + + sut.handleComplete(forTaskWith: downloadTask.taskIdentifier, error: nil) + waitForExpectations(timeout: 3, handler: nil) } - + func test_givenPausedDownloadWithResumptionData_whenScheduleDownloadIsCalledForTheSameURL_thenDownloadTaskIsCreatedFromResumeData() { let url = URL(string: "http://test.com/example")! let resumptionData = Data("resumption".utf8) - let notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() - - let sessionFactory = StubURLSessionFactory() let session = StubURLSession() - sessionFactory.sessionToReturn = session - - let sut = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) + let sut = createSUT(session: session) let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - sut.scheduleDownload(url: url) { _ in } - sut.cancelDownload(url: url) + let downloadID = sut.scheduleDownload(url: url) { _ in } + sut.pauseDownload(downloadID) guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { XCTFail("Unexpected event") @@ -384,7 +457,7 @@ class AssetDownloadsSessionTests: XCTestCase { XCTAssertEqual(session.events.count, 2) - guard case let .downloadTaskWithResumeData(data, _) = session.events.last else { + guard case let .downloadTaskWithResumeData(data) = session.events.last else { XCTFail("Unexpected event") return } @@ -405,17 +478,11 @@ class AssetDownloadsSessionTests: XCTestCase { XCTAssertFalse(expectedData.isEmpty) - let notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() - - let sessionFactory = StubURLSessionFactory() let session = StubURLSession() - sessionFactory.sessionToReturn = session + let sut = createSUT(session: session) - let sut = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) - - session.downloadTaskToReturn = StubURLSessionDownloadTask() + let downloadTask = StubURLSessionDownloadTask() + session.downloadTaskToReturn = downloadTask var receivedResult: Result? let completionExpectation = expectation(description: "completionExpectation") @@ -424,12 +491,13 @@ class AssetDownloadsSessionTests: XCTestCase { completionExpectation.fulfill() } - guard case let .downloadTask(_, completionHandler) = session.events.first else { + guard case .downloadTask = session.events.first else { XCTFail("Unexpected event") return } - completionHandler(fileURL, nil, nil) + sut.handleFinishedDownloading(forTaskWith: downloadTask.taskIdentifier, to: fileURL) + sut.handleComplete(forTaskWith: downloadTask.taskIdentifier, error: nil) waitForExpectations(timeout: 3, handler: nil) @@ -444,17 +512,11 @@ class AssetDownloadsSessionTests: XCTestCase { func test_givenScheduledDownload_whenTheDownloadTaskCompletesWithAnError_thenTheCompletionHandlerReceivesARetrievalFailure() throws { let url = URL(string: "http://test.com/example")! - let notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() - - let sessionFactory = StubURLSessionFactory() let session = StubURLSession() - sessionFactory.sessionToReturn = session - - let sut = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) + let sut = createSUT(session: session) - session.downloadTaskToReturn = StubURLSessionDownloadTask() + let downloadTask = StubURLSessionDownloadTask() + session.downloadTaskToReturn = downloadTask var receivedResult: Result? let completionExpectation = expectation(description: "completionExpectation") @@ -463,12 +525,12 @@ class AssetDownloadsSessionTests: XCTestCase { completionExpectation.fulfill() } - guard case let .downloadTask(_, completionHandler) = session.events.first else { + guard case .downloadTask = session.events.first else { XCTFail("Unexpected event") return } - completionHandler(nil, nil, TestError.test) + sut.handleComplete(forTaskWith: downloadTask.taskIdentifier, error: TestError.test) waitForExpectations(timeout: 3, handler: nil) @@ -485,17 +547,11 @@ class AssetDownloadsSessionTests: XCTestCase { let url = URL(string: "http://test.com/example")! let unreadableFileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("does-not-exist-\(UUID().uuidString)") - let notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() - - let sessionFactory = StubURLSessionFactory() let session = StubURLSession() - sessionFactory.sessionToReturn = session - - let sut = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) + let sut = createSUT(session: session) - session.downloadTaskToReturn = StubURLSessionDownloadTask() + let downloadTask = StubURLSessionDownloadTask() + session.downloadTaskToReturn = downloadTask var receivedResult: Result? let completionExpectation = expectation(description: "completionExpectation") @@ -504,12 +560,13 @@ class AssetDownloadsSessionTests: XCTestCase { completionExpectation.fulfill() } - guard case let .downloadTask(_, completionHandler) = session.events.first else { + guard case .downloadTask = session.events.first else { XCTFail("Unexpected event") return } - completionHandler(unreadableFileURL, nil, nil) + sut.handleFinishedDownloading(forTaskWith: downloadTask.taskIdentifier, to: unreadableFileURL) + sut.handleComplete(forTaskWith: downloadTask.taskIdentifier, error: nil) waitForExpectations(timeout: 3, handler: nil) @@ -520,49 +577,161 @@ class AssetDownloadsSessionTests: XCTestCase { } } - func test_givenTwoCoalescedDownloads_whenTheDownloadTaskCompletes_thenBothCompletionHandlersReceiveTheSameData() throws { + func test_givenPauseThenResume_whenTheCancelledDownloadTaskCompletes_thenItIsIgnoredAndTheResumedTaskStillCompletes() { let url = URL(string: "http://test.com/example")! - let fileURL = try XCTUnwrap(Bundle(for: type(of: self)).url(forResource: "square", withExtension: "pdf")) - let expectedData = try Data(contentsOf: fileURL) - let notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() - - let sessionFactory = StubURLSessionFactory() let session = StubURLSession() - sessionFactory.sessionToReturn = session + let sut = createSUT(session: session) - let sut = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) + let retiredDownloadTask = StubURLSessionDownloadTask() + session.downloadTaskToReturn = retiredDownloadTask - session.downloadTaskToReturn = StubURLSessionDownloadTask() + let downloadID = sut.scheduleDownload(url: url) { _ in } + sut.pauseDownload(downloadID) - var firstReceivedResult: Result? - let firstCompletionExpectation = expectation(description: "firstCompletionExpectation") - sut.scheduleDownload(url: url) { (result) in - firstReceivedResult = result - firstCompletionExpectation.fulfill() + guard case let .cancelByProducingResumeData(resumeDataHandler) = retiredDownloadTask.events.last else { + XCTFail("Unexpected event") + return } - var secondReceivedResult: Result? - let secondCompletionExpectation = expectation(description: "secondCompletionExpectation") - sut.scheduleDownload(url: url) { (result) in - secondReceivedResult = result - secondCompletionExpectation.fulfill() + resumeDataHandler(Data("resumption".utf8)) + + let resumedDownloadTask = StubURLSessionDownloadTask() + session.downloadTaskWithResumeDataToReturn = resumedDownloadTask + + var results = [Result]() + sut.scheduleDownload(url: url) { results.append($0) } + + //the task the pause retired winds down late and must not be mistaken for this download + sut.handleComplete(forTaskWith: retiredDownloadTask.taskIdentifier, error: URLError(.cancelled)) + + XCTAssertTrue(results.isEmpty) + + sut.handleComplete(forTaskWith: resumedDownloadTask.taskIdentifier, error: nil) + + XCTAssertEqual(results.count, 1) + } + + func test_givenNoMatchingDownload_whenAnEventForAnUnknownTaskIsReceived_thenItIsIgnored() { + let url = URL(string: "http://test.com/example")! + + let session = StubURLSession() + let sut = createSUT(session: session) + + let downloadTask = StubURLSessionDownloadTask() + session.downloadTaskToReturn = downloadTask + + var results = [Result]() + sut.scheduleDownload(url: url) { results.append($0) } + + let unknownURL = URL(string: "http://test.com/unknown")! + let unknownTaskIdentifier = downloadTask.taskIdentifier + 1000 + + sut.handleProgress(for: unknownURL, totalBytesWritten: 50, expectedTotalBytes: 100) + sut.handleResumption(for: unknownURL, fileOffset: 50, expectedTotalBytes: 100) + sut.handleFinishedDownloading(forTaskWith: unknownTaskIdentifier, to: URL(fileURLWithPath: "/dev/null")) + sut.handleComplete(forTaskWith: unknownTaskIdentifier, error: nil) + + XCTAssertTrue(results.isEmpty) + } + + // MARK: Cancel + + func test_givenScheduledDownload_whenCancelDownloadIsCalled_thenDownloadTaskIsCancelledByProducingResumeData() { + let url = URL(string: "http://test.com/example")! + + let session = StubURLSession() + let sut = createSUT(session: session) + + let downloadTask = StubURLSessionDownloadTask() + session.downloadTaskToReturn = downloadTask + + let downloadID = sut.scheduleDownload(url: url) { _ in } + sut.pauseDownload(downloadID) + + XCTAssertEqual(downloadTask.events.count, 2) + + guard case .cancelByProducingResumeData = downloadTask.events.last else { + XCTFail("Unexpected event") + return } + } + + func test_givenScheduledDownload_whenTheCancelledTaskProducesResumptionDataSynchronously_thenTheResumptionDataIsStored() { + let url = URL(string: "http://test.com/example")! + let resumptionData = Data("resumption".utf8) + + let session = StubURLSession() + let sut = createSUT(session: session) + + let downloadTask = StubURLSessionDownloadTask() + downloadTask.resumptionDataToProduceSynchronously = resumptionData + session.downloadTaskToReturn = downloadTask + + let downloadID = sut.scheduleDownload(url: url) { _ in } + + //a task that reports back on the thread that cancelled it deadlocks anything + //cancelling whilst still holding the downloads queue + sut.pauseDownload(downloadID) + + session.downloadTaskWithResumeDataToReturn = StubURLSessionDownloadTask() + + sut.scheduleDownload(url: url) { _ in } - guard case let .downloadTask(_, completionHandler) = session.events.first else { + XCTAssertEqual(session.events.count, 2) + + guard case let .downloadTaskWithResumeData(data) = session.events.last else { XCTFail("Unexpected event") return } - completionHandler(fileURL, nil, nil) + XCTAssertEqual(data, resumptionData) + } + + func test_givenNoScheduledDownloads_whenCancelDownloadIsCalledForAnUnknownID_thenNoDownloadTaskEventsAreRecorded() { + let url = URL(string: "http://test.com/example")! + + let session = StubURLSession() + let sut = createSUT(session: session) - waitForExpectations(timeout: 3, handler: nil) + let downloadTask = StubURLSessionDownloadTask() + session.downloadTaskToReturn = downloadTask + + sut.pauseDownload(DownloadToken(url: url)) + + XCTAssertTrue(session.events.isEmpty) + XCTAssertTrue(downloadTask.events.isEmpty) + } + + // MARK: - Coalescing + + func test_givenTwoCoalescedDownloads_whenTheDownloadTaskCompletes_thenBothCompletionHandlersReceiveTheSameData() throws { + let url = URL(string: "http://test.com/example")! + let fileURL = try XCTUnwrap(Bundle(for: type(of: self)).url(forResource: "square", withExtension: "pdf")) + let expectedData = try Data(contentsOf: fileURL) + + XCTAssertFalse(expectedData.isEmpty) + + let session = StubURLSession() + let sut = createSUT(session: session) + + let downloadTask = StubURLSessionDownloadTask() + session.downloadTaskToReturn = downloadTask + + var firstResult: Result? + sut.scheduleDownload(url: url) { firstResult = $0 } + + var secondResult: Result? + sut.scheduleDownload(url: url) { secondResult = $0 } + + XCTAssertEqual(session.events.count, 1) + + sut.handleFinishedDownloading(forTaskWith: downloadTask.taskIdentifier, to: fileURL) - guard case let .success(firstData) = try XCTUnwrap(firstReceivedResult), - case let .success(secondData) = try XCTUnwrap(secondReceivedResult) else { - XCTFail("Expected both handlers to receive a success result") + //one read of the file, handed to everybody who coalesced onto the download + guard case let .success(firstData) = try XCTUnwrap(firstResult), + case let .success(secondData) = try XCTUnwrap(secondResult) else { + XCTFail("Expected both callers to receive a success result") return } @@ -570,60 +739,209 @@ class AssetDownloadsSessionTests: XCTestCase { XCTAssertEqual(secondData, expectedData) } - // MARK: Cancel - - func test_givenScheduledDownload_whenCancelDownloadIsCalled_thenDownloadTaskIsCancelledByProducingResumeData() { + func test_givenTwoCallersForTheSameURL_whenBothPause_thenTheSharedTaskIsCancelledOnce() { let url = URL(string: "http://test.com/example")! - let notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() - - let sessionFactory = StubURLSessionFactory() let session = StubURLSession() - sessionFactory.sessionToReturn = session - - let sut = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) + let sut = createSUT(session: session) let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - sut.scheduleDownload(url: url) { _ in } - sut.cancelDownload(url: url) - + let firstDownloadToken = sut.scheduleDownload(url: url) { _ in } + let secondDownloadToken = sut.scheduleDownload(url: url) { _ in } + + sut.pauseDownload(firstDownloadToken) + + //somebody still wants it, so nothing is cancelled yet + XCTAssertEqual(downloadTask.events.count, 1) + + sut.pauseDownload(secondDownloadToken) + + //the last interested caller has gone, so the shared task is cancelled exactly once XCTAssertEqual(downloadTask.events.count, 2) guard case .cancelByProducingResumeData = downloadTask.events.last else { + XCTFail("Expected the shared download to be cancelled") + return + } + } + + func test_givenTwoCallersJoinedAPauseInFlight_whenTheResumptionDataLands_thenOnlyOneTaskIsStarted() { + let url = URL(string: "http://test.com/example")! + + let session = StubURLSession() + let sut = createSUT(session: session) + + let downloadTask = StubURLSessionDownloadTask() + session.downloadTaskToReturn = downloadTask + + let firstDownloadToken = sut.scheduleDownload(url: url) { _ in } + sut.pauseDownload(firstDownloadToken) + + guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { XCTFail("Unexpected event") return } + + let resumedTask = StubURLSessionDownloadTask() + session.downloadTaskWithResumeDataToReturn = resumedTask + + //both scheduled whilst the pause is still in flight, so both join it + var secondResults = [Result]() + sut.scheduleDownload(url: url) { secondResults.append($0) } + + var thirdResults = [Result]() + sut.scheduleDownload(url: url) { thirdResults.append($0) } + + XCTAssertEqual(session.events.count, 1) + + resumeDataHandler(Data("resumption".utf8)) + + //one task serves both of them + XCTAssertEqual(session.events.count, 2) + + guard case .downloadTaskWithResumeData = session.events.last else { + XCTFail("Expected a resumed download task") + return + } + + sut.handleComplete(forTaskWith: resumedTask.taskIdentifier, error: TestError.test) + + XCTAssertEqual(secondResults.count, 1) + XCTAssertEqual(thirdResults.count, 1) } - func test_givenNoScheduledDownloads_whenCancelDownloadIsCalledForAnUnknownURL_thenNoDownloadTaskEventsAreRecorded() { - let unknownURL = URL(string: "http://test.com/unknown")! + func test_givenARetiredTaskThatFailsAfterTheDownloadWasResumed_whenItCompletes_thenTheResumedDownloadIsUnaffected() { + let url = URL(string: "http://test.com/example")! + + let session = StubURLSession() + let sut = createSUT(session: session) + + let downloadTask = StubURLSessionDownloadTask() + session.downloadTaskToReturn = downloadTask + + let firstDownloadToken = sut.scheduleDownload(url: url) { _ in } + sut.pauseDownload(firstDownloadToken) + + guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { + XCTFail("Unexpected event") + return + } + + resumeDataHandler(Data("resumption".utf8)) + + let resumedTask = StubURLSessionDownloadTask() + session.downloadTaskWithResumeDataToReturn = resumedTask + + var results = [Result]() + sut.scheduleDownload(url: url) { results.append($0) } + + /* The retired task winds down with a real error rather than a cancellation, so + nothing but the phase stops it being mistaken for the download now running. + */ + sut.handleComplete(forTaskWith: downloadTask.taskIdentifier, error: TestError.test) + + XCTAssertTrue(results.isEmpty) + + sut.handleComplete(forTaskWith: resumedTask.taskIdentifier, error: TestError.test) + + XCTAssertEqual(results.count, 1) + } + + func test_givenADownloadThatIsPausing_whenAMemoryWarningIsReceived_thenAJoinedCallerIsStillAnswered() { + let url = URL(string: "http://test.com/example")! let notificationCenter = StubNotificationCenter() notificationCenter.objectToReturn = NSObject() - let sessionFactory = StubURLSessionFactory() let session = StubURLSession() - sessionFactory.sessionToReturn = session + let sut = createSUT(session: session, notificationCenter: notificationCenter) - let sut = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) + guard case let .addObserver(_, _, _, notificationBlock) = notificationCenter.events.first else { + XCTFail("Unexpected event") + return + } let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - sut.cancelDownload(url: unknownURL) + let firstDownloadToken = sut.scheduleDownload(url: url) { _ in } + sut.pauseDownload(firstDownloadToken) - XCTAssertTrue(session.events.isEmpty) - XCTAssertTrue(downloadTask.events.isEmpty) + guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { + XCTFail("Unexpected event") + return + } + + let resumedTask = StubURLSessionDownloadTask() + session.downloadTaskWithResumeDataToReturn = resumedTask + + var results = [Result]() + sut.scheduleDownload(url: url) { results.append($0) } + + //purging must leave a pause in flight alone or the caller that joined it is stranded + notificationBlock(Notification(name: UIApplication.didReceiveMemoryWarningNotification)) + + resumeDataHandler(Data("resumption".utf8)) + + XCTAssertEqual(session.events.count, 2) + + guard case .downloadTaskWithResumeData = session.events.last else { + XCTFail("Expected a resumed download task") + return + } + + sut.handleComplete(forTaskWith: resumedTask.taskIdentifier, error: TestError.test) + + XCTAssertEqual(results.count, 1) + } + + func test_givenAPausedDownloadWithResumptionData_whenTwoCallersScheduleTheSameURL_thenTheResumptionDataIsUsedOnce() { + let url = URL(string: "http://test.com/example")! + + let session = StubURLSession() + let sut = createSUT(session: session) + + let downloadTask = StubURLSessionDownloadTask() + session.downloadTaskToReturn = downloadTask + + let firstDownloadToken = sut.scheduleDownload(url: url) { _ in } + sut.pauseDownload(firstDownloadToken) + + guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { + XCTFail("Unexpected event") + return + } + + resumeDataHandler(Data("resumption".utf8)) + + session.downloadTaskWithResumeDataToReturn = StubURLSessionDownloadTask() + + sut.scheduleDownload(url: url) { _ in } + sut.scheduleDownload(url: url) { _ in } + + //the second caller coalesces onto the resumed download rather than starting afresh + XCTAssertEqual(session.events.count, 2) + + guard case .downloadTaskWithResumeData = session.events.last else { + XCTFail("Expected the resumption data to be used exactly once") + return + } } } extension AssetDownloadsSessionTests { - func createSUT(urlSessionFactory: URLSessionFactoryType = StubURLSessionFactory(), + func createSUT(session: StubURLSession = StubURLSession(), + notificationCenter: NotificationCenterType = StubNotificationCenter()) -> AssetDownloadsSession { + let urlSessionFactory = StubURLSessionFactory() + urlSessionFactory.sessionToReturn = session + + return createSUT(urlSessionFactory: urlSessionFactory, + notificationCenter: notificationCenter) + } + + func createSUT(urlSessionFactory: URLSessionFactoryType, notificationCenter: NotificationCenterType = StubNotificationCenter()) -> AssetDownloadsSession { AssetDownloadsSession(urlSessionFactory: urlSessionFactory, notificationCenter: notificationCenter) diff --git a/PausableDownloads-ExampleTests/Tests/ImageGalleryViewModelTests.swift b/PausableDownloads-ExampleTests/Tests/ImageGalleryViewModelTests.swift new file mode 100644 index 0000000..c6d2609 --- /dev/null +++ b/PausableDownloads-ExampleTests/Tests/ImageGalleryViewModelTests.swift @@ -0,0 +1,262 @@ +// +// ImageGalleryViewModelTests.swift +// PausableDownloads-ExampleTests +// +// Created by William Boles on 09/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import XCTest + +@testable import PausableDownloads_Example + +final class ImageGalleryViewModelTests: XCTestCase { + + // MARK: - Tests + + // MARK: Load + + func test_givenViewModel_whenLoadIsCalled_thenImagesAreRetrievedOnTheMainQueue() { + let imagesService = StubImagesService() + let delegate = StubImageGalleryViewModelDelegate() + + let sut = createSUT(imagesService: imagesService) + sut.delegate = delegate + + sut.load() + + XCTAssertEqual(imagesService.events.count, 1) + + guard case let .retrieveImages(callbackQueue, _) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + XCTAssertTrue(callbackQueue === DispatchQueue.main) + XCTAssertEqual(sut.state, .loadingImages) + + guard case let .didChangeTo(state) = delegate.events.first else { + XCTFail("Unexpected event") + return + } + + XCTAssertEqual(state, .loadingImages) + } + + func test_givenLoadInProgress_whenImagesAreRetrieved_thenTheFirstAssetIsLoaded() { + let imagesService = StubImagesService() + let assetService = StubAssetService() + + let sut = createSUT(imagesService: imagesService, + assetService: assetService) + + sut.load() + + guard case let .retrieveImages(_, completionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.success([imageA, imageB])) + + XCTAssertEqual(sut.state, .loadedImages) + XCTAssertEqual(sut.numberOfImages, 2) + XCTAssertEqual(sut.currentIndex, 0) + + XCTAssertEqual(assetService.events.count, 1) + + guard case let .loadImage(loadedImage, _, _) = assetService.events.first else { + XCTFail("Unexpected event") + return + } + + XCTAssertEqual(loadedImage, imageA) + } + + func test_givenLoadInProgress_whenImageRetrievalFails_thenStateTransitionsToFailed() { + let imagesService = StubImagesService() + let assetService = StubAssetService() + + let sut = createSUT(imagesService: imagesService, + assetService: assetService) + + sut.load() + + guard case let .retrieveImages(_, completionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.failure(TestError.test)) + + XCTAssertEqual(sut.state, .failed) + XCTAssertTrue(assetService.events.isEmpty) + } + + func test_givenLoadInProgress_whenNoImagesAreRetrieved_thenNoAssetIsLoaded() { + let imagesService = StubImagesService() + let assetService = StubAssetService() + + let sut = createSUT(imagesService: imagesService, + assetService: assetService) + + sut.load() + + guard case let .retrieveImages(_, completionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.success([])) + + XCTAssertEqual(sut.state, .loadedImages) + XCTAssertEqual(sut.numberOfImages, 0) + XCTAssertTrue(assetService.events.isEmpty) + } + + // MARK: Pages + + func test_givenRetrievedImages_whenAViewModelIsRequestedTwiceForTheSameIndex_thenTheSameInstanceIsReturned() { + let sut = createLoadedSUT() + + let first = sut.viewModel(at: 1) + let second = sut.viewModel(at: 1) + + XCTAssertNotNil(first) + XCTAssertTrue(first === second) + } + + func test_givenRetrievedImages_whenAViewModelIsRequestedForEachIndex_thenItRepresentsThatImage() { + let sut = createLoadedSUT() + + XCTAssertEqual(sut.viewModel(at: 0)?.imageDomainModel, imageA) + XCTAssertEqual(sut.viewModel(at: 1)?.imageDomainModel, imageB) + } + + func test_givenRetrievedImages_whenAViewModelIsRequestedOutOfBounds_thenNilIsReturned() { + let sut = createLoadedSUT() + + XCTAssertNil(sut.viewModel(at: -1)) + XCTAssertNil(sut.viewModel(at: 2)) + } + + // MARK: Move + + func test_givenLoadedImages_whenMoveToIsCalled_thenTheOutgoingAssetIsPausedAndTheIncomingOneIsLoaded() { + let assetService = StubAssetService() + + let sut = createLoadedSUT(assetService: assetService) + + sut.moveTo(index: 1) + + XCTAssertEqual(sut.currentIndex, 1) + XCTAssertEqual(assetService.events.count, 3) + + guard case let .cancelLoadingImage(pausedDownloadID) = assetService.events[1] else { + XCTFail("Unexpected event") + return + } + + //the download issued for imageA, which is the page being swiped away from + XCTAssertEqual(pausedDownloadID, assetService.issuedDownloadIDs[0]) + + guard case let .loadImage(loadedImage, _, _) = assetService.events.last else { + XCTFail("Unexpected event") + return + } + + XCTAssertEqual(loadedImage, imageB) + } + + func test_givenAPausedImage_whenMovedBackTo_thenItsAssetIsLoadedAgain() { + let assetService = StubAssetService() + + let sut = createLoadedSUT(assetService: assetService) + + sut.moveTo(index: 1) + sut.moveTo(index: 0) + + XCTAssertEqual(sut.currentIndex, 0) + XCTAssertEqual(assetService.events.count, 5) + + guard case let .cancelLoadingImage(pausedDownloadID) = assetService.events[3] else { + XCTFail("Unexpected event") + return + } + + //the download issued for imageB, which is the page being swiped away from + XCTAssertEqual(pausedDownloadID, assetService.issuedDownloadIDs[1]) + + /* Rescheduling the same URL is what hands the paused download back to the + session to resume rather than restart. + */ + guard case let .loadImage(loadedImage, _, _) = assetService.events.last else { + XCTFail("Unexpected event") + return + } + + XCTAssertEqual(loadedImage, imageA) + } + + func test_givenLoadedImages_whenMoveToIsCalledForTheCurrentIndex_thenNothingHappens() { + let assetService = StubAssetService() + + let sut = createLoadedSUT(assetService: assetService) + + let eventCountBeforeMove = assetService.events.count + + sut.moveTo(index: 0) + + XCTAssertEqual(assetService.events.count, eventCountBeforeMove) + XCTAssertEqual(sut.currentIndex, 0) + } + + func test_givenLoadedImages_whenMoveToIsCalledOutOfBounds_thenNothingHappens() { + let assetService = StubAssetService() + + let sut = createLoadedSUT(assetService: assetService) + + let eventCountBeforeMove = assetService.events.count + + sut.moveTo(index: 2) + sut.moveTo(index: -1) + + XCTAssertEqual(assetService.events.count, eventCountBeforeMove) + XCTAssertEqual(sut.currentIndex, 0) + } +} + +extension ImageGalleryViewModelTests { + var imageA: ImageDomainModel { + ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + } + + var imageB: ImageDomainModel { + ImageDomainModel.testData(identifier: "b", + url: URL(string: "http://test.com/b.jpg")!) + } + + func createSUT(imagesService: ImagesService = StubImagesService(), + assetService: AssetService = StubAssetService()) -> ImageGalleryViewModel { + ImageGalleryViewModel(imagesService: imagesService, + assetService: assetService) + } + + func createLoadedSUT(assetService: AssetService = StubAssetService()) -> ImageGalleryViewModel { + let imagesService = StubImagesService() + + let sut = createSUT(imagesService: imagesService, + assetService: assetService) + + sut.load() + + guard case let .retrieveImages(_, completionHandler) = imagesService.events.first else { + fatalError("Expected images to have been retrieved") + } + + completionHandler(.success([imageA, imageB])) + + return sut + } +} diff --git a/PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift b/PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift index d9bc5b9..b5565fc 100644 --- a/PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift +++ b/PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift @@ -11,168 +11,70 @@ import XCTest @testable import PausableDownloads_Example final class ImageViewerViewModelTests: XCTestCase { - + // MARK: - Tests - // MARK: Load + // MARK: Init - func test_givenViewModel_whenLoadIsCalled_thenImagesAreRetrieved() { - let imagesService = StubImagesService() - - let sut = createSUT(imagesService: imagesService) - - sut.load() + func test_givenImage_whenInitialised_thenStateIsReady() { + let image = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) - XCTAssertEqual(imagesService.events.count, 1) + let sut = createSUT(imageDomainModel: image) - guard case .retrieveImages = imagesService.events.first else { - XCTFail("Unexpected event") - return - } + XCTAssertEqual(sut.state, .ready(description: image.url.absoluteString)) } - func test_givenViewModel_whenLoadIsCalled_thenDelegateIsNotifiedOfLoadingImages() { - let delegate = StubImageViewerViewModelDelegate() - - let sut = createSUT() - sut.delegate = delegate - - sut.load() - - XCTAssertEqual(delegate.events.count, 1) - - guard case let .didChangeTo(state) = delegate.events.first else { - XCTFail("Unexpected event") - return - } - - XCTAssertEqual(state, .loadingImages) - XCTAssertEqual(sut.state, .loadingImages) - } + // MARK: Load - func test_givenLoadInProgress_whenImagesAreRetrieved_thenTheFirstAssetIsLoaded() { - let imagesService = StubImagesService() + func test_givenViewModel_whenLoadIsCalled_thenTheAssetIsRequested() { let assetService = StubAssetService() + let image = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) - let sut = createSUT(imagesService: imagesService, - assetService: assetService) + let sut = createSUT(imageDomainModel: image, assetService: assetService) sut.load() - guard case let .retrieveImages(_, completionHandler) = imagesService.events.first else { - XCTFail("Unexpected event") - return - } - - let imageA = ImageDomainModel.testData(identifier: "a", - url: URL(string: "http://test.com/a.jpg")!) - let imageB = ImageDomainModel.testData(identifier: "b", - url: URL(string: "http://test.com/b.jpg")!) - - completionHandler(.success([imageA, imageB])) - XCTAssertEqual(assetService.events.count, 1) - guard case let .loadImage(loadedImage, _, _) = assetService.events.first else { - XCTFail("Unexpected event") - return - } - - XCTAssertEqual(loadedImage, imageA) - XCTAssertEqual(sut.state, .loadingAsset(description: imageA.url.absoluteString)) - } - - func test_givenLoadInProgress_whenImageRetrievalFails_thenStateTransitionsToFailed() { - let imagesService = StubImagesService() - let assetService = StubAssetService() - - let sut = createSUT(imagesService: imagesService, - assetService: assetService) - - sut.load() - - guard case let .retrieveImages(_, completionHandler) = imagesService.events.first else { - XCTFail("Unexpected event") - return - } - - completionHandler(.failure(TestError.test)) - - XCTAssertEqual(sut.state, .failed) - XCTAssertTrue(assetService.events.isEmpty) - } - - func test_givenLoadInProgress_whenNoImagesAreRetrieved_thenNoAssetIsLoaded() { - let imagesService = StubImagesService() - let assetService = StubAssetService() - - let sut = createSUT(imagesService: imagesService, - assetService: assetService) - - sut.load() - - guard case let .retrieveImages(_, completionHandler) = imagesService.events.first else { + guard case let .loadImage(loadedImage, callbackQueue, _) = assetService.events.first else { XCTFail("Unexpected event") return } - completionHandler(.success([])) - - XCTAssertTrue(assetService.events.isEmpty) - XCTAssertEqual(sut.state, .loadingImages) + XCTAssertEqual(loadedImage, image) + XCTAssertTrue(callbackQueue === DispatchQueue.main) + XCTAssertEqual(sut.state, .loadingAsset(description: image.url.absoluteString)) } - // MARK: Asset - func test_givenAssetLoadInProgress_whenTheAssetLoads_thenStateTransitionsToLoadedAsset() { - let imagesService = StubImagesService() let assetService = StubAssetService() + let image = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) - let sut = createSUT(imagesService: imagesService, - assetService: assetService) - - let imageA = ImageDomainModel.testData(identifier: "a", - url: URL(string: "http://test.com/a.jpg")!) + let sut = createSUT(imageDomainModel: image, assetService: assetService) sut.load() - guard case let .retrieveImages(_, imagesCompletionHandler) = imagesService.events.first else { - XCTFail("Unexpected event") - return - } - - imagesCompletionHandler(.success([imageA])) - guard case let .loadImage(_, _, completionHandler) = assetService.events.first else { XCTFail("Unexpected event") return } - let image = UIImage() - completionHandler(.success(LoadImageResult(imageDomainModel: imageA, image: image))) + let loadedImage = UIImage() + completionHandler(.success(LoadImageResult(imageDomainModel: image, image: loadedImage))) - XCTAssertEqual(sut.state, .loadedAsset(image, description: imageA.url.absoluteString)) + XCTAssertEqual(sut.state, .loadedAsset(loadedImage, description: image.url.absoluteString)) } func test_givenAssetLoadInProgress_whenTheAssetFailsToLoad_thenStateTransitionsToFailed() { - let imagesService = StubImagesService() let assetService = StubAssetService() - let sut = createSUT(imagesService: imagesService, - assetService: assetService) - - let imageA = ImageDomainModel.testData(identifier: "a", - url: URL(string: "http://test.com/a.jpg")!) + let sut = createSUT(assetService: assetService) sut.load() - guard case let .retrieveImages(_, imagesCompletionHandler) = imagesService.events.first else { - XCTFail("Unexpected event") - return - } - - imagesCompletionHandler(.success([imageA])) - guard case let .loadImage(_, _, completionHandler) = assetService.events.first else { XCTFail("Unexpected event") return @@ -183,228 +85,124 @@ final class ImageViewerViewModelTests: XCTestCase { XCTAssertEqual(sut.state, .failed) } - func test_givenAdvancedPastAnImage_whenTheStaleAssetLoads_thenStateIsUnchanged() { - let imagesService = StubImagesService() + func test_givenAssetLoadInProgress_whenAResultForAnotherImageArrives_thenStateIsUnchanged() { let assetService = StubAssetService() let delegate = StubImageViewerViewModelDelegate() - let sut = createSUT(imagesService: imagesService, - assetService: assetService) - sut.delegate = delegate - let imageA = ImageDomainModel.testData(identifier: "a", url: URL(string: "http://test.com/a.jpg")!) let imageB = ImageDomainModel.testData(identifier: "b", url: URL(string: "http://test.com/b.jpg")!) - sut.load() - - guard case let .retrieveImages(_, imagesCompletionHandler) = imagesService.events.first else { - XCTFail("Unexpected event") - return - } + let sut = createSUT(imageDomainModel: imageA, assetService: assetService) + sut.delegate = delegate - imagesCompletionHandler(.success([imageA, imageB])) + sut.load() - guard case let .loadImage(_, _, staleCompletionHandler) = assetService.events.first else { + guard case let .loadImage(_, _, completionHandler) = assetService.events.first else { XCTFail("Unexpected event") return } - sut.advance() - let eventCountBeforeStaleResult = delegate.events.count - staleCompletionHandler(.success(LoadImageResult(imageDomainModel: imageA, image: UIImage()))) + completionHandler(.success(LoadImageResult(imageDomainModel: imageB, image: UIImage()))) XCTAssertEqual(delegate.events.count, eventCountBeforeStaleResult) - XCTAssertEqual(sut.state, .loadingAsset(description: imageB.url.absoluteString)) + XCTAssertEqual(sut.state, .loadingAsset(description: imageA.url.absoluteString)) } - // MARK: Advance - - func test_givenLoadedImages_whenAdvanceIsCalled_thenTheCurrentAssetLoadIsCancelled() { - let imagesService = StubImagesService() + func test_givenAssetLoadInProgress_whenLoadIsCalledAgain_thenTheAssetIsNotRequestedASecondTime() { let assetService = StubAssetService() - let sut = createSUT(imagesService: imagesService, - assetService: assetService) - - let imageA = ImageDomainModel.testData(identifier: "a", - url: URL(string: "http://test.com/a.jpg")!) - let imageB = ImageDomainModel.testData(identifier: "b", - url: URL(string: "http://test.com/b.jpg")!) + let sut = createSUT(assetService: assetService) sut.load() - - guard case let .retrieveImages(_, imagesCompletionHandler) = imagesService.events.first else { - XCTFail("Unexpected event") - return - } - - imagesCompletionHandler(.success([imageA, imageB])) - - sut.advance() - - XCTAssertEqual(assetService.events.count, 3) - - guard case let .cancelLoadingImage(cancelledImage) = assetService.events[1] else { - XCTFail("Unexpected event") - return - } - - XCTAssertEqual(cancelledImage, imageA) - } - - func test_givenLoadedImages_whenAdvanceIsCalled_thenTheNextAssetIsLoaded() { - let imagesService = StubImagesService() - let assetService = StubAssetService() - - let sut = createSUT(imagesService: imagesService, - assetService: assetService) - - let imageA = ImageDomainModel.testData(identifier: "a", - url: URL(string: "http://test.com/a.jpg")!) - let imageB = ImageDomainModel.testData(identifier: "b", - url: URL(string: "http://test.com/b.jpg")!) - sut.load() - guard case let .retrieveImages(_, imagesCompletionHandler) = imagesService.events.first else { - XCTFail("Unexpected event") - return - } - - imagesCompletionHandler(.success([imageA, imageB])) - - sut.advance() - - guard case let .loadImage(loadedImage, _, _) = assetService.events.last else { - XCTFail("Unexpected event") - return - } - - XCTAssertEqual(loadedImage, imageB) - XCTAssertEqual(sut.state, .loadingAsset(description: imageB.url.absoluteString)) + XCTAssertEqual(assetService.events.count, 1) } - func test_givenTheLastImage_whenAdvanceIsCalled_thenNoFurtherAssetIsLoaded() { - let imagesService = StubImagesService() + func test_givenLoadedAsset_whenLoadIsCalledAgain_thenTheAssetIsNotRequestedASecondTime() { let assetService = StubAssetService() + let image = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) - let sut = createSUT(imagesService: imagesService, - assetService: assetService) - - let imageA = ImageDomainModel.testData(identifier: "a", - url: URL(string: "http://test.com/a.jpg")!) + let sut = createSUT(imageDomainModel: image, assetService: assetService) sut.load() - guard case let .retrieveImages(_, imagesCompletionHandler) = imagesService.events.first else { + guard case let .loadImage(_, _, completionHandler) = assetService.events.first else { XCTFail("Unexpected event") return } - imagesCompletionHandler(.success([imageA])) - - let eventCountBeforeAdvance = assetService.events.count + let loadedImage = UIImage() + completionHandler(.success(LoadImageResult(imageDomainModel: image, image: loadedImage))) - sut.advance() - - XCTAssertEqual(assetService.events.count, eventCountBeforeAdvance + 1) + sut.load() - guard case .cancelLoadingImage = assetService.events.last else { - XCTFail("Unexpected event") - return - } + XCTAssertEqual(assetService.events.count, 1) + XCTAssertEqual(sut.state, .loadedAsset(loadedImage, description: image.url.absoluteString)) } - func test_givenTheLastImage_whenAdvanceIsCalled_thenStateIsUnchanged() { - let imagesService = StubImagesService() + // MARK: Pause + + func test_givenAssetLoadInProgress_whenPauseIsCalled_thenTheAssetLoadIsCancelledAndStateReturnsToReady() { let assetService = StubAssetService() - let delegate = StubImageViewerViewModelDelegate() - - let sut = createSUT(imagesService: imagesService, - assetService: assetService) - sut.delegate = delegate + let image = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) - let imageA = ImageDomainModel.testData(identifier: "a", - url: URL(string: "http://test.com/a.jpg")!) + let sut = createSUT(imageDomainModel: image, assetService: assetService) sut.load() + sut.pause() - guard case let .retrieveImages(_, imagesCompletionHandler) = imagesService.events.first else { + XCTAssertEqual(assetService.events.count, 2) + + guard case let .cancelLoadingImage(cancelledDownloadID) = assetService.events.last else { XCTFail("Unexpected event") return } - imagesCompletionHandler(.success([imageA])) - - let eventCountBeforeAdvance = delegate.events.count - - sut.advance() - - XCTAssertEqual(delegate.events.count, eventCountBeforeAdvance) - XCTAssertEqual(sut.state, .loadingAsset(description: imageA.url.absoluteString)) + //the view model pauses the download it started, not whatever shares the URL + XCTAssertEqual(cancelledDownloadID, assetService.issuedDownloadIDs.first) + XCTAssertEqual(sut.state, .ready(description: image.url.absoluteString)) } - // MARK: Callback queue - - func test_givenViewModel_whenLoadIsCalled_thenImagesAreRequestedOnTheMainQueue() { - let imagesService = StubImagesService() + func test_givenNoAssetLoadInProgress_whenPauseIsCalled_thenNothingIsCancelled() { + let assetService = StubAssetService() - let sut = createSUT(imagesService: imagesService) + let sut = createSUT(assetService: assetService) - sut.load() + sut.pause() - guard case let .retrieveImages(callbackQueue, _) = imagesService.events.first else { - XCTFail("Unexpected event") - return - } - - XCTAssertTrue(callbackQueue === DispatchQueue.main) + XCTAssertTrue(assetService.events.isEmpty) } - func test_givenRetrievedImages_whenAnAssetIsLoaded_thenItIsRequestedOnTheMainQueue() { - let imagesService = StubImagesService() + func test_givenPausedAssetLoad_whenLoadIsCalledAgain_thenTheAssetIsRequestedAgain() { let assetService = StubAssetService() - let sut = createSUT(imagesService: imagesService, - assetService: assetService) + let sut = createSUT(assetService: assetService) + sut.load() + sut.pause() sut.load() - guard case let .retrieveImages(_, imagesCompletionHandler) = imagesService.events.first else { - XCTFail("Unexpected event") - return - } - - imagesCompletionHandler(.success([ImageDomainModel.testData(identifier: "a", - url: URL(string: "http://test.com/a.jpg")!)])) + XCTAssertEqual(assetService.events.count, 3) - guard case let .loadImage(_, callbackQueue, _) = assetService.events.first else { + guard case .loadImage = assetService.events.last else { XCTFail("Unexpected event") return } - - XCTAssertTrue(callbackQueue === DispatchQueue.main) - } - - func test_givenNoImages_whenAdvanceIsCalled_thenNoAssetIsCancelledOrLoaded() { - let assetService = StubAssetService() - - let sut = createSUT(assetService: assetService) - - sut.advance() - - XCTAssertTrue(assetService.events.isEmpty) } + } extension ImageViewerViewModelTests { - func createSUT(imagesService: ImagesService = StubImagesService(), + func createSUT(imageDomainModel: ImageDomainModel = .testData(), assetService: AssetService = StubAssetService()) -> ImageViewerViewModel { - ImageViewerViewModel(imagesService: imagesService, + ImageViewerViewModel(imageDomainModel: imageDomainModel, assetService: assetService) } } diff --git a/README.md b/README.md index cb3e513..754a171 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,11 @@ An example project about pausing and resuming download requests, https://williamboles.com/not-all-downloads-are-born-equal/ In order to run this project, you will need to register with [TheCatAPI](https://thecatapi.com/) to get an API key to access TheCatAPI's API (which the project uses to get its example content). Once you have your key, add an `xcconfig` file called `Secrets` to the top directory with your key as the value of `CAT_API_KEY` and the project should now run. If you have any trouble getting the project to run, please create an issue or get in touch with me on Twitter at [wibosco](https://twitter.com/wibosco). + +## Seeing a download resume + +The app is a gallery of cat images that you swipe through, downloading each image as you reach it. Swiping away from an image that hasn't finished downloading pauses it and holds onto its resumption data; swiping back resumes that download from where it left off rather than starting it again. + +The console is where you see this happen. Swiping away logs `Pausing download: ...` and then the resumption data that cancelling produced; swiping back logs `Resuming an existing download: ...` followed by `Resuming download: ... from: NN.NN%` - the percentage the download is picking back up from rather than starting over at. + +Images are requested at their full size so that there is time to swipe away mid-download, but on a fast connection they can still complete in well under a second. Turning on **Network Link Conditioner** with a slow profile makes the pause and resume easy to catch. From a236288da9e77b9b7f118d129c9e35a2a4d0c236 Mon Sep 17 00:00:00 2001 From: William Boles Date: Fri, 11 Sep 2026 12:15:11 +0100 Subject: [PATCH 08/16] Added session protocol --- .../project.pbxproj | 4 +++ .../Asset/AssetDownloadsSession.swift | 18 ++++++---- .../Services/Asset/AssetService.swift | 12 +++++-- .../Doubles/StubAssetDownloadsSession.swift | 33 +++++++++++++++++++ .../Tests/AssetDownloadsSessionTests.swift | 8 ++--- 5 files changed, 62 insertions(+), 13 deletions(-) create mode 100644 PausableDownloads-ExampleTests/Doubles/StubAssetDownloadsSession.swift diff --git a/PausableDownloads-Example.xcodeproj/project.pbxproj b/PausableDownloads-Example.xcodeproj/project.pbxproj index 6fb1764..e00a83f 100644 --- a/PausableDownloads-Example.xcodeproj/project.pbxproj +++ b/PausableDownloads-Example.xcodeproj/project.pbxproj @@ -41,6 +41,7 @@ 43A1000030600012009529DF /* ImageGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600002009529DF /* ImageGalleryViewModel.swift */; }; 43A1000030600013009529DF /* ImageGalleryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600003009529DF /* ImageGalleryViewModelTests.swift */; }; 43A1000030600014009529DF /* StubImageGalleryViewModelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600004009529DF /* StubImageGalleryViewModelDelegate.swift */; }; + 43F8C812305418EA00150C94 /* StubAssetDownloadsSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F8C811305418EA00150C94 /* StubAssetDownloadsSession.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -93,6 +94,7 @@ 43A1000030600003009529DF /* ImageGalleryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryViewModelTests.swift; sourceTree = ""; }; 43A1000030600004009529DF /* StubImageGalleryViewModelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubImageGalleryViewModelDelegate.swift; sourceTree = ""; }; 43DF70D53051B477004E9EEA /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = ""; }; + 43F8C811305418EA00150C94 /* StubAssetDownloadsSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubAssetDownloadsSession.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -295,6 +297,7 @@ 4399D38C3050B4DB009D2CEB /* TestError.swift */, 437C0D403051F9F3009529DF /* StubImagesService.swift */, 437C0D423051FA65009529DF /* StubAssetService.swift */, + 43F8C811305418EA00150C94 /* StubAssetDownloadsSession.swift */, ); path = Doubles; sourceTree = ""; @@ -455,6 +458,7 @@ 437C0D413051F9F3009529DF /* StubImagesService.swift in Sources */, 4399D3923050B4DB009D2CEB /* StubURLSessionFactory.swift in Sources */, 4399D3933050B4DB009D2CEB /* TestError.swift in Sources */, + 43F8C812305418EA00150C94 /* StubAssetDownloadsSession.swift in Sources */, 4399D3943050B4DB009D2CEB /* StubNotificationCenter.swift in Sources */, 437C0D6B3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift in Sources */, 43A1000030600014009529DF /* StubImageGalleryViewModelDelegate.swift in Sources */, diff --git a/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift b/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift index 9b3b69b..b996bd6 100644 --- a/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift +++ b/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift @@ -35,7 +35,14 @@ struct DownloadToken: Hashable { } } -final class AssetDownloadsSession: NSObject { +protocol AssetDownloadsSession { + @discardableResult + func scheduleDownload(url: URL, + completionHandler: @escaping DownloadCompletionHandler) -> DownloadToken + func pauseDownload(_ token: DownloadToken) +} + +final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { private struct Download { var handlers: [DownloadToken: DownloadCompletionHandler] var stage: DownloadStage @@ -63,7 +70,7 @@ final class AssetDownloadsSession: NSObject { // MARK: - Singleton - static let shared = AssetDownloadsSession() + static let shared = DefaultAssetDownloadsSession() // MARK: - Init @@ -258,11 +265,8 @@ final class AssetDownloadsSession: NSObject { handlers: download.handlers) } } -} - -extension AssetDownloadsSession { - // MARK: - Handling + // MARK: - DelegateHandling func handleProgress(for url: URL, totalBytesWritten: Int64, @@ -329,7 +333,7 @@ extension AssetDownloadsSession { } } -extension AssetDownloadsSession: URLSessionDownloadDelegate { +extension DefaultAssetDownloadsSession: URLSessionDownloadDelegate { // MARK: - URLSessionDownloadDelegate diff --git a/PausableDownloads-Example/Services/Asset/AssetService.swift b/PausableDownloads-Example/Services/Asset/AssetService.swift index 6368acc..a8b51cd 100644 --- a/PausableDownloads-Example/Services/Asset/AssetService.swift +++ b/PausableDownloads-Example/Services/Asset/AssetService.swift @@ -26,8 +26,16 @@ protocol AssetService { } final class DefaultAssetService: AssetService { - private let session = AssetDownloadsSession.shared - private let fileManager = FileManager.default + private let session: AssetDownloadsSession + private let fileManager: FileManager + + // MARK: - Init + + init(session: AssetDownloadsSession = DefaultAssetDownloadsSession.shared, + fileManager: FileManager = FileManager.default) { + self.session = session + self.fileManager = fileManager + } // MARK: - Load diff --git a/PausableDownloads-ExampleTests/Doubles/StubAssetDownloadsSession.swift b/PausableDownloads-ExampleTests/Doubles/StubAssetDownloadsSession.swift new file mode 100644 index 0000000..31109ff --- /dev/null +++ b/PausableDownloads-ExampleTests/Doubles/StubAssetDownloadsSession.swift @@ -0,0 +1,33 @@ +// +// StubAssetDownloadsSession.swift +// PausableDownloads-ExampleTests +// +// Created by William Boles on 11/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import Foundation + +@testable import PausableDownloads_Example + +final class StubAssetDownloadsSession: AssetDownloadsSession { + enum Event { + case scheduleDownload(URL, DownloadCompletionHandler) + case pauseDownload(DownloadToken) + } + + private(set) var events = [Event]() + + var tokenToReturn: DownloadToken! + + func scheduleDownload(url: URL, + completionHandler: @escaping DownloadCompletionHandler) -> DownloadToken { + events.append(.scheduleDownload(url, completionHandler)) + + return tokenToReturn + } + + func pauseDownload(_ token: DownloadToken) { + events.append(.pauseDownload(token)) + } +} diff --git a/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift b/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift index 00e8ed1..2deadc2 100644 --- a/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift +++ b/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift @@ -933,7 +933,7 @@ class AssetDownloadsSessionTests: XCTestCase { extension AssetDownloadsSessionTests { func createSUT(session: StubURLSession = StubURLSession(), - notificationCenter: NotificationCenterType = StubNotificationCenter()) -> AssetDownloadsSession { + notificationCenter: NotificationCenterType = StubNotificationCenter()) -> DefaultAssetDownloadsSession { let urlSessionFactory = StubURLSessionFactory() urlSessionFactory.sessionToReturn = session @@ -942,8 +942,8 @@ extension AssetDownloadsSessionTests { } func createSUT(urlSessionFactory: URLSessionFactoryType, - notificationCenter: NotificationCenterType = StubNotificationCenter()) -> AssetDownloadsSession { - AssetDownloadsSession(urlSessionFactory: urlSessionFactory, - notificationCenter: notificationCenter) + notificationCenter: NotificationCenterType = StubNotificationCenter()) -> DefaultAssetDownloadsSession { + DefaultAssetDownloadsSession(urlSessionFactory: urlSessionFactory, + notificationCenter: notificationCenter) } } From 244e7071b2632c7fd7661db453c3a646c53de488 Mon Sep 17 00:00:00 2001 From: William Boles Date: Fri, 11 Sep 2026 12:29:42 +0100 Subject: [PATCH 09/16] Simplified tests --- .../project.pbxproj | 16 ++++-- .../Doubles/StubAssetService.swift | 16 +++--- .../Doubles/StubURLSession.swift | 16 +----- .../Doubles/StubURLSessionDownloadTask.swift | 19 ++----- .../{Doubles => Helpers}/TestError.swift | 0 .../Tests/AssetDownloadsSessionTests.swift | 49 +++++++------------ .../Tests/ImageGalleryViewModelTests.swift | 22 +++++++-- .../Tests/ImageViewerViewModelTests.swift | 12 +++-- 8 files changed, 67 insertions(+), 83 deletions(-) rename PausableDownloads-ExampleTests/{Doubles => Helpers}/TestError.swift (100%) diff --git a/PausableDownloads-Example.xcodeproj/project.pbxproj b/PausableDownloads-Example.xcodeproj/project.pbxproj index e00a83f..2b814e4 100644 --- a/PausableDownloads-Example.xcodeproj/project.pbxproj +++ b/PausableDownloads-Example.xcodeproj/project.pbxproj @@ -34,7 +34,6 @@ 4399D3903050B4DB009D2CEB /* StubURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D3893050B4DB009D2CEB /* StubURLSession.swift */; }; 4399D3913050B4DB009D2CEB /* StubURLSessionDownloadTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */; }; 4399D3923050B4DB009D2CEB /* StubURLSessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */; }; - 4399D3933050B4DB009D2CEB /* TestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38C3050B4DB009D2CEB /* TestError.swift */; }; 4399D3943050B4DB009D2CEB /* StubNotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D3883050B4DB009D2CEB /* StubNotificationCenter.swift */; }; 4399D3953050B4DB009D2CEB /* AssetDownloadsSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38E3050B4DB009D2CEB /* AssetDownloadsSessionTests.swift */; }; 43A1000030600011009529DF /* ImageGalleryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600001009529DF /* ImageGalleryViewController.swift */; }; @@ -42,6 +41,7 @@ 43A1000030600013009529DF /* ImageGalleryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600003009529DF /* ImageGalleryViewModelTests.swift */; }; 43A1000030600014009529DF /* StubImageGalleryViewModelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600004009529DF /* StubImageGalleryViewModelDelegate.swift */; }; 43F8C812305418EA00150C94 /* StubAssetDownloadsSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F8C811305418EA00150C94 /* StubAssetDownloadsSession.swift */; }; + 43F8C81530541F5800150C94 /* TestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F8C81330541F5800150C94 /* TestError.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -87,7 +87,6 @@ 4399D3893050B4DB009D2CEB /* StubURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSession.swift; sourceTree = ""; }; 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSessionDownloadTask.swift; sourceTree = ""; }; 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSessionFactory.swift; sourceTree = ""; }; - 4399D38C3050B4DB009D2CEB /* TestError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestError.swift; sourceTree = ""; }; 4399D38E3050B4DB009D2CEB /* AssetDownloadsSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetDownloadsSessionTests.swift; sourceTree = ""; }; 43A1000030600001009529DF /* ImageGalleryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryViewController.swift; sourceTree = ""; }; 43A1000030600002009529DF /* ImageGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryViewModel.swift; sourceTree = ""; }; @@ -95,6 +94,7 @@ 43A1000030600004009529DF /* StubImageGalleryViewModelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubImageGalleryViewModelDelegate.swift; sourceTree = ""; }; 43DF70D53051B477004E9EEA /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = ""; }; 43F8C811305418EA00150C94 /* StubAssetDownloadsSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubAssetDownloadsSession.swift; sourceTree = ""; }; + 43F8C81330541F5800150C94 /* TestError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestError.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -184,6 +184,7 @@ 3D63CC6E204B555300797A82 /* PausableDownloads-ExampleTests */ = { isa = PBXGroup; children = ( + 43F8C81430541F5800150C94 /* Helpers */, 437C0D463052022A009529DF /* TestData */, 4399D38D3050B4DB009D2CEB /* Doubles */, 4399D38F3050B4DB009D2CEB /* Tests */, @@ -294,7 +295,6 @@ 4399D3893050B4DB009D2CEB /* StubURLSession.swift */, 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */, 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */, - 4399D38C3050B4DB009D2CEB /* TestError.swift */, 437C0D403051F9F3009529DF /* StubImagesService.swift */, 437C0D423051FA65009529DF /* StubAssetService.swift */, 43F8C811305418EA00150C94 /* StubAssetDownloadsSession.swift */, @@ -321,6 +321,14 @@ path = ImageGallery; sourceTree = ""; }; + 43F8C81430541F5800150C94 /* Helpers */ = { + isa = PBXGroup; + children = ( + 43F8C81330541F5800150C94 /* TestError.swift */, + ); + path = Helpers; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -457,7 +465,7 @@ 437C0D4830520236009529DF /* ImageDomainModel+TestData.swift in Sources */, 437C0D413051F9F3009529DF /* StubImagesService.swift in Sources */, 4399D3923050B4DB009D2CEB /* StubURLSessionFactory.swift in Sources */, - 4399D3933050B4DB009D2CEB /* TestError.swift in Sources */, + 43F8C81530541F5800150C94 /* TestError.swift in Sources */, 43F8C812305418EA00150C94 /* StubAssetDownloadsSession.swift in Sources */, 4399D3943050B4DB009D2CEB /* StubNotificationCenter.swift in Sources */, 437C0D6B3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift in Sources */, diff --git a/PausableDownloads-ExampleTests/Doubles/StubAssetService.swift b/PausableDownloads-ExampleTests/Doubles/StubAssetService.swift index dd0eeeb..04db144 100644 --- a/PausableDownloads-ExampleTests/Doubles/StubAssetService.swift +++ b/PausableDownloads-ExampleTests/Doubles/StubAssetService.swift @@ -12,28 +12,24 @@ import Foundation final class StubAssetService: AssetService { enum Event { - case loadImage(ImageDomainModel, DispatchQueue, (Result) -> ()) + case loadImage(ImageDomainModel, DispatchQueue, ((_ result: Result) -> ())) case cancelLoadingImage(DownloadToken) } private(set) var events = [Event]() - //a fresh id per load, in issue order, so a test can say which download was cancelled - private(set) var issuedDownloadIDs = [DownloadToken]() + var downloadTokenToReturn: DownloadToken? @discardableResult func loadImage(_ imageDomainModel: ImageDomainModel, callbackQueue: DispatchQueue, - completionHandler: @escaping (Result) -> ()) -> DownloadToken? { + completionHandler: @escaping ((_ result: Result) -> ())) -> DownloadToken? { events.append(.loadImage(imageDomainModel, callbackQueue, completionHandler)) - let downloadID = DownloadToken(url: imageDomainModel.url) - issuedDownloadIDs.append(downloadID) - - return downloadID + return downloadTokenToReturn } - func cancelLoadingImage(_ downloadID: DownloadToken) { - events.append(.cancelLoadingImage(downloadID)) + func cancelLoadingImage(_ downloadToken: DownloadToken) { + events.append(.cancelLoadingImage(downloadToken)) } } diff --git a/PausableDownloads-ExampleTests/Doubles/StubURLSession.swift b/PausableDownloads-ExampleTests/Doubles/StubURLSession.swift index bc653b1..cfbd9a4 100644 --- a/PausableDownloads-ExampleTests/Doubles/StubURLSession.swift +++ b/PausableDownloads-ExampleTests/Doubles/StubURLSession.swift @@ -21,27 +21,15 @@ class StubURLSession: URLSessionType { var downloadTaskToReturn: StubURLSessionDownloadTask! var downloadTaskWithResumeDataToReturn: StubURLSessionDownloadTask! - //when several downloads are in flight at once they need distinct tasks - each call - //takes the next one from here before falling back to the single stubs above - var downloadTasksToReturn = [StubURLSessionDownloadTask]() - func downloadTask(with url: URL) -> URLSessionDownloadTaskType { events.append(.downloadTask(url)) - return nextDownloadTask() ?? downloadTaskToReturn + return downloadTaskToReturn } func downloadTask(withResumeData resumeData: Data) -> URLSessionDownloadTaskType { events.append(.downloadTaskWithResumeData(resumeData)) - return nextDownloadTask() ?? downloadTaskWithResumeDataToReturn - } - - private func nextDownloadTask() -> StubURLSessionDownloadTask? { - guard !downloadTasksToReturn.isEmpty else { - return nil - } - - return downloadTasksToReturn.removeFirst() + return downloadTaskWithResumeDataToReturn } } diff --git a/PausableDownloads-ExampleTests/Doubles/StubURLSessionDownloadTask.swift b/PausableDownloads-ExampleTests/Doubles/StubURLSessionDownloadTask.swift index 7b0f9ac..443d8ba 100644 --- a/PausableDownloads-ExampleTests/Doubles/StubURLSessionDownloadTask.swift +++ b/PausableDownloads-ExampleTests/Doubles/StubURLSessionDownloadTask.swift @@ -17,21 +17,12 @@ class StubURLSessionDownloadTask: URLSessionDownloadTaskType { case cancelByProducingResumeData((Data?) -> Void) } - private static var lastTaskIdentifier = 0 - private(set) var events = [Event]() - let taskIdentifier: Int - - //set to report resumption data back on the thread that cancelled, rather than - //handing the closure back to the test to call later - var resumptionDataToProduceSynchronously: Data? - - // MARK: - Init + var taskIdentifierToReturn: Int! - init() { - StubURLSessionDownloadTask.lastTaskIdentifier += 1 - taskIdentifier = StubURLSessionDownloadTask.lastTaskIdentifier + var taskIdentifier: Int { + taskIdentifierToReturn } // MARK: - Task @@ -46,9 +37,5 @@ class StubURLSessionDownloadTask: URLSessionDownloadTaskType { func cancel(byProducingResumeData completionHandler: @escaping (Data?) -> Void) { events.append(.cancelByProducingResumeData(completionHandler)) - - if let resumptionDataToProduceSynchronously = resumptionDataToProduceSynchronously { - completionHandler(resumptionDataToProduceSynchronously) - } } } diff --git a/PausableDownloads-ExampleTests/Doubles/TestError.swift b/PausableDownloads-ExampleTests/Helpers/TestError.swift similarity index 100% rename from PausableDownloads-ExampleTests/Doubles/TestError.swift rename to PausableDownloads-ExampleTests/Helpers/TestError.swift diff --git a/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift b/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift index 2deadc2..d42f2f1 100644 --- a/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift +++ b/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift @@ -192,6 +192,7 @@ class AssetDownloadsSessionTests: XCTestCase { let sut = createSUT(session: session) let downloadTask = StubURLSessionDownloadTask() + downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask var firstResults = [Result]() @@ -217,6 +218,7 @@ class AssetDownloadsSessionTests: XCTestCase { let sut = createSUT(session: session) let downloadTask = StubURLSessionDownloadTask() + downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask var firstResults = [Result]() @@ -363,6 +365,7 @@ class AssetDownloadsSessionTests: XCTestCase { let sut = createSUT(session: session) let downloadTask = StubURLSessionDownloadTask() + downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask var results = [Result]() @@ -388,6 +391,7 @@ class AssetDownloadsSessionTests: XCTestCase { let sut = createSUT(session: session) let downloadTask = StubURLSessionDownloadTask() + downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask sut.scheduleDownload(url: url) { _ in } @@ -413,6 +417,7 @@ class AssetDownloadsSessionTests: XCTestCase { let sut = createSUT(session: session) let downloadTask = StubURLSessionDownloadTask() + downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask let completionExpectation = expectation(description: "completionExpectation") @@ -482,6 +487,7 @@ class AssetDownloadsSessionTests: XCTestCase { let sut = createSUT(session: session) let downloadTask = StubURLSessionDownloadTask() + downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask var receivedResult: Result? @@ -516,6 +522,7 @@ class AssetDownloadsSessionTests: XCTestCase { let sut = createSUT(session: session) let downloadTask = StubURLSessionDownloadTask() + downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask var receivedResult: Result? @@ -551,6 +558,7 @@ class AssetDownloadsSessionTests: XCTestCase { let sut = createSUT(session: session) let downloadTask = StubURLSessionDownloadTask() + downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask var receivedResult: Result? @@ -584,6 +592,7 @@ class AssetDownloadsSessionTests: XCTestCase { let sut = createSUT(session: session) let retiredDownloadTask = StubURLSessionDownloadTask() + retiredDownloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = retiredDownloadTask let downloadID = sut.scheduleDownload(url: url) { _ in } @@ -597,6 +606,7 @@ class AssetDownloadsSessionTests: XCTestCase { resumeDataHandler(Data("resumption".utf8)) let resumedDownloadTask = StubURLSessionDownloadTask() + resumedDownloadTask.taskIdentifierToReturn = 2 session.downloadTaskWithResumeDataToReturn = resumedDownloadTask var results = [Result]() @@ -619,13 +629,14 @@ class AssetDownloadsSessionTests: XCTestCase { let sut = createSUT(session: session) let downloadTask = StubURLSessionDownloadTask() + downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask var results = [Result]() sut.scheduleDownload(url: url) { results.append($0) } let unknownURL = URL(string: "http://test.com/unknown")! - let unknownTaskIdentifier = downloadTask.taskIdentifier + 1000 + let unknownTaskIdentifier = 2 sut.handleProgress(for: unknownURL, totalBytesWritten: 50, expectedTotalBytes: 100) sut.handleResumption(for: unknownURL, fileOffset: 50, expectedTotalBytes: 100) @@ -657,37 +668,6 @@ class AssetDownloadsSessionTests: XCTestCase { } } - func test_givenScheduledDownload_whenTheCancelledTaskProducesResumptionDataSynchronously_thenTheResumptionDataIsStored() { - let url = URL(string: "http://test.com/example")! - let resumptionData = Data("resumption".utf8) - - let session = StubURLSession() - let sut = createSUT(session: session) - - let downloadTask = StubURLSessionDownloadTask() - downloadTask.resumptionDataToProduceSynchronously = resumptionData - session.downloadTaskToReturn = downloadTask - - let downloadID = sut.scheduleDownload(url: url) { _ in } - - //a task that reports back on the thread that cancelled it deadlocks anything - //cancelling whilst still holding the downloads queue - sut.pauseDownload(downloadID) - - session.downloadTaskWithResumeDataToReturn = StubURLSessionDownloadTask() - - sut.scheduleDownload(url: url) { _ in } - - XCTAssertEqual(session.events.count, 2) - - guard case let .downloadTaskWithResumeData(data) = session.events.last else { - XCTFail("Unexpected event") - return - } - - XCTAssertEqual(data, resumptionData) - } - func test_givenNoScheduledDownloads_whenCancelDownloadIsCalledForAnUnknownID_thenNoDownloadTaskEventsAreRecorded() { let url = URL(string: "http://test.com/example")! @@ -716,6 +696,7 @@ class AssetDownloadsSessionTests: XCTestCase { let sut = createSUT(session: session) let downloadTask = StubURLSessionDownloadTask() + downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask var firstResult: Result? @@ -785,6 +766,7 @@ class AssetDownloadsSessionTests: XCTestCase { } let resumedTask = StubURLSessionDownloadTask() + resumedTask.taskIdentifierToReturn = 2 session.downloadTaskWithResumeDataToReturn = resumedTask //both scheduled whilst the pause is still in flight, so both join it @@ -819,6 +801,7 @@ class AssetDownloadsSessionTests: XCTestCase { let sut = createSUT(session: session) let downloadTask = StubURLSessionDownloadTask() + downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask let firstDownloadToken = sut.scheduleDownload(url: url) { _ in } @@ -832,6 +815,7 @@ class AssetDownloadsSessionTests: XCTestCase { resumeDataHandler(Data("resumption".utf8)) let resumedTask = StubURLSessionDownloadTask() + resumedTask.taskIdentifierToReturn = 2 session.downloadTaskWithResumeDataToReturn = resumedTask var results = [Result]() @@ -875,6 +859,7 @@ class AssetDownloadsSessionTests: XCTestCase { } let resumedTask = StubURLSessionDownloadTask() + resumedTask.taskIdentifierToReturn = 2 session.downloadTaskWithResumeDataToReturn = resumedTask var results = [Result]() diff --git a/PausableDownloads-ExampleTests/Tests/ImageGalleryViewModelTests.swift b/PausableDownloads-ExampleTests/Tests/ImageGalleryViewModelTests.swift index c6d2609..c70f72e 100644 --- a/PausableDownloads-ExampleTests/Tests/ImageGalleryViewModelTests.swift +++ b/PausableDownloads-ExampleTests/Tests/ImageGalleryViewModelTests.swift @@ -145,20 +145,26 @@ final class ImageGalleryViewModelTests: XCTestCase { func test_givenLoadedImages_whenMoveToIsCalled_thenTheOutgoingAssetIsPausedAndTheIncomingOneIsLoaded() { let assetService = StubAssetService() + let downloadTokenForImageA = DownloadToken(url: imageA.url) + assetService.downloadTokenToReturn = downloadTokenForImageA + let sut = createLoadedSUT(assetService: assetService) + //the token the next load hands back, so the paused one is identifiable + assetService.downloadTokenToReturn = DownloadToken(url: imageB.url) + sut.moveTo(index: 1) XCTAssertEqual(sut.currentIndex, 1) XCTAssertEqual(assetService.events.count, 3) - guard case let .cancelLoadingImage(pausedDownloadID) = assetService.events[1] else { + guard case let .cancelLoadingImage(pausedDownloadToken) = assetService.events[1] else { XCTFail("Unexpected event") return } //the download issued for imageA, which is the page being swiped away from - XCTAssertEqual(pausedDownloadID, assetService.issuedDownloadIDs[0]) + XCTAssertEqual(pausedDownloadToken, downloadTokenForImageA) guard case let .loadImage(loadedImage, _, _) = assetService.events.last else { XCTFail("Unexpected event") @@ -171,21 +177,29 @@ final class ImageGalleryViewModelTests: XCTestCase { func test_givenAPausedImage_whenMovedBackTo_thenItsAssetIsLoadedAgain() { let assetService = StubAssetService() + assetService.downloadTokenToReturn = DownloadToken(url: imageA.url) + let sut = createLoadedSUT(assetService: assetService) + let downloadTokenForImageB = DownloadToken(url: imageB.url) + assetService.downloadTokenToReturn = downloadTokenForImageB + sut.moveTo(index: 1) + + assetService.downloadTokenToReturn = DownloadToken(url: imageA.url) + sut.moveTo(index: 0) XCTAssertEqual(sut.currentIndex, 0) XCTAssertEqual(assetService.events.count, 5) - guard case let .cancelLoadingImage(pausedDownloadID) = assetService.events[3] else { + guard case let .cancelLoadingImage(pausedDownloadToken) = assetService.events[3] else { XCTFail("Unexpected event") return } //the download issued for imageB, which is the page being swiped away from - XCTAssertEqual(pausedDownloadID, assetService.issuedDownloadIDs[1]) + XCTAssertEqual(pausedDownloadToken, downloadTokenForImageB) /* Rescheduling the same URL is what hands the paused download back to the session to resume rather than restart. diff --git a/PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift b/PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift index b5565fc..0226a53 100644 --- a/PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift +++ b/PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift @@ -153,6 +153,9 @@ final class ImageViewerViewModelTests: XCTestCase { let image = ImageDomainModel.testData(identifier: "a", url: URL(string: "http://test.com/a.jpg")!) + let downloadToken = DownloadToken(url: image.url) + assetService.downloadTokenToReturn = downloadToken + let sut = createSUT(imageDomainModel: image, assetService: assetService) sut.load() @@ -160,13 +163,13 @@ final class ImageViewerViewModelTests: XCTestCase { XCTAssertEqual(assetService.events.count, 2) - guard case let .cancelLoadingImage(cancelledDownloadID) = assetService.events.last else { + guard case let .cancelLoadingImage(cancelledDownloadToken) = assetService.events.last else { XCTFail("Unexpected event") return } //the view model pauses the download it started, not whatever shares the URL - XCTAssertEqual(cancelledDownloadID, assetService.issuedDownloadIDs.first) + XCTAssertEqual(cancelledDownloadToken, downloadToken) XCTAssertEqual(sut.state, .ready(description: image.url.absoluteString)) } @@ -182,8 +185,11 @@ final class ImageViewerViewModelTests: XCTestCase { func test_givenPausedAssetLoad_whenLoadIsCalledAgain_thenTheAssetIsRequestedAgain() { let assetService = StubAssetService() + let image = ImageDomainModel.testData() - let sut = createSUT(assetService: assetService) + assetService.downloadTokenToReturn = DownloadToken(url: image.url) + + let sut = createSUT(imageDomainModel: image, assetService: assetService) sut.load() sut.pause() From 842c70b64fd2aac21b4d9420b6848d60bda76c9d Mon Sep 17 00:00:00 2001 From: William Boles Date: Fri, 11 Sep 2026 15:18:57 +0100 Subject: [PATCH 10/16] Added DispatchSource to recieve memory warnings rather than via UIKit --- .../project.pbxproj | 48 +++++++++++---- .../PausableDownloads-Example.xcscheme | 2 +- .../Asset/AssetDownloadsSession.swift | 38 ++++-------- .../Services/Asset/AssetService.swift | 8 --- .../Asset/MemoryPressureMonitor.swift | 41 +++++++++++++ .../Doubles/StubMemoryPressureMonitor.swift | 23 +++++++ .../Doubles/StubNotificationCenter.swift | 33 ---------- .../Tests/AssetDownloadsSessionTests.swift | 60 ++++++++----------- 8 files changed, 138 insertions(+), 115 deletions(-) create mode 100644 PausableDownloads-Example/Services/Asset/MemoryPressureMonitor.swift create mode 100644 PausableDownloads-ExampleTests/Doubles/StubMemoryPressureMonitor.swift delete mode 100644 PausableDownloads-ExampleTests/Doubles/StubNotificationCenter.swift diff --git a/PausableDownloads-Example.xcodeproj/project.pbxproj b/PausableDownloads-Example.xcodeproj/project.pbxproj index 2b814e4..c6f9eeb 100644 --- a/PausableDownloads-Example.xcodeproj/project.pbxproj +++ b/PausableDownloads-Example.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 48; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -34,12 +34,13 @@ 4399D3903050B4DB009D2CEB /* StubURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D3893050B4DB009D2CEB /* StubURLSession.swift */; }; 4399D3913050B4DB009D2CEB /* StubURLSessionDownloadTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */; }; 4399D3923050B4DB009D2CEB /* StubURLSessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */; }; - 4399D3943050B4DB009D2CEB /* StubNotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D3883050B4DB009D2CEB /* StubNotificationCenter.swift */; }; 4399D3953050B4DB009D2CEB /* AssetDownloadsSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38E3050B4DB009D2CEB /* AssetDownloadsSessionTests.swift */; }; 43A1000030600011009529DF /* ImageGalleryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600001009529DF /* ImageGalleryViewController.swift */; }; 43A1000030600012009529DF /* ImageGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600002009529DF /* ImageGalleryViewModel.swift */; }; 43A1000030600013009529DF /* ImageGalleryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600003009529DF /* ImageGalleryViewModelTests.swift */; }; 43A1000030600014009529DF /* StubImageGalleryViewModelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600004009529DF /* StubImageGalleryViewModelDelegate.swift */; }; + 43C2000030700002009529DF /* MemoryPressureMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C2000030700001009529DF /* MemoryPressureMonitor.swift */; }; + 43C2000030700004009529DF /* StubMemoryPressureMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C2000030700003009529DF /* StubMemoryPressureMonitor.swift */; }; 43F8C812305418EA00150C94 /* StubAssetDownloadsSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F8C811305418EA00150C94 /* StubAssetDownloadsSession.swift */; }; 43F8C81530541F5800150C94 /* TestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F8C81330541F5800150C94 /* TestError.swift */; }; /* End PBXBuildFile section */ @@ -83,7 +84,6 @@ 437C0D4A3051ED90009529DF /* ImagesDomainModelFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesDomainModelFactory.swift; sourceTree = ""; }; 437C0D5A3051EDA0009529DF /* ImageViewerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewerViewModel.swift; sourceTree = ""; }; 437C0D6A3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubImageViewerViewModelDelegate.swift; sourceTree = ""; }; - 4399D3883050B4DB009D2CEB /* StubNotificationCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubNotificationCenter.swift; sourceTree = ""; }; 4399D3893050B4DB009D2CEB /* StubURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSession.swift; sourceTree = ""; }; 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSessionDownloadTask.swift; sourceTree = ""; }; 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSessionFactory.swift; sourceTree = ""; }; @@ -92,6 +92,8 @@ 43A1000030600002009529DF /* ImageGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryViewModel.swift; sourceTree = ""; }; 43A1000030600003009529DF /* ImageGalleryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryViewModelTests.swift; sourceTree = ""; }; 43A1000030600004009529DF /* StubImageGalleryViewModelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubImageGalleryViewModelDelegate.swift; sourceTree = ""; }; + 43C2000030700001009529DF /* MemoryPressureMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryPressureMonitor.swift; sourceTree = ""; }; + 43C2000030700003009529DF /* StubMemoryPressureMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubMemoryPressureMonitor.swift; sourceTree = ""; }; 43DF70D53051B477004E9EEA /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = ""; }; 43F8C811305418EA00150C94 /* StubAssetDownloadsSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubAssetDownloadsSession.swift; sourceTree = ""; }; 43F8C81330541F5800150C94 /* TestError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestError.swift; sourceTree = ""; }; @@ -255,6 +257,7 @@ children = ( 437C0CAF3051EC36009529DF /* AssetDownloadsSession.swift */, 437C0D3E3051EDCC009529DF /* AssetService.swift */, + 43C2000030700001009529DF /* MemoryPressureMonitor.swift */, ); path = Asset; sourceTree = ""; @@ -289,7 +292,7 @@ 4399D38D3050B4DB009D2CEB /* Doubles */ = { isa = PBXGroup; children = ( - 4399D3883050B4DB009D2CEB /* StubNotificationCenter.swift */, + 43C2000030700003009529DF /* StubMemoryPressureMonitor.swift */, 437C0D6A3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift */, 43A1000030600004009529DF /* StubImageGalleryViewModelDelegate.swift */, 4399D3893050B4DB009D2CEB /* StubURLSession.swift */, @@ -373,8 +376,9 @@ 3DE07FC11FFF0F31003C95C0 /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1130; + LastUpgradeCheck = 2660; ORGANIZATIONNAME = "William Boles"; TargetAttributes = { 3DE07FC81FFF0F31003C95C0 = { @@ -446,6 +450,7 @@ 437C0CA83051EC1A009529DF /* URLRequest+HTTPBody.swift in Sources */, 437C0CA93051EC1A009529DF /* URLRequestFactory.swift in Sources */, 437C0CAA3051EC1A009529DF /* URLSessionFactory.swift in Sources */, + 43C2000030700002009529DF /* MemoryPressureMonitor.swift in Sources */, 437C0D3D3051ED80009529DF /* ImageDomainModel.swift in Sources */, 3D63CC5F204B554700797A82 /* AppDelegate.swift in Sources */, 3D63CC5E204B554700797A82 /* ImageViewerViewController.swift in Sources */, @@ -467,7 +472,7 @@ 4399D3923050B4DB009D2CEB /* StubURLSessionFactory.swift in Sources */, 43F8C81530541F5800150C94 /* TestError.swift in Sources */, 43F8C812305418EA00150C94 /* StubAssetDownloadsSession.swift in Sources */, - 4399D3943050B4DB009D2CEB /* StubNotificationCenter.swift in Sources */, + 43C2000030700004009529DF /* StubMemoryPressureMonitor.swift in Sources */, 437C0D6B3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift in Sources */, 43A1000030600014009529DF /* StubImageGalleryViewModelDelegate.swift in Sources */, 437C0D453051FB2E009529DF /* ImageViewerViewModelTests.swift in Sources */, @@ -532,6 +537,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -543,6 +549,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -561,6 +568,7 @@ MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -593,6 +601,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -604,6 +613,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -615,7 +625,9 @@ IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; VALIDATE_PRODUCT = YES; }; @@ -627,7 +639,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = "PausableDownloads-Example/Application/Info.plist"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); PRODUCT_BUNDLE_IDENTIFIER = "com.williamboles.PausableDownloads-Example"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -641,7 +656,10 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = "PausableDownloads-Example/Application/Info.plist"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); PRODUCT_BUNDLE_IDENTIFIER = "com.williamboles.PausableDownloads-Example"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -655,7 +673,11 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = "PausableDownloads-ExampleTests/Info.plist"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); PRODUCT_BUNDLE_IDENTIFIER = "com.williamboles.PausableDownloads-ExampleTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -670,7 +692,11 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = "PausableDownloads-ExampleTests/Info.plist"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); PRODUCT_BUNDLE_IDENTIFIER = "com.williamboles.PausableDownloads-ExampleTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; diff --git a/PausableDownloads-Example.xcodeproj/xcshareddata/xcschemes/PausableDownloads-Example.xcscheme b/PausableDownloads-Example.xcodeproj/xcshareddata/xcschemes/PausableDownloads-Example.xcscheme index f6aa99e..d0aa298 100644 --- a/PausableDownloads-Example.xcodeproj/xcshareddata/xcschemes/PausableDownloads-Example.xcscheme +++ b/PausableDownloads-Example.xcodeproj/xcshareddata/xcschemes/PausableDownloads-Example.xcscheme @@ -1,6 +1,6 @@ Void) -> NSObjectProtocol -} - -extension NotificationCenter: NotificationCenterType { } - typealias DownloadCompletionHandler = ((_ result: Result) -> ()) /* Identifies one caller's interest in a URL rather than one download, so several callers @@ -67,6 +57,7 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { private let queue = DispatchQueue(label: "com.williamboles.downloadssession") private var session: URLSessionType! + private let memoryPressureMonitor: MemoryPressureMonitor // MARK: - Singleton @@ -75,11 +66,16 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { // MARK: - Init init(urlSessionFactory: URLSessionFactoryType = URLSessionFactory(), - notificationCenter: NotificationCenterType = NotificationCenter.default) { + memoryPressureMonitor: MemoryPressureMonitor = DefaultMemoryPressureMonitor()) { + self.memoryPressureMonitor = memoryPressureMonitor + super.init() self.session = urlSessionFactory.defaultSession(delegate: self) - registerForNotifications(on: notificationCenter) + + memoryPressureMonitor.startMonitoring { [weak self] in + self?.purgePausedDownloads() + } } // MARK: - State @@ -106,24 +102,12 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { }.map { (url: $0.key, download: $0.value) } } - // MARK: - Notification - - private func registerForNotifications(on notificationCenter: NotificationCenterType) { - notificationCenter.addObserver(forName: UIApplication.didReceiveMemoryWarningNotification, - object: nil, - queue: .main) { [weak self] _ in - self?.purgePausedDownloads() - } - } + // MARK: - MemoryPressure private func purgePausedDownloads() { sync { - os_log(.info, "Purging paused items") + os_log(.info, "Purging paused items under memory pressure") - //Only a paused download occupies memory without anybody waiting on it. Dropping - //one that's still pausing would strand whoever joined it and lose the record of a - //cancel we've already issued, so the next schedule would start a second task for a - //URL that already has one winding down. downloads = downloads.filter { !$0.value.stage.isPaused } } } diff --git a/PausableDownloads-Example/Services/Asset/AssetService.swift b/PausableDownloads-Example/Services/Asset/AssetService.swift index a8b51cd..982f6e7 100644 --- a/PausableDownloads-Example/Services/Asset/AssetService.swift +++ b/PausableDownloads-Example/Services/Asset/AssetService.swift @@ -15,9 +15,6 @@ struct LoadImageResult: Equatable { } protocol AssetService { - /* Returns the id of the download it started, or nil when the asset was already - cached locally and there's nothing to pause. - */ @discardableResult func loadImage(_ imageDomainModel: ImageDomainModel, callbackQueue: DispatchQueue, @@ -92,11 +89,6 @@ final class DefaultAssetService: AssetService { } do { - /* Callers that coalesced onto one download each write these same bytes to - the same path. The writes are atomic, sequential and identical, so the - redundancy costs a little disk churn and nothing else - deduplicating it - would mean moving caching down into the download session. - */ try data.write(to: imageDomainModel.cachedLocalAssetURL(), options: .atomic) } catch let error { callbackQueue.async { diff --git a/PausableDownloads-Example/Services/Asset/MemoryPressureMonitor.swift b/PausableDownloads-Example/Services/Asset/MemoryPressureMonitor.swift new file mode 100644 index 0000000..6b8fa70 --- /dev/null +++ b/PausableDownloads-Example/Services/Asset/MemoryPressureMonitor.swift @@ -0,0 +1,41 @@ +// +// MemoryPressureMonitor.swift +// PausableDownloads-Example +// +// Created by William Boles on 11/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import Foundation + +protocol MemoryPressureMonitor { + func startMonitoring(handler: @escaping () -> Void) +} + +final class DefaultMemoryPressureMonitor: MemoryPressureMonitor { + private var source: DispatchSourceMemoryPressure + private let queue: DispatchQueue + + // MARK: - Init + + init() { + let queue = DispatchQueue(label: "com.williamboles.memorypressure") + let source = DispatchSource.makeMemoryPressureSource(eventMask: [.warning, .critical], + queue: queue) + self.queue = queue + self.source = source + } + + // MARK: - Monitoring + + func startMonitoring(handler: @escaping () -> Void) { + source.setEventHandler(handler: handler) + source.resume() + } + + // MARK: - Deinit + + deinit { + source.cancel() + } +} diff --git a/PausableDownloads-ExampleTests/Doubles/StubMemoryPressureMonitor.swift b/PausableDownloads-ExampleTests/Doubles/StubMemoryPressureMonitor.swift new file mode 100644 index 0000000..723bdc3 --- /dev/null +++ b/PausableDownloads-ExampleTests/Doubles/StubMemoryPressureMonitor.swift @@ -0,0 +1,23 @@ +// +// StubMemoryPressureMonitor.swift +// PausableDownloads-ExampleTests +// +// Created by William Boles on 11/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import Foundation + +@testable import PausableDownloads_Example + +class StubMemoryPressureMonitor: MemoryPressureMonitor { + enum Event { + case startMonitoring(() -> Void) + } + + private(set) var events = [Event]() + + func startMonitoring(handler: @escaping () -> Void) { + events.append(.startMonitoring(handler)) + } +} diff --git a/PausableDownloads-ExampleTests/Doubles/StubNotificationCenter.swift b/PausableDownloads-ExampleTests/Doubles/StubNotificationCenter.swift deleted file mode 100644 index 58eeeeb..0000000 --- a/PausableDownloads-ExampleTests/Doubles/StubNotificationCenter.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// StubNotificationCenter.swift -// PausableDownloads-ExampleTests -// -// Created by William Boles on 14/12/2019. -// Copyright © 2019 William Boles. All rights reserved. -// - -import Foundation - -@testable import PausableDownloads_Example - -class StubNotificationCenter: NotificationCenterType { - enum Event { - case addObserver(NSNotification.Name?, Any?, OperationQueue?, (Notification) -> Void) - } - - private(set) var events = [Event]() - - var objectToReturn: NSObjectProtocol! = NSObject() - - func addObserver(forName name: NSNotification.Name?, - object obj: Any?, - queue: OperationQueue?, - using block: @escaping (Notification) -> Void) -> NSObjectProtocol { - events.append(.addObserver(name, - obj, - queue, - block)) - - return objectToReturn - } -} diff --git a/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift b/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift index d42f2f1..7a9908d 100644 --- a/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift +++ b/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift @@ -33,36 +33,30 @@ class AssetDownloadsSessionTests: XCTestCase { XCTAssertNil(queue) } - // MARK: Notification + // MARK: MemoryPressure - func test_givenNotificationCenter_whenInitialised_thenObserverIsAddedForMemoryWarningOnMainQueue() { - let notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() + func test_givenMemoryPressureMonitor_whenInitialised_thenMonitoringIsStarted() { + let memoryPressureMonitor = StubMemoryPressureMonitor() - _ = createSUT(notificationCenter: notificationCenter) + _ = createSUT(memoryPressureMonitor: memoryPressureMonitor) - XCTAssertEqual(notificationCenter.events.count, 1) + XCTAssertEqual(memoryPressureMonitor.events.count, 1) - guard case let .addObserver(name, object, queue, _) = notificationCenter.events.first else { + guard case .startMonitoring = memoryPressureMonitor.events.first else { XCTFail("Unexpected event") return } - - XCTAssertEqual(name, UIApplication.didReceiveMemoryWarningNotification) - XCTAssertNil(object) - XCTAssertTrue(queue === OperationQueue.main) } - func test_givenPausedDownload_whenMemoryWarningNotificationIsReceived_thenTheDownloadIsDiscarded() { + func test_givenPausedDownload_whenMemoryPressureIsReceived_thenTheDownloadIsDiscarded() { let url = URL(string: "http://test.com/example")! - let notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() + let memoryPressureMonitor = StubMemoryPressureMonitor() let session = StubURLSession() - let sut = createSUT(session: session, notificationCenter: notificationCenter) + let sut = createSUT(session: session, memoryPressureMonitor: memoryPressureMonitor) - guard case let .addObserver(_, _, _, notificationBlock) = notificationCenter.events.first else { + guard case let .startMonitoring(memoryPressureHandler) = memoryPressureMonitor.events.first else { XCTFail("Unexpected event") return } @@ -85,8 +79,7 @@ class AssetDownloadsSessionTests: XCTestCase { resumeDataHandler(Data("resumption".utf8)) - let notification = Notification(name: UIApplication.didReceiveMemoryWarningNotification) - notificationBlock(notification) + memoryPressureHandler() //the purged item took its resumption data with it, so the next schedule starts over session.downloadTaskWithResumeDataToReturn = StubURLSessionDownloadTask() @@ -101,19 +94,18 @@ class AssetDownloadsSessionTests: XCTestCase { } } - func test_givenActiveDownload_whenMemoryWarningNotificationIsReceived_thenTheDownloadTaskIsNotCancelled() { + func test_givenActiveDownload_whenMemoryPressureIsReceived_thenTheDownloadTaskIsNotCancelled() { let url = URL(string: "http://test.com/example")! - let notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() + let memoryPressureMonitor = StubMemoryPressureMonitor() let session = StubURLSession() - let sut = createSUT(session: session, notificationCenter: notificationCenter) + let sut = createSUT(session: session, memoryPressureMonitor: memoryPressureMonitor) let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - guard case let .addObserver(_, _, _, notificationBlock) = notificationCenter.events.first else { + guard case let .startMonitoring(memoryPressureHandler) = memoryPressureMonitor.events.first else { XCTFail("Unexpected event") return } @@ -122,8 +114,7 @@ class AssetDownloadsSessionTests: XCTestCase { XCTAssertEqual(downloadTask.events.count, 1) - let notification = Notification(name: UIApplication.didReceiveMemoryWarningNotification) - notificationBlock(notification) + memoryPressureHandler() XCTAssertEqual(downloadTask.events.count, 1) @@ -833,16 +824,15 @@ class AssetDownloadsSessionTests: XCTestCase { XCTAssertEqual(results.count, 1) } - func test_givenADownloadThatIsPausing_whenAMemoryWarningIsReceived_thenAJoinedCallerIsStillAnswered() { + func test_givenADownloadThatIsPausing_whenMemoryPressureIsReceived_thenAJoinedCallerIsStillAnswered() { let url = URL(string: "http://test.com/example")! - let notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() + let memoryPressureMonitor = StubMemoryPressureMonitor() let session = StubURLSession() - let sut = createSUT(session: session, notificationCenter: notificationCenter) + let sut = createSUT(session: session, memoryPressureMonitor: memoryPressureMonitor) - guard case let .addObserver(_, _, _, notificationBlock) = notificationCenter.events.first else { + guard case let .startMonitoring(memoryPressureHandler) = memoryPressureMonitor.events.first else { XCTFail("Unexpected event") return } @@ -866,7 +856,7 @@ class AssetDownloadsSessionTests: XCTestCase { sut.scheduleDownload(url: url) { results.append($0) } //purging must leave a pause in flight alone or the caller that joined it is stranded - notificationBlock(Notification(name: UIApplication.didReceiveMemoryWarningNotification)) + memoryPressureHandler() resumeDataHandler(Data("resumption".utf8)) @@ -918,17 +908,17 @@ class AssetDownloadsSessionTests: XCTestCase { extension AssetDownloadsSessionTests { func createSUT(session: StubURLSession = StubURLSession(), - notificationCenter: NotificationCenterType = StubNotificationCenter()) -> DefaultAssetDownloadsSession { + memoryPressureMonitor: MemoryPressureMonitor = StubMemoryPressureMonitor()) -> DefaultAssetDownloadsSession { let urlSessionFactory = StubURLSessionFactory() urlSessionFactory.sessionToReturn = session return createSUT(urlSessionFactory: urlSessionFactory, - notificationCenter: notificationCenter) + memoryPressureMonitor: memoryPressureMonitor) } func createSUT(urlSessionFactory: URLSessionFactoryType, - notificationCenter: NotificationCenterType = StubNotificationCenter()) -> DefaultAssetDownloadsSession { + memoryPressureMonitor: MemoryPressureMonitor = StubMemoryPressureMonitor()) -> DefaultAssetDownloadsSession { DefaultAssetDownloadsSession(urlSessionFactory: urlSessionFactory, - notificationCenter: notificationCenter) + memoryPressureMonitor: memoryPressureMonitor) } } From a5abd9aadab713f1dc939f43b4a093b10cbd8f94 Mon Sep 17 00:00:00 2001 From: William Boles Date: Fri, 11 Sep 2026 15:41:26 +0100 Subject: [PATCH 11/16] Moved more functionality into to make its purpose more understandable --- .../project.pbxproj | 8 +-- ...ry.swift => ImagesURLRequestFactory.swift} | 6 +- .../Networking/URLSessionFactory.swift | 13 ++-- .../Images/ImagesRepository.swift | 4 +- .../Asset/AssetDownloadsSession.swift | 62 +++++++------------ 5 files changed, 39 insertions(+), 54 deletions(-) rename PausableDownloads-Example/Networking/{CatImagesURLRequestFactory.swift => ImagesURLRequestFactory.swift} (57%) diff --git a/PausableDownloads-Example.xcodeproj/project.pbxproj b/PausableDownloads-Example.xcodeproj/project.pbxproj index c6f9eeb..b6d4e77 100644 --- a/PausableDownloads-Example.xcodeproj/project.pbxproj +++ b/PausableDownloads-Example.xcodeproj/project.pbxproj @@ -13,7 +13,7 @@ 3D63CC58204B554700797A82 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3D63CC2F204B554700797A82 /* Main.storyboard */; }; 3D63CC5E204B554700797A82 /* ImageViewerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D63CC3E204B554700797A82 /* ImageViewerViewController.swift */; }; 3D63CC5F204B554700797A82 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D63CC40204B554700797A82 /* AppDelegate.swift */; }; - 437C0CA63051EC1A009529DF /* CatImagesURLRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CA33051EC1A009529DF /* CatImagesURLRequestFactory.swift */; }; + 437C0CA63051EC1A009529DF /* ImagesURLRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CA33051EC1A009529DF /* ImagesURLRequestFactory.swift */; }; 437C0CA73051EC1A009529DF /* RequestConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0C9F3051EC1A009529DF /* RequestConfig.swift */; }; 437C0CA83051EC1A009529DF /* URLRequest+HTTPBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CA03051EC1A009529DF /* URLRequest+HTTPBody.swift */; }; 437C0CA93051EC1A009529DF /* URLRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CA13051EC1A009529DF /* URLRequestFactory.swift */; }; @@ -69,7 +69,7 @@ 437C0C9F3051EC1A009529DF /* RequestConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestConfig.swift; sourceTree = ""; }; 437C0CA03051EC1A009529DF /* URLRequest+HTTPBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest+HTTPBody.swift"; sourceTree = ""; }; 437C0CA13051EC1A009529DF /* URLRequestFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLRequestFactory.swift; sourceTree = ""; }; - 437C0CA33051EC1A009529DF /* CatImagesURLRequestFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatImagesURLRequestFactory.swift; sourceTree = ""; }; + 437C0CA33051EC1A009529DF /* ImagesURLRequestFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesURLRequestFactory.swift; sourceTree = ""; }; 437C0CA43051EC1A009529DF /* URLSessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionFactory.swift; sourceTree = ""; }; 437C0CAB3051EC36009529DF /* ImageDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDTO.swift; sourceTree = ""; }; 437C0CAC3051EC36009529DF /* ImagesRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesRepository.swift; sourceTree = ""; }; @@ -229,7 +229,7 @@ isa = PBXGroup; children = ( 437C0CA23051EC1A009529DF /* Abstract */, - 437C0CA33051EC1A009529DF /* CatImagesURLRequestFactory.swift */, + 437C0CA33051EC1A009529DF /* ImagesURLRequestFactory.swift */, 437C0CA43051EC1A009529DF /* URLSessionFactory.swift */, ); path = Networking; @@ -439,7 +439,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 437C0CA63051EC1A009529DF /* CatImagesURLRequestFactory.swift in Sources */, + 437C0CA63051EC1A009529DF /* ImagesURLRequestFactory.swift in Sources */, 437C0CB33051EC36009529DF /* AssetDownloadsSession.swift in Sources */, 437C0CB43051EC36009529DF /* ImageDTO.swift in Sources */, 437C0D3F3051EDCC009529DF /* AssetService.swift in Sources */, diff --git a/PausableDownloads-Example/Networking/CatImagesURLRequestFactory.swift b/PausableDownloads-Example/Networking/ImagesURLRequestFactory.swift similarity index 57% rename from PausableDownloads-Example/Networking/CatImagesURLRequestFactory.swift rename to PausableDownloads-Example/Networking/ImagesURLRequestFactory.swift index cecac81..bdada69 100644 --- a/PausableDownloads-Example/Networking/CatImagesURLRequestFactory.swift +++ b/PausableDownloads-Example/Networking/ImagesURLRequestFactory.swift @@ -8,14 +8,10 @@ import Foundation -class CatImagesURLRequestFactory: URLRequestFactory { +class ImagesURLRequestFactory: URLRequestFactory { // MARK: - Retrieval - //`order=RANDOM` as TheCatAPI has no chronological ordering - `ASC`/`DESC` sort by id, - //which always surfaces the same legacy images. - //`size=full` returns the originals rather than resized copies - small assets finish - //downloading before there's any chance to pause one, which is the whole point here func requestToRetrieveImages(limit: Int = 10) -> URLRequest { var request = jsonRequest(endPoint: "images/search?limit=\(limit)&order=RANDOM&size=full") request.httpMethod = HTTPRequestMethod.get.rawValue diff --git a/PausableDownloads-Example/Networking/URLSessionFactory.swift b/PausableDownloads-Example/Networking/URLSessionFactory.swift index 5ef107b..0c4b385 100644 --- a/PausableDownloads-Example/Networking/URLSessionFactory.swift +++ b/PausableDownloads-Example/Networking/URLSessionFactory.swift @@ -9,11 +9,13 @@ import Foundation protocol URLSessionFactoryType { - func defaultSession(delegate: URLSessionDelegate?, delegateQueue queue: OperationQueue?) -> URLSessionType + func defaultSession(delegate: URLSessionDelegate?, + delegateQueue queue: OperationQueue?) -> URLSessionType } extension URLSessionFactoryType { - func defaultSession(delegate: URLSessionDelegate? = nil, delegateQueue queue: OperationQueue? = nil) -> URLSessionType { + func defaultSession(delegate: URLSessionDelegate? = nil, + delegateQueue queue: OperationQueue? = nil) -> URLSessionType { return defaultSession(delegate: delegate, delegateQueue: queue) } } @@ -47,14 +49,17 @@ class URLSessionFactory: URLSessionFactoryType { // MARK: - Default - func defaultSession(delegate: URLSessionDelegate? = nil, delegateQueue queue: OperationQueue? = nil) -> URLSessionType { + func defaultSession(delegate: URLSessionDelegate? = nil, + delegateQueue queue: OperationQueue? = nil) -> URLSessionType { let configuration = URLSessionConfiguration.default //For demonstration purposes disable caching configuration.requestCachePolicy = .reloadIgnoringLocalCacheData configuration.urlCache = nil - let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: queue) + let session = URLSession(configuration: configuration, + delegate: delegate, + delegateQueue: queue) return session } diff --git a/PausableDownloads-Example/Repositories/Images/ImagesRepository.swift b/PausableDownloads-Example/Repositories/Images/ImagesRepository.swift index 0147769..b9c5e0c 100644 --- a/PausableDownloads-Example/Repositories/Images/ImagesRepository.swift +++ b/PausableDownloads-Example/Repositories/Images/ImagesRepository.swift @@ -9,13 +9,13 @@ import Foundation class ImagesRepository { - private let urlRequestFactory: CatImagesURLRequestFactory + private let urlRequestFactory: ImagesURLRequestFactory private let session: URLSession // MARK: - Init init(session: URLSession = URLSession.shared, - urlRequestFactory: CatImagesURLRequestFactory = CatImagesURLRequestFactory()) { + urlRequestFactory: ImagesURLRequestFactory = ImagesURLRequestFactory()) { self.session = session self.urlRequestFactory = urlRequestFactory } diff --git a/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift b/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift index d525bf2..ff2ea87 100644 --- a/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift +++ b/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift @@ -52,7 +52,7 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { } } - //one entry per URL - everybody who wants it shares the one download + //one entry per URL - everybody who wants it shares the same download private var downloads = [URL: Download]() private let queue = DispatchQueue(label: "com.williamboles.downloadssession") @@ -78,7 +78,7 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { } } - // MARK: - State + // MARK: - ThreadSafety //`downloads` is only ever reached from inside here, so a read-modify-write of it //stays indivisible. @@ -89,19 +89,6 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { return queue.sync(execute: body) } - //only a running download owns a task, so only a running download can be matched by one - private func runningDownload(withTaskIdentifier taskIdentifier: Int) -> (url: URL, download: Download)? { - dispatchPrecondition(condition: .onQueue(queue)) - - return downloads.first { entry in - guard case let .running(task) = entry.value.stage else { - return false - } - - return task.taskIdentifier == taskIdentifier - }.map { (url: $0.key, download: $0.value) } - } - // MARK: - MemoryPressure private func purgePausedDownloads() { @@ -146,9 +133,6 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { return token } - //Deciding to start a task and recording it both happen on the queue, so they can't be - //split apart by another caller. Replacing the whole entry is what makes resumption data - //single use - starting a task overwrites the stage holding it. private func startDownload(for url: URL, resumingFrom resumptionData: Data?, handlers: [DownloadToken: DownloadCompletionHandler]) { @@ -166,8 +150,6 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { downloads[url] = Download(handlers: handlers, stage: .running(task: task)) - //`URLSession` delivers its callbacks on its own queue, never synchronously on this - //thread, so holding the downloads queue here can't deadlock the way a cancel would task.resume() } @@ -177,23 +159,18 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { let url = token.url let taskToPause = sync { () -> URLSessionDownloadTaskType? in - //Pausing drops the caller - it isn't a result anybody is waiting to hear. A - //token that isn't in there has already been dropped, so there's nothing to do. guard var download = downloads[url], download.handlers.removeValue(forKey: token) != nil else { return nil } - //write the entry back whichever way we leave, so no return can half-update it defer { downloads[url] = download } - //somebody else still wants this URL, so the download carries on guard download.handlers.isEmpty else { os_log(.info, "Dropping a caller from a download others still want: %{public}@", url.absoluteString) return nil } - //a cancel that's already in flight will produce the resumption data on its own guard case let .running(task) = download.stage else { return nil } @@ -209,8 +186,6 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { return } - //`URLSession` can answer on the thread that cancelled, so the downloads queue - //mustn't be held here taskToPause.cancel(byProducingResumeData: { [weak self] data in self?.handleResumptionData(data, for: url) @@ -220,7 +195,6 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { private func handleResumptionData(_ data: Data?, for url: URL) { sync { - //only a pause we issued can be answered here, and only once guard var download = downloads[url], case .pausing = download.stage else { os_log(.info, "Ignoring resumption data for a download that is no longer pausing: %{public}@", url.absoluteString) @@ -228,7 +202,6 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { } guard !download.handlers.isEmpty else { - //nobody's waiting, so park the data if there is any and forget the download if there isn't if let data = data { os_log(.info, "Cancelled download task has produced resumption data of: %{public}@ for %{public}@", data.description, url.absoluteString) @@ -243,7 +216,7 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { os_log(.info, "Resumption data has landed so starting the download somebody joined: %{public}@", url.absoluteString) - //somebody asked for this URL whilst the pause was in flight + //somebody asked for this URL whilst the pause was in flight so restart download startDownload(for: url, resumingFrom: data, handlers: download.handlers) @@ -292,28 +265,39 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { private func deliverResult(forTaskWith taskIdentifier: Int, _ makeResult: () -> Result) { - let handlers = sync { () -> [DownloadCompletionHandler] in - //a download that has already been delivered went with the entry that held it - guard let running = runningDownload(withTaskIdentifier: taskIdentifier) else { + let completionHandlers = sync { () -> [DownloadCompletionHandler] in + let entry = downloads.first { entry in + guard case let .running(task) = entry.value.stage else { + return false + } + + return task.taskIdentifier == taskIdentifier + } + + guard let entry = entry else { + os_log(.info, "Unknown download finished: %{public}d", taskIdentifier) return [] } - os_log(.info, "Finished download of: %{public}@", running.url.absoluteString) + let url = entry.key + let download = entry.value + + os_log(.info, "Finished download of: %{public}@", url.absoluteString) - //the download is over for everybody who asked for it, so the entry goes with it - downloads[running.url] = nil + downloads[url] = nil - return Array(running.download.handlers.values) + return Array(download.handlers.values) } - guard !handlers.isEmpty else { + guard !completionHandlers.isEmpty else { return } //made once and handed to everybody who coalesced onto this download let result = makeResult() - handlers.forEach { $0(result) } + // can't happen within `sync` in case the callee blocks the thread + completionHandlers.forEach { $0(result) } } } From ad7d0729628b064c35932592965dd8ec953c65ed Mon Sep 17 00:00:00 2001 From: William Boles Date: Fri, 11 Sep 2026 23:24:14 +0100 Subject: [PATCH 12/16] Made Download a class to simplify interactions with it --- .../Asset/AssetDownloadsSession.swift | 296 +++++++++++------- .../Services/Asset/AssetService.swift | 2 +- .../Doubles/StubAssetDownloadsSession.swift | 2 +- .../Tests/AssetDownloadsSessionTests.swift | 127 ++++---- 4 files changed, 253 insertions(+), 174 deletions(-) diff --git a/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift b/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift index ff2ea87..80da7e5 100644 --- a/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift +++ b/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift @@ -27,31 +27,77 @@ struct DownloadToken: Hashable { protocol AssetDownloadsSession { @discardableResult - func scheduleDownload(url: URL, + func scheduleDownload(for url: URL, completionHandler: @escaping DownloadCompletionHandler) -> DownloadToken func pauseDownload(_ token: DownloadToken) } final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { - private struct Download { - var handlers: [DownloadToken: DownloadCompletionHandler] - var stage: DownloadStage - } - - private enum DownloadStage { - case running(task: URLSessionDownloadTaskType) - case pausing //cancel issued, resumption data hasn't landed yet - case paused(resumptionData: Data) + private final class Download { + let url: URL + + private(set) var completionHandlers: [DownloadToken: DownloadCompletionHandler] + private(set) var stage: DownloadStage = .ready + private(set) var task: URLSessionDownloadTaskType? + private(set) var resumptionData: Data? - var isPaused: Bool { - guard case .paused = self else { - return false + // MARK: - Init + + init(url: URL, + completionHandler: @escaping DownloadCompletionHandler, + for token: DownloadToken) { + self.url = url + self.completionHandlers = [token: completionHandler] + } + + // MARK: - Coalescing + + //a token is unique per caller, so this coalesces onto the download rather than + //replacing whoever is already waiting on it + func addCoalescedCompletionHandler(_ completionHandler: @escaping DownloadCompletionHandler, + for token: DownloadToken) { + completionHandlers[token] = completionHandler + } + + //`true` when the token was one of ours + @discardableResult + func removeCoalescedCompletionHandler(for token: DownloadToken) -> Bool { + completionHandlers.removeValue(forKey: token) != nil + } + + // MARK: - Stage + + func started(with task: URLSessionDownloadTaskType) { + stage = .running + self.task = task + resumptionData = nil + } + + func pausing() -> URLSessionDownloadTaskType? { + guard stage == .running, + let task = task else { + return nil } - return true + stage = .pausing + + return task + } + + func paused(with resumptionData: Data) { + stage = .paused + self.resumptionData = resumptionData + task = nil } } + private enum DownloadStage { + case ready //constructed, no task yet - lives for one `sync` block + case running + case pausing //cancel issued, resumption data hasn't landed yet + case paused + } + //one entry per URL - everybody who wants it shares the same download private var downloads = [URL: Download]() private let queue = DispatchQueue(label: "com.williamboles.downloadssession") @@ -95,60 +141,80 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { sync { os_log(.info, "Purging paused items under memory pressure") - downloads = downloads.filter { !$0.value.stage.isPaused } + downloads = downloads.filter { $0.value.stage != .paused } } } // MARK: - Schedule @discardableResult - func scheduleDownload(url: URL, + func scheduleDownload(for url: URL, completionHandler: @escaping DownloadCompletionHandler) -> DownloadToken { let token = DownloadToken(url: url) sync { - guard var download = downloads[url] else { - startDownload(for: url, - resumingFrom: nil, - handlers: [token: completionHandler]) - return - } - - //a download for `url` already exists so coalescing this new request with it - download.handlers[token] = completionHandler - - //a paused download is the only one with nothing already on its way - guard case let .paused(resumptionData) = download.stage else { - os_log(.info, "Joining an existing download of: %{public}@", url.absoluteString) - - downloads[url] = download - return + if let download = downloads[url] { + coalesceWithExistingDownload(download, + completionHandler: completionHandler, + for: token) + } else { + scheduleNewDownload(for: url, + completionHandler: completionHandler, + token: token) } - - startDownload(for: url, - resumingFrom: resumptionData, - handlers: download.handlers) } return token } - private func startDownload(for url: URL, - resumingFrom resumptionData: Data?, - handlers: [DownloadToken: DownloadCompletionHandler]) { + private func scheduleNewDownload(for url: URL, + completionHandler: @escaping DownloadCompletionHandler, + token: DownloadToken) { + dispatchPrecondition(condition: .onQueue(queue)) + + let download = Download(url: url, + completionHandler: completionHandler, + for: token) + downloads[url] = download + + startDownload(download, + resumingFrom: nil) + } + + private func coalesceWithExistingDownload(_ download: Download, + completionHandler: @escaping DownloadCompletionHandler, + for token: DownloadToken) { + dispatchPrecondition(condition: .onQueue(queue)) + + //a download for `url` already exists so coalescing this new request with it + download.addCoalescedCompletionHandler(completionHandler, + for: token) + + //a paused download is the only one with nothing already on its way + guard download.stage == .paused else { + os_log(.info, "Joining an existing active download of: %{public}@", download.url.absoluteString) + + return + } + + startDownload(download, + resumingFrom: download.resumptionData) + } + + private func startDownload(_ download: Download, + resumingFrom resumptionData: Data?) { dispatchPrecondition(condition: .onQueue(queue)) let task: URLSessionDownloadTaskType if let resumptionData = resumptionData { - os_log(.info, "Resuming an existing download: %{public}@", url.absoluteString) + os_log(.info, "Resuming an existing paused download: %{public}@", download.url.absoluteString) task = session.downloadTask(withResumeData: resumptionData) } else { - os_log(.info, "Creating a new download: %{public}@", url.absoluteString) - task = session.downloadTask(with: url) + os_log(.info, "Creating a new download: %{public}@", download.url.absoluteString) + task = session.downloadTask(with: download.url) } - downloads[url] = Download(handlers: handlers, - stage: .running(task: task)) + download.started(with: task) task.resume() } @@ -159,26 +225,22 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { let url = token.url let taskToPause = sync { () -> URLSessionDownloadTaskType? in - guard var download = downloads[url], - download.handlers.removeValue(forKey: token) != nil else { + guard let download = downloads[url], + download.removeCoalescedCompletionHandler(for: token) else { return nil } - defer { downloads[url] = download } - - guard download.handlers.isEmpty else { + guard download.completionHandlers.isEmpty else { os_log(.info, "Dropping a caller from a download others still want: %{public}@", url.absoluteString) return nil } - guard case let .running(task) = download.stage else { + guard let task = download.pausing() else { return nil } os_log(.info, "Pausing download: %{public}@", url.absoluteString) - download.stage = .pausing - return task } @@ -187,39 +249,39 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { } taskToPause.cancel(byProducingResumeData: { [weak self] data in - self?.handleResumptionData(data, - for: url) + self?.finishPausing(for: url, + resumptionData: data) }) } - private func handleResumptionData(_ data: Data?, - for url: URL) { + private func finishPausing(for url: URL, + resumptionData: Data?) { sync { - guard var download = downloads[url], - case .pausing = download.stage else { + guard let download = downloads[url], + download.stage == .pausing else { os_log(.info, "Ignoring resumption data for a download that is no longer pausing: %{public}@", url.absoluteString) return } - guard !download.handlers.isEmpty else { - if let data = data { - os_log(.info, "Cancelled download task has produced resumption data of: %{public}@ for %{public}@", data.description, url.absoluteString) - - download.stage = .paused(resumptionData: data) - downloads[url] = download - } else { - downloads[url] = nil - } + guard download.completionHandlers.isEmpty else { + os_log(.info, "Restarting download: %{public}@", url.absoluteString) + //whilst this download was being paused, another request came in for download so restart the download + startDownload(download, + resumingFrom: resumptionData) return } - os_log(.info, "Resumption data has landed so starting the download somebody joined: %{public}@", url.absoluteString) + guard let resumptionData = resumptionData else { + os_log(.info, "Dropping a paused download that produced no resumption data: %{public}@", url.absoluteString) + + downloads[url] = nil + return + } - //somebody asked for this URL whilst the pause was in flight so restart download - startDownload(for: url, - resumingFrom: data, - handlers: download.handlers) + os_log(.info, "Cancelled download task has produced resumption data of: %{public}@ for %{public}@", resumptionData.description, url.absoluteString) + + download.paused(with: resumptionData) } } @@ -239,63 +301,67 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { os_log(.info, "Resuming download: %{public}@ from: %{public}.02f%%", url.absoluteString, resumptionPercentage) } - func handleFinishedDownloading(forTaskWith taskIdentifier: Int, + func handleFinishedDownloading(for url: URL, + taskIdentifier: Int, to location: URL) { - deliverResult(forTaskWith: taskIdentifier) { - do { - return .success(try Data(contentsOf: location)) - } catch let error { - return .failure(NetworkingError.invalidData(underlyingError: error)) - } + let result: Result + do { + result = .success(try Data(contentsOf: location)) + } catch let error { + result = .failure(NetworkingError.invalidData(underlyingError: error)) } + + os_log(.info, "Download completed for: %{public}@", url.absoluteString) + + deliverResult(result, + for: url, + taskIdentifier: taskIdentifier) } - func handleComplete(forTaskWith taskIdentifier: Int, - error: Error?) { + func handleFailedDownloading(for url: URL, + taskIdentifier: Int, + error: Error) { //a pause or a purge cancels the task; that isn't a failure anybody asked about if let error = error as? URLError, error.code == .cancelled { os_log(.info, "Ignoring the cancellation of task: %{public}d", taskIdentifier) return } - deliverResult(forTaskWith: taskIdentifier) { - .failure(NetworkingError.retrieval(underlyingError: error)) - } + os_log(.info, "Download failed for: %{public}@ with error: %{public}@", url.absoluteString, error.localizedDescription) + + deliverResult(.failure(NetworkingError.retrieval(underlyingError: error)), + for: url, + taskIdentifier: taskIdentifier) } - private func deliverResult(forTaskWith taskIdentifier: Int, - _ makeResult: () -> Result) { + //the result is made once and handed to everybody who coalesced onto this download + private func deliverResult(_ result: Result, + for url: URL, + taskIdentifier: Int) { + //get all completionHandlers for this url let completionHandlers = sync { () -> [DownloadCompletionHandler] in - let entry = downloads.first { entry in - guard case let .running(task) = entry.value.stage else { - return false - } - - return task.taskIdentifier == taskIdentifier + guard let download = downloads[url] else { + os_log(.info, "Ignoring an unknown download: %{public}@", url.absoluteString) + return [] } - guard let entry = entry else { - os_log(.info, "Unknown download finished: %{public}d", taskIdentifier) + //nothing should be in flight whilst pausing or paused + guard download.stage == .running else { + os_log(.info, "Ignoring a download that isn't running: %{public}@", url.absoluteString) return [] } - let url = entry.key - let download = entry.value - - os_log(.info, "Finished download of: %{public}@", url.absoluteString) + //a task this download has since replaced, winding down late + guard download.task?.taskIdentifier == taskIdentifier else { + os_log(.info, "Ignoring download where the task has been replaced: %{public}d", taskIdentifier) + return [] + } downloads[url] = nil - return Array(download.handlers.values) - } - - guard !completionHandlers.isEmpty else { - return + return Array(download.completionHandlers.values) } - //made once and handed to everybody who coalesced onto this download - let result = makeResult() - // can't happen within `sync` in case the callee blocks the thread completionHandlers.forEach { $0(result) } } @@ -335,12 +401,26 @@ extension DefaultAssetDownloadsSession: URLSessionDownloadDelegate { func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - handleFinishedDownloading(forTaskWith: downloadTask.taskIdentifier, to: location) + guard let url = downloadTask.originalRequest?.url else { + return + } + + handleFinishedDownloading(for: url, + taskIdentifier: downloadTask.taskIdentifier, + to: location) } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - handleComplete(forTaskWith: task.taskIdentifier, error: error) + //a success has already been dealt with by `didFinishDownloadingTo` + guard let error = error, + let url = task.originalRequest?.url else { + return + } + + handleFailedDownloading(for: url, + taskIdentifier: task.taskIdentifier, + error: error) } } diff --git a/PausableDownloads-Example/Services/Asset/AssetService.swift b/PausableDownloads-Example/Services/Asset/AssetService.swift index 982f6e7..c0c0e13 100644 --- a/PausableDownloads-Example/Services/Asset/AssetService.swift +++ b/PausableDownloads-Example/Services/Asset/AssetService.swift @@ -78,7 +78,7 @@ final class DefaultAssetService: AssetService { callbackQueue: DispatchQueue, completionHandler: @escaping ((_ result: Result) -> ())) -> DownloadToken { - session.scheduleDownload(url: imageDomainModel.url) { (result) in + session.scheduleDownload(for: imageDomainModel.url) { (result) in switch result { case .success(let data): guard let image = UIImage(data: data) else { diff --git a/PausableDownloads-ExampleTests/Doubles/StubAssetDownloadsSession.swift b/PausableDownloads-ExampleTests/Doubles/StubAssetDownloadsSession.swift index 31109ff..9161b59 100644 --- a/PausableDownloads-ExampleTests/Doubles/StubAssetDownloadsSession.swift +++ b/PausableDownloads-ExampleTests/Doubles/StubAssetDownloadsSession.swift @@ -20,7 +20,7 @@ final class StubAssetDownloadsSession: AssetDownloadsSession { var tokenToReturn: DownloadToken! - func scheduleDownload(url: URL, + func scheduleDownload(for url: URL, completionHandler: @escaping DownloadCompletionHandler) -> DownloadToken { events.append(.scheduleDownload(url, completionHandler)) diff --git a/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift b/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift index 7a9908d..ad1f4cd 100644 --- a/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift +++ b/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift @@ -64,7 +64,7 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - let downloadID = sut.scheduleDownload(url: url) { _ in } + let downloadID = sut.scheduleDownload(for: url) { _ in } XCTAssertEqual(session.events.count, 1) @@ -84,7 +84,7 @@ class AssetDownloadsSessionTests: XCTestCase { //the purged item took its resumption data with it, so the next schedule starts over session.downloadTaskWithResumeDataToReturn = StubURLSessionDownloadTask() - sut.scheduleDownload(url: url) { _ in } + sut.scheduleDownload(for: url) { _ in } XCTAssertEqual(session.events.count, 2) @@ -110,7 +110,7 @@ class AssetDownloadsSessionTests: XCTestCase { return } - sut.scheduleDownload(url: url) { _ in } + sut.scheduleDownload(for: url) { _ in } XCTAssertEqual(downloadTask.events.count, 1) @@ -135,7 +135,7 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - sut.scheduleDownload(url: url) { _ in } + sut.scheduleDownload(for: url) { _ in } XCTAssertEqual(downloadTask.events.count, 1) @@ -164,8 +164,8 @@ class AssetDownloadsSessionTests: XCTestCase { let urlA = URL(string: "http://example.com/resourceA")! let urlB = URL(string: "http://example.com/resourceB")! - sut.scheduleDownload(url: urlA) { _ in } - sut.scheduleDownload(url: urlB) { _ in } + sut.scheduleDownload(for: urlA) { _ in } + sut.scheduleDownload(for: urlB) { _ in } XCTAssertEqual(downloadTask.events.count, 2) @@ -187,16 +187,16 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskToReturn = downloadTask var firstResults = [Result]() - sut.scheduleDownload(url: url) { firstResults.append($0) } + sut.scheduleDownload(for: url) { firstResults.append($0) } var secondResults = [Result]() - sut.scheduleDownload(url: url) { secondResults.append($0) } + sut.scheduleDownload(for: url) { secondResults.append($0) } //a second caller coalesces onto the download that's already running XCTAssertEqual(session.events.count, 1) XCTAssertEqual(downloadTask.events.count, 1) - sut.handleComplete(forTaskWith: downloadTask.taskIdentifier, error: TestError.test) + sut.handleFailedDownloading(for: url, taskIdentifier: downloadTask.taskIdentifier, error: TestError.test) XCTAssertEqual(firstResults.count, 1) XCTAssertEqual(secondResults.count, 1) @@ -213,10 +213,10 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskToReturn = downloadTask var firstResults = [Result]() - let firstDownloadToken = sut.scheduleDownload(url: url) { firstResults.append($0) } + let firstDownloadToken = sut.scheduleDownload(for: url) { firstResults.append($0) } var secondResults = [Result]() - sut.scheduleDownload(url: url) { secondResults.append($0) } + sut.scheduleDownload(for: url) { secondResults.append($0) } sut.pauseDownload(firstDownloadToken) @@ -228,7 +228,7 @@ class AssetDownloadsSessionTests: XCTestCase { return } - sut.handleComplete(forTaskWith: downloadTask.taskIdentifier, error: TestError.test) + sut.handleFailedDownloading(for: url, taskIdentifier: downloadTask.taskIdentifier, error: TestError.test) XCTAssertEqual(secondResults.count, 1) XCTAssertTrue(firstResults.isEmpty) @@ -243,7 +243,7 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - let downloadID = sut.scheduleDownload(url: url) { _ in } + let downloadID = sut.scheduleDownload(for: url) { _ in } sut.pauseDownload(downloadID) XCTAssertEqual(downloadTask.events.count, 2) @@ -256,7 +256,7 @@ class AssetDownloadsSessionTests: XCTestCase { //a server that can't resume hands back no data, so starting over is all that's left resumeDataHandler(nil) - sut.scheduleDownload(url: url) { _ in } + sut.scheduleDownload(for: url) { _ in } XCTAssertEqual(session.events.count, 2) @@ -283,7 +283,7 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - let downloadID = sut.scheduleDownload(url: url) { _ in } + let downloadID = sut.scheduleDownload(for: url) { _ in } sut.pauseDownload(downloadID) guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { @@ -295,7 +295,7 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskWithResumeDataToReturn = resumedDownloadTask //rescheduling whilst the resumption data is still in flight - the fast swipe back - sut.scheduleDownload(url: url) { _ in } + sut.scheduleDownload(for: url) { _ in } XCTAssertEqual(session.events.count, 1) @@ -328,7 +328,7 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskToReturn = downloadTask session.downloadTaskWithResumeDataToReturn = StubURLSessionDownloadTask() - let firstDownloadID = sut.scheduleDownload(url: url) { _ in } + let firstDownloadID = sut.scheduleDownload(for: url) { _ in } sut.pauseDownload(firstDownloadID) guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { @@ -337,7 +337,7 @@ class AssetDownloadsSessionTests: XCTestCase { } //scheduled whilst the pause is still in flight, so it joins rather than starting a task - let joinedDownloadToken = sut.scheduleDownload(url: url) { _ in } + let joinedDownloadToken = sut.scheduleDownload(for: url) { _ in } XCTAssertEqual(session.events.count, 1) @@ -360,7 +360,7 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskToReturn = downloadTask var results = [Result]() - let downloadID = sut.scheduleDownload(url: url) { results.append($0) } + let downloadID = sut.scheduleDownload(for: url) { results.append($0) } guard case .downloadTask = session.events.first else { XCTFail("Unexpected event") @@ -370,13 +370,14 @@ class AssetDownloadsSessionTests: XCTestCase { sut.pauseDownload(downloadID) //pausing cancels the underlying task, which reports back as a cancellation error - sut.handleComplete(forTaskWith: downloadTask.taskIdentifier, error: URLError(.cancelled)) + sut.handleFailedDownloading(for: url, taskIdentifier: downloadTask.taskIdentifier, error: URLError(.cancelled)) XCTAssertTrue(results.isEmpty) } - func test_givenCompletedDownload_whenScheduleDownloadIsCalledForTheSameURL_thenANewDownloadTaskIsCreated() { + func test_givenCompletedDownload_whenScheduleDownloadIsCalledForTheSameURL_thenANewDownloadTaskIsCreated() throws { let url = URL(string: "http://test.com/example")! + let fileURL = try XCTUnwrap(Bundle(for: type(of: self)).url(forResource: "square", withExtension: "pdf")) let session = StubURLSession() let sut = createSUT(session: session) @@ -385,7 +386,7 @@ class AssetDownloadsSessionTests: XCTestCase { downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask - sut.scheduleDownload(url: url) { _ in } + sut.scheduleDownload(for: url) { _ in } XCTAssertEqual(session.events.count, 1) @@ -394,14 +395,14 @@ class AssetDownloadsSessionTests: XCTestCase { return } - sut.handleComplete(forTaskWith: downloadTask.taskIdentifier, error: nil) + sut.handleFinishedDownloading(for: url, taskIdentifier: downloadTask.taskIdentifier, to: fileURL) - sut.scheduleDownload(url: url) { _ in } + sut.scheduleDownload(for: url) { _ in } XCTAssertEqual(session.events.count, 2) } - func test_givenScheduledDownload_whenTheDownloadTaskCompletes_thenTheCompletionHandlerIsCalled() { + func test_givenScheduledDownload_whenTheDownloadTaskFails_thenTheCompletionHandlerIsCalled() { let url = URL(string: "http://test.com/example")! let session = StubURLSession() @@ -412,7 +413,7 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskToReturn = downloadTask let completionExpectation = expectation(description: "completionExpectation") - sut.scheduleDownload(url: url) { _ in + sut.scheduleDownload(for: url) { _ in completionExpectation.fulfill() } @@ -421,7 +422,7 @@ class AssetDownloadsSessionTests: XCTestCase { return } - sut.handleComplete(forTaskWith: downloadTask.taskIdentifier, error: nil) + sut.handleFailedDownloading(for: url, taskIdentifier: downloadTask.taskIdentifier, error: TestError.test) waitForExpectations(timeout: 3, handler: nil) } @@ -436,7 +437,7 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - let downloadID = sut.scheduleDownload(url: url) { _ in } + let downloadID = sut.scheduleDownload(for: url) { _ in } sut.pauseDownload(downloadID) guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { @@ -449,7 +450,7 @@ class AssetDownloadsSessionTests: XCTestCase { let resumedDownloadTask = StubURLSessionDownloadTask() session.downloadTaskWithResumeDataToReturn = resumedDownloadTask - sut.scheduleDownload(url: url) { _ in } + sut.scheduleDownload(for: url) { _ in } XCTAssertEqual(session.events.count, 2) @@ -483,7 +484,7 @@ class AssetDownloadsSessionTests: XCTestCase { var receivedResult: Result? let completionExpectation = expectation(description: "completionExpectation") - sut.scheduleDownload(url: url) { (result) in + sut.scheduleDownload(for: url) { (result) in receivedResult = result completionExpectation.fulfill() } @@ -493,8 +494,7 @@ class AssetDownloadsSessionTests: XCTestCase { return } - sut.handleFinishedDownloading(forTaskWith: downloadTask.taskIdentifier, to: fileURL) - sut.handleComplete(forTaskWith: downloadTask.taskIdentifier, error: nil) + sut.handleFinishedDownloading(for: url, taskIdentifier: downloadTask.taskIdentifier, to: fileURL) waitForExpectations(timeout: 3, handler: nil) @@ -518,7 +518,7 @@ class AssetDownloadsSessionTests: XCTestCase { var receivedResult: Result? let completionExpectation = expectation(description: "completionExpectation") - sut.scheduleDownload(url: url) { (result) in + sut.scheduleDownload(for: url) { (result) in receivedResult = result completionExpectation.fulfill() } @@ -528,7 +528,7 @@ class AssetDownloadsSessionTests: XCTestCase { return } - sut.handleComplete(forTaskWith: downloadTask.taskIdentifier, error: TestError.test) + sut.handleFailedDownloading(for: url, taskIdentifier: downloadTask.taskIdentifier, error: TestError.test) waitForExpectations(timeout: 3, handler: nil) @@ -554,7 +554,7 @@ class AssetDownloadsSessionTests: XCTestCase { var receivedResult: Result? let completionExpectation = expectation(description: "completionExpectation") - sut.scheduleDownload(url: url) { (result) in + sut.scheduleDownload(for: url) { (result) in receivedResult = result completionExpectation.fulfill() } @@ -564,8 +564,7 @@ class AssetDownloadsSessionTests: XCTestCase { return } - sut.handleFinishedDownloading(forTaskWith: downloadTask.taskIdentifier, to: unreadableFileURL) - sut.handleComplete(forTaskWith: downloadTask.taskIdentifier, error: nil) + sut.handleFinishedDownloading(for: url, taskIdentifier: downloadTask.taskIdentifier, to: unreadableFileURL) waitForExpectations(timeout: 3, handler: nil) @@ -586,7 +585,7 @@ class AssetDownloadsSessionTests: XCTestCase { retiredDownloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = retiredDownloadTask - let downloadID = sut.scheduleDownload(url: url) { _ in } + let downloadID = sut.scheduleDownload(for: url) { _ in } sut.pauseDownload(downloadID) guard case let .cancelByProducingResumeData(resumeDataHandler) = retiredDownloadTask.events.last else { @@ -601,14 +600,14 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskWithResumeDataToReturn = resumedDownloadTask var results = [Result]() - sut.scheduleDownload(url: url) { results.append($0) } + sut.scheduleDownload(for: url) { results.append($0) } //the task the pause retired winds down late and must not be mistaken for this download - sut.handleComplete(forTaskWith: retiredDownloadTask.taskIdentifier, error: URLError(.cancelled)) + sut.handleFailedDownloading(for: url, taskIdentifier: retiredDownloadTask.taskIdentifier, error: URLError(.cancelled)) XCTAssertTrue(results.isEmpty) - sut.handleComplete(forTaskWith: resumedDownloadTask.taskIdentifier, error: nil) + sut.handleFailedDownloading(for: url, taskIdentifier: resumedDownloadTask.taskIdentifier, error: TestError.test) XCTAssertEqual(results.count, 1) } @@ -624,15 +623,15 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskToReturn = downloadTask var results = [Result]() - sut.scheduleDownload(url: url) { results.append($0) } + sut.scheduleDownload(for: url) { results.append($0) } let unknownURL = URL(string: "http://test.com/unknown")! let unknownTaskIdentifier = 2 sut.handleProgress(for: unknownURL, totalBytesWritten: 50, expectedTotalBytes: 100) sut.handleResumption(for: unknownURL, fileOffset: 50, expectedTotalBytes: 100) - sut.handleFinishedDownloading(forTaskWith: unknownTaskIdentifier, to: URL(fileURLWithPath: "/dev/null")) - sut.handleComplete(forTaskWith: unknownTaskIdentifier, error: nil) + sut.handleFinishedDownloading(for: unknownURL, taskIdentifier: unknownTaskIdentifier, to: URL(fileURLWithPath: "/dev/null")) + sut.handleFailedDownloading(for: unknownURL, taskIdentifier: unknownTaskIdentifier, error: TestError.test) XCTAssertTrue(results.isEmpty) } @@ -648,7 +647,7 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - let downloadID = sut.scheduleDownload(url: url) { _ in } + let downloadID = sut.scheduleDownload(for: url) { _ in } sut.pauseDownload(downloadID) XCTAssertEqual(downloadTask.events.count, 2) @@ -691,14 +690,14 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskToReturn = downloadTask var firstResult: Result? - sut.scheduleDownload(url: url) { firstResult = $0 } + sut.scheduleDownload(for: url) { firstResult = $0 } var secondResult: Result? - sut.scheduleDownload(url: url) { secondResult = $0 } + sut.scheduleDownload(for: url) { secondResult = $0 } XCTAssertEqual(session.events.count, 1) - sut.handleFinishedDownloading(forTaskWith: downloadTask.taskIdentifier, to: fileURL) + sut.handleFinishedDownloading(for: url, taskIdentifier: downloadTask.taskIdentifier, to: fileURL) //one read of the file, handed to everybody who coalesced onto the download guard case let .success(firstData) = try XCTUnwrap(firstResult), @@ -720,8 +719,8 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - let firstDownloadToken = sut.scheduleDownload(url: url) { _ in } - let secondDownloadToken = sut.scheduleDownload(url: url) { _ in } + let firstDownloadToken = sut.scheduleDownload(for: url) { _ in } + let secondDownloadToken = sut.scheduleDownload(for: url) { _ in } sut.pauseDownload(firstDownloadToken) @@ -748,7 +747,7 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - let firstDownloadToken = sut.scheduleDownload(url: url) { _ in } + let firstDownloadToken = sut.scheduleDownload(for: url) { _ in } sut.pauseDownload(firstDownloadToken) guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { @@ -762,10 +761,10 @@ class AssetDownloadsSessionTests: XCTestCase { //both scheduled whilst the pause is still in flight, so both join it var secondResults = [Result]() - sut.scheduleDownload(url: url) { secondResults.append($0) } + sut.scheduleDownload(for: url) { secondResults.append($0) } var thirdResults = [Result]() - sut.scheduleDownload(url: url) { thirdResults.append($0) } + sut.scheduleDownload(for: url) { thirdResults.append($0) } XCTAssertEqual(session.events.count, 1) @@ -779,7 +778,7 @@ class AssetDownloadsSessionTests: XCTestCase { return } - sut.handleComplete(forTaskWith: resumedTask.taskIdentifier, error: TestError.test) + sut.handleFailedDownloading(for: url, taskIdentifier: resumedTask.taskIdentifier, error: TestError.test) XCTAssertEqual(secondResults.count, 1) XCTAssertEqual(thirdResults.count, 1) @@ -795,7 +794,7 @@ class AssetDownloadsSessionTests: XCTestCase { downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask - let firstDownloadToken = sut.scheduleDownload(url: url) { _ in } + let firstDownloadToken = sut.scheduleDownload(for: url) { _ in } sut.pauseDownload(firstDownloadToken) guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { @@ -810,16 +809,16 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskWithResumeDataToReturn = resumedTask var results = [Result]() - sut.scheduleDownload(url: url) { results.append($0) } + sut.scheduleDownload(for: url) { results.append($0) } /* The retired task winds down with a real error rather than a cancellation, so nothing but the phase stops it being mistaken for the download now running. */ - sut.handleComplete(forTaskWith: downloadTask.taskIdentifier, error: TestError.test) + sut.handleFailedDownloading(for: url, taskIdentifier: downloadTask.taskIdentifier, error: TestError.test) XCTAssertTrue(results.isEmpty) - sut.handleComplete(forTaskWith: resumedTask.taskIdentifier, error: TestError.test) + sut.handleFailedDownloading(for: url, taskIdentifier: resumedTask.taskIdentifier, error: TestError.test) XCTAssertEqual(results.count, 1) } @@ -840,7 +839,7 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - let firstDownloadToken = sut.scheduleDownload(url: url) { _ in } + let firstDownloadToken = sut.scheduleDownload(for: url) { _ in } sut.pauseDownload(firstDownloadToken) guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { @@ -853,7 +852,7 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskWithResumeDataToReturn = resumedTask var results = [Result]() - sut.scheduleDownload(url: url) { results.append($0) } + sut.scheduleDownload(for: url) { results.append($0) } //purging must leave a pause in flight alone or the caller that joined it is stranded memoryPressureHandler() @@ -867,7 +866,7 @@ class AssetDownloadsSessionTests: XCTestCase { return } - sut.handleComplete(forTaskWith: resumedTask.taskIdentifier, error: TestError.test) + sut.handleFailedDownloading(for: url, taskIdentifier: resumedTask.taskIdentifier, error: TestError.test) XCTAssertEqual(results.count, 1) } @@ -881,7 +880,7 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - let firstDownloadToken = sut.scheduleDownload(url: url) { _ in } + let firstDownloadToken = sut.scheduleDownload(for: url) { _ in } sut.pauseDownload(firstDownloadToken) guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { @@ -893,8 +892,8 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskWithResumeDataToReturn = StubURLSessionDownloadTask() - sut.scheduleDownload(url: url) { _ in } - sut.scheduleDownload(url: url) { _ in } + sut.scheduleDownload(for: url) { _ in } + sut.scheduleDownload(for: url) { _ in } //the second caller coalesces onto the resumed download rather than starting afresh XCTAssertEqual(session.events.count, 2) From 920373f64bf2d6987464efc83dceea8fe5735d40 Mon Sep 17 00:00:00 2001 From: William Boles Date: Sat, 12 Sep 2026 22:05:17 +0100 Subject: [PATCH 13/16] Renamed and simplified a number of types --- .../project.pbxproj | 116 +++++--- .../Image/ImageLoader/ImageLoader.swift | 112 +++++++ .../Service}/ImageDomainModel.swift | 0 .../Service}/ImagesDomainModelFactory.swift | 0 .../Image/Service/ImagesService.swift | 45 +++ .../Downloader/Downloader.swift} | 227 +++++++------- .../Downloader}/MemoryPressureMonitor.swift | 0 .../Services/Asset/AssetService.swift | 130 -------- .../Services/Images/ImagesService.swift | 47 --- .../ImageGalleryViewController.swift | 6 +- .../ImageGallery/ImageGalleryViewModel.swift | 49 +-- .../ImageViewerViewController.swift | 12 +- .../ImageViewer/ImageViewerViewModel.swift | 82 +++-- .../Doubles/StubAssetDownloadsSession.swift | 33 --- .../Doubles/StubAssetService.swift | 35 --- .../Doubles/StubDownloader.swift | 33 +++ .../Doubles/StubFileManager.swift | 26 ++ .../Doubles/StubImageLoader.swift | 35 +++ .../Doubles/StubImagesService.swift | 8 +- .../TestData/Data+TestData.swift | 23 ++ ...ssionTests.swift => DownloaderTests.swift} | 165 +++++------ .../Tests/ImageGalleryViewModelTests.swift | 252 +++++++++++----- .../Tests/ImageLoaderTests.swift | 279 ++++++++++++++++++ .../Tests/ImageViewerViewModelTests.swift | 165 ++++++----- 24 files changed, 1127 insertions(+), 753 deletions(-) create mode 100644 PausableDownloads-Example/Image/ImageLoader/ImageLoader.swift rename PausableDownloads-Example/{Services/Images => Image/Service}/ImageDomainModel.swift (100%) rename PausableDownloads-Example/{Services/Images => Image/Service}/ImagesDomainModelFactory.swift (100%) create mode 100644 PausableDownloads-Example/Image/Service/ImagesService.swift rename PausableDownloads-Example/{Services/Asset/AssetDownloadsSession.swift => Networking/Downloader/Downloader.swift} (57%) rename PausableDownloads-Example/{Services/Asset => Networking/Downloader}/MemoryPressureMonitor.swift (100%) delete mode 100644 PausableDownloads-Example/Services/Asset/AssetService.swift delete mode 100644 PausableDownloads-Example/Services/Images/ImagesService.swift delete mode 100644 PausableDownloads-ExampleTests/Doubles/StubAssetDownloadsSession.swift delete mode 100644 PausableDownloads-ExampleTests/Doubles/StubAssetService.swift create mode 100644 PausableDownloads-ExampleTests/Doubles/StubDownloader.swift create mode 100644 PausableDownloads-ExampleTests/Doubles/StubFileManager.swift create mode 100644 PausableDownloads-ExampleTests/Doubles/StubImageLoader.swift create mode 100644 PausableDownloads-ExampleTests/TestData/Data+TestData.swift rename PausableDownloads-ExampleTests/Tests/{AssetDownloadsSessionTests.swift => DownloaderTests.swift} (85%) create mode 100644 PausableDownloads-ExampleTests/Tests/ImageLoaderTests.swift diff --git a/PausableDownloads-Example.xcodeproj/project.pbxproj b/PausableDownloads-Example.xcodeproj/project.pbxproj index b6d4e77..4e35362 100644 --- a/PausableDownloads-Example.xcodeproj/project.pbxproj +++ b/PausableDownloads-Example.xcodeproj/project.pbxproj @@ -18,30 +18,33 @@ 437C0CA83051EC1A009529DF /* URLRequest+HTTPBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CA03051EC1A009529DF /* URLRequest+HTTPBody.swift */; }; 437C0CA93051EC1A009529DF /* URLRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CA13051EC1A009529DF /* URLRequestFactory.swift */; }; 437C0CAA3051EC1A009529DF /* URLSessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CA43051EC1A009529DF /* URLSessionFactory.swift */; }; - 437C0CB33051EC36009529DF /* AssetDownloadsSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CAF3051EC36009529DF /* AssetDownloadsSession.swift */; }; 437C0CB43051EC36009529DF /* ImageDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CAB3051EC36009529DF /* ImageDTO.swift */; }; 437C0CB63051EC36009529DF /* ImagesRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CAC3051EC36009529DF /* ImagesRepository.swift */; }; - 437C0D3B3051ED69009529DF /* ImagesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D3A3051ED69009529DF /* ImagesService.swift */; }; - 437C0D3D3051ED80009529DF /* ImageDomainModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D3C3051ED80009529DF /* ImageDomainModel.swift */; }; - 437C0D3F3051EDCC009529DF /* AssetService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D3E3051EDCC009529DF /* AssetService.swift */; }; 437C0D413051F9F3009529DF /* StubImagesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D403051F9F3009529DF /* StubImagesService.swift */; }; - 437C0D433051FA65009529DF /* StubAssetService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D423051FA65009529DF /* StubAssetService.swift */; }; + 437C0D433051FA65009529DF /* StubImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D423051FA65009529DF /* StubImageLoader.swift */; }; 437C0D453051FB2E009529DF /* ImageViewerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D443051FB2E009529DF /* ImageViewerViewModelTests.swift */; }; 437C0D4830520236009529DF /* ImageDomainModel+TestData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D4730520236009529DF /* ImageDomainModel+TestData.swift */; }; - 437C0D4B3051ED90009529DF /* ImagesDomainModelFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D4A3051ED90009529DF /* ImagesDomainModelFactory.swift */; }; + 43F8C82530550003 /* Data+TestData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F8C82430550003 /* Data+TestData.swift */; }; 437C0D5B3051EDA0009529DF /* ImageViewerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D5A3051EDA0009529DF /* ImageViewerViewModel.swift */; }; 437C0D6B3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D6A3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift */; }; + 439551463055E52E00FE65F7 /* Downloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439551433055E52E00FE65F7 /* Downloader.swift */; }; + 439551473055E52E00FE65F7 /* MemoryPressureMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439551443055E52E00FE65F7 /* MemoryPressureMonitor.swift */; }; + 4395514F3055E55500FE65F7 /* ImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439551483055E55500FE65F7 /* ImageLoader.swift */; }; + 439551503055E55500FE65F7 /* ImagesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4395514C3055E55500FE65F7 /* ImagesService.swift */; }; + 439551513055E55500FE65F7 /* ImageDomainModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4395514A3055E55500FE65F7 /* ImageDomainModel.swift */; }; + 439551523055E55500FE65F7 /* ImagesDomainModelFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4395514B3055E55500FE65F7 /* ImagesDomainModelFactory.swift */; }; 4399D3903050B4DB009D2CEB /* StubURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D3893050B4DB009D2CEB /* StubURLSession.swift */; }; 4399D3913050B4DB009D2CEB /* StubURLSessionDownloadTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */; }; 4399D3923050B4DB009D2CEB /* StubURLSessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */; }; - 4399D3953050B4DB009D2CEB /* AssetDownloadsSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38E3050B4DB009D2CEB /* AssetDownloadsSessionTests.swift */; }; + 4399D3953050B4DB009D2CEB /* DownloaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38E3050B4DB009D2CEB /* DownloaderTests.swift */; }; 43A1000030600011009529DF /* ImageGalleryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600001009529DF /* ImageGalleryViewController.swift */; }; 43A1000030600012009529DF /* ImageGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600002009529DF /* ImageGalleryViewModel.swift */; }; 43A1000030600013009529DF /* ImageGalleryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600003009529DF /* ImageGalleryViewModelTests.swift */; }; 43A1000030600014009529DF /* StubImageGalleryViewModelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600004009529DF /* StubImageGalleryViewModelDelegate.swift */; }; - 43C2000030700002009529DF /* MemoryPressureMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C2000030700001009529DF /* MemoryPressureMonitor.swift */; }; 43C2000030700004009529DF /* StubMemoryPressureMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C2000030700003009529DF /* StubMemoryPressureMonitor.swift */; }; - 43F8C812305418EA00150C94 /* StubAssetDownloadsSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F8C811305418EA00150C94 /* StubAssetDownloadsSession.swift */; }; + 43F8C812305418EA00150C94 /* StubDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F8C811305418EA00150C94 /* StubDownloader.swift */; }; + 43F8C82330550002 /* ImageLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F8C82230550002 /* ImageLoaderTests.swift */; }; + 43F8C82130550001 /* StubFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F8C82030550001 /* StubFileManager.swift */; }; 43F8C81530541F5800150C94 /* TestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F8C81330541F5800150C94 /* TestError.swift */; }; /* End PBXBuildFile section */ @@ -73,29 +76,32 @@ 437C0CA43051EC1A009529DF /* URLSessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionFactory.swift; sourceTree = ""; }; 437C0CAB3051EC36009529DF /* ImageDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDTO.swift; sourceTree = ""; }; 437C0CAC3051EC36009529DF /* ImagesRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesRepository.swift; sourceTree = ""; }; - 437C0CAF3051EC36009529DF /* AssetDownloadsSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetDownloadsSession.swift; sourceTree = ""; }; - 437C0D3A3051ED69009529DF /* ImagesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesService.swift; sourceTree = ""; }; - 437C0D3C3051ED80009529DF /* ImageDomainModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDomainModel.swift; sourceTree = ""; }; - 437C0D3E3051EDCC009529DF /* AssetService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetService.swift; sourceTree = ""; }; 437C0D403051F9F3009529DF /* StubImagesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubImagesService.swift; sourceTree = ""; }; - 437C0D423051FA65009529DF /* StubAssetService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubAssetService.swift; sourceTree = ""; }; + 437C0D423051FA65009529DF /* StubImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubImageLoader.swift; sourceTree = ""; }; 437C0D443051FB2E009529DF /* ImageViewerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewerViewModelTests.swift; sourceTree = ""; }; 437C0D4730520236009529DF /* ImageDomainModel+TestData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageDomainModel+TestData.swift"; sourceTree = ""; }; - 437C0D4A3051ED90009529DF /* ImagesDomainModelFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesDomainModelFactory.swift; sourceTree = ""; }; + 43F8C82430550003 /* Data+TestData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+TestData.swift"; sourceTree = ""; }; 437C0D5A3051EDA0009529DF /* ImageViewerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewerViewModel.swift; sourceTree = ""; }; 437C0D6A3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubImageViewerViewModelDelegate.swift; sourceTree = ""; }; + 439551433055E52E00FE65F7 /* Downloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Downloader.swift; sourceTree = ""; }; + 439551443055E52E00FE65F7 /* MemoryPressureMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryPressureMonitor.swift; sourceTree = ""; }; + 439551483055E55500FE65F7 /* ImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLoader.swift; sourceTree = ""; }; + 4395514A3055E55500FE65F7 /* ImageDomainModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDomainModel.swift; sourceTree = ""; }; + 4395514B3055E55500FE65F7 /* ImagesDomainModelFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesDomainModelFactory.swift; sourceTree = ""; }; + 4395514C3055E55500FE65F7 /* ImagesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesService.swift; sourceTree = ""; }; 4399D3893050B4DB009D2CEB /* StubURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSession.swift; sourceTree = ""; }; 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSessionDownloadTask.swift; sourceTree = ""; }; 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSessionFactory.swift; sourceTree = ""; }; - 4399D38E3050B4DB009D2CEB /* AssetDownloadsSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetDownloadsSessionTests.swift; sourceTree = ""; }; + 4399D38E3050B4DB009D2CEB /* DownloaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloaderTests.swift; sourceTree = ""; }; 43A1000030600001009529DF /* ImageGalleryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryViewController.swift; sourceTree = ""; }; 43A1000030600002009529DF /* ImageGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryViewModel.swift; sourceTree = ""; }; 43A1000030600003009529DF /* ImageGalleryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryViewModelTests.swift; sourceTree = ""; }; 43A1000030600004009529DF /* StubImageGalleryViewModelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubImageGalleryViewModelDelegate.swift; sourceTree = ""; }; - 43C2000030700001009529DF /* MemoryPressureMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryPressureMonitor.swift; sourceTree = ""; }; 43C2000030700003009529DF /* StubMemoryPressureMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubMemoryPressureMonitor.swift; sourceTree = ""; }; 43DF70D53051B477004E9EEA /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = ""; }; - 43F8C811305418EA00150C94 /* StubAssetDownloadsSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubAssetDownloadsSession.swift; sourceTree = ""; }; + 43F8C811305418EA00150C94 /* StubDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubDownloader.swift; sourceTree = ""; }; + 43F8C82230550002 /* ImageLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLoaderTests.swift; sourceTree = ""; }; + 43F8C82030550001 /* StubFileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubFileManager.swift; sourceTree = ""; }; 43F8C81330541F5800150C94 /* TestError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestError.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -128,11 +134,11 @@ 3D63CC2B204B554700797A82 /* PausableDownloads-Example */ = { isa = PBXGroup; children = ( + 4395514E3055E55500FE65F7 /* Image */, 3D63CC3F204B554700797A82 /* Application */, 437C0CA53051EC1A009529DF /* Networking */, 437C0CAE3051EC36009529DF /* Repositories */, 3D63CC31204B554700797A82 /* Resources */, - 437C0CB23051EC36009529DF /* Services */, 3D63CC2C204B554700797A82 /* Storyboards */, 3D63CC37204B554700797A82 /* ViewControllers */, ); @@ -231,6 +237,7 @@ 437C0CA23051EC1A009529DF /* Abstract */, 437C0CA33051EC1A009529DF /* ImagesURLRequestFactory.swift */, 437C0CA43051EC1A009529DF /* URLSessionFactory.swift */, + 439551453055E52E00FE65F7 /* Downloader */, ); path = Networking; sourceTree = ""; @@ -252,41 +259,49 @@ path = Repositories; sourceTree = ""; }; - 437C0CB03051EC36009529DF /* Asset */ = { + 437C0D463052022A009529DF /* TestData */ = { isa = PBXGroup; children = ( - 437C0CAF3051EC36009529DF /* AssetDownloadsSession.swift */, - 437C0D3E3051EDCC009529DF /* AssetService.swift */, - 43C2000030700001009529DF /* MemoryPressureMonitor.swift */, + 437C0D4730520236009529DF /* ImageDomainModel+TestData.swift */, + 43F8C82430550003 /* Data+TestData.swift */, ); - path = Asset; + path = TestData; sourceTree = ""; }; - 437C0CB23051EC36009529DF /* Services */ = { + 439551453055E52E00FE65F7 /* Downloader */ = { isa = PBXGroup; children = ( - 437C0D393051ED57009529DF /* Images */, - 437C0CB03051EC36009529DF /* Asset */, + 439551433055E52E00FE65F7 /* Downloader.swift */, + 439551443055E52E00FE65F7 /* MemoryPressureMonitor.swift */, ); - path = Services; + path = Downloader; sourceTree = ""; }; - 437C0D393051ED57009529DF /* Images */ = { + 439551493055E55500FE65F7 /* ImageLoader */ = { isa = PBXGroup; children = ( - 437C0D3A3051ED69009529DF /* ImagesService.swift */, - 437C0D3C3051ED80009529DF /* ImageDomainModel.swift */, - 437C0D4A3051ED90009529DF /* ImagesDomainModelFactory.swift */, + 439551483055E55500FE65F7 /* ImageLoader.swift */, ); - path = Images; + path = ImageLoader; sourceTree = ""; }; - 437C0D463052022A009529DF /* TestData */ = { + 4395514D3055E55500FE65F7 /* Service */ = { isa = PBXGroup; children = ( - 437C0D4730520236009529DF /* ImageDomainModel+TestData.swift */, + 4395514A3055E55500FE65F7 /* ImageDomainModel.swift */, + 4395514B3055E55500FE65F7 /* ImagesDomainModelFactory.swift */, + 4395514C3055E55500FE65F7 /* ImagesService.swift */, ); - path = TestData; + path = Service; + sourceTree = ""; + }; + 4395514E3055E55500FE65F7 /* Image */ = { + isa = PBXGroup; + children = ( + 439551493055E55500FE65F7 /* ImageLoader */, + 4395514D3055E55500FE65F7 /* Service */, + ); + path = Image; sourceTree = ""; }; 4399D38D3050B4DB009D2CEB /* Doubles */ = { @@ -299,8 +314,9 @@ 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */, 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */, 437C0D403051F9F3009529DF /* StubImagesService.swift */, - 437C0D423051FA65009529DF /* StubAssetService.swift */, - 43F8C811305418EA00150C94 /* StubAssetDownloadsSession.swift */, + 437C0D423051FA65009529DF /* StubImageLoader.swift */, + 43F8C811305418EA00150C94 /* StubDownloader.swift */, + 43F8C82030550001 /* StubFileManager.swift */, ); path = Doubles; sourceTree = ""; @@ -308,7 +324,8 @@ 4399D38F3050B4DB009D2CEB /* Tests */ = { isa = PBXGroup; children = ( - 4399D38E3050B4DB009D2CEB /* AssetDownloadsSessionTests.swift */, + 4399D38E3050B4DB009D2CEB /* DownloaderTests.swift */, + 43F8C82230550002 /* ImageLoaderTests.swift */, 437C0D443051FB2E009529DF /* ImageViewerViewModelTests.swift */, 43A1000030600003009529DF /* ImageGalleryViewModelTests.swift */, ); @@ -440,21 +457,21 @@ buildActionMask = 2147483647; files = ( 437C0CA63051EC1A009529DF /* ImagesURLRequestFactory.swift in Sources */, - 437C0CB33051EC36009529DF /* AssetDownloadsSession.swift in Sources */, 437C0CB43051EC36009529DF /* ImageDTO.swift in Sources */, - 437C0D3F3051EDCC009529DF /* AssetService.swift in Sources */, 437C0CB63051EC36009529DF /* ImagesRepository.swift in Sources */, 437C0CA73051EC1A009529DF /* RequestConfig.swift in Sources */, - 437C0D3B3051ED69009529DF /* ImagesService.swift in Sources */, - 437C0D4B3051ED90009529DF /* ImagesDomainModelFactory.swift in Sources */, 437C0CA83051EC1A009529DF /* URLRequest+HTTPBody.swift in Sources */, + 439551463055E52E00FE65F7 /* Downloader.swift in Sources */, + 439551473055E52E00FE65F7 /* MemoryPressureMonitor.swift in Sources */, 437C0CA93051EC1A009529DF /* URLRequestFactory.swift in Sources */, 437C0CAA3051EC1A009529DF /* URLSessionFactory.swift in Sources */, - 43C2000030700002009529DF /* MemoryPressureMonitor.swift in Sources */, - 437C0D3D3051ED80009529DF /* ImageDomainModel.swift in Sources */, 3D63CC5F204B554700797A82 /* AppDelegate.swift in Sources */, 3D63CC5E204B554700797A82 /* ImageViewerViewController.swift in Sources */, 437C0D5B3051EDA0009529DF /* ImageViewerViewModel.swift in Sources */, + 4395514F3055E55500FE65F7 /* ImageLoader.swift in Sources */, + 439551503055E55500FE65F7 /* ImagesService.swift in Sources */, + 439551513055E55500FE65F7 /* ImageDomainModel.swift in Sources */, + 439551523055E55500FE65F7 /* ImagesDomainModelFactory.swift in Sources */, 43A1000030600011009529DF /* ImageGalleryViewController.swift in Sources */, 43A1000030600012009529DF /* ImageGalleryViewModel.swift in Sources */, ); @@ -464,20 +481,23 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 437C0D433051FA65009529DF /* StubAssetService.swift in Sources */, + 437C0D433051FA65009529DF /* StubImageLoader.swift in Sources */, 4399D3903050B4DB009D2CEB /* StubURLSession.swift in Sources */, 4399D3913050B4DB009D2CEB /* StubURLSessionDownloadTask.swift in Sources */, 437C0D4830520236009529DF /* ImageDomainModel+TestData.swift in Sources */, + 43F8C82530550003 /* Data+TestData.swift in Sources */, 437C0D413051F9F3009529DF /* StubImagesService.swift in Sources */, 4399D3923050B4DB009D2CEB /* StubURLSessionFactory.swift in Sources */, 43F8C81530541F5800150C94 /* TestError.swift in Sources */, - 43F8C812305418EA00150C94 /* StubAssetDownloadsSession.swift in Sources */, + 43F8C812305418EA00150C94 /* StubDownloader.swift in Sources */, + 43F8C82330550002 /* ImageLoaderTests.swift in Sources */, + 43F8C82130550001 /* StubFileManager.swift in Sources */, 43C2000030700004009529DF /* StubMemoryPressureMonitor.swift in Sources */, 437C0D6B3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift in Sources */, 43A1000030600014009529DF /* StubImageGalleryViewModelDelegate.swift in Sources */, 437C0D453051FB2E009529DF /* ImageViewerViewModelTests.swift in Sources */, 43A1000030600013009529DF /* ImageGalleryViewModelTests.swift in Sources */, - 4399D3953050B4DB009D2CEB /* AssetDownloadsSessionTests.swift in Sources */, + 4399D3953050B4DB009D2CEB /* DownloaderTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/PausableDownloads-Example/Image/ImageLoader/ImageLoader.swift b/PausableDownloads-Example/Image/ImageLoader/ImageLoader.swift new file mode 100644 index 0000000..0989c9f --- /dev/null +++ b/PausableDownloads-Example/Image/ImageLoader/ImageLoader.swift @@ -0,0 +1,112 @@ +// +// ImageLoader.swift +// PausableDownloads-Example +// +// Created by William Boles on 15/01/2018. +// Copyright © 2018 William Boles. All rights reserved. +// + +import Foundation +import UIKit +import os + +typealias LoadImageCompletionHandler = (Result) -> () + +//callers of `ImageLoader` only need to hand this back to `cancel`; that it is a download +//underneath is the loader's business +typealias LoadToken = DownloadToken + +protocol ImageLoader { + //returns nil when the image was served from the cache - nothing is in flight, so + //there is nothing to cancel + @discardableResult + func load(_ imageDomainModel: ImageDomainModel, + callbackQueue: DispatchQueue, + completionHandler: @escaping LoadImageCompletionHandler) -> LoadToken? + func cancel(_ token: LoadToken) +} + +final class DefaultImageLoader: ImageLoader { + private let downloader: Downloader + private let fileManager: FileManager + + // MARK: - Init + + init(downloader: Downloader = DefaultDownloader.shared, + fileManager: FileManager = FileManager.default) { + self.downloader = downloader + self.fileManager = fileManager + } + + // MARK: - Load + + @discardableResult + func load(_ imageDomainModel: ImageDomainModel, + callbackQueue: DispatchQueue, + completionHandler: @escaping LoadImageCompletionHandler) -> LoadToken? { + //hop to the caller's queue once here, so nothing further down has to remember to + let complete: LoadImageCompletionHandler = { result in + callbackQueue.async { + completionHandler(result) + } + } + + let cacheURL = cacheURL(for: imageDomainModel) + + if let image = cachedImage(at: cacheURL) { + complete(.success(image)) + + return nil + } + + return downloader.download(imageDomainModel.url) { result in + complete(result.flatMap { data in + self.imageResult(from: data, + cachingTo: cacheURL, + for: imageDomainModel) + }) + } + } + + // MARK: - Cancel + + func cancel(_ token: LoadToken) { + downloader.pause(token) + } + + // MARK: - Cache + + private func cacheURL(for imageDomainModel: ImageDomainModel) -> URL { + let cachesDirectory = fileManager.urls(for: .cachesDirectory, + in: .userDomainMask).first! + let fileName = "\(imageDomainModel.identifier).\(imageDomainModel.url.pathExtension)" + + return cachesDirectory.appendingPathComponent(fileName) + } + + private func cachedImage(at url: URL) -> UIImage? { + guard let data = try? Data(contentsOf: url) else { + return nil + } + + return UIImage(data: data) + } + + private func imageResult(from data: Data, + cachingTo cacheURL: URL, + for imageDomainModel: ImageDomainModel) -> Result { + guard let image = UIImage(data: data) else { + return .failure(NetworkingError.invalidData(underlyingError: nil)) + } + + do { + try data.write(to: cacheURL, + options: .atomic) + } catch { + os_log(.error, "Failed to cache image %{public}@: %{public}@", imageDomainModel.identifier, error.localizedDescription) + } + + //send the image regardless of write success or failure + return .success(image) + } +} diff --git a/PausableDownloads-Example/Services/Images/ImageDomainModel.swift b/PausableDownloads-Example/Image/Service/ImageDomainModel.swift similarity index 100% rename from PausableDownloads-Example/Services/Images/ImageDomainModel.swift rename to PausableDownloads-Example/Image/Service/ImageDomainModel.swift diff --git a/PausableDownloads-Example/Services/Images/ImagesDomainModelFactory.swift b/PausableDownloads-Example/Image/Service/ImagesDomainModelFactory.swift similarity index 100% rename from PausableDownloads-Example/Services/Images/ImagesDomainModelFactory.swift rename to PausableDownloads-Example/Image/Service/ImagesDomainModelFactory.swift diff --git a/PausableDownloads-Example/Image/Service/ImagesService.swift b/PausableDownloads-Example/Image/Service/ImagesService.swift new file mode 100644 index 0000000..c7d3b4e --- /dev/null +++ b/PausableDownloads-Example/Image/Service/ImagesService.swift @@ -0,0 +1,45 @@ +// +// ImagesService.swift +// PausableDownloads-Example +// +// Created by William Boles on 09/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import Foundation + +typealias LoadImagesCompletionHandler = (Result<[ImageDomainModel], Error>) -> () + +protocol ImagesService { + func load(callbackQueue: DispatchQueue, + completionHandler: @escaping LoadImagesCompletionHandler) +} + +final class DefaultImagesService: ImagesService { + private let repository: ImagesRepository + private let domainModelFactory: ImagesDomainModelFactory + + // MARK: - Init + + init(repository: ImagesRepository = ImagesRepository(), + domainModelFactory: ImagesDomainModelFactory = ImagesDomainModelFactory()) { + self.repository = repository + self.domainModelFactory = domainModelFactory + } + + // MARK: - Load + + func load(callbackQueue: DispatchQueue, + completionHandler: @escaping LoadImagesCompletionHandler) { + repository.retrieveImages { [domainModelFactory] result in + //a failure passes straight through; a success is mapped from DTOs to domain models + let images = result.map { dtos in + dtos.map { domainModelFactory.buildImage(from: $0) } + } + + callbackQueue.async { + completionHandler(images) + } + } + } +} diff --git a/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift b/PausableDownloads-Example/Networking/Downloader/Downloader.swift similarity index 57% rename from PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift rename to PausableDownloads-Example/Networking/Downloader/Downloader.swift index 80da7e5..c07642f 100644 --- a/PausableDownloads-Example/Services/Asset/AssetDownloadsSession.swift +++ b/PausableDownloads-Example/Networking/Downloader/Downloader.swift @@ -1,5 +1,5 @@ // -// AssetDownloadsSession.swift +// Downloader.swift // PausableDownloads-Example // // Created by William Boles on 14/12/2019. @@ -9,71 +9,80 @@ import Foundation import os -typealias DownloadCompletionHandler = ((_ result: Result) -> ()) +//called on whichever thread `URLSession` delivers on - callers hop to their own queue +typealias DownloadCompletionHandler = (Result) -> () -/* Identifies one caller's interest in a URL rather than one download, so several callers - can share a single download of that URL and each pause and be answered independently. The - URL comes back with the token so a pause can go straight to the download it belongs to. - */ +//identifies one caller's interest in a URL rather than one download, so several callers +//can coalesce onto a single download of that URL and each pause and be answered +//independently. The URL comes back with the token so a pause can go straight to the +//download it belongs to struct DownloadToken: Hashable { let url: URL - private let rawValue = UUID() + private let id = UUID() init(url: URL) { self.url = url } } -protocol AssetDownloadsSession { +protocol Downloader { @discardableResult - func scheduleDownload(for url: URL, - completionHandler: @escaping DownloadCompletionHandler) -> DownloadToken - func pauseDownload(_ token: DownloadToken) + func download(_ url: URL, + completionHandler: @escaping DownloadCompletionHandler) -> DownloadToken + func pause(_ token: DownloadToken) } -final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { +final class DefaultDownloader: NSObject, Downloader { private final class Download { let url: URL - private(set) var completionHandlers: [DownloadToken: DownloadCompletionHandler] + private(set) var completionHandlers = [DownloadToken: DownloadCompletionHandler]() private(set) var stage: DownloadStage = .ready private(set) var task: URLSessionDownloadTaskType? private(set) var resumptionData: Data? // MARK: - Init - init(url: URL, - completionHandler: @escaping DownloadCompletionHandler, - for token: DownloadToken) { + init(url: URL) { self.url = url - self.completionHandlers = [token: completionHandler] } - // MARK: - Coalescing + // MARK: - Callers - //a token is unique per caller, so this coalesces onto the download rather than - //replacing whoever is already waiting on it - func addCoalescedCompletionHandler(_ completionHandler: @escaping DownloadCompletionHandler, - for token: DownloadToken) { + //a token is unique per caller, so this adds to whoever is already waiting rather + //than replacing them + func add(_ completionHandler: @escaping DownloadCompletionHandler, + for token: DownloadToken) { completionHandlers[token] = completionHandler } //`true` when the token was one of ours @discardableResult - func removeCoalescedCompletionHandler(for token: DownloadToken) -> Bool { + func remove(_ token: DownloadToken) -> Bool { completionHandlers.removeValue(forKey: token) != nil } + var hasCallers: Bool { + !completionHandlers.isEmpty + } + // MARK: - Stage - func started(with task: URLSessionDownloadTaskType) { + //a download only needs a task when nothing is already on its way - freshly + //constructed, or paused with resumption data waiting to be picked up + var needsTask: Bool { + stage == .ready || stage == .paused + } + + func markRunning(with task: URLSessionDownloadTaskType) { stage = .running self.task = task resumptionData = nil } - func pausing() -> URLSessionDownloadTaskType? { + //returns the task to cancel, or nil if there isn't one running + func markPausing() -> URLSessionDownloadTaskType? { guard stage == .running, let task = task else { return nil @@ -84,41 +93,47 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { return task } - func paused(with resumptionData: Data) { + func markPaused(with resumptionData: Data) { stage = .paused self.resumptionData = resumptionData task = nil } + + //a result only counts if it came from the task this download is currently running - + //not one it has since replaced, and not one that is winding down after a pause + func isAwaiting(taskIdentifier: Int) -> Bool { + stage == .running && task?.taskIdentifier == taskIdentifier + } } - private enum DownloadStage { + private enum DownloadStage: Equatable { case ready //constructed, no task yet - lives for one `sync` block case running case pausing //cancel issued, resumption data hasn't landed yet case paused } - //one entry per URL - everybody who wants it shares the same download + //one entry per URL - everybody who wants it coalesces onto the same download private var downloads = [URL: Download]() - private let queue = DispatchQueue(label: "com.williamboles.downloadssession") + private let queue = DispatchQueue(label: "com.williamboles.downloader") - private var session: URLSessionType! + private let urlSessionFactory: URLSessionFactoryType + private lazy var session: URLSessionType = urlSessionFactory.defaultSession(delegate: self) private let memoryPressureMonitor: MemoryPressureMonitor // MARK: - Singleton - static let shared = DefaultAssetDownloadsSession() + static let shared = DefaultDownloader() // MARK: - Init init(urlSessionFactory: URLSessionFactoryType = URLSessionFactory(), memoryPressureMonitor: MemoryPressureMonitor = DefaultMemoryPressureMonitor()) { + self.urlSessionFactory = urlSessionFactory self.memoryPressureMonitor = memoryPressureMonitor super.init() - self.session = urlSessionFactory.defaultSession(delegate: self) - memoryPressureMonitor.startMonitoring { [weak self] in self?.purgePausedDownloads() } @@ -127,7 +142,7 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { // MARK: - ThreadSafety //`downloads` is only ever reached from inside here, so a read-modify-write of it - //stays indivisible. + //stays indivisible private func sync(_ body: () -> T) -> T { //`sync` isn't reentrant - trap on a nested call rather than deadlock dispatchPrecondition(condition: .notOnQueue(queue)) @@ -145,97 +160,70 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { } } - // MARK: - Schedule + // MARK: - Download @discardableResult - func scheduleDownload(for url: URL, - completionHandler: @escaping DownloadCompletionHandler) -> DownloadToken { + func download(_ url: URL, + completionHandler: @escaping DownloadCompletionHandler) -> DownloadToken { let token = DownloadToken(url: url) sync { - if let download = downloads[url] { - coalesceWithExistingDownload(download, - completionHandler: completionHandler, - for: token) + let download = downloads[url] ?? makeDownload(for: url) + download.add(completionHandler, for: token) + + if download.needsTask { + startDownload(download) } else { - scheduleNewDownload(for: url, - completionHandler: completionHandler, - token: token) + os_log(.info, "Coalescing onto an existing active download of: %{public}@", url.absoluteString) } } return token } - private func scheduleNewDownload(for url: URL, - completionHandler: @escaping DownloadCompletionHandler, - token: DownloadToken) { + private func makeDownload(for url: URL) -> Download { dispatchPrecondition(condition: .onQueue(queue)) - let download = Download(url: url, - completionHandler: completionHandler, - for: token) + let download = Download(url: url) downloads[url] = download - startDownload(download, - resumingFrom: nil) + return download } - private func coalesceWithExistingDownload(_ download: Download, - completionHandler: @escaping DownloadCompletionHandler, - for token: DownloadToken) { - dispatchPrecondition(condition: .onQueue(queue)) - - //a download for `url` already exists so coalescing this new request with it - download.addCoalescedCompletionHandler(completionHandler, - for: token) - - //a paused download is the only one with nothing already on its way - guard download.stage == .paused else { - os_log(.info, "Joining an existing active download of: %{public}@", download.url.absoluteString) - - return - } - - startDownload(download, - resumingFrom: download.resumptionData) - } - - private func startDownload(_ download: Download, - resumingFrom resumptionData: Data?) { + private func startDownload(_ download: Download) { dispatchPrecondition(condition: .onQueue(queue)) let task: URLSessionDownloadTaskType - if let resumptionData = resumptionData { - os_log(.info, "Resuming an existing paused download: %{public}@", download.url.absoluteString) + if let resumptionData = download.resumptionData { + os_log(.info, "Resuming a paused download: %{public}@", download.url.absoluteString) task = session.downloadTask(withResumeData: resumptionData) } else { - os_log(.info, "Creating a new download: %{public}@", download.url.absoluteString) + os_log(.info, "Starting a new download: %{public}@", download.url.absoluteString) task = session.downloadTask(with: download.url) } - download.started(with: task) + download.markRunning(with: task) task.resume() } // MARK: - Pause - func pauseDownload(_ token: DownloadToken) { + func pause(_ token: DownloadToken) { let url = token.url - let taskToPause = sync { () -> URLSessionDownloadTaskType? in + let taskToPause: URLSessionDownloadTaskType? = sync { guard let download = downloads[url], - download.removeCoalescedCompletionHandler(for: token) else { + download.remove(token) else { return nil } - guard download.completionHandlers.isEmpty else { - os_log(.info, "Dropping a caller from a download others still want: %{public}@", url.absoluteString) + guard !download.hasCallers else { + os_log(.info, "Dropping a coalesced caller from a download others still want: %{public}@", url.absoluteString) return nil } - guard let task = download.pausing() else { + guard let task = download.markPausing() else { return nil } @@ -263,25 +251,23 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { return } - guard download.completionHandlers.isEmpty else { - os_log(.info, "Restarting download: %{public}@", url.absoluteString) + if let resumptionData = resumptionData { + os_log(.info, "Cancelled download task has produced %{public}d bytes of resumption data for %{public}@", resumptionData.count, url.absoluteString) - //whilst this download was being paused, another request came in for download so restart the download - startDownload(download, - resumingFrom: resumptionData) - return + download.markPaused(with: resumptionData) } - guard let resumptionData = resumptionData else { - os_log(.info, "Dropping a paused download that produced no resumption data: %{public}@", url.absoluteString) + if download.hasCallers { + //whilst this download was being paused another request came in for it, so + //pick it straight back up - from the resumption data if there was any + os_log(.info, "Restarting download: %{public}@", url.absoluteString) + + startDownload(download) + } else if resumptionData == nil { + os_log(.error, "Dropping a paused download that produced no resumption data: %{public}@", url.absoluteString) downloads[url] = nil - return } - - os_log(.info, "Cancelled download task has produced resumption data of: %{public}@ for %{public}@", resumptionData.description, url.absoluteString) - - download.paused(with: resumptionData) } } @@ -304,15 +290,18 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { func handleFinishedDownloading(for url: URL, taskIdentifier: Int, to location: URL) { + //`location` is only valid until this delegate call returns, so read it now let result: Result do { result = .success(try Data(contentsOf: location)) + + os_log(.info, "Download completed for: %{public}@", url.absoluteString) } catch let error { result = .failure(NetworkingError.invalidData(underlyingError: error)) + + os_log(.error, "Download completed for: %{public}@ but its file could not be read: %{public}@", url.absoluteString, error.localizedDescription) } - os_log(.info, "Download completed for: %{public}@", url.absoluteString) - deliverResult(result, for: url, taskIdentifier: taskIdentifier) @@ -327,7 +316,7 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { return } - os_log(.info, "Download failed for: %{public}@ with error: %{public}@", url.absoluteString, error.localizedDescription) + os_log(.error, "Download failed for: %{public}@ with error: %{public}@", url.absoluteString, error.localizedDescription) deliverResult(.failure(NetworkingError.retrieval(underlyingError: error)), for: url, @@ -338,22 +327,10 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { private func deliverResult(_ result: Result, for url: URL, taskIdentifier: Int) { - //get all completionHandlers for this url - let completionHandlers = sync { () -> [DownloadCompletionHandler] in - guard let download = downloads[url] else { - os_log(.info, "Ignoring an unknown download: %{public}@", url.absoluteString) - return [] - } - - //nothing should be in flight whilst pausing or paused - guard download.stage == .running else { - os_log(.info, "Ignoring a download that isn't running: %{public}@", url.absoluteString) - return [] - } - - //a task this download has since replaced, winding down late - guard download.task?.taskIdentifier == taskIdentifier else { - os_log(.info, "Ignoring download where the task has been replaced: %{public}d", taskIdentifier) + let completionHandlers: [DownloadCompletionHandler] = sync { + guard let download = downloads[url], + download.isAwaiting(taskIdentifier: taskIdentifier) else { + os_log(.info, "Ignoring a result for a task this downloader isn't waiting on: %{public}d", taskIdentifier) return [] } @@ -362,12 +339,18 @@ final class DefaultAssetDownloadsSession: NSObject, AssetDownloadsSession { return Array(download.completionHandlers.values) } - // can't happen within `sync` in case the callee blocks the thread + //can't happen within `sync` in case the callee blocks the thread completionHandlers.forEach { $0(result) } } } -extension DefaultAssetDownloadsSession: URLSessionDownloadDelegate { +private extension URLSessionTask { + var downloadURL: URL? { + originalRequest?.url + } +} + +extension DefaultDownloader: URLSessionDownloadDelegate { // MARK: - URLSessionDownloadDelegate @@ -376,7 +359,7 @@ extension DefaultAssetDownloadsSession: URLSessionDownloadDelegate { didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { - guard let url = downloadTask.originalRequest?.url else { + guard let url = downloadTask.downloadURL else { return } @@ -389,7 +372,7 @@ extension DefaultAssetDownloadsSession: URLSessionDownloadDelegate { downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) { - guard let url = downloadTask.originalRequest?.url else { + guard let url = downloadTask.downloadURL else { return } @@ -401,7 +384,7 @@ extension DefaultAssetDownloadsSession: URLSessionDownloadDelegate { func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - guard let url = downloadTask.originalRequest?.url else { + guard let url = downloadTask.downloadURL else { return } @@ -415,7 +398,7 @@ extension DefaultAssetDownloadsSession: URLSessionDownloadDelegate { didCompleteWithError error: Error?) { //a success has already been dealt with by `didFinishDownloadingTo` guard let error = error, - let url = task.originalRequest?.url else { + let url = task.downloadURL else { return } diff --git a/PausableDownloads-Example/Services/Asset/MemoryPressureMonitor.swift b/PausableDownloads-Example/Networking/Downloader/MemoryPressureMonitor.swift similarity index 100% rename from PausableDownloads-Example/Services/Asset/MemoryPressureMonitor.swift rename to PausableDownloads-Example/Networking/Downloader/MemoryPressureMonitor.swift diff --git a/PausableDownloads-Example/Services/Asset/AssetService.swift b/PausableDownloads-Example/Services/Asset/AssetService.swift deleted file mode 100644 index c0c0e13..0000000 --- a/PausableDownloads-Example/Services/Asset/AssetService.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// AssetService.swift -// PausableDownloads-Example -// -// Created by William Boles on 15/01/2018. -// Copyright © 2018 William Boles. All rights reserved. -// - -import Foundation -import UIKit - -struct LoadImageResult: Equatable { - let imageDomainModel: ImageDomainModel - let image: UIImage -} - -protocol AssetService { - @discardableResult - func loadImage(_ imageDomainModel: ImageDomainModel, - callbackQueue: DispatchQueue, - completionHandler: @escaping ((_ result: Result) -> ())) -> DownloadToken? - func cancelLoadingImage(_ downloadToken: DownloadToken) -} - -final class DefaultAssetService: AssetService { - private let session: AssetDownloadsSession - private let fileManager: FileManager - - // MARK: - Init - - init(session: AssetDownloadsSession = DefaultAssetDownloadsSession.shared, - fileManager: FileManager = FileManager.default) { - self.session = session - self.fileManager = fileManager - } - - // MARK: - Load - - @discardableResult - func loadImage(_ imageDomainModel: ImageDomainModel, - callbackQueue: DispatchQueue, - completionHandler: @escaping ((_ result: Result) -> ())) -> DownloadToken? { - if fileManager.fileExists(atPath: imageDomainModel.cachedLocalAssetURL().path) { - return locallyLoadImage(imageDomainModel, callbackQueue: callbackQueue, completionHandler: completionHandler) - } else { - return remotelyLoadImage(imageDomainModel, callbackQueue: callbackQueue, completionHandler: completionHandler) - } - } - - private func locallyLoadImage(_ imageDomainModel: ImageDomainModel, - callbackQueue: DispatchQueue, - completionHandler: @escaping ((_ result: Result) -> ())) -> DownloadToken? { - do { - let data = try Data(contentsOf: URL(fileURLWithPath: imageDomainModel.cachedLocalAssetURL().path)) - - guard let image = UIImage(data: data) else { - callbackQueue.async { - completionHandler(.failure(NetworkingError.invalidData(underlyingError: nil))) - } - return nil - } - - let loadResult = LoadImageResult(imageDomainModel: imageDomainModel, image: image) - let dataRequestResult = Result.success(loadResult) - - callbackQueue.async { - completionHandler(dataRequestResult) - } - - return nil - } catch { - return remotelyLoadImage(imageDomainModel, callbackQueue: callbackQueue, completionHandler: completionHandler) - } - } - - @discardableResult - private func remotelyLoadImage(_ imageDomainModel: ImageDomainModel, - callbackQueue: DispatchQueue, - completionHandler: @escaping ((_ result: Result) -> ())) -> DownloadToken { - - session.scheduleDownload(for: imageDomainModel.url) { (result) in - switch result { - case .success(let data): - guard let image = UIImage(data: data) else { - callbackQueue.async { - completionHandler(.failure(NetworkingError.invalidData(underlyingError: nil))) - } - return - } - - do { - try data.write(to: imageDomainModel.cachedLocalAssetURL(), options: .atomic) - } catch let error { - callbackQueue.async { - completionHandler(.failure(NetworkingError.invalidData(underlyingError: error))) - } - return - } - - let loadResult = LoadImageResult(imageDomainModel: imageDomainModel, image: image) - let dataRequestResult = Result.success(loadResult) - - callbackQueue.async { - completionHandler(dataRequestResult) - } - case .failure(let error): - callbackQueue.async { - completionHandler(.failure(error)) - } - } - } - } - - // MARK: - Cancel - - func cancelLoadingImage(_ downloadToken: DownloadToken) { - session.pauseDownload(downloadToken) - } -} - -private extension ImageDomainModel { - // MARK: - Cache - - func cachedLocalAssetURL() -> URL { - let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).last! - let fileName = "\(identifier).\(url.pathExtension)" - - return cacheURL.appendingPathComponent(fileName) - } -} diff --git a/PausableDownloads-Example/Services/Images/ImagesService.swift b/PausableDownloads-Example/Services/Images/ImagesService.swift deleted file mode 100644 index 55ac415..0000000 --- a/PausableDownloads-Example/Services/Images/ImagesService.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// ImagesService.swift -// PausableDownloads-Example -// -// Created by William Boles on 09/09/2026. -// Copyright © 2026 William Boles. All rights reserved. -// - -import Foundation - -protocol ImagesService { - func retrieveImages(callbackQueue: DispatchQueue, - completionHandler: @escaping ((_ result: Result<[ImageDomainModel], Error>) -> ())) -} - -final class DefaultImagesService: ImagesService { - private let repository: ImagesRepository - private let domainModelFactory: ImagesDomainModelFactory - - // MARK: - Init - - init(repository: ImagesRepository = ImagesRepository(), - domainModelFactory: ImagesDomainModelFactory = ImagesDomainModelFactory()) { - self.repository = repository - self.domainModelFactory = domainModelFactory - } - - // MARK: - Retrieval - - func retrieveImages(callbackQueue: DispatchQueue, - completionHandler: @escaping ((_ result: Result<[ImageDomainModel], Error>) -> ())) { - repository.retrieveImages { [domainModelFactory] (result) in - switch result { - case .success(let dtos): - let images = dtos.map { domainModelFactory.buildImage(from: $0) } - - callbackQueue.async { - completionHandler(.success(images)) - } - case .failure(let error): - callbackQueue.async { - completionHandler(.failure(error)) - } - } - } - } -} diff --git a/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewController.swift b/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewController.swift index fdd96c0..79ef6a3 100644 --- a/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewController.swift +++ b/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewController.swift @@ -115,7 +115,7 @@ extension ImageGalleryViewController: UIPageViewControllerDelegate { return } - galleryViewModel.moveTo(index: imageViewerViewController.index) + galleryViewModel.move(to: imageViewerViewController.index) updateTitle(for: imageViewerViewController.index) } @@ -128,9 +128,9 @@ extension ImageGalleryViewController: ImageGalleryViewModelDelegate { func viewModel(_ viewModel: ImageGalleryViewModel, didChangeTo state: ImageGalleryViewModel.State) { switch state { - case .loadingImages: + case .loading: loadingActivityIndicator.startAnimating() - case .loadedImages: + case .loaded: loadingActivityIndicator.stopAnimating() showFirstImage() case .failed: diff --git a/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewModel.swift b/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewModel.swift index 6acf202..f5deb27 100644 --- a/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewModel.swift +++ b/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewModel.swift @@ -15,50 +15,53 @@ protocol ImageGalleryViewModelDelegate: AnyObject { final class ImageGalleryViewModel { enum State: Equatable { - case loadingImages - case loadedImages + case loading + case loaded case failed } weak var delegate: ImageGalleryViewModelDelegate? - private(set) var state: State = .loadingImages + private(set) var state: State = .loading private(set) var currentIndex = 0 private let imagesService: ImagesService - private let assetService: AssetService + private let imageLoader: ImageLoader private var images = [ImageDomainModel]() + + //keyed by position in `images` - safe because `images` is only ever replaced wholesale + //and this is cleared at the same moment, so the indices can't drift apart private var imageViewerViewModels = [Int: ImageViewerViewModel]() // MARK: - Init init(imagesService: ImagesService = DefaultImagesService(), - assetService: AssetService = DefaultAssetService()) { + imageLoader: ImageLoader = DefaultImageLoader()) { self.imagesService = imagesService - self.assetService = assetService + self.imageLoader = imageLoader } // MARK: - Load func load() { - transition(to: .loadingImages) + transition(to: .loading) - imagesService.retrieveImages(callbackQueue: .main) { [weak self] (result) in - guard let self = self else { + imagesService.load(callbackQueue: .main) { [weak self] result in + guard let self else { return } switch result { - case .success(let images): + case let .success(images): self.images = images self.imageViewerViewModels.removeAll() self.currentIndex = 0 - self.transition(to: .loadedImages) + self.transition(to: .loaded) - self.viewModel(at: self.currentIndex)?.load() - case .failure(_): + self.viewModel(at: self.currentIndex)?.loadImage() + case .failure: self.transition(to: .failed) } } @@ -67,11 +70,11 @@ final class ImageGalleryViewModel { // MARK: - Pages var numberOfImages: Int { - return images.count + images.count } func viewModel(at index: Int) -> ImageViewerViewModel? { - guard index >= 0 && index < images.count else { + guard images.indices.contains(index) else { return nil } @@ -80,7 +83,7 @@ final class ImageGalleryViewModel { } let viewModel = ImageViewerViewModel(imageDomainModel: images[index], - assetService: assetService) + imageLoader: imageLoader) imageViewerViewModels[index] = viewModel return viewModel @@ -88,18 +91,19 @@ final class ImageGalleryViewModel { // MARK: - Move - func moveTo(index: Int) { + func move(to index: Int) { guard index != currentIndex, - index >= 0, - index < images.count else { + images.indices.contains(index) else { return } - imageViewerViewModels[currentIndex]?.pause() + //deliberately not `viewModel(at:)` - a page that never had a view model never + //started a load, so there is nothing to cancel and no reason to create one + imageViewerViewModels[currentIndex]?.cancelImageLoad() currentIndex = index - viewModel(at: index)?.load() + viewModel(at: index)?.loadImage() } // MARK: - State @@ -107,6 +111,7 @@ final class ImageGalleryViewModel { private func transition(to state: State) { self.state = state - delegate?.viewModel(self, didChangeTo: state) + delegate?.viewModel(self, + didChangeTo: state) } } diff --git a/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift b/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift index f45e412..d5c7b5d 100644 --- a/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift +++ b/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift @@ -50,18 +50,18 @@ class ImageViewerViewController: UIViewController { private func render(_ state: ImageViewerViewModel.State) { switch state { - case .ready(let description): + case .ready: loadingActivityIndicator.stopAnimating() assetImageView.image = nil - descriptionLabel.text = description - case .loadingAsset(let description): + descriptionLabel.text = viewModel.description + case .loading: loadingActivityIndicator.startAnimating() assetImageView.image = nil - descriptionLabel.text = description - case .loadedAsset(let image, let description): + descriptionLabel.text = viewModel.description + case let .loaded(image): loadingActivityIndicator.stopAnimating() assetImageView.image = image - descriptionLabel.text = description + descriptionLabel.text = viewModel.description case .failed: loadingActivityIndicator.stopAnimating() //TODO: Handle error diff --git a/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewModel.swift b/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewModel.swift index d127120..7813549 100644 --- a/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewModel.swift +++ b/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewModel.swift @@ -15,101 +15,87 @@ protocol ImageViewerViewModelDelegate: AnyObject { final class ImageViewerViewModel { enum State: Equatable { - case ready(description: String) - case loadingAsset(description: String) - case loadedAsset(UIImage, description: String) + case ready + case loading + case loaded(UIImage) case failed } weak var delegate: ImageViewerViewModelDelegate? - private(set) var state: State + private(set) var state: State = .ready let imageDomainModel: ImageDomainModel + let description: String - private let assetService: AssetService + private let imageLoader: ImageLoader - //the download this view model started, so a pause targets its own and nobody else's - private var downloadToken: DownloadToken? + //a live token means a load is in flight - cleared when the load finishes or is cancelled + private var loadToken: LoadToken? // MARK: - Init init(imageDomainModel: ImageDomainModel, - assetService: AssetService = DefaultAssetService()) { + imageLoader: ImageLoader = DefaultImageLoader()) { self.imageDomainModel = imageDomainModel - self.assetService = assetService - self.state = .ready(description: imageDomainModel.url.absoluteString) + self.imageLoader = imageLoader + self.description = imageDomainModel.url.absoluteString } // MARK: - Load - func load() { - /* Returning to an image that has already downloaded shouldn't tear the - asset back off screen, and one that is already in flight is being taken - care of by the download session. - */ - guard !isLoaded && !isLoading else { + func loadImage() { + guard canLoad else { return } - transition(to: .loadingAsset(description: imageDomainModel.url.absoluteString)) + transition(to: .loading) - downloadToken = assetService.loadImage(imageDomainModel, callbackQueue: .main) { [weak self] (result) in + loadToken = imageLoader.load(imageDomainModel, + callbackQueue: .main) { [weak self] result in guard let self = self else { return } + self.loadToken = nil + switch result { - case .success(let loadResult): - //a stale download for an image this view model no longer represents - guard loadResult.imageDomainModel == self.imageDomainModel else { - return - } - - self.transition(to: .loadedAsset(loadResult.image, description: self.imageDomainModel.url.absoluteString)) - case .failure(_): + case let .success(image): + self.transition(to: .loaded(image)) + case .failure: self.transition(to: .failed) } } } - // MARK: - Pause + // MARK: - Cancel - func pause() { - guard isLoading, - let downloadToken = downloadToken else { + func cancelImageLoad() { + guard let loadToken else { return } - assetService.cancelLoadingImage(downloadToken) - - self.downloadToken = nil + imageLoader.cancel(loadToken) + self.loadToken = nil - transition(to: .ready(description: imageDomainModel.url.absoluteString)) + transition(to: .ready) } // MARK: - State - private var isLoaded: Bool { - guard case .loadedAsset = state else { + private var canLoad: Bool { + switch state { + case .ready, .failed: + return true + case .loading, .loaded: return false } - - return true - } - - private var isLoading: Bool { - guard case .loadingAsset = state else { - return false - } - - return true } private func transition(to state: State) { self.state = state - delegate?.viewModel(self, didChangeTo: state) + delegate?.viewModel(self, + didChangeTo: state) } - } diff --git a/PausableDownloads-ExampleTests/Doubles/StubAssetDownloadsSession.swift b/PausableDownloads-ExampleTests/Doubles/StubAssetDownloadsSession.swift deleted file mode 100644 index 9161b59..0000000 --- a/PausableDownloads-ExampleTests/Doubles/StubAssetDownloadsSession.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// StubAssetDownloadsSession.swift -// PausableDownloads-ExampleTests -// -// Created by William Boles on 11/09/2026. -// Copyright © 2026 William Boles. All rights reserved. -// - -import Foundation - -@testable import PausableDownloads_Example - -final class StubAssetDownloadsSession: AssetDownloadsSession { - enum Event { - case scheduleDownload(URL, DownloadCompletionHandler) - case pauseDownload(DownloadToken) - } - - private(set) var events = [Event]() - - var tokenToReturn: DownloadToken! - - func scheduleDownload(for url: URL, - completionHandler: @escaping DownloadCompletionHandler) -> DownloadToken { - events.append(.scheduleDownload(url, completionHandler)) - - return tokenToReturn - } - - func pauseDownload(_ token: DownloadToken) { - events.append(.pauseDownload(token)) - } -} diff --git a/PausableDownloads-ExampleTests/Doubles/StubAssetService.swift b/PausableDownloads-ExampleTests/Doubles/StubAssetService.swift deleted file mode 100644 index 04db144..0000000 --- a/PausableDownloads-ExampleTests/Doubles/StubAssetService.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// StubAssetService.swift -// PausableDownloads-ExampleTests -// -// Created by William Boles on 09/09/2026. -// Copyright © 2026 William Boles. All rights reserved. -// - -import Foundation - -@testable import PausableDownloads_Example - -final class StubAssetService: AssetService { - enum Event { - case loadImage(ImageDomainModel, DispatchQueue, ((_ result: Result) -> ())) - case cancelLoadingImage(DownloadToken) - } - - private(set) var events = [Event]() - - var downloadTokenToReturn: DownloadToken? - - @discardableResult - func loadImage(_ imageDomainModel: ImageDomainModel, - callbackQueue: DispatchQueue, - completionHandler: @escaping ((_ result: Result) -> ())) -> DownloadToken? { - events.append(.loadImage(imageDomainModel, callbackQueue, completionHandler)) - - return downloadTokenToReturn - } - - func cancelLoadingImage(_ downloadToken: DownloadToken) { - events.append(.cancelLoadingImage(downloadToken)) - } -} diff --git a/PausableDownloads-ExampleTests/Doubles/StubDownloader.swift b/PausableDownloads-ExampleTests/Doubles/StubDownloader.swift new file mode 100644 index 0000000..b01bfee --- /dev/null +++ b/PausableDownloads-ExampleTests/Doubles/StubDownloader.swift @@ -0,0 +1,33 @@ +// +// StubDownloader.swift +// PausableDownloads-ExampleTests +// +// Created by William Boles on 11/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import Foundation + +@testable import PausableDownloads_Example + +final class StubDownloader: Downloader { + enum Event { + case download(URL, DownloadCompletionHandler) + case pause(DownloadToken) + } + + private(set) var events = [Event]() + + var tokenToReturn: DownloadToken! + + func download(_ url: URL, + completionHandler: @escaping DownloadCompletionHandler) -> DownloadToken { + events.append(.download(url, completionHandler)) + + return tokenToReturn + } + + func pause(_ token: DownloadToken) { + events.append(.pause(token)) + } +} diff --git a/PausableDownloads-ExampleTests/Doubles/StubFileManager.swift b/PausableDownloads-ExampleTests/Doubles/StubFileManager.swift new file mode 100644 index 0000000..4261d9d --- /dev/null +++ b/PausableDownloads-ExampleTests/Doubles/StubFileManager.swift @@ -0,0 +1,26 @@ +// +// StubFileManager.swift +// PausableDownloads-ExampleTests +// +// Created by William Boles on 12/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import Foundation + +final class StubFileManager: FileManager { + enum Event { + case urls(FileManager.SearchPathDirectory, FileManager.SearchPathDomainMask) + } + + private(set) var events = [Event]() + + var urlsToReturn = [URL]() + + override func urls(for directory: FileManager.SearchPathDirectory, + in domainMask: FileManager.SearchPathDomainMask) -> [URL] { + events.append(.urls(directory, domainMask)) + + return urlsToReturn + } +} diff --git a/PausableDownloads-ExampleTests/Doubles/StubImageLoader.swift b/PausableDownloads-ExampleTests/Doubles/StubImageLoader.swift new file mode 100644 index 0000000..86e3fd1 --- /dev/null +++ b/PausableDownloads-ExampleTests/Doubles/StubImageLoader.swift @@ -0,0 +1,35 @@ +// +// StubImageLoader.swift +// PausableDownloads-ExampleTests +// +// Created by William Boles on 09/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import Foundation + +@testable import PausableDownloads_Example + +final class StubImageLoader: ImageLoader { + enum Event { + case load(ImageDomainModel, DispatchQueue, LoadImageCompletionHandler) + case cancel(LoadToken) + } + + private(set) var events = [Event]() + + var tokenToReturn: LoadToken? + + @discardableResult + func load(_ imageDomainModel: ImageDomainModel, + callbackQueue: DispatchQueue, + completionHandler: @escaping LoadImageCompletionHandler) -> LoadToken? { + events.append(.load(imageDomainModel, callbackQueue, completionHandler)) + + return tokenToReturn + } + + func cancel(_ token: LoadToken) { + events.append(.cancel(token)) + } +} diff --git a/PausableDownloads-ExampleTests/Doubles/StubImagesService.swift b/PausableDownloads-ExampleTests/Doubles/StubImagesService.swift index dc7f942..a97cc57 100644 --- a/PausableDownloads-ExampleTests/Doubles/StubImagesService.swift +++ b/PausableDownloads-ExampleTests/Doubles/StubImagesService.swift @@ -12,13 +12,13 @@ import Foundation final class StubImagesService: ImagesService { enum Event { - case retrieveImages(DispatchQueue, ((_ result: Result<[ImageDomainModel], Error>) -> ())) + case load(DispatchQueue, LoadImagesCompletionHandler) } private(set) var events = [Event]() - func retrieveImages(callbackQueue: DispatchQueue, - completionHandler: @escaping ((_ result: Result<[ImageDomainModel], Error>) -> ())) { - events.append(.retrieveImages(callbackQueue, completionHandler)) + func load(callbackQueue: DispatchQueue, + completionHandler: @escaping LoadImagesCompletionHandler) { + events.append(.load(callbackQueue, completionHandler)) } } diff --git a/PausableDownloads-ExampleTests/TestData/Data+TestData.swift b/PausableDownloads-ExampleTests/TestData/Data+TestData.swift new file mode 100644 index 0000000..648099c --- /dev/null +++ b/PausableDownloads-ExampleTests/TestData/Data+TestData.swift @@ -0,0 +1,23 @@ +// +// Data+TestData.swift +// PausableDownloads-ExampleTests +// +// Created by William Boles on 12/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import Foundation +import UIKit + +extension Data { + + static func imageTestData(size: CGSize = CGSize(width: 1, height: 1), + color: UIColor = .red) -> Data { + let renderer = UIGraphicsImageRenderer(size: size) + + return renderer.pngData { context in + color.setFill() + context.fill(CGRect(origin: .zero, size: size)) + } + } +} diff --git a/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift b/PausableDownloads-ExampleTests/Tests/DownloaderTests.swift similarity index 85% rename from PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift rename to PausableDownloads-ExampleTests/Tests/DownloaderTests.swift index ad1f4cd..54e1587 100644 --- a/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift +++ b/PausableDownloads-ExampleTests/Tests/DownloaderTests.swift @@ -1,5 +1,5 @@ // -// AssetDownloadsSessionTests.swift +// DownloaderTests.swift // PausableDownloads-ExampleTests // // Created by William Boles on 15/12/2019. @@ -10,29 +10,10 @@ import XCTest @testable import PausableDownloads_Example -class AssetDownloadsSessionTests: XCTestCase { +class DownloaderTests: XCTestCase { // MARK: - Tests - // MARK: Init - - func test_givenURLSessionFactory_whenInitialised_thenDefaultSessionIsCreatedWithSelfAsDelegateAndNoQueue() { - let sessionFactory = StubURLSessionFactory() - sessionFactory.sessionToReturn = StubURLSession() - - let sut = createSUT(urlSessionFactory: sessionFactory) - - XCTAssertEqual(sessionFactory.events.count, 1) - - guard case let .defaultSession(delegate, queue) = sessionFactory.events.first else { - XCTFail("Unexpected event") - return - } - - XCTAssertTrue(delegate === sut) - XCTAssertNil(queue) - } - // MARK: MemoryPressure func test_givenMemoryPressureMonitor_whenInitialised_thenMonitoringIsStarted() { @@ -64,11 +45,11 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - let downloadID = sut.scheduleDownload(for: url) { _ in } + let downloadID = sut.download(url) { _ in } XCTAssertEqual(session.events.count, 1) - sut.pauseDownload(downloadID) + sut.pause(downloadID) XCTAssertEqual(downloadTask.events.count, 2) @@ -84,7 +65,7 @@ class AssetDownloadsSessionTests: XCTestCase { //the purged item took its resumption data with it, so the next schedule starts over session.downloadTaskWithResumeDataToReturn = StubURLSessionDownloadTask() - sut.scheduleDownload(for: url) { _ in } + sut.download(url) { _ in } XCTAssertEqual(session.events.count, 2) @@ -110,7 +91,7 @@ class AssetDownloadsSessionTests: XCTestCase { return } - sut.scheduleDownload(for: url) { _ in } + sut.download(url) { _ in } XCTAssertEqual(downloadTask.events.count, 1) @@ -126,7 +107,7 @@ class AssetDownloadsSessionTests: XCTestCase { // MARK: Schedule - func test_givenNoExistingDownload_whenScheduleDownloadIsCalled_thenDownloadTaskIsCreatedForURLAndResumed() { + func test_givenNoExistingDownload_whendownloadIsCalled_thenDownloadTaskIsCreatedForURLAndResumed() { let url = URL(string: "http://test.com/example")! let session = StubURLSession() @@ -135,7 +116,7 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - sut.scheduleDownload(for: url) { _ in } + sut.download(url) { _ in } XCTAssertEqual(downloadTask.events.count, 1) @@ -154,7 +135,7 @@ class AssetDownloadsSessionTests: XCTestCase { XCTAssertEqual(downloadTaskURL, url) } - func test_givenNoExistingDownloads_whenScheduleDownloadIsCalledForTwoDifferentURLs_thenBothDownloadTasksAreResumed() { + func test_givenNoExistingDownloads_whendownloadIsCalledForTwoDifferentURLs_thenBothDownloadTasksAreResumed() { let session = StubURLSession() let sut = createSUT(session: session) @@ -164,8 +145,8 @@ class AssetDownloadsSessionTests: XCTestCase { let urlA = URL(string: "http://example.com/resourceA")! let urlB = URL(string: "http://example.com/resourceB")! - sut.scheduleDownload(for: urlA) { _ in } - sut.scheduleDownload(for: urlB) { _ in } + sut.download(urlA) { _ in } + sut.download(urlB) { _ in } XCTAssertEqual(downloadTask.events.count, 2) @@ -176,7 +157,7 @@ class AssetDownloadsSessionTests: XCTestCase { } } - func test_givenInFlightDownload_whenScheduleDownloadIsCalledForTheSameURL_thenOneDownloadIsSharedAndBothCompletionHandlersAreCalled() { + func test_givenInFlightDownload_whendownloadIsCalledForTheSameURL_thenOneDownloadIsSharedAndBothCompletionHandlersAreCalled() { let url = URL(string: "http://test.com/example")! let session = StubURLSession() @@ -187,10 +168,10 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskToReturn = downloadTask var firstResults = [Result]() - sut.scheduleDownload(for: url) { firstResults.append($0) } + sut.download(url) { firstResults.append($0) } var secondResults = [Result]() - sut.scheduleDownload(for: url) { secondResults.append($0) } + sut.download(url) { secondResults.append($0) } //a second caller coalesces onto the download that's already running XCTAssertEqual(session.events.count, 1) @@ -213,12 +194,12 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskToReturn = downloadTask var firstResults = [Result]() - let firstDownloadToken = sut.scheduleDownload(for: url) { firstResults.append($0) } + let firstDownloadToken = sut.download(url) { firstResults.append($0) } var secondResults = [Result]() - sut.scheduleDownload(for: url) { secondResults.append($0) } + sut.download(url) { secondResults.append($0) } - sut.pauseDownload(firstDownloadToken) + sut.pause(firstDownloadToken) //the second caller still wants this URL, so the shared task keeps running XCTAssertEqual(downloadTask.events.count, 1) @@ -234,7 +215,7 @@ class AssetDownloadsSessionTests: XCTestCase { XCTAssertTrue(firstResults.isEmpty) } - func test_givenPausedDownloadThatProducedNoResumptionData_whenScheduleDownloadIsCalledForTheSameURL_thenTheDownloadRestarts() { + func test_givenPausedDownloadThatProducedNoResumptionData_whendownloadIsCalledForTheSameURL_thenTheDownloadRestarts() { let url = URL(string: "http://test.com/example")! let session = StubURLSession() @@ -243,8 +224,8 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - let downloadID = sut.scheduleDownload(for: url) { _ in } - sut.pauseDownload(downloadID) + let downloadID = sut.download(url) { _ in } + sut.pause(downloadID) XCTAssertEqual(downloadTask.events.count, 2) @@ -256,7 +237,7 @@ class AssetDownloadsSessionTests: XCTestCase { //a server that can't resume hands back no data, so starting over is all that's left resumeDataHandler(nil) - sut.scheduleDownload(for: url) { _ in } + sut.download(url) { _ in } XCTAssertEqual(session.events.count, 2) @@ -273,7 +254,7 @@ class AssetDownloadsSessionTests: XCTestCase { } } - func test_givenPauseStillProducingResumptionData_whenScheduleDownloadIsCalledForTheSameURL_thenTheResumeWaitsForTheResumptionData() { + func test_givenPauseStillProducingResumptionData_whendownloadIsCalledForTheSameURL_thenTheResumeWaitsForTheResumptionData() { let url = URL(string: "http://test.com/example")! let resumptionData = Data("resumption".utf8) @@ -283,8 +264,8 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - let downloadID = sut.scheduleDownload(for: url) { _ in } - sut.pauseDownload(downloadID) + let downloadID = sut.download(url) { _ in } + sut.pause(downloadID) guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { XCTFail("Unexpected event") @@ -295,7 +276,7 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskWithResumeDataToReturn = resumedDownloadTask //rescheduling whilst the resumption data is still in flight - the fast swipe back - sut.scheduleDownload(for: url) { _ in } + sut.download(url) { _ in } XCTAssertEqual(session.events.count, 1) @@ -328,8 +309,8 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskToReturn = downloadTask session.downloadTaskWithResumeDataToReturn = StubURLSessionDownloadTask() - let firstDownloadID = sut.scheduleDownload(for: url) { _ in } - sut.pauseDownload(firstDownloadID) + let firstDownloadID = sut.download(url) { _ in } + sut.pause(firstDownloadID) guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { XCTFail("Unexpected event") @@ -337,11 +318,11 @@ class AssetDownloadsSessionTests: XCTestCase { } //scheduled whilst the pause is still in flight, so it joins rather than starting a task - let joinedDownloadToken = sut.scheduleDownload(for: url) { _ in } + let joinedDownloadToken = sut.download(url) { _ in } XCTAssertEqual(session.events.count, 1) - sut.pauseDownload(joinedDownloadToken) + sut.pause(joinedDownloadToken) resumeDataHandler(Data("resumption".utf8)) @@ -360,14 +341,14 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskToReturn = downloadTask var results = [Result]() - let downloadID = sut.scheduleDownload(for: url) { results.append($0) } + let downloadID = sut.download(url) { results.append($0) } guard case .downloadTask = session.events.first else { XCTFail("Unexpected event") return } - sut.pauseDownload(downloadID) + sut.pause(downloadID) //pausing cancels the underlying task, which reports back as a cancellation error sut.handleFailedDownloading(for: url, taskIdentifier: downloadTask.taskIdentifier, error: URLError(.cancelled)) @@ -375,7 +356,7 @@ class AssetDownloadsSessionTests: XCTestCase { XCTAssertTrue(results.isEmpty) } - func test_givenCompletedDownload_whenScheduleDownloadIsCalledForTheSameURL_thenANewDownloadTaskIsCreated() throws { + func test_givenCompletedDownload_whendownloadIsCalledForTheSameURL_thenANewDownloadTaskIsCreated() throws { let url = URL(string: "http://test.com/example")! let fileURL = try XCTUnwrap(Bundle(for: type(of: self)).url(forResource: "square", withExtension: "pdf")) @@ -386,7 +367,7 @@ class AssetDownloadsSessionTests: XCTestCase { downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask - sut.scheduleDownload(for: url) { _ in } + sut.download(url) { _ in } XCTAssertEqual(session.events.count, 1) @@ -397,7 +378,7 @@ class AssetDownloadsSessionTests: XCTestCase { sut.handleFinishedDownloading(for: url, taskIdentifier: downloadTask.taskIdentifier, to: fileURL) - sut.scheduleDownload(for: url) { _ in } + sut.download(url) { _ in } XCTAssertEqual(session.events.count, 2) } @@ -413,7 +394,7 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskToReturn = downloadTask let completionExpectation = expectation(description: "completionExpectation") - sut.scheduleDownload(for: url) { _ in + sut.download(url) { _ in completionExpectation.fulfill() } @@ -427,7 +408,7 @@ class AssetDownloadsSessionTests: XCTestCase { waitForExpectations(timeout: 3, handler: nil) } - func test_givenPausedDownloadWithResumptionData_whenScheduleDownloadIsCalledForTheSameURL_thenDownloadTaskIsCreatedFromResumeData() { + func test_givenPausedDownloadWithResumptionData_whendownloadIsCalledForTheSameURL_thenDownloadTaskIsCreatedFromResumeData() { let url = URL(string: "http://test.com/example")! let resumptionData = Data("resumption".utf8) @@ -437,8 +418,8 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - let downloadID = sut.scheduleDownload(for: url) { _ in } - sut.pauseDownload(downloadID) + let downloadID = sut.download(url) { _ in } + sut.pause(downloadID) guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { XCTFail("Unexpected event") @@ -450,7 +431,7 @@ class AssetDownloadsSessionTests: XCTestCase { let resumedDownloadTask = StubURLSessionDownloadTask() session.downloadTaskWithResumeDataToReturn = resumedDownloadTask - sut.scheduleDownload(for: url) { _ in } + sut.download(url) { _ in } XCTAssertEqual(session.events.count, 2) @@ -484,7 +465,7 @@ class AssetDownloadsSessionTests: XCTestCase { var receivedResult: Result? let completionExpectation = expectation(description: "completionExpectation") - sut.scheduleDownload(for: url) { (result) in + sut.download(url) { (result) in receivedResult = result completionExpectation.fulfill() } @@ -518,7 +499,7 @@ class AssetDownloadsSessionTests: XCTestCase { var receivedResult: Result? let completionExpectation = expectation(description: "completionExpectation") - sut.scheduleDownload(for: url) { (result) in + sut.download(url) { (result) in receivedResult = result completionExpectation.fulfill() } @@ -554,7 +535,7 @@ class AssetDownloadsSessionTests: XCTestCase { var receivedResult: Result? let completionExpectation = expectation(description: "completionExpectation") - sut.scheduleDownload(for: url) { (result) in + sut.download(url) { (result) in receivedResult = result completionExpectation.fulfill() } @@ -585,8 +566,8 @@ class AssetDownloadsSessionTests: XCTestCase { retiredDownloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = retiredDownloadTask - let downloadID = sut.scheduleDownload(for: url) { _ in } - sut.pauseDownload(downloadID) + let downloadID = sut.download(url) { _ in } + sut.pause(downloadID) guard case let .cancelByProducingResumeData(resumeDataHandler) = retiredDownloadTask.events.last else { XCTFail("Unexpected event") @@ -600,7 +581,7 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskWithResumeDataToReturn = resumedDownloadTask var results = [Result]() - sut.scheduleDownload(for: url) { results.append($0) } + sut.download(url) { results.append($0) } //the task the pause retired winds down late and must not be mistaken for this download sut.handleFailedDownloading(for: url, taskIdentifier: retiredDownloadTask.taskIdentifier, error: URLError(.cancelled)) @@ -623,7 +604,7 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskToReturn = downloadTask var results = [Result]() - sut.scheduleDownload(for: url) { results.append($0) } + sut.download(url) { results.append($0) } let unknownURL = URL(string: "http://test.com/unknown")! let unknownTaskIdentifier = 2 @@ -647,8 +628,8 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - let downloadID = sut.scheduleDownload(for: url) { _ in } - sut.pauseDownload(downloadID) + let downloadID = sut.download(url) { _ in } + sut.pause(downloadID) XCTAssertEqual(downloadTask.events.count, 2) @@ -667,7 +648,7 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - sut.pauseDownload(DownloadToken(url: url)) + sut.pause(DownloadToken(url: url)) XCTAssertTrue(session.events.isEmpty) XCTAssertTrue(downloadTask.events.isEmpty) @@ -690,10 +671,10 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskToReturn = downloadTask var firstResult: Result? - sut.scheduleDownload(for: url) { firstResult = $0 } + sut.download(url) { firstResult = $0 } var secondResult: Result? - sut.scheduleDownload(for: url) { secondResult = $0 } + sut.download(url) { secondResult = $0 } XCTAssertEqual(session.events.count, 1) @@ -719,15 +700,15 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - let firstDownloadToken = sut.scheduleDownload(for: url) { _ in } - let secondDownloadToken = sut.scheduleDownload(for: url) { _ in } + let firstDownloadToken = sut.download(url) { _ in } + let secondDownloadToken = sut.download(url) { _ in } - sut.pauseDownload(firstDownloadToken) + sut.pause(firstDownloadToken) //somebody still wants it, so nothing is cancelled yet XCTAssertEqual(downloadTask.events.count, 1) - sut.pauseDownload(secondDownloadToken) + sut.pause(secondDownloadToken) //the last interested caller has gone, so the shared task is cancelled exactly once XCTAssertEqual(downloadTask.events.count, 2) @@ -747,8 +728,8 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - let firstDownloadToken = sut.scheduleDownload(for: url) { _ in } - sut.pauseDownload(firstDownloadToken) + let firstDownloadToken = sut.download(url) { _ in } + sut.pause(firstDownloadToken) guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { XCTFail("Unexpected event") @@ -761,10 +742,10 @@ class AssetDownloadsSessionTests: XCTestCase { //both scheduled whilst the pause is still in flight, so both join it var secondResults = [Result]() - sut.scheduleDownload(for: url) { secondResults.append($0) } + sut.download(url) { secondResults.append($0) } var thirdResults = [Result]() - sut.scheduleDownload(for: url) { thirdResults.append($0) } + sut.download(url) { thirdResults.append($0) } XCTAssertEqual(session.events.count, 1) @@ -794,8 +775,8 @@ class AssetDownloadsSessionTests: XCTestCase { downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask - let firstDownloadToken = sut.scheduleDownload(for: url) { _ in } - sut.pauseDownload(firstDownloadToken) + let firstDownloadToken = sut.download(url) { _ in } + sut.pause(firstDownloadToken) guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { XCTFail("Unexpected event") @@ -809,7 +790,7 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskWithResumeDataToReturn = resumedTask var results = [Result]() - sut.scheduleDownload(for: url) { results.append($0) } + sut.download(url) { results.append($0) } /* The retired task winds down with a real error rather than a cancellation, so nothing but the phase stops it being mistaken for the download now running. @@ -839,8 +820,8 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - let firstDownloadToken = sut.scheduleDownload(for: url) { _ in } - sut.pauseDownload(firstDownloadToken) + let firstDownloadToken = sut.download(url) { _ in } + sut.pause(firstDownloadToken) guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { XCTFail("Unexpected event") @@ -852,7 +833,7 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskWithResumeDataToReturn = resumedTask var results = [Result]() - sut.scheduleDownload(for: url) { results.append($0) } + sut.download(url) { results.append($0) } //purging must leave a pause in flight alone or the caller that joined it is stranded memoryPressureHandler() @@ -880,8 +861,8 @@ class AssetDownloadsSessionTests: XCTestCase { let downloadTask = StubURLSessionDownloadTask() session.downloadTaskToReturn = downloadTask - let firstDownloadToken = sut.scheduleDownload(for: url) { _ in } - sut.pauseDownload(firstDownloadToken) + let firstDownloadToken = sut.download(url) { _ in } + sut.pause(firstDownloadToken) guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { XCTFail("Unexpected event") @@ -892,8 +873,8 @@ class AssetDownloadsSessionTests: XCTestCase { session.downloadTaskWithResumeDataToReturn = StubURLSessionDownloadTask() - sut.scheduleDownload(for: url) { _ in } - sut.scheduleDownload(for: url) { _ in } + sut.download(url) { _ in } + sut.download(url) { _ in } //the second caller coalesces onto the resumed download rather than starting afresh XCTAssertEqual(session.events.count, 2) @@ -905,9 +886,9 @@ class AssetDownloadsSessionTests: XCTestCase { } } -extension AssetDownloadsSessionTests { +extension DownloaderTests { func createSUT(session: StubURLSession = StubURLSession(), - memoryPressureMonitor: MemoryPressureMonitor = StubMemoryPressureMonitor()) -> DefaultAssetDownloadsSession { + memoryPressureMonitor: MemoryPressureMonitor = StubMemoryPressureMonitor()) -> DefaultDownloader { let urlSessionFactory = StubURLSessionFactory() urlSessionFactory.sessionToReturn = session @@ -916,8 +897,8 @@ extension AssetDownloadsSessionTests { } func createSUT(urlSessionFactory: URLSessionFactoryType, - memoryPressureMonitor: MemoryPressureMonitor = StubMemoryPressureMonitor()) -> DefaultAssetDownloadsSession { - DefaultAssetDownloadsSession(urlSessionFactory: urlSessionFactory, + memoryPressureMonitor: MemoryPressureMonitor = StubMemoryPressureMonitor()) -> DefaultDownloader { + DefaultDownloader(urlSessionFactory: urlSessionFactory, memoryPressureMonitor: memoryPressureMonitor) } } diff --git a/PausableDownloads-ExampleTests/Tests/ImageGalleryViewModelTests.swift b/PausableDownloads-ExampleTests/Tests/ImageGalleryViewModelTests.swift index c70f72e..76ff39d 100644 --- a/PausableDownloads-ExampleTests/Tests/ImageGalleryViewModelTests.swift +++ b/PausableDownloads-ExampleTests/Tests/ImageGalleryViewModelTests.swift @@ -27,45 +27,50 @@ final class ImageGalleryViewModelTests: XCTestCase { XCTAssertEqual(imagesService.events.count, 1) - guard case let .retrieveImages(callbackQueue, _) = imagesService.events.first else { + guard case let .load(callbackQueue, _) = imagesService.events.first else { XCTFail("Unexpected event") return } XCTAssertTrue(callbackQueue === DispatchQueue.main) - XCTAssertEqual(sut.state, .loadingImages) + XCTAssertEqual(sut.state, .loading) guard case let .didChangeTo(state) = delegate.events.first else { XCTFail("Unexpected event") return } - XCTAssertEqual(state, .loadingImages) + XCTAssertEqual(state, .loading) } func test_givenLoadInProgress_whenImagesAreRetrieved_thenTheFirstAssetIsLoaded() { let imagesService = StubImagesService() - let assetService = StubAssetService() + let imageLoader = StubImageLoader() + + let imageA = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + let imageB = ImageDomainModel.testData(identifier: "b", + url: URL(string: "http://test.com/b.jpg")!) let sut = createSUT(imagesService: imagesService, - assetService: assetService) + imageLoader: imageLoader) sut.load() - guard case let .retrieveImages(_, completionHandler) = imagesService.events.first else { + guard case let .load(_, completionHandler) = imagesService.events.first else { XCTFail("Unexpected event") return } completionHandler(.success([imageA, imageB])) - XCTAssertEqual(sut.state, .loadedImages) + XCTAssertEqual(sut.state, .loaded) XCTAssertEqual(sut.numberOfImages, 2) XCTAssertEqual(sut.currentIndex, 0) - XCTAssertEqual(assetService.events.count, 1) + XCTAssertEqual(imageLoader.events.count, 1) - guard case let .loadImage(loadedImage, _, _) = assetService.events.first else { + guard case let .load(loadedImage, _, _) = imageLoader.events.first else { XCTFail("Unexpected event") return } @@ -75,14 +80,14 @@ final class ImageGalleryViewModelTests: XCTestCase { func test_givenLoadInProgress_whenImageRetrievalFails_thenStateTransitionsToFailed() { let imagesService = StubImagesService() - let assetService = StubAssetService() + let imageLoader = StubImageLoader() let sut = createSUT(imagesService: imagesService, - assetService: assetService) + imageLoader: imageLoader) sut.load() - guard case let .retrieveImages(_, completionHandler) = imagesService.events.first else { + guard case let .load(_, completionHandler) = imagesService.events.first else { XCTFail("Unexpected event") return } @@ -90,34 +95,50 @@ final class ImageGalleryViewModelTests: XCTestCase { completionHandler(.failure(TestError.test)) XCTAssertEqual(sut.state, .failed) - XCTAssertTrue(assetService.events.isEmpty) + XCTAssertTrue(imageLoader.events.isEmpty) } func test_givenLoadInProgress_whenNoImagesAreRetrieved_thenNoAssetIsLoaded() { let imagesService = StubImagesService() - let assetService = StubAssetService() + let imageLoader = StubImageLoader() let sut = createSUT(imagesService: imagesService, - assetService: assetService) + imageLoader: imageLoader) sut.load() - guard case let .retrieveImages(_, completionHandler) = imagesService.events.first else { + guard case let .load(_, completionHandler) = imagesService.events.first else { XCTFail("Unexpected event") return } completionHandler(.success([])) - XCTAssertEqual(sut.state, .loadedImages) + XCTAssertEqual(sut.state, .loaded) XCTAssertEqual(sut.numberOfImages, 0) - XCTAssertTrue(assetService.events.isEmpty) + XCTAssertTrue(imageLoader.events.isEmpty) } // MARK: Pages func test_givenRetrievedImages_whenAViewModelIsRequestedTwiceForTheSameIndex_thenTheSameInstanceIsReturned() { - let sut = createLoadedSUT() + let imagesService = StubImagesService() + + let imageA = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + let imageB = ImageDomainModel.testData(identifier: "b", + url: URL(string: "http://test.com/b.jpg")!) + + let sut = createSUT(imagesService: imagesService) + + sut.load() + + guard case let .load(_, completionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.success([imageA, imageB])) let first = sut.viewModel(at: 1) let second = sut.viewModel(at: 1) @@ -127,14 +148,46 @@ final class ImageGalleryViewModelTests: XCTestCase { } func test_givenRetrievedImages_whenAViewModelIsRequestedForEachIndex_thenItRepresentsThatImage() { - let sut = createLoadedSUT() + let imagesService = StubImagesService() + + let imageA = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + let imageB = ImageDomainModel.testData(identifier: "b", + url: URL(string: "http://test.com/b.jpg")!) + + let sut = createSUT(imagesService: imagesService) + + sut.load() + + guard case let .load(_, completionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.success([imageA, imageB])) XCTAssertEqual(sut.viewModel(at: 0)?.imageDomainModel, imageA) XCTAssertEqual(sut.viewModel(at: 1)?.imageDomainModel, imageB) } func test_givenRetrievedImages_whenAViewModelIsRequestedOutOfBounds_thenNilIsReturned() { - let sut = createLoadedSUT() + let imagesService = StubImagesService() + + let imageA = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + let imageB = ImageDomainModel.testData(identifier: "b", + url: URL(string: "http://test.com/b.jpg")!) + + let sut = createSUT(imagesService: imagesService) + + sut.load() + + guard case let .load(_, completionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.success([imageA, imageB])) XCTAssertNil(sut.viewModel(at: -1)) XCTAssertNil(sut.viewModel(at: 2)) @@ -143,30 +196,46 @@ final class ImageGalleryViewModelTests: XCTestCase { // MARK: Move func test_givenLoadedImages_whenMoveToIsCalled_thenTheOutgoingAssetIsPausedAndTheIncomingOneIsLoaded() { - let assetService = StubAssetService() + let imagesService = StubImagesService() + let imageLoader = StubImageLoader() + + let imageA = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + let imageB = ImageDomainModel.testData(identifier: "b", + url: URL(string: "http://test.com/b.jpg")!) - let downloadTokenForImageA = DownloadToken(url: imageA.url) - assetService.downloadTokenToReturn = downloadTokenForImageA + let tokenForImageA = LoadToken(url: imageA.url) + imageLoader.tokenToReturn = tokenForImageA - let sut = createLoadedSUT(assetService: assetService) + let sut = createSUT(imagesService: imagesService, + imageLoader: imageLoader) + + sut.load() + + guard case let .load(_, completionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.success([imageA, imageB])) //the token the next load hands back, so the paused one is identifiable - assetService.downloadTokenToReturn = DownloadToken(url: imageB.url) + imageLoader.tokenToReturn = LoadToken(url: imageB.url) - sut.moveTo(index: 1) + sut.move(to: 1) XCTAssertEqual(sut.currentIndex, 1) - XCTAssertEqual(assetService.events.count, 3) + XCTAssertEqual(imageLoader.events.count, 3) - guard case let .cancelLoadingImage(pausedDownloadToken) = assetService.events[1] else { + guard case let .cancel(pausedToken) = imageLoader.events[1] else { XCTFail("Unexpected event") return } //the download issued for imageA, which is the page being swiped away from - XCTAssertEqual(pausedDownloadToken, downloadTokenForImageA) + XCTAssertEqual(pausedToken, tokenForImageA) - guard case let .loadImage(loadedImage, _, _) = assetService.events.last else { + guard case let .load(loadedImage, _, _) = imageLoader.events.last else { XCTFail("Unexpected event") return } @@ -175,36 +244,52 @@ final class ImageGalleryViewModelTests: XCTestCase { } func test_givenAPausedImage_whenMovedBackTo_thenItsAssetIsLoadedAgain() { - let assetService = StubAssetService() + let imagesService = StubImagesService() + let imageLoader = StubImageLoader() + + let imageA = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + let imageB = ImageDomainModel.testData(identifier: "b", + url: URL(string: "http://test.com/b.jpg")!) + + imageLoader.tokenToReturn = LoadToken(url: imageA.url) + + let sut = createSUT(imagesService: imagesService, + imageLoader: imageLoader) - assetService.downloadTokenToReturn = DownloadToken(url: imageA.url) + sut.load() + + guard case let .load(_, completionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } - let sut = createLoadedSUT(assetService: assetService) + completionHandler(.success([imageA, imageB])) - let downloadTokenForImageB = DownloadToken(url: imageB.url) - assetService.downloadTokenToReturn = downloadTokenForImageB + let tokenForImageB = LoadToken(url: imageB.url) + imageLoader.tokenToReturn = tokenForImageB - sut.moveTo(index: 1) + sut.move(to: 1) - assetService.downloadTokenToReturn = DownloadToken(url: imageA.url) + imageLoader.tokenToReturn = LoadToken(url: imageA.url) - sut.moveTo(index: 0) + sut.move(to: 0) XCTAssertEqual(sut.currentIndex, 0) - XCTAssertEqual(assetService.events.count, 5) + XCTAssertEqual(imageLoader.events.count, 5) - guard case let .cancelLoadingImage(pausedDownloadToken) = assetService.events[3] else { + guard case let .cancel(pausedToken) = imageLoader.events[3] else { XCTFail("Unexpected event") return } //the download issued for imageB, which is the page being swiped away from - XCTAssertEqual(pausedDownloadToken, downloadTokenForImageB) + XCTAssertEqual(pausedToken, tokenForImageB) /* Rescheduling the same URL is what hands the paused download back to the session to resume rather than restart. */ - guard case let .loadImage(loadedImage, _, _) = assetService.events.last else { + guard case let .load(loadedImage, _, _) = imageLoader.events.last else { XCTFail("Unexpected event") return } @@ -213,64 +298,69 @@ final class ImageGalleryViewModelTests: XCTestCase { } func test_givenLoadedImages_whenMoveToIsCalledForTheCurrentIndex_thenNothingHappens() { - let assetService = StubAssetService() + let imagesService = StubImagesService() + let imageLoader = StubImageLoader() - let sut = createLoadedSUT(assetService: assetService) + let imageA = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + let imageB = ImageDomainModel.testData(identifier: "b", + url: URL(string: "http://test.com/b.jpg")!) - let eventCountBeforeMove = assetService.events.count + let sut = createSUT(imagesService: imagesService, + imageLoader: imageLoader) - sut.moveTo(index: 0) + sut.load() - XCTAssertEqual(assetService.events.count, eventCountBeforeMove) - XCTAssertEqual(sut.currentIndex, 0) - } - - func test_givenLoadedImages_whenMoveToIsCalledOutOfBounds_thenNothingHappens() { - let assetService = StubAssetService() + guard case let .load(_, completionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } - let sut = createLoadedSUT(assetService: assetService) + completionHandler(.success([imageA, imageB])) - let eventCountBeforeMove = assetService.events.count + let eventCountBeforeMove = imageLoader.events.count - sut.moveTo(index: 2) - sut.moveTo(index: -1) + sut.move(to: 0) - XCTAssertEqual(assetService.events.count, eventCountBeforeMove) + XCTAssertEqual(imageLoader.events.count, eventCountBeforeMove) XCTAssertEqual(sut.currentIndex, 0) } -} - -extension ImageGalleryViewModelTests { - var imageA: ImageDomainModel { - ImageDomainModel.testData(identifier: "a", - url: URL(string: "http://test.com/a.jpg")!) - } - - var imageB: ImageDomainModel { - ImageDomainModel.testData(identifier: "b", - url: URL(string: "http://test.com/b.jpg")!) - } - func createSUT(imagesService: ImagesService = StubImagesService(), - assetService: AssetService = StubAssetService()) -> ImageGalleryViewModel { - ImageGalleryViewModel(imagesService: imagesService, - assetService: assetService) - } - - func createLoadedSUT(assetService: AssetService = StubAssetService()) -> ImageGalleryViewModel { + func test_givenLoadedImages_whenMoveToIsCalledOutOfBounds_thenNothingHappens() { let imagesService = StubImagesService() + let imageLoader = StubImageLoader() + + let imageA = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + let imageB = ImageDomainModel.testData(identifier: "b", + url: URL(string: "http://test.com/b.jpg")!) let sut = createSUT(imagesService: imagesService, - assetService: assetService) + imageLoader: imageLoader) sut.load() - guard case let .retrieveImages(_, completionHandler) = imagesService.events.first else { - fatalError("Expected images to have been retrieved") + guard case let .load(_, completionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return } completionHandler(.success([imageA, imageB])) - return sut + let eventCountBeforeMove = imageLoader.events.count + + sut.move(to: 2) + sut.move(to: -1) + + XCTAssertEqual(imageLoader.events.count, eventCountBeforeMove) + XCTAssertEqual(sut.currentIndex, 0) + } +} + +extension ImageGalleryViewModelTests { + func createSUT(imagesService: ImagesService = StubImagesService(), + imageLoader: ImageLoader = StubImageLoader()) -> ImageGalleryViewModel { + ImageGalleryViewModel(imagesService: imagesService, + imageLoader: imageLoader) } } diff --git a/PausableDownloads-ExampleTests/Tests/ImageLoaderTests.swift b/PausableDownloads-ExampleTests/Tests/ImageLoaderTests.swift new file mode 100644 index 0000000..a09708d --- /dev/null +++ b/PausableDownloads-ExampleTests/Tests/ImageLoaderTests.swift @@ -0,0 +1,279 @@ +// +// ImageLoaderTests.swift +// PausableDownloads-ExampleTests +// +// Created by William Boles on 12/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import XCTest +import UIKit + +@testable import PausableDownloads_Example + +final class ImageLoaderTests: XCTestCase { + private var cacheDirectory: URL! + + // MARK: - Lifecycle + + override func setUpWithError() throws { + try super.setUpWithError() + + cacheDirectory = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + + try FileManager.default.createDirectory(at: cacheDirectory, + withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: cacheDirectory) + cacheDirectory = nil + + try super.tearDownWithError() + } + + // MARK: - Tests + + // MARK: Load + + func test_givenNothingCached_whenLoadIsCalled_thenTheImageIsDownloadedAndItsTokenIsReturned() { + let downloader = StubDownloader() + + let image = ImageDomainModel.testData() + let token = DownloadToken(url: image.url) + downloader.tokenToReturn = token + + let fileManager = StubFileManager() + fileManager.urlsToReturn = [cacheDirectory] + + let sut = createSUT(downloader: downloader, + fileManager: fileManager) + + let returnedToken = sut.load(image, + callbackQueue: .main) { _ in } + + XCTAssertEqual(returnedToken, token) + XCTAssertEqual(downloader.events.count, 1) + + guard case let .download(downloadedURL, _) = downloader.events.first else { + XCTFail("Unexpected event") + return + } + + XCTAssertEqual(downloadedURL, image.url) + } + + func test_givenDownloadInProgress_whenItCompletesWithImageData_thenTheImageIsDeliveredOnTheCallbackQueue() throws { + let downloader = StubDownloader() + downloader.tokenToReturn = DownloadToken(url: ImageDomainModel.testData().url) + + let fileManager = StubFileManager() + fileManager.urlsToReturn = [cacheDirectory] + + let sut = createSUT(downloader: downloader, + fileManager: fileManager) + + let callbackQueue = DispatchQueue(label: "com.williamboles.imageloadertests") + + var receivedResult: Result? + let completionExpectation = expectation(description: "completionExpectation") + sut.load(ImageDomainModel.testData(), + callbackQueue: callbackQueue) { result in + dispatchPrecondition(condition: .onQueue(callbackQueue)) + + receivedResult = result + completionExpectation.fulfill() + } + + guard case let .download(_, completionHandler) = downloader.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.success(.imageTestData())) + + waitForExpectations(timeout: 3, handler: nil) + + guard case .success = try XCTUnwrap(receivedResult) else { + XCTFail("Expected a success result") + return + } + } + + func test_givenDownloadInProgress_whenItCompletesWithImageData_thenTheImageIsCached() throws { + let downloader = StubDownloader() + + let image = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + downloader.tokenToReturn = DownloadToken(url: image.url) + + let fileManager = StubFileManager() + fileManager.urlsToReturn = [cacheDirectory] + + let sut = createSUT(downloader: downloader, + fileManager: fileManager) + + let data = Data.imageTestData() + + let completionExpectation = expectation(description: "completionExpectation") + sut.load(image, + callbackQueue: .main) { _ in + completionExpectation.fulfill() + } + + guard case let .download(_, completionHandler) = downloader.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.success(data)) + + waitForExpectations(timeout: 3, handler: nil) + + //named for the image so a later load of the same image finds it + let cachedFileURL = cacheDirectory.appendingPathComponent("a.jpg") + + XCTAssertEqual(try Data(contentsOf: cachedFileURL), data) + } + + func test_givenCachedImage_whenLoadIsCalled_thenTheCachedImageIsDeliveredWithoutADownload() throws { + let downloader = StubDownloader() + + let image = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + + try Data.imageTestData().write(to: cacheDirectory.appendingPathComponent("a.jpg")) + + let fileManager = StubFileManager() + fileManager.urlsToReturn = [cacheDirectory] + + let sut = createSUT(downloader: downloader, + fileManager: fileManager) + + var receivedResult: Result? + let completionExpectation = expectation(description: "completionExpectation") + let token = sut.load(image, + callbackQueue: .main) { result in + receivedResult = result + completionExpectation.fulfill() + } + + waitForExpectations(timeout: 3, handler: nil) + + //nothing is in flight, so there is nothing for the caller to cancel + XCTAssertNil(token) + XCTAssertTrue(downloader.events.isEmpty) + + guard case .success = try XCTUnwrap(receivedResult) else { + XCTFail("Expected a success result") + return + } + } + + func test_givenDownloadInProgress_whenItCompletesWithDataThatIsNotAnImage_thenAnInvalidDataFailureIsDelivered() throws { + let downloader = StubDownloader() + downloader.tokenToReturn = DownloadToken(url: ImageDomainModel.testData().url) + + let fileManager = StubFileManager() + fileManager.urlsToReturn = [cacheDirectory] + + let sut = createSUT(downloader: downloader, + fileManager: fileManager) + + var receivedResult: Result? + let completionExpectation = expectation(description: "completionExpectation") + sut.load(ImageDomainModel.testData(), + callbackQueue: .main) { result in + receivedResult = result + completionExpectation.fulfill() + } + + guard case let .download(_, completionHandler) = downloader.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.success(Data("not an image".utf8))) + + waitForExpectations(timeout: 3, handler: nil) + + guard case let .failure(error) = try XCTUnwrap(receivedResult), + case NetworkingError.invalidData = error else { + XCTFail("Expected an invalid data failure") + return + } + } + + func test_givenDownloadInProgress_whenItFails_thenTheFailureIsPassedOn() throws { + let downloader = StubDownloader() + downloader.tokenToReturn = DownloadToken(url: ImageDomainModel.testData().url) + + let fileManager = StubFileManager() + fileManager.urlsToReturn = [cacheDirectory] + + let sut = createSUT(downloader: downloader, + fileManager: fileManager) + + var receivedResult: Result? + let completionExpectation = expectation(description: "completionExpectation") + sut.load(ImageDomainModel.testData(), + callbackQueue: .main) { result in + receivedResult = result + completionExpectation.fulfill() + } + + guard case let .download(_, completionHandler) = downloader.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.failure(TestError.test)) + + waitForExpectations(timeout: 3, handler: nil) + + guard case let .failure(error) = try XCTUnwrap(receivedResult) else { + XCTFail("Expected a failure result") + return + } + + XCTAssertEqual(error as? TestError, .test) + } + + // MARK: Cancel + + func test_givenLoadInProgress_whenCancelIsCalled_thenTheDownloadIsPaused() throws { + let downloader = StubDownloader() + + let image = ImageDomainModel.testData() + let token = DownloadToken(url: image.url) + downloader.tokenToReturn = token + + let fileManager = StubFileManager() + fileManager.urlsToReturn = [cacheDirectory] + + let sut = createSUT(downloader: downloader, + fileManager: fileManager) + + let loadToken = sut.load(image, + callbackQueue: .main) { _ in } + + sut.cancel(try XCTUnwrap(loadToken)) + + XCTAssertEqual(downloader.events.count, 2) + + guard case let .pause(pausedToken) = downloader.events.last else { + XCTFail("Unexpected event") + return + } + + XCTAssertEqual(pausedToken, token) + } +} + +extension ImageLoaderTests { + func createSUT(downloader: Downloader = StubDownloader(), + fileManager: FileManager = StubFileManager()) -> DefaultImageLoader { + DefaultImageLoader(downloader: downloader, + fileManager: fileManager) + } +} diff --git a/PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift b/PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift index 0226a53..bb66dfb 100644 --- a/PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift +++ b/PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift @@ -22,60 +22,62 @@ final class ImageViewerViewModelTests: XCTestCase { let sut = createSUT(imageDomainModel: image) - XCTAssertEqual(sut.state, .ready(description: image.url.absoluteString)) + XCTAssertEqual(sut.state, .ready) } // MARK: Load func test_givenViewModel_whenLoadIsCalled_thenTheAssetIsRequested() { - let assetService = StubAssetService() + let imageLoader = StubImageLoader() let image = ImageDomainModel.testData(identifier: "a", url: URL(string: "http://test.com/a.jpg")!) - let sut = createSUT(imageDomainModel: image, assetService: assetService) + let sut = createSUT(imageDomainModel: image, + imageLoader: imageLoader) - sut.load() + sut.loadImage() - XCTAssertEqual(assetService.events.count, 1) + XCTAssertEqual(imageLoader.events.count, 1) - guard case let .loadImage(loadedImage, callbackQueue, _) = assetService.events.first else { + guard case let .load(loadedImage, callbackQueue, _) = imageLoader.events.first else { XCTFail("Unexpected event") return } XCTAssertEqual(loadedImage, image) XCTAssertTrue(callbackQueue === DispatchQueue.main) - XCTAssertEqual(sut.state, .loadingAsset(description: image.url.absoluteString)) + XCTAssertEqual(sut.state, .loading) } func test_givenAssetLoadInProgress_whenTheAssetLoads_thenStateTransitionsToLoadedAsset() { - let assetService = StubAssetService() + let imageLoader = StubImageLoader() let image = ImageDomainModel.testData(identifier: "a", url: URL(string: "http://test.com/a.jpg")!) - let sut = createSUT(imageDomainModel: image, assetService: assetService) + let sut = createSUT(imageDomainModel: image, + imageLoader: imageLoader) - sut.load() + sut.loadImage() - guard case let .loadImage(_, _, completionHandler) = assetService.events.first else { + guard case let .load(_, _, completionHandler) = imageLoader.events.first else { XCTFail("Unexpected event") return } let loadedImage = UIImage() - completionHandler(.success(LoadImageResult(imageDomainModel: image, image: loadedImage))) + completionHandler(.success(loadedImage)) - XCTAssertEqual(sut.state, .loadedAsset(loadedImage, description: image.url.absoluteString)) + XCTAssertEqual(sut.state, .loaded(loadedImage)) } func test_givenAssetLoadInProgress_whenTheAssetFailsToLoad_thenStateTransitionsToFailed() { - let assetService = StubAssetService() + let imageLoader = StubImageLoader() - let sut = createSUT(assetService: assetService) + let sut = createSUT(imageLoader: imageLoader) - sut.load() + sut.loadImage() - guard case let .loadImage(_, _, completionHandler) = assetService.events.first else { + guard case let .load(_, _, completionHandler) = imageLoader.events.first else { XCTFail("Unexpected event") return } @@ -85,130 +87,129 @@ final class ImageViewerViewModelTests: XCTestCase { XCTAssertEqual(sut.state, .failed) } - func test_givenAssetLoadInProgress_whenAResultForAnotherImageArrives_thenStateIsUnchanged() { - let assetService = StubAssetService() - let delegate = StubImageViewerViewModelDelegate() - - let imageA = ImageDomainModel.testData(identifier: "a", - url: URL(string: "http://test.com/a.jpg")!) - let imageB = ImageDomainModel.testData(identifier: "b", - url: URL(string: "http://test.com/b.jpg")!) - - let sut = createSUT(imageDomainModel: imageA, assetService: assetService) - sut.delegate = delegate - - sut.load() - - guard case let .loadImage(_, _, completionHandler) = assetService.events.first else { - XCTFail("Unexpected event") - return - } - - let eventCountBeforeStaleResult = delegate.events.count - - completionHandler(.success(LoadImageResult(imageDomainModel: imageB, image: UIImage()))) - - XCTAssertEqual(delegate.events.count, eventCountBeforeStaleResult) - XCTAssertEqual(sut.state, .loadingAsset(description: imageA.url.absoluteString)) - } - func test_givenAssetLoadInProgress_whenLoadIsCalledAgain_thenTheAssetIsNotRequestedASecondTime() { - let assetService = StubAssetService() + let imageLoader = StubImageLoader() - let sut = createSUT(assetService: assetService) + let sut = createSUT(imageLoader: imageLoader) - sut.load() - sut.load() + sut.loadImage() + sut.loadImage() - XCTAssertEqual(assetService.events.count, 1) + XCTAssertEqual(imageLoader.events.count, 1) } func test_givenLoadedAsset_whenLoadIsCalledAgain_thenTheAssetIsNotRequestedASecondTime() { - let assetService = StubAssetService() + let imageLoader = StubImageLoader() let image = ImageDomainModel.testData(identifier: "a", url: URL(string: "http://test.com/a.jpg")!) - let sut = createSUT(imageDomainModel: image, assetService: assetService) + let sut = createSUT(imageDomainModel: image, + imageLoader: imageLoader) - sut.load() + sut.loadImage() - guard case let .loadImage(_, _, completionHandler) = assetService.events.first else { + guard case let .load(_, _, completionHandler) = imageLoader.events.first else { XCTFail("Unexpected event") return } let loadedImage = UIImage() - completionHandler(.success(LoadImageResult(imageDomainModel: image, image: loadedImage))) + completionHandler(.success(loadedImage)) - sut.load() + sut.loadImage() - XCTAssertEqual(assetService.events.count, 1) - XCTAssertEqual(sut.state, .loadedAsset(loadedImage, description: image.url.absoluteString)) + XCTAssertEqual(imageLoader.events.count, 1) + XCTAssertEqual(sut.state, .loaded(loadedImage)) } // MARK: Pause func test_givenAssetLoadInProgress_whenPauseIsCalled_thenTheAssetLoadIsCancelledAndStateReturnsToReady() { - let assetService = StubAssetService() + let imageLoader = StubImageLoader() let image = ImageDomainModel.testData(identifier: "a", url: URL(string: "http://test.com/a.jpg")!) - let downloadToken = DownloadToken(url: image.url) - assetService.downloadTokenToReturn = downloadToken + let token = LoadToken(url: image.url) + imageLoader.tokenToReturn = token - let sut = createSUT(imageDomainModel: image, assetService: assetService) + let sut = createSUT(imageDomainModel: image, + imageLoader: imageLoader) - sut.load() - sut.pause() + sut.loadImage() + sut.cancelImageLoad() - XCTAssertEqual(assetService.events.count, 2) + XCTAssertEqual(imageLoader.events.count, 2) - guard case let .cancelLoadingImage(cancelledDownloadToken) = assetService.events.last else { + guard case let .cancel(cancelledToken) = imageLoader.events.last else { XCTFail("Unexpected event") return } //the view model pauses the download it started, not whatever shares the URL - XCTAssertEqual(cancelledDownloadToken, downloadToken) - XCTAssertEqual(sut.state, .ready(description: image.url.absoluteString)) + XCTAssertEqual(cancelledToken, token) + XCTAssertEqual(sut.state, .ready) } func test_givenNoAssetLoadInProgress_whenPauseIsCalled_thenNothingIsCancelled() { - let assetService = StubAssetService() + let imageLoader = StubImageLoader() + + let sut = createSUT(imageLoader: imageLoader) - let sut = createSUT(assetService: assetService) + sut.cancelImageLoad() - sut.pause() + XCTAssertTrue(imageLoader.events.isEmpty) + } + + func test_givenAssetServedFromTheCache_whenPauseIsCalled_thenNothingIsCancelled() { + let imageLoader = StubImageLoader() + + //a cache hit has nothing in flight, so the loader hands back no token + imageLoader.tokenToReturn = nil + + let sut = createSUT(imageLoader: imageLoader) - XCTAssertTrue(assetService.events.isEmpty) + sut.loadImage() + + guard case let .load(_, _, completionHandler) = imageLoader.events.first else { + XCTFail("Unexpected event") + return + } + + let loadedImage = UIImage() + completionHandler(.success(loadedImage)) + + sut.cancelImageLoad() + + XCTAssertEqual(imageLoader.events.count, 1) + XCTAssertEqual(sut.state, .loaded(loadedImage)) } func test_givenPausedAssetLoad_whenLoadIsCalledAgain_thenTheAssetIsRequestedAgain() { - let assetService = StubAssetService() + let imageLoader = StubImageLoader() let image = ImageDomainModel.testData() - assetService.downloadTokenToReturn = DownloadToken(url: image.url) + imageLoader.tokenToReturn = LoadToken(url: image.url) - let sut = createSUT(imageDomainModel: image, assetService: assetService) + let sut = createSUT(imageDomainModel: image, + imageLoader: imageLoader) - sut.load() - sut.pause() - sut.load() + sut.loadImage() + sut.cancelImageLoad() + sut.loadImage() - XCTAssertEqual(assetService.events.count, 3) + XCTAssertEqual(imageLoader.events.count, 3) - guard case .loadImage = assetService.events.last else { + guard case .load = imageLoader.events.last else { XCTFail("Unexpected event") return } } - } extension ImageViewerViewModelTests { func createSUT(imageDomainModel: ImageDomainModel = .testData(), - assetService: AssetService = StubAssetService()) -> ImageViewerViewModel { + imageLoader: ImageLoader = StubImageLoader()) -> ImageViewerViewModel { ImageViewerViewModel(imageDomainModel: imageDomainModel, - assetService: assetService) + imageLoader: imageLoader) } } From 3f112fe4254039606b254a4bdcafaa6196ef0485 Mon Sep 17 00:00:00 2001 From: William Boles Date: Sat, 12 Sep 2026 22:36:20 +0100 Subject: [PATCH 14/16] Simplified networking stack --- .../project.pbxproj | 80 ++++------ .../Application/AppDelegate.swift | 5 - .../Image/ImageLoader/ImageLoader.swift | 6 +- .../Image/Service/ImagesService.swift | 12 +- .../Networking/Abstract/RequestConfig.swift | 49 ------ .../Abstract/URLRequest+HTTPBody.swift | 23 --- .../Abstract/URLRequestFactory.swift | 45 ------ .../Downloader/DownloadSessionFactory.swift | 56 +++++++ .../Networking/Downloader/Downloader.swift | 41 ++--- .../Networking/ImagesURLRequestFactory.swift | 21 --- .../Networking/Service/NetworkService.swift | 135 ++++++++++++++++ .../Networking/URLSessionFactory.swift | 66 -------- .../Images/ImagesRepository.swift | 54 +++---- .../ImageGalleryViewController.swift | 12 +- .../ImageGallery/ImageGalleryViewModel.swift | 5 - .../ImageViewerViewController.swift | 5 +- ...ession.swift => StubDownloadSession.swift} | 12 +- .../Doubles/StubDownloadSessionFactory.swift | 27 ++++ ...nloadTask.swift => StubDownloadTask.swift} | 9 +- .../Doubles/StubURLSessionFactory.swift | 28 ---- .../Tests/DownloaderTests.swift | 147 +++++++++--------- .../Tests/ImageGalleryViewModelTests.swift | 5 +- .../Tests/ImageLoaderTests.swift | 2 +- 23 files changed, 395 insertions(+), 450 deletions(-) delete mode 100644 PausableDownloads-Example/Networking/Abstract/RequestConfig.swift delete mode 100644 PausableDownloads-Example/Networking/Abstract/URLRequest+HTTPBody.swift delete mode 100644 PausableDownloads-Example/Networking/Abstract/URLRequestFactory.swift create mode 100644 PausableDownloads-Example/Networking/Downloader/DownloadSessionFactory.swift delete mode 100644 PausableDownloads-Example/Networking/ImagesURLRequestFactory.swift create mode 100644 PausableDownloads-Example/Networking/Service/NetworkService.swift delete mode 100644 PausableDownloads-Example/Networking/URLSessionFactory.swift rename PausableDownloads-ExampleTests/Doubles/{StubURLSession.swift => StubDownloadSession.swift} (62%) create mode 100644 PausableDownloads-ExampleTests/Doubles/StubDownloadSessionFactory.swift rename PausableDownloads-ExampleTests/Doubles/{StubURLSessionDownloadTask.swift => StubDownloadTask.swift} (79%) delete mode 100644 PausableDownloads-ExampleTests/Doubles/StubURLSessionFactory.swift diff --git a/PausableDownloads-Example.xcodeproj/project.pbxproj b/PausableDownloads-Example.xcodeproj/project.pbxproj index 4e35362..353c277 100644 --- a/PausableDownloads-Example.xcodeproj/project.pbxproj +++ b/PausableDownloads-Example.xcodeproj/project.pbxproj @@ -13,18 +13,12 @@ 3D63CC58204B554700797A82 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3D63CC2F204B554700797A82 /* Main.storyboard */; }; 3D63CC5E204B554700797A82 /* ImageViewerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D63CC3E204B554700797A82 /* ImageViewerViewController.swift */; }; 3D63CC5F204B554700797A82 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D63CC40204B554700797A82 /* AppDelegate.swift */; }; - 437C0CA63051EC1A009529DF /* ImagesURLRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CA33051EC1A009529DF /* ImagesURLRequestFactory.swift */; }; - 437C0CA73051EC1A009529DF /* RequestConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0C9F3051EC1A009529DF /* RequestConfig.swift */; }; - 437C0CA83051EC1A009529DF /* URLRequest+HTTPBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CA03051EC1A009529DF /* URLRequest+HTTPBody.swift */; }; - 437C0CA93051EC1A009529DF /* URLRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CA13051EC1A009529DF /* URLRequestFactory.swift */; }; - 437C0CAA3051EC1A009529DF /* URLSessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CA43051EC1A009529DF /* URLSessionFactory.swift */; }; 437C0CB43051EC36009529DF /* ImageDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CAB3051EC36009529DF /* ImageDTO.swift */; }; 437C0CB63051EC36009529DF /* ImagesRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0CAC3051EC36009529DF /* ImagesRepository.swift */; }; 437C0D413051F9F3009529DF /* StubImagesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D403051F9F3009529DF /* StubImagesService.swift */; }; 437C0D433051FA65009529DF /* StubImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D423051FA65009529DF /* StubImageLoader.swift */; }; 437C0D453051FB2E009529DF /* ImageViewerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D443051FB2E009529DF /* ImageViewerViewModelTests.swift */; }; 437C0D4830520236009529DF /* ImageDomainModel+TestData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D4730520236009529DF /* ImageDomainModel+TestData.swift */; }; - 43F8C82530550003 /* Data+TestData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F8C82430550003 /* Data+TestData.swift */; }; 437C0D5B3051EDA0009529DF /* ImageViewerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D5A3051EDA0009529DF /* ImageViewerViewModel.swift */; }; 437C0D6B3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437C0D6A3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift */; }; 439551463055E52E00FE65F7 /* Downloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439551433055E52E00FE65F7 /* Downloader.swift */; }; @@ -33,19 +27,22 @@ 439551503055E55500FE65F7 /* ImagesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4395514C3055E55500FE65F7 /* ImagesService.swift */; }; 439551513055E55500FE65F7 /* ImageDomainModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4395514A3055E55500FE65F7 /* ImageDomainModel.swift */; }; 439551523055E55500FE65F7 /* ImagesDomainModelFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4395514B3055E55500FE65F7 /* ImagesDomainModelFactory.swift */; }; - 4399D3903050B4DB009D2CEB /* StubURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D3893050B4DB009D2CEB /* StubURLSession.swift */; }; - 4399D3913050B4DB009D2CEB /* StubURLSessionDownloadTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */; }; - 4399D3923050B4DB009D2CEB /* StubURLSessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */; }; + 439551553055FCD200FE65F7 /* NetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439551533055FCD200FE65F7 /* NetworkService.swift */; }; + 4399D3903050B4DB009D2CEB /* StubDownloadSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D3893050B4DB009D2CEB /* StubDownloadSession.swift */; }; + 4399D3913050B4DB009D2CEB /* StubDownloadTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38A3050B4DB009D2CEB /* StubDownloadTask.swift */; }; 4399D3953050B4DB009D2CEB /* DownloaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4399D38E3050B4DB009D2CEB /* DownloaderTests.swift */; }; 43A1000030600011009529DF /* ImageGalleryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600001009529DF /* ImageGalleryViewController.swift */; }; 43A1000030600012009529DF /* ImageGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600002009529DF /* ImageGalleryViewModel.swift */; }; 43A1000030600013009529DF /* ImageGalleryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600003009529DF /* ImageGalleryViewModelTests.swift */; }; 43A1000030600014009529DF /* StubImageGalleryViewModelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1000030600004009529DF /* StubImageGalleryViewModelDelegate.swift */; }; + 43A2000030700003009529DF /* DownloadSessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A2000030700002009529DF /* DownloadSessionFactory.swift */; }; + 43A2000030700005009529DF /* StubDownloadSessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A2000030700004009529DF /* StubDownloadSessionFactory.swift */; }; 43C2000030700004009529DF /* StubMemoryPressureMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C2000030700003009529DF /* StubMemoryPressureMonitor.swift */; }; 43F8C812305418EA00150C94 /* StubDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F8C811305418EA00150C94 /* StubDownloader.swift */; }; - 43F8C82330550002 /* ImageLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F8C82230550002 /* ImageLoaderTests.swift */; }; - 43F8C82130550001 /* StubFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F8C82030550001 /* StubFileManager.swift */; }; 43F8C81530541F5800150C94 /* TestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F8C81330541F5800150C94 /* TestError.swift */; }; + 43F8C82130550001 /* StubFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F8C82030550001 /* StubFileManager.swift */; }; + 43F8C82330550002 /* ImageLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F8C82230550002 /* ImageLoaderTests.swift */; }; + 43F8C82530550003 /* Data+TestData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F8C82430550003 /* Data+TestData.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -69,18 +66,12 @@ 3D63CC75204B555300797A82 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 3DE07FC91FFF0F31003C95C0 /* PausableDownloads-Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "PausableDownloads-Example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 3DE07FE11FFF0F31003C95C0 /* PausableDownloads-ExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "PausableDownloads-ExampleTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - 437C0C9F3051EC1A009529DF /* RequestConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestConfig.swift; sourceTree = ""; }; - 437C0CA03051EC1A009529DF /* URLRequest+HTTPBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest+HTTPBody.swift"; sourceTree = ""; }; - 437C0CA13051EC1A009529DF /* URLRequestFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLRequestFactory.swift; sourceTree = ""; }; - 437C0CA33051EC1A009529DF /* ImagesURLRequestFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesURLRequestFactory.swift; sourceTree = ""; }; - 437C0CA43051EC1A009529DF /* URLSessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionFactory.swift; sourceTree = ""; }; 437C0CAB3051EC36009529DF /* ImageDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDTO.swift; sourceTree = ""; }; 437C0CAC3051EC36009529DF /* ImagesRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesRepository.swift; sourceTree = ""; }; 437C0D403051F9F3009529DF /* StubImagesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubImagesService.swift; sourceTree = ""; }; 437C0D423051FA65009529DF /* StubImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubImageLoader.swift; sourceTree = ""; }; 437C0D443051FB2E009529DF /* ImageViewerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewerViewModelTests.swift; sourceTree = ""; }; 437C0D4730520236009529DF /* ImageDomainModel+TestData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageDomainModel+TestData.swift"; sourceTree = ""; }; - 43F8C82430550003 /* Data+TestData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+TestData.swift"; sourceTree = ""; }; 437C0D5A3051EDA0009529DF /* ImageViewerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewerViewModel.swift; sourceTree = ""; }; 437C0D6A3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubImageViewerViewModelDelegate.swift; sourceTree = ""; }; 439551433055E52E00FE65F7 /* Downloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Downloader.swift; sourceTree = ""; }; @@ -89,20 +80,23 @@ 4395514A3055E55500FE65F7 /* ImageDomainModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDomainModel.swift; sourceTree = ""; }; 4395514B3055E55500FE65F7 /* ImagesDomainModelFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesDomainModelFactory.swift; sourceTree = ""; }; 4395514C3055E55500FE65F7 /* ImagesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesService.swift; sourceTree = ""; }; - 4399D3893050B4DB009D2CEB /* StubURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSession.swift; sourceTree = ""; }; - 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSessionDownloadTask.swift; sourceTree = ""; }; - 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubURLSessionFactory.swift; sourceTree = ""; }; + 439551533055FCD200FE65F7 /* NetworkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkService.swift; sourceTree = ""; }; + 4399D3893050B4DB009D2CEB /* StubDownloadSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubDownloadSession.swift; sourceTree = ""; }; + 4399D38A3050B4DB009D2CEB /* StubDownloadTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubDownloadTask.swift; sourceTree = ""; }; 4399D38E3050B4DB009D2CEB /* DownloaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloaderTests.swift; sourceTree = ""; }; 43A1000030600001009529DF /* ImageGalleryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryViewController.swift; sourceTree = ""; }; 43A1000030600002009529DF /* ImageGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryViewModel.swift; sourceTree = ""; }; 43A1000030600003009529DF /* ImageGalleryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryViewModelTests.swift; sourceTree = ""; }; 43A1000030600004009529DF /* StubImageGalleryViewModelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubImageGalleryViewModelDelegate.swift; sourceTree = ""; }; + 43A2000030700002009529DF /* DownloadSessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadSessionFactory.swift; sourceTree = ""; }; + 43A2000030700004009529DF /* StubDownloadSessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubDownloadSessionFactory.swift; sourceTree = ""; }; 43C2000030700003009529DF /* StubMemoryPressureMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubMemoryPressureMonitor.swift; sourceTree = ""; }; 43DF70D53051B477004E9EEA /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = ""; }; 43F8C811305418EA00150C94 /* StubDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubDownloader.swift; sourceTree = ""; }; - 43F8C82230550002 /* ImageLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLoaderTests.swift; sourceTree = ""; }; - 43F8C82030550001 /* StubFileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubFileManager.swift; sourceTree = ""; }; 43F8C81330541F5800150C94 /* TestError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestError.swift; sourceTree = ""; }; + 43F8C82030550001 /* StubFileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubFileManager.swift; sourceTree = ""; }; + 43F8C82230550002 /* ImageLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLoaderTests.swift; sourceTree = ""; }; + 43F8C82430550003 /* Data+TestData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+TestData.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -221,22 +215,10 @@ name = Products; sourceTree = ""; }; - 437C0CA23051EC1A009529DF /* Abstract */ = { - isa = PBXGroup; - children = ( - 437C0C9F3051EC1A009529DF /* RequestConfig.swift */, - 437C0CA03051EC1A009529DF /* URLRequest+HTTPBody.swift */, - 437C0CA13051EC1A009529DF /* URLRequestFactory.swift */, - ); - path = Abstract; - sourceTree = ""; - }; 437C0CA53051EC1A009529DF /* Networking */ = { isa = PBXGroup; children = ( - 437C0CA23051EC1A009529DF /* Abstract */, - 437C0CA33051EC1A009529DF /* ImagesURLRequestFactory.swift */, - 437C0CA43051EC1A009529DF /* URLSessionFactory.swift */, + 439551543055FCD200FE65F7 /* Service */, 439551453055E52E00FE65F7 /* Downloader */, ); path = Networking; @@ -271,6 +253,7 @@ 439551453055E52E00FE65F7 /* Downloader */ = { isa = PBXGroup; children = ( + 43A2000030700002009529DF /* DownloadSessionFactory.swift */, 439551433055E52E00FE65F7 /* Downloader.swift */, 439551443055E52E00FE65F7 /* MemoryPressureMonitor.swift */, ); @@ -304,15 +287,23 @@ path = Image; sourceTree = ""; }; + 439551543055FCD200FE65F7 /* Service */ = { + isa = PBXGroup; + children = ( + 439551533055FCD200FE65F7 /* NetworkService.swift */, + ); + path = Service; + sourceTree = ""; + }; 4399D38D3050B4DB009D2CEB /* Doubles */ = { isa = PBXGroup; children = ( 43C2000030700003009529DF /* StubMemoryPressureMonitor.swift */, 437C0D6A3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift */, 43A1000030600004009529DF /* StubImageGalleryViewModelDelegate.swift */, - 4399D3893050B4DB009D2CEB /* StubURLSession.swift */, - 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */, - 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */, + 4399D3893050B4DB009D2CEB /* StubDownloadSession.swift */, + 43A2000030700004009529DF /* StubDownloadSessionFactory.swift */, + 4399D38A3050B4DB009D2CEB /* StubDownloadTask.swift */, 437C0D403051F9F3009529DF /* StubImagesService.swift */, 437C0D423051FA65009529DF /* StubImageLoader.swift */, 43F8C811305418EA00150C94 /* StubDownloader.swift */, @@ -456,19 +447,16 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 437C0CA63051EC1A009529DF /* ImagesURLRequestFactory.swift in Sources */, 437C0CB43051EC36009529DF /* ImageDTO.swift in Sources */, 437C0CB63051EC36009529DF /* ImagesRepository.swift in Sources */, - 437C0CA73051EC1A009529DF /* RequestConfig.swift in Sources */, - 437C0CA83051EC1A009529DF /* URLRequest+HTTPBody.swift in Sources */, 439551463055E52E00FE65F7 /* Downloader.swift in Sources */, + 43A2000030700003009529DF /* DownloadSessionFactory.swift in Sources */, 439551473055E52E00FE65F7 /* MemoryPressureMonitor.swift in Sources */, - 437C0CA93051EC1A009529DF /* URLRequestFactory.swift in Sources */, - 437C0CAA3051EC1A009529DF /* URLSessionFactory.swift in Sources */, 3D63CC5F204B554700797A82 /* AppDelegate.swift in Sources */, 3D63CC5E204B554700797A82 /* ImageViewerViewController.swift in Sources */, 437C0D5B3051EDA0009529DF /* ImageViewerViewModel.swift in Sources */, 4395514F3055E55500FE65F7 /* ImageLoader.swift in Sources */, + 439551553055FCD200FE65F7 /* NetworkService.swift in Sources */, 439551503055E55500FE65F7 /* ImagesService.swift in Sources */, 439551513055E55500FE65F7 /* ImageDomainModel.swift in Sources */, 439551523055E55500FE65F7 /* ImagesDomainModelFactory.swift in Sources */, @@ -482,12 +470,12 @@ buildActionMask = 2147483647; files = ( 437C0D433051FA65009529DF /* StubImageLoader.swift in Sources */, - 4399D3903050B4DB009D2CEB /* StubURLSession.swift in Sources */, - 4399D3913050B4DB009D2CEB /* StubURLSessionDownloadTask.swift in Sources */, + 4399D3903050B4DB009D2CEB /* StubDownloadSession.swift in Sources */, + 43A2000030700005009529DF /* StubDownloadSessionFactory.swift in Sources */, + 4399D3913050B4DB009D2CEB /* StubDownloadTask.swift in Sources */, 437C0D4830520236009529DF /* ImageDomainModel+TestData.swift in Sources */, 43F8C82530550003 /* Data+TestData.swift in Sources */, 437C0D413051F9F3009529DF /* StubImagesService.swift in Sources */, - 4399D3923050B4DB009D2CEB /* StubURLSessionFactory.swift in Sources */, 43F8C81530541F5800150C94 /* TestError.swift in Sources */, 43F8C812305418EA00150C94 /* StubDownloader.swift in Sources */, 43F8C82330550002 /* ImageLoaderTests.swift in Sources */, diff --git a/PausableDownloads-Example/Application/AppDelegate.swift b/PausableDownloads-Example/Application/AppDelegate.swift index 41e120d..fb06464 100644 --- a/PausableDownloads-Example/Application/AppDelegate.swift +++ b/PausableDownloads-Example/Application/AppDelegate.swift @@ -10,10 +10,8 @@ import UIKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { - var window: UIWindow? - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. return true @@ -40,7 +38,4 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func applicationWillTerminate(_ application: UIApplication) { // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. } - - } - diff --git a/PausableDownloads-Example/Image/ImageLoader/ImageLoader.swift b/PausableDownloads-Example/Image/ImageLoader/ImageLoader.swift index 0989c9f..9115778 100644 --- a/PausableDownloads-Example/Image/ImageLoader/ImageLoader.swift +++ b/PausableDownloads-Example/Image/ImageLoader/ImageLoader.swift @@ -16,6 +16,10 @@ typealias LoadImageCompletionHandler = (Result) -> () //underneath is the loader's business typealias LoadToken = DownloadToken +enum ImageLoaderError: Error { + case invalidImageData +} + protocol ImageLoader { //returns nil when the image was served from the cache - nothing is in flight, so //there is nothing to cancel @@ -96,7 +100,7 @@ final class DefaultImageLoader: ImageLoader { cachingTo cacheURL: URL, for imageDomainModel: ImageDomainModel) -> Result { guard let image = UIImage(data: data) else { - return .failure(NetworkingError.invalidData(underlyingError: nil)) + return .failure(ImageLoaderError.invalidImageData) } do { diff --git a/PausableDownloads-Example/Image/Service/ImagesService.swift b/PausableDownloads-Example/Image/Service/ImagesService.swift index c7d3b4e..a638c99 100644 --- a/PausableDownloads-Example/Image/Service/ImagesService.swift +++ b/PausableDownloads-Example/Image/Service/ImagesService.swift @@ -16,12 +16,12 @@ protocol ImagesService { } final class DefaultImagesService: ImagesService { - private let repository: ImagesRepository + private let repository: DefaultImagesRepository private let domainModelFactory: ImagesDomainModelFactory // MARK: - Init - init(repository: ImagesRepository = ImagesRepository(), + init(repository: DefaultImagesRepository = DefaultImagesRepository(), domainModelFactory: ImagesDomainModelFactory = ImagesDomainModelFactory()) { self.repository = repository self.domainModelFactory = domainModelFactory @@ -31,11 +31,11 @@ final class DefaultImagesService: ImagesService { func load(callbackQueue: DispatchQueue, completionHandler: @escaping LoadImagesCompletionHandler) { - repository.retrieveImages { [domainModelFactory] result in + repository.load { [domainModelFactory] result in //a failure passes straight through; a success is mapped from DTOs to domain models - let images = result.map { dtos in - dtos.map { domainModelFactory.buildImage(from: $0) } - } + let images = result + .map { dtos in dtos.map { domainModelFactory.buildImage(from: $0) } } + .mapError { $0 as Error } callbackQueue.async { completionHandler(images) diff --git a/PausableDownloads-Example/Networking/Abstract/RequestConfig.swift b/PausableDownloads-Example/Networking/Abstract/RequestConfig.swift deleted file mode 100644 index 149dae9..0000000 --- a/PausableDownloads-Example/Networking/Abstract/RequestConfig.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// RequestConfig.swift -// DownloadStack-Example -// -// Created by William Boles on 07/01/2018. -// Copyright © 2018 William Boles. All rights reserved. -// - -import Foundation -import os - -enum HTTPRequestMethod: String { - case get = "GET" - case post = "POST" - case put = "PUT" - case delete = "DELETE" -} - -class RequestConfig { - let apiKey: String - let APIHost: String - let timeInterval: TimeInterval - let cachePolicy: NSURLRequest.CachePolicy - - // MARK: - Shared - - static let shared = RequestConfig() - - // MARK: - Init - - init() { - self.apiKey = Bundle.main.object(forInfoDictionaryKey: "CatAPIKey") as? String ?? "" // Add your API key from: https://thecatapi.com/ - self.APIHost = "https://api.thecatapi.com/v1" - self.timeInterval = 45 - self.cachePolicy = .useProtocolCachePolicy - - if apiKey.isEmpty { - os_log(.error, """ - ******************************************************************************* - ******************************************************************************* - ******************************************************************************* - ******************************* MISSING API KEY ******************************* - ******************************************************************************* - ******************************************************************************* - ******************************************************************************* - """) - } - } -} diff --git a/PausableDownloads-Example/Networking/Abstract/URLRequest+HTTPBody.swift b/PausableDownloads-Example/Networking/Abstract/URLRequest+HTTPBody.swift deleted file mode 100644 index b034062..0000000 --- a/PausableDownloads-Example/Networking/Abstract/URLRequest+HTTPBody.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// URLRequest+HTTPBody.swift -// DownloadStack-Example -// -// Created by William Boles on 14/01/2018. -// Copyright © 2018 William Boles. All rights reserved. -// - -import Foundation - -extension URLRequest { - - // MARK: - JSON - - mutating func setJSONParameters(_ parameters: [String: Any]?) { - guard let parameters = parameters else { - httpBody = nil - return - } - - httpBody = try! JSONSerialization.data(withJSONObject: parameters, options: JSONSerialization.WritingOptions(rawValue: 0)) - } -} diff --git a/PausableDownloads-Example/Networking/Abstract/URLRequestFactory.swift b/PausableDownloads-Example/Networking/Abstract/URLRequestFactory.swift deleted file mode 100644 index 472c186..0000000 --- a/PausableDownloads-Example/Networking/Abstract/URLRequestFactory.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// URLRequestFactory.swift -// DownloadStack-Example -// -// Created by William Boles on 07/01/2018. -// Copyright © 2018 William Boles. All rights reserved. -// - -import Foundation - -enum NetworkingError: Error { - case unknown - case retrieval(underlyingError: Error?) - case invalidData(underlyingError: Error?) -} - -class URLRequestFactory { - let config: RequestConfig - - // MARK: - Init - - init(config: RequestConfig = RequestConfig.shared) { - self.config = config - } - - // MARK: - Factory - - func baseRequest(endPoint: String) -> URLRequest { - let stringURL = "\(config.APIHost)/\(endPoint)" - let encodedStringURL = stringURL.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) - let url = URL(string: encodedStringURL!)! - - var request = URLRequest(url: url) - request.addValue(config.apiKey, forHTTPHeaderField: "x-api-key") - - return request - } - - func jsonRequest(endPoint: String) -> URLRequest { - var request = baseRequest(endPoint: endPoint) - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - - return request - } -} diff --git a/PausableDownloads-Example/Networking/Downloader/DownloadSessionFactory.swift b/PausableDownloads-Example/Networking/Downloader/DownloadSessionFactory.swift new file mode 100644 index 0000000..2734347 --- /dev/null +++ b/PausableDownloads-Example/Networking/Downloader/DownloadSessionFactory.swift @@ -0,0 +1,56 @@ +// +// DownloadSessionFactory.swift +// PausableDownloads-Example +// +// Created by William Boles on 12/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import Foundation + +//the parts of `URLSession` that a downloader needs, kept behind a protocol so tests can +//stand in for them +protocol DownloadSession { + func downloadTask(with url: URL) -> DownloadTask + func downloadTask(withResumeData resumeData: Data) -> DownloadTask +} + +extension URLSession: DownloadSession { + func downloadTask(with url: URL) -> DownloadTask { + downloadTask(with: url) as URLSessionDownloadTask + } + + func downloadTask(withResumeData resumeData: Data) -> DownloadTask { + downloadTask(withResumeData: resumeData) as URLSessionDownloadTask + } +} + +protocol DownloadTask { + var taskIdentifier: Int { get } + + func resume() + func cancel(byProducingResumeData completionHandler: @escaping (Data?) -> Void) +} + +extension URLSessionDownloadTask: DownloadTask { } + +protocol DownloadSessionFactory { + func makeSession(delegate: URLSessionDelegate) -> DownloadSession +} + +final class DefaultDownloadSessionFactory: DownloadSessionFactory { + + // MARK: - Session + + func makeSession(delegate: URLSessionDelegate) -> DownloadSession { + let configuration = URLSessionConfiguration.default + + //For demonstration purposes disable caching + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.urlCache = nil + + return URLSession(configuration: configuration, + delegate: delegate, + delegateQueue: nil) + } +} diff --git a/PausableDownloads-Example/Networking/Downloader/Downloader.swift b/PausableDownloads-Example/Networking/Downloader/Downloader.swift index c07642f..bf75b58 100644 --- a/PausableDownloads-Example/Networking/Downloader/Downloader.swift +++ b/PausableDownloads-Example/Networking/Downloader/Downloader.swift @@ -26,6 +26,11 @@ struct DownloadToken: Hashable { } } +enum DownloadError: Error { + case download(underlyingError: Error?) + case invalidData(underlyingError: Error?) +} + protocol Downloader { @discardableResult func download(_ url: URL, @@ -35,11 +40,18 @@ protocol Downloader { final class DefaultDownloader: NSObject, Downloader { private final class Download { + enum DownloadStage: Equatable { + case ready //constructed, no task yet - lives for one `sync` block + case running + case pausing //cancel issued, resumption data hasn't landed yet + case paused + } + let url: URL private(set) var completionHandlers = [DownloadToken: DownloadCompletionHandler]() private(set) var stage: DownloadStage = .ready - private(set) var task: URLSessionDownloadTaskType? + private(set) var task: DownloadTask? private(set) var resumptionData: Data? // MARK: - Init @@ -75,14 +87,14 @@ final class DefaultDownloader: NSObject, Downloader { stage == .ready || stage == .paused } - func markRunning(with task: URLSessionDownloadTaskType) { + func markRunning(with task: DownloadTask) { stage = .running self.task = task resumptionData = nil } //returns the task to cancel, or nil if there isn't one running - func markPausing() -> URLSessionDownloadTaskType? { + func markPausing() -> DownloadTask? { guard stage == .running, let task = task else { return nil @@ -106,19 +118,12 @@ final class DefaultDownloader: NSObject, Downloader { } } - private enum DownloadStage: Equatable { - case ready //constructed, no task yet - lives for one `sync` block - case running - case pausing //cancel issued, resumption data hasn't landed yet - case paused - } - //one entry per URL - everybody who wants it coalesces onto the same download private var downloads = [URL: Download]() private let queue = DispatchQueue(label: "com.williamboles.downloader") - private let urlSessionFactory: URLSessionFactoryType - private lazy var session: URLSessionType = urlSessionFactory.defaultSession(delegate: self) + private let sessionFactory: DownloadSessionFactory + private lazy var session: DownloadSession = sessionFactory.makeSession(delegate: self) private let memoryPressureMonitor: MemoryPressureMonitor // MARK: - Singleton @@ -127,9 +132,9 @@ final class DefaultDownloader: NSObject, Downloader { // MARK: - Init - init(urlSessionFactory: URLSessionFactoryType = URLSessionFactory(), + init(sessionFactory: DownloadSessionFactory = DefaultDownloadSessionFactory(), memoryPressureMonitor: MemoryPressureMonitor = DefaultMemoryPressureMonitor()) { - self.urlSessionFactory = urlSessionFactory + self.sessionFactory = sessionFactory self.memoryPressureMonitor = memoryPressureMonitor super.init() @@ -193,7 +198,7 @@ final class DefaultDownloader: NSObject, Downloader { private func startDownload(_ download: Download) { dispatchPrecondition(condition: .onQueue(queue)) - let task: URLSessionDownloadTaskType + let task: DownloadTask if let resumptionData = download.resumptionData { os_log(.info, "Resuming a paused download: %{public}@", download.url.absoluteString) task = session.downloadTask(withResumeData: resumptionData) @@ -212,7 +217,7 @@ final class DefaultDownloader: NSObject, Downloader { func pause(_ token: DownloadToken) { let url = token.url - let taskToPause: URLSessionDownloadTaskType? = sync { + let taskToPause: DownloadTask? = sync { guard let download = downloads[url], download.remove(token) else { return nil @@ -297,7 +302,7 @@ final class DefaultDownloader: NSObject, Downloader { os_log(.info, "Download completed for: %{public}@", url.absoluteString) } catch let error { - result = .failure(NetworkingError.invalidData(underlyingError: error)) + result = .failure(DownloadError.invalidData(underlyingError: error)) os_log(.error, "Download completed for: %{public}@ but its file could not be read: %{public}@", url.absoluteString, error.localizedDescription) } @@ -318,7 +323,7 @@ final class DefaultDownloader: NSObject, Downloader { os_log(.error, "Download failed for: %{public}@ with error: %{public}@", url.absoluteString, error.localizedDescription) - deliverResult(.failure(NetworkingError.retrieval(underlyingError: error)), + deliverResult(.failure(DownloadError.download(underlyingError: error)), for: url, taskIdentifier: taskIdentifier) } diff --git a/PausableDownloads-Example/Networking/ImagesURLRequestFactory.swift b/PausableDownloads-Example/Networking/ImagesURLRequestFactory.swift deleted file mode 100644 index bdada69..0000000 --- a/PausableDownloads-Example/Networking/ImagesURLRequestFactory.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// CatImagesURLRequestFactory.swift -// PausableDownloads-Example -// -// Created by William Boles on 07/01/2018. -// Copyright © 2018 William Boles. All rights reserved. -// - -import Foundation - -class ImagesURLRequestFactory: URLRequestFactory { - - // MARK: - Retrieval - - func requestToRetrieveImages(limit: Int = 10) -> URLRequest { - var request = jsonRequest(endPoint: "images/search?limit=\(limit)&order=RANDOM&size=full") - request.httpMethod = HTTPRequestMethod.get.rawValue - - return request - } -} diff --git a/PausableDownloads-Example/Networking/Service/NetworkService.swift b/PausableDownloads-Example/Networking/Service/NetworkService.swift new file mode 100644 index 0000000..2dfed51 --- /dev/null +++ b/PausableDownloads-Example/Networking/Service/NetworkService.swift @@ -0,0 +1,135 @@ +// +// NetworkService.swift +// PausableDownloads-Example +// +// Created by William Boles on 12/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import Foundation +import os + +protocol URLSessionType { + func data(for request: URLRequest, + completionHandler: @escaping (Data?, URLResponse?, Error?) -> ()) +} + +extension URLSession: URLSessionType { + func data(for request: URLRequest, + completionHandler: @escaping (Data?, URLResponse?, Error?) -> ()) { + dataTask(with: request, + completionHandler: completionHandler).resume() + } +} + +enum NetworkError: Error { + case transportFailure(Error?) + case invalidResponse + case unacceptableStatusCode(Int) + case decodingFailed(Error) +} + +typealias NetworkCompletionHandler = (Result) -> () + +protocol NetworkService { + var baseURL: URL { get } + + func makeJSONRequest(_ request: URLRequest, + decoder: JSONDecoder, + completionHandler: @escaping NetworkCompletionHandler) +} + +extension NetworkService { + func makeJSONRequest(_ request: URLRequest, + completionHandler: @escaping NetworkCompletionHandler) { + makeJSONRequest(request, + decoder: .domainDecoder, + completionHandler: completionHandler) + } +} + +final class DefaultNetworkService: NetworkService { + let baseURL = URL(string: "https://api.thecatapi.com/v1")! + + private let session: URLSessionType + private let apiKey: String + + // MARK: - Init + + init(session: URLSessionType = URLSession.shared, + apiKey: String = Bundle.main.catAPIKey) { + self.session = session + self.apiKey = apiKey + } + + // MARK: - Request + + func makeJSONRequest(_ request: URLRequest, + decoder: JSONDecoder, + completionHandler: @escaping NetworkCompletionHandler) { + //the API expects to be told who is asking on every request, so callers don't have to + var request = request + request.addValue(apiKey, forHTTPHeaderField: "x-api-key") + + session.data(for: request) { data, response, error in + let result: Result = Self.decode(data, + response: response, + error: error, + using: decoder) + + completionHandler(result) + } + } + + private static func decode(_ data: Data?, + response: URLResponse?, + error: Error?, + using decoder: JSONDecoder) -> Result { + guard let data = data else { + return .failure(.transportFailure(error)) + } + + guard let statusCode = (response as? HTTPURLResponse)?.statusCode else { + return .failure(.invalidResponse) + } + + guard (200..<300).contains(statusCode) else { + return .failure(.unacceptableStatusCode(statusCode)) + } + + do { + return .success(try decoder.decode(T.self, from: data)) + } catch let error { + return .failure(.decodingFailed(error)) + } + } +} + +extension JSONDecoder { + static var domainDecoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + return decoder + } +} + +extension Bundle { + var catAPIKey: String { + let apiKey = object(forInfoDictionaryKey: "CatAPIKey") as? String ?? "" // Add your API key from: https://thecatapi.com/ + + if apiKey.isEmpty { + os_log(.error, """ + ******************************************************************************* + ******************************************************************************* + ******************************************************************************* + ******************************* MISSING API KEY ******************************* + ******************************************************************************* + ******************************************************************************* + ******************************************************************************* + """) + } + + return apiKey + } +} diff --git a/PausableDownloads-Example/Networking/URLSessionFactory.swift b/PausableDownloads-Example/Networking/URLSessionFactory.swift deleted file mode 100644 index 0c4b385..0000000 --- a/PausableDownloads-Example/Networking/URLSessionFactory.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// URLSessionFactory.swift -// DownloadStack-Example -// -// Created by William Boles on 13/12/2019. -// Copyright © 2019 William Boles. All rights reserved. -// - -import Foundation - -protocol URLSessionFactoryType { - func defaultSession(delegate: URLSessionDelegate?, - delegateQueue queue: OperationQueue?) -> URLSessionType -} - -extension URLSessionFactoryType { - func defaultSession(delegate: URLSessionDelegate? = nil, - delegateQueue queue: OperationQueue? = nil) -> URLSessionType { - return defaultSession(delegate: delegate, delegateQueue: queue) - } -} - -protocol URLSessionType { - func downloadTask(with url: URL) -> URLSessionDownloadTaskType - func downloadTask(withResumeData resumeData: Data) -> URLSessionDownloadTaskType -} - -extension URLSession: URLSessionType { - func downloadTask(with url: URL) -> URLSessionDownloadTaskType { - return downloadTask(with: url) as URLSessionDownloadTask - } - - func downloadTask(withResumeData resumeData: Data) -> URLSessionDownloadTaskType { - return downloadTask(withResumeData: resumeData) as URLSessionDownloadTask - } -} - -protocol URLSessionDownloadTaskType { - var taskIdentifier: Int { get } - - func resume() - func cancel() - func cancel(byProducingResumeData completionHandler: @escaping (Data?) -> Void) -} - -extension URLSessionDownloadTask: URLSessionDownloadTaskType {} - -class URLSessionFactory: URLSessionFactoryType { - - // MARK: - Default - - func defaultSession(delegate: URLSessionDelegate? = nil, - delegateQueue queue: OperationQueue? = nil) -> URLSessionType { - let configuration = URLSessionConfiguration.default - - //For demonstration purposes disable caching - configuration.requestCachePolicy = .reloadIgnoringLocalCacheData - configuration.urlCache = nil - - let session = URLSession(configuration: configuration, - delegate: delegate, - delegateQueue: queue) - - return session - } -} diff --git a/PausableDownloads-Example/Repositories/Images/ImagesRepository.swift b/PausableDownloads-Example/Repositories/Images/ImagesRepository.swift index b9c5e0c..3de2c03 100644 --- a/PausableDownloads-Example/Repositories/Images/ImagesRepository.swift +++ b/PausableDownloads-Example/Repositories/Images/ImagesRepository.swift @@ -8,47 +8,35 @@ import Foundation -class ImagesRepository { - private let urlRequestFactory: ImagesURLRequestFactory - private let session: URLSession +protocol ImagesRepository { + func load(completionHandler: @escaping NetworkCompletionHandler<[ImageDTO]>) +} + +final class DefaultImagesRepository { + private let networkService: NetworkService // MARK: - Init - init(session: URLSession = URLSession.shared, - urlRequestFactory: ImagesURLRequestFactory = ImagesURLRequestFactory()) { - self.session = session - self.urlRequestFactory = urlRequestFactory + init(networkService: NetworkService = DefaultNetworkService()) { + self.networkService = networkService } // MARK: - List - func retrieveImages(completionHandler: @escaping ((_ result: Result<[ImageDTO], Error>) -> ())) { - let request = urlRequestFactory.requestToRetrieveImages() + func load(completionHandler: @escaping NetworkCompletionHandler<[ImageDTO]>) { + networkService.makeJSONRequest(urlRequest(), + completionHandler: completionHandler) + } + + private func urlRequest() -> URLRequest { + let url = networkService.baseURL.appendingPathComponent("images/search") - let task = session.dataTask(with: request) { (data: Data?, response: URLResponse?, error: Error?) in - guard let data = data else { - let retrievalError = NetworkingError.retrieval(underlyingError: error) - completionHandler(Result.failure(retrievalError)) - return - } - - guard let statusCode = (response as? HTTPURLResponse)?.statusCode, - (200..<300).contains(statusCode) else { - let retrievalError = NetworkingError.retrieval(underlyingError: error) - completionHandler(Result.failure(retrievalError)) - return - } - - do { - let dtos = try JSONDecoder().decode([ImageDTO].self, from: data) - - completionHandler(Result.success(dtos)) - } catch let error { - let invalidError = NetworkingError.invalidData(underlyingError: error) - completionHandler(Result.failure(invalidError)) - } - } + var components = URLComponents(url: url, + resolvingAgainstBaseURL: false)! + components.queryItems = [URLQueryItem(name: "limit", value: "10"), + URLQueryItem(name: "order", value: "RANDOM"), + URLQueryItem(name: "size", value: "full")] - task.resume() + return URLRequest(url: components.url!) } } diff --git a/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewController.swift b/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewController.swift index 79ef6a3..af1615c 100644 --- a/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewController.swift +++ b/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewController.swift @@ -30,10 +30,9 @@ class ImageGalleryViewController: UIPageViewController { } private func configureNavigationBar() { - /* Paging in `.scroll` style puts a scroll view behind the bar, so without - this it adopts its transparent scroll-edge appearance and the position - indicator disappears against the black background. - */ + //Paging in `.scroll` style puts a scroll view behind the bar, so without + //this it adopts its transparent scroll-edge appearance and the position + //indicator disappears against the black background. let appearance = UINavigationBarAppearance() appearance.configureWithOpaqueBackground() @@ -107,9 +106,8 @@ extension ImageGalleryViewController: UIPageViewControllerDelegate { didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { - /* Only a transition the user actually landed on should pause what came - before it - a cancelled swipe hasn't moved anywhere. - */ + //Only a transition the user actually landed on should pause what came + //before it - a cancelled swipe hasn't moved anywhere. guard completed, let imageViewerViewController = viewControllers?.first as? ImageViewerViewController else { return diff --git a/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewModel.swift b/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewModel.swift index f5deb27..7ab21fe 100644 --- a/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewModel.swift +++ b/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewModel.swift @@ -29,9 +29,6 @@ final class ImageGalleryViewModel { private let imageLoader: ImageLoader private var images = [ImageDomainModel]() - - //keyed by position in `images` - safe because `images` is only ever replaced wholesale - //and this is cleared at the same moment, so the indices can't drift apart private var imageViewerViewModels = [Int: ImageViewerViewModel]() // MARK: - Init @@ -97,8 +94,6 @@ final class ImageGalleryViewModel { return } - //deliberately not `viewModel(at:)` - a page that never had a view model never - //started a load, so there is nothing to cancel and no reason to create one imageViewerViewModels[currentIndex]?.cancelImageLoad() currentIndex = index diff --git a/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift b/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift index d5c7b5d..ae2c0c7 100644 --- a/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift +++ b/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift @@ -40,9 +40,8 @@ class ImageViewerViewController: UIViewController { viewModel.delegate = self - /* This page may well be being rebuilt around a view model that is already - loading or loaded, so render what is there rather than waiting for a change. - */ + //This page may well be being rebuilt around a view model that is already + //loading or loaded, so render what is there rather than waiting for a change. render(viewModel.state) } diff --git a/PausableDownloads-ExampleTests/Doubles/StubURLSession.swift b/PausableDownloads-ExampleTests/Doubles/StubDownloadSession.swift similarity index 62% rename from PausableDownloads-ExampleTests/Doubles/StubURLSession.swift rename to PausableDownloads-ExampleTests/Doubles/StubDownloadSession.swift index cfbd9a4..54db99f 100644 --- a/PausableDownloads-ExampleTests/Doubles/StubURLSession.swift +++ b/PausableDownloads-ExampleTests/Doubles/StubDownloadSession.swift @@ -1,5 +1,5 @@ // -// StubURLSession.swift +// StubDownloadSession.swift // PausableDownloads-ExampleTests // // Created by William Boles on 13/12/2019. @@ -10,7 +10,7 @@ import Foundation @testable import PausableDownloads_Example -class StubURLSession: URLSessionType { +class StubDownloadSession: DownloadSession { enum Event { case downloadTask(URL) case downloadTaskWithResumeData(Data) @@ -18,16 +18,16 @@ class StubURLSession: URLSessionType { private(set) var events = [Event]() - var downloadTaskToReturn: StubURLSessionDownloadTask! - var downloadTaskWithResumeDataToReturn: StubURLSessionDownloadTask! + var downloadTaskToReturn: StubDownloadTask! + var downloadTaskWithResumeDataToReturn: StubDownloadTask! - func downloadTask(with url: URL) -> URLSessionDownloadTaskType { + func downloadTask(with url: URL) -> DownloadTask { events.append(.downloadTask(url)) return downloadTaskToReturn } - func downloadTask(withResumeData resumeData: Data) -> URLSessionDownloadTaskType { + func downloadTask(withResumeData resumeData: Data) -> DownloadTask { events.append(.downloadTaskWithResumeData(resumeData)) return downloadTaskWithResumeDataToReturn diff --git a/PausableDownloads-ExampleTests/Doubles/StubDownloadSessionFactory.swift b/PausableDownloads-ExampleTests/Doubles/StubDownloadSessionFactory.swift new file mode 100644 index 0000000..2b3d84a --- /dev/null +++ b/PausableDownloads-ExampleTests/Doubles/StubDownloadSessionFactory.swift @@ -0,0 +1,27 @@ +// +// StubDownloadSessionFactory.swift +// PausableDownloads-ExampleTests +// +// Created by William Boles on 14/12/2019. +// Copyright © 2019 William Boles. All rights reserved. +// + +import Foundation + +@testable import PausableDownloads_Example + +class StubDownloadSessionFactory: DownloadSessionFactory { + enum Event { + case makeSession(URLSessionDelegate) + } + + private(set) var events = [Event]() + + var sessionToReturn: DownloadSession! + + func makeSession(delegate: URLSessionDelegate) -> DownloadSession { + events.append(.makeSession(delegate)) + + return sessionToReturn + } +} diff --git a/PausableDownloads-ExampleTests/Doubles/StubURLSessionDownloadTask.swift b/PausableDownloads-ExampleTests/Doubles/StubDownloadTask.swift similarity index 79% rename from PausableDownloads-ExampleTests/Doubles/StubURLSessionDownloadTask.swift rename to PausableDownloads-ExampleTests/Doubles/StubDownloadTask.swift index 443d8ba..b8a6a93 100644 --- a/PausableDownloads-ExampleTests/Doubles/StubURLSessionDownloadTask.swift +++ b/PausableDownloads-ExampleTests/Doubles/StubDownloadTask.swift @@ -1,5 +1,5 @@ // -// StubURLSessionDownloadTask.swift +// StubDownloadTask.swift // PausableDownloads-ExampleTests // // Created by William Boles on 13/12/2019. @@ -10,10 +10,9 @@ import Foundation @testable import PausableDownloads_Example -class StubURLSessionDownloadTask: URLSessionDownloadTaskType { +class StubDownloadTask: DownloadTask { enum Event { case resume - case cancel case cancelByProducingResumeData((Data?) -> Void) } @@ -31,10 +30,6 @@ class StubURLSessionDownloadTask: URLSessionDownloadTaskType { events.append(.resume) } - func cancel() { - events.append(.cancel) - } - func cancel(byProducingResumeData completionHandler: @escaping (Data?) -> Void) { events.append(.cancelByProducingResumeData(completionHandler)) } diff --git a/PausableDownloads-ExampleTests/Doubles/StubURLSessionFactory.swift b/PausableDownloads-ExampleTests/Doubles/StubURLSessionFactory.swift deleted file mode 100644 index fa3acb3..0000000 --- a/PausableDownloads-ExampleTests/Doubles/StubURLSessionFactory.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// StubURLSessionFactory.swift -// PausableDownloads-ExampleTests -// -// Created by William Boles on 14/12/2019. -// Copyright © 2019 William Boles. All rights reserved. -// - -import Foundation - -@testable import PausableDownloads_Example - -class StubURLSessionFactory: URLSessionFactoryType { - enum Event { - case defaultSession(URLSessionDelegate?, OperationQueue?) - } - - private(set) var events = [Event]() - - var sessionToReturn: URLSessionType! - - func defaultSession(delegate: URLSessionDelegate?, - delegateQueue queue: OperationQueue?) -> URLSessionType { - events.append(.defaultSession(delegate, queue)) - - return sessionToReturn - } -} diff --git a/PausableDownloads-ExampleTests/Tests/DownloaderTests.swift b/PausableDownloads-ExampleTests/Tests/DownloaderTests.swift index 54e1587..72c552a 100644 --- a/PausableDownloads-ExampleTests/Tests/DownloaderTests.swift +++ b/PausableDownloads-ExampleTests/Tests/DownloaderTests.swift @@ -34,7 +34,7 @@ class DownloaderTests: XCTestCase { let memoryPressureMonitor = StubMemoryPressureMonitor() - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session, memoryPressureMonitor: memoryPressureMonitor) guard case let .startMonitoring(memoryPressureHandler) = memoryPressureMonitor.events.first else { @@ -42,7 +42,7 @@ class DownloaderTests: XCTestCase { return } - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() session.downloadTaskToReturn = downloadTask let downloadID = sut.download(url) { _ in } @@ -63,7 +63,7 @@ class DownloaderTests: XCTestCase { memoryPressureHandler() //the purged item took its resumption data with it, so the next schedule starts over - session.downloadTaskWithResumeDataToReturn = StubURLSessionDownloadTask() + session.downloadTaskWithResumeDataToReturn = StubDownloadTask() sut.download(url) { _ in } @@ -80,10 +80,10 @@ class DownloaderTests: XCTestCase { let memoryPressureMonitor = StubMemoryPressureMonitor() - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session, memoryPressureMonitor: memoryPressureMonitor) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() session.downloadTaskToReturn = downloadTask guard case let .startMonitoring(memoryPressureHandler) = memoryPressureMonitor.events.first else { @@ -110,10 +110,10 @@ class DownloaderTests: XCTestCase { func test_givenNoExistingDownload_whendownloadIsCalled_thenDownloadTaskIsCreatedForURLAndResumed() { let url = URL(string: "http://test.com/example")! - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() session.downloadTaskToReturn = downloadTask sut.download(url) { _ in } @@ -136,10 +136,10 @@ class DownloaderTests: XCTestCase { } func test_givenNoExistingDownloads_whendownloadIsCalledForTwoDifferentURLs_thenBothDownloadTasksAreResumed() { - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() session.downloadTaskToReturn = downloadTask let urlA = URL(string: "http://example.com/resourceA")! @@ -160,10 +160,10 @@ class DownloaderTests: XCTestCase { func test_givenInFlightDownload_whendownloadIsCalledForTheSameURL_thenOneDownloadIsSharedAndBothCompletionHandlersAreCalled() { let url = URL(string: "http://test.com/example")! - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask @@ -186,10 +186,10 @@ class DownloaderTests: XCTestCase { func test_givenTwoCallersForTheSameURL_whenOneIsPaused_thenTheSharedTaskIsNotCancelledAndTheOtherIsStillAnswered() { let url = URL(string: "http://test.com/example")! - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask @@ -218,10 +218,10 @@ class DownloaderTests: XCTestCase { func test_givenPausedDownloadThatProducedNoResumptionData_whendownloadIsCalledForTheSameURL_thenTheDownloadRestarts() { let url = URL(string: "http://test.com/example")! - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() session.downloadTaskToReturn = downloadTask let downloadID = sut.download(url) { _ in } @@ -258,10 +258,10 @@ class DownloaderTests: XCTestCase { let url = URL(string: "http://test.com/example")! let resumptionData = Data("resumption".utf8) - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() session.downloadTaskToReturn = downloadTask let downloadID = sut.download(url) { _ in } @@ -272,7 +272,7 @@ class DownloaderTests: XCTestCase { return } - let resumedDownloadTask = StubURLSessionDownloadTask() + let resumedDownloadTask = StubDownloadTask() session.downloadTaskWithResumeDataToReturn = resumedDownloadTask //rescheduling whilst the resumption data is still in flight - the fast swipe back @@ -302,12 +302,12 @@ class DownloaderTests: XCTestCase { func test_givenACallerThatJoinedAPauseInFlight_whenItPausesBeforeTheResumptionDataLands_thenNoTaskIsEverStarted() { let url = URL(string: "http://test.com/example")! - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() session.downloadTaskToReturn = downloadTask - session.downloadTaskWithResumeDataToReturn = StubURLSessionDownloadTask() + session.downloadTaskWithResumeDataToReturn = StubDownloadTask() let firstDownloadID = sut.download(url) { _ in } sut.pause(firstDownloadID) @@ -333,10 +333,10 @@ class DownloaderTests: XCTestCase { func test_givenPausedDownload_whenTheCancelledDownloadTaskCompletes_thenTheCompletionHandlerIsNotCalled() { let url = URL(string: "http://test.com/example")! - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask @@ -360,10 +360,10 @@ class DownloaderTests: XCTestCase { let url = URL(string: "http://test.com/example")! let fileURL = try XCTUnwrap(Bundle(for: type(of: self)).url(forResource: "square", withExtension: "pdf")) - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask @@ -386,10 +386,10 @@ class DownloaderTests: XCTestCase { func test_givenScheduledDownload_whenTheDownloadTaskFails_thenTheCompletionHandlerIsCalled() { let url = URL(string: "http://test.com/example")! - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask @@ -412,10 +412,10 @@ class DownloaderTests: XCTestCase { let url = URL(string: "http://test.com/example")! let resumptionData = Data("resumption".utf8) - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() session.downloadTaskToReturn = downloadTask let downloadID = sut.download(url) { _ in } @@ -428,7 +428,7 @@ class DownloaderTests: XCTestCase { resumeDataHandler(resumptionData) - let resumedDownloadTask = StubURLSessionDownloadTask() + let resumedDownloadTask = StubDownloadTask() session.downloadTaskWithResumeDataToReturn = resumedDownloadTask sut.download(url) { _ in } @@ -456,10 +456,10 @@ class DownloaderTests: XCTestCase { XCTAssertFalse(expectedData.isEmpty) - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask @@ -490,10 +490,10 @@ class DownloaderTests: XCTestCase { func test_givenScheduledDownload_whenTheDownloadTaskCompletesWithAnError_thenTheCompletionHandlerReceivesARetrievalFailure() throws { let url = URL(string: "http://test.com/example")! - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask @@ -514,7 +514,7 @@ class DownloaderTests: XCTestCase { waitForExpectations(timeout: 3, handler: nil) guard case let .failure(error) = try XCTUnwrap(receivedResult), - case let NetworkingError.retrieval(underlyingError) = error else { + case let DownloadError.download(underlyingError) = error else { XCTFail("Expected a retrieval failure") return } @@ -526,10 +526,10 @@ class DownloaderTests: XCTestCase { let url = URL(string: "http://test.com/example")! let unreadableFileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("does-not-exist-\(UUID().uuidString)") - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask @@ -550,7 +550,7 @@ class DownloaderTests: XCTestCase { waitForExpectations(timeout: 3, handler: nil) guard case let .failure(error) = try XCTUnwrap(receivedResult), - case NetworkingError.invalidData = error else { + case DownloadError.invalidData = error else { XCTFail("Expected an invalid data failure") return } @@ -559,10 +559,10 @@ class DownloaderTests: XCTestCase { func test_givenPauseThenResume_whenTheCancelledDownloadTaskCompletes_thenItIsIgnoredAndTheResumedTaskStillCompletes() { let url = URL(string: "http://test.com/example")! - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let retiredDownloadTask = StubURLSessionDownloadTask() + let retiredDownloadTask = StubDownloadTask() retiredDownloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = retiredDownloadTask @@ -576,7 +576,7 @@ class DownloaderTests: XCTestCase { resumeDataHandler(Data("resumption".utf8)) - let resumedDownloadTask = StubURLSessionDownloadTask() + let resumedDownloadTask = StubDownloadTask() resumedDownloadTask.taskIdentifierToReturn = 2 session.downloadTaskWithResumeDataToReturn = resumedDownloadTask @@ -596,10 +596,10 @@ class DownloaderTests: XCTestCase { func test_givenNoMatchingDownload_whenAnEventForAnUnknownTaskIsReceived_thenItIsIgnored() { let url = URL(string: "http://test.com/example")! - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask @@ -622,10 +622,10 @@ class DownloaderTests: XCTestCase { func test_givenScheduledDownload_whenCancelDownloadIsCalled_thenDownloadTaskIsCancelledByProducingResumeData() { let url = URL(string: "http://test.com/example")! - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() session.downloadTaskToReturn = downloadTask let downloadID = sut.download(url) { _ in } @@ -642,10 +642,10 @@ class DownloaderTests: XCTestCase { func test_givenNoScheduledDownloads_whenCancelDownloadIsCalledForAnUnknownID_thenNoDownloadTaskEventsAreRecorded() { let url = URL(string: "http://test.com/example")! - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() session.downloadTaskToReturn = downloadTask sut.pause(DownloadToken(url: url)) @@ -663,10 +663,10 @@ class DownloaderTests: XCTestCase { XCTAssertFalse(expectedData.isEmpty) - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask @@ -694,10 +694,10 @@ class DownloaderTests: XCTestCase { func test_givenTwoCallersForTheSameURL_whenBothPause_thenTheSharedTaskIsCancelledOnce() { let url = URL(string: "http://test.com/example")! - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() session.downloadTaskToReturn = downloadTask let firstDownloadToken = sut.download(url) { _ in } @@ -722,10 +722,10 @@ class DownloaderTests: XCTestCase { func test_givenTwoCallersJoinedAPauseInFlight_whenTheResumptionDataLands_thenOnlyOneTaskIsStarted() { let url = URL(string: "http://test.com/example")! - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() session.downloadTaskToReturn = downloadTask let firstDownloadToken = sut.download(url) { _ in } @@ -736,7 +736,7 @@ class DownloaderTests: XCTestCase { return } - let resumedTask = StubURLSessionDownloadTask() + let resumedTask = StubDownloadTask() resumedTask.taskIdentifierToReturn = 2 session.downloadTaskWithResumeDataToReturn = resumedTask @@ -768,10 +768,10 @@ class DownloaderTests: XCTestCase { func test_givenARetiredTaskThatFailsAfterTheDownloadWasResumed_whenItCompletes_thenTheResumedDownloadIsUnaffected() { let url = URL(string: "http://test.com/example")! - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() downloadTask.taskIdentifierToReturn = 1 session.downloadTaskToReturn = downloadTask @@ -785,16 +785,15 @@ class DownloaderTests: XCTestCase { resumeDataHandler(Data("resumption".utf8)) - let resumedTask = StubURLSessionDownloadTask() + let resumedTask = StubDownloadTask() resumedTask.taskIdentifierToReturn = 2 session.downloadTaskWithResumeDataToReturn = resumedTask var results = [Result]() sut.download(url) { results.append($0) } - /* The retired task winds down with a real error rather than a cancellation, so - nothing but the phase stops it being mistaken for the download now running. - */ + //The retired task winds down with a real error rather than a cancellation, so + //nothing but the phase stops it being mistaken for the download now running. sut.handleFailedDownloading(for: url, taskIdentifier: downloadTask.taskIdentifier, error: TestError.test) XCTAssertTrue(results.isEmpty) @@ -809,7 +808,7 @@ class DownloaderTests: XCTestCase { let memoryPressureMonitor = StubMemoryPressureMonitor() - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session, memoryPressureMonitor: memoryPressureMonitor) guard case let .startMonitoring(memoryPressureHandler) = memoryPressureMonitor.events.first else { @@ -817,7 +816,7 @@ class DownloaderTests: XCTestCase { return } - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() session.downloadTaskToReturn = downloadTask let firstDownloadToken = sut.download(url) { _ in } @@ -828,7 +827,7 @@ class DownloaderTests: XCTestCase { return } - let resumedTask = StubURLSessionDownloadTask() + let resumedTask = StubDownloadTask() resumedTask.taskIdentifierToReturn = 2 session.downloadTaskWithResumeDataToReturn = resumedTask @@ -855,10 +854,10 @@ class DownloaderTests: XCTestCase { func test_givenAPausedDownloadWithResumptionData_whenTwoCallersScheduleTheSameURL_thenTheResumptionDataIsUsedOnce() { let url = URL(string: "http://test.com/example")! - let session = StubURLSession() + let session = StubDownloadSession() let sut = createSUT(session: session) - let downloadTask = StubURLSessionDownloadTask() + let downloadTask = StubDownloadTask() session.downloadTaskToReturn = downloadTask let firstDownloadToken = sut.download(url) { _ in } @@ -871,7 +870,7 @@ class DownloaderTests: XCTestCase { resumeDataHandler(Data("resumption".utf8)) - session.downloadTaskWithResumeDataToReturn = StubURLSessionDownloadTask() + session.downloadTaskWithResumeDataToReturn = StubDownloadTask() sut.download(url) { _ in } sut.download(url) { _ in } @@ -887,18 +886,12 @@ class DownloaderTests: XCTestCase { } extension DownloaderTests { - func createSUT(session: StubURLSession = StubURLSession(), + func createSUT(session: StubDownloadSession = StubDownloadSession(), memoryPressureMonitor: MemoryPressureMonitor = StubMemoryPressureMonitor()) -> DefaultDownloader { - let urlSessionFactory = StubURLSessionFactory() - urlSessionFactory.sessionToReturn = session + let sessionFactory = StubDownloadSessionFactory() + sessionFactory.sessionToReturn = session - return createSUT(urlSessionFactory: urlSessionFactory, - memoryPressureMonitor: memoryPressureMonitor) - } - - func createSUT(urlSessionFactory: URLSessionFactoryType, - memoryPressureMonitor: MemoryPressureMonitor = StubMemoryPressureMonitor()) -> DefaultDownloader { - DefaultDownloader(urlSessionFactory: urlSessionFactory, - memoryPressureMonitor: memoryPressureMonitor) + return DefaultDownloader(sessionFactory: sessionFactory, + memoryPressureMonitor: memoryPressureMonitor) } } diff --git a/PausableDownloads-ExampleTests/Tests/ImageGalleryViewModelTests.swift b/PausableDownloads-ExampleTests/Tests/ImageGalleryViewModelTests.swift index 76ff39d..668a1db 100644 --- a/PausableDownloads-ExampleTests/Tests/ImageGalleryViewModelTests.swift +++ b/PausableDownloads-ExampleTests/Tests/ImageGalleryViewModelTests.swift @@ -286,9 +286,8 @@ final class ImageGalleryViewModelTests: XCTestCase { //the download issued for imageB, which is the page being swiped away from XCTAssertEqual(pausedToken, tokenForImageB) - /* Rescheduling the same URL is what hands the paused download back to the - session to resume rather than restart. - */ + //Rescheduling the same URL is what hands the paused download back to the + //session to resume rather than restart. guard case let .load(loadedImage, _, _) = imageLoader.events.last else { XCTFail("Unexpected event") return diff --git a/PausableDownloads-ExampleTests/Tests/ImageLoaderTests.swift b/PausableDownloads-ExampleTests/Tests/ImageLoaderTests.swift index a09708d..c7ed28c 100644 --- a/PausableDownloads-ExampleTests/Tests/ImageLoaderTests.swift +++ b/PausableDownloads-ExampleTests/Tests/ImageLoaderTests.swift @@ -198,7 +198,7 @@ final class ImageLoaderTests: XCTestCase { waitForExpectations(timeout: 3, handler: nil) guard case let .failure(error) = try XCTUnwrap(receivedResult), - case NetworkingError.invalidData = error else { + case ImageLoaderError.invalidImageData = error else { XCTFail("Expected an invalid data failure") return } From 88c7d971416d06653d6bca8eb5094c6434e99791 Mon Sep 17 00:00:00 2001 From: William Boles Date: Sat, 12 Sep 2026 23:01:47 +0100 Subject: [PATCH 15/16] Added previous and next buttons --- .../Storyboards/Base.lproj/Main.storyboard | 2 +- .../ImageGalleryViewController.swift | 129 +++++++++++++++++- 2 files changed, 124 insertions(+), 7 deletions(-) diff --git a/PausableDownloads-Example/Storyboards/Base.lproj/Main.storyboard b/PausableDownloads-Example/Storyboards/Base.lproj/Main.storyboard index fa27499..0f9b53d 100644 --- a/PausableDownloads-Example/Storyboards/Base.lproj/Main.storyboard +++ b/PausableDownloads-Example/Storyboards/Base.lproj/Main.storyboard @@ -53,7 +53,7 @@ diff --git a/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewController.swift b/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewController.swift index af1615c..0c7ac10 100644 --- a/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewController.swift +++ b/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewController.swift @@ -9,8 +9,16 @@ import UIKit class ImageGalleryViewController: UIPageViewController { + private static let pagingButtonSize: CGFloat = 44 + private let galleryViewModel = ImageGalleryViewModel() private let loadingActivityIndicator = UIActivityIndicatorView(style: .large) + private let previousButton = UIButton(type: .system) + private let nextButton = UIButton(type: .system) + + //setting a page whilst one is already on its way leaves `UIPageViewController` showing + //one page and reporting another, so taps that land mid-transition are ignored + private var isPaging = false // MARK: - ViewLifecycle @@ -21,6 +29,7 @@ class ImageGalleryViewController: UIPageViewController { configureNavigationBar() configureLoadingActivityIndicator() + configurePagingButtons() dataSource = self delegate = self @@ -30,9 +39,6 @@ class ImageGalleryViewController: UIPageViewController { } private func configureNavigationBar() { - //Paging in `.scroll` style puts a scroll view behind the bar, so without - //this it adopts its transparent scroll-edge appearance and the position - //indicator disappears against the black background. let appearance = UINavigationBarAppearance() appearance.configureWithOpaqueBackground() @@ -50,8 +56,56 @@ class ImageGalleryViewController: UIPageViewController { loadingActivityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)]) } + private func configurePagingButtons() { + configure(previousButton, + symbolName: "chevron.left", + accessibilityLabel: "Previous image", + action: #selector(previousButtonPressed)) + configure(nextButton, + symbolName: "chevron.right", + accessibilityLabel: "Next image", + action: #selector(nextButtonPressed)) + + let safeArea = view.safeAreaLayoutGuide + + NSLayoutConstraint.activate([previousButton.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: 16), + previousButton.centerYAnchor.constraint(equalTo: safeArea.centerYAnchor), + safeArea.trailingAnchor.constraint(equalTo: nextButton.trailingAnchor, constant: 16), + nextButton.centerYAnchor.constraint(equalTo: safeArea.centerYAnchor)]) + } + + private func configure(_ button: UIButton, + symbolName: String, + accessibilityLabel: String, + action: Selector) { + let symbolConfiguration = UIImage.SymbolConfiguration(pointSize: 20, + weight: .semibold) + + button.setImage(UIImage(systemName: symbolName, withConfiguration: symbolConfiguration), + for: .normal) + button.tintColor = .label + button.backgroundColor = UIColor.systemBackground.withAlphaComponent(0.6) + button.layer.cornerRadius = Self.pagingButtonSize / 2 + button.accessibilityLabel = accessibilityLabel + button.isHidden = true + button.translatesAutoresizingMaskIntoConstraints = false + + button.addTarget(self, + action: action, + for: .touchUpInside) + + view.addSubview(button) + + NSLayoutConstraint.activate([button.widthAnchor.constraint(equalToConstant: Self.pagingButtonSize), + button.heightAnchor.constraint(equalToConstant: Self.pagingButtonSize)]) + } + // MARK: - Pages + private var currentIndex: Int? { + (viewControllers?.first as? ImageViewerViewController)?.index + } + private func imageViewerViewController(at index: Int) -> ImageViewerViewController? { guard let viewModel = galleryViewModel.viewModel(at: index) else { return nil @@ -67,11 +121,66 @@ class ImageGalleryViewController: UIPageViewController { setViewControllers([viewController], direction: .forward, animated: false) - updateTitle(for: 0) + bringPagingButtonsToFront() + updatePagingControls(for: 0) } - private func updateTitle(for index: Int) { + // MARK: - Paging + + @objc private func previousButtonPressed() { + guard let currentIndex = currentIndex else { + return + } + + showImage(at: currentIndex - 1, + direction: .reverse) + } + + @objc private func nextButtonPressed() { + guard let currentIndex = currentIndex else { + return + } + + showImage(at: currentIndex + 1, + direction: .forward) + } + + private func showImage(at index: Int, + direction: UIPageViewController.NavigationDirection) { + guard !isPaging, + let viewController = imageViewerViewController(at: index) else { + return + } + + isPaging = true + + setViewControllers([viewController], direction: direction, animated: true) { [weak self] _ in + self?.isPaging = false + } + + bringPagingButtonsToFront() + + //a page set in code doesn't reach `didFinishAnimating`, so landing on it has to + //be reported here instead + galleryViewModel.move(to: index) + updatePagingControls(for: index) + } + + private func bringPagingButtonsToFront() { + view.bringSubviewToFront(previousButton) + view.bringSubviewToFront(nextButton) + } + + private func updatePagingControls(for index: Int) { title = "\(index + 1) of \(galleryViewModel.numberOfImages)" + + previousButton.isHidden = index == 0 + nextButton.isHidden = index >= (galleryViewModel.numberOfImages - 1) + } + + private func hidePagingButtons() { + previousButton.isHidden = true + nextButton.isHidden = true } } @@ -102,10 +211,17 @@ extension ImageGalleryViewController: UIPageViewControllerDelegate { // MARK: - UIPageViewControllerDelegate + func pageViewController(_ pageViewController: UIPageViewController, + willTransitionTo pendingViewControllers: [UIViewController]) { + isPaging = true + } + func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { + isPaging = false + //Only a transition the user actually landed on should pause what came //before it - a cancelled swipe hasn't moved anywhere. guard completed, @@ -115,7 +231,7 @@ extension ImageGalleryViewController: UIPageViewControllerDelegate { galleryViewModel.move(to: imageViewerViewController.index) - updateTitle(for: imageViewerViewController.index) + updatePagingControls(for: imageViewerViewController.index) } } @@ -128,6 +244,7 @@ extension ImageGalleryViewController: ImageGalleryViewModelDelegate { switch state { case .loading: loadingActivityIndicator.startAnimating() + hidePagingButtons() case .loaded: loadingActivityIndicator.stopAnimating() showFirstImage() From 37c5400830891f5cc275383665ac4bf5ddbad21b Mon Sep 17 00:00:00 2001 From: William Boles Date: Sun, 13 Sep 2026 09:41:50 +0100 Subject: [PATCH 16/16] Generate Secrets.xcconfig as part of build script --- .github/workflows/swift.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 7101e52..1674dc0 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -16,6 +16,8 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Create Secrets.xcconfig + run: echo "CAT_API_KEY = ${{ secrets.CAT_API_KEY }}" > Secrets.xcconfig - name: List available Xcode versions run: ls /Applications | grep Xcode - name: Show current version of Xcode