├── .github └── workflows │ └── swift.yml ├── .gitignore ├── .ruby-version ├── BackgroundTransfer-Example.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ └── BackgroundTransfer-Example.xcscheme ├── BackgroundTransfer-Example ├── Application │ ├── AppDelegate.swift │ └── Info.plist ├── Extensions │ └── NSObject │ │ └── NSObject+Name.swift ├── Network │ ├── BackgroundDownloadService.swift │ ├── BackgroundDownloadStore.swift │ ├── ImageLoader.swift │ └── NetworkService.swift ├── Resources │ └── Images │ │ └── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ └── Contents.json │ │ ├── Contents.json │ │ └── icon-placeholder.imageset │ │ ├── Contents.json │ │ └── default-placeholder-300x300.png ├── Storyboards │ └── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard └── ViewControllers │ └── Cats │ ├── CatsViewController.swift │ ├── CatsViewModel.swift │ └── Cells │ └── CatCollectionViewCell.swift ├── BackgroundTransfer-ExampleTests ├── BackgroundTransfer_ExampleTests.swift └── Info.plist ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md └── fastlane ├── Appfile ├── Fastfile └── README.md /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Install Bundle 17 | run: bundle install 18 | - name: Run unit tests 19 | run: bundle exec fastlane run_unit_tests --verbose 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xccheckout 23 | *.xcscmblueprint 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | *.ipa 28 | *.dSYM.zip 29 | *.dSYM 30 | 31 | ## Playgrounds 32 | timeline.xctimeline 33 | playground.xcworkspace 34 | 35 | # Swift Package Manager 36 | # 37 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 38 | # Packages/ 39 | # Package.pins 40 | .build/ 41 | 42 | # CocoaPods 43 | # 44 | # We recommend against adding the Pods directory to your .gitignore. However 45 | # you should judge for yourself, the pros and cons are mentioned at: 46 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 47 | # 48 | # Pods/ 49 | 50 | # Carthage 51 | # 52 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 53 | # Carthage/Checkouts 54 | 55 | Carthage/Build 56 | 57 | # fastlane 58 | # 59 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 60 | # screenshots whenever they are needed. 61 | # For more information about the recommended setup visit: 62 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 63 | 64 | fastlane/report.xml 65 | fastlane/Preview.html 66 | fastlane/screenshots 67 | fastlane/test_output 68 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.1.0 2 | -------------------------------------------------------------------------------- /BackgroundTransfer-Example.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 54; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 3DA8112620946F5E007F6272 /* BackgroundTransfer_ExampleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DA8112520946F5E007F6272 /* BackgroundTransfer_ExampleTests.swift */; }; 11 | 3DEAF27720947086004FE44E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DEAF26D20947086004FE44E /* AppDelegate.swift */; }; 12 | 3DEAF27A20947086004FE44E /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3DEAF27220947086004FE44E /* LaunchScreen.storyboard */; }; 13 | 3DEAF27B20947086004FE44E /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3DEAF27420947086004FE44E /* Main.storyboard */; }; 14 | 3DEAF27E20948B9A004FE44E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3DEAF27D20948B9A004FE44E /* Assets.xcassets */; }; 15 | 3DEAF2AA20948C8A004FE44E /* NSObject+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DEAF29C20948C8A004FE44E /* NSObject+Name.swift */; }; 16 | 43896B7A2D6906CE00FF34F8 /* CatCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43896B782D6906CE00FF34F8 /* CatCollectionViewCell.swift */; }; 17 | 43896B7B2D6906CE00FF34F8 /* CatsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43896B792D6906CE00FF34F8 /* CatsViewController.swift */; }; 18 | 43896B7D2D6909A600FF34F8 /* CatsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43896B7C2D6909A600FF34F8 /* CatsViewModel.swift */; }; 19 | 438F85472D6E241D00AF956D /* ImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438F85442D6E241D00AF956D /* ImageLoader.swift */; }; 20 | 438F85482D6E241D00AF956D /* NetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438F85452D6E241D00AF956D /* NetworkService.swift */; }; 21 | 438F85492D6E241D00AF956D /* BackgroundDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438F85462D6E241D00AF956D /* BackgroundDownloadService.swift */; }; 22 | 43DAF0CE2D8C75D3005900E7 /* BackgroundDownloadStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DAF0CD2D8C75D3005900E7 /* BackgroundDownloadStore.swift */; }; 23 | /* End PBXBuildFile section */ 24 | 25 | /* Begin PBXContainerItemProxy section */ 26 | 3DA8112220946F5E007F6272 /* PBXContainerItemProxy */ = { 27 | isa = PBXContainerItemProxy; 28 | containerPortal = 3DA8110520946F5D007F6272 /* Project object */; 29 | proxyType = 1; 30 | remoteGlobalIDString = 3DA8110C20946F5D007F6272; 31 | remoteInfo = "BackgroundTransfer-Example"; 32 | }; 33 | /* End PBXContainerItemProxy section */ 34 | 35 | /* Begin PBXFileReference section */ 36 | 3DA8110D20946F5D007F6272 /* BackgroundTransfer-Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "BackgroundTransfer-Example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 37 | 3DA8112120946F5E007F6272 /* BackgroundTransfer-ExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "BackgroundTransfer-ExampleTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 38 | 3DA8112520946F5E007F6272 /* BackgroundTransfer_ExampleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTransfer_ExampleTests.swift; sourceTree = ""; }; 39 | 3DA8112720946F5E007F6272 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 40 | 3DEAF26D20947086004FE44E /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 41 | 3DEAF26E20947086004FE44E /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 42 | 3DEAF27320947086004FE44E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 43 | 3DEAF27520947086004FE44E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 44 | 3DEAF27D20948B9A004FE44E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 45 | 3DEAF29C20948C8A004FE44E /* NSObject+Name.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSObject+Name.swift"; sourceTree = ""; }; 46 | 43896B782D6906CE00FF34F8 /* CatCollectionViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CatCollectionViewCell.swift; sourceTree = ""; }; 47 | 43896B792D6906CE00FF34F8 /* CatsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CatsViewController.swift; sourceTree = ""; }; 48 | 43896B7C2D6909A600FF34F8 /* CatsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatsViewModel.swift; sourceTree = ""; }; 49 | 438F85442D6E241D00AF956D /* ImageLoader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageLoader.swift; sourceTree = ""; }; 50 | 438F85452D6E241D00AF956D /* NetworkService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkService.swift; sourceTree = ""; }; 51 | 438F85462D6E241D00AF956D /* BackgroundDownloadService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundDownloadService.swift; sourceTree = ""; }; 52 | 43DAF0CD2D8C75D3005900E7 /* BackgroundDownloadStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundDownloadStore.swift; sourceTree = ""; }; 53 | /* End PBXFileReference section */ 54 | 55 | /* Begin PBXFrameworksBuildPhase section */ 56 | 3DA8110A20946F5D007F6272 /* Frameworks */ = { 57 | isa = PBXFrameworksBuildPhase; 58 | buildActionMask = 2147483647; 59 | files = ( 60 | ); 61 | runOnlyForDeploymentPostprocessing = 0; 62 | }; 63 | 3DA8111E20946F5E007F6272 /* Frameworks */ = { 64 | isa = PBXFrameworksBuildPhase; 65 | buildActionMask = 2147483647; 66 | files = ( 67 | ); 68 | runOnlyForDeploymentPostprocessing = 0; 69 | }; 70 | /* End PBXFrameworksBuildPhase section */ 71 | 72 | /* Begin PBXGroup section */ 73 | 3DA8110420946F5D007F6272 = { 74 | isa = PBXGroup; 75 | children = ( 76 | 3DA8110F20946F5D007F6272 /* BackgroundTransfer-Example */, 77 | 3DA8112420946F5E007F6272 /* BackgroundTransfer-ExampleTests */, 78 | 3DA8110E20946F5D007F6272 /* Products */, 79 | ); 80 | sourceTree = ""; 81 | }; 82 | 3DA8110E20946F5D007F6272 /* Products */ = { 83 | isa = PBXGroup; 84 | children = ( 85 | 3DA8110D20946F5D007F6272 /* BackgroundTransfer-Example.app */, 86 | 3DA8112120946F5E007F6272 /* BackgroundTransfer-ExampleTests.xctest */, 87 | ); 88 | name = Products; 89 | sourceTree = ""; 90 | }; 91 | 3DA8110F20946F5D007F6272 /* BackgroundTransfer-Example */ = { 92 | isa = PBXGroup; 93 | children = ( 94 | 3DEAF26C20947086004FE44E /* Application */, 95 | 3DEAF29A20948C8A004FE44E /* Extensions */, 96 | 438F85432D6E241D00AF956D /* Network */, 97 | 3DEAF26F20947086004FE44E /* Resources */, 98 | 3DEAF27120947086004FE44E /* Storyboards */, 99 | 3DEAF26A20947086004FE44E /* ViewControllers */, 100 | ); 101 | path = "BackgroundTransfer-Example"; 102 | sourceTree = ""; 103 | }; 104 | 3DA8112420946F5E007F6272 /* BackgroundTransfer-ExampleTests */ = { 105 | isa = PBXGroup; 106 | children = ( 107 | 3DA8112520946F5E007F6272 /* BackgroundTransfer_ExampleTests.swift */, 108 | 3DA8112720946F5E007F6272 /* Info.plist */, 109 | ); 110 | path = "BackgroundTransfer-ExampleTests"; 111 | sourceTree = ""; 112 | }; 113 | 3DEAF26A20947086004FE44E /* ViewControllers */ = { 114 | isa = PBXGroup; 115 | children = ( 116 | 43896B762D6906CE00FF34F8 /* Cats */, 117 | ); 118 | path = ViewControllers; 119 | sourceTree = ""; 120 | }; 121 | 3DEAF26C20947086004FE44E /* Application */ = { 122 | isa = PBXGroup; 123 | children = ( 124 | 3DEAF26D20947086004FE44E /* AppDelegate.swift */, 125 | 3DEAF26E20947086004FE44E /* Info.plist */, 126 | ); 127 | path = Application; 128 | sourceTree = ""; 129 | }; 130 | 3DEAF26F20947086004FE44E /* Resources */ = { 131 | isa = PBXGroup; 132 | children = ( 133 | 3DEAF27C20948B9A004FE44E /* Images */, 134 | ); 135 | path = Resources; 136 | sourceTree = ""; 137 | }; 138 | 3DEAF27120947086004FE44E /* Storyboards */ = { 139 | isa = PBXGroup; 140 | children = ( 141 | 3DEAF27220947086004FE44E /* LaunchScreen.storyboard */, 142 | 3DEAF27420947086004FE44E /* Main.storyboard */, 143 | ); 144 | path = Storyboards; 145 | sourceTree = ""; 146 | }; 147 | 3DEAF27C20948B9A004FE44E /* Images */ = { 148 | isa = PBXGroup; 149 | children = ( 150 | 3DEAF27D20948B9A004FE44E /* Assets.xcassets */, 151 | ); 152 | path = Images; 153 | sourceTree = ""; 154 | }; 155 | 3DEAF29A20948C8A004FE44E /* Extensions */ = { 156 | isa = PBXGroup; 157 | children = ( 158 | 3DEAF29B20948C8A004FE44E /* NSObject */, 159 | ); 160 | path = Extensions; 161 | sourceTree = ""; 162 | }; 163 | 3DEAF29B20948C8A004FE44E /* NSObject */ = { 164 | isa = PBXGroup; 165 | children = ( 166 | 3DEAF29C20948C8A004FE44E /* NSObject+Name.swift */, 167 | ); 168 | path = NSObject; 169 | sourceTree = ""; 170 | }; 171 | 43896B762D6906CE00FF34F8 /* Cats */ = { 172 | isa = PBXGroup; 173 | children = ( 174 | 43896B772D6906CE00FF34F8 /* Cells */, 175 | 43896B792D6906CE00FF34F8 /* CatsViewController.swift */, 176 | 43896B7C2D6909A600FF34F8 /* CatsViewModel.swift */, 177 | ); 178 | path = Cats; 179 | sourceTree = ""; 180 | }; 181 | 43896B772D6906CE00FF34F8 /* Cells */ = { 182 | isa = PBXGroup; 183 | children = ( 184 | 43896B782D6906CE00FF34F8 /* CatCollectionViewCell.swift */, 185 | ); 186 | path = Cells; 187 | sourceTree = ""; 188 | }; 189 | 438F85432D6E241D00AF956D /* Network */ = { 190 | isa = PBXGroup; 191 | children = ( 192 | 438F85442D6E241D00AF956D /* ImageLoader.swift */, 193 | 438F85452D6E241D00AF956D /* NetworkService.swift */, 194 | 438F85462D6E241D00AF956D /* BackgroundDownloadService.swift */, 195 | 43DAF0CD2D8C75D3005900E7 /* BackgroundDownloadStore.swift */, 196 | ); 197 | path = Network; 198 | sourceTree = ""; 199 | }; 200 | /* End PBXGroup section */ 201 | 202 | /* Begin PBXNativeTarget section */ 203 | 3DA8110C20946F5D007F6272 /* BackgroundTransfer-Example */ = { 204 | isa = PBXNativeTarget; 205 | buildConfigurationList = 3DA8112A20946F5E007F6272 /* Build configuration list for PBXNativeTarget "BackgroundTransfer-Example" */; 206 | buildPhases = ( 207 | 3DA8110920946F5D007F6272 /* Sources */, 208 | 3DA8110A20946F5D007F6272 /* Frameworks */, 209 | 3DA8110B20946F5D007F6272 /* Resources */, 210 | ); 211 | buildRules = ( 212 | ); 213 | dependencies = ( 214 | ); 215 | name = "BackgroundTransfer-Example"; 216 | productName = "BackgroundTransfer-Example"; 217 | productReference = 3DA8110D20946F5D007F6272 /* BackgroundTransfer-Example.app */; 218 | productType = "com.apple.product-type.application"; 219 | }; 220 | 3DA8112020946F5E007F6272 /* BackgroundTransfer-ExampleTests */ = { 221 | isa = PBXNativeTarget; 222 | buildConfigurationList = 3DA8112D20946F5E007F6272 /* Build configuration list for PBXNativeTarget "BackgroundTransfer-ExampleTests" */; 223 | buildPhases = ( 224 | 3DA8111D20946F5E007F6272 /* Sources */, 225 | 3DA8111E20946F5E007F6272 /* Frameworks */, 226 | 3DA8111F20946F5E007F6272 /* Resources */, 227 | ); 228 | buildRules = ( 229 | ); 230 | dependencies = ( 231 | 3DA8112320946F5E007F6272 /* PBXTargetDependency */, 232 | ); 233 | name = "BackgroundTransfer-ExampleTests"; 234 | productName = "BackgroundTransfer-ExampleTests"; 235 | productReference = 3DA8112120946F5E007F6272 /* BackgroundTransfer-ExampleTests.xctest */; 236 | productType = "com.apple.product-type.bundle.unit-test"; 237 | }; 238 | /* End PBXNativeTarget section */ 239 | 240 | /* Begin PBXProject section */ 241 | 3DA8110520946F5D007F6272 /* Project object */ = { 242 | isa = PBXProject; 243 | attributes = { 244 | BuildIndependentTargetsInParallel = YES; 245 | LastSwiftUpdateCheck = 0930; 246 | LastUpgradeCheck = 1520; 247 | ORGANIZATIONNAME = "William Boles"; 248 | TargetAttributes = { 249 | 3DA8110C20946F5D007F6272 = { 250 | CreatedOnToolsVersion = 9.3; 251 | LastSwiftMigration = 1520; 252 | ProvisioningStyle = Automatic; 253 | SystemCapabilities = { 254 | com.apple.BackgroundModes = { 255 | enabled = 1; 256 | }; 257 | }; 258 | }; 259 | 3DA8112020946F5E007F6272 = { 260 | CreatedOnToolsVersion = 9.3; 261 | LastSwiftMigration = 1520; 262 | ProvisioningStyle = Automatic; 263 | }; 264 | }; 265 | }; 266 | buildConfigurationList = 3DA8110820946F5D007F6272 /* Build configuration list for PBXProject "BackgroundTransfer-Example" */; 267 | compatibilityVersion = "Xcode 8.0"; 268 | developmentRegion = en; 269 | hasScannedForEncodings = 0; 270 | knownRegions = ( 271 | en, 272 | Base, 273 | ); 274 | mainGroup = 3DA8110420946F5D007F6272; 275 | productRefGroup = 3DA8110E20946F5D007F6272 /* Products */; 276 | projectDirPath = ""; 277 | projectRoot = ""; 278 | targets = ( 279 | 3DA8110C20946F5D007F6272 /* BackgroundTransfer-Example */, 280 | 3DA8112020946F5E007F6272 /* BackgroundTransfer-ExampleTests */, 281 | ); 282 | }; 283 | /* End PBXProject section */ 284 | 285 | /* Begin PBXResourcesBuildPhase section */ 286 | 3DA8110B20946F5D007F6272 /* Resources */ = { 287 | isa = PBXResourcesBuildPhase; 288 | buildActionMask = 2147483647; 289 | files = ( 290 | 3DEAF27A20947086004FE44E /* LaunchScreen.storyboard in Resources */, 291 | 3DEAF27E20948B9A004FE44E /* Assets.xcassets in Resources */, 292 | 3DEAF27B20947086004FE44E /* Main.storyboard in Resources */, 293 | ); 294 | runOnlyForDeploymentPostprocessing = 0; 295 | }; 296 | 3DA8111F20946F5E007F6272 /* Resources */ = { 297 | isa = PBXResourcesBuildPhase; 298 | buildActionMask = 2147483647; 299 | files = ( 300 | ); 301 | runOnlyForDeploymentPostprocessing = 0; 302 | }; 303 | /* End PBXResourcesBuildPhase section */ 304 | 305 | /* Begin PBXSourcesBuildPhase section */ 306 | 3DA8110920946F5D007F6272 /* Sources */ = { 307 | isa = PBXSourcesBuildPhase; 308 | buildActionMask = 2147483647; 309 | files = ( 310 | 43896B7A2D6906CE00FF34F8 /* CatCollectionViewCell.swift in Sources */, 311 | 3DEAF27720947086004FE44E /* AppDelegate.swift in Sources */, 312 | 438F85472D6E241D00AF956D /* ImageLoader.swift in Sources */, 313 | 43DAF0CE2D8C75D3005900E7 /* BackgroundDownloadStore.swift in Sources */, 314 | 438F85492D6E241D00AF956D /* BackgroundDownloadService.swift in Sources */, 315 | 3DEAF2AA20948C8A004FE44E /* NSObject+Name.swift in Sources */, 316 | 43896B7B2D6906CE00FF34F8 /* CatsViewController.swift in Sources */, 317 | 43896B7D2D6909A600FF34F8 /* CatsViewModel.swift in Sources */, 318 | 438F85482D6E241D00AF956D /* NetworkService.swift in Sources */, 319 | ); 320 | runOnlyForDeploymentPostprocessing = 0; 321 | }; 322 | 3DA8111D20946F5E007F6272 /* Sources */ = { 323 | isa = PBXSourcesBuildPhase; 324 | buildActionMask = 2147483647; 325 | files = ( 326 | 3DA8112620946F5E007F6272 /* BackgroundTransfer_ExampleTests.swift in Sources */, 327 | ); 328 | runOnlyForDeploymentPostprocessing = 0; 329 | }; 330 | /* End PBXSourcesBuildPhase section */ 331 | 332 | /* Begin PBXTargetDependency section */ 333 | 3DA8112320946F5E007F6272 /* PBXTargetDependency */ = { 334 | isa = PBXTargetDependency; 335 | target = 3DA8110C20946F5D007F6272 /* BackgroundTransfer-Example */; 336 | targetProxy = 3DA8112220946F5E007F6272 /* PBXContainerItemProxy */; 337 | }; 338 | /* End PBXTargetDependency section */ 339 | 340 | /* Begin PBXVariantGroup section */ 341 | 3DEAF27220947086004FE44E /* LaunchScreen.storyboard */ = { 342 | isa = PBXVariantGroup; 343 | children = ( 344 | 3DEAF27320947086004FE44E /* Base */, 345 | ); 346 | name = LaunchScreen.storyboard; 347 | sourceTree = ""; 348 | }; 349 | 3DEAF27420947086004FE44E /* Main.storyboard */ = { 350 | isa = PBXVariantGroup; 351 | children = ( 352 | 3DEAF27520947086004FE44E /* Base */, 353 | ); 354 | name = Main.storyboard; 355 | sourceTree = ""; 356 | }; 357 | /* End PBXVariantGroup section */ 358 | 359 | /* Begin XCBuildConfiguration section */ 360 | 3DA8112820946F5E007F6272 /* Debug */ = { 361 | isa = XCBuildConfiguration; 362 | buildSettings = { 363 | ALWAYS_SEARCH_USER_PATHS = NO; 364 | CLANG_ANALYZER_NONNULL = YES; 365 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 366 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 367 | CLANG_CXX_LIBRARY = "libc++"; 368 | CLANG_ENABLE_MODULES = YES; 369 | CLANG_ENABLE_OBJC_ARC = YES; 370 | CLANG_ENABLE_OBJC_WEAK = YES; 371 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 372 | CLANG_WARN_BOOL_CONVERSION = YES; 373 | CLANG_WARN_COMMA = YES; 374 | CLANG_WARN_CONSTANT_CONVERSION = YES; 375 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 376 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 377 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 378 | CLANG_WARN_EMPTY_BODY = YES; 379 | CLANG_WARN_ENUM_CONVERSION = YES; 380 | CLANG_WARN_INFINITE_RECURSION = YES; 381 | CLANG_WARN_INT_CONVERSION = YES; 382 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 383 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 384 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 385 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 386 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 387 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 388 | CLANG_WARN_STRICT_PROTOTYPES = YES; 389 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 390 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 391 | CLANG_WARN_UNREACHABLE_CODE = YES; 392 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 393 | CODE_SIGN_IDENTITY = "iPhone Developer"; 394 | COPY_PHASE_STRIP = NO; 395 | DEBUG_INFORMATION_FORMAT = dwarf; 396 | ENABLE_STRICT_OBJC_MSGSEND = YES; 397 | ENABLE_TESTABILITY = YES; 398 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 399 | GCC_C_LANGUAGE_STANDARD = gnu11; 400 | GCC_DYNAMIC_NO_PIC = NO; 401 | GCC_NO_COMMON_BLOCKS = YES; 402 | GCC_OPTIMIZATION_LEVEL = 0; 403 | GCC_PREPROCESSOR_DEFINITIONS = ( 404 | "DEBUG=1", 405 | "$(inherited)", 406 | ); 407 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 408 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 409 | GCC_WARN_UNDECLARED_SELECTOR = YES; 410 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 411 | GCC_WARN_UNUSED_FUNCTION = YES; 412 | GCC_WARN_UNUSED_VARIABLE = YES; 413 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 414 | MTL_ENABLE_DEBUG_INFO = YES; 415 | ONLY_ACTIVE_ARCH = YES; 416 | SDKROOT = iphoneos; 417 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 418 | SWIFT_COMPILATION_MODE = singlefile; 419 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 420 | }; 421 | name = Debug; 422 | }; 423 | 3DA8112920946F5E007F6272 /* Release */ = { 424 | isa = XCBuildConfiguration; 425 | buildSettings = { 426 | ALWAYS_SEARCH_USER_PATHS = NO; 427 | CLANG_ANALYZER_NONNULL = YES; 428 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 429 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 430 | CLANG_CXX_LIBRARY = "libc++"; 431 | CLANG_ENABLE_MODULES = YES; 432 | CLANG_ENABLE_OBJC_ARC = YES; 433 | CLANG_ENABLE_OBJC_WEAK = YES; 434 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 435 | CLANG_WARN_BOOL_CONVERSION = YES; 436 | CLANG_WARN_COMMA = YES; 437 | CLANG_WARN_CONSTANT_CONVERSION = YES; 438 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 439 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 440 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 441 | CLANG_WARN_EMPTY_BODY = YES; 442 | CLANG_WARN_ENUM_CONVERSION = YES; 443 | CLANG_WARN_INFINITE_RECURSION = YES; 444 | CLANG_WARN_INT_CONVERSION = YES; 445 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 446 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 447 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 448 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 449 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 450 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 451 | CLANG_WARN_STRICT_PROTOTYPES = YES; 452 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 453 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 454 | CLANG_WARN_UNREACHABLE_CODE = YES; 455 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 456 | CODE_SIGN_IDENTITY = "iPhone Developer"; 457 | COPY_PHASE_STRIP = NO; 458 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 459 | ENABLE_NS_ASSERTIONS = NO; 460 | ENABLE_STRICT_OBJC_MSGSEND = YES; 461 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 462 | GCC_C_LANGUAGE_STANDARD = gnu11; 463 | GCC_NO_COMMON_BLOCKS = YES; 464 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 465 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 466 | GCC_WARN_UNDECLARED_SELECTOR = YES; 467 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 468 | GCC_WARN_UNUSED_FUNCTION = YES; 469 | GCC_WARN_UNUSED_VARIABLE = YES; 470 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 471 | MTL_ENABLE_DEBUG_INFO = NO; 472 | SDKROOT = iphoneos; 473 | SWIFT_COMPILATION_MODE = wholemodule; 474 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 475 | VALIDATE_PRODUCT = YES; 476 | }; 477 | name = Release; 478 | }; 479 | 3DA8112B20946F5E007F6272 /* Debug */ = { 480 | isa = XCBuildConfiguration; 481 | buildSettings = { 482 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 483 | CODE_SIGN_STYLE = Automatic; 484 | INFOPLIST_FILE = "BackgroundTransfer-Example/Application/Info.plist"; 485 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 486 | LD_RUNPATH_SEARCH_PATHS = ( 487 | "$(inherited)", 488 | "@executable_path/Frameworks", 489 | ); 490 | PRODUCT_BUNDLE_IDENTIFIER = "com.williamboles.BackgroundTransfer-Example"; 491 | PRODUCT_NAME = "$(TARGET_NAME)"; 492 | SWIFT_VERSION = 5.0; 493 | TARGETED_DEVICE_FAMILY = "1,2"; 494 | }; 495 | name = Debug; 496 | }; 497 | 3DA8112C20946F5E007F6272 /* Release */ = { 498 | isa = XCBuildConfiguration; 499 | buildSettings = { 500 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 501 | CODE_SIGN_STYLE = Automatic; 502 | INFOPLIST_FILE = "BackgroundTransfer-Example/Application/Info.plist"; 503 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 504 | LD_RUNPATH_SEARCH_PATHS = ( 505 | "$(inherited)", 506 | "@executable_path/Frameworks", 507 | ); 508 | PRODUCT_BUNDLE_IDENTIFIER = "com.williamboles.BackgroundTransfer-Example"; 509 | PRODUCT_NAME = "$(TARGET_NAME)"; 510 | SWIFT_VERSION = 5.0; 511 | TARGETED_DEVICE_FAMILY = "1,2"; 512 | }; 513 | name = Release; 514 | }; 515 | 3DA8112E20946F5E007F6272 /* Debug */ = { 516 | isa = XCBuildConfiguration; 517 | buildSettings = { 518 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 519 | CODE_SIGN_STYLE = Automatic; 520 | INFOPLIST_FILE = "BackgroundTransfer-ExampleTests/Info.plist"; 521 | LD_RUNPATH_SEARCH_PATHS = ( 522 | "$(inherited)", 523 | "@executable_path/Frameworks", 524 | "@loader_path/Frameworks", 525 | ); 526 | PRODUCT_BUNDLE_IDENTIFIER = "com.williamboles.BackgroundTransfer-ExampleTests"; 527 | PRODUCT_NAME = "$(TARGET_NAME)"; 528 | SWIFT_VERSION = 5.0; 529 | TARGETED_DEVICE_FAMILY = "1,2"; 530 | }; 531 | name = Debug; 532 | }; 533 | 3DA8112F20946F5E007F6272 /* Release */ = { 534 | isa = XCBuildConfiguration; 535 | buildSettings = { 536 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 537 | CODE_SIGN_STYLE = Automatic; 538 | INFOPLIST_FILE = "BackgroundTransfer-ExampleTests/Info.plist"; 539 | LD_RUNPATH_SEARCH_PATHS = ( 540 | "$(inherited)", 541 | "@executable_path/Frameworks", 542 | "@loader_path/Frameworks", 543 | ); 544 | PRODUCT_BUNDLE_IDENTIFIER = "com.williamboles.BackgroundTransfer-ExampleTests"; 545 | PRODUCT_NAME = "$(TARGET_NAME)"; 546 | SWIFT_VERSION = 5.0; 547 | TARGETED_DEVICE_FAMILY = "1,2"; 548 | }; 549 | name = Release; 550 | }; 551 | /* End XCBuildConfiguration section */ 552 | 553 | /* Begin XCConfigurationList section */ 554 | 3DA8110820946F5D007F6272 /* Build configuration list for PBXProject "BackgroundTransfer-Example" */ = { 555 | isa = XCConfigurationList; 556 | buildConfigurations = ( 557 | 3DA8112820946F5E007F6272 /* Debug */, 558 | 3DA8112920946F5E007F6272 /* Release */, 559 | ); 560 | defaultConfigurationIsVisible = 0; 561 | defaultConfigurationName = Release; 562 | }; 563 | 3DA8112A20946F5E007F6272 /* Build configuration list for PBXNativeTarget "BackgroundTransfer-Example" */ = { 564 | isa = XCConfigurationList; 565 | buildConfigurations = ( 566 | 3DA8112B20946F5E007F6272 /* Debug */, 567 | 3DA8112C20946F5E007F6272 /* Release */, 568 | ); 569 | defaultConfigurationIsVisible = 0; 570 | defaultConfigurationName = Release; 571 | }; 572 | 3DA8112D20946F5E007F6272 /* Build configuration list for PBXNativeTarget "BackgroundTransfer-ExampleTests" */ = { 573 | isa = XCConfigurationList; 574 | buildConfigurations = ( 575 | 3DA8112E20946F5E007F6272 /* Debug */, 576 | 3DA8112F20946F5E007F6272 /* Release */, 577 | ); 578 | defaultConfigurationIsVisible = 0; 579 | defaultConfigurationName = Release; 580 | }; 581 | /* End XCConfigurationList section */ 582 | }; 583 | rootObject = 3DA8110520946F5D007F6272 /* Project object */; 584 | } 585 | -------------------------------------------------------------------------------- /BackgroundTransfer-Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /BackgroundTransfer-Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /BackgroundTransfer-Example.xcodeproj/xcshareddata/xcschemes/BackgroundTransfer-Example.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 44 | 45 | 46 | 47 | 49 | 55 | 56 | 57 | 58 | 59 | 69 | 71 | 77 | 78 | 79 | 80 | 86 | 88 | 94 | 95 | 96 | 97 | 99 | 100 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /BackgroundTransfer-Example/Application/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // BackgroundTransfer-Example 4 | // 5 | // Created by William Boles on 28/04/2018. 6 | // Copyright © 2018 William Boles. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Foundation 11 | import os 12 | 13 | @UIApplicationMain 14 | class AppDelegate: UIResponder, UIApplicationDelegate { 15 | 16 | var window: UIWindow? 17 | 18 | // MARK: - Lifecycle 19 | 20 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 21 | return true 22 | } 23 | 24 | func applicationWillResignActive(_ application: UIApplication) { 25 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. 26 | // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. 27 | } 28 | 29 | func applicationDidEnterBackground(_ application: UIApplication) { 30 | os_log(.info, "Downloaded content will be saved to: %{public}@", FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].absoluteString) 31 | 32 | //Exit app to test restoring app from a terminated state. Comment out to test restoring app from a suspended state. 33 | DispatchQueue.main.asyncAfter(deadline: .now()) { 34 | os_log(.info, "Simulating app termination by exit(0)") 35 | 36 | exit(0) 37 | } 38 | } 39 | 40 | func applicationWillEnterForeground(_ application: UIApplication) { 41 | // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. 42 | } 43 | 44 | func applicationDidBecomeActive(_ application: UIApplication) { 45 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. 46 | } 47 | 48 | func applicationWillTerminate(_ application: UIApplication) { 49 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. 50 | } 51 | 52 | // MARK: - Background 53 | 54 | func application(_ application: UIApplication, 55 | handleEventsForBackgroundURLSession identifier: String, 56 | completionHandler: @escaping () -> Void) { 57 | BackgroundDownloadService().backgroundCompletionHandler = completionHandler 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /BackgroundTransfer-Example/Application/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIBackgroundModes 24 | 25 | fetch 26 | processing 27 | 28 | UILaunchStoryboardName 29 | LaunchScreen 30 | UIMainStoryboardFile 31 | Main 32 | UIRequiredDeviceCapabilities 33 | 34 | armv7 35 | 36 | UISupportedInterfaceOrientations 37 | 38 | UIInterfaceOrientationPortrait 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | UISupportedInterfaceOrientations~ipad 43 | 44 | UIInterfaceOrientationPortrait 45 | UIInterfaceOrientationPortraitUpsideDown 46 | UIInterfaceOrientationLandscapeLeft 47 | UIInterfaceOrientationLandscapeRight 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /BackgroundTransfer-Example/Extensions/NSObject/NSObject+Name.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSObject+Name.swift 3 | // BackgroundTransfer-Example 4 | // 5 | // Created by William Boles on 28/04/2018. 6 | // Copyright © 2018 William Boles. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension NSObject { 12 | 13 | var className: String { 14 | return String(describing: type(of: self)) 15 | } 16 | 17 | class var className: String { 18 | return String(describing: self) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /BackgroundTransfer-Example/Network/BackgroundDownloadService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackgroundDownloadService.swift 3 | // BackgroundTransfer-Example 4 | // 5 | // Created by William Boles on 02/05/2018. 6 | // Copyright © 2018 William Boles. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import os 11 | 12 | enum BackgroundDownloadError: Error { 13 | case missingInstructionsError 14 | case fileSystemError(_ underlyingError: Error) 15 | case clientError(_ underlyingError: Error) 16 | case serverError(_ underlyingResponse: URLResponse?) 17 | } 18 | 19 | class BackgroundDownloadService: NSObject { 20 | var backgroundCompletionHandler: (() -> Void)? 21 | 22 | private var session: URLSession! 23 | private let store = BackgroundDownloadStore() 24 | 25 | // MARK: - Singleton 26 | 27 | static let shared = BackgroundDownloadService() 28 | 29 | // MARK: - Init 30 | 31 | override init() { 32 | super.init() 33 | 34 | configureSession() 35 | } 36 | 37 | private func configureSession() { 38 | let configuration = URLSessionConfiguration.background(withIdentifier: "com.williamboles.background.download.session") 39 | configuration.sessionSendsLaunchEvents = true 40 | let session = URLSession(configuration: configuration, 41 | delegate: self, 42 | delegateQueue: nil) 43 | self.session = session 44 | } 45 | 46 | // MARK: - Download 47 | 48 | func download(from remoteURL: URL, 49 | saveDownloadTo localURL: URL, 50 | completionHandler: @escaping BackgroundDownloadCompletion) { 51 | os_log(.info, "Scheduling to download: %{public}@", remoteURL.absoluteString) 52 | 53 | store.storeMetadata(from: remoteURL, 54 | to: localURL, 55 | completionHandler: completionHandler) 56 | 57 | let task = session.downloadTask(with: remoteURL) 58 | task.earliestBeginDate = Date().addingTimeInterval(2) // Remove this in production, the delay was added for demonstration purposes only 59 | task.resume() 60 | } 61 | } 62 | 63 | // MARK: - URLSessionDownloadDelegate 64 | 65 | extension BackgroundDownloadService: URLSessionDownloadDelegate { 66 | func urlSession(_ session: URLSession, 67 | downloadTask: URLSessionDownloadTask, 68 | didFinishDownloadingTo location: URL) { 69 | guard let fromURL = downloadTask.originalRequest?.url else { 70 | os_log(.error, "Unexpected nil URL") 71 | // Unable to call the closure here as we use fromURL as the key to retrieve the closure 72 | return 73 | } 74 | 75 | let fromURLAsString = fromURL.absoluteString 76 | 77 | os_log(.info, "Download request completed for: %{public}@", fromURLAsString) 78 | 79 | let tempLocation = FileManager.default.temporaryDirectory.appendingPathComponent(location.lastPathComponent) 80 | try? FileManager.default.moveItem(at: location, 81 | to: tempLocation) 82 | 83 | store.retrieveMetadata(for: fromURL) { [weak self] toURL, completionHandler in 84 | defer { 85 | self?.store.removeMetadata(for: fromURL) 86 | } 87 | 88 | guard let toURL else { 89 | os_log(.error, "Unable to find existing download item for: %{public}@", fromURLAsString) 90 | completionHandler?(.failure(BackgroundDownloadError.missingInstructionsError)) 91 | return 92 | } 93 | 94 | guard let response = downloadTask.response as? HTTPURLResponse, 95 | response.statusCode == 200 else { 96 | os_log(.error, "Unexpected response for: %{public}@", fromURLAsString) 97 | completionHandler?(.failure(BackgroundDownloadError.serverError(downloadTask.response))) 98 | return 99 | } 100 | 101 | os_log(.info, "Download successful for: %{public}@", fromURLAsString) 102 | 103 | do { 104 | try FileManager.default.moveItem(at: tempLocation, 105 | to: toURL) 106 | 107 | completionHandler?(.success(toURL)) 108 | } catch { 109 | completionHandler?(.failure(BackgroundDownloadError.fileSystemError(error))) 110 | } 111 | } 112 | } 113 | 114 | func urlSession(_ session: URLSession, 115 | task: URLSessionTask, 116 | didCompleteWithError error: Error?) { 117 | guard let error = error else { 118 | return 119 | } 120 | 121 | guard let fromURL = task.originalRequest?.url else { 122 | os_log(.error, "Unexpected nil URL") 123 | return 124 | } 125 | 126 | let fromURLAsString = fromURL.absoluteString 127 | 128 | os_log(.info, "Download failed for: %{public}@", fromURLAsString) 129 | 130 | store.retrieveMetadata(for: fromURL) { [weak self] _, completionHandler in 131 | completionHandler?(.failure(BackgroundDownloadError.clientError(error))) 132 | 133 | self?.store.removeMetadata(for: fromURL) 134 | } 135 | } 136 | 137 | func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { 138 | DispatchQueue.main.async { 139 | // needs to be called on the main queue 140 | self.backgroundCompletionHandler?() 141 | self.backgroundCompletionHandler = nil 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /BackgroundTransfer-Example/Network/BackgroundDownloadStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackgroundDownloadStore.swift 3 | // BackgroundTransfer-Example 4 | // 5 | // Created by William Boles on 02/05/2018. 6 | // Copyright © 2018 William Boles. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | typealias BackgroundDownloadCompletion = (_ result: Result) -> () 12 | 13 | class BackgroundDownloadStore { 14 | private var inMemoryStore = [String: BackgroundDownloadCompletion]() 15 | private let persistentStore = UserDefaults.standard 16 | 17 | private let queue = DispatchQueue(label: "com.williamboles.background.download.service", 18 | qos: .userInitiated, 19 | attributes: .concurrent) 20 | 21 | // MARK: - Store 22 | 23 | func storeMetadata(from fromURL: URL, 24 | to toURL: URL, 25 | completionHandler: @escaping BackgroundDownloadCompletion) { 26 | queue.async(flags: .barrier) { [weak self] in 27 | self?.inMemoryStore[fromURL.absoluteString] = completionHandler 28 | self?.persistentStore.set(toURL, forKey: fromURL.absoluteString) 29 | } 30 | } 31 | 32 | // MARK: - Retrieve 33 | 34 | func retrieveMetadata(for forURL: URL, 35 | completionHandler: @escaping ((URL?, BackgroundDownloadCompletion?) -> ())) { 36 | return queue.async { [weak self] in 37 | let key = forURL.absoluteString 38 | 39 | let toURL = self?.persistentStore.url(forKey: key) 40 | let metaDataCompletionHandler = self?.inMemoryStore[key] 41 | 42 | completionHandler(toURL, metaDataCompletionHandler) 43 | } 44 | } 45 | 46 | // MARK: - Remove 47 | 48 | func removeMetadata(for forURL: URL) { 49 | queue.async(flags: .barrier) { [weak self] in 50 | let key = forURL.absoluteString 51 | 52 | self?.inMemoryStore[key] = nil 53 | self?.persistentStore.removeObject(forKey: key) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /BackgroundTransfer-Example/Network/ImageLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageLoader.swift 3 | // BackgroundTransfer-Example 4 | // 5 | // Created by William Boles on 21/02/2025. 6 | // Copyright © 2025 William Boles. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | enum ImageLoaderError: Error { 13 | case invalidImageData 14 | } 15 | 16 | class ImageLoader { 17 | private let backgroundDownloadService: BackgroundDownloadService 18 | private let fileManager: FileManager 19 | private let documentsDirectoryURL: URL 20 | private let imageLoadingQueue = DispatchQueue(label: "com.williamboles.image.loader", 21 | qos: .userInitiated) 22 | 23 | // MARK: - Init 24 | 25 | init(backgroundDownloadService: BackgroundDownloadService = .shared, 26 | fileManager: FileManager = .default) { 27 | self.backgroundDownloadService = backgroundDownloadService 28 | self.fileManager = fileManager 29 | self.documentsDirectoryURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] 30 | } 31 | 32 | // MARK: - Load 33 | 34 | func loadImage(name: String, 35 | url: URL, 36 | completionHandler: @escaping ((_ result: Result) -> ())) { 37 | imageLoadingQueue.async { [weak self] in 38 | guard let self = self else { return } 39 | 40 | let localImageURL = self.documentsDirectoryURL.appendingPathComponent(name) 41 | 42 | if let imageData = loadLocalImage(from: localImageURL) { 43 | reportOutcome(imageData: imageData, 44 | completionHandler: completionHandler) 45 | } else { 46 | loadRemoteImage(from: url, 47 | to: localImageURL) { [weak self] imageData in 48 | self?.reportOutcome(imageData: imageData, 49 | completionHandler: completionHandler) 50 | } 51 | } 52 | } 53 | } 54 | 55 | private func reportOutcome(imageData: Data?, 56 | completionHandler: @escaping ((_ result: Result) -> ())) { 57 | DispatchQueue.main.async { 58 | guard let imageData, let image = UIImage(data: imageData) else { 59 | completionHandler(.failure(ImageLoaderError.invalidImageData)) 60 | return 61 | } 62 | completionHandler(.success(image)) 63 | } 64 | } 65 | 66 | private func loadLocalImage(from fromURL: URL) -> Data? { 67 | try? Data(contentsOf: fromURL) 68 | } 69 | 70 | private func loadRemoteImage(from fromURL: URL, 71 | to toURL: URL, 72 | completionHandler: @escaping ((Data?) -> ())) { 73 | backgroundDownloadService.download(from: fromURL, 74 | saveDownloadTo: toURL) { _ in 75 | let imageData = try? Data(contentsOf: toURL) 76 | completionHandler(imageData) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /BackgroundTransfer-Example/Network/NetworkService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkService.swift 3 | // BackgroundTransfer-Example 4 | // 5 | // Created by William Boles on 21/02/2025. 6 | // Copyright © 2025 William Boles. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import os 11 | 12 | struct Cat: Decodable, Equatable { 13 | let id: String 14 | let url: URL 15 | } 16 | 17 | enum NetworkServiceError: Error { 18 | case networkError 19 | case unexpectedStatusCode 20 | case decodingErrror 21 | } 22 | 23 | class NetworkService { 24 | // MARK: - Cats 25 | 26 | func retrieveCats(completionHandler: @escaping ((Result<[Cat], Error>) -> ())) { 27 | let APIKey = "" 28 | 29 | assert(!APIKey.isEmpty, "Replace this empty string with your API key from: https://thecatapi.com/") 30 | 31 | let limitQueryItem = URLQueryItem(name: "limit", value: "50") 32 | let sizeQueryItem = URLQueryItem(name: "size", value: "thumb") 33 | 34 | let queryItems = [limitQueryItem, sizeQueryItem] 35 | 36 | var components = URLComponents() 37 | components.scheme = "https" 38 | components.host = "api.thecatapi.com" 39 | components.path = "/v1/images/search" 40 | components.queryItems = queryItems 41 | 42 | var urlRequest = URLRequest(url: components.url!) 43 | urlRequest.httpMethod = "GET" 44 | urlRequest.addValue(APIKey, forHTTPHeaderField: "x-api-key") 45 | 46 | os_log(.error, "Retrieving cats...") 47 | 48 | let task = URLSession.shared.dataTask(with: urlRequest) { data, response, error in 49 | guard let data = data, let response = response else { 50 | completionHandler(.failure(NetworkServiceError.networkError)) 51 | return 52 | } 53 | 54 | guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { 55 | completionHandler(.failure(NetworkServiceError.unexpectedStatusCode)) 56 | return 57 | } 58 | 59 | guard let cats = try? JSONDecoder().decode([Cat].self, from: data) else { 60 | completionHandler(.failure(NetworkServiceError.decodingErrror)) 61 | return 62 | } 63 | 64 | os_log(.error, "Cats successfully retrieved!") 65 | completionHandler(.success(cats)) 66 | } 67 | task.resume() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /BackgroundTransfer-Example/Resources/Images/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /BackgroundTransfer-Example/Resources/Images/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /BackgroundTransfer-Example/Resources/Images/Assets.xcassets/icon-placeholder.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "idiom" : "universal", 13 | "filename" : "default-placeholder-300x300.png", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /BackgroundTransfer-Example/Resources/Images/Assets.xcassets/icon-placeholder.imageset/default-placeholder-300x300.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wibosco/BackgroundTransfer-Example/b96e58d44ba9033a7142faf01b7eb9ce40ef091d/BackgroundTransfer-Example/Resources/Images/Assets.xcassets/icon-placeholder.imageset/default-placeholder-300x300.png -------------------------------------------------------------------------------- /BackgroundTransfer-Example/Storyboards/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /BackgroundTransfer-Example/Storyboards/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /BackgroundTransfer-Example/ViewControllers/Cats/CatsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatsViewController.swift 3 | // BackgroundTransfer-Example 4 | // 5 | // Created by William Boles on 28/04/2018. 6 | // Copyright © 2018 William Boles. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import os 11 | 12 | class CatsViewController: UIViewController { 13 | @IBOutlet weak var collectionView: UICollectionView! 14 | @IBOutlet weak var loadingActivityIndicatorView: UIActivityIndicatorView! 15 | 16 | private let viewModel = CatsViewModel() 17 | 18 | // MARK: - Lifecycle 19 | 20 | override func viewDidLoad() { 21 | super.viewDidLoad() 22 | 23 | loadingActivityIndicatorView.startAnimating() 24 | viewModel.retrieveCats { 25 | self.loadingActivityIndicatorView.stopAnimating() 26 | self.collectionView.reloadData() 27 | } 28 | } 29 | } 30 | 31 | extension CatsViewController: UICollectionViewDataSource { 32 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 33 | return viewModel.numberOfCats() 34 | } 35 | 36 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 37 | guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CatCollectionViewCell.className, for: indexPath) as? CatCollectionViewCell else { 38 | fatalError("Expected cell of type: \(CatCollectionViewCell.className)") 39 | } 40 | 41 | viewModel.loadCatImage(at: indexPath) { (image, imageIndexPath) in 42 | guard let cellCurrentIndexPath = collectionView.indexPath(for: cell), 43 | cellCurrentIndexPath == imageIndexPath else { 44 | return 45 | } 46 | 47 | cell.catImageView.image = image 48 | } 49 | 50 | return cell 51 | } 52 | } 53 | 54 | extension CatsViewController: UICollectionViewDelegateFlowLayout { 55 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 56 | let cellWidth = (view.frame.size.width - 12.0)/3.0 57 | return CGSize(width: cellWidth, height: cellWidth) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /BackgroundTransfer-Example/ViewControllers/Cats/CatsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatsViewModel.swift 3 | // BackgroundTransfer-Example 4 | // 5 | // Created by William Boles on 21/02/2025. 6 | // Copyright © 2025 William Boles. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import os 11 | import UIKit 12 | 13 | class CatsViewModel { 14 | private var cats: [Cat] = [] 15 | 16 | private let networkService = NetworkService() 17 | private let imageLoader = ImageLoader() 18 | 19 | // MARK: - Retrieval 20 | 21 | func retrieveCats(completion: @escaping (() -> ())) { 22 | networkService.retrieveCats { [weak self] result in 23 | DispatchQueue.main.async { 24 | switch result { 25 | case let .success(cats): 26 | self?.cats = cats 27 | 28 | completion() 29 | case let .failure(error): 30 | // TODO: Handle 31 | os_log(.error, "Error when retrieving json: %{public}@", error.localizedDescription) 32 | } 33 | } 34 | } 35 | } 36 | 37 | func numberOfCats() -> Int { 38 | return cats.count 39 | } 40 | 41 | // MARK: - Image 42 | 43 | func loadCatImage(at indexPath: IndexPath, 44 | completion: @escaping (((UIImage, IndexPath)) -> ())) { 45 | let cat = cats[indexPath.row] 46 | 47 | imageLoader.loadImage(name: cat.id, 48 | url: cat.url) { result in 49 | DispatchQueue.main.async { 50 | switch result { 51 | case let .success(image): 52 | completion((image, indexPath)) 53 | case let .failure(error): 54 | // TODO: Handle 55 | os_log(.error, "Error when loading image: %{public}@", error.localizedDescription) 56 | } 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /BackgroundTransfer-Example/ViewControllers/Cats/Cells/CatCollectionViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatCollectionViewCell.swift 3 | // BackgroundTransfer-Example 4 | // 5 | // Created by William Boles on 28/04/2018. 6 | // Copyright © 2018 William Boles. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import os 11 | 12 | class CatCollectionViewCell: UICollectionViewCell { 13 | @IBOutlet weak var catImageView: UIImageView! 14 | 15 | // MARK: - Reuse 16 | 17 | override func prepareForReuse() { 18 | super.prepareForReuse() 19 | 20 | catImageView.image = UIImage(named: "icon-placeholder") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /BackgroundTransfer-ExampleTests/BackgroundTransfer_ExampleTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackgroundTransfer_ExampleTests.swift 3 | // BackgroundTransfer-ExampleTests 4 | // 5 | // Created by William Boles on 28/04/2018. 6 | // Copyright © 2018 William Boles. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | @testable import BackgroundTransfer_Example 12 | 13 | class BackgroundTransfer_ExampleTests: XCTestCase { 14 | 15 | override func setUp() { 16 | super.setUp() 17 | // Put setup code here. This method is called before the invocation of each test method in the class. 18 | } 19 | 20 | override func tearDown() { 21 | // Put teardown code here. This method is called after the invocation of each test method in the class. 22 | super.tearDown() 23 | } 24 | 25 | func testExample() { 26 | // This is an example of a functional test case. 27 | // Use XCTAssert and related functions to verify your tests produce the correct results. 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /BackgroundTransfer-ExampleTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane", "2.226.0" 4 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.7) 5 | base64 6 | nkf 7 | rexml 8 | addressable (2.8.7) 9 | public_suffix (>= 2.0.2, < 7.0) 10 | artifactory (3.0.17) 11 | atomos (0.1.3) 12 | aws-eventstream (1.3.1) 13 | aws-partitions (1.1058.0) 14 | aws-sdk-core (3.219.0) 15 | aws-eventstream (~> 1, >= 1.3.0) 16 | aws-partitions (~> 1, >= 1.992.0) 17 | aws-sigv4 (~> 1.9) 18 | base64 19 | jmespath (~> 1, >= 1.6.1) 20 | aws-sdk-kms (1.99.0) 21 | aws-sdk-core (~> 3, >= 3.216.0) 22 | aws-sigv4 (~> 1.5) 23 | aws-sdk-s3 (1.182.0) 24 | aws-sdk-core (~> 3, >= 3.216.0) 25 | aws-sdk-kms (~> 1) 26 | aws-sigv4 (~> 1.5) 27 | aws-sigv4 (1.11.0) 28 | aws-eventstream (~> 1, >= 1.0.2) 29 | babosa (1.0.4) 30 | base64 (0.2.0) 31 | claide (1.1.0) 32 | colored (1.2) 33 | colored2 (3.1.2) 34 | commander (4.6.0) 35 | highline (~> 2.0.0) 36 | declarative (0.0.20) 37 | digest-crc (0.7.0) 38 | rake (>= 12.0.0, < 14.0.0) 39 | domain_name (0.6.20240107) 40 | dotenv (2.8.1) 41 | emoji_regex (3.2.3) 42 | excon (0.112.0) 43 | faraday (1.10.4) 44 | faraday-em_http (~> 1.0) 45 | faraday-em_synchrony (~> 1.0) 46 | faraday-excon (~> 1.1) 47 | faraday-httpclient (~> 1.0) 48 | faraday-multipart (~> 1.0) 49 | faraday-net_http (~> 1.0) 50 | faraday-net_http_persistent (~> 1.0) 51 | faraday-patron (~> 1.0) 52 | faraday-rack (~> 1.0) 53 | faraday-retry (~> 1.0) 54 | ruby2_keywords (>= 0.0.4) 55 | faraday-cookie_jar (0.0.7) 56 | faraday (>= 0.8.0) 57 | http-cookie (~> 1.0.0) 58 | faraday-em_http (1.0.0) 59 | faraday-em_synchrony (1.0.0) 60 | faraday-excon (1.1.0) 61 | faraday-httpclient (1.0.1) 62 | faraday-multipart (1.1.0) 63 | multipart-post (~> 2.0) 64 | faraday-net_http (1.0.2) 65 | faraday-net_http_persistent (1.2.0) 66 | faraday-patron (1.0.0) 67 | faraday-rack (1.0.0) 68 | faraday-retry (1.0.3) 69 | faraday_middleware (1.2.1) 70 | faraday (~> 1.0) 71 | fastimage (2.4.0) 72 | fastlane (2.226.0) 73 | CFPropertyList (>= 2.3, < 4.0.0) 74 | addressable (>= 2.8, < 3.0.0) 75 | artifactory (~> 3.0) 76 | aws-sdk-s3 (~> 1.0) 77 | babosa (>= 1.0.3, < 2.0.0) 78 | bundler (>= 1.12.0, < 3.0.0) 79 | colored (~> 1.2) 80 | commander (~> 4.6) 81 | dotenv (>= 2.1.1, < 3.0.0) 82 | emoji_regex (>= 0.1, < 4.0) 83 | excon (>= 0.71.0, < 1.0.0) 84 | faraday (~> 1.0) 85 | faraday-cookie_jar (~> 0.0.6) 86 | faraday_middleware (~> 1.0) 87 | fastimage (>= 2.1.0, < 3.0.0) 88 | fastlane-sirp (>= 1.0.0) 89 | gh_inspector (>= 1.1.2, < 2.0.0) 90 | google-apis-androidpublisher_v3 (~> 0.3) 91 | google-apis-playcustomapp_v1 (~> 0.1) 92 | google-cloud-env (>= 1.6.0, < 2.0.0) 93 | google-cloud-storage (~> 1.31) 94 | highline (~> 2.0) 95 | http-cookie (~> 1.0.5) 96 | json (< 3.0.0) 97 | jwt (>= 2.1.0, < 3) 98 | mini_magick (>= 4.9.4, < 5.0.0) 99 | multipart-post (>= 2.0.0, < 3.0.0) 100 | naturally (~> 2.2) 101 | optparse (>= 0.1.1, < 1.0.0) 102 | plist (>= 3.1.0, < 4.0.0) 103 | rubyzip (>= 2.0.0, < 3.0.0) 104 | security (= 0.1.5) 105 | simctl (~> 1.6.3) 106 | terminal-notifier (>= 2.0.0, < 3.0.0) 107 | terminal-table (~> 3) 108 | tty-screen (>= 0.6.3, < 1.0.0) 109 | tty-spinner (>= 0.8.0, < 1.0.0) 110 | word_wrap (~> 1.0.0) 111 | xcodeproj (>= 1.13.0, < 2.0.0) 112 | xcpretty (~> 0.4.0) 113 | xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) 114 | fastlane-sirp (1.0.0) 115 | sysrandom (~> 1.0) 116 | gh_inspector (1.1.3) 117 | google-apis-androidpublisher_v3 (0.54.0) 118 | google-apis-core (>= 0.11.0, < 2.a) 119 | google-apis-core (0.11.3) 120 | addressable (~> 2.5, >= 2.5.1) 121 | googleauth (>= 0.16.2, < 2.a) 122 | httpclient (>= 2.8.1, < 3.a) 123 | mini_mime (~> 1.0) 124 | representable (~> 3.0) 125 | retriable (>= 2.0, < 4.a) 126 | rexml 127 | google-apis-iamcredentials_v1 (0.17.0) 128 | google-apis-core (>= 0.11.0, < 2.a) 129 | google-apis-playcustomapp_v1 (0.13.0) 130 | google-apis-core (>= 0.11.0, < 2.a) 131 | google-apis-storage_v1 (0.31.0) 132 | google-apis-core (>= 0.11.0, < 2.a) 133 | google-cloud-core (1.7.1) 134 | google-cloud-env (>= 1.0, < 3.a) 135 | google-cloud-errors (~> 1.0) 136 | google-cloud-env (1.6.0) 137 | faraday (>= 0.17.3, < 3.0) 138 | google-cloud-errors (1.4.0) 139 | google-cloud-storage (1.47.0) 140 | addressable (~> 2.8) 141 | digest-crc (~> 0.4) 142 | google-apis-iamcredentials_v1 (~> 0.1) 143 | google-apis-storage_v1 (~> 0.31.0) 144 | google-cloud-core (~> 1.6) 145 | googleauth (>= 0.16.2, < 2.a) 146 | mini_mime (~> 1.0) 147 | googleauth (1.8.1) 148 | faraday (>= 0.17.3, < 3.a) 149 | jwt (>= 1.4, < 3.0) 150 | multi_json (~> 1.11) 151 | os (>= 0.9, < 2.0) 152 | signet (>= 0.16, < 2.a) 153 | highline (2.0.3) 154 | http-cookie (1.0.8) 155 | domain_name (~> 0.5) 156 | httpclient (2.9.0) 157 | mutex_m 158 | jmespath (1.6.2) 159 | json (2.10.1) 160 | jwt (2.10.1) 161 | base64 162 | mini_magick (4.13.2) 163 | mini_mime (1.1.5) 164 | multi_json (1.15.0) 165 | multipart-post (2.4.1) 166 | mutex_m (0.3.0) 167 | nanaimo (0.4.0) 168 | naturally (2.2.1) 169 | nkf (0.2.0) 170 | optparse (0.6.0) 171 | os (1.1.4) 172 | plist (3.7.2) 173 | public_suffix (6.0.1) 174 | rake (13.2.1) 175 | representable (3.2.0) 176 | declarative (< 0.1.0) 177 | trailblazer-option (>= 0.1.1, < 0.2.0) 178 | uber (< 0.2.0) 179 | retriable (3.1.2) 180 | rexml (3.4.1) 181 | rouge (3.28.0) 182 | ruby2_keywords (0.0.5) 183 | rubyzip (2.4.1) 184 | security (0.1.5) 185 | signet (0.19.0) 186 | addressable (~> 2.8) 187 | faraday (>= 0.17.5, < 3.a) 188 | jwt (>= 1.5, < 3.0) 189 | multi_json (~> 1.10) 190 | simctl (1.6.10) 191 | CFPropertyList 192 | naturally 193 | sysrandom (1.0.5) 194 | terminal-notifier (2.0.0) 195 | terminal-table (3.0.2) 196 | unicode-display_width (>= 1.1.1, < 3) 197 | trailblazer-option (0.1.2) 198 | tty-cursor (0.7.1) 199 | tty-screen (0.8.2) 200 | tty-spinner (0.9.3) 201 | tty-cursor (~> 0.7) 202 | uber (0.1.0) 203 | unicode-display_width (2.6.0) 204 | word_wrap (1.0.0) 205 | xcodeproj (1.27.0) 206 | CFPropertyList (>= 2.3.3, < 4.0) 207 | atomos (~> 0.1.3) 208 | claide (>= 1.0.2, < 2.0) 209 | colored2 (~> 3.1) 210 | nanaimo (~> 0.4.0) 211 | rexml (>= 3.3.6, < 4.0) 212 | xcpretty (0.4.0) 213 | rouge (~> 3.28.0) 214 | xcpretty-travis-formatter (1.0.1) 215 | xcpretty (~> 0.2, >= 0.0.7) 216 | 217 | PLATFORMS 218 | ruby 219 | x86_64-darwin-22 220 | 221 | DEPENDENCIES 222 | fastlane (= 2.226.0) 223 | 224 | BUNDLED WITH 225 | 2.6.5 226 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 William Boles 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build](https://github.com/wibosco/BackgroundTransfer-Example/actions/workflows/swift.yml/badge.svg)](https://github.com/wibosco/BackgroundTransfer-Example/actions/workflows/swift.yml) 2 | Swift 3 | [![License](http://img.shields.io/badge/License-MIT-green.svg?style=flat)](https://github.com/wibosco/BackgroundTransfer-Example/blob/main/LICENSE) 4 | 5 | # BackgroundTransfer-Example 6 | An example project looking at how to implement background transfers on iOS as shown in this article - https://williamboles.com/keeping-things-going-when-the-user-leaves-with-urlsession-and-background-transfers/ 7 | 8 | In order to run this project, you will need to register with [TheCatAPI](https://thecatapi.com/) to get a `x-api-key` token to access TheCatAPI's API (which the project uses to get its example content). Once you have your `x-api-key`, add it to the project as the value of the `APIKey` property in the `NetworkService` class and the project should now run. If you have any trouble getting the project to run, please create an issue. 9 | -------------------------------------------------------------------------------- /fastlane/Appfile: -------------------------------------------------------------------------------- 1 | # app_identifier("[[APP_IDENTIFIER]]") # The bundle identifier of your app 2 | # apple_id("[[APPLE_ID]]") # Your Apple email address 3 | 4 | 5 | # For more information about the Appfile, see: 6 | # https://docs.fastlane.tools/advanced/#appfile 7 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | # This file contains the fastlane.tools configuration 2 | # You can find the documentation at https://docs.fastlane.tools 3 | # 4 | # For a list of all available actions, check out 5 | # 6 | # https://docs.fastlane.tools/actions 7 | # 8 | # For a list of all available plugins, check out 9 | # 10 | # https://docs.fastlane.tools/plugins/available-plugins 11 | # 12 | 13 | # Uncomment the line if you want fastlane to automatically update itself 14 | # update_fastlane 15 | 16 | default_platform(:ios) 17 | 18 | platform :ios do 19 | lane :run_unit_tests do 20 | scan( 21 | scheme: 'BackgroundTransfer-Example', 22 | skip_build: true 23 | ) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ---- 3 | 4 | # Installation 5 | 6 | Make sure you have the latest version of the Xcode command line tools installed: 7 | 8 | ```sh 9 | xcode-select --install 10 | ``` 11 | 12 | For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) 13 | 14 | # Available Actions 15 | 16 | ## iOS 17 | 18 | ### ios run_unit_tests 19 | 20 | ```sh 21 | [bundle exec] fastlane ios run_unit_tests 22 | ``` 23 | 24 | 25 | 26 | ---- 27 | 28 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. 29 | 30 | More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). 31 | 32 | The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 33 | --------------------------------------------------------------------------------