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 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. diff --git a/PausableDownloads-Example.xcodeproj/project.pbxproj b/PausableDownloads-Example.xcodeproj/project.pbxproj index b09a6a3..353c277 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 */ @@ -11,31 +11,38 @@ 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 */; }; - 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 */; }; - 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 */; }; - 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 */; }; - 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */ @@ -53,35 +60,43 @@ 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; 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 = ""; }; - 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 */ @@ -113,9 +128,10 @@ 3D63CC2B204B554700797A82 /* PausableDownloads-Example */ = { isa = PBXGroup; children = ( + 4395514E3055E55500FE65F7 /* Image */, 3D63CC3F204B554700797A82 /* Application */, - 3DB20EF123B8283C00B5B6AD /* Data */, - 3D63CC34204B554700797A82 /* Extensions */, + 437C0CA53051EC1A009529DF /* Networking */, + 437C0CAE3051EC36009529DF /* Repositories */, 3D63CC31204B554700797A82 /* Resources */, 3D63CC2C204B554700797A82 /* Storyboards */, 3D63CC37204B554700797A82 /* ViewControllers */, @@ -140,55 +156,22 @@ 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 */, + 43A1000030600021009529DF /* ImageGallery */, + 3D63CC3C204B554700797A82 /* ImageViewer */, ); path = ViewControllers; sourceTree = ""; }; - 3D63CC38204B554700797A82 /* Albums */ = { - isa = PBXGroup; - children = ( - 3D63CC39204B554700797A82 /* Cells */, - 3D63CC3B204B554700797A82 /* GalleryAlbumsViewController.swift */, - ); - path = Albums; - sourceTree = ""; - }; - 3D63CC39204B554700797A82 /* Cells */ = { + 3D63CC3C204B554700797A82 /* ImageViewer */ = { isa = PBXGroup; children = ( - 3D63CC3A204B554700797A82 /* GalleryAlbumCollectionViewCell.swift */, + 3D63CC3E204B554700797A82 /* ImageViewerViewController.swift */, + 437C0D5A3051EDA0009529DF /* ImageViewerViewModel.swift */, ); - path = Cells; - sourceTree = ""; - }; - 3D63CC3C204B554700797A82 /* AlbumViewer */ = { - isa = PBXGroup; - children = ( - 3D63CC3D204B554700797A82 /* GalleryAlbumViewerTitleView.swift */, - 3D63CC3E204B554700797A82 /* GalleryAlbumViewerViewController.swift */, - ); - path = AlbumViewer; + path = ImageViewer; sourceTree = ""; }; 3D63CC3F204B554700797A82 /* Application */ = { @@ -203,6 +186,8 @@ 3D63CC6E204B555300797A82 /* PausableDownloads-ExampleTests */ = { isa = PBXGroup; children = ( + 43F8C81430541F5800150C94 /* Helpers */, + 437C0D463052022A009529DF /* TestData */, 4399D38D3050B4DB009D2CEB /* Doubles */, 4399D38F3050B4DB009D2CEB /* Tests */, 3D2AF9E923BB5D4B00A6D999 /* Resources */, @@ -211,134 +196,148 @@ path = "PausableDownloads-ExampleTests"; sourceTree = ""; }; - 3DB20EF123B8283C00B5B6AD /* Data */ = { + 3DE07FC01FFF0F31003C95C0 = { isa = PBXGroup; children = ( - 3DB20EF223B8283C00B5B6AD /* Parsers */, - 3DB20EF623B8283C00B5B6AD /* Managers */, - 3DB20EFD23B8283C00B5B6AD /* Factories */, - 3DB20F0623B8283C00B5B6AD /* Model */, + 43DF70D53051B477004E9EEA /* Secrets.xcconfig */, + 3D63CC2B204B554700797A82 /* PausableDownloads-Example */, + 3D63CC6E204B555300797A82 /* PausableDownloads-ExampleTests */, + 3DE07FCA1FFF0F31003C95C0 /* Products */, ); - path = Data; sourceTree = ""; }; - 3DB20EF223B8283C00B5B6AD /* Parsers */ = { + 3DE07FCA1FFF0F31003C95C0 /* Products */ = { isa = PBXGroup; children = ( - 3DB20EF323B8283C00B5B6AD /* Abstract */, - 3DB20EF523B8283C00B5B6AD /* GalleryAlbumParser.swift */, + 3DE07FC91FFF0F31003C95C0 /* PausableDownloads-Example.app */, + 3DE07FE11FFF0F31003C95C0 /* PausableDownloads-ExampleTests.xctest */, ); - path = Parsers; + name = Products; sourceTree = ""; }; - 3DB20EF323B8283C00B5B6AD /* Abstract */ = { + 437C0CA53051EC1A009529DF /* Networking */ = { isa = PBXGroup; children = ( - 3DB20EF423B8283C00B5B6AD /* Parser.swift */, + 439551543055FCD200FE65F7 /* Service */, + 439551453055E52E00FE65F7 /* Downloader */, ); - path = Abstract; + path = Networking; sourceTree = ""; }; - 3DB20EF623B8283C00B5B6AD /* Managers */ = { + 437C0CAD3051EC36009529DF /* Images */ = { isa = PBXGroup; children = ( - 3DB20EF723B8283C00B5B6AD /* Asset */, - 3DB20EFB23B8283C00B5B6AD /* AssetDataManager.swift */, - 3DB20EFC23B8283C00B5B6AD /* GalleryDataManager.swift */, + 437C0CAB3051EC36009529DF /* ImageDTO.swift */, + 437C0CAC3051EC36009529DF /* ImagesRepository.swift */, ); - path = Managers; + path = Images; sourceTree = ""; }; - 3DB20EF723B8283C00B5B6AD /* Asset */ = { + 437C0CAE3051EC36009529DF /* Repositories */ = { isa = PBXGroup; children = ( - 3DB20EF823B8283C00B5B6AD /* AssetDownloadsSession.swift */, + 437C0CAD3051EC36009529DF /* Images */, ); - path = Asset; + path = Repositories; sourceTree = ""; }; - 3DB20EFD23B8283C00B5B6AD /* Factories */ = { + 437C0D463052022A009529DF /* TestData */ = { isa = PBXGroup; children = ( - 3DB20EFE23B8283C00B5B6AD /* Requests */, - 3DB20F0423B8283C00B5B6AD /* Sessions */, + 437C0D4730520236009529DF /* ImageDomainModel+TestData.swift */, + 43F8C82430550003 /* Data+TestData.swift */, ); - path = Factories; + path = TestData; sourceTree = ""; }; - 3DB20EFE23B8283C00B5B6AD /* Requests */ = { + 439551453055E52E00FE65F7 /* Downloader */ = { isa = PBXGroup; children = ( - 3DB20EFF23B8283C00B5B6AD /* Abstract */, - 3DB20F0323B8283C00B5B6AD /* GalleryURLRequestFactory.swift */, + 43A2000030700002009529DF /* DownloadSessionFactory.swift */, + 439551433055E52E00FE65F7 /* Downloader.swift */, + 439551443055E52E00FE65F7 /* MemoryPressureMonitor.swift */, ); - path = Requests; + path = Downloader; sourceTree = ""; }; - 3DB20EFF23B8283C00B5B6AD /* Abstract */ = { + 439551493055E55500FE65F7 /* ImageLoader */ = { isa = PBXGroup; children = ( - 3DB20F0023B8283C00B5B6AD /* URLRequest+HTTPBody.swift */, - 3DB20F0123B8283C00B5B6AD /* RequestConfig.swift */, - 3DB20F0223B8283C00B5B6AD /* URLRequestFactory.swift */, + 439551483055E55500FE65F7 /* ImageLoader.swift */, ); - path = Abstract; + path = ImageLoader; sourceTree = ""; }; - 3DB20F0423B8283C00B5B6AD /* Sessions */ = { + 4395514D3055E55500FE65F7 /* Service */ = { isa = PBXGroup; children = ( - 3DB20F0523B8283C00B5B6AD /* URLSessionFactory.swift */, + 4395514A3055E55500FE65F7 /* ImageDomainModel.swift */, + 4395514B3055E55500FE65F7 /* ImagesDomainModelFactory.swift */, + 4395514C3055E55500FE65F7 /* ImagesService.swift */, ); - path = Sessions; + path = Service; sourceTree = ""; }; - 3DB20F0623B8283C00B5B6AD /* Model */ = { + 4395514E3055E55500FE65F7 /* Image */ = { isa = PBXGroup; children = ( - 3DB20F0723B8283C00B5B6AD /* GalleryAsset.swift */, - 3DB20F0823B8283C00B5B6AD /* GalleryItem.swift */, - 3DB20F0923B8283C00B5B6AD /* GalleryAlbum.swift */, + 439551493055E55500FE65F7 /* ImageLoader */, + 4395514D3055E55500FE65F7 /* Service */, ); - path = Model; + path = Image; sourceTree = ""; }; - 3DE07FC01FFF0F31003C95C0 = { + 439551543055FCD200FE65F7 /* Service */ = { isa = PBXGroup; children = ( - 3D63CC2B204B554700797A82 /* PausableDownloads-Example */, - 3D63CC6E204B555300797A82 /* PausableDownloads-ExampleTests */, - 3DE07FCA1FFF0F31003C95C0 /* Products */, + 439551533055FCD200FE65F7 /* NetworkService.swift */, ); + path = Service; sourceTree = ""; }; - 3DE07FCA1FFF0F31003C95C0 /* Products */ = { + 4399D38D3050B4DB009D2CEB /* Doubles */ = { isa = PBXGroup; children = ( - 3DE07FC91FFF0F31003C95C0 /* PausableDownloads-Example.app */, - 3DE07FE11FFF0F31003C95C0 /* PausableDownloads-ExampleTests.xctest */, + 43C2000030700003009529DF /* StubMemoryPressureMonitor.swift */, + 437C0D6A3051EDB0009529DF /* StubImageViewerViewModelDelegate.swift */, + 43A1000030600004009529DF /* StubImageGalleryViewModelDelegate.swift */, + 4399D3893050B4DB009D2CEB /* StubDownloadSession.swift */, + 43A2000030700004009529DF /* StubDownloadSessionFactory.swift */, + 4399D38A3050B4DB009D2CEB /* StubDownloadTask.swift */, + 437C0D403051F9F3009529DF /* StubImagesService.swift */, + 437C0D423051FA65009529DF /* StubImageLoader.swift */, + 43F8C811305418EA00150C94 /* StubDownloader.swift */, + 43F8C82030550001 /* StubFileManager.swift */, ); - name = Products; + path = Doubles; sourceTree = ""; }; - 4399D38D3050B4DB009D2CEB /* Doubles */ = { + 4399D38F3050B4DB009D2CEB /* Tests */ = { isa = PBXGroup; children = ( - 4399D3883050B4DB009D2CEB /* StubNotificationCenter.swift */, - 4399D3893050B4DB009D2CEB /* StubURLSession.swift */, - 4399D38A3050B4DB009D2CEB /* StubURLSessionDownloadTask.swift */, - 4399D38B3050B4DB009D2CEB /* StubURLSessionFactory.swift */, - 4399D38C3050B4DB009D2CEB /* TestError.swift */, + 4399D38E3050B4DB009D2CEB /* DownloaderTests.swift */, + 43F8C82230550002 /* ImageLoaderTests.swift */, + 437C0D443051FB2E009529DF /* ImageViewerViewModelTests.swift */, + 43A1000030600003009529DF /* ImageGalleryViewModelTests.swift */, ); - path = Doubles; + path = Tests; sourceTree = ""; }; - 4399D38F3050B4DB009D2CEB /* Tests */ = { + 43A1000030600021009529DF /* ImageGallery */ = { isa = PBXGroup; children = ( - 4399D38E3050B4DB009D2CEB /* AssetDownloadsSessionTests.swift */, + 43A1000030600001009529DF /* ImageGalleryViewController.swift */, + 43A1000030600002009529DF /* ImageGalleryViewModel.swift */, ); - path = Tests; + path = ImageGallery; + sourceTree = ""; + }; + 43F8C81430541F5800150C94 /* Helpers */ = { + isa = PBXGroup; + children = ( + 43F8C81330541F5800150C94 /* TestError.swift */, + ); + path = Helpers; sourceTree = ""; }; /* End PBXGroup section */ @@ -385,8 +384,9 @@ 3DE07FC11FFF0F31003C95C0 /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1130; + LastUpgradeCheck = 2660; ORGANIZATIONNAME = "William Boles"; TargetAttributes = { 3DE07FC81FFF0F31003C95C0 = { @@ -447,25 +447,21 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 3DB20F1623B8283C00B5B6AD /* GalleryAsset.swift in Sources */, - 3DB20F1123B8283C00B5B6AD /* URLRequest+HTTPBody.swift in Sources */, + 437C0CB43051EC36009529DF /* ImageDTO.swift in Sources */, + 437C0CB63051EC36009529DF /* ImagesRepository.swift in Sources */, + 439551463055E52E00FE65F7 /* Downloader.swift in Sources */, + 43A2000030700003009529DF /* DownloadSessionFactory.swift in Sources */, + 439551473055E52E00FE65F7 /* MemoryPressureMonitor.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 */, - 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 */, + 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 */, + 43A1000030600011009529DF /* ImageGalleryViewController.swift in Sources */, + 43A1000030600012009529DF /* ImageGalleryViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -473,12 +469,23 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 4399D3903050B4DB009D2CEB /* StubURLSession.swift in Sources */, - 4399D3913050B4DB009D2CEB /* StubURLSessionDownloadTask.swift in Sources */, - 4399D3923050B4DB009D2CEB /* StubURLSessionFactory.swift in Sources */, - 4399D3933050B4DB009D2CEB /* TestError.swift in Sources */, - 4399D3943050B4DB009D2CEB /* StubNotificationCenter.swift in Sources */, - 4399D3953050B4DB009D2CEB /* AssetDownloadsSessionTests.swift in Sources */, + 437C0D433051FA65009529DF /* StubImageLoader.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 */, + 43F8C81530541F5800150C94 /* TestError.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 /* DownloaderTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -514,6 +521,7 @@ /* Begin XCBuildConfiguration section */ 3DE07FE81FFF0F31003C95C0 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 43DF70D53051B477004E9EEA /* Secrets.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -537,6 +545,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; @@ -548,6 +557,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; @@ -566,6 +576,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; @@ -574,6 +585,7 @@ }; 3DE07FE91FFF0F31003C95C0 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 43DF70D53051B477004E9EEA /* Secrets.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -597,6 +609,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; @@ -608,6 +621,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; @@ -619,7 +633,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; }; @@ -631,7 +647,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; @@ -645,7 +664,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; @@ -659,7 +681,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; @@ -674,7 +700,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 @@ 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/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 deleted file mode 100644 index b993016..0000000 --- a/PausableDownloads-Example/Data/Factories/Requests/Abstract/RequestConfig.swift +++ /dev/null @@ -1,42 +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 clientID: String - let APIHost: String - let timeInterval: TimeInterval - let cachePolicy: NSURLRequest.CachePolicy - - // MARK: - Shared - - static let shared = RequestConfig() - - // MARK: - Init - - init() { - self.clientID = "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") - } - } -} diff --git a/PausableDownloads-Example/Data/Factories/Requests/Abstract/URLRequest+HTTPBody.swift b/PausableDownloads-Example/Data/Factories/Requests/Abstract/URLRequest+HTTPBody.swift deleted file mode 100644 index b034062..0000000 --- a/PausableDownloads-Example/Data/Factories/Requests/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/Data/Factories/Requests/Abstract/URLRequestFactory.swift b/PausableDownloads-Example/Data/Factories/Requests/Abstract/URLRequestFactory.swift deleted file mode 100644 index ad8e467..0000000 --- a/PausableDownloads-Example/Data/Factories/Requests/Abstract/URLRequestFactory.swift +++ /dev/null @@ -1,46 +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("Client-ID \(config.clientID)", forHTTPHeaderField: "Authorization") - - 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/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/Factories/Sessions/URLSessionFactory.swift b/PausableDownloads-Example/Data/Factories/Sessions/URLSessionFactory.swift deleted file mode 100644 index 62b66bd..0000000 --- a/PausableDownloads-Example/Data/Factories/Sessions/URLSessionFactory.swift +++ /dev/null @@ -1,61 +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, completionHandler: @escaping (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTaskType - func downloadTask(withResumeData resumeData: Data, completionHandler: @escaping (URL?, URLResponse?, Error?) -> Void) -> 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(withResumeData resumeData: Data, completionHandler: @escaping (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTaskType { - return downloadTask(withResumeData: resumeData, completionHandler: completionHandler) as URLSessionDownloadTask - } -} - -protocol URLSessionDownloadTaskType { - var progress: Progress { 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/Data/Managers/Asset/AssetDownloadsSession.swift b/PausableDownloads-Example/Data/Managers/Asset/AssetDownloadsSession.swift deleted file mode 100644 index 5193a15..0000000 --- a/PausableDownloads-Example/Data/Managers/Asset/AssetDownloadsSession.swift +++ /dev/null @@ -1,283 +0,0 @@ -// -// AssetDownloadsSession.swift -// PausableDownloads-Example -// -// Created by William Boles on 14/12/2019. -// Copyright © 2019 William Boles. All rights reserved. -// - -import UIKit -import os - -protocol NotificationCenterType { - @discardableResult - func addObserver(forName name: NSNotification.Name?, object obj: Any?, queue: OperationQueue?, using block: @escaping (Notification) -> Void) -> NSObjectProtocol -} - -extension NotificationCenter: NotificationCenterType { } - -class AssetDownloadsSession: NSObject, AssetDownloadItemDelegate, URLSessionDownloadDelegate { - - private var assetDownloadItems = [AssetDownloadItem]() - private let accessQueue = DispatchQueue(label: "com.williamboles.downloadssession") - private var session: URLSessionType! - - // MARK: - Singleton - - static let shared = AssetDownloadsSession() - - // MARK: - Init - - init(urlSessionFactory: URLSessionFactoryType = URLSessionFactory(), - notificationCenter: NotificationCenterType = NotificationCenter.default) { - super.init() - - self.session = urlSessionFactory.defaultSession(delegate: self) - registerForNotifications(on: notificationCenter) - } - - // MARK: - Notification - - private func registerForNotifications(on notificationCenter: NotificationCenterType) { - 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") - - assetDownloadItems = assetDownloadItems.filter { (assetDownloadItem) -> Bool in - let isPaused = assetDownloadItem.isPaused - if isPaused { - assetDownloadItem.cancel() - } - - return !isPaused - } - } - } - - // MARK: - Schedule - - 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) - - 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) - - assetDownloadItem.resume() - } - } - } - - // MARK: - Cancel - - func cancelDownload(url: URL) { - accessQueue.sync { - guard let assetDownloadItem = assetDownloadItems.first(where: { $0.url == url }) else { - return - } - - os_log(.info, "Download: %{public}@ going to paused", assetDownloadItem.description) - assetDownloadItem.pause() - } - } - - // MARK: - AssetDownloadItemDelegate - - fileprivate func assetDownloadItemCompleted(_ assetDownloadItem: AssetDownloadItem) { - accessQueue.sync { - os_log(.info, "Completed download of: %{public}@", assetDownloadItem.description) - - if let index = assetDownloadItems.firstIndex(where: { $0.url == assetDownloadItem.url && $0.isCompleted }) { - assetDownloadItems.remove(at: index) - } - } - } - - // MARK: - 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 { - return - } - let resumptionPercentage = (Double(fileOffset)/Double(expectedTotalBytes)) * 100 - os_log(.info, "Resuming download: %{public}@ from: %{public}.02f%%", url.absoluteString, resumptionPercentage) - } -} - -fileprivate enum State: String { - case ready - case downloading - case paused - case cancelled - case completed -} - -fileprivate protocol AssetDownloadItemDelegate { - func assetDownloadItemCompleted(_ assetDownloadItem: AssetDownloadItem) -} - -typealias DownloadCompletionHandler = ((_ result: Result) -> ()) - -fileprivate class AssetDownloadItem { - - 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(set) var state: State = .ready - - var description: String { - return url.absoluteString - } - - var isCoalescable: Bool { - return (state == .ready) || - (state == .downloading) || - (state == .paused) - } - - var isResumable: Bool { - return (state == . ready) || - (state == .paused) - } - - var isPaused: Bool { - return state == .paused - } - - var isCompleted: Bool { - return state == .completed - } - - // MARK: - Init - - init(session: URLSessionType, url: URL) { - self.session = session - self.url = url - } - - deinit { - observation?.invalidate() - } - - // 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) - } - - 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 ?? "") - } - - 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() - } - - cleanup() - } - - guard let fileLocationURL = fileLocationURL else { - result = .failure(NetworkingError.retrieval(underlyingError: error)) - return - } - - do { - let data = try Data(contentsOf: fileLocationURL) - result = .success(data) - } catch let error { - result = .failure(NetworkingError.invalidData(underlyingError: error)) - } - } - - func pause() { - state = .paused - - cancelAndSaveData() - } - - 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 - }) - } - - func cancel() { - state = .cancelled - - downloadTask?.cancel() - } - - private func complete() { - state = .completed - - delegate?.assetDownloadItemCompleted(self) - } - - private func cleanup() { - observation?.invalidate() - downloadTask = nil - downloadCompletionHandler = nil - } - - //MARK: - Coalesce - - func coalesceDownloadCompletionHandler(_ otherDownloadCompletionHandler: @escaping DownloadCompletionHandler) { - let initialDownloadCompletionHandler = downloadCompletionHandler - - downloadCompletionHandler = { result in - initialDownloadCompletionHandler?(result) - otherDownloadCompletionHandler(result) - } - } -} - diff --git a/PausableDownloads-Example/Data/Managers/AssetDataManager.swift b/PausableDownloads-Example/Data/Managers/AssetDataManager.swift deleted file mode 100644 index 186504e..0000000 --- a/PausableDownloads-Example/Data/Managers/AssetDataManager.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// AssetDataManager.swift -// PausableDownloads-Example -// -// Created by William Boles on 15/01/2018. -// Copyright © 2018 William Boles. All rights reserved. -// - -import Foundation -import UIKit - -struct LoadAssetResult: Equatable { - let asset: GalleryAsset - let image: UIImage -} - -class AssetDataManager { - - private let assetDownloadSession = AssetDownloadsSession.shared - private let fileManager = FileManager.default - - // MARK: - GalleryAlbum - - func loadAlbumThumbnailAsset(_ asset: GalleryAsset, completionHandler: @escaping ((_ result: Result) -> ())) { - if fileManager.fileExists(atPath: asset.cachedLocalAssetURL().path) { - locallyLoadAsset(asset, completionHandler: completionHandler) - } else { - remotelyLoadAsset(asset, 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) - } - - // MARK: - Asset - - private func locallyLoadAsset(_ asset: GalleryAsset, completionHandler: @escaping ((_ result: Result) -> ())) { - do { - let data = try Data(contentsOf: URL(fileURLWithPath: asset.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) - - DispatchQueue.main.async { - completionHandler(dataRequestResult) - } - } catch { - remotelyLoadAsset(asset, completionHandler: completionHandler) - } - } - - private func remotelyLoadAsset(_ asset: GalleryAsset, completionHandler: @escaping ((_ result: Result) -> ())) { - - assetDownloadSession.scheduleDownload(url: asset.url) { (result) in - switch result { - case .success(let data): - guard let image = UIImage(data: data) else { - completionHandler(.failure(NetworkingError.invalidData(underlyingError: nil))) - return - } - - do { - try data.write(to: asset.cachedLocalAssetURL(), options: .atomic) - } catch let error { - completionHandler(.failure(NetworkingError.invalidData(underlyingError: error))) - } - - let loadResult = LoadAssetResult(asset: asset, image: image) - let dataRequestResult = Result.success(loadResult) - - DispatchQueue.main.async { - completionHandler(dataRequestResult) - } - case .failure(let error): - completionHandler(.failure(error)) - } - } - } -} diff --git a/PausableDownloads-Example/Data/Managers/GalleryDataManager.swift b/PausableDownloads-Example/Data/Managers/GalleryDataManager.swift deleted file mode 100644 index da9ce6c..0000000 --- a/PausableDownloads-Example/Data/Managers/GalleryDataManager.swift +++ /dev/null @@ -1,56 +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/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/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/Image/ImageLoader/ImageLoader.swift b/PausableDownloads-Example/Image/ImageLoader/ImageLoader.swift new file mode 100644 index 0000000..9115778 --- /dev/null +++ b/PausableDownloads-Example/Image/ImageLoader/ImageLoader.swift @@ -0,0 +1,116 @@ +// +// 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 + +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 + @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(ImageLoaderError.invalidImageData) + } + + 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/Image/Service/ImageDomainModel.swift b/PausableDownloads-Example/Image/Service/ImageDomainModel.swift new file mode 100644 index 0000000..5aef1be --- /dev/null +++ b/PausableDownloads-Example/Image/Service/ImageDomainModel.swift @@ -0,0 +1,16 @@ +// +// ImageDomainModel.swift +// PausableDownloads-Example +// +// Created by William Boles on 09/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +import Foundation + +struct ImageDomainModel: Equatable { + let identifier: String + let url: URL + let width: Int + let height: Int +} diff --git a/PausableDownloads-Example/Image/Service/ImagesDomainModelFactory.swift b/PausableDownloads-Example/Image/Service/ImagesDomainModelFactory.swift new file mode 100644 index 0000000..ebd0626 --- /dev/null +++ b/PausableDownloads-Example/Image/Service/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/Image/Service/ImagesService.swift b/PausableDownloads-Example/Image/Service/ImagesService.swift new file mode 100644 index 0000000..a638c99 --- /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: DefaultImagesRepository + private let domainModelFactory: ImagesDomainModelFactory + + // MARK: - Init + + init(repository: DefaultImagesRepository = DefaultImagesRepository(), + domainModelFactory: ImagesDomainModelFactory = ImagesDomainModelFactory()) { + self.repository = repository + self.domainModelFactory = domainModelFactory + } + + // MARK: - Load + + func load(callbackQueue: DispatchQueue, + completionHandler: @escaping LoadImagesCompletionHandler) { + 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) } } + .mapError { $0 as Error } + + callbackQueue.async { + completionHandler(images) + } + } + } +} 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 new file mode 100644 index 0000000..bf75b58 --- /dev/null +++ b/PausableDownloads-Example/Networking/Downloader/Downloader.swift @@ -0,0 +1,414 @@ +// +// Downloader.swift +// PausableDownloads-Example +// +// Created by William Boles on 14/12/2019. +// Copyright © 2019 William Boles. All rights reserved. +// + +import Foundation +import os + +//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 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 id = UUID() + + init(url: URL) { + self.url = url + } +} + +enum DownloadError: Error { + case download(underlyingError: Error?) + case invalidData(underlyingError: Error?) +} + +protocol Downloader { + @discardableResult + func download(_ url: URL, + completionHandler: @escaping DownloadCompletionHandler) -> DownloadToken + func pause(_ token: DownloadToken) +} + +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: DownloadTask? + private(set) var resumptionData: Data? + + // MARK: - Init + + init(url: URL) { + self.url = url + } + + // MARK: - Callers + + //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 remove(_ token: DownloadToken) -> Bool { + completionHandlers.removeValue(forKey: token) != nil + } + + var hasCallers: Bool { + !completionHandlers.isEmpty + } + + // MARK: - Stage + + //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: DownloadTask) { + stage = .running + self.task = task + resumptionData = nil + } + + //returns the task to cancel, or nil if there isn't one running + func markPausing() -> DownloadTask? { + guard stage == .running, + let task = task else { + return nil + } + + stage = .pausing + + return task + } + + 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 + } + } + + //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 sessionFactory: DownloadSessionFactory + private lazy var session: DownloadSession = sessionFactory.makeSession(delegate: self) + private let memoryPressureMonitor: MemoryPressureMonitor + + // MARK: - Singleton + + static let shared = DefaultDownloader() + + // MARK: - Init + + init(sessionFactory: DownloadSessionFactory = DefaultDownloadSessionFactory(), + memoryPressureMonitor: MemoryPressureMonitor = DefaultMemoryPressureMonitor()) { + self.sessionFactory = sessionFactory + self.memoryPressureMonitor = memoryPressureMonitor + + super.init() + + memoryPressureMonitor.startMonitoring { [weak self] in + self?.purgePausedDownloads() + } + } + + // MARK: - ThreadSafety + + //`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) + } + + // MARK: - MemoryPressure + + private func purgePausedDownloads() { + sync { + os_log(.info, "Purging paused items under memory pressure") + + downloads = downloads.filter { $0.value.stage != .paused } + } + } + + // MARK: - Download + + @discardableResult + func download(_ url: URL, + completionHandler: @escaping DownloadCompletionHandler) -> DownloadToken { + let token = DownloadToken(url: url) + + sync { + let download = downloads[url] ?? makeDownload(for: url) + download.add(completionHandler, for: token) + + if download.needsTask { + startDownload(download) + } else { + os_log(.info, "Coalescing onto an existing active download of: %{public}@", url.absoluteString) + } + } + + return token + } + + private func makeDownload(for url: URL) -> Download { + dispatchPrecondition(condition: .onQueue(queue)) + + let download = Download(url: url) + downloads[url] = download + + return download + } + + private func startDownload(_ download: Download) { + dispatchPrecondition(condition: .onQueue(queue)) + + let task: DownloadTask + 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, "Starting a new download: %{public}@", download.url.absoluteString) + task = session.downloadTask(with: download.url) + } + + download.markRunning(with: task) + + task.resume() + } + + // MARK: - Pause + + func pause(_ token: DownloadToken) { + let url = token.url + + let taskToPause: DownloadTask? = sync { + guard let download = downloads[url], + download.remove(token) else { + return nil + } + + 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.markPausing() else { + return nil + } + + os_log(.info, "Pausing download: %{public}@", url.absoluteString) + + return task + } + + guard let taskToPause = taskToPause else { + return + } + + taskToPause.cancel(byProducingResumeData: { [weak self] data in + self?.finishPausing(for: url, + resumptionData: data) + }) + } + + private func finishPausing(for url: URL, + resumptionData: Data?) { + sync { + 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 + } + + if let resumptionData = resumptionData { + os_log(.info, "Cancelled download task has produced %{public}d bytes of resumption data for %{public}@", resumptionData.count, url.absoluteString) + + download.markPaused(with: resumptionData) + } + + 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 + } + } + } + + // MARK: - DelegateHandling + + 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) + } + + 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) + } + + 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(DownloadError.invalidData(underlyingError: error)) + + os_log(.error, "Download completed for: %{public}@ but its file could not be read: %{public}@", url.absoluteString, error.localizedDescription) + } + + deliverResult(result, + for: url, + taskIdentifier: taskIdentifier) + } + + 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 + } + + os_log(.error, "Download failed for: %{public}@ with error: %{public}@", url.absoluteString, error.localizedDescription) + + deliverResult(.failure(DownloadError.download(underlyingError: error)), + for: url, + taskIdentifier: taskIdentifier) + } + + //the result is made once and handed to everybody who coalesced onto this download + private func deliverResult(_ result: Result, + for url: URL, + taskIdentifier: Int) { + 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 [] + } + + downloads[url] = nil + + return Array(download.completionHandlers.values) + } + + //can't happen within `sync` in case the callee blocks the thread + completionHandlers.forEach { $0(result) } + } +} + +private extension URLSessionTask { + var downloadURL: URL? { + originalRequest?.url + } +} + +extension DefaultDownloader: URLSessionDownloadDelegate { + + // MARK: - URLSessionDownloadDelegate + + func urlSession(_ session: URLSession, + downloadTask: URLSessionDownloadTask, + didWriteData bytesWritten: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64) { + guard let url = downloadTask.downloadURL else { + return + } + + handleProgress(for: url, + totalBytesWritten: totalBytesWritten, + expectedTotalBytes: totalBytesExpectedToWrite) + } + + func urlSession(_ session: URLSession, + downloadTask: URLSessionDownloadTask, + didResumeAtOffset fileOffset: Int64, + expectedTotalBytes: Int64) { + guard let url = downloadTask.downloadURL else { + return + } + + handleResumption(for: url, + fileOffset: fileOffset, + expectedTotalBytes: expectedTotalBytes) + } + + func urlSession(_ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL) { + guard let url = downloadTask.downloadURL else { + return + } + + handleFinishedDownloading(for: url, + taskIdentifier: downloadTask.taskIdentifier, + to: location) + } + + func urlSession(_ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error?) { + //a success has already been dealt with by `didFinishDownloadingTo` + guard let error = error, + let url = task.downloadURL else { + return + } + + handleFailedDownloading(for: url, + taskIdentifier: task.taskIdentifier, + error: error) + } +} diff --git a/PausableDownloads-Example/Networking/Downloader/MemoryPressureMonitor.swift b/PausableDownloads-Example/Networking/Downloader/MemoryPressureMonitor.swift new file mode 100644 index 0000000..6b8fa70 --- /dev/null +++ b/PausableDownloads-Example/Networking/Downloader/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-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/Repositories/Images/ImageDTO.swift b/PausableDownloads-Example/Repositories/Images/ImageDTO.swift new file mode 100644 index 0000000..606c2fc --- /dev/null +++ b/PausableDownloads-Example/Repositories/Images/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/Repositories/Images/ImagesRepository.swift b/PausableDownloads-Example/Repositories/Images/ImagesRepository.swift new file mode 100644 index 0000000..3de2c03 --- /dev/null +++ b/PausableDownloads-Example/Repositories/Images/ImagesRepository.swift @@ -0,0 +1,42 @@ +// +// ImagesRepository.swift +// PausableDownloads-Example +// +// Created by William Boles on 15/01/2018. +// Copyright © 2018 William Boles. All rights reserved. +// + +import Foundation + +protocol ImagesRepository { + func load(completionHandler: @escaping NetworkCompletionHandler<[ImageDTO]>) +} + +final class DefaultImagesRepository { + private let networkService: NetworkService + + // MARK: - Init + + init(networkService: NetworkService = DefaultNetworkService()) { + self.networkService = networkService + } + + // MARK: - List + + func load(completionHandler: @escaping NetworkCompletionHandler<[ImageDTO]>) { + networkService.makeJSONRequest(urlRequest(), + completionHandler: completionHandler) + } + + private func urlRequest() -> URLRequest { + let url = networkService.baseURL.appendingPathComponent("images/search") + + var components = URLComponents(url: url, + resolvingAgainstBaseURL: false)! + components.queryItems = [URLQueryItem(name: "limit", value: "10"), + URLQueryItem(name: "order", value: "RANDOM"), + URLQueryItem(name: "size", value: "full")] + + return URLRequest(url: components.url!) + } +} diff --git a/PausableDownloads-Example/Storyboards/Base.lproj/Main.storyboard b/PausableDownloads-Example/Storyboards/Base.lproj/Main.storyboard index 315e53b..0f9b53d 100644 --- a/PausableDownloads-Example/Storyboards/Base.lproj/Main.storyboard +++ b/PausableDownloads-Example/Storyboards/Base.lproj/Main.storyboard @@ -1,9 +1,11 @@ - + - + + + @@ -13,144 +15,48 @@ - + - + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - + - + - + - + - - - - - + - + - @@ -159,10 +65,11 @@ - + @@ -174,48 +81,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -223,16 +89,13 @@ - - - - - - + - + + + 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/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 793cc0d..0000000 --- a/PausableDownloads-Example/ViewControllers/AlbumViewer/GalleryAlbumViewerViewController.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// GalleryAlbumViewerViewController.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/Albums/Cells/GalleryAlbumCollectionViewCell.swift b/PausableDownloads-Example/ViewControllers/Albums/Cells/GalleryAlbumCollectionViewCell.swift deleted file mode 100644 index a555a57..0000000 --- a/PausableDownloads-Example/ViewControllers/Albums/Cells/GalleryAlbumCollectionViewCell.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// GalleryAlbumCollectionViewCell.swift -// PausableDownloads-Example -// -// Created by William Boles on 17/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 thumbnailAsset: GalleryAsset? - - // MARK: - Reuse - - override func prepareForReuse() { - super.prepareForReuse() - - thumbnailImageView.image = UIImage(named: "icon-placeholder") - } - - // MARK: - Configure - - func configure(galleryAlbum: GalleryAlbum) { - informationalLabel.text = "\(galleryAlbum.thumbnailAsset.url.absoluteString)" - thumbnailAsset = galleryAlbum.thumbnailAsset - - assetDataManager.loadAlbumThumbnailAsset(galleryAlbum.thumbnailAsset) { [weak self] (result) in - switch result { - case .success(let loadResult): - if loadResult.asset == self?.thumbnailAsset { - 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 ee83c98..0000000 --- a/PausableDownloads-Example/ViewControllers/Albums/GalleryAlbumsViewController.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// GalleryAlbumsViewController.swift -// PausableDownloads-Example -// -// Created by William Boles on 17/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 = GalleryDataManager() - var galleryAlbums = [GalleryAlbum]() - let fileManager = FileManager.default - - // MARK: - Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - retrieveAlbums() - } - - // MARK: - Albums - - func retrieveAlbums() { - loadingActivityIndicatorView.startAnimating() - - dataManager.retrieveGallery(forSearchTerms: "cats") { (searchTerms, result) in - self.loadingActivityIndicatorView.stopAnimating() - - switch result { - case .success(let galleryAlbums): - self.galleryAlbums = galleryAlbums - 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.galleryItems = galleryAlbums[indexPath.item].items - } - } - - // MARK: - Reset - - @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()) - } - } - - galleryAlbums.removeAll() - collectionView.reloadData() - retrieveAlbums() - } -} - -extension GalleryAlbumsViewController: UICollectionViewDataSource { - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return galleryAlbums.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 galleryAlbum = galleryAlbums[indexPath.row] - - cell.configure(galleryAlbum: galleryAlbum) - - 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/ImageGallery/ImageGalleryViewController.swift b/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewController.swift new file mode 100644 index 0000000..0c7ac10 --- /dev/null +++ b/PausableDownloads-Example/ViewControllers/ImageGallery/ImageGalleryViewController.swift @@ -0,0 +1,257 @@ +// +// 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 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 + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + + configureNavigationBar() + configureLoadingActivityIndicator() + configurePagingButtons() + + dataSource = self + delegate = self + + galleryViewModel.delegate = self + galleryViewModel.load() + } + + private func configureNavigationBar() { + 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)]) + } + + 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 + } + + return ImageViewerViewController.instantiate(viewModel: viewModel, index: index) + } + + private func showFirstImage() { + guard let viewController = imageViewerViewController(at: 0) else { + return + } + + setViewControllers([viewController], direction: .forward, animated: false) + + bringPagingButtonsToFront() + updatePagingControls(for: 0) + } + + // 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 + } +} + +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, + 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, + let imageViewerViewController = viewControllers?.first as? ImageViewerViewController else { + return + } + + galleryViewModel.move(to: imageViewerViewController.index) + + updatePagingControls(for: imageViewerViewController.index) + } +} + +extension ImageGalleryViewController: ImageGalleryViewModelDelegate { + + // MARK: - ImageGalleryViewModelDelegate + + func viewModel(_ viewModel: ImageGalleryViewModel, + didChangeTo state: ImageGalleryViewModel.State) { + switch state { + case .loading: + loadingActivityIndicator.startAnimating() + hidePagingButtons() + case .loaded: + 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..7ab21fe --- /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 loading + case loaded + case failed + } + + weak var delegate: ImageGalleryViewModelDelegate? + + private(set) var state: State = .loading + private(set) var currentIndex = 0 + + private let imagesService: ImagesService + private let imageLoader: ImageLoader + + private var images = [ImageDomainModel]() + private var imageViewerViewModels = [Int: ImageViewerViewModel]() + + // MARK: - Init + + init(imagesService: ImagesService = DefaultImagesService(), + imageLoader: ImageLoader = DefaultImageLoader()) { + self.imagesService = imagesService + self.imageLoader = imageLoader + } + + // MARK: - Load + + func load() { + transition(to: .loading) + + imagesService.load(callbackQueue: .main) { [weak self] result in + guard let self else { + return + } + + switch result { + case let .success(images): + self.images = images + self.imageViewerViewModels.removeAll() + self.currentIndex = 0 + + self.transition(to: .loaded) + + self.viewModel(at: self.currentIndex)?.loadImage() + case .failure: + self.transition(to: .failed) + } + } + } + + // MARK: - Pages + + var numberOfImages: Int { + images.count + } + + func viewModel(at index: Int) -> ImageViewerViewModel? { + guard images.indices.contains(index) else { + return nil + } + + if let existingViewModel = imageViewerViewModels[index] { + return existingViewModel + } + + let viewModel = ImageViewerViewModel(imageDomainModel: images[index], + imageLoader: imageLoader) + imageViewerViewModels[index] = viewModel + + return viewModel + } + + // MARK: - Move + + func move(to index: Int) { + guard index != currentIndex, + images.indices.contains(index) else { + return + } + + imageViewerViewModels[currentIndex]?.cancelImageLoad() + + currentIndex = index + + viewModel(at: index)?.loadImage() + } + + // 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 new file mode 100644 index 0000000..ae2c0c7 --- /dev/null +++ b/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewController.swift @@ -0,0 +1,80 @@ +// +// 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(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 + + override func viewDidLoad() { + super.viewDidLoad() + + 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. + render(viewModel.state) + } + + // MARK: - Render + + private func render(_ state: ImageViewerViewModel.State) { + switch state { + case .ready: + loadingActivityIndicator.stopAnimating() + assetImageView.image = nil + descriptionLabel.text = viewModel.description + case .loading: + loadingActivityIndicator.startAnimating() + assetImageView.image = nil + descriptionLabel.text = viewModel.description + case let .loaded(image): + loadingActivityIndicator.stopAnimating() + assetImageView.image = image + descriptionLabel.text = viewModel.description + case .failed: + loadingActivityIndicator.stopAnimating() + //TODO: Handle error + break + } + } +} + +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 new file mode 100644 index 0000000..7813549 --- /dev/null +++ b/PausableDownloads-Example/ViewControllers/ImageViewer/ImageViewerViewModel.swift @@ -0,0 +1,101 @@ +// +// 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) +} + +final class ImageViewerViewModel { + enum State: Equatable { + case ready + case loading + case loaded(UIImage) + case failed + } + + weak var delegate: ImageViewerViewModelDelegate? + + private(set) var state: State = .ready + + let imageDomainModel: ImageDomainModel + let description: String + + private let imageLoader: ImageLoader + + //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, + imageLoader: ImageLoader = DefaultImageLoader()) { + self.imageDomainModel = imageDomainModel + self.imageLoader = imageLoader + self.description = imageDomainModel.url.absoluteString + } + + // MARK: - Load + + func loadImage() { + guard canLoad else { + return + } + + transition(to: .loading) + + loadToken = imageLoader.load(imageDomainModel, + callbackQueue: .main) { [weak self] result in + guard let self = self else { + return + } + + self.loadToken = nil + + switch result { + case let .success(image): + self.transition(to: .loaded(image)) + case .failure: + self.transition(to: .failed) + } + } + } + + // MARK: - Cancel + + func cancelImageLoad() { + guard let loadToken else { + return + } + + imageLoader.cancel(loadToken) + self.loadToken = nil + + transition(to: .ready) + } + + // MARK: - State + + private var canLoad: Bool { + switch state { + case .ready, .failed: + return true + case .loading, .loaded: + return false + } + } + + private func transition(to state: State) { + self.state = state + + delegate?.viewModel(self, + didChangeTo: state) + } +} diff --git a/PausableDownloads-ExampleTests/Doubles/StubDownloadSession.swift b/PausableDownloads-ExampleTests/Doubles/StubDownloadSession.swift new file mode 100644 index 0000000..54db99f --- /dev/null +++ b/PausableDownloads-ExampleTests/Doubles/StubDownloadSession.swift @@ -0,0 +1,35 @@ +// +// StubDownloadSession.swift +// PausableDownloads-ExampleTests +// +// Created by William Boles on 13/12/2019. +// Copyright © 2019 William Boles. All rights reserved. +// + +import Foundation + +@testable import PausableDownloads_Example + +class StubDownloadSession: DownloadSession { + enum Event { + case downloadTask(URL) + case downloadTaskWithResumeData(Data) + } + + private(set) var events = [Event]() + + var downloadTaskToReturn: StubDownloadTask! + var downloadTaskWithResumeDataToReturn: StubDownloadTask! + + func downloadTask(with url: URL) -> DownloadTask { + events.append(.downloadTask(url)) + + return downloadTaskToReturn + } + + 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 74% rename from PausableDownloads-ExampleTests/Doubles/StubURLSessionDownloadTask.swift rename to PausableDownloads-ExampleTests/Doubles/StubDownloadTask.swift index 788c702..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,23 +10,24 @@ import Foundation @testable import PausableDownloads_Example -class StubURLSessionDownloadTask: URLSessionDownloadTaskType { +class StubDownloadTask: DownloadTask { enum Event { case resume - case cancel case cancelByProducingResumeData((Data?) -> Void) } private(set) var events = [Event]() - var progress: Progress = Progress() + var taskIdentifierToReturn: Int! - func resume() { - events.append(.resume) + var taskIdentifier: Int { + taskIdentifierToReturn } - func cancel() { - events.append(.cancel) + // MARK: - Task + + func resume() { + events.append(.resume) } func cancel(byProducingResumeData completionHandler: @escaping (Data?) -> Void) { 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/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/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/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..a97cc57 --- /dev/null +++ b/PausableDownloads-ExampleTests/Doubles/StubImagesService.swift @@ -0,0 +1,24 @@ +// +// 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 load(DispatchQueue, LoadImagesCompletionHandler) + } + + private(set) var events = [Event]() + + func load(callbackQueue: DispatchQueue, + completionHandler: @escaping LoadImagesCompletionHandler) { + events.append(.load(callbackQueue, completionHandler)) + } +} 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 3620106..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! - - 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/Doubles/StubURLSession.swift b/PausableDownloads-ExampleTests/Doubles/StubURLSession.swift deleted file mode 100644 index 9f1826e..0000000 --- a/PausableDownloads-ExampleTests/Doubles/StubURLSession.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// StubURLSession.swift -// PausableDownloads-ExampleTests -// -// Created by William Boles on 13/12/2019. -// Copyright © 2019 William Boles. All rights reserved. -// - -import Foundation - -@testable import PausableDownloads_Example - -class StubURLSession: URLSessionType { - enum Event { - case downloadTask(URL, (URL?, URLResponse?, Error?) -> Void) - case downloadTaskWithResumeData(Data, (URL?, URLResponse?, Error?) -> Void) - } - - private(set) var events = [Event]() - - var downloadTaskToReturn: StubURLSessionDownloadTask! - var downloadTaskWithResumeDataToReturn: StubURLSessionDownloadTask! - - func downloadTask(with url: URL, completionHandler: @escaping (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTaskType { - events.append(.downloadTask(url, completionHandler)) - - return downloadTaskToReturn - } - - func downloadTask(withResumeData resumeData: Data, - completionHandler: @escaping (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTaskType { - events.append(.downloadTaskWithResumeData(resumeData, completionHandler)) - - return downloadTaskWithResumeDataToReturn - } -} 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/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/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/TestData/ImageDomainModel+TestData.swift b/PausableDownloads-ExampleTests/TestData/ImageDomainModel+TestData.swift new file mode 100644 index 0000000..c75c50f --- /dev/null +++ b/PausableDownloads-ExampleTests/TestData/ImageDomainModel+TestData.swift @@ -0,0 +1,24 @@ +// +// ImageDomainModel+TestData.swift +// PausableDownloads-ExampleTests +// +// Created by William Boles on 09/09/2026. +// Copyright © 2026 William Boles. All rights reserved. +// + +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/AssetDownloadsSessionTests.swift b/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift deleted file mode 100644 index bf56580..0000000 --- a/PausableDownloads-ExampleTests/Tests/AssetDownloadsSessionTests.swift +++ /dev/null @@ -1,631 +0,0 @@ -// -// AssetDownloadsSessionTests.swift -// PausableDownloads-ExampleTests -// -// Created by William Boles on 15/12/2019. -// Copyright © 2019 William Boles. All rights reserved. -// - -import XCTest - -@testable import PausableDownloads_Example - -class AssetDownloadsSessionTests: XCTestCase { - - // MARK: - Tests - - // 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) - - 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: Notification - - func test_givenNotificationCenter_whenInitialised_thenObserverIsAddedForMemoryWarningOnMainQueue() { - let notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() - - let sessionFactory = StubURLSessionFactory() - sessionFactory.sessionToReturn = StubURLSession() - - _ = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) - - XCTAssertEqual(notificationCenter.events.count, 1) - - guard case let .addObserver(name, object, queue, _) = notificationCenter.events.first else { - XCTFail("Unexpected event") - return - } - - XCTAssertEqual(name, UIApplication.didReceiveMemoryWarningNotification) - XCTAssertNil(object) - XCTAssertTrue(queue === OperationQueue.main) - } - - func test_givenPausedDownload_whenMemoryWarningNotificationIsReceived_thenDownloadTaskIsCancelled() { - 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) - - guard case let .addObserver(_, _, _, notificationBlock) = notificationCenter.events.first else { - XCTFail("Unexpected event") - return - } - - let downloadTask = StubURLSessionDownloadTask() - session.downloadTaskToReturn = downloadTask - - sut.scheduleDownload(url: url) { _ in } - - XCTAssertEqual(session.events.count, 1) - - sut.cancelDownload(url: url) - - XCTAssertEqual(downloadTask.events.count, 2) - - guard case .cancelByProducingResumeData = downloadTask.events.last else { - XCTFail("Unexpected event") - return - } - - let notification = Notification(name: UIApplication.didReceiveMemoryWarningNotification) - notificationBlock(notification) - - XCTAssertEqual(downloadTask.events.count, 3) - - guard case .cancel = downloadTask.events.last else { - XCTFail("Unexpected event") - return - } - } - - func test_givenActiveDownload_whenMemoryWarningNotificationIsReceived_thenTheDownloadTaskIsNotCancelled() { - 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 downloadTask = StubURLSessionDownloadTask() - session.downloadTaskToReturn = downloadTask - - guard case let .addObserver(_, _, _, notificationBlock) = notificationCenter.events.first else { - XCTFail("Unexpected event") - return - } - - sut.scheduleDownload(url: url) { _ in } - - XCTAssertEqual(downloadTask.events.count, 1) - - let notification = Notification(name: UIApplication.didReceiveMemoryWarningNotification) - notificationBlock(notification) - - XCTAssertEqual(downloadTask.events.count, 1) - - guard case .resume = downloadTask.events.last else { - XCTFail("Unexpected event") - return - } - } - - // MARK: Schedule - - 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 downloadTask = StubURLSessionDownloadTask() - session.downloadTaskToReturn = downloadTask - - sut.scheduleDownload(url: url) { _ in } - - XCTAssertEqual(downloadTask.events.count, 1) - - guard case .resume = downloadTask.events.first else { - XCTFail("Unexpected event") - return - } - - XCTAssertEqual(session.events.count, 1) - - guard case let .downloadTask(downloadTaskURL, _) = session.events.first else { - XCTFail("Unexpected event") - return - } - - XCTAssertEqual(downloadTaskURL, url) - } - - 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 downloadTask = StubURLSessionDownloadTask() - session.downloadTaskToReturn = downloadTask - - 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 } - - XCTAssertEqual(downloadTask.events.count, 2) - - guard case .resume = downloadTask.events.first, - case .resume = downloadTask.events.last else { - XCTFail("Unexpected events") - return - } - } - - func test_givenInFlightDownload_whenScheduleDownloadIsCalledForTheSameURL_thenNoSecondTaskIsCreatedAndBothCompletionHandlersAreCalled() { - 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 downloadTask = StubURLSessionDownloadTask() - session.downloadTaskToReturn = downloadTask - - let firstCompletionExpectation = expectation(description: "firstCompletionExpectation") - sut.scheduleDownload(url: url) { (_) in - firstCompletionExpectation.fulfill() - } - - let secondCompletionExpectation = expectation(description: "secondCompletionExpectation") - sut.scheduleDownload(url: url) { (_) in - secondCompletionExpectation.fulfill() - } - - XCTAssertEqual(downloadTask.events.count, 1) - - guard case .resume = downloadTask.events.first else { - XCTFail("Unexpected event") - return - } - - XCTAssertEqual(session.events.count, 1) - - guard case let .downloadTask(_, completionHandler) = session.events.first else { - XCTFail("Unexpected event") - return - } - - completionHandler(nil, nil, nil) - - waitForExpectations(timeout: 3, handler: nil) - } - - func test_givenPausedDownload_whenScheduleDownloadIsCalledForTheSameURL_thenExistingDownloadTaskIsResumed() { - 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 downloadTask = StubURLSessionDownloadTask() - session.downloadTaskToReturn = downloadTask - - sut.scheduleDownload(url: url) { _ in } - sut.cancelDownload(url: url) - - XCTAssertEqual(downloadTask.events.count, 2) - - guard case .cancelByProducingResumeData = downloadTask.events.last else { - XCTFail("Unexpected event") - return - } - - sut.scheduleDownload(url: url) { _ in } - - XCTAssertEqual(downloadTask.events.count, 3) - - guard case .resume = downloadTask.events.last else { - XCTFail("Unexpected event") - return - } - } - - 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) - - session.downloadTaskToReturn = StubURLSessionDownloadTask() - - sut.scheduleDownload(url: url) { (_) in } - - XCTAssertEqual(session.events.count, 1) - - guard case let .downloadTask(_, completionHandler) = session.events.first else { - XCTFail("Unexpected event") - return - } - - completionHandler(nil, nil, nil) - - 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) - - session.downloadTaskToReturn = StubURLSessionDownloadTask() - - let completionExpectation = expectation(description: "completionExpectation") - sut.scheduleDownload(url: url) { (_) in - completionExpectation.fulfill() - } - - guard case let .downloadTask(_, completionHandler) = session.events.first else { - XCTFail("Unexpected event") - return - } - - completionHandler(nil, nil, 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 downloadTask = StubURLSessionDownloadTask() - session.downloadTaskToReturn = downloadTask - - sut.scheduleDownload(url: url) { _ in } - sut.cancelDownload(url: url) - - guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { - XCTFail("Unexpected event") - return - } - - resumeDataHandler(resumptionData) - - let resumedDownloadTask = StubURLSessionDownloadTask() - session.downloadTaskWithResumeDataToReturn = resumedDownloadTask - - 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) - XCTAssertEqual(resumedDownloadTask.events.count, 1) - - guard case .resume = resumedDownloadTask.events.first else { - XCTFail("Unexpected event") - return - } - } - - func test_givenScheduledDownload_whenTheDownloadTaskCompletesWithAFileURL_thenTheCompletionHandlerReceivesTheFileContents() 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 notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() - - let sessionFactory = StubURLSessionFactory() - let session = StubURLSession() - sessionFactory.sessionToReturn = session - - let sut = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) - - session.downloadTaskToReturn = StubURLSessionDownloadTask() - - var receivedResult: Result? - let completionExpectation = expectation(description: "completionExpectation") - sut.scheduleDownload(url: url) { (result) in - receivedResult = result - completionExpectation.fulfill() - } - - guard case let .downloadTask(_, completionHandler) = session.events.first else { - XCTFail("Unexpected event") - return - } - - completionHandler(fileURL, nil, nil) - - waitForExpectations(timeout: 3, handler: nil) - - guard case let .success(data) = try XCTUnwrap(receivedResult) else { - XCTFail("Expected a success result") - return - } - - XCTAssertEqual(data, expectedData) - } - - 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) - - session.downloadTaskToReturn = StubURLSessionDownloadTask() - - var receivedResult: Result? - let completionExpectation = expectation(description: "completionExpectation") - sut.scheduleDownload(url: url) { (result) in - receivedResult = result - completionExpectation.fulfill() - } - - guard case let .downloadTask(_, completionHandler) = session.events.first else { - XCTFail("Unexpected event") - return - } - - completionHandler(nil, nil, TestError.test) - - waitForExpectations(timeout: 3, handler: nil) - - guard case let .failure(error) = try XCTUnwrap(receivedResult), - case let NetworkingError.retrieval(underlyingError) = error else { - XCTFail("Expected a retrieval failure") - return - } - - XCTAssertEqual(underlyingError as? TestError, .test) - } - - func test_givenScheduledDownload_whenTheDownloadTaskCompletesWithAnUnreadableFileURL_thenTheCompletionHandlerReceivesAnInvalidDataFailure() throws { - 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) - - session.downloadTaskToReturn = StubURLSessionDownloadTask() - - var receivedResult: Result? - let completionExpectation = expectation(description: "completionExpectation") - sut.scheduleDownload(url: url) { (result) in - receivedResult = result - completionExpectation.fulfill() - } - - guard case let .downloadTask(_, completionHandler) = session.events.first else { - XCTFail("Unexpected event") - return - } - - completionHandler(unreadableFileURL, nil, nil) - - 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_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) - - let notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() - - let sessionFactory = StubURLSessionFactory() - let session = StubURLSession() - sessionFactory.sessionToReturn = session - - let sut = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) - - session.downloadTaskToReturn = StubURLSessionDownloadTask() - - var firstReceivedResult: Result? - let firstCompletionExpectation = expectation(description: "firstCompletionExpectation") - sut.scheduleDownload(url: url) { (result) in - firstReceivedResult = result - firstCompletionExpectation.fulfill() - } - - var secondReceivedResult: Result? - let secondCompletionExpectation = expectation(description: "secondCompletionExpectation") - sut.scheduleDownload(url: url) { (result) in - secondReceivedResult = result - secondCompletionExpectation.fulfill() - } - - guard case let .downloadTask(_, completionHandler) = session.events.first else { - XCTFail("Unexpected event") - return - } - - completionHandler(fileURL, nil, nil) - - waitForExpectations(timeout: 3, handler: nil) - - 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") - return - } - - XCTAssertEqual(firstData, expectedData) - XCTAssertEqual(secondData, expectedData) - } - - // MARK: Cancel - - func test_givenScheduledDownload_whenCancelDownloadIsCalled_thenDownloadTaskIsCancelledByProducingResumeData() { - 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 downloadTask = StubURLSessionDownloadTask() - session.downloadTaskToReturn = downloadTask - - sut.scheduleDownload(url: url) { _ in } - sut.cancelDownload(url: url) - - XCTAssertEqual(downloadTask.events.count, 2) - - guard case .cancelByProducingResumeData = downloadTask.events.last else { - XCTFail("Unexpected event") - return - } - } - - func test_givenNoScheduledDownloads_whenCancelDownloadIsCalledForAnUnknownURL_thenNoDownloadTaskEventsAreRecorded() { - let unknownURL = URL(string: "http://test.com/unknown")! - - let notificationCenter = StubNotificationCenter() - notificationCenter.objectToReturn = NSObject() - - let sessionFactory = StubURLSessionFactory() - let session = StubURLSession() - sessionFactory.sessionToReturn = session - - let sut = createSUT(urlSessionFactory: sessionFactory, - notificationCenter: notificationCenter) - - let downloadTask = StubURLSessionDownloadTask() - session.downloadTaskToReturn = downloadTask - - sut.cancelDownload(url: unknownURL) - - XCTAssertTrue(session.events.isEmpty) - XCTAssertTrue(downloadTask.events.isEmpty) - } -} - -extension AssetDownloadsSessionTests { - func createSUT(urlSessionFactory: URLSessionFactoryType = StubURLSessionFactory(), - notificationCenter: NotificationCenterType = StubNotificationCenter()) -> AssetDownloadsSession { - AssetDownloadsSession(urlSessionFactory: urlSessionFactory, - notificationCenter: notificationCenter) - } -} diff --git a/PausableDownloads-ExampleTests/Tests/DownloaderTests.swift b/PausableDownloads-ExampleTests/Tests/DownloaderTests.swift new file mode 100644 index 0000000..72c552a --- /dev/null +++ b/PausableDownloads-ExampleTests/Tests/DownloaderTests.swift @@ -0,0 +1,897 @@ +// +// DownloaderTests.swift +// PausableDownloads-ExampleTests +// +// Created by William Boles on 15/12/2019. +// Copyright © 2019 William Boles. All rights reserved. +// + +import XCTest + +@testable import PausableDownloads_Example + +class DownloaderTests: XCTestCase { + + // MARK: - Tests + + // MARK: MemoryPressure + + func test_givenMemoryPressureMonitor_whenInitialised_thenMonitoringIsStarted() { + let memoryPressureMonitor = StubMemoryPressureMonitor() + + _ = createSUT(memoryPressureMonitor: memoryPressureMonitor) + + XCTAssertEqual(memoryPressureMonitor.events.count, 1) + + guard case .startMonitoring = memoryPressureMonitor.events.first else { + XCTFail("Unexpected event") + return + } + } + + func test_givenPausedDownload_whenMemoryPressureIsReceived_thenTheDownloadIsDiscarded() { + let url = URL(string: "http://test.com/example")! + + let memoryPressureMonitor = StubMemoryPressureMonitor() + + let session = StubDownloadSession() + let sut = createSUT(session: session, memoryPressureMonitor: memoryPressureMonitor) + + guard case let .startMonitoring(memoryPressureHandler) = memoryPressureMonitor.events.first else { + XCTFail("Unexpected event") + return + } + + let downloadTask = StubDownloadTask() + session.downloadTaskToReturn = downloadTask + + let downloadID = sut.download(url) { _ in } + + XCTAssertEqual(session.events.count, 1) + + sut.pause(downloadID) + + XCTAssertEqual(downloadTask.events.count, 2) + + guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { + XCTFail("Unexpected event") + return + } + + resumeDataHandler(Data("resumption".utf8)) + + memoryPressureHandler() + + //the purged item took its resumption data with it, so the next schedule starts over + session.downloadTaskWithResumeDataToReturn = StubDownloadTask() + + sut.download(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 + } + } + + func test_givenActiveDownload_whenMemoryPressureIsReceived_thenTheDownloadTaskIsNotCancelled() { + let url = URL(string: "http://test.com/example")! + + let memoryPressureMonitor = StubMemoryPressureMonitor() + + let session = StubDownloadSession() + let sut = createSUT(session: session, memoryPressureMonitor: memoryPressureMonitor) + + let downloadTask = StubDownloadTask() + session.downloadTaskToReturn = downloadTask + + guard case let .startMonitoring(memoryPressureHandler) = memoryPressureMonitor.events.first else { + XCTFail("Unexpected event") + return + } + + sut.download(url) { _ in } + + XCTAssertEqual(downloadTask.events.count, 1) + + memoryPressureHandler() + + XCTAssertEqual(downloadTask.events.count, 1) + + guard case .resume = downloadTask.events.last else { + XCTFail("Unexpected event") + return + } + } + + // MARK: Schedule + + func test_givenNoExistingDownload_whendownloadIsCalled_thenDownloadTaskIsCreatedForURLAndResumed() { + let url = URL(string: "http://test.com/example")! + + let session = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + session.downloadTaskToReturn = downloadTask + + sut.download(url) { _ in } + + XCTAssertEqual(downloadTask.events.count, 1) + + guard case .resume = downloadTask.events.first else { + XCTFail("Unexpected event") + return + } + + XCTAssertEqual(session.events.count, 1) + + guard case let .downloadTask(downloadTaskURL) = session.events.first else { + XCTFail("Unexpected event") + return + } + + XCTAssertEqual(downloadTaskURL, url) + } + + func test_givenNoExistingDownloads_whendownloadIsCalledForTwoDifferentURLs_thenBothDownloadTasksAreResumed() { + let session = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + session.downloadTaskToReturn = downloadTask + + let urlA = URL(string: "http://example.com/resourceA")! + let urlB = URL(string: "http://example.com/resourceB")! + + sut.download(urlA) { _ in } + sut.download(urlB) { _ in } + + XCTAssertEqual(downloadTask.events.count, 2) + + guard case .resume = downloadTask.events.first, + case .resume = downloadTask.events.last else { + XCTFail("Unexpected events") + return + } + } + + func test_givenInFlightDownload_whendownloadIsCalledForTheSameURL_thenOneDownloadIsSharedAndBothCompletionHandlersAreCalled() { + let url = URL(string: "http://test.com/example")! + + let session = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + downloadTask.taskIdentifierToReturn = 1 + session.downloadTaskToReturn = downloadTask + + var firstResults = [Result]() + sut.download(url) { firstResults.append($0) } + + var secondResults = [Result]() + sut.download(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.handleFailedDownloading(for: url, taskIdentifier: 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 session = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + downloadTask.taskIdentifierToReturn = 1 + session.downloadTaskToReturn = downloadTask + + var firstResults = [Result]() + let firstDownloadToken = sut.download(url) { firstResults.append($0) } + + var secondResults = [Result]() + sut.download(url) { secondResults.append($0) } + + sut.pause(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.handleFailedDownloading(for: url, taskIdentifier: downloadTask.taskIdentifier, error: TestError.test) + + XCTAssertEqual(secondResults.count, 1) + XCTAssertTrue(firstResults.isEmpty) + } + + func test_givenPausedDownloadThatProducedNoResumptionData_whendownloadIsCalledForTheSameURL_thenTheDownloadRestarts() { + let url = URL(string: "http://test.com/example")! + + let session = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + session.downloadTaskToReturn = downloadTask + + let downloadID = sut.download(url) { _ in } + sut.pause(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.download(url) { _ in } + + XCTAssertEqual(session.events.count, 2) + + guard case .downloadTask = session.events.last else { + XCTFail("Unexpected event") + return + } + + XCTAssertEqual(downloadTask.events.count, 3) + + guard case .resume = downloadTask.events.last else { + XCTFail("Unexpected event") + return + } + } + + func test_givenPauseStillProducingResumptionData_whendownloadIsCalledForTheSameURL_thenTheResumeWaitsForTheResumptionData() { + let url = URL(string: "http://test.com/example")! + let resumptionData = Data("resumption".utf8) + + let session = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + session.downloadTaskToReturn = downloadTask + + let downloadID = sut.download(url) { _ in } + sut.pause(downloadID) + + guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { + XCTFail("Unexpected event") + return + } + + let resumedDownloadTask = StubDownloadTask() + session.downloadTaskWithResumeDataToReturn = resumedDownloadTask + + //rescheduling whilst the resumption data is still in flight - the fast swipe back + sut.download(url) { _ in } + + XCTAssertEqual(session.events.count, 1) + + resumeDataHandler(resumptionData) + + XCTAssertEqual(session.events.count, 2) + + guard case let .downloadTaskWithResumeData(data) = session.events.last else { + XCTFail("Unexpected event") + return + } + + XCTAssertEqual(data, resumptionData) + + XCTAssertEqual(resumedDownloadTask.events.count, 1) + + guard case .resume = resumedDownloadTask.events.first else { + XCTFail("Unexpected event") + return + } + } + + func test_givenACallerThatJoinedAPauseInFlight_whenItPausesBeforeTheResumptionDataLands_thenNoTaskIsEverStarted() { + let url = URL(string: "http://test.com/example")! + + let session = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + session.downloadTaskToReturn = downloadTask + session.downloadTaskWithResumeDataToReturn = StubDownloadTask() + + let firstDownloadID = sut.download(url) { _ in } + sut.pause(firstDownloadID) + + guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { + XCTFail("Unexpected event") + return + } + + //scheduled whilst the pause is still in flight, so it joins rather than starting a task + let joinedDownloadToken = sut.download(url) { _ in } + + XCTAssertEqual(session.events.count, 1) + + sut.pause(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 = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + downloadTask.taskIdentifierToReturn = 1 + session.downloadTaskToReturn = downloadTask + + var results = [Result]() + let downloadID = sut.download(url) { results.append($0) } + + guard case .downloadTask = session.events.first else { + XCTFail("Unexpected event") + return + } + + 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)) + + XCTAssertTrue(results.isEmpty) + } + + 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")) + + let session = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + downloadTask.taskIdentifierToReturn = 1 + session.downloadTaskToReturn = downloadTask + + sut.download(url) { _ in } + + XCTAssertEqual(session.events.count, 1) + + guard case .downloadTask = session.events.first else { + XCTFail("Unexpected event") + return + } + + sut.handleFinishedDownloading(for: url, taskIdentifier: downloadTask.taskIdentifier, to: fileURL) + + sut.download(url) { _ in } + + XCTAssertEqual(session.events.count, 2) + } + + func test_givenScheduledDownload_whenTheDownloadTaskFails_thenTheCompletionHandlerIsCalled() { + let url = URL(string: "http://test.com/example")! + + let session = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + downloadTask.taskIdentifierToReturn = 1 + session.downloadTaskToReturn = downloadTask + + let completionExpectation = expectation(description: "completionExpectation") + sut.download(url) { _ in + completionExpectation.fulfill() + } + + guard case .downloadTask = session.events.first else { + XCTFail("Unexpected event") + return + } + + sut.handleFailedDownloading(for: url, taskIdentifier: downloadTask.taskIdentifier, error: TestError.test) + + waitForExpectations(timeout: 3, handler: nil) + } + + func test_givenPausedDownloadWithResumptionData_whendownloadIsCalledForTheSameURL_thenDownloadTaskIsCreatedFromResumeData() { + let url = URL(string: "http://test.com/example")! + let resumptionData = Data("resumption".utf8) + + let session = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + session.downloadTaskToReturn = downloadTask + + let downloadID = sut.download(url) { _ in } + sut.pause(downloadID) + + guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { + XCTFail("Unexpected event") + return + } + + resumeDataHandler(resumptionData) + + let resumedDownloadTask = StubDownloadTask() + session.downloadTaskWithResumeDataToReturn = resumedDownloadTask + + sut.download(url) { _ in } + + XCTAssertEqual(session.events.count, 2) + + guard case let .downloadTaskWithResumeData(data) = session.events.last else { + XCTFail("Unexpected event") + return + } + + XCTAssertEqual(data, resumptionData) + XCTAssertEqual(resumedDownloadTask.events.count, 1) + + guard case .resume = resumedDownloadTask.events.first else { + XCTFail("Unexpected event") + return + } + } + + func test_givenScheduledDownload_whenTheDownloadTaskCompletesWithAFileURL_thenTheCompletionHandlerReceivesTheFileContents() 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 = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + downloadTask.taskIdentifierToReturn = 1 + session.downloadTaskToReturn = downloadTask + + var receivedResult: Result? + let completionExpectation = expectation(description: "completionExpectation") + sut.download(url) { (result) in + receivedResult = result + completionExpectation.fulfill() + } + + guard case .downloadTask = session.events.first else { + XCTFail("Unexpected event") + return + } + + sut.handleFinishedDownloading(for: url, taskIdentifier: downloadTask.taskIdentifier, to: fileURL) + + waitForExpectations(timeout: 3, handler: nil) + + guard case let .success(data) = try XCTUnwrap(receivedResult) else { + XCTFail("Expected a success result") + return + } + + XCTAssertEqual(data, expectedData) + } + + func test_givenScheduledDownload_whenTheDownloadTaskCompletesWithAnError_thenTheCompletionHandlerReceivesARetrievalFailure() throws { + let url = URL(string: "http://test.com/example")! + + let session = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + downloadTask.taskIdentifierToReturn = 1 + session.downloadTaskToReturn = downloadTask + + var receivedResult: Result? + let completionExpectation = expectation(description: "completionExpectation") + sut.download(url) { (result) in + receivedResult = result + completionExpectation.fulfill() + } + + guard case .downloadTask = session.events.first else { + XCTFail("Unexpected event") + return + } + + sut.handleFailedDownloading(for: url, taskIdentifier: downloadTask.taskIdentifier, error: TestError.test) + + waitForExpectations(timeout: 3, handler: nil) + + guard case let .failure(error) = try XCTUnwrap(receivedResult), + case let DownloadError.download(underlyingError) = error else { + XCTFail("Expected a retrieval failure") + return + } + + XCTAssertEqual(underlyingError as? TestError, .test) + } + + func test_givenScheduledDownload_whenTheDownloadTaskCompletesWithAnUnreadableFileURL_thenTheCompletionHandlerReceivesAnInvalidDataFailure() throws { + let url = URL(string: "http://test.com/example")! + let unreadableFileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("does-not-exist-\(UUID().uuidString)") + + let session = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + downloadTask.taskIdentifierToReturn = 1 + session.downloadTaskToReturn = downloadTask + + var receivedResult: Result? + let completionExpectation = expectation(description: "completionExpectation") + sut.download(url) { (result) in + receivedResult = result + completionExpectation.fulfill() + } + + guard case .downloadTask = session.events.first else { + XCTFail("Unexpected event") + return + } + + sut.handleFinishedDownloading(for: url, taskIdentifier: downloadTask.taskIdentifier, to: unreadableFileURL) + + waitForExpectations(timeout: 3, handler: nil) + + guard case let .failure(error) = try XCTUnwrap(receivedResult), + case DownloadError.invalidData = error else { + XCTFail("Expected an invalid data failure") + return + } + } + + func test_givenPauseThenResume_whenTheCancelledDownloadTaskCompletes_thenItIsIgnoredAndTheResumedTaskStillCompletes() { + let url = URL(string: "http://test.com/example")! + + let session = StubDownloadSession() + let sut = createSUT(session: session) + + let retiredDownloadTask = StubDownloadTask() + retiredDownloadTask.taskIdentifierToReturn = 1 + session.downloadTaskToReturn = retiredDownloadTask + + let downloadID = sut.download(url) { _ in } + sut.pause(downloadID) + + guard case let .cancelByProducingResumeData(resumeDataHandler) = retiredDownloadTask.events.last else { + XCTFail("Unexpected event") + return + } + + resumeDataHandler(Data("resumption".utf8)) + + let resumedDownloadTask = StubDownloadTask() + resumedDownloadTask.taskIdentifierToReturn = 2 + session.downloadTaskWithResumeDataToReturn = resumedDownloadTask + + var results = [Result]() + 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)) + + XCTAssertTrue(results.isEmpty) + + sut.handleFailedDownloading(for: url, taskIdentifier: resumedDownloadTask.taskIdentifier, error: TestError.test) + + XCTAssertEqual(results.count, 1) + } + + func test_givenNoMatchingDownload_whenAnEventForAnUnknownTaskIsReceived_thenItIsIgnored() { + let url = URL(string: "http://test.com/example")! + + let session = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + downloadTask.taskIdentifierToReturn = 1 + session.downloadTaskToReturn = downloadTask + + var results = [Result]() + sut.download(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(for: unknownURL, taskIdentifier: unknownTaskIdentifier, to: URL(fileURLWithPath: "/dev/null")) + sut.handleFailedDownloading(for: unknownURL, taskIdentifier: unknownTaskIdentifier, error: TestError.test) + + XCTAssertTrue(results.isEmpty) + } + + // MARK: Cancel + + func test_givenScheduledDownload_whenCancelDownloadIsCalled_thenDownloadTaskIsCancelledByProducingResumeData() { + let url = URL(string: "http://test.com/example")! + + let session = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + session.downloadTaskToReturn = downloadTask + + let downloadID = sut.download(url) { _ in } + sut.pause(downloadID) + + XCTAssertEqual(downloadTask.events.count, 2) + + guard case .cancelByProducingResumeData = downloadTask.events.last else { + XCTFail("Unexpected event") + return + } + } + + func test_givenNoScheduledDownloads_whenCancelDownloadIsCalledForAnUnknownID_thenNoDownloadTaskEventsAreRecorded() { + let url = URL(string: "http://test.com/example")! + + let session = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + session.downloadTaskToReturn = downloadTask + + sut.pause(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 = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + downloadTask.taskIdentifierToReturn = 1 + session.downloadTaskToReturn = downloadTask + + var firstResult: Result? + sut.download(url) { firstResult = $0 } + + var secondResult: Result? + sut.download(url) { secondResult = $0 } + + XCTAssertEqual(session.events.count, 1) + + 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), + case let .success(secondData) = try XCTUnwrap(secondResult) else { + XCTFail("Expected both callers to receive a success result") + return + } + + XCTAssertEqual(firstData, expectedData) + XCTAssertEqual(secondData, expectedData) + } + + func test_givenTwoCallersForTheSameURL_whenBothPause_thenTheSharedTaskIsCancelledOnce() { + let url = URL(string: "http://test.com/example")! + + let session = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + session.downloadTaskToReturn = downloadTask + + let firstDownloadToken = sut.download(url) { _ in } + let secondDownloadToken = sut.download(url) { _ in } + + sut.pause(firstDownloadToken) + + //somebody still wants it, so nothing is cancelled yet + XCTAssertEqual(downloadTask.events.count, 1) + + sut.pause(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 = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + session.downloadTaskToReturn = downloadTask + + let firstDownloadToken = sut.download(url) { _ in } + sut.pause(firstDownloadToken) + + guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { + XCTFail("Unexpected event") + return + } + + let resumedTask = StubDownloadTask() + resumedTask.taskIdentifierToReturn = 2 + session.downloadTaskWithResumeDataToReturn = resumedTask + + //both scheduled whilst the pause is still in flight, so both join it + var secondResults = [Result]() + sut.download(url) { secondResults.append($0) } + + var thirdResults = [Result]() + sut.download(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.handleFailedDownloading(for: url, taskIdentifier: resumedTask.taskIdentifier, error: TestError.test) + + XCTAssertEqual(secondResults.count, 1) + XCTAssertEqual(thirdResults.count, 1) + } + + func test_givenARetiredTaskThatFailsAfterTheDownloadWasResumed_whenItCompletes_thenTheResumedDownloadIsUnaffected() { + let url = URL(string: "http://test.com/example")! + + let session = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + downloadTask.taskIdentifierToReturn = 1 + session.downloadTaskToReturn = downloadTask + + let firstDownloadToken = sut.download(url) { _ in } + sut.pause(firstDownloadToken) + + guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { + XCTFail("Unexpected event") + return + } + + resumeDataHandler(Data("resumption".utf8)) + + 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. + sut.handleFailedDownloading(for: url, taskIdentifier: downloadTask.taskIdentifier, error: TestError.test) + + XCTAssertTrue(results.isEmpty) + + sut.handleFailedDownloading(for: url, taskIdentifier: resumedTask.taskIdentifier, error: TestError.test) + + XCTAssertEqual(results.count, 1) + } + + func test_givenADownloadThatIsPausing_whenMemoryPressureIsReceived_thenAJoinedCallerIsStillAnswered() { + let url = URL(string: "http://test.com/example")! + + let memoryPressureMonitor = StubMemoryPressureMonitor() + + let session = StubDownloadSession() + let sut = createSUT(session: session, memoryPressureMonitor: memoryPressureMonitor) + + guard case let .startMonitoring(memoryPressureHandler) = memoryPressureMonitor.events.first else { + XCTFail("Unexpected event") + return + } + + let downloadTask = StubDownloadTask() + session.downloadTaskToReturn = downloadTask + + let firstDownloadToken = sut.download(url) { _ in } + sut.pause(firstDownloadToken) + + guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { + XCTFail("Unexpected event") + return + } + + let resumedTask = StubDownloadTask() + resumedTask.taskIdentifierToReturn = 2 + session.downloadTaskWithResumeDataToReturn = resumedTask + + var results = [Result]() + sut.download(url) { results.append($0) } + + //purging must leave a pause in flight alone or the caller that joined it is stranded + memoryPressureHandler() + + resumeDataHandler(Data("resumption".utf8)) + + XCTAssertEqual(session.events.count, 2) + + guard case .downloadTaskWithResumeData = session.events.last else { + XCTFail("Expected a resumed download task") + return + } + + sut.handleFailedDownloading(for: url, taskIdentifier: resumedTask.taskIdentifier, error: TestError.test) + + XCTAssertEqual(results.count, 1) + } + + func test_givenAPausedDownloadWithResumptionData_whenTwoCallersScheduleTheSameURL_thenTheResumptionDataIsUsedOnce() { + let url = URL(string: "http://test.com/example")! + + let session = StubDownloadSession() + let sut = createSUT(session: session) + + let downloadTask = StubDownloadTask() + session.downloadTaskToReturn = downloadTask + + let firstDownloadToken = sut.download(url) { _ in } + sut.pause(firstDownloadToken) + + guard case let .cancelByProducingResumeData(resumeDataHandler) = downloadTask.events.last else { + XCTFail("Unexpected event") + return + } + + resumeDataHandler(Data("resumption".utf8)) + + session.downloadTaskWithResumeDataToReturn = StubDownloadTask() + + 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) + + guard case .downloadTaskWithResumeData = session.events.last else { + XCTFail("Expected the resumption data to be used exactly once") + return + } + } +} + +extension DownloaderTests { + func createSUT(session: StubDownloadSession = StubDownloadSession(), + memoryPressureMonitor: MemoryPressureMonitor = StubMemoryPressureMonitor()) -> DefaultDownloader { + let sessionFactory = StubDownloadSessionFactory() + sessionFactory.sessionToReturn = session + + return DefaultDownloader(sessionFactory: sessionFactory, + memoryPressureMonitor: memoryPressureMonitor) + } +} diff --git a/PausableDownloads-ExampleTests/Tests/ImageGalleryViewModelTests.swift b/PausableDownloads-ExampleTests/Tests/ImageGalleryViewModelTests.swift new file mode 100644 index 0000000..668a1db --- /dev/null +++ b/PausableDownloads-ExampleTests/Tests/ImageGalleryViewModelTests.swift @@ -0,0 +1,365 @@ +// +// 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 .load(callbackQueue, _) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + XCTAssertTrue(callbackQueue === DispatchQueue.main) + XCTAssertEqual(sut.state, .loading) + + guard case let .didChangeTo(state) = delegate.events.first else { + XCTFail("Unexpected event") + return + } + + XCTAssertEqual(state, .loading) + } + + func test_givenLoadInProgress_whenImagesAreRetrieved_thenTheFirstAssetIsLoaded() { + 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, + imageLoader: imageLoader) + + sut.load() + + guard case let .load(_, completionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.success([imageA, imageB])) + + XCTAssertEqual(sut.state, .loaded) + XCTAssertEqual(sut.numberOfImages, 2) + XCTAssertEqual(sut.currentIndex, 0) + + XCTAssertEqual(imageLoader.events.count, 1) + + guard case let .load(loadedImage, _, _) = imageLoader.events.first else { + XCTFail("Unexpected event") + return + } + + XCTAssertEqual(loadedImage, imageA) + } + + func test_givenLoadInProgress_whenImageRetrievalFails_thenStateTransitionsToFailed() { + let imagesService = StubImagesService() + let imageLoader = StubImageLoader() + + let sut = createSUT(imagesService: imagesService, + imageLoader: imageLoader) + + sut.load() + + guard case let .load(_, completionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.failure(TestError.test)) + + XCTAssertEqual(sut.state, .failed) + XCTAssertTrue(imageLoader.events.isEmpty) + } + + func test_givenLoadInProgress_whenNoImagesAreRetrieved_thenNoAssetIsLoaded() { + let imagesService = StubImagesService() + let imageLoader = StubImageLoader() + + let sut = createSUT(imagesService: imagesService, + imageLoader: imageLoader) + + sut.load() + + guard case let .load(_, completionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.success([])) + + XCTAssertEqual(sut.state, .loaded) + XCTAssertEqual(sut.numberOfImages, 0) + XCTAssertTrue(imageLoader.events.isEmpty) + } + + // MARK: Pages + + func test_givenRetrievedImages_whenAViewModelIsRequestedTwiceForTheSameIndex_thenTheSameInstanceIsReturned() { + 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) + + XCTAssertNotNil(first) + XCTAssertTrue(first === second) + } + + func test_givenRetrievedImages_whenAViewModelIsRequestedForEachIndex_thenItRepresentsThatImage() { + 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 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)) + } + + // MARK: Move + + func test_givenLoadedImages_whenMoveToIsCalled_thenTheOutgoingAssetIsPausedAndTheIncomingOneIsLoaded() { + 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 tokenForImageA = LoadToken(url: imageA.url) + imageLoader.tokenToReturn = tokenForImageA + + 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 + imageLoader.tokenToReturn = LoadToken(url: imageB.url) + + sut.move(to: 1) + + XCTAssertEqual(sut.currentIndex, 1) + XCTAssertEqual(imageLoader.events.count, 3) + + 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(pausedToken, tokenForImageA) + + guard case let .load(loadedImage, _, _) = imageLoader.events.last else { + XCTFail("Unexpected event") + return + } + + XCTAssertEqual(loadedImage, imageB) + } + + func test_givenAPausedImage_whenMovedBackTo_thenItsAssetIsLoadedAgain() { + 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) + + sut.load() + + guard case let .load(_, completionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.success([imageA, imageB])) + + let tokenForImageB = LoadToken(url: imageB.url) + imageLoader.tokenToReturn = tokenForImageB + + sut.move(to: 1) + + imageLoader.tokenToReturn = LoadToken(url: imageA.url) + + sut.move(to: 0) + + XCTAssertEqual(sut.currentIndex, 0) + XCTAssertEqual(imageLoader.events.count, 5) + + 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(pausedToken, tokenForImageB) + + //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 + } + + XCTAssertEqual(loadedImage, imageA) + } + + func test_givenLoadedImages_whenMoveToIsCalledForTheCurrentIndex_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, + imageLoader: imageLoader) + + sut.load() + + guard case let .load(_, completionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.success([imageA, imageB])) + + let eventCountBeforeMove = imageLoader.events.count + + sut.move(to: 0) + + XCTAssertEqual(imageLoader.events.count, eventCountBeforeMove) + XCTAssertEqual(sut.currentIndex, 0) + } + + 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, + imageLoader: imageLoader) + + sut.load() + + guard case let .load(_, completionHandler) = imagesService.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.success([imageA, imageB])) + + 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..c7ed28c --- /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 ImageLoaderError.invalidImageData = 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 new file mode 100644 index 0000000..bb66dfb --- /dev/null +++ b/PausableDownloads-ExampleTests/Tests/ImageViewerViewModelTests.swift @@ -0,0 +1,215 @@ +// +// 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: Init + + func test_givenImage_whenInitialised_thenStateIsReady() { + let image = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + + let sut = createSUT(imageDomainModel: image) + + XCTAssertEqual(sut.state, .ready) + } + + // MARK: Load + + func test_givenViewModel_whenLoadIsCalled_thenTheAssetIsRequested() { + let imageLoader = StubImageLoader() + let image = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + + let sut = createSUT(imageDomainModel: image, + imageLoader: imageLoader) + + sut.loadImage() + + XCTAssertEqual(imageLoader.events.count, 1) + + guard case let .load(loadedImage, callbackQueue, _) = imageLoader.events.first else { + XCTFail("Unexpected event") + return + } + + XCTAssertEqual(loadedImage, image) + XCTAssertTrue(callbackQueue === DispatchQueue.main) + XCTAssertEqual(sut.state, .loading) + } + + func test_givenAssetLoadInProgress_whenTheAssetLoads_thenStateTransitionsToLoadedAsset() { + let imageLoader = StubImageLoader() + let image = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + + let sut = createSUT(imageDomainModel: image, + imageLoader: imageLoader) + + sut.loadImage() + + guard case let .load(_, _, completionHandler) = imageLoader.events.first else { + XCTFail("Unexpected event") + return + } + + let loadedImage = UIImage() + completionHandler(.success(loadedImage)) + + XCTAssertEqual(sut.state, .loaded(loadedImage)) + } + + func test_givenAssetLoadInProgress_whenTheAssetFailsToLoad_thenStateTransitionsToFailed() { + let imageLoader = StubImageLoader() + + let sut = createSUT(imageLoader: imageLoader) + + sut.loadImage() + + guard case let .load(_, _, completionHandler) = imageLoader.events.first else { + XCTFail("Unexpected event") + return + } + + completionHandler(.failure(TestError.test)) + + XCTAssertEqual(sut.state, .failed) + } + + func test_givenAssetLoadInProgress_whenLoadIsCalledAgain_thenTheAssetIsNotRequestedASecondTime() { + let imageLoader = StubImageLoader() + + let sut = createSUT(imageLoader: imageLoader) + + sut.loadImage() + sut.loadImage() + + XCTAssertEqual(imageLoader.events.count, 1) + } + + func test_givenLoadedAsset_whenLoadIsCalledAgain_thenTheAssetIsNotRequestedASecondTime() { + let imageLoader = StubImageLoader() + let image = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + + let sut = createSUT(imageDomainModel: image, + imageLoader: imageLoader) + + sut.loadImage() + + guard case let .load(_, _, completionHandler) = imageLoader.events.first else { + XCTFail("Unexpected event") + return + } + + let loadedImage = UIImage() + completionHandler(.success(loadedImage)) + + sut.loadImage() + + XCTAssertEqual(imageLoader.events.count, 1) + XCTAssertEqual(sut.state, .loaded(loadedImage)) + } + + // MARK: Pause + + func test_givenAssetLoadInProgress_whenPauseIsCalled_thenTheAssetLoadIsCancelledAndStateReturnsToReady() { + let imageLoader = StubImageLoader() + let image = ImageDomainModel.testData(identifier: "a", + url: URL(string: "http://test.com/a.jpg")!) + + let token = LoadToken(url: image.url) + imageLoader.tokenToReturn = token + + let sut = createSUT(imageDomainModel: image, + imageLoader: imageLoader) + + sut.loadImage() + sut.cancelImageLoad() + + XCTAssertEqual(imageLoader.events.count, 2) + + 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(cancelledToken, token) + XCTAssertEqual(sut.state, .ready) + } + + func test_givenNoAssetLoadInProgress_whenPauseIsCalled_thenNothingIsCancelled() { + let imageLoader = StubImageLoader() + + let sut = createSUT(imageLoader: imageLoader) + + sut.cancelImageLoad() + + 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) + + 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 imageLoader = StubImageLoader() + let image = ImageDomainModel.testData() + + imageLoader.tokenToReturn = LoadToken(url: image.url) + + let sut = createSUT(imageDomainModel: image, + imageLoader: imageLoader) + + sut.loadImage() + sut.cancelImageLoad() + sut.loadImage() + + XCTAssertEqual(imageLoader.events.count, 3) + + guard case .load = imageLoader.events.last else { + XCTFail("Unexpected event") + return + } + } +} + +extension ImageViewerViewModelTests { + func createSUT(imageDomainModel: ImageDomainModel = .testData(), + imageLoader: ImageLoader = StubImageLoader()) -> ImageViewerViewModel { + ImageViewerViewModel(imageDomainModel: imageDomainModel, + imageLoader: imageLoader) + } +} diff --git a/README.md b/README.md index 2d338ba..754a171 100644 --- a/README.md +++ b/README.md @@ -5,4 +5,12 @@ # 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). + +## 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.