diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index b99bd59a..b252f3e6 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -23,5 +23,5 @@ * iOS Version: -* raywenderlich.com App Version: +* kodeco.com App Version: * Device: diff --git a/.github/workflows/appstore-upload.yml b/.github/workflows/appstore-upload.yml index ac847668..53400581 100644 --- a/.github/workflows/appstore-upload.yml +++ b/.github/workflows/appstore-upload.yml @@ -7,13 +7,11 @@ on: jobs: build: - - runs-on: macos-11 - + runs-on: macos-12 steps: - uses: actions/checkout@v1 - - name: Switch to Xcode 13.0 - run: sudo xcode-select -s /Applications/Xcode_13.0.app + - name: Switch to Xcode 13.3.1 + run: sudo xcode-select -s /Applications/Xcode_13.3.1.app - name: Update fastlane run: | cd Emitron @@ -37,6 +35,7 @@ jobs: APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 5 FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 5 + ITMSTRANSPORTER_FORCE_ITMS_PACKAGE_UPLOAD: true run: | cd Emitron bundle exec fastlane -v diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 02c8179e..8f51a51a 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -6,11 +6,11 @@ on: jobs: build: - runs-on: macos-11 + runs-on: macos-12 steps: - uses: actions/checkout@v1 - - name: Switch to Xcode 13.0 - run: sudo xcode-select -s /Applications/Xcode_13.0.app + - name: Switch to Xcode 13.3.1 + run: sudo xcode-select -s /Applications/Xcode_13.3.1.app - name: Update fastlane run: | cd Emitron diff --git a/.github/workflows/swiftlint.yml b/.github/workflows/swiftlint.yml index 934ea9d2..275cf23f 100644 --- a/.github/workflows/swiftlint.yml +++ b/.github/workflows/swiftlint.yml @@ -9,7 +9,7 @@ on: jobs: SwiftLint: - runs-on: macos-11 + runs-on: macos-12 steps: - uses: actions/checkout@v1 diff --git a/.github/workflows/testflight-beta.yml b/.github/workflows/testflight-beta.yml index 7ef0eecd..80e44b9d 100644 --- a/.github/workflows/testflight-beta.yml +++ b/.github/workflows/testflight-beta.yml @@ -7,13 +7,11 @@ on: jobs: build: - - runs-on: macos-11 - + runs-on: macos-12 steps: - uses: actions/checkout@v1 - - name: Switch to Xcode 13.0 - run: sudo xcode-select -s /Applications/Xcode_13.0.app + - name: Switch to Xcode 13.3.1 + run: sudo xcode-select -s /Applications/Xcode_13.3.1.app - name: Update fastlane run: | cd Emitron @@ -37,6 +35,7 @@ jobs: APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 5 FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 5 + ITMSTRANSPORTER_FORCE_ITMS_PACKAGE_UPLOAD: true run: | cd Emitron bundle exec fastlane -v diff --git a/.github/workflows/testflight-release.yml b/.github/workflows/testflight-release.yml index c1dd38bb..fbdc18fd 100644 --- a/.github/workflows/testflight-release.yml +++ b/.github/workflows/testflight-release.yml @@ -7,13 +7,11 @@ on: jobs: build: - - runs-on: macos-11 - + runs-on: macos-12 steps: - uses: actions/checkout@v1 - - name: Switch to Xcode 13.0 - run: sudo xcode-select -s /Applications/Xcode_13.0.app + - name: Switch to Xcode 13.3.1 + run: sudo xcode-select -s /Applications/Xcode_13.3.1.app - name: Update fastlane run: | cd Emitron @@ -37,6 +35,7 @@ jobs: APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 5 FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 5 + ITMSTRANSPORTER_FORCE_ITMS_PACKAGE_UPLOAD: true run: | cd Emitron bundle exec fastlane -v diff --git a/.gitignore b/.gitignore index ebf28711..09e9b110 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,7 @@ playground.xcworkspace # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. # Packages/ # Package.pins -# Package.resolved +Package.resolved .build/ # CocoaPods diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c4398dc..33bbe7a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to emitron -👋 Welcome! Thanks for expressing an interest in contributing to the raywenderlich.com app. +👋 Welcome! Thanks for expressing an interest in contributing to the kodeco.com app. ## Testing @@ -12,7 +12,7 @@ If you are fixing a single GitHub issue in particular, please add a test named ` ## User Account -In order to use __emitron__, you must currently be a video subscriber to raywenderlich.com. If you are keen to contribute to the project, but are unable to use features of the app due to a lack of a raywenderlich.com subscription, please contact emitron@razeware.com noting which feature or area of the app you are interested in contributing to, and we should be able to set you up with appropriate access. +In order to use __emitron__, you must currently be a video subscriber to kodeco.com. If you are keen to contribute to the project, but are unable to use features of the app due to a lack of a kodeco.com subscription, please contact emitron@razeware.com noting which feature or area of the app you are interested in contributing to, and we should be able to set you up with appropriate access. ## Access Tokens @@ -22,7 +22,7 @@ However, it does not contain the token that will allow access to downloads. If y ## API Documentation -__emitron__ interfaces with the raywenderlich.com API to retrieve data. You can find API documentation here: +__emitron__ interfaces with the kodeco.com API to retrieve data. You can find API documentation here: https://raywenderlich.docs.apiary.io diff --git a/Emitron/.ruby-version b/Emitron/.ruby-version deleted file mode 100644 index 2c9b4ef4..00000000 --- a/Emitron/.ruby-version +++ /dev/null @@ -1 +0,0 @@ -2.7.3 diff --git a/Emitron/.swiftlint.yml b/Emitron/.swiftlint.yml index 4d1ed600..af3a92ac 100644 --- a/Emitron/.swiftlint.yml +++ b/Emitron/.swiftlint.yml @@ -43,12 +43,10 @@ opt_in_rules: - required_enum_case - single_test_class - sorted_first_last - - strict_fileprivate - strong_iboutlet - switch_case_on_newline - toggle_bool - unneeded_parentheses_in_closure_argument - - unowned_variable_capture - untyped_error_in_catch - unused_import - vertical_parameter_alignment_on_call @@ -57,11 +55,13 @@ opt_in_rules: - yoda_condition disabled_rules: # rule identifiers to exclude from running + - closure_parameter_position - force_cast - line_length - multiple_closures_with_trailing_closure - todo - trailing_whitespace + - xctfail_message excluded: # paths to ignore during linting. overridden by `included` - Carthage diff --git a/Emitron/Emitron.xcodeproj/project.pbxproj b/Emitron/Emitron.xcodeproj/project.pbxproj index cffd0a6b..cd1b5eef 100644 --- a/Emitron/Emitron.xcodeproj/project.pbxproj +++ b/Emitron/Emitron.xcodeproj/project.pbxproj @@ -133,7 +133,6 @@ 22A265B02396CDBE000DD276 /* User+Mocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22A265AF2396CDBE000DD276 /* User+Mocks.swift */; }; 22A265B22396CE82000DD276 /* Permissions+Mocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22A265B12396CE82000DD276 /* Permissions+Mocks.swift */; }; 22A36F50236F76B30064A406 /* DownloadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22A36F4F236F76B30064A406 /* DownloadProcessor.swift */; }; - 22A36F53236F7C890064A406 /* DownloadProcessorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22A36F52236F7C890064A406 /* DownloadProcessorTest.swift */; }; 22A9CD5B2385A1D3001EAFBF /* DownloadQueueManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22A9CD5A2385A1D3001EAFBF /* DownloadQueueManager.swift */; }; 22ADBB452400731D003E8346 /* VideoOverlayButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22ADBB442400731D003E8346 /* VideoOverlayButtonView.swift */; }; 22B8265923AF109800D4BA23 /* EntityAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22B8265823AF109800D4BA23 /* EntityAdapter.swift */; }; @@ -203,7 +202,6 @@ 22C4EAE423DE0958001A3FDA /* PersistenceStore+Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C4EAE323DE0958001A3FDA /* PersistenceStore+Keychain.swift */; }; 22C4EAED23DEF910001A3FDA /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C4EAEC23DEF910001A3FDA /* SettingsManager.swift */; }; 22C4EAEF23DEF91D001A3FDA /* SettingsKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C4EAEE23DEF91D001A3FDA /* SettingsKey.swift */; }; - 22C4EAF123DEFA76001A3FDA /* EmitronSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C4EAF023DEFA76001A3FDA /* EmitronSettings.swift */; }; 22C640F623F604E700CBFDE5 /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C640F523F604E700CBFDE5 /* View+Extensions.swift */; }; 22C640FA23F609B600CBFDE5 /* SearchFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C640F923F609B600CBFDE5 /* SearchFieldView.swift */; }; 22C640FC23F75CDD00CBFDE5 /* TabViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C640FB23F75CDD00CBFDE5 /* TabViewModel.swift */; }; @@ -243,9 +241,11 @@ 22F2C45F23EF79F9007ED4A1 /* ContentSubscriptionPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22F2C45E23EF79F9007ED4A1 /* ContentSubscriptionPlan.swift */; }; 22F2C46123EF7A27007ED4A1 /* ContentSubscriptionPlan+Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22F2C46023EF7A27007ED4A1 /* ContentSubscriptionPlan+Request.swift */; }; 22FDB2EE23CAC7E6001F883E /* ChildContentListingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22FDB2ED23CAC7E6001F883E /* ChildContentListingView.swift */; }; + 491E522F27F3A3CA004F80E6 /* FileManager+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491E522E27F3A3CA004F80E6 /* FileManager+Extensions.swift */; }; 492E632627A6B96900CD1F19 /* Binding+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 492E632527A6B96900CD1F19 /* Binding+Extensions.swift */; }; 493DA0A127266049006ED195 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 493DA0A027266049006ED195 /* GRDB */; }; 494A79A82465C8C90097E8F4 /* RefreshableTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 494A79A72465C8C90097E8F4 /* RefreshableTestCase.swift */; }; + 495E2B1A27F4FE8C003EEE86 /* Optional+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 495E2B1927F4FE8C003EEE86 /* Optional+Extensions.swift */; }; 49971FEA27B297DA00FBCCEA /* TabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49971FE927B297DA00FBCCEA /* TabView.swift */; }; 8B283DEF23169A1F001F1B17 /* ProgressBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B283DEE23169A1E001F1B17 /* ProgressBarView.swift */; }; 8B7E96DD2357A65F0083DA38 /* ProTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B7E96DC2357A65F0083DA38 /* ProTag.swift */; }; @@ -287,7 +287,6 @@ B6D4529822CAB3F600BFB812 /* DateFormatter+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D4529722CAB3F600BFB812 /* DateFormatter+Extensions.swift */; }; B6D4529C22CAB67900BFB812 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D4529B22CAB67900BFB812 /* Date+Extensions.swift */; }; B6D7DC2F22C79743006DD325 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D7DC2E22C79743006DD325 /* AppDelegate.swift */; }; - B6D7DC3122C79743006DD325 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D7DC3022C79743006DD325 /* SceneDelegate.swift */; }; B6D7DC3522C79745006DD325 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B6D7DC3422C79745006DD325 /* Assets.xcassets */; }; B6D7DC3822C79745006DD325 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B6D7DC3722C79745006DD325 /* Preview Assets.xcassets */; }; B6D7DC3B22C79745006DD325 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B6D7DC3922C79745006DD325 /* LaunchScreen.storyboard */; }; @@ -473,7 +472,6 @@ 22A265AF2396CDBE000DD276 /* User+Mocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "User+Mocks.swift"; sourceTree = ""; }; 22A265B12396CE82000DD276 /* Permissions+Mocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Permissions+Mocks.swift"; sourceTree = ""; }; 22A36F4F236F76B30064A406 /* DownloadProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadProcessor.swift; sourceTree = ""; }; - 22A36F52236F7C890064A406 /* DownloadProcessorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadProcessorTest.swift; sourceTree = ""; }; 22A9CD5A2385A1D3001EAFBF /* DownloadQueueManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadQueueManager.swift; sourceTree = ""; }; 22ADBB442400731D003E8346 /* VideoOverlayButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoOverlayButtonView.swift; sourceTree = ""; }; 22B8265823AF109800D4BA23 /* EntityAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityAdapter.swift; sourceTree = ""; }; @@ -540,7 +538,6 @@ 22C4EAE323DE0958001A3FDA /* PersistenceStore+Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistenceStore+Keychain.swift"; sourceTree = ""; }; 22C4EAEC23DEF910001A3FDA /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = ""; }; 22C4EAEE23DEF91D001A3FDA /* SettingsKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsKey.swift; sourceTree = ""; }; - 22C4EAF023DEFA76001A3FDA /* EmitronSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmitronSettings.swift; sourceTree = ""; }; 22C640F523F604E700CBFDE5 /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; 22C640F923F609B600CBFDE5 /* SearchFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFieldView.swift; sourceTree = ""; }; 22C640FB23F75CDD00CBFDE5 /* TabViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabViewModel.swift; sourceTree = ""; }; @@ -583,8 +580,10 @@ 22F2C45E23EF79F9007ED4A1 /* ContentSubscriptionPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentSubscriptionPlan.swift; sourceTree = ""; }; 22F2C46023EF7A27007ED4A1 /* ContentSubscriptionPlan+Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentSubscriptionPlan+Request.swift"; sourceTree = ""; }; 22FDB2ED23CAC7E6001F883E /* ChildContentListingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChildContentListingView.swift; sourceTree = ""; }; + 491E522E27F3A3CA004F80E6 /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = ""; }; 492E632527A6B96900CD1F19 /* Binding+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Binding+Extensions.swift"; sourceTree = ""; }; 494A79A72465C8C90097E8F4 /* RefreshableTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableTestCase.swift; sourceTree = ""; }; + 495E2B1927F4FE8C003EEE86 /* Optional+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extensions.swift"; sourceTree = ""; }; 49971FE927B297DA00FBCCEA /* TabView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabView.swift; sourceTree = ""; }; 8B283DEE23169A1E001F1B17 /* ProgressBarView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgressBarView.swift; sourceTree = ""; }; 8B7E96DC2357A65F0083DA38 /* ProTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProTag.swift; sourceTree = ""; }; @@ -631,7 +630,6 @@ B6D4529B22CAB67900BFB812 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; B6D7DC2B22C79743006DD325 /* raywenderlich.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = raywenderlich.app; sourceTree = BUILT_PRODUCTS_DIR; }; B6D7DC2E22C79743006DD325 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - B6D7DC3022C79743006DD325 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; B6D7DC3422C79745006DD325 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; B6D7DC3722C79745006DD325 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; B6D7DC3A22C79745006DD325 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; @@ -1064,7 +1062,6 @@ isa = PBXGroup; children = ( 2288EF0C23741B9700514043 /* DownloadServiceTest.swift */, - 22A36F52236F7C890064A406 /* DownloadProcessorTest.swift */, 22D382BB2387D9E200FBBEF7 /* DownloadQueueManagerTest.swift */, ); path = Downloads; @@ -1145,7 +1142,6 @@ children = ( 22C4EAEC23DEF910001A3FDA /* SettingsManager.swift */, 22C4EAEE23DEF91D001A3FDA /* SettingsKey.swift */, - 22C4EAF023DEFA76001A3FDA /* EmitronSettings.swift */, 229935CF23FE8C6F00D3D16A /* SettingsSelectable.swift */, 2278AE4224099EBD00855221 /* IconManager.swift */, ); @@ -1420,6 +1416,8 @@ 22BE654E23F1F66E00717369 /* Comparable+Clamped.swift */, 22C640F523F604E700CBFDE5 /* View+Extensions.swift */, 2278AE4F240A74C400855221 /* UIApplication+DismissKeyboard.swift */, + 491E522E27F3A3CA004F80E6 /* FileManager+Extensions.swift */, + 495E2B1927F4FE8C003EEE86 /* Optional+Extensions.swift */, ); path = Extensions; sourceTree = ""; @@ -1519,7 +1517,6 @@ isa = PBXGroup; children = ( 22C3F154242795A1002812CB /* PortraitHostingController.swift */, - B6D7DC3022C79743006DD325 /* SceneDelegate.swift */, B62B9A7C22DF764900122CE8 /* App Root */, B6FC15AA22CB52430078CEDB /* Downloads */, 2213193D23E4E83300F15816 /* Empty States */, @@ -1950,7 +1947,6 @@ 222EEF7923CB379D00B025A4 /* ContentPersistableState+Mocks.swift in Sources */, 2237AAB223C336FB008E9976 /* PermissionAdapterTest.swift in Sources */, 2237AAA223C3368B008E9976 /* AttachmentAdapterTest.swift in Sources */, - 22A36F53236F7C890064A406 /* DownloadProcessorTest.swift in Sources */, 2237AAA423C33697008E9976 /* BookmarkAdapterTest.swift in Sources */, 2288EF162374299500514043 /* DownloadTest.swift in Sources */, 22A265B02396CDBE000DD276 /* User+Mocks.swift in Sources */, @@ -2022,7 +2018,6 @@ B6DF2F9122CA00820081A3A3 /* Request.swift in Sources */, B62B9A8022DF76A500122CE8 /* MainView.swift in Sources */, B66778AC2305D2D4003EEBAB /* MainButtonView.swift in Sources */, - B6D7DC3122C79743006DD325 /* SceneDelegate.swift in Sources */, 22C640FF23F805F300CBFDE5 /* PagerView.swift in Sources */, 223D77E123B84C00005BE95D /* CompletedRepository.swift in Sources */, B6DF2FC822CA862C0081A3A3 /* SingleSignOnResponse.swift in Sources */, @@ -2078,10 +2073,10 @@ 22B8265D23AF37FE00D4BA23 /* ProgressionAdapter.swift in Sources */, 222C0AB023DF554E00D65EBD /* SettingsOption.swift in Sources */, 49971FEA27B297DA00FBCCEA /* TabView.swift in Sources */, + 491E522F27F3A3CA004F80E6 /* FileManager+Extensions.swift in Sources */, 2278AE50240A74C400855221 /* UIApplication+DismissKeyboard.swift in Sources */, B6C0F0DD22D5FA1C00012839 /* ContentDetailModel+Extensions.swift in Sources */, 22C0513A23A4FBB0004D1223 /* ContentCategory+Persistence.swift in Sources */, - 22C4EAF123DEFA76001A3FDA /* EmitronSettings.swift in Sources */, 223D77A923B07842005BE95D /* PermissionAdapter.swift in Sources */, B6DF2FC622CA862C0081A3A3 /* SingleSignOnRequest.swift in Sources */, 22F2C45223EF2525007ED4A1 /* SortFilter.swift in Sources */, @@ -2174,6 +2169,7 @@ 222EEF7223CAE04D00B025A4 /* DownloadIcon.swift in Sources */, 223D77D123B0DD3A005BE95D /* Repository.swift in Sources */, 22C6410623F893F400CBFDE5 /* SpinningCircleView.swift in Sources */, + 495E2B1A27F4FE8C003EEE86 /* Optional+Extensions.swift in Sources */, 22C4EADF23DC4338001A3FDA /* SyncRequest+WatchStat.swift in Sources */, B6DF2FC122CA861C0081A3A3 /* Data+Hex.swift in Sources */, 22C914C423BD8D6400A05E00 /* BookmarksService+ContentServiceAdapter.swift in Sources */, @@ -2353,7 +2349,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -2375,11 +2371,12 @@ DEVELOPMENT_TEAM = KFCNEC27GU; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = "Emitron/Support Files/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.9; + MARKETING_VERSION = 1.0.10; PRODUCT_BUNDLE_IDENTIFIER = "com.razeware.emitron.ios$(BUNDLE_ID_SUFFIX)"; PRODUCT_MODULE_NAME = Emitron; PRODUCT_NAME = raywenderlich; @@ -2501,7 +2498,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -2557,7 +2554,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -2577,14 +2574,15 @@ CURRENT_PROJECT_VERSION = UNKNOWN; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "Emitron/Preview\\ Content"; - DEVELOPMENT_TEAM = KFCNEC27GU; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = "Emitron/Support Files/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.9; + MARKETING_VERSION = 1.0.10; PRODUCT_BUNDLE_IDENTIFIER = "com.razeware.emitron.ios$(BUNDLE_ID_SUFFIX)"; PRODUCT_MODULE_NAME = Emitron; PRODUCT_NAME = raywenderlich; @@ -2606,11 +2604,12 @@ DEVELOPMENT_TEAM = KFCNEC27GU; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = "Emitron/Support Files/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.9; + MARKETING_VERSION = 1.0.10; PRODUCT_BUNDLE_IDENTIFIER = "com.razeware.emitron.ios$(BUNDLE_ID_SUFFIX)"; PRODUCT_MODULE_NAME = Emitron; PRODUCT_NAME = raywenderlich; diff --git a/Emitron/Emitron.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Emitron/Emitron.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 4ac68732..00000000 --- a/Emitron/Emitron.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,52 +0,0 @@ -{ - "object": { - "pins": [ - { - "package": "CombineExpectations", - "repositoryURL": "https://github.com/groue/CombineExpectations.git", - "state": { - "branch": null, - "revision": "04d4e4b21c9e8361925f03f64a7ecda89ef9974f", - "version": "0.10.0" - } - }, - { - "package": "GRDB", - "repositoryURL": "https://github.com/groue/GRDB.swift.git", - "state": { - "branch": null, - "revision": "dfca044433050bb3e297761bf827e01ef376f5d9", - "version": "5.18.0" - } - }, - { - "package": "KeychainSwift", - "repositoryURL": "https://github.com/evgenyneu/keychain-swift", - "state": { - "branch": null, - "revision": "d108a1fa6189e661f91560548ef48651ed8d93b9", - "version": "20.0.0" - } - }, - { - "package": "Kingfisher", - "repositoryURL": "https://github.com/onevcat/Kingfisher", - "state": { - "branch": null, - "revision": "0c02c46cfdc0656ce74fd0963a75e5000a0b7f23", - "version": "7.1.2" - } - }, - { - "package": "SwiftyJSON", - "repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON", - "state": { - "branch": null, - "revision": "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07", - "version": "5.0.1" - } - } - ] - }, - "version": 1 -} diff --git a/Emitron/Emitron/App.swift b/Emitron/Emitron/App.swift index 8a6e2e41..e87d41f5 100644 --- a/Emitron/Emitron/App.swift +++ b/Emitron/Emitron/App.swift @@ -102,32 +102,47 @@ extension App: SwiftUI.App { // MARK: - internal extension App { + // Initialise the database static var objects: Objects { - // Initialise the database // swiftlint:disable:next force_try let databaseURL = try! FileManager.default .url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) .appendingPathComponent("emitron.sqlite") - // swiftlint:disable:next force_try - let databasePool = try! EmitronDatabase.openDatabase(atPath: databaseURL.path) - let persistenceStore = PersistenceStore(db: databasePool) - let guardpost = Guardpost(baseURL: "https://accounts.raywenderlich.com", - urlScheme: "com.razeware.emitron", - ssoSecret: Configuration.ssoSecret, - persistenceStore: persistenceStore) + let persistenceStore = PersistenceStore( + // swiftlint:disable:next force_try + db: try! EmitronDatabase.openDatabase(atPath: databaseURL.path) + ) + let guardpost = Guardpost( + baseURL: "https://accounts.kodeco.com", + urlScheme: "com.razeware.emitron", + ssoSecret: Configuration.ssoSecret, + persistenceStore: persistenceStore + ) let sessionController = SessionController(guardpost: guardpost) - let settingsManager = SettingsManager(userDefaults: .standard, userModelController: sessionController) - let downloadService = DownloadService(persistenceStore: persistenceStore, userModelController: sessionController, settingsManager: settingsManager) + let settingsManager = SettingsManager( + userDefaults: .standard, + userModelController: sessionController + ) + let downloadService = DownloadService( + persistenceStore: persistenceStore, + userModelController: sessionController, + settingsManager: settingsManager + ) let messageBus = MessageBus() - let dataManager = DataManager(sessionController: sessionController, persistenceStore: persistenceStore, downloadService: downloadService, messageBus: messageBus, settingsManager: settingsManager) - return Objects( + return ( persistenceStore: persistenceStore, guardpost: guardpost, sessionController: sessionController, settingsManager: settingsManager, downloadService: downloadService, - dataManager: dataManager, + dataManager: .init( + sessionController: sessionController, + persistenceStore: persistenceStore, + downloadService: downloadService, + messageBus: messageBus, + settingsManager: settingsManager + ), messageBus: messageBus ) } @@ -137,22 +152,24 @@ extension App { private extension App { mutating func startServices() { // guardpost - guardpost = Guardpost(baseURL: "https://accounts.raywenderlich.com", - urlScheme: "com.razeware.emitron://", - ssoSecret: Configuration.ssoSecret, - persistenceStore: persistenceStore) + guardpost = .init( + baseURL: "https://accounts.kodeco.com", + urlScheme: "com.razeware.emitron://", + ssoSecret: Configuration.ssoSecret, + persistenceStore: persistenceStore + ) // session controller sessionController = SessionController(guardpost: guardpost) // settings - settingsManager = SettingsManager( + settingsManager = .init( userDefaults: .standard, userModelController: sessionController ) // download service - downloadService = DownloadService( + downloadService = .init( persistenceStore: persistenceStore, userModelController: sessionController, settingsManager: settingsManager @@ -160,7 +177,7 @@ private extension App { appDelegate.downloadService = downloadService // data manager - dataManager = DataManager( + dataManager = .init( sessionController: sessionController, persistenceStore: persistenceStore, downloadService: downloadService, diff --git a/Emitron/Emitron/AppDelegate.swift b/Emitron/Emitron/AppDelegate.swift index e85016c5..4737ae7f 100644 --- a/Emitron/Emitron/AppDelegate.swift +++ b/Emitron/Emitron/AppDelegate.swift @@ -30,22 +30,31 @@ import UIKit import AVFoundation import GRDB -class AppDelegate: UIResponder, UIApplicationDelegate { - +final class AppDelegate: UIResponder, UIApplicationDelegate { var downloadService: DownloadService? - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { - let audioSession = AVAudioSession.sharedInstance() + func application( + _: UIApplication, + didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { do { - try audioSession.setCategory(AVAudioSession.Category.playback) + try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback) } catch { print("Setting category to AVAudioSessionCategoryPlayback failed.") } return true } + // For dealing with downloading of videos in the background - func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) { - assert(identifier == DownloadProcessor.sessionIdentifier, "Unknown Background URLSession. Unable to handle these events.") + func application( + _: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void + ) { + assert( + identifier == DownloadProcessor.sessionIdentifier, + "Unknown Background URLSession. Unable to handle these events." + ) downloadService?.backgroundSessionCompletionHandler = completionHandler } diff --git a/Emitron/Emitron/Assets.xcassets/Colours/Button/primaryButtonBackground.colorset/Contents.json b/Emitron/Emitron/Assets.xcassets/Colours/Button/primaryButtonBackground.colorset/Contents.json index a5364501..8820dbcc 100644 --- a/Emitron/Emitron/Assets.xcassets/Colours/Button/primaryButtonBackground.colorset/Contents.json +++ b/Emitron/Emitron/Assets.xcassets/Colours/Button/primaryButtonBackground.colorset/Contents.json @@ -1,20 +1,20 @@ { - "info" : { - "version" : 1, - "author" : "xcode" - }, "colors" : [ { - "idiom" : "universal", "color" : { "color-space" : "srgb", "components" : { - "red" : "0.082", "alpha" : "1.000", - "blue" : "0.263", - "green" : "0.518" + "blue" : "43", + "green" : "102", + "red" : "236" } - } + }, + "idiom" : "universal" } - ] -} \ No newline at end of file + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emitron/Emitron/Assets.xcassets/Colours/Checkmark/checkmarkBackground.colorset/Contents.json b/Emitron/Emitron/Assets.xcassets/Colours/Checkmark/checkmarkBackground.colorset/Contents.json index a5364501..8820dbcc 100644 --- a/Emitron/Emitron/Assets.xcassets/Colours/Checkmark/checkmarkBackground.colorset/Contents.json +++ b/Emitron/Emitron/Assets.xcassets/Colours/Checkmark/checkmarkBackground.colorset/Contents.json @@ -1,20 +1,20 @@ { - "info" : { - "version" : 1, - "author" : "xcode" - }, "colors" : [ { - "idiom" : "universal", "color" : { "color-space" : "srgb", "components" : { - "red" : "0.082", "alpha" : "1.000", - "blue" : "0.263", - "green" : "0.518" + "blue" : "43", + "green" : "102", + "red" : "236" } - } + }, + "idiom" : "universal" } - ] -} \ No newline at end of file + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emitron/Emitron/Assets.xcassets/Colours/Download Button/downloadButtonDownloaded.colorset/Contents.json b/Emitron/Emitron/Assets.xcassets/Colours/Download Button/downloadButtonDownloaded.colorset/Contents.json index a5364501..8820dbcc 100644 --- a/Emitron/Emitron/Assets.xcassets/Colours/Download Button/downloadButtonDownloaded.colorset/Contents.json +++ b/Emitron/Emitron/Assets.xcassets/Colours/Download Button/downloadButtonDownloaded.colorset/Contents.json @@ -1,20 +1,20 @@ { - "info" : { - "version" : 1, - "author" : "xcode" - }, "colors" : [ { - "idiom" : "universal", "color" : { "color-space" : "srgb", "components" : { - "red" : "0.082", "alpha" : "1.000", - "blue" : "0.263", - "green" : "0.518" + "blue" : "43", + "green" : "102", + "red" : "236" } - } + }, + "idiom" : "universal" } - ] -} \ No newline at end of file + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emitron/Emitron/Assets.xcassets/Colours/Download Button/downloadButtonDownloadingForeground.colorset/Contents.json b/Emitron/Emitron/Assets.xcassets/Colours/Download Button/downloadButtonDownloadingForeground.colorset/Contents.json index a5364501..8820dbcc 100644 --- a/Emitron/Emitron/Assets.xcassets/Colours/Download Button/downloadButtonDownloadingForeground.colorset/Contents.json +++ b/Emitron/Emitron/Assets.xcassets/Colours/Download Button/downloadButtonDownloadingForeground.colorset/Contents.json @@ -1,20 +1,20 @@ { - "info" : { - "version" : 1, - "author" : "xcode" - }, "colors" : [ { - "idiom" : "universal", "color" : { "color-space" : "srgb", "components" : { - "red" : "0.082", "alpha" : "1.000", - "blue" : "0.263", - "green" : "0.518" + "blue" : "43", + "green" : "102", + "red" : "236" } - } + }, + "idiom" : "universal" } - ] -} \ No newline at end of file + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emitron/Emitron/Assets.xcassets/Colours/Download Button/downloadButtonWarning.colorset/Contents.json b/Emitron/Emitron/Assets.xcassets/Colours/Download Button/downloadButtonWarning.colorset/Contents.json index 4bc685cf..f5bea9bf 100644 --- a/Emitron/Emitron/Assets.xcassets/Colours/Download Button/downloadButtonWarning.colorset/Contents.json +++ b/Emitron/Emitron/Assets.xcassets/Colours/Download Button/downloadButtonWarning.colorset/Contents.json @@ -1,20 +1,20 @@ { - "info" : { - "version" : 1, - "author" : "xcode" - }, "colors" : [ { - "idiom" : "universal", "color" : { "color-space" : "srgb", "components" : { - "red" : "0.992", "alpha" : "1.000", - "blue" : "0.004", - "green" : "0.455" + "blue" : "1", + "green" : "228", + "red" : "253" } - } + }, + "idiom" : "universal" } - ] -} \ No newline at end of file + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emitron/Emitron/Assets.xcassets/Colours/Icons/inactiveIcon.colorset/Contents.json b/Emitron/Emitron/Assets.xcassets/Colours/Icons/inactiveIcon.colorset/Contents.json index a5364501..8820dbcc 100644 --- a/Emitron/Emitron/Assets.xcassets/Colours/Icons/inactiveIcon.colorset/Contents.json +++ b/Emitron/Emitron/Assets.xcassets/Colours/Icons/inactiveIcon.colorset/Contents.json @@ -1,20 +1,20 @@ { - "info" : { - "version" : 1, - "author" : "xcode" - }, "colors" : [ { - "idiom" : "universal", "color" : { "color-space" : "srgb", "components" : { - "red" : "0.082", "alpha" : "1.000", - "blue" : "0.263", - "green" : "0.518" + "blue" : "43", + "green" : "102", + "red" : "236" } - } + }, + "idiom" : "universal" } - ] -} \ No newline at end of file + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emitron/Emitron/Assets.xcassets/Colours/Snackbar/success.colorset/Contents.json b/Emitron/Emitron/Assets.xcassets/Colours/Snackbar/success.colorset/Contents.json index 179564be..8820dbcc 100644 --- a/Emitron/Emitron/Assets.xcassets/Colours/Snackbar/success.colorset/Contents.json +++ b/Emitron/Emitron/Assets.xcassets/Colours/Snackbar/success.colorset/Contents.json @@ -1,20 +1,20 @@ { - "info" : { - "version" : 1, - "author" : "xcode" - }, "colors" : [ { - "idiom" : "universal", "color" : { "color-space" : "srgb", "components" : { - "red" : "21", "alpha" : "1.000", - "blue" : "67", - "green" : "132" + "blue" : "43", + "green" : "102", + "red" : "236" } - } + }, + "idiom" : "universal" } - ] -} \ No newline at end of file + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emitron/Emitron/Assets.xcassets/Colours/Snackbar/warning.colorset/Contents.json b/Emitron/Emitron/Assets.xcassets/Colours/Snackbar/warning.colorset/Contents.json index ed25a040..f5bea9bf 100644 --- a/Emitron/Emitron/Assets.xcassets/Colours/Snackbar/warning.colorset/Contents.json +++ b/Emitron/Emitron/Assets.xcassets/Colours/Snackbar/warning.colorset/Contents.json @@ -1,20 +1,20 @@ { - "info" : { - "version" : 1, - "author" : "xcode" - }, "colors" : [ { - "idiom" : "universal", "color" : { "color-space" : "srgb", "components" : { - "red" : "253", "alpha" : "1.000", "blue" : "1", - "green" : "116" + "green" : "228", + "red" : "253" } - } + }, + "idiom" : "universal" } - ] -} \ No newline at end of file + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emitron/Emitron/Assets.xcassets/Colours/Tags/accentTagBackground.colorset/Contents.json b/Emitron/Emitron/Assets.xcassets/Colours/Tags/accentTagBackground.colorset/Contents.json index a5364501..8820dbcc 100644 --- a/Emitron/Emitron/Assets.xcassets/Colours/Tags/accentTagBackground.colorset/Contents.json +++ b/Emitron/Emitron/Assets.xcassets/Colours/Tags/accentTagBackground.colorset/Contents.json @@ -1,20 +1,20 @@ { - "info" : { - "version" : 1, - "author" : "xcode" - }, "colors" : [ { - "idiom" : "universal", "color" : { "color-space" : "srgb", "components" : { - "red" : "0.082", "alpha" : "1.000", - "blue" : "0.263", - "green" : "0.518" + "blue" : "43", + "green" : "102", + "red" : "236" } - } + }, + "idiom" : "universal" } - ] -} \ No newline at end of file + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emitron/Emitron/Assets.xcassets/Colours/Toggle/toggleLineSelected.colorset/Contents.json b/Emitron/Emitron/Assets.xcassets/Colours/Toggle/toggleLineSelected.colorset/Contents.json index 5b71e1e9..84012de3 100644 --- a/Emitron/Emitron/Assets.xcassets/Colours/Toggle/toggleLineSelected.colorset/Contents.json +++ b/Emitron/Emitron/Assets.xcassets/Colours/Toggle/toggleLineSelected.colorset/Contents.json @@ -1,23 +1,18 @@ { - "info" : { - "version" : 1, - "author" : "xcode" - }, "colors" : [ { - "idiom" : "universal", "color" : { "color-space" : "srgb", "components" : { - "red" : "0.082", "alpha" : "1.000", - "blue" : "0.263", - "green" : "0.518" + "blue" : "43", + "green" : "102", + "red" : "236" } - } + }, + "idiom" : "universal" }, { - "idiom" : "universal", "appearances" : [ { "appearance" : "luminosity", @@ -27,15 +22,15 @@ "color" : { "color-space" : "srgb", "components" : { - "red" : "0.082", "alpha" : "1.000", - "blue" : "0.263", - "green" : "0.518" + "blue" : "43", + "green" : "102", + "red" : "236" } - } + }, + "idiom" : "universal" }, { - "idiom" : "universal", "appearances" : [ { "appearance" : "luminosity", @@ -45,12 +40,17 @@ "color" : { "color-space" : "srgb", "components" : { - "red" : "1.000", "alpha" : "1.000", "blue" : "1.000", - "green" : "1.000" + "green" : "1.000", + "red" : "1.000" } - } + }, + "idiom" : "universal" } - ] -} \ No newline at end of file + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emitron/Emitron/Assets.xcassets/Colours/Toggle/toggleTextSelected.colorset/Contents.json b/Emitron/Emitron/Assets.xcassets/Colours/Toggle/toggleTextSelected.colorset/Contents.json index 4e84cac1..fb2a207c 100644 --- a/Emitron/Emitron/Assets.xcassets/Colours/Toggle/toggleTextSelected.colorset/Contents.json +++ b/Emitron/Emitron/Assets.xcassets/Colours/Toggle/toggleTextSelected.colorset/Contents.json @@ -1,23 +1,18 @@ { - "info" : { - "version" : 1, - "author" : "xcode" - }, "colors" : [ { - "idiom" : "universal", "color" : { "color-space" : "srgb", "components" : { - "red" : "0.082", "alpha" : "1.000", - "blue" : "0.263", - "green" : "0.518" + "blue" : "43", + "green" : "102", + "red" : "236" } - } + }, + "idiom" : "universal" }, { - "idiom" : "universal", "appearances" : [ { "appearance" : "luminosity", @@ -27,15 +22,15 @@ "color" : { "color-space" : "srgb", "components" : { - "red" : "21", "alpha" : "1.000", - "blue" : "67", - "green" : "132" + "blue" : "43", + "green" : "102", + "red" : "236" } - } + }, + "idiom" : "universal" }, { - "idiom" : "universal", "appearances" : [ { "appearance" : "luminosity", @@ -45,12 +40,17 @@ "color" : { "color-space" : "srgb", "components" : { - "red" : "255", "alpha" : "1.000", "blue" : "255", - "green" : "255" + "green" : "255", + "red" : "255" } - } + }, + "idiom" : "universal" } - ] -} \ No newline at end of file + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emitron/Emitron/Assets.xcassets/Colours/accent.colorset/Contents.json b/Emitron/Emitron/Assets.xcassets/Colours/accent.colorset/Contents.json index a5364501..8820dbcc 100644 --- a/Emitron/Emitron/Assets.xcassets/Colours/accent.colorset/Contents.json +++ b/Emitron/Emitron/Assets.xcassets/Colours/accent.colorset/Contents.json @@ -1,20 +1,20 @@ { - "info" : { - "version" : 1, - "author" : "xcode" - }, "colors" : [ { - "idiom" : "universal", "color" : { "color-space" : "srgb", "components" : { - "red" : "0.082", "alpha" : "1.000", - "blue" : "0.263", - "green" : "0.518" + "blue" : "43", + "green" : "102", + "red" : "236" } - } + }, + "idiom" : "universal" } - ] -} \ No newline at end of file + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emitron/Emitron/Constants.swift b/Emitron/Emitron/Constants.swift index 9d641687..cd096427 100644 --- a/Emitron/Emitron/Constants.swift +++ b/Emitron/Emitron/Constants.swift @@ -84,12 +84,12 @@ extension String { static let videoPlaybackCannotStreamWhenOffline = "Cannot stream video when offline." static let videoPlaybackInvalidPermissions = "You don't have the required permissions to view this video." - static let videoPlaybackExpiredPermissions = "Download expired. Please reconnect to the internet to reverify." + static let videoPlaybackExpiredPermissions = "Download expired. Please reconnect to the internet to re-verify." static let appIconUpdatedSuccessfully = "You app icon has been updated!" static let appIconUpdateProblem = "There was a problem updating the app icon." - // MARK: Onboarding + // MARK: On-boarding static let login = "Login" // MARK: Other diff --git a/Emitron/Emitron/Data Synchronisation/ProgressEngine.swift b/Emitron/Emitron/Data Synchronisation/ProgressEngine.swift index 6403fc95..f4a3eb6e 100644 --- a/Emitron/Emitron/Data Synchronisation/ProgressEngine.swift +++ b/Emitron/Emitron/Data Synchronisation/ProgressEngine.swift @@ -38,7 +38,7 @@ enum ProgressEngineError: Error { var localizedDescription: String { switch self { case .simultaneousStreamsNotAllowed: - return "ProgressEngineError::SimulataneousStreamsNotAllowed" + return "ProgressEngineError::SimultaneousStreamsNotAllowed" case .upstreamError(let error): return "ProgressEngineError::UpstreamError:: \(error)" case .notImplemented: @@ -73,7 +73,7 @@ final class ProgressEngine { } func start() { - networkMonitor.start(queue: DispatchQueue.global(qos: .utility)) + networkMonitor.start(queue: .global(qos: .utility)) setupSubscriptions() } @@ -81,73 +81,55 @@ final class ProgressEngine { // Don't especially care if we're in offline mode guard mode == .online else { return } playbackToken = nil - // Need to refresh the plaback token - contentsService.getBeginPlaybackToken { [weak self] result in - guard let self = self else { return } - switch result { - case .failure(let error): + // Need to refresh the playback token + Task { + do { + playbackToken = try await contentsService.beginPlaybackToken + } catch { Failure - .fetch(from: String(describing: type(of: self)), reason: "Unable to fetch playback token: \(error)") + .fetch(from: Self.self, reason: "Unable to fetch playback token: \(error)") .log() - case .success(let token): - self.playbackToken = token } } } - func updateProgress(for contentID: Int, progress: Int) -> Future { + func updateProgress(for contentID: Int, progress: Int) async throws -> Progression { let progression = updateCacheWithProgress(for: contentID, progress: progress) switch mode { case .offline: - do { - try syncAction?.updateProgress(for: contentID, progress: progress) - try syncAction?.recordWatchStats(for: contentID, secondsWatched: .videoPlaybackProgressTrackingInterval) - - return Future { promise in - promise(.success(progression)) - } - } catch { - return Future { promise in - promise(.failure(.upstreamError(error))) - } - } - + try syncAction?.updateProgress(for: contentID, progress: progress) + try syncAction?.recordWatchStats(for: contentID, secondsWatched: .videoPlaybackProgressTrackingInterval) + + return progression case .online: - return Future { promise in - // Don't bother trying if the playback token is empty. - guard let playbackToken = self.playbackToken else { return } - self.contentsService.reportPlaybackUsage(for: contentID, progress: progress, playbackToken: playbackToken) { [weak self] response in - guard let self = self else { return promise(.failure(.notImplemented)) } - switch response { - case .failure(let error): - if case .requestFailed(_, let statusCode) = error, statusCode == 400 { - // This is an invalid token - return promise(.failure(.simultaneousStreamsNotAllowed)) - } - // Some other error. Let's just send it back - return promise(.failure(.upstreamError(error))) - case .success(let (progression, cacheUpdate)): - // Update the cache and return the updated progression - self.repository.apply(update: cacheUpdate) - // Do we need to update the parent? - if let parentContent = self.repository.parentContent(for: contentID), - let childProgressUpdate = self.repository.childProgress(for: parentContent.id), - var existingProgression = self.repository.progression(for: parentContent.id) { - existingProgression.progress = childProgressUpdate.completed - let parentCacheUpdate = DataCacheUpdate(progressions: [existingProgression]) - self.repository.apply(update: parentCacheUpdate) - } - - return promise(.success(progression)) - } - } + // Don't bother trying if the playback token is empty. + guard let playbackToken = playbackToken else { return progression } + + let (progression, cacheUpdate) = try await contentsService.reportPlaybackUsage( + for: contentID, + progress: progress, + playbackToken: playbackToken + ) + + // Update the cache and return the updated progression + repository.apply(update: cacheUpdate) + // Do we need to update the parent? + if + let parentContent = repository.parentContent(for: contentID), + let childProgressUpdate = repository.childProgress(for: parentContent.id), + var existingProgression = repository.progression(for: parentContent.id) + { + existingProgression.progress = childProgressUpdate.completed + repository.apply(update: .init(progressions: [existingProgression])) } + + return progression } } private func setupSubscriptions() { - if networkMonitor.currentPath.status == .satisfied { + if case .satisfied = networkMonitor.currentPath.status { mode = .online } networkMonitor.pathUpdateHandler = { [weak self] path in @@ -155,7 +137,11 @@ final class ProgressEngine { } } - @discardableResult private func updateCacheWithProgress(for contentID: Int, progress: Int, target: Int? = nil) -> Progression { + @discardableResult private func updateCacheWithProgress( + for contentID: Int, + progress: Int, + target: Int? = nil + ) -> Progression { let content = repository.content(for: contentID) let progression: Progression diff --git a/Emitron/Emitron/Data Synchronisation/SyncEngine.swift b/Emitron/Emitron/Data Synchronisation/SyncEngine.swift index 0c370212..5f4652a4 100644 --- a/Emitron/Emitron/Data Synchronisation/SyncEngine.swift +++ b/Emitron/Emitron/Data Synchronisation/SyncEngine.swift @@ -68,21 +68,20 @@ extension SyncEngine { self.stopProcessing() } } - networkMonitor.start(queue: DispatchQueue.global(qos: .utility)) + networkMonitor.start(queue: .global(qos: .utility)) } - private func completionHandler() -> (Subscribers.Completion) -> Void { { [weak self] completion in - guard let self = self else { return } - - switch completion { - case .finished: - // Don't think we should ever actually arrive here... - print("SyncEngine Request Stream finished. Didn't really expect it to.") - case .failure(let error): - Failure - .loadFromPersistentStore(from: String(describing: type(of: self)), reason: "Couldn't load sync requests: \(error)") - .log() - } + private var completionHandler: (Subscribers.Completion) -> Void { + { completion in + switch completion { + case .finished: + // Don't think we should ever actually arrive here... + print("SyncEngine Request Stream finished. Didn't really expect it to.") + case .failure(let error): + Failure + .loadFromPersistentStore(from: Self.self, reason: "Couldn't load sync requests: \(error)") + .log() + } } } @@ -101,31 +100,31 @@ extension SyncEngine { persistenceStore .syncRequestStream(for: [.createBookmark]) .removeDuplicates() - .sink(receiveCompletion: completionHandler()) { [weak self] in self?.syncBookmarkCreations(syncRequests: $0) } + .sink(receiveCompletion: completionHandler) { [weak self] in self?.syncBookmarkCreations(syncRequests: $0) } .store(in: &subscriptions) persistenceStore .syncRequestStream(for: [.deleteBookmark]) .removeDuplicates() - .sink(receiveCompletion: completionHandler()) { [weak self] in self?.syncBookmarkDeletions(syncRequests: $0) } + .sink(receiveCompletion: completionHandler) { [weak self] in self?.syncBookmarkDeletions(syncRequests: $0) } .store(in: &subscriptions) persistenceStore .syncRequestStream(for: [.markContentComplete, .updateProgress]) .removeDuplicates() - .sink(receiveCompletion: completionHandler()) { [weak self] in self?.syncProgressionUpdates(syncRequests: $0) } + .sink(receiveCompletion: completionHandler) { [weak self] in self?.syncProgressionUpdates(syncRequests: $0) } .store(in: &subscriptions) persistenceStore .syncRequestStream(for: [.deleteProgression]) .removeDuplicates() - .sink(receiveCompletion: completionHandler()) { [weak self] in self?.syncProgressionDeletions(syncRequests: $0) } + .sink(receiveCompletion: completionHandler) { [weak self] in self?.syncProgressionDeletions(syncRequests: $0) } .store(in: &subscriptions) persistenceStore .syncRequestStream(for: [.recordWatchStats]) .removeDuplicates() - .sink(receiveCompletion: completionHandler()) { [weak self] in self?.syncWatchStats(syncRequests: $0) } + .sink(receiveCompletion: completionHandler) { [weak self] in self?.syncWatchStats(syncRequests: $0) } .store(in: &subscriptions) } @@ -149,21 +148,19 @@ extension SyncEngine { syncRequests.forEach { syncRequest in guard syncRequest.type == .createBookmark else { return } - - bookmarksService.makeBookmark(for: syncRequest.contentID) { [weak self] result in - guard let self = self else { return } - - switch result { - case .failure(let error): - Failure - .fetch(from: String(describing: type(of: self)), reason: "syncBookmarkCreations:: \(error.localizedDescription)") - .log() - case .success(let bookmark): + + Task { + do { + let bookmark = try await bookmarksService.makeBookmark(for: syncRequest.contentID) // Update the cache let cacheUpdate = DataCacheUpdate(bookmarks: [bookmark]) self.repository.apply(update: cacheUpdate) // Remove the sync request—we're done self.persistenceStore.complete(syncRequests: [syncRequest]) + } catch { + Failure + .fetch(from: Self.self, reason: "syncBookmarkCreations:: \(error.localizedDescription)") + .log() } } } @@ -177,28 +174,27 @@ extension SyncEngine { .log() syncRequests.forEach { syncRequest in - guard syncRequest.type == .deleteBookmark, + guard + case .deleteBookmark = syncRequest.type, let bookmarkID = syncRequest.associatedRecordID - else { return } - - bookmarksService.destroyBookmark(for: bookmarkID) { [weak self] result in - guard let self = self else { return } - - switch result { - case .failure(let error): - Failure - .fetch(from: String(describing: type(of: self)), reason: "syncBookmarkDeletions:: \(error.localizedDescription)") - .log() - if case .requestFailed(_, 404) = error { - // Remove the sync request—a 404 means it doesn't exist on the server - self.persistenceStore.complete(syncRequests: [syncRequest]) - } - case .success: + else { return } + + Task { + do { + try await bookmarksService.destroyBookmark(for: bookmarkID) // Update the cache let cacheUpdate = DataCacheUpdate(bookmarkDeletionContentIDs: [syncRequest.contentID]) self.repository.apply(update: cacheUpdate) // Remove the sync request—we're done self.persistenceStore.complete(syncRequests: [syncRequest]) + } catch { + Failure + .fetch(from: Self.self, reason: "syncBookmarkDeletions:: \(error.localizedDescription)") + .log() + if case RWAPIError.requestFailed(_, 404) = error { + // Remove the sync request—a 404 means it doesn't exist on the server + self.persistenceStore.complete(syncRequests: [syncRequest]) + } } } } @@ -219,17 +215,15 @@ extension SyncEngine { return } - watchStatsService.update(watchStats: watchStatRequests) { [weak self] result in - guard let self = self else { return } - - switch result { - case .failure(let error): - Failure - .fetch(from: String(describing: type(of: self)), reason: "syncWatchStats:: \(error.localizedDescription)") - .log() - case .success: + Task { + do { + try await watchStatsService.update(watchStats: watchStatRequests) // Remove the sync requests—we're done self.persistenceStore.complete(syncRequests: watchStatRequests) + } catch { + Failure + .fetch(from: Self.self, reason: "syncWatchStats:: \(error.localizedDescription)") + .log() } } } @@ -248,20 +242,19 @@ extension SyncEngine { if progressionUpdates.isEmpty { return } - - progressionsService.update(progressions: progressionUpdates) { [weak self] result in - guard let self = self else { return } - - switch result { - case .failure(let error): - Failure - .fetch(from: String(describing: type(of: self)), reason: "syncProgressionUpdates:: \(error.localizedDescription)") - .log() - case .success( (_, let cacheUpdate) ): + + Task { + do { // Update the cache - self.repository.apply(update: cacheUpdate) + repository.apply( + update: try await progressionsService.update(progressions: progressionUpdates).cacheUpdate + ) // Remove the sync request—we're done - self.persistenceStore.complete(syncRequests: progressionUpdates) + persistenceStore.complete(syncRequests: progressionUpdates) + } catch { + Failure + .fetch(from: Self.self, reason: "syncProgressionUpdates:: \(error.localizedDescription)") + .log() } } } @@ -274,29 +267,27 @@ extension SyncEngine { .log() syncRequests.forEach { syncRequest in - guard syncRequest.type == .deleteProgression, + guard + case .deleteProgression = syncRequest.type, let progressionID = syncRequest.associatedRecordID - else { return } - - progressionsService.delete(with: progressionID) { [weak self] result in - guard let self = self else { return } - - switch result { - case .failure(let error): + else { return } + + Task { + do { + try await progressionsService.delete(with: progressionID) + let cacheUpdate = DataCacheUpdate(progressionDeletionContentIDs: [syncRequest.contentID]) + repository.apply(update: cacheUpdate) + // Remove the sync request—we're done + persistenceStore.complete(syncRequests: [syncRequest]) + } catch { Failure - .fetch(from: String(describing: type(of: self)), reason: "syncProgressionDeletions:: \(error.localizedDescription)") + .fetch(from: Self.self, reason: "syncProgressionDeletions:: \(error.localizedDescription)") .log() - - if case .requestFailed(_, 404) = error { + + if case RWAPIError.requestFailed(_, 404) = error { // Remove the sync request—a 404 means it doesn't exist on the server - self.persistenceStore.complete(syncRequests: [syncRequest]) + persistenceStore.complete(syncRequests: [syncRequest]) } - case .success: - // Update the cache - let cacheUpdate = DataCacheUpdate(progressionDeletionContentIDs: [syncRequest.contentID]) - self.repository.apply(update: cacheUpdate) - // Remove the sync request—we're done - self.persistenceStore.complete(syncRequests: [syncRequest]) } } } diff --git a/Emitron/Emitron/Data Synchronisation/SyncRequest+ProgressionUpdate.swift b/Emitron/Emitron/Data Synchronisation/SyncRequest+ProgressionUpdate.swift index 58708f74..1f2ac194 100644 --- a/Emitron/Emitron/Data Synchronisation/SyncRequest+ProgressionUpdate.swift +++ b/Emitron/Emitron/Data Synchronisation/SyncRequest+ProgressionUpdate.swift @@ -34,7 +34,7 @@ extension SyncRequest: ProgressionUpdate { // doesn't actually represent a progression. But that seems ok // we can test that elsewhere. - if type == .markContentComplete { + if case .markContentComplete = type { return .finished } @@ -47,7 +47,5 @@ extension SyncRequest: ProgressionUpdate { ) } - var updatedAt: Date { - date - } + var updatedAt: Date { date } } diff --git a/Emitron/Emitron/Data Synchronisation/SyncRequest+WatchStat.swift b/Emitron/Emitron/Data Synchronisation/SyncRequest+WatchStat.swift index 82258986..feac6cbb 100644 --- a/Emitron/Emitron/Data Synchronisation/SyncRequest+WatchStat.swift +++ b/Emitron/Emitron/Data Synchronisation/SyncRequest+WatchStat.swift @@ -30,16 +30,12 @@ import struct Foundation.Date extension SyncRequest: WatchStat { var secondsWatched: Int { - for attribute in attributes { - if case .time(let seconds) = attribute { - return seconds - } + for case .time(let seconds) in attributes { + return seconds } return 0 } - var dateWatched: Date { - date - } + var dateWatched: Date { date } } diff --git a/Emitron/Emitron/Data/ContentRepositories/BookmarkRepository.swift b/Emitron/Emitron/Data/ContentRepositories/BookmarkRepository.swift index 6ed96fee..f2319840 100644 --- a/Emitron/Emitron/Data/ContentRepositories/BookmarkRepository.swift +++ b/Emitron/Emitron/Data/ContentRepositories/BookmarkRepository.swift @@ -31,7 +31,7 @@ import Combine final class BookmarkRepository: ContentRepository { override var nonPaginationParameters: [Parameter] { get { - let filters = Param.filters(for: [.contentTypes(types: [.collection, .screencast])]) + let filters = Param.filters(for: [.contentTypes([.collection, .screencast])]) let sortOrder = Param.sort(for: .updatedAt, descending: true) return filters + [sortOrder] } diff --git a/Emitron/Emitron/Data/ContentRepositories/CompletedRepository.swift b/Emitron/Emitron/Data/ContentRepositories/CompletedRepository.swift index 4041e9c0..bd5f8ce9 100644 --- a/Emitron/Emitron/Data/ContentRepositories/CompletedRepository.swift +++ b/Emitron/Emitron/Data/ContentRepositories/CompletedRepository.swift @@ -31,7 +31,7 @@ import Combine final class CompletedRepository: ContentRepository { override var nonPaginationParameters: [Parameter] { get { - let filters = Param.filters(for: [.contentTypes(types: [.collection, .screencast])]) + let filters = Param.filters(for: [.contentTypes([.collection, .screencast])]) let completionStatus = CompletionStatus.completed let completionFilter = Param.filter(for: .completionStatus(status: completionStatus)) let sortOrder = Param.sort(for: .updatedAt, descending: true) diff --git a/Emitron/Emitron/Data/ContentRepositories/ContentRepository.swift b/Emitron/Emitron/Data/ContentRepositories/ContentRepository.swift index 9e555055..680a5716 100644 --- a/Emitron/Emitron/Data/ContentRepositories/ContentRepository.swift +++ b/Emitron/Emitron/Data/ContentRepositories/ContentRepository.swift @@ -38,7 +38,7 @@ class ContentRepository: ObservableObject, ContentPaginatable { private(set) weak var syncAction: SyncAction? private let contentsService: ContentsService - private let downloadAction: DownloadAction + private let downloadService: DownloadService private let serviceAdapter: ContentServiceAdapter! private var contentIDs: [Int] = [] @@ -50,19 +50,11 @@ class ContentRepository: ObservableObject, ContentPaginatable { private(set) var currentPage = 1 - // This should be @Published too, but it crashes the compiler (Version 11.3 (11C29)) - // Let's see if we actually need it to be @Published... - var state: DataState = .initial + @Published var state: DataState = .initial private(set) var totalContentNum = 0 - // This should be @Published, but it /sometimes/ crashes the app with EXC_BAD_ACCESS - // when you try and reference it. Which is handy. - var contents: [ContentListDisplayable] = [] { - willSet { - objectWillChange.send() - } - } + @Published var contents: [ContentListDisplayable] = [] func loadMore() { if state == .loading || state == .loadingAdditional { @@ -78,25 +70,23 @@ class ContentRepository: ObservableObject, ContentPaginatable { let pageParam = ParameterKey.pageNumber(number: currentPage).param let allParams = nonPaginationParameters + [pageParam] - - serviceAdapter.findContent(parameters: allParams) { [weak self] result in - guard let self = self else { return } - - switch result { - case .failure(let error): - self.currentPage -= 1 - self.state = .failed - self.objectWillChange.send() + + Task { + do { + let (newContentIDs, cacheUpdate, totalResultCount) + = try await serviceAdapter.findContent(parameters: allParams) + contentIDs += newContentIDs + contentSubscription?.cancel() + repository.apply(update: cacheUpdate) + totalContentNum = totalResultCount + state = .hasData + configureContentSubscription() + } catch { + currentPage -= 1 + state = .failed Failure - .fetch(from: String(describing: type(of: self)), reason: error.localizedDescription) + .fetch(from: Self.self, reason: error.localizedDescription) .log() - case .success(let (newContentIDs, cacheUpdate, totalResultCount)): - self.contentIDs += newContentIDs - self.contentSubscription?.cancel() - self.repository.apply(update: cacheUpdate) - self.totalContentNum = totalResultCount - self.state = .hasData - self.configureContentSubscription() } } } @@ -107,31 +97,25 @@ class ContentRepository: ObservableObject, ContentPaginatable { } state = .loading - // `state` can't be @Published, so we have to do this manually - objectWillChange.send() // Reset current page to 1 currentPage = startingPage - - serviceAdapter.findContent(parameters: nonPaginationParameters) { [weak self] result in - guard let self = self else { - return - } - - switch result { - case .failure(let error): + + Task { + do { + let (newContentIDs, cacheUpdate, totalResultCount) + = try await serviceAdapter.findContent(parameters: nonPaginationParameters) + contentIDs = newContentIDs + contentSubscription?.cancel() + repository.apply(update: cacheUpdate) + totalContentNum = totalResultCount + state = .hasData + configureContentSubscription() + } catch { self.state = .failed - self.objectWillChange.send() Failure - .fetch(from: String(describing: type(of: self)), reason: error.localizedDescription) + .fetch(from: Self.self, reason: error.localizedDescription) .log() - case .success(let (newContentIDs, cacheUpdate, totalResultCount)): - self.contentIDs = newContentIDs - self.contentSubscription?.cancel() - self.repository.apply(update: cacheUpdate) - self.totalContentNum = totalResultCount - self.state = .hasData - self.configureContentSubscription() } } } @@ -149,7 +133,7 @@ class ContentRepository: ObservableObject, ContentPaginatable { init( repository: Repository, contentsService: ContentsService, - downloadAction: DownloadAction, + downloadService: DownloadService, syncAction: SyncAction, serviceAdapter: ContentServiceAdapter! = nil, messageBus: MessageBus, @@ -158,7 +142,7 @@ class ContentRepository: ObservableObject, ContentPaginatable { ) { self.repository = repository self.contentsService = contentsService - self.downloadAction = downloadAction + self.downloadService = downloadService self.syncAction = syncAction self.serviceAdapter = serviceAdapter self.messageBus = messageBus @@ -171,7 +155,7 @@ class ContentRepository: ObservableObject, ContentPaginatable { // Default to using the cached version DataCacheChildContentsViewModel( parentContentID: contentID, - downloadAction: downloadAction, + downloadService: downloadService, syncAction: syncAction, repository: repository, service: contentsService, @@ -190,7 +174,7 @@ extension ContentRepository { .init( contentID: contentID, repository: repository, - downloadAction: downloadAction, + downloadService: downloadService, syncAction: syncAction, messageBus: messageBus, settingsManager: settingsManager, @@ -222,11 +206,9 @@ private extension ContentRepository { .contentSummaryState(for: contentIDs) .removeDuplicates() .sink( - receiveCompletion: { [weak self] error in - guard let self = self else { return } - + receiveCompletion: { error in Failure - .repositoryLoad(from: String(describing: type(of: self)), reason: "Unable to receive content summary update: \(error)") + .repositoryLoad(from: Self.self, reason: "Unable to receive content summary update: \(error)") .log() }, receiveValue: { [weak self] contentSummaryStates in diff --git a/Emitron/Emitron/Data/ContentRepositories/DownloadRepository.swift b/Emitron/Emitron/Data/ContentRepositories/DownloadRepository.swift index 5176ee77..24a2994f 100644 --- a/Emitron/Emitron/Data/ContentRepositories/DownloadRepository.swift +++ b/Emitron/Emitron/Data/ContentRepositories/DownloadRepository.swift @@ -47,7 +47,7 @@ final class DownloadRepository: ContentRepository { super.init( repository: repository, contentsService: contentsService, - downloadAction: downloadService, + downloadService: downloadService, syncAction: syncAction, messageBus: messageBus, settingsManager: settingsManager, @@ -69,7 +69,7 @@ final class DownloadRepository: ContentRepository { // For downloaded content, we need to tell it to use the DB, not the service PersistenceStoreChildContentsViewModel( parentContentID: contentID, - downloadAction: downloadService, + downloadService: downloadService, syncAction: syncAction, repository: repository, messageBus: messageBus, @@ -90,7 +90,7 @@ private extension DownloadRepository { guard let self = self else { return } self.state = .failed Failure - .loadFromPersistentStore(from: String(describing: type(of: self)), reason: "Unable to retrieve download content summaries: \(error)") + .loadFromPersistentStore(from: Self.self, reason: "Unable to retrieve download content summaries: \(error)") .log() }, receiveValue: { [weak self] contentSummaryStates in diff --git a/Emitron/Emitron/Data/ContentRepositories/InProgressRepository.swift b/Emitron/Emitron/Data/ContentRepositories/InProgressRepository.swift index 901bee40..1597b507 100644 --- a/Emitron/Emitron/Data/ContentRepositories/InProgressRepository.swift +++ b/Emitron/Emitron/Data/ContentRepositories/InProgressRepository.swift @@ -31,7 +31,7 @@ import Combine final class InProgressRepository: ContentRepository { override var nonPaginationParameters: [Parameter] { get { - let filters = Param.filters(for: [.contentTypes(types: [.collection, .screencast])]) + let filters = Param.filters(for: [.contentTypes([.collection, .screencast])]) let completionStatus = CompletionStatus.inProgress let completionFilter = Param.filter(for: .completionStatus(status: completionStatus)) let sortOrder = Param.sort(for: .updatedAt, descending: true) diff --git a/Emitron/Emitron/Data/ContentRepositories/LibraryRepository.swift b/Emitron/Emitron/Data/ContentRepositories/LibraryRepository.swift index 9b11143d..ce805a0c 100644 --- a/Emitron/Emitron/Data/ContentRepositories/LibraryRepository.swift +++ b/Emitron/Emitron/Data/ContentRepositories/LibraryRepository.swift @@ -32,7 +32,7 @@ final class LibraryRepository: ContentRepository { init( repository: Repository, contentsService: ContentsService, - downloadAction: DownloadAction, + downloadService: DownloadService, syncAction: SyncAction, serviceAdapter: ContentServiceAdapter, messageBus: MessageBus, @@ -44,7 +44,7 @@ final class LibraryRepository: ContentRepository { super.init( repository: repository, contentsService: contentsService, - downloadAction: downloadAction, + downloadService: downloadService, syncAction: syncAction, serviceAdapter: serviceAdapter, messageBus: messageBus, diff --git a/Emitron/Emitron/Data/DataManager.swift b/Emitron/Emitron/Data/DataManager.swift index a73d67f4..bc605afa 100644 --- a/Emitron/Emitron/Data/DataManager.swift +++ b/Emitron/Emitron/Data/DataManager.swift @@ -32,7 +32,7 @@ import Combine final class DataManager: ObservableObject { // MARK: - Properties - // Initialiser Arguments + // Initializer Arguments let persistenceStore: PersistenceStore let downloadService: DownloadService let sessionController: SessionController @@ -61,7 +61,7 @@ final class DataManager: ObservableObject { private (set) var syncEngine: SyncEngine! private var domainsSubscriber: AnyCancellable? - private var categoriesSubsciber: AnyCancellable? + private var categoriesSubscriber: AnyCancellable? // MARK: - Initializers init(sessionController: SessionController, @@ -93,13 +93,13 @@ final class DataManager: ObservableObject { dataCache = DataCache() repository = Repository(persistenceStore: persistenceStore, dataCache: dataCache) - let contentsService = ContentsService(client: sessionController.client) - let bookmarksService = BookmarksService(client: sessionController.client) - let progressionsService = ProgressionsService(client: sessionController.client) - let libraryService = ContentsService(client: sessionController.client) - let domainsService = DomainsService(client: sessionController.client) - let categoriesService = CategoriesService(client: sessionController.client) - let watchStatsService = WatchStatsService(client: sessionController.client) + let contentsService = ContentsService(networkClient: sessionController.client) + let bookmarksService = BookmarksService(networkClient: sessionController.client) + let progressionsService = ProgressionsService(networkClient: sessionController.client) + let libraryService = ContentsService(networkClient: sessionController.client) + let domainsService = DomainsService(networkClient: sessionController.client) + let categoriesService = CategoriesService(networkClient: sessionController.client) + let watchStatsService = WatchStatsService(networkClient: sessionController.client) syncEngine = SyncEngine( persistenceStore: persistenceStore, @@ -109,12 +109,12 @@ final class DataManager: ObservableObject { watchStatsService: watchStatsService ) - bookmarkRepository = BookmarkRepository(repository: repository, contentsService: contentsService, downloadAction: downloadService, syncAction: syncEngine, serviceAdapter: bookmarksService, messageBus: messageBus, settingsManager: settingsManager, sessionController: sessionController) + bookmarkRepository = BookmarkRepository(repository: repository, contentsService: contentsService, downloadService: downloadService, syncAction: syncEngine, serviceAdapter: bookmarksService, messageBus: messageBus, settingsManager: settingsManager, sessionController: sessionController) - completedRepository = CompletedRepository(repository: repository, contentsService: contentsService, downloadAction: downloadService, syncAction: syncEngine, serviceAdapter: progressionsService, messageBus: messageBus, settingsManager: settingsManager, sessionController: sessionController) - inProgressRepository = InProgressRepository(repository: repository, contentsService: contentsService, downloadAction: downloadService, syncAction: syncEngine, serviceAdapter: progressionsService, messageBus: messageBus, settingsManager: settingsManager, sessionController: sessionController) + completedRepository = CompletedRepository(repository: repository, contentsService: contentsService, downloadService: downloadService, syncAction: syncEngine, serviceAdapter: progressionsService, messageBus: messageBus, settingsManager: settingsManager, sessionController: sessionController) + inProgressRepository = InProgressRepository(repository: repository, contentsService: contentsService, downloadService: downloadService, syncAction: syncEngine, serviceAdapter: progressionsService, messageBus: messageBus, settingsManager: settingsManager, sessionController: sessionController) - libraryRepository = LibraryRepository(repository: repository, contentsService: contentsService, downloadAction: downloadService, syncAction: syncEngine, serviceAdapter: libraryService, messageBus: messageBus, settingsManager: settingsManager, sessionController: sessionController, filters: filters) + libraryRepository = LibraryRepository(repository: repository, contentsService: contentsService, downloadService: downloadService, syncAction: syncEngine, serviceAdapter: libraryService, messageBus: messageBus, settingsManager: settingsManager, sessionController: sessionController, filters: filters) downloadRepository = DownloadRepository(repository: repository, contentsService: contentsService, downloadService: downloadService, syncAction: syncEngine, messageBus: messageBus, settingsManager: settingsManager, sessionController: sessionController) @@ -124,7 +124,7 @@ final class DataManager: ObservableObject { } categoryRepository = CategoryRepository(repository: repository, service: categoriesService) - categoriesSubsciber = categoryRepository.$categories.sink { categories in + categoriesSubscriber = categoryRepository.$categories.sink { categories in self.filters.updateCategoryFilters(for: categories) } } diff --git a/Emitron/Emitron/Data/Other Repositories/CategoryRepository.swift b/Emitron/Emitron/Data/Other Repositories/CategoryRepository.swift index 88f54f26..81a401f6 100644 --- a/Emitron/Emitron/Data/Other Repositories/CategoryRepository.swift +++ b/Emitron/Emitron/Data/Other Repositories/CategoryRepository.swift @@ -58,7 +58,7 @@ class CategoryRepository: Refreshable { } catch { state = .failed Failure - .fetch(from: "CategoryRepository", reason: error.localizedDescription) + .fetch(from: Self.self, reason: error.localizedDescription) .log() } } @@ -68,7 +68,7 @@ class CategoryRepository: Refreshable { try repository.syncCategoryList(categories) } catch { Failure - .fetch(from: "CategoryRepository", reason: error.localizedDescription) + .fetch(from: Self.self, reason: error.localizedDescription) .log() } } @@ -79,21 +79,18 @@ class CategoryRepository: Refreshable { } state = .loading - - service.allCategories { [weak self] result in - guard let self = self else { return } - - switch result { - case .failure(let error): + + Task { + do { + categories = try await service.allCategories + state = .hasData + saveToPersistentStore() + saveOrReplaceRefreshableUpdateDate() + } catch { self.state = .failed Failure - .fetch(from: "CategoryRepository", reason: error.localizedDescription) - .log() - case .success(let categories): - self.categories = categories - self.state = .hasData - self.saveToPersistentStore() - self.saveOrReplaceRefreshableUpdateDate() + .fetch(from: Self.self, reason: error.localizedDescription) + .log() } } } diff --git a/Emitron/Emitron/Data/Other Repositories/DomainRepository.swift b/Emitron/Emitron/Data/Other Repositories/DomainRepository.swift index 37da5063..2d30043d 100644 --- a/Emitron/Emitron/Data/Other Repositories/DomainRepository.swift +++ b/Emitron/Emitron/Data/Other Repositories/DomainRepository.swift @@ -28,7 +28,7 @@ import Combine -class DomainRepository: ObservableObject, Refreshable { +final class DomainRepository: ObservableObject, Refreshable { let repository: Repository let service: DomainsService @@ -50,50 +50,49 @@ class DomainRepository: ObservableObject, Refreshable { fetchDomainsAndUpdatePersistentStore() } } - - private func loadFromPersistentStore() { +} + +private extension DomainRepository { + func loadFromPersistentStore() { do { domains = try repository.domainList() state = .hasData } catch { state = .failed Failure - .fetch(from: "DomainRepository", reason: error.localizedDescription) + .fetch(from: Self.self, reason: error.localizedDescription) .log() } } - private func saveToPersistentStore() { + func saveToPersistentStore() { do { try repository.syncDomainList(domains) } catch { Failure - .fetch(from: "DomainRepository", reason: error.localizedDescription) + .fetch(from: Self.self, reason: error.localizedDescription) .log() } } - private func fetchDomainsAndUpdatePersistentStore() { + func fetchDomainsAndUpdatePersistentStore() { if state == .loading || state == .loadingAdditional { return } state = .loading - - service.allDomains { [weak self] result in - guard let self = self else { return } - - switch result { - case .failure(let error): - self.state = .failed + + Task { + do { + domains = try await service.allDomains + state = .hasData + saveToPersistentStore() + saveOrReplaceRefreshableUpdateDate() + } catch { + state = .failed Failure - .fetch(from: "DomainRepository", reason: error.localizedDescription) - .log() - case .success(let domains): - self.domains = domains - self.state = .hasData - self.saveToPersistentStore() - self.saveOrReplaceRefreshableUpdateDate() + .fetch(from: Self.self, reason: error.localizedDescription) + .log() } } } diff --git a/Emitron/Emitron/Data/Repository.swift b/Emitron/Emitron/Data/Repository.swift index 3fa22aac..d789e974 100644 --- a/Emitron/Emitron/Data/Repository.swift +++ b/Emitron/Emitron/Data/Repository.swift @@ -77,15 +77,17 @@ extension Repository { return fromCache .combineLatest(download) .map { cachedState, download in - DynamicContentState(download: download, - progression: cachedState.progression, - bookmark: cachedState.bookmark) + DynamicContentState( + download: download, + progression: cachedState.progression, + bookmark: cachedState.bookmark + ) } .removeDuplicates() .eraseToAnyPublisher() } - func contentPersistableState(for contentID: Int) throws -> ContentPersistableState? { + func contentPersistableState(for contentID: Int) throws -> ContentPersistableState { try dataCache.cachedContentPersistableState(for: contentID) } @@ -147,7 +149,7 @@ extension Repository { func loadDownloadedChildContentsIntoCache(for contentID: Int) throws { guard let content = try persistenceStore.downloadedContent(with: contentID), let childContents = try persistenceStore.childContentsForDownloadedContent(with: contentID) else { - throw PersistenceStoreError.notFound + throw PersistenceStore.Error.notFound } let cacheUpdate = DataCacheUpdate(contents: childContents.contents + [content], groups: childContents.groups) apply(update: cacheUpdate) @@ -164,10 +166,10 @@ extension Repository { private func domains(from contentDomains: [ContentDomain]) -> [Domain] { do { - return try persistenceStore.domains( with: contentDomains.map(\.domainID) ) + return try persistenceStore.domains(with: contentDomains.map(\.domainID)) } catch { Failure - .loadFromPersistentStore(from: String(describing: type(of: self)), reason: "There was a problem getting domains: \(error)") + .loadFromPersistentStore(from: Self.self, reason: "There was a problem getting domains: \(error)") .log() return [] } @@ -175,10 +177,10 @@ extension Repository { private func categories(from contentCategories: [ContentCategory]) -> [Category] { do { - return try persistenceStore.categories( with: contentCategories.map(\.categoryID) ) + return try persistenceStore.categories(with: contentCategories.map(\.categoryID)) } catch { Failure - .loadFromPersistentStore(from: String(describing: type(of: self)), reason: "There was a problem getting categories: \(error)") + .loadFromPersistentStore(from: Self.self, reason: "There was a problem getting categories: \(error)") .log() return [] } diff --git a/Emitron/Emitron/Data/Service Adapters/BookmarksService+ContentServiceAdapter.swift b/Emitron/Emitron/Data/Service Adapters/BookmarksService+ContentServiceAdapter.swift index f3a10545..0fffe30c 100644 --- a/Emitron/Emitron/Data/Service Adapters/BookmarksService+ContentServiceAdapter.swift +++ b/Emitron/Emitron/Data/Service Adapters/BookmarksService+ContentServiceAdapter.swift @@ -27,15 +27,12 @@ // THE SOFTWARE. extension BookmarksService: ContentServiceAdapter { - func findContent(parameters: [Parameter], completion: @escaping (ContentServiceAdapterResponse) -> Void) { - bookmarks(parameters: parameters) { result in - completion(result.map { response in - ( - contentIDs: response.bookmarks.map(\.contentID), - cacheUpdate: response.cacheUpdate, - totalResultCount: response.totalNumber - ) - }) - } + func findContent(parameters: [Parameter]) async throws -> ContentServiceAdapterResponse { + let response = try await bookmarks(parameters: parameters) + return ( + contentIDs: response.bookmarks.map(\.contentID), + cacheUpdate: response.cacheUpdate, + totalResultCount: response.totalNumber + ) } } diff --git a/Emitron/Emitron/Data/Service Adapters/ContentServiceAdapter.swift b/Emitron/Emitron/Data/Service Adapters/ContentServiceAdapter.swift index 46c59c25..3b450efd 100644 --- a/Emitron/Emitron/Data/Service Adapters/ContentServiceAdapter.swift +++ b/Emitron/Emitron/Data/Service Adapters/ContentServiceAdapter.swift @@ -26,8 +26,8 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -typealias ContentServiceAdapterResponse = Result<(contentIDs: [Int], cacheUpdate: DataCacheUpdate, totalResultCount: Int), RWAPIError> +typealias ContentServiceAdapterResponse = (contentIDs: [Int], cacheUpdate: DataCacheUpdate, totalResultCount: Int) protocol ContentServiceAdapter { - func findContent(parameters: [Parameter], completion: @escaping(_ response: ContentServiceAdapterResponse) -> Void) + func findContent(parameters: [Parameter]) async throws -> ContentServiceAdapterResponse } diff --git a/Emitron/Emitron/Data/Service Adapters/ContentsService+ContentServiceAdapter.swift b/Emitron/Emitron/Data/Service Adapters/ContentsService+ContentServiceAdapter.swift index 98f93f76..832b6e8f 100644 --- a/Emitron/Emitron/Data/Service Adapters/ContentsService+ContentServiceAdapter.swift +++ b/Emitron/Emitron/Data/Service Adapters/ContentsService+ContentServiceAdapter.swift @@ -27,15 +27,12 @@ // THE SOFTWARE. extension ContentsService: ContentServiceAdapter { - func findContent(parameters: [Parameter], completion: @escaping (ContentServiceAdapterResponse) -> Void) { - allContents(parameters: parameters) { result in - completion(result.map { response in - ( - contentIDs: response.contents.map(\.id), - cacheUpdate: response.cacheUpdate, - totalResultCount: response.totalNumber - ) - }) - } + func findContent(parameters: [Parameter]) async throws -> ContentServiceAdapterResponse { + let response = try await allContents(parameters: parameters) + return ( + contentIDs: response.contents.map(\.id), + cacheUpdate: response.cacheUpdate, + totalResultCount: response.totalNumber + ) } } diff --git a/Emitron/Emitron/Data/Service Adapters/ProgressionsService+ContentServiceAdapter.swift b/Emitron/Emitron/Data/Service Adapters/ProgressionsService+ContentServiceAdapter.swift index 993a90c8..c77d316f 100644 --- a/Emitron/Emitron/Data/Service Adapters/ProgressionsService+ContentServiceAdapter.swift +++ b/Emitron/Emitron/Data/Service Adapters/ProgressionsService+ContentServiceAdapter.swift @@ -27,15 +27,12 @@ // THE SOFTWARE. extension ProgressionsService: ContentServiceAdapter { - func findContent(parameters: [Parameter], completion: @escaping (ContentServiceAdapterResponse) -> Void) { - progressions(parameters: parameters) { result in - completion(result.map { response in - ( - contentIDs: response.progressions.map(\.contentID), - cacheUpdate: response.cacheUpdate, - totalResultCount: response.totalNumber - ) - }) - } + func findContent(parameters: [Parameter]) async throws -> ContentServiceAdapterResponse { + let response = try await progressions(parameters: parameters) + return ( + contentIDs: response.progressions.map(\.id), + cacheUpdate: response.cacheUpdate, + totalResultCount: response.totalNumber + ) } } diff --git a/Emitron/Emitron/Data/States/ContentPersistableState.swift b/Emitron/Emitron/Data/States/ContentPersistableState.swift index 8e6ff286..b21b3ead 100644 --- a/Emitron/Emitron/Data/States/ContentPersistableState.swift +++ b/Emitron/Emitron/Data/States/ContentPersistableState.swift @@ -37,4 +37,4 @@ struct ContentPersistableState: Equatable { let childContents: [Content] } -typealias ContentLookup = (_ contentID: Int) -> ContentPersistableState? +typealias ContentLookup = (_ contentID: Int) throws -> ContentPersistableState diff --git a/Emitron/Emitron/Data/ViewModels/ChildContentsViewModel.swift b/Emitron/Emitron/Data/ViewModels/ChildContentsViewModel.swift index 62738bd7..d0fc534d 100644 --- a/Emitron/Emitron/Data/ViewModels/ChildContentsViewModel.swift +++ b/Emitron/Emitron/Data/ViewModels/ChildContentsViewModel.swift @@ -30,35 +30,44 @@ import Combine class ChildContentsViewModel: ObservableObject { let parentContentID: Int - let downloadAction: DownloadAction + let downloadService: DownloadService weak var syncAction: SyncAction? let repository: Repository let messageBus: MessageBus let settingsManager: SettingsManager let sessionController: SessionController - var state: DataState = .initial + @Published var state: DataState = .initial @Published var groups: [GroupDisplayable] = [] @Published var contents: [ChildContentListDisplayable] = [] - var subscriptions = Set() - - init(parentContentID: Int, - downloadAction: DownloadAction, - syncAction: SyncAction?, - repository: Repository, - messageBus: MessageBus, - settingsManager: SettingsManager, - sessionController: SessionController) { + private var subscriptions = Set() + + init( + parentContentID: Int, + downloadService: DownloadService, + syncAction: SyncAction?, + repository: Repository, + messageBus: MessageBus, + settingsManager: SettingsManager, + sessionController: SessionController + ) { self.parentContentID = parentContentID - self.downloadAction = downloadAction + self.downloadService = downloadService self.syncAction = syncAction self.repository = repository self.messageBus = messageBus self.settingsManager = settingsManager self.sessionController = sessionController } - + + func loadContentDetailsIntoCache() { + preconditionFailure("Override in a subclass please.") + } +} + +// MARK: - internal +extension ChildContentsViewModel { func initialiseIfRequired() { if state == .initial { reload() @@ -67,10 +76,7 @@ class ChildContentsViewModel: ObservableObject { func reload() { state = .loading - // Manually do this since can't have a @Published state property - objectWillChange.send() - - subscriptions.forEach({ $0.cancel() }) + subscriptions.forEach { $0.cancel() } subscriptions.removeAll() configureSubscriptions() } @@ -82,35 +88,36 @@ class ChildContentsViewModel: ObservableObject { func configureSubscriptions() { repository .childContentsState(for: parentContentID) - .sink(receiveCompletion: { [weak self] completion in - guard let self = self else { return } - if case .failure(let error) = completion, (error as? DataCacheError) == DataCacheError.cacheMiss { - self.loadContentDetailsIntoCache() - } else { - self.state = .failed - Failure - .repositoryLoad(from: "DataCacheContentDetailsViewModel", reason: "Unable to retrieve download content detail: \(completion)") - .log() + .sink( + receiveCompletion: { [weak self] completion in + guard let self = self else { return } + + switch completion { + case .failure(let error as DataCacheError) where error == .cacheMiss: + self.loadContentDetailsIntoCache() + default: + self.state = .failed + Failure + .repositoryLoad(from: Self.self, reason: "Unable to retrieve download content detail: \(completion)") + .log() + } + }, + receiveValue: { [weak self] childContentsState in + guard let self = self else { return } + + self.state = .hasData + self.contents = childContentsState.contents + self.groups = childContentsState.groups } - }, receiveValue: { [weak self] childContentsState in - guard let self = self else { return } - - self.state = .hasData - self.contents = childContentsState.contents - self.groups = childContentsState.groups - }) + ) .store(in: &subscriptions) } - func loadContentDetailsIntoCache() { - preconditionFailure("Override in a subclass please.") - } - func dynamicContentViewModel(for contentID: Int) -> DynamicContentViewModel { - DynamicContentViewModel( + .init( contentID: contentID, repository: repository, - downloadAction: downloadAction, + downloadService: downloadService, syncAction: syncAction, messageBus: messageBus, settingsManager: settingsManager, diff --git a/Emitron/Emitron/Data/ViewModels/ContentScreen.swift b/Emitron/Emitron/Data/ViewModels/ContentScreen.swift index 09495795..c1b814f8 100644 --- a/Emitron/Emitron/Data/ViewModels/ContentScreen.swift +++ b/Emitron/Emitron/Data/ViewModels/ContentScreen.swift @@ -59,7 +59,7 @@ enum ContentScreen { } } - var detailMesage: String { + var detailMessage: String { switch self { case .library: return "Try removing some filters." diff --git a/Emitron/Emitron/Data/ViewModels/DataCacheChildContentsViewModel.swift b/Emitron/Emitron/Data/ViewModels/DataCacheChildContentsViewModel.swift index c3583e82..253e35f1 100644 --- a/Emitron/Emitron/Data/ViewModels/DataCacheChildContentsViewModel.swift +++ b/Emitron/Emitron/Data/ViewModels/DataCacheChildContentsViewModel.swift @@ -31,7 +31,7 @@ final class DataCacheChildContentsViewModel: ChildContentsViewModel { init( parentContentID: Int, - downloadAction: DownloadAction, + downloadService: DownloadService, syncAction: SyncAction?, repository: Repository, service: ContentsService, @@ -42,7 +42,7 @@ final class DataCacheChildContentsViewModel: ChildContentsViewModel { self.service = service super.init( parentContentID: parentContentID, - downloadAction: downloadAction, + downloadService: downloadService, syncAction: syncAction, repository: repository, messageBus: messageBus, @@ -53,16 +53,17 @@ final class DataCacheChildContentsViewModel: ChildContentsViewModel { override func loadContentDetailsIntoCache() { state = .loading - service.contentDetails(for: parentContentID) { result in - switch result { - case .failure(let error): - self.state = .failed + Task { + do { + repository.apply( + update: try await service.contentDetails(for: parentContentID).cacheUpdate + ) + reload() + } catch { + state = .failed Failure - .fetch(from: String(describing: type(of: self)), reason: error.localizedDescription) + .fetch(from: Self.self, reason: error.localizedDescription) .log() - case .success(let (_, cacheUpdate)): - self.repository.apply(update: cacheUpdate) - self.reload() } } } diff --git a/Emitron/Emitron/Data/ViewModels/DynamicContentViewModel.swift b/Emitron/Emitron/Data/ViewModels/DynamicContentViewModel.swift index 3319ab61..42b2f85e 100644 --- a/Emitron/Emitron/Data/ViewModels/DynamicContentViewModel.swift +++ b/Emitron/Emitron/Data/ViewModels/DynamicContentViewModel.swift @@ -33,7 +33,7 @@ import class Foundation.RunLoop final class DynamicContentViewModel: ObservableObject, DynamicContentDisplayable { private let contentID: Int private let repository: Repository - private let downloadAction: DownloadAction + private let downloadService: DownloadService private weak var syncAction: SyncAction? private let messageBus: MessageBus private let settingsManager: SettingsManager @@ -47,12 +47,19 @@ final class DynamicContentViewModel: ObservableObject, DynamicContentDisplayable @Published var bookmarked = false private var subscriptions = Set() - private var downloadActionSubscriptions = Set() - init(contentID: Int, repository: Repository, downloadAction: DownloadAction, syncAction: SyncAction?, messageBus: MessageBus, settingsManager: SettingsManager, sessionController: SessionController) { + init( + contentID: Int, + repository: Repository, + downloadService: DownloadService, + syncAction: SyncAction?, + messageBus: MessageBus, + settingsManager: SettingsManager, + sessionController: SessionController + ) { self.contentID = contentID self.repository = repository - self.downloadAction = downloadAction + self.downloadService = downloadService self.syncAction = syncAction self.messageBus = messageBus self.settingsManager = settingsManager @@ -77,75 +84,64 @@ final class DynamicContentViewModel: ObservableObject, DynamicContentDisplayable switch downloadProgress { case .downloadable: - downloadAction.requestDownload(contentID: contentID) { contentID -> (ContentPersistableState?) in + Task { @MainActor in do { - return try self.repository.contentPersistableState(for: contentID) + let result = try await downloadService.requestDownload(contentID: contentID) { [repository] contentID in + do { + return try repository.contentPersistableState(for: contentID) + } catch { + Failure + .repositoryLoad(from: Self.self, reason: "Unable to locate persistable state in cache: \(error)") + .log() + throw error + } + } + + switch result { + case .downloadRequestedSuccessfully: + break + case .downloadRequestedButQueueInactive: + messageBus.post(message: Message(level: .warning, message: .downloadRequestedButQueueInactive)) + } } catch { - Failure - .repositoryLoad(from: String(describing: type(of: self)), reason: "Unable to locate persistable state in cache: \(error)") - .log() - return nil - } - } - .receive(on: RunLoop.main) - .sink(receiveCompletion: { [weak self] completion in - if case .failure(let error) = completion { - self?.messageBus.post(message: Message(level: .error, message: error.localizedDescription)) - } - }) { [weak self] result in - switch result { - case .downloadRequestedSuccessfully: - break - case .downloadRequestedButQueueInactive: - self?.messageBus.post(message: Message(level: .warning, message: .downloadRequestedButQueueInactive)) + messageBus.post(message: Message(level: .error, message: error.localizedDescription)) } } - .store(in: &downloadActionSubscriptions) - case .enqueued, .inProgress: - downloadAction.cancelDownload(contentID: contentID) - .receive(on: RunLoop.main) - .sink(receiveCompletion: { [weak self] completion in - if case .failure(let error) = completion { - self?.messageBus.post(message: Message(level: .error, message: error.localizedDescription)) - } - }) { [weak self] _ in - self?.messageBus.post(message: Message(level: .success, message: .downloadCancelled)) + Task { @MainActor in + do { + try await downloadService.cancelDownload(contentID: contentID) + messageBus.post(message: Message(level: .success, message: .downloadCancelled)) + } catch { + messageBus.post(message: Message(level: .error, message: error.localizedDescription)) } - .store(in: &downloadActionSubscriptions) - + } case .downloaded: return DownloadDeletionConfirmation( contentID: contentID, title: "Confirm Delete", message: "Are you sure you want to delete this download?" - ) { [weak self] in - guard let self = self else { return } - - self.downloadAction.deleteDownload(contentID: self.contentID) - .receive(on: RunLoop.main) - .sink(receiveCompletion: { [weak self] completion in - if case .failure(let error) = completion { - self?.messageBus.post(message: Message(level: .error, message: error.localizedDescription)) - } - }) { [weak self] _ in - self?.messageBus.post(message: Message(level: .success, message: .downloadDeleted)) + ) { [downloadService, messageBus, contentID] in + Task { @MainActor in + do { + try await downloadService.deleteDownload(contentID: contentID) + messageBus.post(message: Message(level: .success, message: .downloadDeleted)) + } catch { + messageBus.post(message: Message(level: .error, message: error.localizedDescription)) } - .store(in: &self.downloadActionSubscriptions) + } } - case .notDownloadable: - downloadAction.cancelDownload(contentID: contentID) - .receive(on: RunLoop.main) - .sink(receiveCompletion: { [weak self] completion in - if case .failure(let error) = completion { - self?.messageBus.post(message: Message(level: .error, message: error.localizedDescription)) - } - }) { [weak self] _ in - self?.messageBus.post(message: Message(level: .warning, message: .downloadReset)) + Task { @MainActor in + do { + try await downloadService.cancelDownload(contentID: contentID) + messageBus.post(message: Message(level: .warning, message: .downloadReset)) + } catch { + messageBus.post(message: Message(level: .error, message: error.localizedDescription)) } - .store(in: &downloadActionSubscriptions) + } } + return nil } @@ -161,7 +157,7 @@ final class DynamicContentViewModel: ObservableObject, DynamicContentDisplayable } catch { messageBus.post(message: Message(level: .error, message: .bookmarkDeletedError)) Failure - .viewModelAction(from: String(describing: type(of: self)), reason: "Unable to delete bookmark: \(error)") + .viewModelAction(from: Self.self, reason: "Unable to delete bookmark: \(error)") .log() } } else { @@ -171,7 +167,7 @@ final class DynamicContentViewModel: ObservableObject, DynamicContentDisplayable } catch { messageBus.post(message: Message(level: .error, message: .bookmarkCreatedError)) Failure - .viewModelAction(from: String(describing: type(of: self)), reason: "Unable to create bookmark: \(error)") + .viewModelAction(from: Self.self, reason: "Unable to create bookmark: \(error)") .log() } } @@ -188,7 +184,7 @@ final class DynamicContentViewModel: ObservableObject, DynamicContentDisplayable } catch { messageBus.post(message: Message(level: .error, message: .progressRemovedError)) Failure - .viewModelAction(from: String(describing: type(of: self)), reason: "Unable to delete progress: \(error)") + .viewModelAction(from: Self.self, reason: "Unable to delete progress: \(error)") .log() } } else { @@ -198,16 +194,19 @@ final class DynamicContentViewModel: ObservableObject, DynamicContentDisplayable } catch { messageBus.post(message: Message(level: .error, message: .progressMarkedAsCompleteError)) Failure - .viewModelAction(from: String(describing: type(of: self)), reason: "Unable to mark as complete: \(error)") + .viewModelAction(from: Self.self, reason: "Unable to mark as complete: \(error)") .log() } } } - func videoPlaybackViewModel(apiClient: RWAPI, dismissClosure: @escaping () -> Void) -> VideoPlaybackViewModel { - let videosService = VideosService(client: apiClient) - let contentsService = ContentsService(client: apiClient) - return VideoPlaybackViewModel( + func videoPlaybackViewModel( + apiClient: RWAPI, + dismissClosure: @escaping () -> Void + ) -> VideoPlaybackViewModel { + let videosService = VideosService(networkClient: apiClient) + let contentsService = ContentsService(networkClient: apiClient) + return .init( contentID: contentID, repository: repository, videosService: videosService, @@ -227,12 +226,14 @@ private extension DynamicContentViewModel { repository .contentDynamicState(for: contentID) .removeDuplicates() - .sink(receiveCompletion: { [weak self] completion in - self?.state = .failed - Failure - .repositoryLoad(from: "DynamicContentViewModel", reason: "Unable to retrieve dynamic download content: \(completion)") - .log() - }) { [weak self] contentState in + .sink( + receiveCompletion: { [weak self] completion in + self?.state = .failed + Failure + .repositoryLoad(from: Self.self, reason: "Unable to retrieve dynamic download content: \(completion)") + .log() + } + ) { [weak self] contentState in guard let self = self else { return } self.viewProgress = ContentViewProgressDisplayable(progression: contentState.progression) diff --git a/Emitron/Emitron/Data/ViewModels/PersistenceStoreChildContentsViewModel.swift b/Emitron/Emitron/Data/ViewModels/PersistenceStoreChildContentsViewModel.swift index 85fecde9..eb3c83b4 100644 --- a/Emitron/Emitron/Data/ViewModels/PersistenceStoreChildContentsViewModel.swift +++ b/Emitron/Emitron/Data/ViewModels/PersistenceStoreChildContentsViewModel.swift @@ -26,14 +26,11 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import class Foundation.DispatchQueue - final class PersistenceStoreChildContentsViewModel: ChildContentsViewModel { - override func loadContentDetailsIntoCache() { do { try repository.loadDownloadedChildContentsIntoCache(for: parentContentID) - DispatchQueue.main.async(execute: reload) + Task { @MainActor in reload() } } catch { state = .failed messageBus.post(message: Message(level: .error, message: .downloadedContentNotFound)) diff --git a/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift b/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift index bb75289a..4723e170 100644 --- a/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift +++ b/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift @@ -73,7 +73,7 @@ extension VideoPlaybackViewModel { } extension Notification.Name { - static let requestReview = Notification.Name("requestReview") + static let requestReview = Self("requestReview") } final class VideoPlaybackViewModel { @@ -89,9 +89,9 @@ final class VideoPlaybackViewModel { private let dismiss: () -> Void // These are the content models that this view model is capable of playing. In this order. - private var contentList = [VideoPlaybackState]() + private var contentList: [VideoPlaybackState] = [] // A cache of playback items, and a way of finding the content model for the currently playing item - private var playerItems = [Int: AVPlayerItem]() + private var playerItems: [Int: AVPlayerItem] = [:] private var currentlyPlayingContentID: Int? { guard let currentItem = player.currentItem, let contentID = playerItems.first(where: { $1 == currentItem })?.key @@ -104,7 +104,6 @@ final class VideoPlaybackViewModel { contentList[nextContentToEnqueueIndex] } private var subscriptions = Set() - private var currentItemStateSubscription: AnyCancellable? let player = AVQueuePlayer() let messageBus: MessageBus @@ -197,7 +196,7 @@ final class VideoPlaybackViewModel { } } catch { Failure - .viewModelAction(from: String(describing: type(of: self)), reason: "Unable to load playlist: \(error)") + .viewModelAction(from: Self.self, reason: "Unable to load playlist: \(error)") .log() } } @@ -238,30 +237,26 @@ private extension VideoPlaybackViewModel { .sink { [weak self] rate in self?.shouldBePlaying = rate == 0 - guard let self = self, - ![0, self.settingsManager.playbackSpeed.rate].contains(rate) - else { return } + guard + let self = self, + ![0, self.settingsManager.playbackSpeed.rate].contains(rate) + else { return } self.player.rate = self.settingsManager.playbackSpeed.rate } .store(in: &subscriptions) - player.publisher(for: \.currentItem) + player.publisher(for: \.currentItem?.status) .removeDuplicates() - .sink { [weak self] item in - guard let self = self, - let item = item else { return } - - self.currentItemStateSubscription = item.publisher(for: \.status) - .removeDuplicates() - .sink { [weak self] status in - guard let self = self, - status == .readyToPlay, - self.shouldBePlaying, - self.player.rate == 0 else { return } - - self.player.play() - } + .sink { [weak self] status in + guard + let self = self, + case .readyToPlay = status, + self.shouldBePlaying, + self.player.rate == 0 + else { return } + + self.player.play() } .store(in: &subscriptions) @@ -287,8 +282,7 @@ private extension VideoPlaybackViewModel { .publisher(for: .AVPlayerItemDidPlayToEndTime) .sink { [weak self] _ in guard let self = self else { return } - - if self.player.items().last == self.player.currentItem { + if self.player.currentItem == self.player.items().last { // We're done. Let's dismiss the player self.dismiss() } @@ -298,25 +292,26 @@ private extension VideoPlaybackViewModel { func handleTimeUpdate(time: CMTime) { guard let currentlyPlayingContentID = currentlyPlayingContentID else { return } + // Update progress - progressEngine.updateProgress(for: currentlyPlayingContentID, progress: Int(time.seconds)) - .sink(receiveCompletion: { [weak self] completion in - guard let self = self else { return } - - if case .failure(let error) = completion { - if case .simultaneousStreamsNotAllowed = error { - self.messageBus.post(message: Message(level: .error, message: .simultaneousStreamsError)) - self.player.pause() - } - Failure - .viewModelAction(from: String(describing: type(of: self)), reason: "Error updating progress: \(error)") - .log() + Task { + do { + update( + progression: try await progressEngine.updateProgress( + for: currentlyPlayingContentID, + progress: Int(time.seconds) + ) + ) + } catch { + if case ProgressEngineError.simultaneousStreamsNotAllowed = error { + messageBus.post(message: .init(level: .error, message: .simultaneousStreamsError)) + await player.pause() } - }) { [weak self] updatedProgression in - guard let self = self else { return } - self.update(progression: updatedProgression) + Failure + .viewModelAction(from: Self.self, reason: "Error updating progress: \(error)") + .log() } - .store(in: &subscriptions) + } // Check whether we need to enqueue the next one yet if state == .loading || state == .loadingAdditional { @@ -342,85 +337,72 @@ private extension VideoPlaybackViewModel { func enqueue(index: Int, startTime: Double? = nil) { state = .loadingAdditional let nextContent = contentList[index] + guard sessionController.canPlay(content: nextContent.content) else { // This user doesn't have permission to play this content. So skip to the next. nextContentToEnqueueIndex += 1 return enqueueNext() } - avItem(for: nextContent) - .sink(receiveCompletion: { [weak self] completion in - guard let self = self else { return } - switch completion { - case .finished: - self.state = .hasData - case .failure(let error): - self.state = .failed - Failure - .viewModelAction(from: String(describing: type(of: self)), reason: "Unable to enqueue next playlist item: \(error))") - .log() - } - }) { [weak self] playerItem in - guard let self = self else { return } + + Task { + do { + let playerItem = try await avItem(for: nextContent) // Try to seek if needed if let startTime = startTime { - playerItem.seek(to: CMTime(seconds: startTime, preferredTimescale: 100)) { [weak self] _ in - guard let self = self else { return } - self.player.insert(playerItem, after: nil) - } - } else { - // Append it to the end of the player queue - self.player.insert(playerItem, after: nil) + await playerItem.seek(to: .init(seconds: startTime, preferredTimescale: 100)) } + + // Append it to the end of the player queue + player.insert(playerItem, after: nil) + // Move the current content item pointer - self.nextContentToEnqueueIndex += 1 + nextContentToEnqueueIndex += 1 + state = .hasData + } catch { + state = .failed + Failure + .viewModelAction(from: Self.self, reason: "Unable to enqueue next playlist item: \(error))") + .log() } - .store(in: &subscriptions) + } } - func avItem(for state: VideoPlaybackState) -> Future { + func avItem(for state: VideoPlaybackState) async throws -> AVPlayerItem { // Do we already have it it in cache? if let item = playerItems[state.content.id] { - return Future { $0(.success(item)) } + return item + } + + // Is there a completed download? + if + let download = state.download, + download.state == .complete, + let localURL = download.localURL + { + let item = AVPlayerItem(asset: AVURLAsset(url: localURL)) + addMetadata(from: state, to: item) + addClosedCaptions(for: item) + // Add it to the cache + playerItems[state.content.id] = item + return item } - - return createAvItem(for: state) - } - - func createAvItem(for state: VideoPlaybackState) -> Future { - .init { promise in - // Is there a completed download? - if let download = state.download, - download.state == .complete, - let localURL = download.localURL { - let asset = AVURLAsset(url: localURL) - let item = AVPlayerItem(asset: asset) - self.addMetadata(from: state, to: item) - self.addClosedCaptions(for: item) - // Add it to the cache - self.playerItems[state.content.id] = item - return promise(.success(item)) - } - - // We're gonna need to stream it. - guard let videoIdentifier = state.content.videoIdentifier else { - return promise(.failure(Error.invalidOrMissingAttribute("videoIdentifier"))) - } - self.videosService.getVideoStream(for: videoIdentifier) { result in - switch result { - case .failure(let error): - return promise(.failure(error)) - case .success(let response): - guard response.kind == .stream else { return promise(.failure(Error.invalidOrMissingAttribute("Not A Stream"))) } - let item = AVPlayerItem(url: response.url) - self.addMetadata(from: state, to: item) - self.addClosedCaptions(for: item) - // Add it to the cache - self.playerItems[state.content.id] = item - return promise(.success(item)) - } - } + // We're gonna need to stream it. + guard let videoIdentifier = state.content.videoIdentifier else { + throw Error.invalidOrMissingAttribute("videoIdentifier") } + + let attachment = try await videosService.videoStream(for: videoIdentifier) + + guard attachment.kind == .stream + else { throw Error.invalidOrMissingAttribute("Not A Stream") } + + let item = AVPlayerItem(url: attachment.url) + self.addMetadata(from: state, to: item) + self.addClosedCaptions(for: item) + // Add it to the cache + self.playerItems[state.content.id] = item + return item } func addClosedCaptions(for playerItem: AVPlayerItem) { @@ -453,15 +435,12 @@ private extension VideoPlaybackViewModel { request.respond(error: Error.unableToLoadArtwork) return } - - let task = URLSession.shared.dataTask(with: url) { data, _, _ in - guard let data = data else { - request.respond(error: Error.unableToLoadArtwork) - return - } - request.respond(value: data as NSData) + + Task { + request.respond( + value: try await URLSession.shared.data(from: url).0 as NSData + ) } - task.resume() } playerItem.externalMetadata = [title, description, deferredArtwork] @@ -469,7 +448,8 @@ private extension VideoPlaybackViewModel { func update(progression: Progression) { // Find appropriate playback state - guard let contentIndex = contentList.firstIndex(where: { $0.content.id == progression.id }) else { return } + guard let contentIndex = (contentList.firstIndex { $0.content.id == progression.id }) + else { return } let currentState = contentList[contentIndex] contentList[contentIndex] = VideoPlaybackState( diff --git a/Emitron/Emitron/Displayable/ContentSummaryState+ContentListDisplayable.swift b/Emitron/Emitron/Displayable/ContentSummaryState+ContentListDisplayable.swift index e8533d63..f30feef3 100644 --- a/Emitron/Emitron/Displayable/ContentSummaryState+ContentListDisplayable.swift +++ b/Emitron/Emitron/Displayable/ContentSummaryState+ContentListDisplayable.swift @@ -41,12 +41,14 @@ extension ContentSummaryState: ContentListDisplayable { var name: String { content.name } var cardViewSubtitle: String { - if domains.count == 1 { + switch domains.count { + case 1: return domains.first!.name - } else if domains.count > 1 { + case 2...: return "Multi-platform" + default: + return .init() } - return "" } var descriptionPlainText: String { diff --git a/Emitron/Emitron/Downloads/DownloadAction.swift b/Emitron/Emitron/Downloads/DownloadAction.swift index cd88742d..80c6e8fc 100644 --- a/Emitron/Emitron/Downloads/DownloadAction.swift +++ b/Emitron/Emitron/Downloads/DownloadAction.swift @@ -55,9 +55,3 @@ enum DownloadActionError: Error { } } } - -protocol DownloadAction { - func requestDownload(contentID: Int, contentLookup: @escaping ContentLookup) -> AnyPublisher - func cancelDownload(contentID: Int) -> AnyPublisher - func deleteDownload(contentID: Int) -> AnyPublisher -} diff --git a/Emitron/Emitron/Downloads/DownloadProcessor.swift b/Emitron/Emitron/Downloads/DownloadProcessor.swift index 3c327b9c..9756fc52 100644 --- a/Emitron/Emitron/Downloads/DownloadProcessor.swift +++ b/Emitron/Emitron/Downloads/DownloadProcessor.swift @@ -37,12 +37,12 @@ protocol DownloadProcessorModel { } protocol DownloadProcessorDelegate: AnyObject { - func downloadProcessor(_ processor: DownloadProcessor, downloadModelForDownloadWithID downloadID: UUID) -> DownloadProcessorModel? - func downloadProcessor(_ processor: DownloadProcessor, didStartDownloadWithID downloadID: UUID) - func downloadProcessor(_ processor: DownloadProcessor, downloadWithID downloadID: UUID, didUpdateProgress progress: Double) - func downloadProcessor(_ processor: DownloadProcessor, didFinishDownloadWithID downloadID: UUID) - func downloadProcessor(_ processor: DownloadProcessor, didCancelDownloadWithID downloadID: UUID) - func downloadProcessor(_ processor: DownloadProcessor, downloadWithID downloadID: UUID, didFailWithError error: Error) + func downloadProcessor(downloadModelForDownloadWithID downloadID: UUID) -> DownloadProcessorModel? + func downloadProcessor(didStartDownloadWithID downloadID: UUID) + func downloadProcessor(downloadWithID downloadID: UUID, didUpdateProgress progress: Double) + func downloadProcessor(didFinishDownloadWithID downloadID: UUID) + func downloadProcessor(didCancelDownloadWithID downloadID: UUID) + func downloadProcessor(downloadWithID downloadID: UUID, didFailWithError error: Error) } private extension URLSessionDownloadTask { @@ -76,7 +76,7 @@ final class DownloadProcessor: NSObject { init(settingsManager: SettingsManager) { self.settingsManager = settingsManager super.init() - populateDownloadListFromSession() + Task { await populateDownloadListFromSession() } } private lazy var session: AVAssetDownloadURLSession = { @@ -94,7 +94,7 @@ final class DownloadProcessor: NSObject { } extension DownloadProcessor { - func add(download: DownloadProcessorModel) throws { + func add(download: Model) throws { guard let remoteURL = download.remoteURL else { throw DownloadProcessorError.invalidArguments } let hlsAsset = AVURLAsset(url: remoteURL) var options: [String: Any]? @@ -108,10 +108,10 @@ extension DownloadProcessor { currentDownloads.append(downloadTask) - delegate.downloadProcessor(self, didStartDownloadWithID: download.id) + delegate.downloadProcessor(didStartDownloadWithID: download.id) } - func cancelDownload(_ download: DownloadProcessorModel) throws { + func cancelDownload(_ download: Model) throws { guard let downloadTask = currentDownloads.first(where: { $0.downloadID == download.id }) else { throw DownloadProcessorError.unknownDownload } downloadTask.cancel() @@ -130,33 +130,23 @@ extension DownloadProcessor { } } -extension DownloadProcessor { - private func getDownloadTasksFromSession() -> [AVAssetDownloadTask] { - var tasks = [AVAssetDownloadTask]() - // Use a semaphore to make an async call synchronous - // --There's no point in trying to complete instantiating this class without this list. - let semaphore = DispatchSemaphore(value: 0) - session.getAllTasks { downloadTasks in - - let myTasks = downloadTasks as! [AVAssetDownloadTask] - tasks = myTasks - semaphore.signal() - } - - _ = semaphore.wait(timeout: .distantFuture) - - return tasks - } - - private func populateDownloadListFromSession() { - currentDownloads = getDownloadTasksFromSession() +// MARK: - private +private extension DownloadProcessor { + // --There's no point in trying to complete instantiating this class without this list. + func populateDownloadListFromSession() async { + currentDownloads = await session.allTasks as! [AVAssetDownloadTask] } } +// MARK: - AVAssetDownloadDelegate extension DownloadProcessor: AVAssetDownloadDelegate { - - func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange) { - + func urlSession( + _: URLSession, + assetDownloadTask: AVAssetDownloadTask, + didLoad timeRange: CMTimeRange, + totalTimeRangesLoaded loadedTimeRanges: [NSValue], + timeRangeExpectedToLoad: CMTimeRange + ) { guard let downloadID = assetDownloadTask.downloadID else { return } var percentComplete = 0.0 @@ -171,41 +161,45 @@ extension DownloadProcessor: AVAssetDownloadDelegate { return } throttleList[downloadID] = percentComplete - delegate.downloadProcessor(self, downloadWithID: downloadID, didUpdateProgress: percentComplete) + delegate.downloadProcessor(downloadWithID: downloadID, didUpdateProgress: percentComplete) } - func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) { + func urlSession(_: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) { guard let downloadID = assetDownloadTask.downloadID, let delegate = delegate else { return } - let download = delegate.downloadProcessor(self, downloadModelForDownloadWithID: downloadID) + let download = delegate.downloadProcessor(downloadModelForDownloadWithID: downloadID) guard let localURL = download?.localURL else { return } - let fileManager = FileManager.default do { - if fileManager.fileExists(atPath: localURL.path) { - try fileManager.removeItem(at: localURL) - } - try fileManager.moveItem(at: location, to: localURL) + try FileManager.removeExistingFile(at: localURL) + try FileManager.default.moveItem(at: location, to: localURL) } catch { - delegate.downloadProcessor(self, downloadWithID: downloadID, didFailWithError: error) + delegate.downloadProcessor(downloadWithID: downloadID, didFailWithError: error) } } } +// MARK: - URLSessionDownloadDelegate extension DownloadProcessor: URLSessionDownloadDelegate { // When the background session has finished sending us events, we can tell the system we're done. - func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + func urlSessionDidFinishEvents(forBackgroundURLSession _: URLSession) { guard let backgroundSessionCompletionHandler = backgroundSessionCompletionHandler else { return } // Need to marshal back to the main queue - DispatchQueue.main.async(execute: backgroundSessionCompletionHandler) + Task { @MainActor in backgroundSessionCompletionHandler() } } // Used to update the progress stats of a download task - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + func urlSession( + _: URLSession, + downloadTask: URLSessionDownloadTask, + didWriteData _: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64 + ) { guard let downloadID = downloadTask.downloadID else { return } let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) @@ -218,28 +212,36 @@ extension DownloadProcessor: URLSessionDownloadDelegate { // Update the throttle list and make the delegate call throttleList[downloadID] = progress - delegate.downloadProcessor(self, downloadWithID: downloadID, didUpdateProgress: progress) + delegate.downloadProcessor(downloadWithID: downloadID, didUpdateProgress: progress) } // Download completed—move the file to the appropriate place and update the DB - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - guard let downloadID = downloadTask.downloadID, - let delegate = delegate else { return } + func urlSession( + _: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + guard + let downloadID = downloadTask.downloadID, + let delegate = delegate + else { return } - let download = delegate.downloadProcessor(self, downloadModelForDownloadWithID: downloadID) + let download = delegate.downloadProcessor(downloadModelForDownloadWithID: downloadID) guard let localURL = download?.localURL else { return } - let fileManager = FileManager.default do { - try fileManager.moveItem(at: location, to: localURL) + try FileManager.default.moveItem(at: location, to: localURL) } catch { - delegate.downloadProcessor(self, downloadWithID: downloadID, didFailWithError: error) + delegate.downloadProcessor(downloadWithID: downloadID, didFailWithError: error) } } // Use this to handle and client-side download errors - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - + func urlSession( + _: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { guard let downloadTask = task as? AVAssetDownloadTask, let downloadID = downloadTask.downloadID else { return } if let error = error as NSError? { @@ -252,18 +254,18 @@ extension DownloadProcessor: URLSessionDownloadDelegate { // User-requested cancellation currentDownloads.removeAll { $0 == downloadTask } - delegate.downloadProcessor(self, didCancelDownloadWithID: downloadID) + delegate.downloadProcessor(didCancelDownloadWithID: downloadID) } else { // Unknown error currentDownloads.removeAll { $0 == downloadTask } - delegate.downloadProcessor(self, downloadWithID: downloadID, didFailWithError: error) + delegate.downloadProcessor(downloadWithID: downloadID, didFailWithError: error) } } else { // Success! currentDownloads.removeAll { $0 == downloadTask } - delegate.downloadProcessor(self, didFinishDownloadWithID: downloadID) + delegate.downloadProcessor(didFinishDownloadWithID: downloadID) } } } diff --git a/Emitron/Emitron/Downloads/DownloadQueueManager.swift b/Emitron/Emitron/Downloads/DownloadQueueManager.swift index fa06484c..24e1b935 100644 --- a/Emitron/Emitron/Downloads/DownloadQueueManager.swift +++ b/Emitron/Emitron/Downloads/DownloadQueueManager.swift @@ -29,8 +29,8 @@ import Combine final class DownloadQueueManager { - private let maxSimultaneousDownloads: Int private let persistenceStore: PersistenceStore + private let maxSimultaneousDownloads: Int private(set) lazy var pendingStream: AnyPublisher = persistenceStore @@ -48,7 +48,7 @@ final class DownloadQueueManager { .eraseToAnyPublisher() init(persistenceStore: PersistenceStore, maxSimultaneousDownloads: Int = 2) { - self.maxSimultaneousDownloads = maxSimultaneousDownloads self.persistenceStore = persistenceStore + self.maxSimultaneousDownloads = maxSimultaneousDownloads } } diff --git a/Emitron/Emitron/Downloads/DownloadService.swift b/Emitron/Emitron/Downloads/DownloadService.swift index 3d550a93..4528d9f5 100644 --- a/Emitron/Emitron/Downloads/DownloadService.swift +++ b/Emitron/Emitron/Downloads/DownloadService.swift @@ -34,43 +34,55 @@ final class DownloadService: ObservableObject { enum Status { case active case inactive - - static func status(expensive: Bool, expensiveAllowed: Bool) -> Status { - if expensive && !expensiveAllowed { - return .inactive - } - return .active + + init(expensive: Bool, expensiveAllowed: Bool) { + self = + expensive && !expensiveAllowed + ? .inactive + : .active } } + + init( + persistenceStore: PersistenceStore, + userModelController: UserModelController, + videosServiceProvider: VideosService.Provider? = nil, + settingsManager: SettingsManager + ) { + self.persistenceStore = persistenceStore + self.userModelController = userModelController + downloadProcessor = .init(settingsManager: settingsManager) + queueManager = DownloadQueueManager(persistenceStore: persistenceStore, maxSimultaneousDownloads: 3) + self.videosServiceProvider = videosServiceProvider ?? { VideosService(networkClient: $0) } + self.settingsManager = settingsManager + userModelControllerSubscription = userModelController.objectDidChange.sink { [weak self] in + self?.stopProcessing() + self?.checkPermissions() + self?.startProcessing() + } + downloadProcessor.delegate = self + checkPermissions() + } // MARK: Properties + let settingsManager: SettingsManager + private let persistenceStore: PersistenceStore private let userModelController: UserModelController private var userModelControllerSubscription: AnyCancellable? private let videosServiceProvider: VideosService.Provider - private var videosService: VideosService? + private var videosService: (any VideosServiceProtocol)? private let queueManager: DownloadQueueManager private let downloadProcessor: DownloadProcessor private var processingSubscriptions = Set() - private let networkMonitor = NWPathMonitor() private var status: Status = .inactive private var settingsSubscription: AnyCancellable? private var downloadQueueSubscription: AnyCancellable? - - private var downloadQuality: Attachment.Kind { - settingsManager.downloadQuality - } - private lazy var downloadsDirectory: URL = { - let fileManager = FileManager.default - let documentsDirectories = fileManager.urls(for: .documentDirectory, in: .userDomainMask) - guard let documentsDirectory = documentsDirectories.first else { - preconditionFailure("Unable to locate the documents directory") - } - - return documentsDirectory.appendingPathComponent("downloads", isDirectory: true) - }() - +} + +// MARK: - internal +extension DownloadService { var backgroundSessionCompletionHandler: (() -> Void)? { get { downloadProcessor.backgroundSessionCompletionHandler @@ -80,54 +92,41 @@ final class DownloadService: ObservableObject { } } - let settingsManager: SettingsManager - - // MARK: Initialisers - init(persistenceStore: PersistenceStore, userModelController: UserModelController, videosServiceProvider: VideosService.Provider? = .none, settingsManager: SettingsManager) { - self.persistenceStore = persistenceStore - self.userModelController = userModelController - downloadProcessor = DownloadProcessor(settingsManager: settingsManager) - queueManager = DownloadQueueManager(persistenceStore: persistenceStore, maxSimultaneousDownloads: 3) - self.videosServiceProvider = videosServiceProvider ?? { VideosService(client: $0) } - self.settingsManager = settingsManager - userModelControllerSubscription = userModelController.objectDidChange.sink { [weak self] in - self?.stopProcessing() - self?.checkPermissions() - self?.startProcessing() - } - downloadProcessor.delegate = self - checkPermissions() - } - // MARK: Queue Management func startProcessing() { // Make sure that we can't start multiple processing subscriptions stopProcessing() queueManager.pendingStream - .sink(receiveCompletion: { completion in - Failure - .repositoryLoad(from: String(describing: type(of: self)), reason: "Error: \(completion)") - .log() - }, receiveValue: { [weak self] downloadQueueItem in - guard let self = self, let downloadQueueItem = downloadQueueItem else { return } - self.requestDownloadURL(downloadQueueItem) - }) + .sink( + receiveCompletion: { completion in + Failure + .repositoryLoad(from: Self.self, reason: "Error: \(completion)") + .log() + }, + receiveValue: { [weak self] downloadQueueItem in + guard let self = self, let downloadQueueItem = downloadQueueItem else { return } + Task { await self.requestDownloadURL(downloadQueueItem) } + } + ) .store(in: &processingSubscriptions) queueManager.readyForDownloadStream - .sink(receiveCompletion: { completion in - Failure - .repositoryLoad(from: String(describing: type(of: self)), reason: "Error: \(completion)") - .log() - }, receiveValue: { [weak self] downloadQueueItem in - guard let self = self, let downloadQueueItem = downloadQueueItem else { return } - self.enqueue(downloadQueueItem: downloadQueueItem) - }) + .sink( + receiveCompletion: { completion in + Failure + .repositoryLoad(from: Self.self, reason: "Error: \(completion)") + .log() + }, + receiveValue: { [weak self] downloadQueueItem in + guard let self = self, let downloadQueueItem = downloadQueueItem else { return } + Task { await self.enqueue(downloadQueueItem: downloadQueueItem) } + } + ) .store(in: &processingSubscriptions) // The download queue subscription is part of the // network monitoring process. - checkQueueStatus() + Task { await checkQueueStatus() } } func stopProcessing() { @@ -136,57 +135,56 @@ final class DownloadService: ObservableObject { pauseQueue() } -} -// MARK: - DownloadAction Methods -extension DownloadService: DownloadAction { - func requestDownload(contentID: Int, contentLookup: @escaping ContentLookup) -> AnyPublisher { + func requestDownload( + contentID: Int, + contentLookup: @escaping ContentLookup + ) async throws -> RequestDownloadResult { guard videosService != nil else { Failure - .fetch(from: String(describing: type(of: self)), reason: "User not allowed to request downloads") + .fetch(from: Self.self, reason: "User not allowed to request downloads") .log() - return Future { promise in - promise(.failure(.problemRequestingDownload)) - } - .eraseToAnyPublisher() + throw DownloadActionError.problemRequestingDownload } - - guard let contentPersistableState = contentLookup(contentID) else { + + let contentPersistableState: ContentPersistableState + + do { + contentPersistableState = try contentLookup(contentID) + } catch { Failure - .loadFromPersistentStore(from: String(describing: type(of: self)), reason: "Unable to locate content to persist") + .loadFromPersistentStore(from: Self.self, reason: "Unable to locate content to persist") .log() - return Future { promise in - promise(.failure(.problemRequestingDownload)) - } - .eraseToAnyPublisher() + throw DownloadActionError.problemRequestingDownload } - // Let's ensure that all the relevant content is stored locally - return persistenceStore.persistContentGraph( + // Let's ensure that all the relevant content is stored locally + try await persistenceStore.persistContentGraph( for: contentPersistableState, contentLookup: contentLookup ) - .flatMap { - self.persistenceStore.createDownloads(for: contentPersistableState.content) - } - .map { - switch self.status { + + do { + try await persistenceStore.createDownloads(for: contentPersistableState.content) + + switch status { case .active: - return RequestDownloadResult.downloadRequestedSuccessfully + return .downloadRequestedSuccessfully case .inactive: - return RequestDownloadResult.downloadRequestedButQueueInactive + return .downloadRequestedButQueueInactive } - } - .mapError { error in + } catch { Failure - .saveToPersistentStore(from: String(describing: type(of: self)), reason: "There was a problem requesting the download: \(error)") + .saveToPersistentStore( + from: Self.self, + reason: "There was a problem requesting the download: \(error)" + ) .log() - return DownloadActionError.problemRequestingDownload + throw DownloadActionError.problemRequestingDownload } - .eraseToAnyPublisher() } - func cancelDownload(contentID: Int) -> AnyPublisher { + func cancelDownload(contentID: Int) async throws { var contentIDs = [Int]() // 0. If there are some children, then let's find their content ids too @@ -203,33 +201,24 @@ extension DownloadService: DownloadAction { let currentlyDownloading = downloads.filter(\.isDownloading) let notYetDownloading = downloads.filter { !$0.isDownloading } - return Future { promise in - do { - // It's in the download process, so let's ask it to cancel it. - // The delegate callback will handle deleting the value in - // the persistence store. - try currentlyDownloading.forEach { try self.downloadProcessor.cancelDownload($0) } - promise(.success(())) - } catch { - promise(.failure(error)) - } - } - .flatMap { _ in + do { + // It's in the download process, so let's ask it to cancel it. + // The delegate callback will handle deleting the value in + // the persistence store. + try currentlyDownloading.forEach(downloadProcessor.cancelDownload) + // Don't have it in the processor, so we just need to // delete the download model - self.persistenceStore - .deleteDownloads(withIDs: notYetDownloading.map(\.id)) - } - .mapError { error in + try await persistenceStore.deleteDownloads(withIDs: notYetDownloading.map(\.id)) + } catch { Failure - .deleteFromPersistentStore(from: String(describing: type(of: self)), reason: "There was a problem cancelling the download (contentID: \(contentID)): \(error)") + .deleteFromPersistentStore(from: Self.self, reason: "There was a problem cancelling the download (contentID: \(contentID)): \(error)") .log() - return DownloadActionError.unableToCancelDownload + throw DownloadActionError.unableToCancelDownload } - .eraseToAnyPublisher() } - func deleteDownload(contentID: Int) -> AnyPublisher { + func deleteDownload(contentID: Int) async throws { var contentIDs = [Int]() // 0. If there are some children, the let's find their content ids too @@ -243,117 +232,121 @@ extension DownloadService: DownloadAction { try? persistenceStore.download(forContentID: $0) } - return Future { promise in - do { - // 2. Delete the file from disk - try downloads - .filter { $0.isDownloaded } - .forEach(self.deleteFile) - promise(.success(())) - } catch { - promise(.failure(error)) - } - } - .flatMap { - // 3. Delete the persisted record - self.persistenceStore - .deleteDownloads(withIDs: downloads.map(\.id)) - } - .mapError { error in + do { + // 2. Delete the file from disk + try downloads + .filter { $0.isDownloaded } + .forEach(self.deleteFile) + + try await persistenceStore.deleteDownloads(withIDs: downloads.map(\.id)) + } catch { Failure - .deleteFromPersistentStore(from: String(describing: type(of: self)), reason: "There was a problem deleting the download (contentID: \(contentID)): \(error)") + .deleteFromPersistentStore( + from: Self.self, + reason: "There was a problem deleting the download (contentID: \(contentID)): \(error)") .log() - return DownloadActionError.unableToDeleteDownload + throw DownloadActionError.unableToDeleteDownload } - .eraseToAnyPublisher() } -} -// MARK: - Internal methods -extension DownloadService { - func requestDownloadURL(_ downloadQueueItem: PersistenceStore.DownloadQueueItem) { + func requestDownloadURL(_ downloadQueueItem: PersistenceStore.DownloadQueueItem) async { guard let videosService = videosService else { Failure .downloadService( - from: "requestDownloadURL", + from: #function, reason: "User not allowed to request downloads." ) .log() return } - guard downloadQueueItem.download.remoteURL == nil, + + guard + downloadQueueItem.download.remoteURL == nil, downloadQueueItem.download.state == .pending, - downloadQueueItem.content.contentType != .collection else { - Failure - .downloadService(from: "requestDownloadURL", - reason: "Cannot request download URL for: \(downloadQueueItem.download)") - .log() + downloadQueueItem.content.contentType != .collection + else { + Failure + .downloadService( + from: #function, + reason: "Cannot request download URL for: \(downloadQueueItem.download)" + ) + .log() return } + // Find the video ID - guard let videoID = downloadQueueItem.content.videoIdentifier, - videoID != 0 else { - Failure - .downloadService( - from: "requestDownloadURL", - reason: "Unable to locate videoID for download: \(downloadQueueItem.download)" - ) - .log() + guard + let videoID = downloadQueueItem.content.videoIdentifier, + videoID != 0 + else { + Failure + .downloadService( + from: #function, + reason: "Unable to locate videoID for download: \(downloadQueueItem.download)" + ) + .log() return } // Use the video service to request the URLs - videosService.getVideoStreamDownload(for: videoID) { [weak self] result in - // Ensure we're still around - guard let self = self else { return } - var download = downloadQueueItem.download + var download = downloadQueueItem.download - switch result { - case .failure(let error): - Failure - .downloadService(from: "requestDownloadURL", - reason: "Unable to obtain download URLs: \(error)") - .log() - case .success(let attachment): - download.remoteURL = attachment.url - download.lastValidatedAt = .now - download.state = .readyForDownload - } + do { + let attachment = try await videosService.videoStreamDownload(for: videoID) + download.remoteURL = attachment.url + download.lastValidatedAt = .now + download.state = .readyForDownload + } catch { + Failure + .downloadService( + from: #function, + reason: "Unable to obtain download URLs: \(error)" + ) + .log() + } - // Update the state if required - if download.remoteURL == nil { - download.state = .error - } + // Update the state if required + if download.remoteURL == nil { + download.state = .error + } - // Commit the changes - do { - try self.persistenceStore.update(download: download) - } catch { - Failure - .downloadService(from: "requestDownloadURL", - reason: "Unable to save download URL: \(error)") - .log() - self.transitionDownload(withID: download.id, to: .failed) - } + // Commit the changes + do { + try await persistenceStore.update(download: download) + } catch { + Failure + .downloadService( + from: #function, + reason: "Unable to save download URL: \(error)" + ) + .log() + await transitionDownload(withID: download.id, to: .failed) } + // Move it on through the state machine - transitionDownload(withID: downloadQueueItem.download.id, to: .urlRequested) + await transitionDownload(withID: downloadQueueItem.download.id, to: .urlRequested) } - func enqueue(downloadQueueItem: PersistenceStore.DownloadQueueItem) { - guard downloadQueueItem.download.remoteURL != nil, - downloadQueueItem.download.state == .readyForDownload else { - Failure - .downloadService(from: "enqueue", - reason: "Cannot enqueue download: \(downloadQueueItem.download)") - .log() + func enqueue(downloadQueueItem: PersistenceStore.DownloadQueueItem) async { + guard + downloadQueueItem.download.remoteURL != nil, + downloadQueueItem.download.state == .readyForDownload + else { + Failure + .downloadService( + from: #function, + reason: "Cannot enqueue download: \(downloadQueueItem.download)" + ) + .log() return } // Find the video ID guard let videoID = downloadQueueItem.content.videoIdentifier else { Failure - .downloadService(from: "enqueue", - reason: "Unable to locate videoID for download: \(downloadQueueItem.download)") + .downloadService( + from: #function, + reason: "Unable to locate videoID for download: \(downloadQueueItem.download)" + ) .log() return } @@ -367,8 +360,7 @@ extension DownloadService { // Transition download to correct status // If file exists, update the download - let fileManager = FileManager.default - if let localURL = download.localURL, fileManager.fileExists(atPath: localURL.path) { + if let localURL = download.localURL, FileManager.default.fileExists(atPath: localURL.path) { download.state = .complete } else { download.state = .enqueued @@ -376,10 +368,10 @@ extension DownloadService { // Save do { - try persistenceStore.update(download: download) + try await persistenceStore.update(download: download) } catch { Failure - .saveToPersistentStore(from: String(describing: type(of: self)), reason: "Unable to enqueue download: \(error)") + .saveToPersistentStore(from: Self.self, reason: "Unable to enqueue download: \(error)") .log() } } @@ -387,14 +379,15 @@ extension DownloadService { private func prepareDownloadDirectory() { let fileManager = FileManager.default do { - if !fileManager.fileExists(atPath: downloadsDirectory.path) { - try fileManager.createDirectory(at: downloadsDirectory, withIntermediateDirectories: false) + if !fileManager.fileExists(atPath: URL.downloadsDirectory.path) { + try fileManager.createDirectory(at: .downloadsDirectory, withIntermediateDirectories: false) } var values = URLResourceValues() values.isExcludedFromBackup = true + var downloadsDirectory = URL.downloadsDirectory try downloadsDirectory.setResourceValues(values) #if DEBUG - print("Download directory located at: \(downloadsDirectory.path)") + print("Download directory located at: \(URL.downloadsDirectory.path)") #endif } catch { preconditionFailure("Unable to prepare downloads directory: \(error)") @@ -402,11 +395,8 @@ extension DownloadService { } private func deleteExistingDownloads() { - let fileManager = FileManager.default do { - if fileManager.fileExists(atPath: downloadsDirectory.path) { - try fileManager.removeItem(at: downloadsDirectory) - } + try FileManager.removeExistingFile(at: .downloadsDirectory) prepareDownloadDirectory() } catch { preconditionFailure("Unable to delete the contents of the downloads directory: \(error)") @@ -415,17 +405,14 @@ extension DownloadService { try persistenceStore.erase() } catch { Failure - .deleteFromPersistentStore(from: String(describing: type(of: self)), reason: "Unable to destroy all downloads") + .deleteFromPersistentStore(from: Self.self, reason: "Unable to destroy all downloads") .log() } } private func deleteFile(for download: Download) throws { guard let localURL = download.localURL else { return } - let filemanager = FileManager.default - if filemanager.fileExists(atPath: localURL.path) { - try filemanager.removeItem(at: localURL) - } + try FileManager.removeExistingFile(at: localURL) } private func checkPermissions() { @@ -457,64 +444,71 @@ extension DownloadService { } } -// MARK: - DownloadProcesserDelegate Methods +// MARK: - DownloadProcessorDelegate extension DownloadService: DownloadProcessorDelegate { - func downloadProcessor(_ processor: DownloadProcessor, downloadModelForDownloadWithID downloadID: UUID) -> DownloadProcessorModel? { + func downloadProcessor( + downloadModelForDownloadWithID downloadID: UUID + ) -> DownloadProcessorModel? { do { return try persistenceStore.download(withID: downloadID) } catch { Failure - .loadFromPersistentStore(from: String(describing: type(of: self)), reason: "Error finding download: \(error)") + .loadFromPersistentStore(from: Self.self, reason: "Error finding download: \(error)") .log() return .none } } - func downloadProcessor(_ processor: DownloadProcessor, didStartDownloadWithID downloadID: UUID) { - transitionDownload(withID: downloadID, to: .inProgress) + func downloadProcessor(didStartDownloadWithID downloadID: UUID) { + Task { await transitionDownload(withID: downloadID, to: .inProgress) } } - func downloadProcessor(_ processor: DownloadProcessor, downloadWithID downloadID: UUID, didUpdateProgress progress: Double) { - do { - try persistenceStore.updateDownload(withID: downloadID, withProgress: progress) - } catch { - Failure - .saveToPersistentStore(from: String(describing: type(of: self)), reason: "Unable to update progress on download: \(error)") - .log() + func downloadProcessor(downloadWithID downloadID: UUID, didUpdateProgress progress: Double) { + Task { + do { + try await persistenceStore.updateDownload(withID: downloadID, withProgress: progress) + } catch { + Failure + .saveToPersistentStore(from: Self.self, reason: "Unable to update progress on download: \(error)") + .log() + } } } - func downloadProcessor(_ processor: DownloadProcessor, didFinishDownloadWithID downloadID: UUID) { - transitionDownload(withID: downloadID, to: .complete) + func downloadProcessor(didFinishDownloadWithID downloadID: UUID) { + Task { await transitionDownload(withID: downloadID, to: .complete) } } - func downloadProcessor(_ processor: DownloadProcessor, didCancelDownloadWithID downloadID: UUID) { + func downloadProcessor(didCancelDownloadWithID downloadID: UUID) { do { if try !persistenceStore.deleteDownload(withID: downloadID) { Failure - .deleteFromPersistentStore(from: String(describing: type(of: self)), reason: "Unable to delete download: \(downloadID)") + .deleteFromPersistentStore(from: Self.self, reason: "Unable to delete download: \(downloadID)") .log() } } catch { Failure - .deleteFromPersistentStore(from: String(describing: type(of: self)), reason: "Unable to delete download: \(error)") + .deleteFromPersistentStore(from: Self.self, reason: "Unable to delete download: \(error)") .log() } } - func downloadProcessor(_ processor: DownloadProcessor, downloadWithID downloadID: UUID, didFailWithError error: Error) { - transitionDownload(withID: downloadID, to: .error) + func downloadProcessor( + downloadWithID downloadID: UUID, + didFailWithError error: Error + ) { + Task { await transitionDownload(withID: downloadID, to: .error) } Failure - .saveToPersistentStore(from: String(describing: type(of: self)), reason: "DownloadDidFailWithError: \(error)") + .saveToPersistentStore(from: Self.self, reason: "DownloadDidFailWithError: \(error)") .log() } - private func transitionDownload(withID id: UUID, to state: Download.State) { + private func transitionDownload(withID id: UUID, to state: Download.State) async { do { - try persistenceStore.transitionDownload(withID: id, to: state) + try await persistenceStore.transitionDownload(withID: id, to: state) } catch { Failure - .saveToPersistentStore(from: String(describing: type(of: self)), reason: "Unable to transition download: \(error)") + .saveToPersistentStore(from: Self.self, reason: "Unable to transition download: \(error)") .log() } } @@ -534,33 +528,32 @@ extension DownloadService { private func configureWifiObservation() { // Track the network status networkMonitor.pathUpdateHandler = { [weak self] _ in - self?.checkQueueStatus() + guard let self = self else { return } + Task { await self.checkQueueStatus() } } - networkMonitor.start(queue: DispatchQueue.global(qos: .utility)) + networkMonitor.start(queue: .global(qos: .utility)) // Track the status of the wifi downloads setting settingsSubscription = settingsManager .wifiOnlyDownloadsPublisher .removeDuplicates() - .sink(receiveValue: { [weak self] _ in - self?.checkQueueStatus() - }) + .sink { [weak self] _ in + guard let self = self else { return } + Task { await self.checkQueueStatus() } + } } - private func checkQueueStatus() { - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - - let expensive = self.networkMonitor.currentPath.isExpensive - let allowedExpensive = self.settingsManager.wifiOnlyDownloads - self.status = Status.status(expensive: expensive, expensiveAllowed: allowedExpensive) - - switch self.status { - case .active: - self.resumeQueue() - case .inactive: - self.pauseQueue() - } + @MainActor private func checkQueueStatus() { + status = .init( + expensive: networkMonitor.currentPath.isExpensive, + expensiveAllowed: settingsManager.wifiOnlyDownloads + ) + + switch status { + case .active: + resumeQueue() + case .inactive: + pauseQueue() } } @@ -579,29 +572,31 @@ extension DownloadService { // Start download queue processing downloadQueueSubscription = queueManager.downloadQueue - .sink(receiveCompletion: { completion in - switch completion { - case .finished: - print("Should never get here.... \(completion)") - case .failure(let error): - Failure - .downloadService(from: String(describing: type(of: self)), reason: "DownloadQueue: \(error)") - .log() - } - }, receiveValue: { [weak self] downloadQueueItems in - guard let self = self else { return } - downloadQueueItems.filter { $0.download.state == .enqueued } - .forEach { downloadQueueItem in + .sink( + receiveCompletion: { completion in + switch completion { + case .finished: + print("Should never get here.... \(completion)") + case .failure(let error): + Failure + .downloadService(from: Self.self, reason: "DownloadQueue: \(error)") + .log() + } + }, + receiveValue: { [weak self] downloadQueueItems in + guard let self = self else { return } + for downloadQueueItem in downloadQueueItems.filter({ $0.download.state == .enqueued }) { do { try self.downloadProcessor.add(download: downloadQueueItem.download) } catch { Failure - .downloadService(from: String(describing: type(of: self)), reason: "Problem adding download: \(error)") - .log() - self.transitionDownload(withID: downloadQueueItem.download.id, to: .failed) + .downloadService(from: Self.self, reason: "Problem adding download: \(error)") + .log() + Task { await self.transitionDownload(withID: downloadQueueItem.download.id, to: .failed) } } } - }) + } + ) // Resume all downloads that the processor is already working on downloadProcessor.resumeAllDownloads() diff --git a/Emitron/Emitron/Extensions/Date+Extensions.swift b/Emitron/Emitron/Extensions/Date+Extensions.swift index 7af1d2af..5cd0c599 100644 --- a/Emitron/Emitron/Extensions/Date+Extensions.swift +++ b/Emitron/Emitron/Extensions/Date+Extensions.swift @@ -29,12 +29,6 @@ import Foundation extension Date { - @available( - iOS, deprecated: 15, - message: "Delete this extension now; it was added to Foundation." - ) - static var now: Self { .init() } // swiftlint:disable:this let_var_whitespace - static var topOfTheHour: Date { let cmpts = Calendar.current.dateComponents([.year, .month, .day, .hour], from: .now) return Calendar.current.date(from: cmpts)! diff --git a/Emitron/emitronTests/Downloads/DownloadProcessorTest.swift b/Emitron/Emitron/Extensions/FileManager+Extensions.swift similarity index 67% rename from Emitron/emitronTests/Downloads/DownloadProcessorTest.swift rename to Emitron/Emitron/Extensions/FileManager+Extensions.swift index 544b557e..83cffeb2 100644 --- a/Emitron/emitronTests/Downloads/DownloadProcessorTest.swift +++ b/Emitron/Emitron/Extensions/FileManager+Extensions.swift @@ -26,15 +26,29 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import XCTest -import CoreData -@testable import Emitron +import Foundation -final class DownloadProcessorTest: XCTestCase { - private var downloadProcessor: DownloadProcessor! - - override func setUp() { - super.setUp() - downloadProcessor = DownloadProcessor(settingsManager: App.objects.settingsManager) +public extension FileManager { + /// The document directory for the current user. + /// - Throws: `FileManager.Error. + static var userDocumentsDirectory: URL { + `default`.urls(for: .documentDirectory, in: .userDomainMask).first! + } + + /// Removes the file or directory at the specified URL, if it exists. + /// + /// - Note: This is a convenience to only call `removeItem` if `fileExists`. + /// `removeItem` traps otherwise. + static func removeExistingFile(at url: URL) throws { + if `default`.fileExists(atPath: url.path) { + try `default`.removeItem(at: url) + } + } +} + +// MARK: - Emitron +extension URL { + static var downloadsDirectory: URL { + FileManager.userDocumentsDirectory.appendingPathComponent("downloads", isDirectory: true) } } diff --git a/Emitron/Emitron/Settings/EmitronSettings.swift b/Emitron/Emitron/Extensions/Optional+Extensions.swift similarity index 60% rename from Emitron/Emitron/Settings/EmitronSettings.swift rename to Emitron/Emitron/Extensions/Optional+Extensions.swift index bf9b0db0..e237e3b3 100644 --- a/Emitron/Emitron/Settings/EmitronSettings.swift +++ b/Emitron/Emitron/Extensions/Optional+Extensions.swift @@ -26,31 +26,40 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import Combine +public extension Optional { + /// Represents that an `Optional` was `nil`. + enum UnwrapError: Error { + case `nil` + case typeMismatch + } -protocol EmitronSettings { - // MARK: - Library - // MARK: Filters - var filters: Set { get set } - - // MARK: Sorting - var sortFilter: SortFilter { get set } - - // MARK: - Video Playback - var playbackToken: String? { get set } - - // MARK: - User Settings - // MARK: Video Playback - var playbackSpeed: PlaybackSpeed { get set } - var playbackSpeedPublisher: AnyPublisher { get } - - var closedCaptionOn: Bool { get set } - var closedCaptionOnPublisher: AnyPublisher { get } - - // MARK: Download Behaviour - var downloadQuality: Attachment.Kind { get set } - var downloadQualityPublisher: AnyPublisher { get } - - var wifiOnlyDownloads: Bool { get set } - var wifiOnlyDownloadsPublisher: AnyPublisher { get } + /// [An alternative to overloading `??` to throw errors upon `nil`.]( + /// https://forums.swift.org/t/unwrap-or-throw-make-the-safe-choice-easier/14453/7) + /// - Note: Useful for emulating `break`, with `map`, `forEach`, etc. + /// - Throws: `UnwrapError` when `nil`. + var unwrapped: Wrapped { + get throws { + switch self { + case let wrapped?: + return wrapped + case nil: + throw UnwrapError.nil + } + } + } + + /// [An alternative to overloading `??` to throw errors upon `nil`.]( + /// https://forums.swift.org/t/unwrap-or-throw-make-the-safe-choice-easier/14453/7) + /// - Note: Useful for emulating `break`, with `map`, `forEach`, etc. + /// - Throws: `UnwrapError` + func unwrap() throws -> Wrapped { + switch self { + case let wrapped as Wrapped: + return wrapped + case .some: + throw UnwrapError.typeMismatch + case nil: + throw UnwrapError.nil + } + } } diff --git a/Emitron/Emitron/FOSS Licenses/FossLicenses.json b/Emitron/Emitron/FOSS Licenses/FossLicenses.json index f08d49ed..fa970a16 100644 --- a/Emitron/Emitron/FOSS Licenses/FossLicenses.json +++ b/Emitron/Emitron/FOSS Licenses/FossLicenses.json @@ -22,20 +22,13 @@ }, { "id": 4, - "name": "GRDBCombine", - "copyright": "2019 Gwendal Roué", - "url": "https://github.com/groue/GRDBCombine", - "body": "Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE." - }, - { - "id": 5, "name": "CombineExpectations", "copyright": "2019 Gwendal Roué", "url": "https://github.com/groue/CombineExpectations", "body": "Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE." }, { - "id": 6, + "id": 5, "name": "SwiftyJSON", "copyright": "2017 Ruoyu Fu", "url": "https://github.com/SwiftyJSON/SwiftyJSON", diff --git a/Emitron/Emitron/Filters/Filters.swift b/Emitron/Emitron/Filters/Filters.swift index 08dc3ea1..a702fb5f 100644 --- a/Emitron/Emitron/Filters/Filters.swift +++ b/Emitron/Emitron/Filters/Filters.swift @@ -82,7 +82,7 @@ class Filters: ObservableObject { // They can only select between .collection, .screencast var defaultFilters: [Filter] { Param - .filters(for: [.contentTypes(types: [.collection, .screencast])]) + .filters(for: [.contentTypes([.collection, .screencast])]) .map { Filter(groupType: .contentTypes, param: $0, isOn: true) } } @@ -98,7 +98,7 @@ class Filters: ObservableObject { searchFilter = nil return } - searchFilter = Filter(groupType: .search, param: Param.filter(for: .queryString(string: query)), isOn: !query.isEmpty) + searchFilter = Filter(groupType: .search, param: Param.filter(for: .queryString(query)), isOn: !query.isEmpty) all.update(with: searchFilter!) } } @@ -132,7 +132,7 @@ class Filters: ObservableObject { self.settingsManager = settingsManager let contentFilters = Param - .filters(for: [.contentTypes(types: [.collection, .screencast])]) + .filters(for: [.contentTypes([.collection, .screencast])]) .map { Filter(groupType: .contentTypes, param: $0, isOn: false ) } contentTypes = FilterGroup(type: .contentTypes, filters: contentFilters) @@ -186,7 +186,7 @@ class Filters: ObservableObject { let userFacingDomains = domains.filter { $0.level.userFacing } let domainTypes = userFacingDomains.map { (id: $0.id, name: $0.name, sortOrdinal: $0.ordinal) } let platformFilters = Param - .filters(for: [.domainTypes(types: domainTypes)]) + .filters(for: [.domainTypes(domainTypes)]) .map { Filter(groupType: .platforms, param: $0, isOn: false ) } platforms.filters = platformFilters @@ -199,7 +199,7 @@ class Filters: ObservableObject { func updateCategoryFilters(for newCategories: [Category]) { let categoryTypes = newCategories.map { (id: $0.id, name: $0.name, sortOrdinal: $0.ordinal) } let categoryFilters = Param - .filters(for: [.categoryTypes(types: categoryTypes)]) + .filters(for: [.categoryTypes(categoryTypes)]) .map { Filter(groupType: .categories, param: $0, isOn: false ) } categories.filters = categoryFilters @@ -231,7 +231,7 @@ class Filters: ObservableObject { // Returns the applied parameters array from an array of Filters, but applied the current sort and search filters as well // If there are no content filters, it adds the default ones. - func appliedParamteresWithCurrentSortAndSearch(from filters: [Filter]) -> [Parameter] { + func appliedParametersWithCurrentSortAndSearch(from filters: [Filter]) -> [Parameter] { var filterParameters = filters.map(\.parameter) let appliedContentFilters = filters.filter { $0.groupType == .contentTypes && $0.isOn } diff --git a/Emitron/Emitron/Guardpost/Guardpost.swift b/Emitron/Emitron/Guardpost/Guardpost.swift index 5dd5fb4c..476457e0 100644 --- a/Emitron/Emitron/Guardpost/Guardpost.swift +++ b/Emitron/Emitron/Guardpost/Guardpost.swift @@ -30,135 +30,139 @@ import AuthenticationServices import Combine public class Guardpost: ObservableObject { - // MARK: - Properties + init( + baseURL: String, + urlScheme: String, + ssoSecret: String, + persistenceStore: PersistenceStore + ) { + self.baseURL = baseURL + self.urlScheme = urlScheme + self.ssoSecret = ssoSecret + self.persistenceStore = persistenceStore + } + private let baseURL: String private let urlScheme: String private let ssoSecret: String - private var _currentUser: User? - private var authSession: ASWebAuthenticationSession? private let persistenceStore: PersistenceStore - public weak var presentationContextDelegate: ASWebAuthenticationPresentationContextProviding? + private var _currentUser: User? +} - public var currentUser: User? { - if _currentUser == .none { +// MARK: - public +public extension Guardpost { + enum LoginError: Error { + case unableToCreateLoginURL + case errorResponseFromGuardpost(Error?) + case unableToDecodeGuardpostResponse + case invalidSignature + case unableToCreateValidUser + } + + var currentUser: User? { + if _currentUser == nil { _currentUser = persistenceStore.userFromKeychain() } return _currentUser } - // MARK: - Initializers - init(baseURL: String, - urlScheme: String, - ssoSecret: String, - persistenceStore: PersistenceStore) { - self.baseURL = baseURL - self.urlScheme = urlScheme - self.ssoSecret = ssoSecret - self.persistenceStore = persistenceStore - } - - public func login(callback: @escaping (Result) -> Void) { + /// - Throws: `LoginError` + func logIn() async throws -> User { let guardpostLogin = "\(baseURL)/v2/sso/login" let returnURL = "\(urlScheme)://sessions/create" - let ssoRequest = SingleSignOnRequest(endpoint: guardpostLogin, - secret: ssoSecret, - callbackURL: returnURL) - - guard let loginURL = ssoRequest.url else { - let result: Result = .failure(.unableToCreateLoginURL) - return asyncResponse(callback: callback, result: result) - } - - authSession = ASWebAuthenticationSession(url: loginURL, - callbackURLScheme: urlScheme) { url, error in - - var result: Result - - guard let url = url else { - result = .failure(LoginError.errorResponseFromGuardpost(error)) - return self.asyncResponse(callback: callback, result: result) - } - - guard let response = SingleSignOnResponse(request: ssoRequest, responseURL: url) else { - result = .failure(LoginError.unableToDecodeGuardpostResponse) - return self.asyncResponse(callback: callback, result: result) - } - - if !response.isValid { - result = .failure(LoginError.invalidSignature) - return self.asyncResponse(callback: callback, result: result) + let ssoRequest = SingleSignOnRequest( + endpoint: guardpostLogin, + secret: ssoSecret, + callbackURL: returnURL + ) + + guard let loginURL = ssoRequest.url + else { throw LoginError.unableToCreateLoginURL } + + let user: User = try await withCheckedThrowingContinuation { + [presentationContextDelegate = PresentationContextDelegate()] continuation in + let authSession = ASWebAuthenticationSession( + url: loginURL, + callbackURLScheme: urlScheme + ) { url, error in + guard let url = url else { + continuation.resume(throwing: LoginError.errorResponseFromGuardpost(error)) + return + } + + guard let response = SingleSignOnResponse(request: ssoRequest, responseURL: url) else { + continuation.resume(throwing: LoginError.unableToDecodeGuardpostResponse) + return + } + + guard response.isValid else { + continuation.resume(throwing: LoginError.invalidSignature) + return + } + + guard let user = response.user else { + continuation.resume(throwing: LoginError.unableToCreateValidUser) + return + } + + continuation.resume(returning: user) } - guard let user = response.user else { - result = .failure(LoginError.unableToCreateValidUser) - return self.asyncResponse(callback: callback, result: result) - } + authSession.presentationContextProvider = presentationContextDelegate - self.persistenceStore.persistUserToKeychain(user: user) - self._currentUser = user + // This will prevent sharing cookies with Safari, which means no auto-login + // However, it also means that you can actually log out, which is good, I guess. + #if (!DEBUG) + authSession.prefersEphemeralWebBrowserSession = true + #endif - result = Result.success(user) - return self.asyncResponse(callback: callback, result: result) + authSession.start() } - authSession?.presentationContextProvider = presentationContextDelegate - // This will prevent sharing cookies with Safari, which means no auto-login - // However, it also means that you can actually log out, which is good, I guess. - #if (!DEBUG) - authSession?.prefersEphemeralWebBrowserSession = true - #endif - - authSession?.start() - } - - public func cancelLogin() { - authSession?.cancel() + try persistenceStore.persistUserToKeychain(user: user) + _currentUser = user + return user } - public func logout() { - persistenceStore.removeUserFromKeychain() + func logOut() { + try? persistenceStore.removeUserFromKeychain() _currentUser = .none } - public func updateUser(with user: User?) { + func updateUser(with user: User?) { _currentUser = user if let user = user { - persistenceStore.persistUserToKeychain(user: user) + try? persistenceStore.persistUserToKeychain(user: user) } else { - persistenceStore.removeUserFromKeychain() + try? persistenceStore.removeUserFromKeychain() } } +} - private func asyncResponse(callback: @escaping (Result) -> Void, - result: Result) { - DispatchQueue.global(qos: .userInitiated).async { - callback(result) +public extension Guardpost.LoginError { + var localizedDescription: String { + let prefix = "GuardpostLoginError::" + switch self { + case .unableToCreateLoginURL: + return "\(prefix)UnableToCreateLoginURL" + case .errorResponseFromGuardpost(let error): + return "\(prefix)[Error: \(error?.localizedDescription ?? "UNKNOWN")]" + case .unableToDecodeGuardpostResponse: + return "\(prefix)UnableToDecodeGuardpostResponse" + case .invalidSignature: + return "\(prefix)InvalidSignature" + case .unableToCreateValidUser: + return "\(prefix)UnableToCreateValidUser" } } } -public extension Guardpost { - enum LoginError: Error { - case unableToCreateLoginURL - case errorResponseFromGuardpost(Error?) - case unableToDecodeGuardpostResponse - case invalidSignature - case unableToCreateValidUser - - public var localizedDescription: String { - let prefix = "GuardpostLoginError::" - switch self { - case .unableToCreateLoginURL: - return "\(prefix)UnableToCreateLoginURL" - case .errorResponseFromGuardpost(let error): - return "\(prefix)[Error: \(error?.localizedDescription ?? "UNKNOWN")]" - case .unableToDecodeGuardpostResponse: - return "\(prefix)UnableToDecodeGuardpostResponse" - case .invalidSignature: - return "\(prefix)InvalidSignature" - case .unableToCreateValidUser: - return "\(prefix)UnableToCreateValidUser" - } - } +// MARK: - private +private final class PresentationContextDelegate: NSObject { } + +// MARK: - ASWebAuthenticationPresentationContextProviding +extension PresentationContextDelegate: ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor { + .init() } } diff --git a/Emitron/Emitron/Guardpost/SSO/SingleSignOnRequest.swift b/Emitron/Emitron/Guardpost/SSO/SingleSignOnRequest.swift index 09fca851..68158293 100644 --- a/Emitron/Emitron/Guardpost/SSO/SingleSignOnRequest.swift +++ b/Emitron/Emitron/Guardpost/SSO/SingleSignOnRequest.swift @@ -30,7 +30,6 @@ import CryptoKit import Foundation struct SingleSignOnRequest { - // MARK: - Properties private let callbackURL: String let secret: String @@ -43,9 +42,11 @@ struct SingleSignOnRequest { } // MARK: - Initializers - init(endpoint: String, - secret: String, - callbackURL: String) { + init( + endpoint: String, + secret: String, + callbackURL: String + ) { self.endpoint = endpoint self.secret = secret self.callbackURL = callbackURL @@ -53,9 +54,8 @@ struct SingleSignOnRequest { } } -// MARK: - Private +// MARK: - private private extension SingleSignOnRequest { - var payload: [URLQueryItem]? { guard let unsignedPayload = unsignedPayload else { return nil @@ -63,22 +63,24 @@ private extension SingleSignOnRequest { let contents = unsignedPayload.toBase64() let symmetricKey = SymmetricKey(data: Data(secret.utf8)) - let signature = HMAC.authenticationCode(for: Data(contents.utf8), - using: symmetricKey) + let signature = HMAC.authenticationCode( + for: Data(contents.utf8), + using: symmetricKey + ) .description .replacingOccurrences(of: String.hmacToRemove, with: "") return [ - URLQueryItem(name: "sso", value: contents), - URLQueryItem(name: "sig", value: signature) + .init(name: "sso", value: contents), + .init(name: "sig", value: signature) ] } var unsignedPayload: String? { var components = URLComponents() components.queryItems = [ - URLQueryItem(name: "callback_url", value: callbackURL), - URLQueryItem(name: "nonce", value: nonce) + .init(name: "callback_url", value: callbackURL), + .init(name: "nonce", value: nonce) ] return components.query } diff --git a/Emitron/Emitron/Guardpost/SSO/SingleSignOnResponse.swift b/Emitron/Emitron/Guardpost/SSO/SingleSignOnResponse.swift index 016dec5e..b5273192 100644 --- a/Emitron/Emitron/Guardpost/SSO/SingleSignOnResponse.swift +++ b/Emitron/Emitron/Guardpost/SSO/SingleSignOnResponse.swift @@ -30,37 +30,37 @@ import CryptoKit import Foundation struct SingleSignOnResponse { - - // MARK: - Properties - private let request: SingleSignOnRequest - private let signature: String - private let payload: String - private let decodedPayload: [URLQueryItem]? - - // MARK: - Initializers init?(request: SingleSignOnRequest, responseURL: URL) { - let responseComponents = URLComponents(url: responseURL, - resolvingAgainstBaseURL: false) - var components = URLComponents() guard - let sso = responseComponents?.queryItems?.first(where: { $0.name == "sso" })?.value, - let sig = responseComponents?.queryItems?.first(where: { $0.name == "sig" })?.value, + let responseComponents = URLComponents( + url: responseURL, + resolvingAgainstBaseURL: false + ), + let sso = (responseComponents.queryItems?.first { $0.name == "sso" })?.value, + let sig = (responseComponents.queryItems?.first { $0.name == "sig" })?.value, let urlString = sso.fromBase64() - else { - return nil + else { + return nil } - components.query = urlString - self.request = request signature = sig payload = sso + + var components = URLComponents() + components.query = urlString decodedPayload = components.queryItems } - var isValid: Bool { - isSignatureValid && isNonceValid - } + private let request: SingleSignOnRequest + private let signature: String + private let payload: String + private let decodedPayload: [URLQueryItem]? +} + +// MARK: - internal +extension SingleSignOnResponse { + var isValid: Bool { isSignatureValid && isNonceValid } var user: User? { if !isValid { @@ -75,13 +75,14 @@ struct SingleSignOnResponse { } } -// MARK: - Private +// MARK: - private private extension SingleSignOnResponse { - var isSignatureValid: Bool { let symmetricKey = SymmetricKey(data: Data(request.secret.utf8)) - let hmac = HMAC.authenticationCode(for: Data(payload.utf8), - using: symmetricKey) + let hmac = HMAC.authenticationCode( + for: Data(payload.utf8), + using: symmetricKey + ) .description .replacingOccurrences(of: String.hmacToRemove, with: "") diff --git a/Emitron/Emitron/Guardpost/Utils/String+Base64.swift b/Emitron/Emitron/Guardpost/Utils/String+Base64.swift index 921e1de1..5595e7c8 100644 --- a/Emitron/Emitron/Guardpost/Utils/String+Base64.swift +++ b/Emitron/Emitron/Guardpost/Utils/String+Base64.swift @@ -30,12 +30,10 @@ import struct Foundation.Data // MARK: - Base64 extension String { - func fromBase64() -> String? { - if let data = Data(base64Encoded: self) { - return String(data: data, encoding: .utf8) + Data(base64Encoded: self).flatMap { data in + .init(data: data, encoding: .utf8) } - return nil } func toBase64() -> String { diff --git a/Emitron/Emitron/Logging/Logger.swift b/Emitron/Emitron/Logging/Logger.swift index ba7525ba..1c9909a1 100644 --- a/Emitron/Emitron/Logging/Logger.swift +++ b/Emitron/Emitron/Logging/Logger.swift @@ -26,142 +26,124 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -protocol Log { - var object: String { get } - var action: String { get } - var reason: String { get } +struct Failure { + static func login(from source: Source.Type, reason: String) -> Self { + .init(source: source, action: "login", reason: reason) + } - func log(additionalParams: [String: String]) - func log() -} + static func fetch(from source: Source.Type, reason: String) -> Self { + .init(source: source, action: "fetch", reason: reason) + } -// To make "reason" optional -extension Log { - var reason: String { - "N/A" + static func loadFromPersistentStore(from source: Source.Type, reason: String) -> Self { + loadFromPersistentStore(from: "\(Source.self)", reason: reason) } - - func log() { - log(additionalParams: [:]) + + static func loadFromPersistentStore(from source: String, reason: String) -> Self { + .init(source: source, action: "loadingFromPersistentStore", reason: reason) } -} -enum Failure: Log { - case login(from: String, reason: String) - case fetch(from: String, reason: String) - case loadFromPersistentStore(from: String, reason: String) - case saveToPersistentStore(from: String, reason: String) - case deleteFromPersistentStore(from: String, reason: String) - case repositoryLoad(from: String, reason: String) - case unsupportedAction(from: String, reason: String) - case downloadAction(from: String, reason: String) - case viewModelAction(from: String, reason: String) - case downloadService(from: String, reason: String) - case appIcon(from: String, reason: String) - - private var failure: String { - "Failed_" + static func saveToPersistentStore(from source: Source.Type, reason: String) -> Self { + .init(source: source, action: "savingToPersistentStore", reason: reason) } - - var object: String { - switch self { - case .login(from: let from, reason: _), - .fetch(from: let from, reason: _), - .loadFromPersistentStore(from: let from, reason: _), - .saveToPersistentStore(from: let from, reason: _), - .deleteFromPersistentStore(from: let from, reason: _), - .repositoryLoad(from: let from, reason: _), - .unsupportedAction(from: let from, reason: _), - .downloadAction(from: let from, reason: _), - .viewModelAction(from: let from, reason: _), - .downloadService(from: let from, reason: _), - .appIcon(from: let from, reason: _): - return from - } + + static func deleteFromPersistentStore(from source: Source.Type, reason: String) -> Self { + .init(source: source, action: "deleteToPersistentStore", reason: reason) } - - var action: String { - switch self { - case .login: - return failure + "login" - case .fetch: - return failure + "fetch" - case .loadFromPersistentStore: - return failure + "loadingFromPersistentStore" - case .saveToPersistentStore: - return failure + "savingToPersistentStore" - case .deleteFromPersistentStore: - return failure + "deleteToPersistentStore" - case .repositoryLoad: - return failure + "repositoryLoad" - case .unsupportedAction: - return failure + "unsupportedAction" - case .downloadAction: - return failure + "downloadAction" - case .viewModelAction: - return failure + "viewModelAction" - case .downloadService: - return failure + "downloadService" - case .appIcon: - return failure + "appIcon" - } + + static func repositoryLoad(from source: Source.Type, reason: String) -> Self { + .init(source: source, action: "repositoryLoad", reason: reason) } - - var reason: String { - switch self { - case .login(from: _, reason: let reason), - .fetch(from: _, reason: let reason), - .loadFromPersistentStore(from: _, reason: let reason), - .saveToPersistentStore(from: _, reason: let reason), - .deleteFromPersistentStore(from: _, reason: let reason), - .repositoryLoad(from: _, reason: let reason), - .unsupportedAction(from: _, reason: let reason), - .downloadAction(from: _, reason: let reason), - .viewModelAction(from: _, reason: let reason), - .downloadService(from: _, reason: let reason), - .appIcon(from: _, reason: let reason): - return reason - } + + static func unsupportedAction(from source: Source.Type, reason: String) -> Self { + .init(source: source, action: "unsupportedAction", reason: reason) } + + static func downloadAction(from source: Source.Type, reason: String) -> Self { + .init(source: source, action: "downloadAction", reason: reason) + } + + static func viewModelAction(from source: Source.Type, reason: String) -> Self { + .init(source: source, action: "viewModelAction", reason: reason) + } + + static func downloadService(from source: Source.Type, reason: String) -> Self { + downloadService(from: "\(Source.self)", reason: reason) + } + + static func downloadService(from source: String, reason: String) -> Self { + .init(source: source, action: "downloadService", reason: reason) + } + + static func appIcon(from source: Source.Type, reason: String) -> Self { + .init(source: source, action: "appIcon", reason: reason) + } + + private init( + source: Source.Type, + action: String, + reason: String + ) { + self.init( + source: "\(Source.self)", + action: action, + reason: reason + ) + } + + private init( + source: String, + action: String, + reason: String + ) { + self.source = source + self.action = "Failed_\(action)" + self.reason = reason + } + + private let source: String + private let action: String + private let reason: String - func log(additionalParams: [String: String]) { - let params = ["object": object, - "action": action, - "reason": reason] - let allParams = params.merging(additionalParams, uniquingKeysWith: { $1 }) - print(allParams) + func log() { + print( + [ "source": source, + "action": action, + "reason": reason + ] + ) } } -enum Event: Log { - case login(from: String) - case refresh(from: String, action: String) - case syncEngine(action: String) - - var object: String { - switch self { - case .login(from: let from), - .refresh(from: let from, action: _): - return from - case .syncEngine(action: _): - return "SyncEngine" - } +struct Event { + static func login(from source: Source.Type) -> Self { + .init( + source: "\(Source.self)", + action: "Login" + ) + } + + static func refresh( + from source: Source.Type, + action: String + ) -> Self { + .init( + source: "\(Source.self)", + action: "Login" + ) + } + + static func syncEngine(action: String) -> Self { + .init( + source: "SyncEngine", + action: action + ) } - var action: String { - switch self { - case .login: - return "Login" - case .refresh(from: _, action: let action), - .syncEngine(action: let action): - return action - } - } - - func log(additionalParams: [String: String]) { - let allParams = - ["object": object, "action": action] - .merging(additionalParams, uniquingKeysWith: { $1 }) - print("EVENT:: \(allParams)") + private let source: String + private let action: String + + func log() { + print("EVENT:: \(["source": source, "action": action])") } } diff --git a/Emitron/Emitron/Models/Content.swift b/Emitron/Emitron/Models/Content.swift index 537af440..77a0708e 100644 --- a/Emitron/Emitron/Models/Content.swift +++ b/Emitron/Emitron/Models/Content.swift @@ -72,22 +72,24 @@ extension Content: Equatable { extension Content { func update(from other: Content) -> Content { - Content(id: other.id, - uri: other.uri, - name: other.name, - descriptionHtml: other.descriptionHtml, - descriptionPlainText: other.descriptionPlainText, - releasedAt: other.releasedAt, - free: other.free, - professional: other.professional, - difficulty: other.difficulty, - contentType: other.contentType, - duration: other.duration, - videoIdentifier: other.videoIdentifier, - cardArtworkURL: other.cardArtworkURL, - technologyTriple: other.technologyTriple, - contributors: other.contributors, - groupID: other.groupID ?? groupID, - ordinal: other.ordinal) + Content( + id: other.id, + uri: other.uri, + name: other.name, + descriptionHtml: other.descriptionHtml, + descriptionPlainText: other.descriptionPlainText, + releasedAt: other.releasedAt, + free: other.free, + professional: other.professional, + difficulty: other.difficulty, + contentType: other.contentType, + duration: other.duration, + videoIdentifier: other.videoIdentifier, + cardArtworkURL: other.cardArtworkURL, + technologyTriple: other.technologyTriple, + contributors: other.contributors, + groupID: other.groupID ?? groupID, + ordinal: other.ordinal + ) } } diff --git a/Emitron/Emitron/Models/DataCache.swift b/Emitron/Emitron/Models/DataCache.swift index 75d606a7..263b5bb9 100644 --- a/Emitron/Emitron/Models/DataCache.swift +++ b/Emitron/Emitron/Models/DataCache.swift @@ -72,7 +72,7 @@ extension DataCache { // swiftlint:disable generic_type_name func mergeWithCacheUpdate( - _ dictionary: inout [ Int: [contentID] ], + _ dictionary: inout [Int: [contentID]], _ getContentID: (DataCacheUpdate) -> [contentID] ) { dictionary.merge( @@ -180,13 +180,14 @@ extension DataCache { extension DataCache { private func cachedContentSummaryState(for contentID: Int) throws -> CachedContentSummaryState { - guard let content = contents[contentID], - let contentDomains = contentDomains[contentID] + guard + let content = contents[contentID], + let contentDomains = contentDomains[contentID] else { throw DataCacheError.cacheMiss } - let contentCategories = self.contentCategories[contentID] ?? [] + let contentCategories = contentCategories[contentID] ?? [] return try CachedContentSummaryState( content: content, @@ -227,34 +228,31 @@ extension DataCache { throw DataCacheError.cacheMiss } - let contentDomains = self.contentDomains[contentID] ?? [] - let contentCategories = self.contentCategories[contentID] ?? [] - - if content.contentType != .episode { - if contentDomains.isEmpty { - throw DataCacheError.cacheMiss - } - } - - let bookmark = self.bookmarks[contentID] - let progression = self.progressions[contentID] - let groups = self.contentIndexedGroups[contentID] ?? [] - let groupIDs = groups.map(\.id) - let childContents = self.contents.values.filter { content in - guard let groupID = content.groupID else { return false } - return groupIDs.contains(groupID) - } - - return try ContentPersistableState( - content: content, - contentDomains: contentDomains, - contentCategories: contentCategories, - bookmark: bookmark, - parentContent: parentContent(for: content), - progression: progression, - groups: groups, - childContents: childContents - ) + let contentDomains = self.contentDomains[contentID] ?? [] + let contentCategories = self.contentCategories[contentID] ?? [] + + if content.contentType != .episode, contentDomains.isEmpty { + throw DataCacheError.cacheMiss + } + + let bookmark = bookmarks[contentID] + let progression = progressions[contentID] + let groups = contentIndexedGroups[contentID] ?? [] + let groupIDs = groups.map(\.id) + let childContents = contents.values.filter { content in + content.groupID.map(groupIDs.contains) == true + } + + return try .init( + content: content, + contentDomains: contentDomains, + contentCategories: contentCategories, + bookmark: bookmark, + parentContent: parentContent(for: content), + progression: progression, + groups: groups, + childContents: childContents + ) } func videoPlaylist(for contentID: Int) throws -> [CachedVideoPlaybackState] { @@ -295,7 +293,7 @@ extension DataCache { } private func cachedDynamicContentState(for contentID: Int) -> CachedDynamicContentState { - CachedDynamicContentState( + .init( progression: progressions[contentID], bookmark: bookmarks[contentID] ) @@ -304,7 +302,7 @@ extension DataCache { private func parentContent(for content: Content) throws -> Content? { guard let groupID = content.groupID else { return nil } guard let group = groupIndexedGroups[groupID] - else { throw DataCacheError.cacheMiss } + else { throw DataCacheError.cacheMiss } return contents[group.contentID] } @@ -326,10 +324,7 @@ extension DataCache { } private func siblingContents(for content: Content) throws -> [Content] { - guard let parentContent = try parentContent(for: content) else { - return [] - } - return try childContents(for: parentContent) + try parentContent(for: content).map(childContents) ?? [] } private func nextToPlay(for contentList: [Content]) throws -> Content { @@ -339,10 +334,8 @@ extension DataCache { let orderedProgressions = contentList.map { progressions[$0.id] } // Find the first index where there's a missing or incomplete progression - guard let incompleteOrNotStartedIndex = orderedProgressions.firstIndex(where: { progression in - guard let progression = progression else { return true } - - return !progression.finished + guard let incompleteOrNotStartedIndex = (orderedProgressions.firstIndex { progression in + progression.map { !$0.finished } ?? true }) else { // If we didn't find one, start at the beginning return contentList[0] diff --git a/Emitron/Emitron/Models/Download.swift b/Emitron/Emitron/Models/Download.swift index 14a65077..ef2959fe 100644 --- a/Emitron/Emitron/Models/Download.swift +++ b/Emitron/Emitron/Models/Download.swift @@ -53,28 +53,14 @@ struct Download: Codable { var ordinal: Int = 0 // We copy this from the Content, and it is used to sort the queue var localURL: URL? { - guard let fileName = fileName, - let downloadDirectory = Download.downloadDirectory else { - return nil - } - - return downloadDirectory.appendingPathComponent(fileName) - } - - static var downloadDirectory: URL? { - let fileManager = FileManager.default - let documentsDirectories = fileManager.urls(for: .documentDirectory, in: .userDomainMask) - guard let documentsDirectory = documentsDirectories.first else { - return nil - } - - return documentsDirectory.appendingPathComponent("downloads", isDirectory: true) + fileName.map(URL.downloadsDirectory.appendingPathComponent) } } extension Download: DownloadProcessorModel { } -extension Download: Equatable { +// MARK: - Hashable +extension Download: Hashable { // We override this function because SQLite doesn't store dates to the same accuracy as Date static func == (lhs: Download, rhs: Download) -> Bool { lhs.id == rhs.id && @@ -89,9 +75,10 @@ extension Download: Equatable { } } +// MARK: - internal extension Download { - static func create(for content: Content) -> Download { - Download( + init(content: Content) { + self.init( id: UUID(), requestedAt: .now, lastValidatedAt: nil, @@ -100,11 +87,10 @@ extension Download { progress: 0, state: .pending, contentID: content.id, - ordinal: content.ordinal ?? 0) + ordinal: content.ordinal ?? 0 + ) } -} -extension Download { var isDownloading: Bool { [.inProgress, .paused].contains(state) && remoteURL != nil } @@ -113,5 +99,3 @@ extension Download { [.complete].contains(state) && remoteURL != nil } } - -extension Download: Hashable { } diff --git a/Emitron/Emitron/Models/Icon.swift b/Emitron/Emitron/Models/Icon.swift index 2bc4df95..27c4f2db 100644 --- a/Emitron/Emitron/Models/Icon.swift +++ b/Emitron/Emitron/Models/Icon.swift @@ -33,12 +33,10 @@ struct Icon: Identifiable, Equatable { let imageName: String let ordinal: Int - var id: String { - imageName - } + var id: String { imageName } var uiImage: UIImage { - UIImage(named: imageName) ?? .init() + .init(named: imageName) ?? .init() } } diff --git a/Emitron/Emitron/Models/Progression.swift b/Emitron/Emitron/Models/Progression.swift index 9014d5d3..516611e4 100644 --- a/Emitron/Emitron/Models/Progression.swift +++ b/Emitron/Emitron/Models/Progression.swift @@ -50,7 +50,7 @@ extension Progression: Equatable { extension Progression { var finished: Bool { - // This is a really nasty hack. And I take full responsbility for it. But + // This is a really nasty hack. And I take full responsibility for it. But // I'm also incredibly lazy. Basically, collections need to be fully complete // before being marked as complete. Whereas videos should only be 90% complete. // Since we don't know whether this is a video or a collection, we're gonna diff --git a/Emitron/Emitron/Networking/Adapters/DataCacheUpdate.swift b/Emitron/Emitron/Networking/Adapters/DataCacheUpdate.swift index 90ea6ff5..ca94def0 100644 --- a/Emitron/Emitron/Networking/Adapters/DataCacheUpdate.swift +++ b/Emitron/Emitron/Networking/Adapters/DataCacheUpdate.swift @@ -44,7 +44,10 @@ struct DataCacheUpdate { static func loadFrom(document: JSONAPIDocument) throws -> DataCacheUpdate { let data = try DataCacheUpdate(resources: document.data) - let included = try DataCacheUpdate(resources: document.included, relationships: document.data.map { (entity: $0.entityID, $0.relationships) }) + let included = try DataCacheUpdate( + resources: document.included, + relationships: document.data.map { (entity: $0.entityID, $0.relationships) } + ) return data.merged(with: included) } @@ -103,18 +106,23 @@ struct DataCacheUpdate { } func merged(with other: DataCacheUpdate) -> DataCacheUpdate { - DataCacheUpdate(contents: contents + other.contents, - bookmarks: bookmarks + other.bookmarks, - progressions: progressions + other.progressions, - domains: domains + other.domains, - groups: groups + other.groups, - categories: categories + other.categories, - contentCategories: contentCategories + other.contentCategories, - contentDomains: contentDomains + other.contentDomains, - relationships: relationships + other.relationships) + .init( + contents: contents + other.contents, + bookmarks: bookmarks + other.bookmarks, + progressions: progressions + other.progressions, + domains: domains + other.domains, + groups: groups + other.groups, + categories: categories + other.categories, + contentCategories: contentCategories + other.contentCategories, + contentDomains: contentDomains + other.contentDomains, + relationships: relationships + other.relationships + ) } - private static func relationships(from resources: [JSONAPIResource], with additionalRelationships: [JSONEntityRelationships]) -> [EntityRelationship] { + private static func relationships( + from resources: [JSONAPIResource], + with additionalRelationships: [JSONEntityRelationships] + ) -> [EntityRelationship] { var relationshipsToReturn = additionalRelationships.flatMap { entityRelationship -> [EntityRelationship] in guard let entityID = entityRelationship.entity else { return [] } return entityRelationships(from: entityRelationship.jsonRelationships, fromEntity: entityID) @@ -126,13 +134,18 @@ struct DataCacheUpdate { return relationshipsToReturn } - private static func entityRelationships(from jsonRelationships: [JSONAPIRelationship], fromEntity: EntityIdentity) -> [EntityRelationship] { + private static func entityRelationships( + from jsonRelationships: [JSONAPIRelationship], + fromEntity: EntityIdentity + ) -> [EntityRelationship] { jsonRelationships.flatMap { relationship in relationship.data.compactMap { resource in guard let toEntity = resource.entityID else { return nil } - return EntityRelationship(name: relationship.type, - from: fromEntity, - to: toEntity) + return EntityRelationship( + name: relationship.type, + from: fromEntity, + to: toEntity + ) } } } diff --git a/Emitron/Emitron/Networking/Network/RWAPI.swift b/Emitron/Emitron/Networking/Network/RWAPI.swift index 20cec277..7179d4df 100644 --- a/Emitron/Emitron/Networking/Network/RWAPI.swift +++ b/Emitron/Emitron/Networking/Network/RWAPI.swift @@ -55,7 +55,6 @@ enum RWAPIError: Error { } struct RWAPI { - // MARK: - Properties let environment: RWEnvironment let session: URLSession diff --git a/Emitron/Emitron/Networking/Network/RWEnvironment.swift b/Emitron/Emitron/Networking/Network/RWEnvironment.swift index 7337a105..443370ee 100644 --- a/Emitron/Emitron/Networking/Network/RWEnvironment.swift +++ b/Emitron/Emitron/Networking/Network/RWEnvironment.swift @@ -29,11 +29,10 @@ import struct Foundation.URL struct RWEnvironment { - // MARK: - Properties var baseURL: URL } extension RWEnvironment { - static let prod = RWEnvironment(baseURL: URL(string: "https://api.raywenderlich.com/api")!) + static let prod = RWEnvironment(baseURL: URL(string: "https://api.kodeco.com/api")!) } diff --git a/Emitron/Emitron/Networking/Requests/ContentsRequest.swift b/Emitron/Emitron/Networking/Requests/ContentsRequest.swift index 706511ac..18e50ed7 100644 --- a/Emitron/Emitron/Networking/Requests/ContentsRequest.swift +++ b/Emitron/Emitron/Networking/Requests/ContentsRequest.swift @@ -93,11 +93,12 @@ struct BeginPlaybackTokenRequest: Request { let json = try JSON(data: response) let doc = JSONAPIDocument(json) - guard let token = doc.data.first, + guard + let token = doc.data.first, let tokenString = token["video_playback_token"] as? String, !tokenString.isEmpty - else { - throw RWAPIError.processingError(nil) + else { + throw RWAPIError.processingError(nil) } return tokenString diff --git a/Emitron/Emitron/Networking/Requests/Parameters.swift b/Emitron/Emitron/Networking/Requests/Parameters.swift index f4419ccf..1f8ee2fd 100644 --- a/Emitron/Emitron/Networking/Requests/Parameters.swift +++ b/Emitron/Emitron/Networking/Requests/Parameters.swift @@ -62,20 +62,22 @@ enum ParameterKey { var param: Parameter { // TODO: This might need to be re-implemented - return Parameter(key: strKey, - value: value, - displayName: "", - sortOrdinal: 0) + .init( + key: strKey, + value: value, + displayName: "", + sortOrdinal: 0 + ) } } enum ParameterFilterValue { - case contentTypes(types: [ContentType]) // An array containing ContentType strings - case domainTypes(types: [(id: Int, name: String, sortOrdinal: Int)]) // An array of numerical IDs of the domains you are interested in. - case categoryTypes(types: [(id: Int, name: String, sortOrdinal: Int)]) // An array of numberical IDs of the categories you are interested in. + case contentTypes([ContentType]) // An array containing ContentType strings + case domainTypes([(id: Int, name: String, sortOrdinal: Int)]) // An array of numerical IDs of the domains you are interested in. + case categoryTypes([(id: Int, name: String, sortOrdinal: Int)]) // An array of numerical IDs of the categories you are interested in. case difficulties([ContentDifficulty]) // An array populated with ContentDifficulty options - case contentIDs(ids: [Int]) - case queryString(string: String) + case contentIDs([Int]) + case queryString(String) case completionStatus(status: CompletionStatus) case subscriptionPlans(plans: [ContentSubscriptionPlan]) @@ -147,22 +149,23 @@ enum ParameterFilterValue { var value: String { switch self { - case .queryString(let str): - return str + case .queryString(let string): + return string case .completionStatus(let status): return status.rawValue - case .contentIDs, - .contentTypes, - .domainTypes, - .difficulties, - .categoryTypes, - .subscriptionPlans: + case + .contentIDs, + .contentTypes, + .domainTypes, + .difficulties, + .categoryTypes, + .subscriptionPlans: return "" } } } -// sort=-released_at; reversechronological order +// sort=-released_at; reverse chronological order enum ParameterSortValue: String, Codable { case popularity = "popularity" case releasedAt = "released_at" @@ -201,14 +204,16 @@ enum Param { // Only to be used for the search query filter static func filter(for param: ParameterFilterValue) -> Parameter { - Parameter(key: "filter[\(param.strKey)]", value: param.value, displayName: param.value, sortOrdinal: 0) + .init(key: "filter[\(param.strKey)]", value: param.value, displayName: param.value, sortOrdinal: 0) } - static func sort(for value: ParameterSortValue, - descending: Bool) -> Parameter { + static func sort( + for value: ParameterSortValue, + descending: Bool + ) -> Parameter { let key = "sort" let value = "\(descending ? "-" : "")\(value.rawValue)" - return Parameter(key: key, value: value, displayName: "Sort", sortOrdinal: 0) + return .init(key: key, value: value, displayName: "Sort", sortOrdinal: 0) } } diff --git a/Emitron/Emitron/Networking/Services/BookmarksService.swift b/Emitron/Emitron/Networking/Services/BookmarksService.swift index bf651108..3dc244f9 100644 --- a/Emitron/Emitron/Networking/Services/BookmarksService.swift +++ b/Emitron/Emitron/Networking/Services/BookmarksService.swift @@ -26,26 +26,24 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -class BookmarksService: Service { +import class Foundation.URLSession - // MARK: - Internal - func bookmarks(parameters: [Parameter]? = nil, - completion: @escaping (_ response: Result) -> Void) { - let request = GetBookmarksRequest() - makeAndProcessRequest(request: request, - parameters: parameters, - completion: completion) +struct BookmarksService: Service { + let networkClient: RWAPI + let session = URLSession(configuration: .default) +} + +// MARK: - internal +extension BookmarksService { + func bookmarks(parameters: [Parameter] = []) async throws -> GetBookmarksRequest.Response { + try await makeRequest(request: GetBookmarksRequest(), parameters: parameters) } - func makeBookmark(for id: Int, completion: @escaping (_ response: Result) -> Void) { - let request = MakeBookmark(id: id) - makeAndProcessRequest(request: request, - completion: completion) + func makeBookmark(for id: Int) async throws -> MakeBookmark.Response { + try await makeRequest(request: MakeBookmark(id: id)) } - func destroyBookmark(for id: Int, completion: @escaping (_ response: Result) -> Void) { - let request = DestroyBookmarkRequest(id: id) - makeAndProcessRequest(request: request, - completion: completion) + func destroyBookmark(for id: Int) async throws -> DestroyBookmarkRequest.Response { + try await makeRequest(request: DestroyBookmarkRequest(id: id)) } } diff --git a/Emitron/Emitron/Networking/Services/CategoriesService.swift b/Emitron/Emitron/Networking/Services/CategoriesService.swift index d16e957a..1e810b75 100644 --- a/Emitron/Emitron/Networking/Services/CategoriesService.swift +++ b/Emitron/Emitron/Networking/Services/CategoriesService.swift @@ -26,12 +26,16 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -class CategoriesService: Service { +import class Foundation.URLSession - // MARK: - Internal - func allCategories(completion: @escaping (_ response: Result) -> Void) { - let request = CategoriesRequest() - makeAndProcessRequest(request: request, - completion: completion) +struct CategoriesService: Service { + let networkClient: RWAPI + let session = URLSession(configuration: .default) +} + +// MARK: - internal +extension CategoriesService { + var allCategories: CategoriesRequest.Response { + get async throws { try await makeRequest(request: CategoriesRequest()) } } } diff --git a/Emitron/Emitron/Networking/Services/ContentsService.swift b/Emitron/Emitron/Networking/Services/ContentsService.swift index 20ddd207..14017c96 100644 --- a/Emitron/Emitron/Networking/Services/ContentsService.swift +++ b/Emitron/Emitron/Networking/Services/ContentsService.swift @@ -26,49 +26,38 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -final class ContentsService: Service { } +import class Foundation.URLSession + +struct ContentsService: Service { + let networkClient: RWAPI + let session = URLSession(configuration: .default) +} // MARK: - internal extension ContentsService { - func allContents( - parameters: [Parameter], - completion: @escaping (_ response: Result) -> Void - ) { - let request = ContentsRequest() - makeAndProcessRequest( - request: request, - parameters: parameters, - completion: completion - ) + func allContents(parameters: [Parameter]) async throws -> ContentsRequest.Response { + try await makeRequest(request: ContentsRequest(), parameters: parameters) } - func contentDetails( - for id: Int, - completion: @escaping (_ response: Result) -> Void - ) { - let request = ContentDetailsRequest(id: id) - makeAndProcessRequest( - request: request, - completion: completion - ) + func contentDetails(for id: Int) async throws -> ContentDetailsRequest.Response { + try await makeRequest(request: ContentDetailsRequest(id: id)) } - func getBeginPlaybackToken(completion: @escaping(_ response: Result) -> Void) { - let request = BeginPlaybackTokenRequest() - makeAndProcessRequest(request: request, - completion: completion) + var beginPlaybackToken: BeginPlaybackTokenRequest.Response { + get async throws { try await makeRequest(request: BeginPlaybackTokenRequest()) } } - func reportPlaybackUsage(for id: Int, - progress: Int, - playbackToken: String, - completion: @escaping(_ response: Result) -> Void) { - let request = PlaybackUsageRequest( - id: id, - progress: progress, - token: playbackToken + func reportPlaybackUsage( + for id: Int, + progress: Int, + playbackToken: String + ) async throws -> PlaybackUsageRequest.Response { + try await makeRequest( + request: PlaybackUsageRequest( + id: id, + progress: progress, + token: playbackToken + ) ) - makeAndProcessRequest(request: request, - completion: completion) } } diff --git a/Emitron/Emitron/Networking/Services/DomainsService.swift b/Emitron/Emitron/Networking/Services/DomainsService.swift index 30bea516..bc0394f3 100644 --- a/Emitron/Emitron/Networking/Services/DomainsService.swift +++ b/Emitron/Emitron/Networking/Services/DomainsService.swift @@ -26,12 +26,16 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -class DomainsService: Service { +import class Foundation.URLSession - // MARK: - Internal - func allDomains(completion: @escaping (_ response: Result) -> Void) { - let request = DomainsRequest() - makeAndProcessRequest(request: request, - completion: completion) +struct DomainsService: Service { + let networkClient: RWAPI + let session = URLSession(configuration: .default) +} + +// MARK: - internal +extension DomainsService { + var allDomains: DomainsRequest.Response { + get async throws { try await makeRequest(request: DomainsRequest()) } } } diff --git a/Emitron/Emitron/Networking/Services/PermissionsService.swift b/Emitron/Emitron/Networking/Services/PermissionsService.swift index 61f16657..3be68ca9 100644 --- a/Emitron/Emitron/Networking/Services/PermissionsService.swift +++ b/Emitron/Emitron/Networking/Services/PermissionsService.swift @@ -26,12 +26,16 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -class PermissionsService: Service { +import class Foundation.URLSession - // MARK: - Internal - func permissions(completion: @escaping (_ response: Result) -> Void) { - let request = PermissionsRequest() - makeAndProcessRequest(request: request, - completion: completion) +struct PermissionsService: Service { + let networkClient: RWAPI + let session = URLSession(configuration: .default) +} + +// MARK: - internal +extension PermissionsService { + var permissions: PermissionsRequest.Response { + get async throws { try await makeRequest(request: PermissionsRequest()) } } } diff --git a/Emitron/Emitron/Networking/Services/ProgressionsService.swift b/Emitron/Emitron/Networking/Services/ProgressionsService.swift index 9c98fc35..067049b3 100644 --- a/Emitron/Emitron/Networking/Services/ProgressionsService.swift +++ b/Emitron/Emitron/Networking/Services/ProgressionsService.swift @@ -26,27 +26,24 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -class ProgressionsService: Service { +import class Foundation.URLSession - // MARK: - Internal - func progressions(parameters: [Parameter]? = nil, - completion: @escaping (_ response: Result) -> Void) { - let request = ProgressionsRequest() - makeAndProcessRequest(request: request, - parameters: parameters, - completion: completion) +struct ProgressionsService: Service { + let networkClient: RWAPI + let session = URLSession(configuration: .default) +} + +// MARK: - internal +extension ProgressionsService { + func progressions(parameters: [Parameter] = []) async throws -> ProgressionsRequest.Response { + try await makeRequest(request: ProgressionsRequest(), parameters: parameters) } - - func update(progressions: [ProgressionUpdate], - completion: @escaping (_ response: Result) -> Void) { - let request = UpdateProgressionsRequest(progressionUpdates: progressions) - makeAndProcessRequest(request: request, - completion: completion) + + func update(progressions: [ProgressionUpdate]) async throws -> UpdateProgressionsRequest.Response { + try await makeRequest(request: UpdateProgressionsRequest(progressionUpdates: progressions)) } - - func delete(with id: Int, completion: @escaping (_ response: Result) -> Void) { - let request = DeleteProgressionRequest(id: id) - makeAndProcessRequest(request: request, - completion: completion) + + func delete(with id: Int) async throws -> DeleteProgressionRequest.Response { + try await makeRequest(request: DeleteProgressionRequest(id: id)) } } diff --git a/Emitron/Emitron/Networking/Services/Service.swift b/Emitron/Emitron/Networking/Services/Service.swift index b192cf9d..d10b9a95 100644 --- a/Emitron/Emitron/Networking/Services/Service.swift +++ b/Emitron/Emitron/Networking/Services/Service.swift @@ -28,84 +28,58 @@ import Foundation -class Service { - - // MARK: - Properties - let networkClient: RWAPI - let session: URLSession +protocol Service { + var networkClient: RWAPI { get } + var session: URLSession { get } +} - // MARK: - Initializers - init(client: RWAPI) { - networkClient = client - session = URLSession(configuration: .default) - } - - // MARK: - Utilities +extension Service { var isAuthenticated: Bool { !networkClient.authToken.isEmpty } - // MARK: - Internal - func makeAndProcessRequest( + @MainActor func makeRequest( request: Request, - parameters: [Parameter]? = nil, - completion: @escaping (Result) -> Void - ) { - let handleResponse = { result in - DispatchQueue.main.async { - completion(result) - } - } - - guard let urlRequest = prepare(request: request, parameters: parameters) else { - return - } + parameters: [Parameter] = [] + ) async throws -> Request.Response { + func prepare( + request: Request, + parameters: [Parameter] + ) throws -> URLRequest { + let pathURL = networkClient.environment.baseURL.appendingPathComponent(request.path) - let task = session.dataTask(with: urlRequest) { data, response, error in - let statusCode = (response as? HTTPURLResponse)?.statusCode - guard statusCode.map((200..<300).contains) == true - else { - handleResponse(.failure(.requestFailed(error, statusCode ?? 0))) - return + guard var components = URLComponents( + url: pathURL, + resolvingAgainstBaseURL: false + ) else { + throw URLError(.badURL) } - do { - if let data = data { - let value = try request.handle(response: data) - handleResponse(.success(value)) - } else { - handleResponse(.failure(.noData)) - } - } catch let handleError as NSError { - handleResponse(.failure(.processingError(handleError))) - } - } - task.resume() - } + components.queryItems = parameters.map { .init(name: $0.key, value: $0.value) } - func prepare(request: R, - parameters: [Parameter]?) -> URLRequest? { - let pathURL = networkClient.environment.baseURL.appendingPathComponent(request.path) - var components = URLComponents(url: pathURL, - resolvingAgainstBaseURL: false) + guard let url = components.url + else { throw URLError(.badURL) } - if let parameters = parameters { - components?.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) } - } + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = request.method.rawValue + // body *needs* to be the last property that we set, because of this bug: https://bugs.swift.org/browse/SR-6687 + urlRequest.httpBody = request.body + + let authTokenHeader: HTTPHeader = ("Authorization", "Token \(networkClient.authToken)") + let headers = + [authTokenHeader, networkClient.contentTypeHeader] + + [networkClient.additionalHeaders, request.additionalHeaders].joined() + headers.forEach { urlRequest.addValue($0.value, forHTTPHeaderField: $0.key) } - guard let url = components?.url else { - return nil + return urlRequest } - var urlRequest = URLRequest(url: url) - urlRequest.httpMethod = request.method.rawValue - // body *needs* to be the last property that we set, because of this bug: https://bugs.swift.org/browse/SR-6687 - urlRequest.httpBody = request.body + let (data, response) = try await session.data( + for: try prepare(request: request, parameters: parameters) + ) - let authTokenHeader: HTTPHeader = ("Authorization", "Token \(networkClient.authToken)") - let headers = - [authTokenHeader, networkClient.contentTypeHeader] - + [networkClient.additionalHeaders, request.additionalHeaders].joined() - headers.forEach { urlRequest.addValue($0.value, forHTTPHeaderField: $0.key) } + let statusCode = (response as? HTTPURLResponse)?.statusCode + guard statusCode.map((200..<300).contains) == true + else { throw RWAPIError.requestFailed(nil, statusCode ?? 0) } - return urlRequest + return try request.handle(response: data) } } diff --git a/Emitron/Emitron/Networking/Services/VideosService.swift b/Emitron/Emitron/Networking/Services/VideosService.swift index 84184d35..d9159664 100644 --- a/Emitron/Emitron/Networking/Services/VideosService.swift +++ b/Emitron/Emitron/Networking/Services/VideosService.swift @@ -26,21 +26,27 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -class VideosService: Service { - typealias Provider = (RWAPI) -> VideosService +import class Foundation.URLSession - // MARK: - Internal - func getVideoStream(for id: Int, - completion: @escaping (_ response: Result) -> Void) { - let request = StreamVideoRequest(id: id) - makeAndProcessRequest(request: request, - completion: completion) +protocol VideosServiceProtocol { + typealias Provider = (RWAPI) -> any VideosServiceProtocol + + func videoStream(for id: Int) async throws -> StreamVideoRequest.Response + func videoStreamDownload(for id: Int) async throws -> StreamVideoRequest.Response +} + +struct VideosService: Service { + let networkClient: RWAPI + let session = URLSession(configuration: .default) +} + +// MARK: - VideosServiceProtocol +extension VideosService: VideosServiceProtocol { + func videoStream(for id: Int) async throws -> StreamVideoRequest.Response { + try await makeRequest(request: StreamVideoRequest(id: id)) } - func getVideoStreamDownload(for id: Int, - completion: @escaping (_ response: Result) -> Void) { - let request = DownloadStreamVideoRequest(id: id) - makeAndProcessRequest(request: request, - completion: completion) + func videoStreamDownload(for id: Int) async throws -> StreamVideoRequest.Response { + try await makeRequest(request: DownloadStreamVideoRequest(id: id)) } } diff --git a/Emitron/Emitron/Networking/Services/WatchStatsService.swift b/Emitron/Emitron/Networking/Services/WatchStatsService.swift index 3ec024e9..9d6f4a20 100644 --- a/Emitron/Emitron/Networking/Services/WatchStatsService.swift +++ b/Emitron/Emitron/Networking/Services/WatchStatsService.swift @@ -26,11 +26,16 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -class WatchStatsService: Service { - func update(watchStats: [WatchStat], - completion: @escaping (_ response: Result) -> Void) { - let request = WatchStatsUpdateRequest(watchStats: watchStats) - makeAndProcessRequest(request: request, - completion: completion) +import class Foundation.URLSession + +struct WatchStatsService: Service { + let networkClient: RWAPI + let session = URLSession(configuration: .default) +} + +// MARK: - internal +extension WatchStatsService { + func update(watchStats: [WatchStat]) async throws -> WatchStatsUpdateRequest.Response { + try await makeRequest(request: WatchStatsUpdateRequest(watchStats: watchStats)) } } diff --git a/Emitron/Emitron/Persistence/EmitronDatabase.swift b/Emitron/Emitron/Persistence/EmitronDatabase.swift index d10cac52..8fd2c25e 100644 --- a/Emitron/Emitron/Persistence/EmitronDatabase.swift +++ b/Emitron/Emitron/Persistence/EmitronDatabase.swift @@ -30,7 +30,7 @@ import GRDB // swiftlint:disable identifier_name -/// A type responsible for initialising the appliation's database +/// A type responsible for initialising the application's database enum EmitronDatabase { /// Creates a fully initialised database /// - Parameter path: Path at which to create the database diff --git a/Emitron/Emitron/Persistence/PersistenceStore+Downloads.swift b/Emitron/Emitron/Persistence/PersistenceStore+Downloads.swift index 0cb56ffb..e20b1cab 100644 --- a/Emitron/Emitron/Persistence/PersistenceStore+Downloads.swift +++ b/Emitron/Emitron/Persistence/PersistenceStore+Downloads.swift @@ -35,7 +35,7 @@ extension PersistenceStore { /// List of all downloads func downloadList() -> DatabasePublishers.Value<[ContentSummaryState]> { ValueObservation.tracking { db -> [ContentSummaryState] in - let contentTypes = [ContentType.collection, ContentType.screencast].map(\.rawValue) + let contentTypes = [ContentType.collection, .screencast].map(\.rawValue) let request = Content .filter(contentTypes.contains(Content.Columns.contentType)) .including(required: Content.download) @@ -154,7 +154,7 @@ extension PersistenceStore { let childrenCompleted: Int var state: Download.State { - if childrenRequested == childrenCompleted && childrenCompleted == totalChildren { + if childrenRequested == childrenCompleted, childrenCompleted == totalChildren { return .complete } if childrenCompleted < childrenRequested { @@ -173,21 +173,22 @@ extension PersistenceStore { /// Summary download stats for the children of the given collection /// - Parameter contentID: ID representing an item of `Content` with `ContentType` of `.collection` - func collectionDownloadSummary(forContentID contentID: Int) throws -> CollectionDownloadSummary { - try db.read { db in - guard let content = try Content.fetchOne(db, key: contentID), - content.contentType == .collection else { - throw PersistenceStoreError.argumentError + func collectionDownloadSummary(forContentID contentID: Int) async throws -> CollectionDownloadSummary { + try await db.read { db in + guard + let content = try Content.fetchOne(db, key: contentID), + content.contentType == .collection + else { + throw PersistenceStore.Error.argumentError } - - let totalChildren = try content.childContents.fetchCount(db) - let totalChildDownloads = try content.childDownloads.fetchCount(db) - let totalCompletedChildDownloads = try content.childDownloads.filter(Download.Columns.state == Download.State.complete.rawValue).fetchCount(db) - return CollectionDownloadSummary( - totalChildren: totalChildren, - childrenRequested: totalChildDownloads, - childrenCompleted: totalCompletedChildDownloads + return .init( + totalChildren: try content.childContents.fetchCount(db), + childrenRequested: try content.childDownloads.fetchCount(db), + childrenCompleted: + try content.childDownloads + .filter(Download.Columns.state == Download.State.complete.rawValue) + .fetchCount(db) ) } } @@ -199,51 +200,49 @@ extension PersistenceStore { /// - Parameters: /// - id: The UUID of the download to transition /// - state: The new `Download.State` to transition to. - func transitionDownload(withID id: UUID, to state: Download.State) throws { - try db.write { db in - if var download = try Download.fetchOne(db, key: id) { - try download.updateChanges(db) { - $0.state = state - } - // Check whether we need to update the parent state - asyncUpdateParentDownloadState(for: download) + func transitionDownload(withID id: UUID, to state: Download.State) async throws { + guard let download = try await db.read({ db in + try Download.fetchOne(db, key: id) + }) else { + return + } + + try await db.write { [download] db in + var download = download + try download.updateChanges(db) { + $0.state = state } } + + // Check whether we need to update the parent state + try await asyncUpdateParentDownloadState(for: download) } /// Asynchronous method to ensure that the parent download object is kept in sync /// - Parameter download: The potential child download whose parent we want to update - private func asyncUpdateParentDownloadState(for download: Download) { - workerQueue.async { [weak self] in - guard let self = self else { return } - - do { - var parentDownload: Download? - try self.db.read { db in - parentDownload = try download.parentDownload.fetchOne(db) - } - if let parentDownload = parentDownload { - try self.updateCollectionDownloadState(collectionDownload: parentDownload) - } - } catch { - Failure - .saveToPersistentStore(from: String(describing: type(of: self)), reason: "Unable to update parent.") - .log() + private func asyncUpdateParentDownloadState(for download: Download) async throws { + do { + if let parentDownload = try await db.read({ db in + try download.parentDownload.fetchOne(db) + }) { + try await updateCollectionDownloadState(collectionDownload: parentDownload) } + } catch { + Failure + .saveToPersistentStore(from: Self.self, reason: "Unable to update parent.") + .log() } } /// Asynchronous method to ensure that this parent download is kept in sync with its kids /// - Parameter parentDownload: The parent object to update private func asyncUpdateDownloadState(forParentDownload parentDownload: Download) { - workerQueue.async { [weak self] in - guard let self = self else { return } - + Task { do { - try self.updateCollectionDownloadState(collectionDownload: parentDownload) + try await updateCollectionDownloadState(collectionDownload: parentDownload) } catch { Failure - .saveToPersistentStore(from: String(describing: type(of: self)), reason: "Unable to update parent.") + .saveToPersistentStore(from: Self.self, reason: "Unable to update parent.") .log() } } @@ -251,11 +250,11 @@ extension PersistenceStore { /// Update the collection download to match the current status of its children /// - Parameter collectionDownload: A `Download` that is associated with a collection `Content` - private func updateCollectionDownloadState(collectionDownload: Download) throws { - let downloadSummary = try collectionDownloadSummary(forContentID: collectionDownload.contentID) - var download = collectionDownload - - _ = try db.write { db in + private func updateCollectionDownloadState(collectionDownload: Download) async throws { + let downloadSummary = try await collectionDownloadSummary(forContentID: collectionDownload.contentID) + + try await db.write { db in + var download = collectionDownload if downloadSummary.childrenRequested == 0 { try download.delete(db) } else { @@ -271,8 +270,8 @@ extension PersistenceStore { /// - Parameters: /// - id: The UUID of the download to update /// - progress: The new value of progress (0–1) - func updateDownload(withID id: UUID, withProgress progress: Double) throws { - try db.write { db in + func updateDownload(withID id: UUID, withProgress progress: Double) async throws { + try await db.write { db in if var download = try Download.fetchOne(db, key: id) { try download.updateChanges(db) { $0.progress = progress @@ -283,11 +282,11 @@ extension PersistenceStore { /// Save the changes to the already persisted Download object /// - Parameter download: The download object to save. It should already exist - func update(download: Download) throws { - try db.write { db in + func update(download: Download) async throws { + try await db.write { db in try download.update(db) } - asyncUpdateParentDownloadState(for: download) + Task { try await asyncUpdateParentDownloadState(for: download) } } /// Delete a download @@ -308,107 +307,94 @@ extension PersistenceStore { /// Delete the downloads without selected IDs. /// - Parameter ids: Array of UUIDs for the downloads to delete - func deleteDownloads(withIDs ids: [UUID]) -> Future { - Future { promise in - self.workerQueue.async { [weak self] in - guard let self = self else { return } - - do { - try self.db.write { db in - let downloads = try ids.compactMap { try Download.fetchOne(db, key: $0) } - let parentDownloads = try Set(downloads.compactMap { try $0.parentDownload.fetchOne(db) }) - // Only update parents that we're not gonna delete - let parentsThatNeedUpdating = parentDownloads.subtracting(downloads) - - // Delete all the downloads requested - try Download.deleteAll(db, keys: ids) - - // And update any parents that need doing - parentsThatNeedUpdating.forEach { - self.asyncUpdateDownloadState(forParentDownload: $0) - } - - promise(.success(())) - } - } catch { - promise(.failure(error)) - } + func deleteDownloads(withIDs ids: [UUID]) async throws { + try await db.write { db in + let downloads = try ids.compactMap { try Download.fetchOne(db, key: $0) } + let parentDownloads = try Set(downloads.compactMap { try $0.parentDownload.fetchOne(db) }) + // Only update parents that we're not gonna delete + let parentsThatNeedUpdating = parentDownloads.subtracting(downloads) + + // Delete all the downloads requested + try Download.deleteAll(db, keys: ids) + + // And update any parents that need doing + parentsThatNeedUpdating.forEach { + self.asyncUpdateDownloadState(forParentDownload: $0) } } } - /// Save the entire graph of models to support this ContentDeailsModel + /// Save the entire graph of models to support this ContentDetailsModel /// - Parameter contentPersistableState: The model to persist—from the DataCache. - func persistContentGraph(for contentPersistableState: ContentPersistableState, contentLookup: ContentLookup? = nil) -> Future { - Future { promise in - self.workerQueue.async { [weak self] in - guard let self = self else { return } - do { - try self.db.write { db in - try self.persistContentItem(for: contentPersistableState, inDatabase: db, withChildren: true, withParent: true, contentLookup: contentLookup) - } - promise(.success(())) - } catch { - promise(.failure(error)) - } - } + func persistContentGraph( + for contentPersistableState: ContentPersistableState, + contentLookup: ContentLookup? = nil + ) async throws { + try await db.write { db in + try Self.persistContentItem( + for: contentPersistableState, + inDatabase: db, + withChildren: true, + withParent: true, + contentLookup: contentLookup + ) } } - func createDownloads(for content: Content) -> Future { - Future { promise in - self.workerQueue.async { [weak self] in - guard let self = self else { return } - do { - try self.db.write { db in - // Create it for this content item - try self.createDownload(for: content, inDatabase: db) - - // Also need to create one for the parent - if let parentContent = try content.parentContent.fetchOne(db) { - try self.createDownload(for: parentContent, inDatabase: db) - } - - // And now for any children that might exist - let childContent = try content.childContents.order(Content.Columns.ordinal.asc).fetchAll(db) - try childContent.forEach { contentItem in - try self.createDownload(for: contentItem, inDatabase: db) - } - promise(.success(())) - } - } catch { - promise(.failure(error)) + func createDownloads(for content: Content) async throws { + try await db.write { db in + func createDownload(for content: Content) throws { + // Check whether this already exists + if try content.download.fetchCount(db) > 0 { + return } + // Create and save the Download + var download = Download(content: content) + try download.insert(db) } + + // Create it for this content item + try createDownload(for: content) + + // Also need to create one for the parent + if let parentContent = try content.parentContent.fetchOne(db) { + try createDownload(for: parentContent) + } + + // And now for any children that might exist + let childContent = try content.childContents.order(Content.Columns.ordinal.asc).fetchAll(db) + try childContent.forEach(createDownload) } } - - private func createDownload(for content: Content, inDatabase db: Database) throws { - // Check whether this already exists - if try content.download.fetchCount(db) > 0 { - return - } - // Create and save the Download - var download = Download.create(for: content) - try download.insert(db) - } } -// MARK: - Private data writing methods -extension PersistenceStore { - /// Save a content item, optionally including it's parent and children +// MARK: - private +private extension PersistenceStore { + /// Save a content item, optionally including its parent and children /// - Parameters: /// - contentDetailState: The ContentDetailState to persist /// - db: A `Database` object to save it - private func persistContentItem(for contentPersistableState: ContentPersistableState, inDatabase db: Database, withChildren: Bool = false, withParent: Bool = false, contentLookup: ContentLookup? = nil) throws { - + static func persistContentItem( + for contentPersistableState: ContentPersistableState, + inDatabase db: Database, + withChildren: Bool = false, + withParent: Bool = false, + contentLookup: ContentLookup? = nil + ) throws { // 1. Need to do parent first—we need foreign key // constraints on the groupID for child content - if withParent, + if + withParent, let parentContent = contentPersistableState.parentContent, - let contentLookup = contentLookup, - let parentPersistable = contentLookup(parentContent.id) { - try persistContentItem(for: parentPersistable, inDatabase: db, withChildren: true, contentLookup: contentLookup) + let contentLookup = contentLookup + { + let parentPersistable = try contentLookup(parentContent.id) + try persistContentItem( + for: parentPersistable, + inDatabase: db, + withChildren: true, + contentLookup: contentLookup + ) } // 2. Generate and save this content item @@ -420,9 +406,8 @@ extension PersistenceStore { // 4. Children if withChildren, let contentLookup = contentLookup { try contentPersistableState.childContents.forEach { content in - if let childPersistable = contentLookup(content.id) { - try persistContentItem(for: childPersistable, inDatabase: db) - } + let childPersistable = try contentLookup(content.id) + try persistContentItem(for: childPersistable, inDatabase: db) } } diff --git a/Emitron/Emitron/Persistence/PersistenceStore+Keychain.swift b/Emitron/Emitron/Persistence/PersistenceStore+Keychain.swift index f5d0c494..4dd7521a 100644 --- a/Emitron/Emitron/Persistence/PersistenceStore+Keychain.swift +++ b/Emitron/Emitron/Persistence/PersistenceStore+Keychain.swift @@ -35,15 +35,14 @@ import KeychainSwift private let ssoUserKey = "com.razeware.emitron.sso_user" extension PersistenceStore { - @discardableResult - func persistUserToKeychain(user: User, encoder: JSONEncoder = .init()) -> Bool { - guard let encoded = try? encoder.encode(user) else { - return false + func persistUserToKeychain(user: User, encoder: JSONEncoder = .init()) throws { + guard KeychainSwift().set( + try encoder.encode(user), + forKey: ssoUserKey, + withAccess: .accessibleAfterFirstUnlock + ) else { + throw Error.keychainFailure } - - return KeychainSwift().set(encoded, - forKey: ssoUserKey, - withAccess: .accessibleAfterFirstUnlock) } func userFromKeychain(_ decoder: JSONDecoder = .init()) -> User? { @@ -55,14 +54,17 @@ extension PersistenceStore { return try decoder.decode(User.self, from: encoded) } catch { Failure - .loadFromPersistentStore(from: "PersistenceStore_Keychain", reason: error.localizedDescription) + .loadFromPersistentStore( + from: "\(PersistenceStore.self)_Keychain", + reason: error.localizedDescription + ) .log() return nil } } - - @discardableResult - func removeUserFromKeychain() -> Bool { - KeychainSwift().delete(ssoUserKey) + + func removeUserFromKeychain() throws { + guard KeychainSwift().delete(ssoUserKey) + else { throw Error.keychainFailure } } } diff --git a/Emitron/Emitron/Persistence/PersistenceStore+Synchronisation.swift b/Emitron/Emitron/Persistence/PersistenceStore+Synchronisation.swift index f1aa4df1..bf41a938 100644 --- a/Emitron/Emitron/Persistence/PersistenceStore+Synchronisation.swift +++ b/Emitron/Emitron/Persistence/PersistenceStore+Synchronisation.swift @@ -224,14 +224,14 @@ extension PersistenceStore { try $0.delete(db) } catch { Failure - .deleteFromPersistentStore(from: String(describing: type(of: self)), reason: "Unable to delete sync request: \(error)") + .deleteFromPersistentStore(from: Self.self, reason: "Unable to delete sync request: \(error)") .log() } } } } catch { Failure - .deleteFromPersistentStore(from: String(describing: type(of: self)), reason: "Unable to delete sync requests: \(error)") + .deleteFromPersistentStore(from: Self.self, reason: "Unable to delete sync requests: \(error)") .log() } } diff --git a/Emitron/Emitron/Persistence/PersistenceStore.swift b/Emitron/Emitron/Persistence/PersistenceStore.swift index 286321a7..5908cde6 100644 --- a/Emitron/Emitron/Persistence/PersistenceStore.swift +++ b/Emitron/Emitron/Persistence/PersistenceStore.swift @@ -26,25 +26,25 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import Combine -import class Foundation.DispatchQueue -import GRDB - -enum PersistenceStoreError: Error { - case argumentError - case notFound -} +import protocol Combine.ObservableObject +import protocol GRDB.DatabaseWriter // The object responsible for managing and accessing cached content final class PersistenceStore: ObservableObject { - let db: DatabaseWriter - let workerQueue = DispatchQueue(label: "com.razeware.emitron.persistence", qos: .background) + enum Error: Swift.Error { + case argumentError + case notFound + case keychainFailure + } + + let db: any DatabaseWriter - init(db: DatabaseWriter) { + init(db: DB) { self.db = db } } +// MARK: - internal extension PersistenceStore { /// Completely erase the database. Used for logout. func erase() throws { diff --git a/Emitron/Emitron/Protocols/Refreshable.swift b/Emitron/Emitron/Protocols/Refreshable.swift index 162d2822..17eac018 100644 --- a/Emitron/Emitron/Protocols/Refreshable.swift +++ b/Emitron/Emitron/Protocols/Refreshable.swift @@ -44,23 +44,23 @@ extension Refreshable { } var shouldRefresh: Bool { - if let lastRefreshedDate = lastRefreshedDate { - if lastRefreshedDate > refreshableCheckTimeSpan.date { - Event - .refresh(from: String(describing: type(of: self)), action: "Last Updated: \(lastRefreshedDate). No refresh required.") - .log() - return false - } else { - Event - .refresh(from: String(describing: type(of: self)), action: "Last Updated: \(lastRefreshedDate). Refresh is required.") - .log() - return true - } + func logEvent(action: String) { + Event + .refresh(from: Self.self, action: "Last Updated: \(action)") + .log() + } + + switch lastRefreshedDate { + case let lastRefreshedDate? where lastRefreshedDate > refreshableCheckTimeSpan.date: + logEvent(action: "\(lastRefreshedDate). No refresh required.") + return false + case let lastRefreshedDate?: + logEvent(action: "\(lastRefreshedDate). Refresh is required.") + return true + case nil: + logEvent(action: "UNKNOWN. Refresh is required.") + return true } - Event - .refresh(from: String(describing: type(of: self)), action: "Last Updated: UNKNOWN. Refresh is required.") - .log() - return true } var refreshableUserDefaultsKey: String { "UserDefaultsRefreshable\(Self.self)" } diff --git a/Emitron/Emitron/Sessions/SessionController.swift b/Emitron/Emitron/Sessions/SessionController.swift index 771767be..95cb216f 100644 --- a/Emitron/Emitron/Sessions/SessionController.swift +++ b/Emitron/Emitron/Sessions/SessionController.swift @@ -42,7 +42,7 @@ protocol UserModelController { } // Conforming to NSObject, so that we can conform to ASWebAuthenticationPresentationContextProviding -final class SessionController: NSObject, UserModelController, ObservablePrePostFactoObject { +final class SessionController: UserModelController, ObservablePrePostFactoObject { private var subscriptions = Set() // Managing the state of the current session @@ -79,9 +79,7 @@ final class SessionController: NSObject, UserModelController, ObservablePrePostF private let connectionMonitor = NWPathMonitor() private(set) var permissionsService: PermissionsService - var isLoggedIn: Bool { - userState == .loggedIn - } + var isLoggedIn: Bool { userState == .loggedIn } var hasPermissions: Bool { if case .loaded = permissionState { @@ -91,7 +89,7 @@ final class SessionController: NSObject, UserModelController, ObservablePrePostF } var hasPermissionToUseApp: Bool { - user?.hasPermissionToUseApp ?? false + user?.hasPermissionToUseApp == true } var hasCurrentDownloadPermissions: Bool { @@ -108,58 +106,47 @@ final class SessionController: NSObject, UserModelController, ObservablePrePostF } // MARK: - Initializers - init(guardpost: Guardpost) { - dispatchPrecondition(condition: .onQueue(.main)) - + @MainActor init(guardpost: Guardpost) { self.guardpost = guardpost let user = User.backdoor ?? guardpost.currentUser client = RWAPI(authToken: user?.token ?? "") - permissionsService = PermissionsService(client: client) - super.init() + permissionsService = .init(networkClient: client) self.user = user prepareSubscriptions() } // MARK: - Internal - func login() { + @MainActor func logIn() async throws { guard userState != .loggingIn else { return } userState = .loggingIn - guardpost.presentationContextDelegate = self if isLoggedIn { if !hasPermissions { fetchPermissions() } } else { - guardpost.login { [weak self] result in - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - - switch result { - case .failure(let error): - self.userState = .notLoggedIn - self.permissionState = .notLoaded + do { + user = try await guardpost.logIn() + Event + .login(from: Self.self) + .log() + fetchPermissions() + } catch { + userState = .notLoggedIn + permissionState = .notLoaded - Failure - .login(from: "SessionController", reason: error.localizedDescription) - .log() - case .success(let user): - self.user = user - Event - .login(from: "SessionController") - .log() - self.fetchPermissions() - } - } + Failure + .login(from: Self.self, reason: error.localizedDescription) + .log() } } } func fetchPermissionsIfNeeded() { - // Request persmission if an app launch has happened or if it's been over 24 hours since the last permission request once the app enters the foreground + // Request permission if an app launch has happened or if it's been over 24 hours since the last permission request once the app enters the foreground guard shouldRefresh || !hasPermissions else { return } fetchPermissions() @@ -179,33 +166,34 @@ final class SessionController: NSObject, UserModelController, ObservablePrePostF guard isLoggedIn else { return } permissionState = .loading - permissionsService.permissions { result in - DispatchQueue.main.async { - switch result { - case .failure(let error): - Failure - .fetch(from: "SessionController_Permissions", reason: error.localizedDescription) - .log() - - self.permissionState = .error - case .success(let permissions): - // Check that we have a logged in user. Otherwise this is pointless - guard let user = self.user else { return } - - // Update the date that we retrieved the permissions - self.saveOrReplaceRefreshableUpdateDate() - - // Update the user - self.user = user.with(permissions: permissions) - // Ensure guardpost is aware, and hence the keychain is updated - self.guardpost.updateUser(with: self.user) - } + + Task { + do { + let permissions = try await permissionsService.permissions + + // Check that we have a logged in user. Otherwise this is pointless + guard let user = self.user else { return } + + // Update the date that we retrieved the permissions + self.saveOrReplaceRefreshableUpdateDate() + + // Update the user + self.user = user.with(permissions: permissions) + // Ensure guardpost is aware, and hence the keychain is updated + self.guardpost.updateUser(with: self.user) + } catch { + enum Permissions { } + Failure + .fetch(from: Permissions.self, reason: error.localizedDescription) + .log() + + self.permissionState = .error } } } - func logout() { - guardpost.logout() + func logOut() { + guardpost.logOut() userState = .notLoggedIn permissionState = .notLoaded @@ -216,7 +204,7 @@ final class SessionController: NSObject, UserModelController, ObservablePrePostF $user.sink { [weak self] user in guard let self = self else { return } self.client = RWAPI(authToken: user?.token ?? "") - self.permissionsService = PermissionsService(client: self.client) + self.permissionsService = .init(networkClient: self.client) } .store(in: &subscriptions) @@ -242,13 +230,6 @@ extension SessionController: Refreshable { var refreshableCheckTimeSpan: RefreshableTimeSpan { .short } } -// MARK: - ASWebAuthenticationPresentationContextProviding -extension SessionController: ASWebAuthenticationPresentationContextProviding { - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - UIApplication.shared.windows.first! - } -} - // MARK: - Content Access Permissions extension SessionController { func canPlay(content: Ownable) -> Bool { diff --git a/Emitron/Emitron/Settings/IconManager.swift b/Emitron/Emitron/Settings/IconManager.swift index c56083d7..417a441e 100644 --- a/Emitron/Emitron/Settings/IconManager.swift +++ b/Emitron/Emitron/Settings/IconManager.swift @@ -29,58 +29,64 @@ import UIKit import Combine -class IconManager: ObservableObject { - private(set) var icons = [Icon]() - let messageBus: MessageBus - @Published private(set) var currentIcon: Icon? - +final class IconManager: ObservableObject { init(messageBus: MessageBus) { - let currentIconName = UIApplication.shared.alternateIconName - currentIcon = icons.first { $0.name == currentIconName } self.messageBus = messageBus - populateIcons() - } - - func set(icon: Icon) { - UIApplication.shared.setAlternateIconName(icon.name) { error in - DispatchQueue.main.async { - if let error = error { - Failure - .appIcon(from: String(describing: type(of: self)), reason: error.localizedDescription) - .log() - self.messageBus.post(message: Message(level: .error, message: .appIconUpdateProblem)) - } else { - self.currentIcon = icon - self.messageBus.post(message: Message(level: .success, message: .appIconUpdatedSuccessfully)) + let currentIconName = UIApplication.shared.alternateIconName + + let icons: [Icon] = { + guard let plistIcons = Bundle.main.object(forInfoDictionaryKey: "CFBundleIcons") as? [String: Any] + else { return [] } + + var iconList = [Icon]() + + if + let primaryIcon = plistIcons["CFBundlePrimaryIcon"] as? [String: Any], + let files = primaryIcon["CFBundleIconFiles"] as? [String], + let fileName = files.first + { + iconList.append(.init(name: nil, imageName: fileName, ordinal: 0)) + } + + if let alternateIcons = plistIcons["CFBundleAlternateIcons"] as? [String: Any] { + iconList += alternateIcons.compactMap { key, value in + guard + let alternateIcon = value as? [String: Any], + let files = alternateIcon["CFBundleIconFiles"] as? [String], + let fileName = files.first, + let ordinal = alternateIcon["ordinal"] as? Int + else { return nil } + + return Icon(name: key, imageName: fileName, ordinal: ordinal) } + .sorted() } - } + + return iconList + }() + + self.icons = icons + currentIcon = icons.first { $0.name == currentIconName } } - private func populateIcons() { - guard let plistIcons = Bundle.main.object(forInfoDictionaryKey: "CFBundleIcons") as? [String: Any] else { return } - - var iconList = [Icon]() - - if let primaryIcon = plistIcons["CFBundlePrimaryIcon"] as? [String: Any], - let files = primaryIcon["CFBundleIconFiles"] as? [String], - let fileName = files.first { - iconList.append(Icon(name: nil, imageName: fileName, ordinal: 0)) - } - - if let alternateIcons = plistIcons["CFBundleAlternateIcons"] as? [String: Any] { - - iconList += alternateIcons.compactMap { key, value in - guard let alternateIcon = value as? [String: Any], - let files = alternateIcon["CFBundleIconFiles"] as? [String], - let fileName = files.first, - let ordinal = alternateIcon["ordinal"] as? Int else { return nil } - - return Icon(name: key, imageName: fileName, ordinal: ordinal) - } - .sorted() + let icons: [Icon] + @Published private(set) var currentIcon: Icon? + private let messageBus: MessageBus +} + +// MARK: - internal +extension IconManager { + @MainActor func set(icon: Icon) async throws { + do { + try await UIApplication.shared.setAlternateIconName(icon.name) + currentIcon = icon + messageBus.post(message: .init(level: .success, message: .appIconUpdatedSuccessfully)) + } catch { + Failure + .appIcon(from: Self.self, reason: error.localizedDescription) + .log() + messageBus.post(message: Message(level: .error, message: .appIconUpdateProblem)) + throw error } - - icons = iconList } } diff --git a/Emitron/Emitron/Settings/SettingsManager.swift b/Emitron/Emitron/Settings/SettingsManager.swift index 4ad6fb9b..53defaea 100644 --- a/Emitron/Emitron/Settings/SettingsManager.swift +++ b/Emitron/Emitron/Settings/SettingsManager.swift @@ -44,7 +44,7 @@ final class SettingsManager: ObservableObject { private let wifiOnlyDownloadsSubject = PassthroughSubject() private let downloadQualitySubject = PassthroughSubject() - // MARK: Initialisers + // MARK: Initializers init(userDefaults: UserDefaults = .standard, userModelController: UserModelController) { self.userDefaults = userDefaults self.userModelController = userModelController @@ -73,7 +73,7 @@ extension SettingsManager { } // We'll store all these settings inside -extension SettingsManager: EmitronSettings { +extension SettingsManager { var filters: Set { get { guard let data: [Data] = userDefaults[.filters] else { @@ -137,7 +137,7 @@ extension SettingsManager: EmitronSettings { var downloadQuality: Attachment.Kind { get { - guard let downloadQuality = userDefaults[.downloadQuality].flatMap( Attachment.Kind.init(rawValue:) ), + guard let downloadQuality = userDefaults[.downloadQuality].flatMap(Attachment.Kind.init(rawValue:)), Attachment.Kind.downloads.contains(downloadQuality) else { return .hdVideoFile } diff --git a/Emitron/Emitron/Styleguide/.DS_Store b/Emitron/Emitron/Styleguide/.DS_Store deleted file mode 100644 index cd9d9947..00000000 Binary files a/Emitron/Emitron/Styleguide/.DS_Store and /dev/null differ diff --git a/Emitron/Emitron/Styleguide/Color+Extensions.swift b/Emitron/Emitron/Styleguide/Color+Extensions.swift index dab852a2..1b679d76 100644 --- a/Emitron/Emitron/Styleguide/Color+Extensions.swift +++ b/Emitron/Emitron/Styleguide/Color+Extensions.swift @@ -30,238 +30,238 @@ import SwiftUI extension Color { static var contentText: Color { - Color("contentText") + .init("contentText") } static var titleText: Color { - Color("titleText") + .init("titleText") } static var background: Color { - Color("backgroundColor") + .init("backgroundColor") } static var cardBackground: Color { - Color("cardBackground") + .init("cardBackground") } static var activeIcon: Color { - Color("activeIcon") + .init("activeIcon") } static var inactiveIcon: Color { - Color("inactiveIcon") + .init("inactiveIcon") } static var accentTagBackground: Color { - Color("accentTagBackground") + .init("accentTagBackground") } static var accentTagForeground: Color { - Color("accentTagForeground") + .init("accentTagForeground") } static var tagBackground: Color { - Color("tagBackground") + .init("tagBackground") } static var tagForeground: Color { - Color("tagForeground") + .init("tagForeground") } static var proTagBackground: Color { - Color("proTagBackground") + .init("proTagBackground") } static var proTagForeground: Color { - Color("proTagForeground") + .init("proTagForeground") } static var proTagBorder: Color { - Color("proTagBorder") + .init("proTagBorder") } static var filterTagBackground: Color { - Color("filterTagBackground") + .init("filterTagBackground") } static var filterTagBorder: Color { - Color("filterTagBorder") + .init("filterTagBorder") } static var filterTagIcon: Color { - Color("filterTagIcon") + .init("filterTagIcon") } static var filterTagText: Color { - Color("filterTagText") + .init("filterTagText") } static var filterTagDestructiveBackground: Color { - Color("filterTagDestructiveBackground") + .init("filterTagDestructiveBackground") } static var filterTagDestructiveBorder: Color { - Color("filterTagDestructiveBorder") + .init("filterTagDestructiveBorder") } static var filterTagDestructiveIcon: Color { - Color("filterTagDestructiveIcon") + .init("filterTagDestructiveIcon") } static var filterTagDestructiveText: Color { - Color("filterTagDestructiveText") + .init("filterTagDestructiveText") } static var filterHeaderBackground: Color { - Color("filterHeaderBackground") + .init("filterHeaderBackground") } static var primaryButtonBackground: Color { - Color("primaryButtonBackground") + .init("primaryButtonBackground") } static var secondaryButtonBackground: Color { - Color("secondaryButtonBackground") + .init("secondaryButtonBackground") } static var destructiveButtonBackground: Color { - Color("destructiveButtonBackground") + .init("destructiveButtonBackground") } static var buttonText: Color { - Color("buttonText") + .init("buttonText") } static var accent: Color { - Color("accent") + .init("accent") } static var alarm: Color { - Color("alarm") + .init("alarm") } static var warning: Color { - Color("warning") + .init("warning") } static var borderColor: Color { - Color("borderColor") + .init("borderColor") } static var separator: Color { - Color("separator") + .init("separator") } static var textButtonText: Color { - Color("textButtonText") + .init("textButtonText") } static var iconButton: Color { - Color("iconButton") + .init("iconButton") } static var modalBackground: Color { - Color("modalBackgroundColor") + .init("modalBackgroundColor") } static var listHeaderBackground: Color { - Color("listHeaderBackground") + .init("listHeaderBackground") } static var appIconBorder: Color { - Color("appIconBorder") + .init("appIconBorder") } static var toggleTextSelected: Color { - Color("toggleTextSelected") + .init("toggleTextSelected") } static var toggleTextDeselected: Color { - Color("toggleTextDeselected") + .init("toggleTextDeselected") } static var toggleLineSelected: Color { - Color("toggleLineSelected") + .init("toggleLineSelected") } static var toggleLineDeselected: Color { - Color("toggleLineDeselected") + .init("toggleLineDeselected") } static var checkmarkBackground: Color { - Color("checkmarkBackground") + .init("checkmarkBackground") } static var checkmarkBorder: Color { - Color("checkmarkBorder") + .init("checkmarkBorder") } static var checkmarkColor: Color { - Color("checkmarkColor") + .init("checkmarkColor") } static var appBlack: Color { - Color(red: 51.0 / 255.0, green: 51.0 / 255.0, blue: 51.0 / 255.0) + .init(red: 51.0 / 255.0, green: 51.0 / 255.0, blue: 51.0 / 255.0) } static var snackError: Color { - Color("error") + .init("error") } static var snackWarning: Color { - Color("warning") + .init("warning") } static var snackSuccess: Color { - Color("success") + .init("success") } static var snackText: Color { - Color("snackText") + .init("snackText") } static var snackTabBg: Color { - Color("snackTagBg") + .init("snackTagBg") } static var searchFieldBackground: Color { - Color("searchFieldBackground") + .init("searchFieldBackground") } static var searchFieldBorder: Color { - Color("searchFieldBorder") + .init("searchFieldBorder") } static var searchFieldIcon: Color { - Color("searchFieldIcon") + .init("searchFieldIcon") } static var searchFieldText: Color { - Color("searchFieldText") + .init("searchFieldText") } static var searchFieldShadow: Color { - Color("searchFieldShadow") + .init("searchFieldShadow") } static var downloadButtonDownloaded: Color { - Color("downloadButtonDownloaded") + .init("downloadButtonDownloaded") } static var downloadButtonDownloadingBackground: Color { - Color("downloadButtonDownloadingBackground") + .init("downloadButtonDownloadingBackground") } static var downloadButtonDownloadingForeground: Color { - Color("downloadButtonDownloadingForeground") + .init("downloadButtonDownloadingForeground") } static var downloadButtonNotDownloaded: Color { - Color("downloadButtonNotDownloaded") + .init("downloadButtonNotDownloaded") } static var downloadButtonWarning: Color { - Color("downloadButtonWarning") + .init("downloadButtonWarning") } } diff --git a/Emitron/Emitron/Styleguide/Image+Extensions.swift b/Emitron/Emitron/Styleguide/Image+Extensions.swift index 38e39a33..bafcc9b3 100644 --- a/Emitron/Emitron/Styleguide/Image+Extensions.swift +++ b/Emitron/Emitron/Styleguide/Image+Extensions.swift @@ -26,38 +26,15 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import SwiftUI +import struct SwiftUI.Image extension Image { - static var closeWhite: Image { - Image("closeWhite") - } - - static var close: Image { - Image("close") - } - - static var padlock: Image { - Image("padlock") - } - - static var bookmark: Image { - Image("bookmark") - } - - static var download: Image { - Image("download") - } - - static var materialIconPlay: Image { - Image("materialIconPlay") - } - - static var checkmark: Image { - Image("checkmark") - } - - static var artworkDownloadSwitch: Image { - Image("artworkDownloadSwitch") - } + static var closeWhite: Self { .init("closeWhite") } + static var close: Self { .init("close") } + static var padlock: Self { .init("padlock") } + static var bookmark: Self { .init("bookmark") } + static var download: Self { .init("download") } + static var materialIconPlay: Self { .init("materialIconPlay") } + static var checkmark: Self { .init("checkmark") } + static var artworkDownloadSwitch: Self { .init("artworkDownloadSwitch") } } diff --git a/Emitron/Emitron/Styleguide/UIFont+Extensions.swift b/Emitron/Emitron/Styleguide/UIFont+Extensions.swift index 85d4b6b2..f64e4c11 100644 --- a/Emitron/Emitron/Styleguide/UIFont+Extensions.swift +++ b/Emitron/Emitron/Styleguide/UIFont+Extensions.swift @@ -30,9 +30,9 @@ import UIKit extension UIFont { static var uiLargeTitle: UIFont { - UIFont(name: "Bitter-Bold", size: 34.0)! + .init(name: "Bitter-Bold", size: 34.0)! } static var uiHeadline: UIFont { - UIFont(name: "Bitter-Regular", size: 17.0)! + .init(name: "Bitter-Regular", size: 17.0)! } } diff --git a/Emitron/Emitron/UI/App Root/LoginView.swift b/Emitron/Emitron/UI/App Root/LoginView.swift index cfcc2263..f145ccea 100644 --- a/Emitron/Emitron/UI/App Root/LoginView.swift +++ b/Emitron/Emitron/UI/App Root/LoginView.swift @@ -91,7 +91,7 @@ struct LoginView: View { Spacer() MainButtonView(title: "Sign In", type: .primary(withArrow: true)) { - sessionController.login() + Task(priority: .userInitiated) { try await sessionController.logIn() } } .padding(.horizontal, 18) .padding([.bottom], 38) diff --git a/Emitron/Emitron/UI/App Root/LogoutView.swift b/Emitron/Emitron/UI/App Root/LogoutView.swift index 5fb0c24d..b1bc809f 100644 --- a/Emitron/Emitron/UI/App Root/LogoutView.swift +++ b/Emitron/Emitron/UI/App Root/LogoutView.swift @@ -44,7 +44,7 @@ struct LogoutView: View { .padding([.bottom], 15) .multilineTextAlignment(.center) - Text("The raywenderlich app is only available to members.") + Text("The Kodeco app is only available to members.") .lineSpacing(8) .font(.uiLabel) .foregroundColor(.contentText) @@ -56,7 +56,7 @@ struct LogoutView: View { MainButtonView( title: "Sign Out", type: .destructive(withArrow: true), - callback: sessionController.logout + callback: sessionController.logOut ) .padding(.horizontal, 18) .padding(.bottom, 38) diff --git a/Emitron/Emitron/UI/App Root/MainView.swift b/Emitron/Emitron/UI/App Root/MainView.swift index 43315741..7277a3a1 100644 --- a/Emitron/Emitron/UI/App Root/MainView.swift +++ b/Emitron/Emitron/UI/App Root/MainView.swift @@ -44,7 +44,8 @@ struct MainView: View { .background(Color.background) .overlay(MessageBarView(messageBus: messageBus), alignment: .bottom) .onReceive(notification) { _ in - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + Task { + try await Task.sleep(nanoseconds: 2_000_000_000) makeReviewRequest() } } @@ -66,7 +67,7 @@ private extension MainView { case .error: ErrorView( buttonTitle: "Back to login screen", - buttonAction: sessionController.logout + buttonAction: sessionController.logOut ) } } @@ -74,7 +75,7 @@ private extension MainView { @ViewBuilder var tabBarView: some View { switch sessionController.sessionState { - case .online : + case .online: TabView( libraryView: { LibraryView( @@ -120,7 +121,7 @@ private extension MainView { } func makeReviewRequest() { - if let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene { + if let scene = (UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene) { SKStoreReviewController.requestReview(in: scene) } } diff --git a/Emitron/Emitron/UI/App Root/MessageBarView.swift b/Emitron/Emitron/UI/App Root/MessageBarView.swift index ae48b6b8..e88db405 100644 --- a/Emitron/Emitron/UI/App Root/MessageBarView.swift +++ b/Emitron/Emitron/UI/App Root/MessageBarView.swift @@ -45,10 +45,10 @@ struct MessageBarView: View { state: messageBus.currentMessage!.snackbarState, visible: $messageBus.messageVisible ) + .transition(.moveAndFade) } } - .transition(.moveAndFade) - .animation(.default) + .animation(.default, value: messageBus.messageVisible) } } diff --git a/Emitron/Emitron/UI/App Root/PermissionsLoadingView.swift b/Emitron/Emitron/UI/App Root/PermissionsLoadingView.swift index c9290aad..3b83d357 100644 --- a/Emitron/Emitron/UI/App Root/PermissionsLoadingView.swift +++ b/Emitron/Emitron/UI/App Root/PermissionsLoadingView.swift @@ -38,11 +38,11 @@ struct PermissionsLoadingView: View { showLogoutAlert.toggle() } .alert(isPresented: $showLogoutAlert) { - Alert( + .init( title: Text("Force Logout?"), primaryButton: .destructive( Text("Logout"), - action: sessionController.logout + action: sessionController.logOut ), secondaryButton: .cancel() ) diff --git a/Emitron/Emitron/UI/Downloads/DownloadsView.swift b/Emitron/Emitron/UI/Downloads/DownloadsView.swift index c9ca2c3f..7063a2cd 100644 --- a/Emitron/Emitron/UI/Downloads/DownloadsView.swift +++ b/Emitron/Emitron/UI/Downloads/DownloadsView.swift @@ -45,7 +45,7 @@ struct DownloadsView: View { var body: some View { ContentListView( contentRepository: downloadRepository, - downloadAction: downloadService, + downloadService: downloadService, contentScreen: contentScreen ) .navigationTitle(String.downloads) diff --git a/Emitron/Emitron/UI/Empty States/NoResultsView.swift b/Emitron/Emitron/UI/Empty States/NoResultsView.swift index 8e41f1e3..c9cf46d0 100644 --- a/Emitron/Emitron/UI/Empty States/NoResultsView.swift +++ b/Emitron/Emitron/UI/Empty States/NoResultsView.swift @@ -65,7 +65,7 @@ extension NoResultsView: View { .padding([.bottom], 20) .padding(.horizontal, 20) - Text(contentScreen.detailMesage) + Text(contentScreen.detailMessage) .lineSpacing(5) .font(.uiLabel) .foregroundColor(.contentText) diff --git a/Emitron/Emitron/UI/Generic/PagerView.swift b/Emitron/Emitron/UI/Generic/PagerView.swift index c77bc60f..d011d0c0 100644 --- a/Emitron/Emitron/UI/Generic/PagerView.swift +++ b/Emitron/Emitron/UI/Generic/PagerView.swift @@ -52,7 +52,7 @@ struct PagerView: View { .frame(width: proxy.size.width, alignment: .leading) .offset(x: -CGFloat(currentIndex) * proxy.size.width) .offset(x: translation) - .animation(.interactiveSpring()) + .animation(.interactiveSpring(), value: currentIndex) .gesture( DragGesture() .updating($translation) { value, state, _ in diff --git a/Emitron/Emitron/UI/Library/LibraryView.swift b/Emitron/Emitron/UI/Library/LibraryView.swift index ddecbe84..952ea46a 100644 --- a/Emitron/Emitron/UI/Library/LibraryView.swift +++ b/Emitron/Emitron/UI/Library/LibraryView.swift @@ -165,7 +165,7 @@ private extension LibraryView { var contentView: some View { ContentListView( contentRepository: libraryRepository, - downloadAction: downloadService, + downloadService: downloadService, contentScreen: .library, header: contentControlsSection ) diff --git a/Emitron/Emitron/UI/My Tutorials/MyTutorialsView.swift b/Emitron/Emitron/UI/My Tutorials/MyTutorialsView.swift index 01af3511..8a6b3813 100644 --- a/Emitron/Emitron/UI/My Tutorials/MyTutorialsView.swift +++ b/Emitron/Emitron/UI/My Tutorials/MyTutorialsView.swift @@ -164,7 +164,7 @@ private extension MyTutorialsView { return ContentListView( contentRepository: contentRepository, - downloadAction: downloadService, + downloadService: downloadService, contentScreen: contentScreen, header: toggleControl ) diff --git a/Emitron/Emitron/UI/PortraitHostingController.swift b/Emitron/Emitron/UI/PortraitHostingController.swift index bd77299d..f969a75d 100644 --- a/Emitron/Emitron/UI/PortraitHostingController.swift +++ b/Emitron/Emitron/UI/PortraitHostingController.swift @@ -28,7 +28,7 @@ import SwiftUI -class PortraitHostingController: UIHostingController where Content: View { +final class PortraitHostingController: UIHostingController { override var supportedInterfaceOrientations: UIInterfaceOrientationMask { // iPads can have any orientation. Everything else should be portrait only. UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait diff --git a/Emitron/Emitron/UI/SceneDelegate.swift b/Emitron/Emitron/UI/SceneDelegate.swift deleted file mode 100644 index fc8e9503..00000000 --- a/Emitron/Emitron/UI/SceneDelegate.swift +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) 2022 Razeware LLC -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// Notwithstanding the foregoing, you may not use, copy, modify, merge, publish, -// distribute, sublicense, create a derivative work, and/or sell copies of the -// Software in any work that is designed, intended, or marketed for pedagogical or -// instructional purposes related to programming, coding, application development, -// or information technology. Permission for such use, copying, modification, -// merger, publication, distribution, sublicensing, creation of derivative works, -// or sale is expressly withheld. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -import SwiftUI -import UIKit - -class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? - - func scene( - _ scene: UIScene, - willConnectTo session: UISceneSession, - options connectionOptions: UIScene.ConnectionOptions - ) { - // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. - // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. - // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). - - // TODO: When a modifier is available these should be refactored - UITableView.appearance().separatorColor = .clear - UITableViewCell.appearance().backgroundColor = .backgroundColor - UITableViewCell.appearance().selectionStyle = .none - - UITableView.appearance().backgroundColor = .backgroundColor - UINavigationBar.appearance().backgroundColor = .backgroundColor - - UINavigationBar.appearance().largeTitleTextAttributes = [ - .foregroundColor: UIColor(named: "titleText")!, - .font: UIFont.uiLargeTitle - ] - - UINavigationBar.appearance().titleTextAttributes = [ - .foregroundColor: UIColor(named: "titleText")!, - .font: UIFont.uiHeadline - ] - - UISwitch.appearance().onTintColor = .accent - } - - func sceneDidDisconnect(_ scene: UIScene) { - // Called as the scene is being released by the system. - // This occurs shortly after the scene enters the background, or when its session is discarded. - // Release any resources associated with this scene that can be re-created the next time the scene connects. - // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). - } - - func sceneDidBecomeActive(_ scene: UIScene) { - // Called when the scene has moved from an inactive state to an active state. - // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. - } - - func sceneWillResignActive(_ scene: UIScene) { - // Called when the scene will move from an active state to an inactive state. - // This may occur due to temporary interruptions (ex. an incoming phone call). - } - - func sceneWillEnterForeground(_ scene: UIScene) { - // Request Permissions if necessary - // SessionController.current.fetchPermissionsIfNeeded() - } - - func sceneDidEnterBackground(_ scene: UIScene) { - // Called as the scene transitions from the foreground to the background. - // Use this method to save data, release shared resources, and store enough scene-specific state information - // to restore the scene back to its current state. - } -} diff --git a/Emitron/Emitron/UI/Settings/Icons/IconChooserView.swift b/Emitron/Emitron/UI/Settings/Icons/IconChooserView.swift index a5cb9f04..43113963 100644 --- a/Emitron/Emitron/UI/Settings/Icons/IconChooserView.swift +++ b/Emitron/Emitron/UI/Settings/Icons/IconChooserView.swift @@ -35,7 +35,7 @@ struct IconChooserView: View { HStack { ForEach(iconManager.icons) { icon in Button { - iconManager.set(icon: icon) + Task { try await iconManager.set(icon: icon) } } label: { IconView(icon: icon, selected: iconManager.currentIcon == icon) } diff --git a/Emitron/Emitron/UI/Settings/SettingsView.swift b/Emitron/Emitron/UI/Settings/SettingsView.swift index 91836d0d..51699909 100644 --- a/Emitron/Emitron/UI/Settings/SettingsView.swift +++ b/Emitron/Emitron/UI/Settings/SettingsView.swift @@ -47,7 +47,7 @@ struct SettingsView: View { var body: some View { VStack(spacing: 0) { - Link(destination: URL(string: "https://accounts.raywenderlich.com")!) { + Link(destination: URL(string: "https://accounts.kodeco.com")!) { SettingsDisclosureRow(title: "My Account", value: "") } .padding(.horizontal, 20) @@ -93,35 +93,18 @@ struct SettingsView: View { MainButtonView(title: "Sign Out", type: .destructive(withArrow: true)) { showingSignOutConfirmation = true } - .modifier { - let dialogTitle = "Are you sure you want to sign out?" - let buttonTitle = "Sign Out" - let action = { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - sessionController.logout() + .confirmationDialog( + "Are you sure you want to sign out?", + isPresented: $showingSignOutConfirmation, + titleVisibility: .visible + ) { + Button("Sign Out", role: .destructive) { + Task { @MainActor in + try await Task.sleep(nanoseconds: 100_000_000) + sessionController.logOut() tabViewModel.selectedTab = .library } } - - if #available(iOS 15, *) { - $0.confirmationDialog( - dialogTitle, - isPresented: $showingSignOutConfirmation, - titleVisibility: .visible - ) { - Button(buttonTitle, role: .destructive, action: action) - } - } else { - $0.actionSheet(isPresented: $showingSignOutConfirmation) { - .init( - title: .init(dialogTitle), - buttons: [ - .destructive(.init(buttonTitle), action: action), - .cancel() - ] - ) - } - } } } .padding([.bottom, .horizontal], 18) diff --git a/Emitron/Emitron/UI/Shared/Content Detail/TextListItemView.swift b/Emitron/Emitron/UI/Shared/Content Detail/TextListItemView.swift index edfe1606..dcddabd2 100644 --- a/Emitron/Emitron/UI/Shared/Content Detail/TextListItemView.swift +++ b/Emitron/Emitron/UI/Shared/Content Detail/TextListItemView.swift @@ -131,7 +131,7 @@ struct TextListItemView: View { } private var wifiOnlyOnCellular: Bool { - guard let reachability = SCNetworkReachabilityCreateWithName(kCFAllocatorDefault, "www.raywenderlich.com") else { + guard let reachability = SCNetworkReachabilityCreateWithName(kCFAllocatorDefault, "www.kodeco.com") else { return false } var flags = SCNetworkReachabilityFlags() diff --git a/Emitron/Emitron/UI/Shared/Content List/CardView.swift b/Emitron/Emitron/UI/Shared/Content List/CardView.swift index 251d72f6..95056089 100644 --- a/Emitron/Emitron/UI/Shared/Content List/CardView.swift +++ b/Emitron/Emitron/UI/Shared/Content List/CardView.swift @@ -150,11 +150,11 @@ struct MockContentListDisplayable: ContentListDisplayable { var duration: Int = 10080 var parentName: String? var contentType: ContentType = .collection - var cardArtworkURL: URL? = URL(string: "https://files.betamax.raywenderlich.com/attachments/collections/216/9eb9899d-47d0-429d-96f0-e15ac9542ecc.png") + var cardArtworkURL: URL? = URL(string: "https://files.betamax.kodeco.com/attachments/collections/216/9eb9899d-47d0-429d-96f0-e15ac9542ecc.png") var ordinal: Int? var technologyTripleString: String = "Doesn't matter" var contentSummaryMetadataString: String = "Doesn't matter" - var contributorString: String = "Deosn't matter" + var contributorString: String = "Doesn't matter" var videoIdentifier: Int? } diff --git a/Emitron/Emitron/UI/Shared/Content List/ContentListView.swift b/Emitron/Emitron/UI/Shared/Content List/ContentListView.swift index b6e1bda2..2757ca7e 100644 --- a/Emitron/Emitron/UI/Shared/Content List/ContentListView.swift +++ b/Emitron/Emitron/UI/Shared/Content List/ContentListView.swift @@ -32,22 +32,21 @@ import Combine struct ContentListView { init( contentRepository: ContentRepository, - downloadAction: DownloadAction, + downloadService: DownloadService, contentScreen: ContentScreen, header: Header ) { self.contentRepository = contentRepository - self.downloadAction = downloadAction + self.downloadService = downloadService self.contentScreen = contentScreen self.header = header } @ObservedObject private var contentRepository: ContentRepository - private let downloadAction: DownloadAction + private let downloadService: DownloadService private let contentScreen: ContentScreen private let header: Header - @State private var deleteSubscriptions: Set = [] @EnvironmentObject private var messageBus: MessageBus @EnvironmentObject private var tabViewModel: TabViewModel @Environment(\.mainTab) private var mainTab @@ -197,29 +196,20 @@ private extension ContentListView { } func delete(at offsets: IndexSet) { - guard let index = offsets.first else { + guard let content = (offsets.first.map { contentRepository.contents[$0] }) else { return } - DispatchQueue.main.async { - let content = contentRepository.contents[index] - - downloadAction - .deleteDownload(contentID: content.id) - .receive(on: RunLoop.main) - .sink( - receiveCompletion: { completion in - if case .failure(let error) = completion { - Failure - .downloadAction(from: String(describing: type(of: self)), reason: "Unable to perform download action: \(error)") - .log() - self.messageBus.post(message: Message(level: .error, message: error.localizedDescription)) - } - }, - receiveValue: { _ in - self.messageBus.post(message: Message(level: .success, message: .downloadDeleted)) - } - ) - .store(in: &deleteSubscriptions) + + Task { @MainActor in + do { + try await downloadService.deleteDownload(contentID: content.id) + messageBus.post(message: Message(level: .success, message: .downloadDeleted)) + } catch { + Failure + .downloadAction(from: Self.self, reason: "Unable to perform download action: \(error)") + .log() + messageBus.post(message: Message(level: .error, message: error.localizedDescription)) + } } } } @@ -227,12 +217,12 @@ private extension ContentListView { extension ContentListView where Header == Never? { init( contentRepository: ContentRepository, - downloadAction: DownloadAction, + downloadService: DownloadService, contentScreen: ContentScreen ) { self.init( contentRepository: contentRepository, - downloadAction: downloadAction, + downloadService: downloadService, contentScreen: contentScreen, header: nil ) diff --git a/Emitron/Emitron/Utilities/MessageBus.swift b/Emitron/Emitron/Utilities/MessageBus.swift index db49e2f0..817194a1 100644 --- a/Emitron/Emitron/Utilities/MessageBus.swift +++ b/Emitron/Emitron/Utilities/MessageBus.swift @@ -41,7 +41,7 @@ struct Message { extension Message { var snackbarState: SnackbarState { - SnackbarState(status: level.snackbarStatus, message: message) + .init(status: level.snackbarStatus, message: message) } } diff --git a/Emitron/Gemfile b/Emitron/Gemfile index 8c88f285..7438615f 100644 --- a/Emitron/Gemfile +++ b/Emitron/Gemfile @@ -1,3 +1,3 @@ source 'https://rubygems.org' -gem 'fastlane', '~> 2.139' +gem 'fastlane', '~> 2.205' diff --git a/Emitron/Gemfile.lock b/Emitron/Gemfile.lock index 59136921..3a6d3092 100644 --- a/Emitron/Gemfile.lock +++ b/Emitron/Gemfile.lock @@ -1,65 +1,80 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.3) - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) + CFPropertyList (3.0.5) + rexml + addressable (2.8.1) + public_suffix (>= 2.0.2, < 6.0) artifactory (3.0.15) atomos (0.1.3) - aws-eventstream (1.1.1) - aws-partitions (1.447.0) - aws-sdk-core (3.114.0) + aws-eventstream (1.2.0) + aws-partitions (1.650.0) + aws-sdk-core (3.164.0) aws-eventstream (~> 1, >= 1.0.2) - aws-partitions (~> 1, >= 1.239.0) + aws-partitions (~> 1, >= 1.525.0) aws-sigv4 (~> 1.1) - jmespath (~> 1.0) - aws-sdk-kms (1.43.0) - aws-sdk-core (~> 3, >= 3.112.0) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.58.0) + aws-sdk-core (~> 3, >= 3.127.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.93.1) - aws-sdk-core (~> 3, >= 3.112.0) + aws-sdk-s3 (1.116.0) + aws-sdk-core (~> 3, >= 3.127.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.1) - aws-sigv4 (1.2.3) + aws-sigv4 (~> 1.4) + aws-sigv4 (1.5.2) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) - claide (1.0.3) + claide (1.1.0) colored (1.2) colored2 (3.1.2) - commander-fastlane (4.4.6) - highline (~> 1.7.2) + commander (4.6.0) + highline (~> 2.0.0) declarative (0.0.20) - digest-crc (0.6.3) + digest-crc (0.6.4) rake (>= 12.0.0, < 14.0.0) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) - dotenv (2.7.6) - emoji_regex (3.2.2) - excon (0.80.1) - faraday (1.4.1) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.93.1) + faraday (1.10.2) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.1) - multipart-post (>= 1.2, < 3) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) faraday-cookie_jar (0.0.7) faraday (>= 0.8.0) http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.4) + multipart-post (~> 2) faraday-net_http (1.0.1) - faraday-net_http_persistent (1.1.0) - faraday_middleware (1.0.0) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.0) faraday (~> 1.0) - fastimage (2.2.3) - fastlane (2.181.0) + fastimage (2.2.6) + fastlane (2.210.1) CFPropertyList (>= 2.3, < 4.0.0) - addressable (>= 2.3, < 3.0.0) + addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) aws-sdk-s3 (~> 1.0) babosa (>= 1.0.3, < 2.0.0) bundler (>= 1.12.0, < 3.0.0) colored - commander-fastlane (>= 4.4.6, < 5.0.0) + commander (~> 4.6) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) excon (>= 0.71.0, < 1.0.0) @@ -68,19 +83,20 @@ GEM faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) gh_inspector (>= 1.1.2, < 2.0.0) - google-api-client (>= 0.37.0, < 0.39.0) - google-cloud-storage (>= 1.15.0, < 2.0.0) - highline (>= 1.7.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-storage (~> 1.31) + highline (~> 2.0) json (< 3.0.0) jwt (>= 2.1.0, < 3) mini_magick (>= 4.9.4, < 5.0.0) multipart-post (~> 2.0.0) naturally (~> 2.2) + optparse (~> 0.1.1) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) security (= 0.1.3) simctl (~> 1.6.3) - slack-notifier (>= 2.0.0, < 3.0.0) terminal-notifier (>= 2.0.0, < 3.0.0) terminal-table (>= 1.4.5, < 2.0.0) tty-screen (>= 0.6.3, < 1.0.0) @@ -90,90 +106,85 @@ GEM xcpretty (~> 0.3.0) xcpretty-travis-formatter (>= 0.0.3) gh_inspector (1.1.3) - google-api-client (0.38.0) - addressable (~> 2.5, >= 2.5.1) - googleauth (~> 0.9) - httpclient (>= 2.8.1, < 3.0) - mini_mime (~> 1.0) - representable (~> 3.0) - retriable (>= 2.0, < 4.0) - signet (~> 0.12) - google-apis-core (0.3.0) + google-apis-androidpublisher_v3 (0.29.0) + google-apis-core (>= 0.9.0, < 2.a) + google-apis-core (0.9.1) addressable (~> 2.5, >= 2.5.1) - googleauth (~> 0.14) - httpclient (>= 2.8.1, < 3.0) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) mini_mime (~> 1.0) representable (~> 3.0) - retriable (>= 2.0, < 4.0) + retriable (>= 2.0, < 4.a) rexml - signet (~> 0.14) webrick - google-apis-iamcredentials_v1 (0.3.0) - google-apis-core (~> 0.1) - google-apis-storage_v1 (0.3.0) - google-apis-core (~> 0.1) + google-apis-iamcredentials_v1 (0.15.0) + google-apis-core (>= 0.9.0, < 2.a) + google-apis-playcustomapp_v1 (0.12.0) + google-apis-core (>= 0.9.1, < 2.a) + google-apis-storage_v1 (0.19.0) + google-apis-core (>= 0.9.0, < 2.a) google-cloud-core (1.6.0) google-cloud-env (~> 1.0) google-cloud-errors (~> 1.0) - google-cloud-env (1.5.0) - faraday (>= 0.17.3, < 2.0) - google-cloud-errors (1.1.0) - google-cloud-storage (1.31.0) - addressable (~> 2.5) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.3.0) + google-cloud-storage (1.43.0) + addressable (~> 2.8) digest-crc (~> 0.4) google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.1) - google-cloud-core (~> 1.2) - googleauth (~> 0.9) + google-apis-storage_v1 (~> 0.19.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) - googleauth (0.16.1) - faraday (>= 0.17.3, < 2.0) + googleauth (1.3.0) + faraday (>= 0.17.3, < 3.a) jwt (>= 1.4, < 3.0) memoist (~> 0.16) multi_json (~> 1.11) os (>= 0.9, < 2.0) - signet (~> 0.14) - highline (1.7.10) - http-cookie (1.0.3) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.5) domain_name (~> 0.5) httpclient (2.8.3) - jmespath (1.4.0) - json (2.5.1) - jwt (2.2.3) + jmespath (1.6.1) + json (2.6.2) + jwt (2.5.0) memoist (0.16.2) mini_magick (4.11.0) - mini_mime (1.1.0) + mini_mime (1.1.2) multi_json (1.15.0) multipart-post (2.0.0) nanaimo (0.3.0) naturally (2.2.1) - os (1.1.1) + optparse (0.1.1) + os (1.1.4) plist (3.6.0) - public_suffix (4.0.6) - rake (13.0.3) - representable (3.1.1) + public_suffix (5.0.0) + rake (13.0.6) + representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) rexml (3.2.5) rouge (2.0.7) - ruby2_keywords (0.0.4) - rubyzip (2.3.0) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) security (0.1.3) - signet (0.15.0) - addressable (~> 2.3) - faraday (>= 0.17.3, < 2.0) + signet (0.17.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 3.0) multi_json (~> 1.10) simctl (1.6.8) CFPropertyList naturally - slack-notifier (2.3.2) terminal-notifier (2.0.0) terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) - trailblazer-option (0.1.1) + trailblazer-option (0.1.2) tty-cursor (0.7.1) tty-screen (0.8.1) tty-spinner (0.9.3) @@ -181,26 +192,27 @@ GEM uber (0.1.0) unf (0.1.4) unf_ext - unf_ext (0.0.7.7) - unicode-display_width (1.7.0) + unf_ext (0.0.8.2) + unicode-display_width (1.8.0) webrick (1.7.0) word_wrap (1.0.0) - xcodeproj (1.19.0) + xcodeproj (1.22.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) nanaimo (~> 0.3.0) + rexml (~> 3.2.4) xcpretty (0.3.0) rouge (~> 2.0.7) xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) PLATFORMS - ruby + universal-darwin-21 DEPENDENCIES - fastlane (~> 2.139) + fastlane (~> 2.205) BUNDLED WITH - 2.1.4 + 2.3.12 diff --git a/Emitron/emitronScreenshots/EmitronScreenshots.swift b/Emitron/emitronScreenshots/EmitronScreenshots.swift index b95f2040..4cdb0631 100644 --- a/Emitron/emitronScreenshots/EmitronScreenshots.swift +++ b/Emitron/emitronScreenshots/EmitronScreenshots.swift @@ -28,7 +28,7 @@ import XCTest -class EmitronScreenshots: XCTestCase { +final class EmitronScreenshots: XCTestCase { func testTakeSnapshots() { let app = XCUIApplication() setupSnapshot(app) diff --git a/Emitron/emitronTests/Combine/PublishedPrePostFactoTest.swift b/Emitron/emitronTests/Combine/PublishedPrePostFactoTest.swift index 5bfea05b..8b1194b2 100644 --- a/Emitron/emitronTests/Combine/PublishedPrePostFactoTest.swift +++ b/Emitron/emitronTests/Combine/PublishedPrePostFactoTest.swift @@ -30,10 +30,9 @@ import XCTest import Combine @testable import Emitron -class PublishedPostFactoTest: XCTestCase { - - class PrePostObservedObject: ObservablePrePostFactoObject { - // This doesn't get syntesized +final class PublishedPostFactoTest: XCTestCase { + final class PrePostObservedObject: ObservablePrePostFactoObject { + // This doesn't get synthesized let objectDidChange = ObservableObjectPublisher() @Published var notifiedBeforeChangeCommitted: Int = 0 diff --git a/Emitron/emitronTests/Data/DataCacheTest.swift b/Emitron/emitronTests/Data/DataCacheTest.swift index 771cdc80..17abf52a 100644 --- a/Emitron/emitronTests/Data/DataCacheTest.swift +++ b/Emitron/emitronTests/Data/DataCacheTest.swift @@ -72,7 +72,7 @@ class DataCacheTest: XCTestCase { let completion = try wait(for: recorder.completion, timeout: 10) if case .finished = completion { - XCTFail("Should not have finished") + XCTFail("Should not have finished") } if case let .failure(error) = completion { if error as? DataCacheError != .some(.cacheMiss) { diff --git a/Emitron/emitronTests/Data/States/ContentPersistableState+Mocks.swift b/Emitron/emitronTests/Data/States/ContentPersistableState+Mocks.swift index f418b3dc..2da5c5c3 100644 --- a/Emitron/emitronTests/Data/States/ContentPersistableState+Mocks.swift +++ b/Emitron/emitronTests/Data/States/ContentPersistableState+Mocks.swift @@ -29,35 +29,32 @@ @testable import Emitron extension ContentPersistableState { - static func persistableState(for content: Content, with cacheUpdate: DataCacheUpdate) -> ContentPersistableState { - persistableState(for: content.id, with: cacheUpdate) - } - - static func persistableState(for contentID: Int, with cacheUpdate: DataCacheUpdate) -> ContentPersistableState { - - guard let content = cacheUpdate.contents.first(where: { $0.id == contentID }) else { preconditionFailure("Invalid cache update") } - - var parentContent: Content? - if let groupID = content.groupID { - // There must be parent content - if let parentGroup = cacheUpdate.groups.first(where: { $0.id == groupID }) { - parentContent = cacheUpdate.contents.first { $0.id == parentGroup.contentID } - } - } - + init(content: Content, cacheUpdate: DataCacheUpdate) { let groups = cacheUpdate.groups.filter { $0.contentID == content.id } - let groupIDs = groups.map(\.id) - let childContent = cacheUpdate.contents.filter { groupIDs.contains($0.groupID ?? -1) } - - return ContentPersistableState( + self.init( content: content, contentDomains: cacheUpdate.contentDomains.filter({ $0.contentID == content.id }), contentCategories: cacheUpdate.contentCategories.filter({ $0.contentID == content.id }), bookmark: cacheUpdate.bookmarks.first(where: { $0.contentID == content.id }), - parentContent: parentContent, - progression: cacheUpdate.progressions.first(where: { $0.contentID == content.id }), + parentContent: content.groupID.flatMap { groupID in + // There must be parent content + cacheUpdate.groups.first { $0.id == groupID } + .flatMap { parentGroup in + cacheUpdate.contents.first { $0.id == parentGroup.contentID } + } + }, + progression: (cacheUpdate.progressions.first { $0.contentID == content.id }), groups: groups, - childContents: childContent + childContents: cacheUpdate.contents.filter { + groups.map(\.id).contains($0.groupID ?? -1) + } ) } + + init(contentID: Int, cacheUpdate: DataCacheUpdate) { + guard let content = (cacheUpdate.contents.first { $0.id == contentID }) + else { preconditionFailure("Invalid cache update") } + + self.init(content: content, cacheUpdate: cacheUpdate) + } } diff --git a/Emitron/emitronTests/Downloads/DownloadQueueManagerTest.swift b/Emitron/emitronTests/Downloads/DownloadQueueManagerTest.swift index 5d0ee766..f62ca202 100644 --- a/Emitron/emitronTests/Downloads/DownloadQueueManagerTest.swift +++ b/Emitron/emitronTests/Downloads/DownloadQueueManagerTest.swift @@ -26,121 +26,51 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. +import CombineExpectations import XCTest -import GRDB -import Combine @testable import Emitron -class DownloadQueueManagerTest: XCTestCase { - private var database: DatabaseWriter! +final class DownloadQueueManagerTest: XCTestCase, DatabaseTestCase { + private(set) var database: TestDatabase! private var persistenceStore: PersistenceStore! private var videoService = VideosServiceMock() private var downloadService: DownloadService! private var queueManager: DownloadQueueManager! - private var subscriptions = Set() private var settingsManager: SettingsManager! - override func setUp() { - super.setUp() + override func setUpWithError() throws { + try super.setUpWithError() // There's one already running—let's stop that if downloadService != nil { downloadService.stopProcessing() } - // swiftlint:disable:next - do { - database = try EmitronDatabase.testDatabase() - } catch { - fatalError("Failed trying to test database") - } + + database = try EmitronDatabase.test persistenceStore = PersistenceStore(db: database) settingsManager = App.objects.settingsManager - let userModelController = UserMCMock(user: .withDownloads) - downloadService = DownloadService(persistenceStore: persistenceStore, userModelController: userModelController, videosServiceProvider: { _ in self.videoService }, settingsManager: settingsManager) + downloadService = .init( + persistenceStore: persistenceStore, + userModelController: UserMCMock(user: .withDownloads), + videosServiceProvider: { _ in self.videoService }, + settingsManager: settingsManager + ) - queueManager = DownloadQueueManager(persistenceStore: persistenceStore) + queueManager = .init(persistenceStore: persistenceStore) downloadService.stopProcessing() } override func tearDown() { super.tearDown() videoService.reset() - subscriptions = [] - } - - func getAllContents() -> [Content] { - // swiftlint:disable:next force_try - try! database.read { db in - try Content.fetchAll(db) - } - } - - func getAllDownloads() -> [Download] { - // swiftlint:disable:next force_try - try! database.read { db in - try Download.fetchAll(db) - } - } - - func persistableState(for content: Content, with cacheUpdate: DataCacheUpdate) -> ContentPersistableState { - - var parentContent: Content? - if let groupID = content.groupID { - // There must be parent content - if let parentGroup = cacheUpdate.groups.first(where: { $0.id == groupID }) { - parentContent = cacheUpdate.contents.first { $0.id == parentGroup.contentID } - } - } - - let groups = cacheUpdate.groups.filter { $0.contentID == content.id } - let groupIDs = groups.map(\.id) - let childContent = cacheUpdate.contents.filter { groupIDs.contains($0.groupID ?? -1) } - - return ContentPersistableState( - content: content, - contentDomains: cacheUpdate.contentDomains.filter({ $0.contentID == content.id }), - contentCategories: cacheUpdate.contentCategories.filter({ $0.contentID == content.id }), - bookmark: cacheUpdate.bookmarks.first(where: { $0.contentID == content.id }), - parentContent: parentContent, - progression: cacheUpdate.progressions.first(where: { $0.contentID == content.id }), - groups: groups, - childContents: childContent - ) - } - - func sampleDownload() throws -> Download { - let screencast = ContentTest.Mocks.screencast - let recorder = downloadService.requestDownload(contentID: screencast.0.id) { _ in - self.persistableState(for: screencast.0, with: screencast.1) - } - .record() - - let completion = try wait(for: recorder.completion, timeout: 10) - - XCTAssert(completion == .finished) - - return getAllDownloads().first! - } - - @discardableResult func samplePersistedDownload(state: Download.State = .pending) throws -> Download { - try database.write { db in - let content = PersistenceMocks.content - try content.save(db) - - var download = PersistenceMocks.download(for: content) - download.state = state - try download.save(db) - - return download - } } - func testPendingStreamSendsNewDownloads() throws { + func testPendingStreamSendsNewDownloads() async throws { let recorder = queueManager.pendingStream.record() - var download = try sampleDownload() - try database.write { db in - try download.save(db) + var download = try await sampleDownload + download = try await database.write { [download] db in + try download.saved(db) } let downloads = try wait(for: recorder.next(2), timeout: 10, description: "PendingDownloads") @@ -148,64 +78,66 @@ class DownloadQueueManagerTest: XCTestCase { XCTAssertEqual([nil, download], downloads.map { $0?.download }) } - func testPendingStreamSendingPreExistingDownloads() throws { - var download = try sampleDownload() - try database.write { db in - try download.save(db) + func testPendingStreamSendingPreExistingDownloads() async throws { + var download = try await sampleDownload + download = try await database.write { [download] db in + try download.saved(db) } - - let recorder = queueManager.pendingStream.record() - let pending = try wait(for: recorder.next(), timeout: 10) - + + let pending = try wait(for: queueManager.pendingStream.record().next(), timeout: 10) XCTAssertEqual(download, pending!.download) } - func testReadyForDownloadStreamSendsUpdatesAsListChanges() throws { - var download1 = try samplePersistedDownload(state: .readyForDownload) - var download2 = try samplePersistedDownload(state: .readyForDownload) - var download3 = try samplePersistedDownload(state: .urlRequested) + func testReadyForDownloadStreamSendsUpdatesAsListChanges() async throws { + let download1 = try await samplePersistedDownload(state: .readyForDownload) + let download2 = try await samplePersistedDownload(state: .readyForDownload) + var download3 = try await samplePersistedDownload(state: .urlRequested) let recorder = queueManager.readyForDownloadStream.record() var readyForDownload = try wait(for: recorder.next(), timeout: 10) XCTAssertEqual(download1, readyForDownload!.download) - try database.write { db in - download3.state = .readyForDownload - try download3.save(db) + download3 = try await database.write { [download3] db in + var download = download3 + download.state = .readyForDownload + return try download.saved(db) } // This shouldn't fire cos it doesn't affect the stream // readyForDownload = try wait(for: recorder.next(), timeout: 10) // XCTAssertEqual(download1, readyForDownload!!.download) - try database.write { db in - download1.state = .enqueued - try download1.save(db) + try await database.write { db in + var download = download1 + download.state = .enqueued + try download.save(db) } readyForDownload = try wait(for: recorder.next(), timeout: 10) XCTAssertEqual(download2, readyForDownload!.download) - try database.write { db in - download2.state = .enqueued - try download2.save(db) + try await database.write { db in + var download = download2 + download.state = .enqueued + try download.save(db) } readyForDownload = try wait(for: recorder.next(), timeout: 10) XCTAssertEqual(download3, readyForDownload!.download) - try database.write { db in - download3.state = .enqueued - try download3.save(db) + try await database.write { [download3] db in + var download = download3 + download.state = .enqueued + try download.save(db) } readyForDownload = try wait(for: recorder.next(), timeout: 10) XCTAssertNil(readyForDownload) } - func testDownloadQueueStreamRespectsTheMaxLimit() throws { + func testDownloadQueueStreamRespectsTheMaxLimit() async throws { let recorder = queueManager.downloadQueue.record() - let download1 = try samplePersistedDownload(state: .enqueued) - let download2 = try samplePersistedDownload(state: .enqueued) - _ = try samplePersistedDownload(state: .enqueued) + let download1 = try await samplePersistedDownload(state: .enqueued) + let download2 = try await samplePersistedDownload(state: .enqueued) + _ = try await samplePersistedDownload(state: .enqueued) let queue = try wait(for: recorder.next(4), timeout: 30) XCTAssertEqual( @@ -218,56 +150,84 @@ class DownloadQueueManagerTest: XCTestCase { ) } - func testDownloadQueueStreamSendsFromThePast() throws { - let download1 = try samplePersistedDownload(state: .enqueued) - let download2 = try samplePersistedDownload(state: .enqueued) - try samplePersistedDownload(state: .enqueued) + func testDownloadQueueStreamSendsFromThePast() async throws { + let download1 = try await samplePersistedDownload(state: .enqueued) + let download2 = try await samplePersistedDownload(state: .enqueued) + try await samplePersistedDownload(state: .enqueued) let recorder = queueManager.downloadQueue.record() let queue = try wait(for: recorder.next(), timeout: 10) XCTAssertEqual([download1, download2], queue.map(\.download)) } - func testDownloadQueueStreamSendsInProgressFirst() throws { - try samplePersistedDownload(state: .enqueued) - let download2 = try samplePersistedDownload(state: .inProgress) - try samplePersistedDownload(state: .enqueued) - let download4 = try samplePersistedDownload(state: .inProgress) + func testDownloadQueueStreamSendsInProgressFirst() async throws { + try await samplePersistedDownload(state: .enqueued) + let download2 = try await samplePersistedDownload(state: .inProgress) + try await samplePersistedDownload(state: .enqueued) + let download4 = try await samplePersistedDownload(state: .inProgress) let recorder = queueManager.downloadQueue.record() let queue = try wait(for: recorder.next(), timeout: 10) XCTAssertEqual([download2, download4], queue.map(\.download)) } - func testDownloadQueueStreamUpdatesWhenInProgressCompleted() throws { - let download1 = try samplePersistedDownload(state: .enqueued) - var download2 = try samplePersistedDownload(state: .inProgress) - try samplePersistedDownload(state: .enqueued) - let download4 = try samplePersistedDownload(state: .inProgress) + func testDownloadQueueStreamUpdatesWhenInProgressCompleted() async throws { + let download1 = try await samplePersistedDownload(state: .enqueued) + let download2 = try await samplePersistedDownload(state: .inProgress) + try await samplePersistedDownload(state: .enqueued) + let download4 = try await samplePersistedDownload(state: .inProgress) let recorder = queueManager.downloadQueue.record() var queue = try wait(for: recorder.next(), timeout: 10) XCTAssertEqual([download2, download4], queue.map(\.download)) - try database.write { db in - download2.state = .complete - try download2.save(db) + try await database.write { db in + var download = download2 + download.state = .complete + try download.save(db) } queue = try wait(for: recorder.next(), timeout: 10) XCTAssertEqual([download4, download1], queue.map(\.download)) } - func testDownloadQueueStreamDoesNotChangeIfAtCapacity() throws { - let download1 = try samplePersistedDownload(state: .enqueued) - let download2 = try samplePersistedDownload(state: .enqueued) + func testDownloadQueueStreamDoesNotChangeIfAtCapacity()async throws { + let download1 = try await samplePersistedDownload(state: .enqueued) + let download2 = try await samplePersistedDownload(state: .enqueued) let recorder = queueManager.downloadQueue.record() var queue = try wait(for: recorder.next(), timeout: 10) XCTAssertEqual([download1, download2], queue.map(\.download)) - try samplePersistedDownload(state: .enqueued) + try await samplePersistedDownload(state: .enqueued) queue = try wait(for: recorder.next(), timeout: 10) XCTAssertEqual([download1, download2], queue.map(\.download)) } } + +// MARK: - private +private extension DownloadQueueManagerTest { + var sampleDownload: Download { + get async throws { + let screencast = ContentTest.Mocks.screencast + let result = try await downloadService.requestDownload(contentID: screencast.0.id) { _ in + .init(content: screencast.0, cacheUpdate: screencast.1) + } + + XCTAssertEqual(result, .downloadRequestedButQueueInactive) + + return try XCTUnwrap(allDownloads.first) + } + } + + @discardableResult func samplePersistedDownload(state: Download.State = .pending) async throws -> Download { + try await database.write { db in + let content = PersistenceMocks.content + try content.save(db) + + var download = PersistenceMocks.download(for: content) + download.state = state + return try download.saved(db) + } + } +} diff --git a/Emitron/emitronTests/Downloads/DownloadServiceTest.swift b/Emitron/emitronTests/Downloads/DownloadServiceTest.swift index f050cc27..57f5877b 100644 --- a/Emitron/emitronTests/Downloads/DownloadServiceTest.swift +++ b/Emitron/emitronTests/Downloads/DownloadServiceTest.swift @@ -30,125 +30,57 @@ import XCTest import GRDB @testable import Emitron -class DownloadServiceTest: XCTestCase { - private var database: DatabaseWriter! +class DownloadServiceTest: XCTestCase, DatabaseTestCase { + private(set) var database: TestDatabase! private var persistenceStore: PersistenceStore! private var videoService = VideosServiceMock() private var downloadService: DownloadService! private var userModelController: UserMCMock! private var settingsManager: SettingsManager! - override func setUp() { - super.setUp() - // swiftlint:disable:next force_try - database = try! EmitronDatabase.testDatabase() + override func setUpWithError() throws { + try super.setUpWithError() + database = try EmitronDatabase.test persistenceStore = PersistenceStore(db: database) userModelController = .init(user: .withDownloads) settingsManager = App.objects.settingsManager - downloadService = DownloadService(persistenceStore: persistenceStore, - userModelController: userModelController, - videosServiceProvider: { _ in self.videoService }, - settingsManager: settingsManager) + downloadService = DownloadService( + persistenceStore: persistenceStore, + userModelController: userModelController, + videosServiceProvider: { [unowned videoService] _ in videoService }, + settingsManager: settingsManager + ) // Check it's all empty - XCTAssert(getAllContents().isEmpty) - XCTAssert(getAllDownloads().isEmpty) + XCTAssert(try allContents.isEmpty) + XCTAssert(try allDownloads.isEmpty) } - override func tearDown() { - super.tearDown() + override func tearDownWithError() throws { + try super.tearDownWithError() videoService.reset() - deleteSampleFile(fileManager: FileManager.default) + try FileManager.removeExistingFile( + at: .downloadsDirectory.appendingPathComponent("sample_file") + ) App.objects.settingsManager.resetAll() } - func getAllContents() -> [Content] { - // swiftlint:disable:next force_try - try! database.read(Content.fetchAll) - } - - func getAllDownloads() -> [Download] { - // swiftlint:disable:next force_try - try! database.read(Download.fetchAll) - } - - func getAllDownloadQueueItems() -> [PersistenceStore.DownloadQueueItem] { - // swiftlint:disable:next force_try - try! database.read { db in - let request = Download.including(required: Download.content) - return try PersistenceStore.DownloadQueueItem.fetchAll(db, request) - } - } - - func sampleDownloadQueueItem() throws -> PersistenceStore.DownloadQueueItem { - let screencast = ContentTest.Mocks.screencast - let recorder = downloadService.requestDownload(contentID: screencast.0.id) { _ in - ContentPersistableState.persistableState(for: screencast.0, with: screencast.1) - } - .record() - - let completion = try wait(for: recorder.completion, timeout: 10) - XCTAssert(completion == .finished) - - let download = getAllDownloads().first! - let content = getAllContents().first! - return PersistenceStore.DownloadQueueItem(download: download, content: content) - } - - func sampleDownload() throws -> Download { - try sampleDownloadQueueItem().download - } - - func downloadsDirectory(fileManager: FileManager) -> URL { - let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first - return documentsDirectory!.appendingPathComponent("downloads", isDirectory: true) - } - - func createSampleFile(fileManager: FileManager) -> URL { - // Create a sample file - let directory = downloadsDirectory(fileManager: fileManager) - let sampleFile = directory.appendingPathComponent("sample_file") - - var fileExists: Bool { - fileManager.fileExists(atPath: sampleFile.path) - } - - XCTAssertFalse(fileExists) - fileManager.createFile(atPath: sampleFile.path, contents: .none) - XCTAssert(fileExists) - - return sampleFile - } - - func deleteSampleFile(fileManager: FileManager) { - let directory = downloadsDirectory(fileManager: fileManager) - let sampleFile = directory.appendingPathComponent("sample_file") - - if fileManager.fileExists(atPath: sampleFile.path) { - // swiftlint:disable:next force_try - try! fileManager.removeItem(at: sampleFile) - } - } - //: requestDownload(content:) Tests - func testRequestDownloadScreencastAddsContentToLocalStore() throws { + func testRequestDownloadScreencastAddsContentToLocalStore() async throws { let screencast = ContentTest.Mocks.screencast - let recorder = downloadService.requestDownload(contentID: screencast.0.id) { _ in - ContentPersistableState.persistableState(for: screencast.0, with: screencast.1) + let result = try await downloadService.requestDownload(contentID: screencast.0.id) { _ in + .init(content: screencast.0, cacheUpdate: screencast.1) } - .record() - - let completion = try wait(for: recorder.completion, timeout: 3) - XCTAssert(completion == .finished) - - XCTAssertEqual(1, getAllContents().count) - XCTAssertEqual(screencast.0.id, Int(getAllContents().first!.id)) + + XCTAssertEqual(result, .downloadRequestedButQueueInactive) + XCTAssertEqual(1, try allContents.count) + XCTAssertEqual(screencast.0.id, Int(try allContents.first!.id)) } - func testRequestDownloadScreencastUpdatesExistingContentInLocalStore() throws { + func testRequestDownloadScreencastUpdatesExistingContentInLocalStore() async throws { let screencastModel = ContentTest.Mocks.screencast var screencast = screencastModel.0 - try database.write(screencast.save) + try await database.write(screencast.save) let originalDuration = screencast.duration let originalDescription = screencast.descriptionPlainText @@ -161,71 +93,63 @@ class DownloadServiceTest: XCTestCase { // Update the persisted model screencast.duration = newDuration screencast.descriptionPlainText = newDescription - try database.write(screencast.save) + screencast = try await database.write { [screencast] db in + try screencast.saved(db) + } // Verify the changes persisted - try database.read { db in - let updatedScreencast = try Content.fetchOne(db, key: screencast.id) - XCTAssertEqual(newDuration, updatedScreencast!.duration) - XCTAssertEqual(newDescription, updatedScreencast!.descriptionPlainText) + try await database.read { [screencast] db in + let updatedScreencast = try Content.fetchOne(db, key: screencast.id).unwrapped + XCTAssertEqual(newDuration, updatedScreencast.duration) + XCTAssertEqual(newDescription, updatedScreencast.descriptionPlainText) } // We only have one item of content - XCTAssertEqual(1, getAllContents().count) + XCTAssertEqual(1, try allContents.count) // Now execute the download request - let recorder = downloadService.requestDownload(contentID: screencast.id) { _ in - ContentPersistableState.persistableState(for: screencast, with: screencastModel.1) + let result = try await downloadService.requestDownload(contentID: screencast.id) { _ in + .init(content: screencast, cacheUpdate: screencastModel.1) } - .record() - - let completion = try wait(for: recorder.completion, timeout: 10) - XCTAssert(completion == .finished) - + + XCTAssertEqual(result, .downloadRequestedButQueueInactive) + // No change to the content count - XCTAssertEqual(1, getAllContents().count) + XCTAssertEqual(1, try allContents.count) // The values will have reverted to those from the cache - try database.read { db in - let updatedScreencast = try Content.fetchOne(db, key: screencast.id) - XCTAssertEqual(originalDuration, updatedScreencast!.duration) - XCTAssertEqual(originalDescription, updatedScreencast!.descriptionPlainText) + try await database.read { [key = screencast.id] db in + let updatedScreencast = try Content.fetchOne(db, key: key).unwrapped + try XCTSkipUnless(originalDuration == updatedScreencast.duration) + XCTAssertEqual(originalDescription, updatedScreencast.descriptionPlainText) } } - func testRequestDownloadEpisodeAddsEpisodeAndCollectionToLocalStore() throws { + func testRequestDownloadEpisodeAddsEpisodeAndCollectionToLocalStore() async throws { let collection = ContentTest.Mocks.collection - let fullState = ContentPersistableState.persistableState(for: collection.0, with: collection.1) + let fullState = ContentPersistableState(content: collection.0, cacheUpdate: collection.1) let episode = fullState.childContents.first! - let recorder = downloadService.requestDownload(contentID: episode.id) { contentID in - ContentPersistableState.persistableState(for: contentID, with: collection.1) + let result = try await downloadService.requestDownload(contentID: episode.id) { contentID in + ContentPersistableState(contentID: contentID, cacheUpdate: collection.1) } - .record() - - let completion = try wait(for: recorder.completion, timeout: 10) - XCTAssert(completion == .finished) - + + XCTAssertEqual(result, .downloadRequestedButQueueInactive) + let allContentIDs = fullState.childContents.map(\.id) + [collection.0.id] - XCTAssertEqual(allContentIDs.count, getAllContents().count) - XCTAssertEqual(allContentIDs.sorted(), getAllContents().map { Int($0.id) }.sorted()) + XCTAssertEqual(allContentIDs.count, try allContents.count) + XCTAssertEqual(allContentIDs.sorted(), try allContents.map { Int($0.id) }.sorted()) } - func testRequestDownloadEpisodeUpdatesLocalDataStore() throws { + func testRequestDownloadEpisodeUpdatesLocalDataStore() async throws { let collectionModel = ContentTest.Mocks.collection var collection = collectionModel.0 - let fullState = ContentPersistableState.persistableState(for: collection, with: collectionModel.1) + let fullState = ContentPersistableState(content: collection, cacheUpdate: collectionModel.1) - let episode = fullState.childContents.first! - let recorder = persistenceStore.persistContentGraph(for: fullState, contentLookup: { contentID in - ContentPersistableState.persistableState(for: contentID, with: collectionModel.1) - }) - .record() - - let completion = try wait(for: recorder.completion, timeout: 10) - if case .failure = completion { - XCTFail("Failed") + let episode = try fullState.childContents.first.unwrapped + try await persistenceStore.persistContentGraph(for: fullState) { contentID in + .init(contentID: contentID, cacheUpdate: collectionModel.1) } let originalDuration = collection.duration @@ -239,66 +163,61 @@ class DownloadServiceTest: XCTestCase { // Update the CD model collection.duration = newDuration collection.descriptionPlainText = newDescription - try database.write(collection.save) + collection = try await database.write { [collection] db in + try collection.saved(db) + } // Confirm the change was persisted - try database.read { db in - let updatedCollection = try Content.fetchOne(db, key: collection.id) - XCTAssertEqual(newDuration, updatedCollection!.duration) - XCTAssertEqual(newDescription, updatedCollection!.descriptionPlainText) + try await database.read { [key = collection.id] db in + let updatedCollection = try Content.fetchOne(db, key: key).unwrapped + XCTAssertEqual(newDuration, updatedCollection.duration) + XCTAssertEqual(newDescription, updatedCollection.descriptionPlainText) } // Now execute the download request - let anotherRecorder = downloadService.requestDownload(contentID: episode.id) { _ in - ContentPersistableState.persistableState(for: collection, with: collectionModel.1) + let result = try await downloadService.requestDownload(contentID: episode.id) { _ in + .init(content: collection, cacheUpdate: collectionModel.1) } - .record() - - let anotherCompletion = try wait(for: anotherRecorder.completion, timeout: 10) - XCTAssert(anotherCompletion == .finished) - + + XCTAssertEqual(result, .downloadRequestedButQueueInactive) + // Adds all episodes and the collection to the DB - XCTAssertEqual(fullState.childContents.count + 1, getAllContents().count) + XCTAssertEqual(fullState.childContents.count + 1, try allContents.count) // The values will have been reverted cos of the cache - try database.read { db in - let updatedCollection = try Content.fetchOne(db, key: collection.id) - XCTAssertEqual(originalDuration, updatedCollection!.duration) - XCTAssertEqual(originalDescription, updatedCollection!.descriptionPlainText) + try await database.read { [key = collection.id] db in + let updatedCollection = try Content.fetchOne(db, key: key).unwrapped + try XCTSkipUnless(originalDuration == updatedCollection.duration) + XCTAssertEqual(originalDescription, updatedCollection.descriptionPlainText) } } - func testRequestDownloadCollectionAddsCollectionAndEpisodesToLocalStore() throws { + func testRequestDownloadCollectionAddsCollectionAndEpisodesToLocalStore() async throws { let collection = ContentTest.Mocks.collection - let fullState = ContentPersistableState.persistableState(for: collection.0, with: collection.1) + let fullState = ContentPersistableState(content: collection.0, cacheUpdate: collection.1) - let recorder = downloadService.requestDownload(contentID: collection.0.id) { contentID in - ContentPersistableState.persistableState(for: contentID, with: collection.1) + let result = try await downloadService.requestDownload(contentID: collection.0.id) { contentID in + .init(contentID: contentID, cacheUpdate: collection.1) } - .record() - - let completion = try wait(for: recorder.completion, timeout: 10) - XCTAssert(completion == .finished) - - XCTAssertEqual(fullState.childContents.count + 1, getAllContents().count) + + XCTAssertEqual(result, .downloadRequestedButQueueInactive) + + XCTAssertEqual(fullState.childContents.count + 1, try allContents.count) XCTAssertEqual( (fullState.childContents.map(\.id) + [collection.0.id]) .sorted(), - getAllContents().map { Int($0.id) }.sorted() + try allContents.map { Int($0.id) }.sorted() ) } - func testRequestDownloadCollectionUpdatesLocalDataStore() throws { + func testRequestDownloadCollectionUpdatesLocalDataStore() async throws { let collectionModel = ContentTest.Mocks.collection - let fullState = ContentPersistableState.persistableState(for: collectionModel.0, with: collectionModel.1) - - var episode = fullState.childContents.first! + let fullState = ContentPersistableState(content: collectionModel.0, cacheUpdate: collectionModel.1) - let recorder = persistenceStore.persistContentGraph(for: fullState, contentLookup: { contentID in - ContentPersistableState.persistableState(for: contentID, with: collectionModel.1) - }) - .record() + var episode = try XCTUnwrap(fullState.childContents.first) - _ = try wait(for: recorder.completion, timeout: 10) + try await persistenceStore.persistContentGraph(for: fullState) { contentID in + .init(contentID: contentID, cacheUpdate: collectionModel.1) + } let originalDuration = episode.duration let originalDescription = episode.descriptionPlainText @@ -311,366 +230,361 @@ class DownloadServiceTest: XCTestCase { // Update the persisted model episode.duration = newDuration episode.descriptionPlainText = newDescription - try database.write { db in - try episode.save(db) + episode = try await database.write { [episode] db in + try episode.saved(db) } // Check that the new values were saved - try database.read { db in - let updatedEpisode = try Content.fetchOne(db, key: episode.id) - XCTAssertEqual(newDuration, updatedEpisode!.duration) - XCTAssertEqual(newDescription, updatedEpisode!.descriptionPlainText) + try await database.read { [key = episode.id] db in + let updatedEpisode = try Content.fetchOne(db, key: key).unwrapped + XCTAssertEqual(newDuration, updatedEpisode.duration) + XCTAssertEqual(newDescription, updatedEpisode.descriptionPlainText) } // Now execute the download request - let recorder2 = downloadService.requestDownload(contentID: collectionModel.0.id) { contentID in - ContentPersistableState.persistableState(for: contentID, with: collectionModel.1) + let result = try await downloadService.requestDownload(contentID: collectionModel.0.id) { contentID in + .init(contentID: contentID, cacheUpdate: collectionModel.1) } - .record() - - let completion = try wait(for: recorder2.completion, timeout: 10) - XCTAssert(completion == .finished) - + + XCTAssertEqual(result, .downloadRequestedButQueueInactive) + // Added the correct number of models - XCTAssertEqual(fullState.childContents.count + 1, getAllContents().count) + XCTAssertEqual(fullState.childContents.count + 1, try allContents.count) // The values reverted cos of the data cache - try database.read { db in - let updatedEpisode = try Content.fetchOne(db, key: episode.id) - XCTAssertEqual(originalDuration, updatedEpisode!.duration) - XCTAssertEqual(originalDescription, updatedEpisode!.descriptionPlainText) + try await database.read { [key = episode.id] db in + let updatedEpisode = try Content.fetchOne(db, key: key).unwrapped + XCTAssertEqual(originalDuration, updatedEpisode.duration) + XCTAssertEqual(originalDescription, updatedEpisode.descriptionPlainText) } } - func testRequestDownloadAddsDownloadToEpisodesAndCreatesOneForItsParentCollection() throws { + func testRequestDownloadAddsDownloadToEpisodesAndCreatesOneForItsParentCollection() async throws { let collection = ContentTest.Mocks.collection - let fullState = ContentPersistableState.persistableState(for: collection.0, with: collection.1) + let fullState = ContentPersistableState(content: collection.0, cacheUpdate: collection.1) let episode = fullState.childContents.first! - let recorder = downloadService.requestDownload(contentID: episode.id) { contentID in - ContentPersistableState.persistableState(for: contentID, with: collection.1) + let result = try await downloadService.requestDownload(contentID: episode.id) { contentID in + .init(contentID: contentID, cacheUpdate: collection.1) } - .record() - - let completion = try wait(for: recorder.completion, timeout: 10) - XCTAssert(completion == .finished) - - XCTAssertEqual(2, getAllDownloads().count) - - let download = getAllDownloads().first! - XCTAssertEqual(episode.id, download.contentID) + + XCTAssertEqual(result, .downloadRequestedButQueueInactive) + XCTAssertEqual(try allDownloads.count, 2) + XCTAssertEqual(episode.id, try allDownloads.first?.contentID) } - func testRequestAdditionalEpisodesUpdatesTheCollectionDownload() throws { + func testRequestAdditionalEpisodesUpdatesTheCollectionDownload() async throws { let collection = ContentTest.Mocks.collection - let fullState = ContentPersistableState.persistableState(for: collection.0, with: collection.1) + let fullState = ContentPersistableState(content: collection.0, cacheUpdate: collection.1) let episodes = fullState.childContents - let recorder1 = downloadService.requestDownload(contentID: episodes[0].id) { contentID in - ContentPersistableState.persistableState(for: contentID, with: collection.1) + let result1 = try await downloadService.requestDownload(contentID: episodes[0].id) { contentID in + .init(contentID: contentID, cacheUpdate: collection.1) } - .record() - let recorder2 = downloadService.requestDownload(contentID: episodes[1].id) { contentID in - ContentPersistableState.persistableState(for: contentID, with: collection.1) + let result2 = try await downloadService.requestDownload(contentID: episodes[1].id) { contentID in + .init(contentID: contentID, cacheUpdate: collection.1) } - .record() - let recorder3 = downloadService.requestDownload(contentID: episodes[2].id) { contentID in - ContentPersistableState.persistableState(for: contentID, with: collection.1) + let result3 = try await downloadService.requestDownload(contentID: episodes[2].id) { contentID in + .init(contentID: contentID, cacheUpdate: collection.1) } - .record() - - _ = try wait(for: recorder1.completion, timeout: 10) - _ = try wait(for: recorder2.completion, timeout: 10) - _ = try wait(for: recorder3.completion, timeout: 10) - - XCTAssertEqual(4, getAllDownloads().count) + + XCTAssertEqual(result1, .downloadRequestedButQueueInactive) + XCTAssertEqual(result2, .downloadRequestedButQueueInactive) + XCTAssertEqual(result3, .downloadRequestedButQueueInactive) + XCTAssertEqual(4, try allDownloads.count) } - func testRequestDownloadAddsDownloadToScreencasts() throws { + func testRequestDownloadAddsDownloadToScreencasts() async throws { let screencast = ContentTest.Mocks.screencast - let recorder = downloadService.requestDownload(contentID: screencast.0.id) { _ in - ContentPersistableState.persistableState(for: screencast.0, with: screencast.1) + let result = try await downloadService.requestDownload(contentID: screencast.0.id) { _ in + .init(content: screencast.0, cacheUpdate: screencast.1) } - .record() - - let completion = try wait(for: recorder.completion, timeout: 10) - XCTAssert(completion == .finished) - - XCTAssertEqual(1, getAllDownloads().count) - let download = getAllDownloads().first! - XCTAssertEqual(screencast.0.id, download.contentID) + + XCTAssertEqual(result, .downloadRequestedButQueueInactive) + XCTAssertEqual(try allDownloads.count, 1) + XCTAssertEqual(screencast.0.id, try allDownloads.first?.contentID) } - func testRequestDownloadAddsDownloadToCollection() throws { + func testRequestDownloadAddsDownloadToCollection() async throws { let collection = ContentTest.Mocks.collection - let fullState = ContentPersistableState.persistableState(for: collection.0, with: collection.1) - let recorder = downloadService.requestDownload(contentID: collection.0.id) { contentID in - ContentPersistableState.persistableState(for: contentID, with: collection.1) + let fullState = ContentPersistableState(content: collection.0, cacheUpdate: collection.1) + let result = try await downloadService.requestDownload(contentID: collection.0.id) { contentID in + .init(contentID: contentID, cacheUpdate: collection.1) } - .record() - - let completion = try wait(for: recorder.completion, timeout: 20) - XCTAssert(completion == .finished) + + XCTAssertEqual(result, .downloadRequestedButQueueInactive) // Adds downloads to the collection and the individual episodes - XCTAssertEqual(fullState.childContents.count + 1, getAllDownloads().count) + XCTAssertEqual(fullState.childContents.count + 1, try allDownloads.count) XCTAssertEqual( (fullState.childContents.map(\.id) + [collection.0.id]).sorted(), - getAllDownloads().map(\.contentID).sorted() + try allDownloads.map(\.contentID).sorted() ) } - func testRequestDownloadAddsDownloadInPendingState() throws { + func testRequestDownloadAddsDownloadInPendingState() async throws { let screencast = ContentTest.Mocks.screencast - let recorder = downloadService.requestDownload(contentID: screencast.0.id) { _ in - ContentPersistableState.persistableState(for: screencast.0, with: screencast.1) + let result = try await downloadService.requestDownload(contentID: screencast.0.id) { _ in + .init(content: screencast.0, cacheUpdate: screencast.1) } - .record() - - let completion = try wait(for: recorder.completion, timeout: 10) - XCTAssert(completion == .finished) - - let download = getAllDownloads().first! - XCTAssertEqual(.pending, download.state) + + XCTAssertEqual(result, .downloadRequestedButQueueInactive) + XCTAssertEqual(.pending, try allDownloads.first?.state) } //: Download directory - func testCreatesDownloadDirectory() { - // This is created at instantiation of the DownloadService object - let fileManager = FileManager.default - let documentsDirectories = fileManager.urls(for: .documentDirectory, in: .userDomainMask) - let documentsDirectory = documentsDirectories.first - - // Can find the documents directory - XCTAssertNotNil(documentsDirectory) - - let downloadsDirectory = documentsDirectory!.appendingPathComponent("downloads", isDirectory: true) - - // The downloads subdirectory exists - // swiftlint:disable:next force_try - let resourceValues = try! downloadsDirectory.resourceValues(forKeys: [.isExcludedFromBackupKey]) - - // The directory is marked as excluded from backups - XCTAssert(resourceValues.isExcludedFromBackup == true) + func testCreatesDownloadDirectory() throws { + XCTAssert( + try URL.downloadsDirectory.resourceValues(forKeys: [.isExcludedFromBackupKey]).isExcludedFromBackup == true + ) } func testEmptiesDownloadsDirectoryIfNotLoggedIn() { - let fileManager = FileManager.default - let sampleFile = createSampleFile(fileManager: fileManager) - userModelController.user = .none - downloadService = DownloadService(persistenceStore: persistenceStore, - userModelController: userModelController, - videosServiceProvider: { _ in self.videoService }, - settingsManager: App.objects.settingsManager) + downloadService = .init( + persistenceStore: persistenceStore, + userModelController: userModelController, + videosServiceProvider: { _ in self.videoService }, + settingsManager: App.objects.settingsManager + ) - XCTAssert(!fileManager.fileExists(atPath: sampleFile.path)) + XCTAssertFalse(sampleFileExists) } func testEmptiesDownloadsDirectoryWhenLogsOut() { - let fileManager = FileManager.default - let sampleFile = createSampleFile(fileManager: fileManager) + createSampleFile() userModelController.objectWillChange.send() userModelController.user = .none userModelController.objectDidChange.send() - XCTAssertFalse(fileManager.fileExists(atPath: sampleFile.path)) + XCTAssertFalse(sampleFileExists) } func testEmptiesDownloadsDirectoryWhenUserDoesNotHaveDownloadPermission() { - let fileManager = FileManager.default - let sampleFile = createSampleFile(fileManager: fileManager) + createSampleFile() userModelController.user = .noPermissions - downloadService = DownloadService(persistenceStore: persistenceStore, - userModelController: userModelController, - videosServiceProvider: { _ in self.videoService }, - settingsManager: App.objects.settingsManager) + downloadService = .init( + persistenceStore: persistenceStore, + userModelController: userModelController, + videosServiceProvider: { _ in self.videoService }, + settingsManager: App.objects.settingsManager + ) - XCTAssertFalse(fileManager.fileExists(atPath: sampleFile.path)) + XCTAssertFalse(sampleFileExists) } func testEmptiesDownloadsDirectoryWhenPermissionsChange() { - let fileManager = FileManager.default - let sampleFile = createSampleFile(fileManager: fileManager) + createSampleFile() userModelController.objectWillChange.send() userModelController.user = .noPermissions userModelController.objectDidChange.send() - XCTAssertFalse(fileManager.fileExists(atPath: sampleFile.path)) + XCTAssertFalse(sampleFileExists) } func testDoesNotEmptyDownloadDirectoryIfUserHasDownloadPermission() { - let fileManager = FileManager.default - let sampleFile = createSampleFile(fileManager: fileManager) + createSampleFile() userModelController.objectWillChange.send() userModelController.user = .withDownloads userModelController.objectDidChange.send() - XCTAssert(fileManager.fileExists(atPath: sampleFile.path)) + XCTAssert(sampleFileExists) } //: requestDownloadURL() Tests - func testRequestDownloadURLRequestsDownloadURLForEpisode() throws { + func testRequestDownloadURLRequestsDownloadURLForEpisode() async throws { let collection = ContentTest.Mocks.collection - let fullState = ContentPersistableState.persistableState(for: collection.0, with: collection.1) + let fullState = ContentPersistableState(content: collection.0, cacheUpdate: collection.1) let episode = fullState.childContents.first! - let recorder = downloadService.requestDownload(contentID: episode.id) { contentID in - ContentPersistableState.persistableState(for: contentID, with: collection.1) + let result = try await downloadService.requestDownload(contentID: episode.id) { contentID in + .init(contentID: contentID, cacheUpdate: collection.1) } - .record() - - let completion = try wait(for: recorder.completion, timeout: 10) - XCTAssert(completion == .finished) - - let downloadQueueItem = getAllDownloadQueueItems().first! - - XCTAssertEqual(0, videoService.getVideoDownloadCount) - - downloadService.requestDownloadURL(downloadQueueItem) - - XCTAssertEqual(1, videoService.getVideoDownloadCount) + + XCTAssertEqual(result, .downloadRequestedButQueueInactive) + + XCTAssertEqual(videoService.getVideoDownloadCount, 0) + await downloadService.requestDownloadURL(try XCTUnwrap(allDownloadQueueItems.first)) + XCTAssertEqual(videoService.getVideoDownloadCount, 1) } - func testRequestDownloadURLRequestsDownloadsURLForScreencast() throws { - let downloadQueueItem = try sampleDownloadQueueItem() - + func testRequestDownloadURLRequestsDownloadsURLForScreencast() async throws { + let downloadQueueItem = try await sampleDownloadQueueItem XCTAssertEqual(0, videoService.getVideoDownloadCount) - - downloadService.requestDownloadURL(downloadQueueItem) - - XCTAssertEqual(1, videoService.getVideoDownloadCount) + await downloadService.requestDownloadURL(downloadQueueItem) + XCTAssertEqual(videoService.getVideoDownloadCount, 1) } - func testRequestDownloadURLDoesNothingForCollection() throws { + func testRequestDownloadURLDoesNothingForCollection() async throws { let collection = ContentTest.Mocks.collection - let recorder = downloadService.requestDownload(contentID: collection.0.id) { _ in - ContentPersistableState.persistableState(for: collection.0, with: collection.1) + let result = try await downloadService.requestDownload(contentID: collection.0.id) { _ in + .init(content: collection.0, cacheUpdate: collection.1) } - .record() - - let completion = try wait(for: recorder.completion, timeout: 10) - XCTAssert(.finished == completion) - - let downloadQueueItem = getAllDownloadQueueItems().first { $0.content.contentType == .collection } - - XCTAssertNotNil(downloadQueueItem) + + XCTAssertEqual(result, .downloadRequestedButQueueInactive) + + let downloadQueueItem = try allDownloadQueueItems.first { $0.content.contentType == .collection }.unwrapped XCTAssertEqual(0, videoService.getVideoDownloadCount) - - downloadService.enqueue(downloadQueueItem: downloadQueueItem!) - + await downloadService.enqueue(downloadQueueItem: downloadQueueItem) XCTAssertEqual(0, videoService.getVideoDownloadCount) } - func testRequestDownloadURLDoesNothingForDownloadInWrongState() throws { - let downloadQueueItem = try sampleDownloadQueueItem() + func testRequestDownloadURLDoesNothingForDownloadInWrongState() async throws { + let downloadQueueItem = try await sampleDownloadQueueItem var download = downloadQueueItem.download download.state = .urlRequested - try database.write { db in - try download.save(db) + download = try await database.write { [download] db in + try download.saved(db) } XCTAssertEqual(0, videoService.getVideoDownloadCount) let newQueueItem = PersistenceStore.DownloadQueueItem(download: download, content: downloadQueueItem.content) - downloadService.enqueue(downloadQueueItem: newQueueItem) + await downloadService.enqueue(downloadQueueItem: newQueueItem) XCTAssertEqual(0, videoService.getVideoDownloadCount) } - func testRequestDownloadURLUpdatesDownloadInCallback() throws { - let downloadQueueItem = try sampleDownloadQueueItem() + func testRequestDownloadURLUpdatesDownloadInCallback() async throws { + let downloadQueueItem = try await sampleDownloadQueueItem XCTAssertNil(downloadQueueItem.download.remoteURL) XCTAssertNil(downloadQueueItem.download.lastValidatedAt) XCTAssertEqual(Download.State.pending, downloadQueueItem.download.state) - downloadService.requestDownloadURL(downloadQueueItem) + await downloadService.requestDownloadURL(downloadQueueItem) - try database.read { db in + try await database.read { db in let download = try Download.fetchOne(db, key: downloadQueueItem.download.id)! XCTAssertNotNil(download.remoteURL) XCTAssertNotNil(download.lastValidatedAt) } } - func testRequestDownloadUpdatesTheStateCorrectly() throws { - let downloadQueueItem = try sampleDownloadQueueItem() + func testRequestDownloadUpdatesTheStateCorrectly() async throws { + let downloadQueueItem = try await sampleDownloadQueueItem + await downloadService.requestDownloadURL(downloadQueueItem) - try database.read { db in + try await database.read { db in let download = try Download.fetchOne(db, key: downloadQueueItem.download.id)! XCTAssertEqual(Download.State.urlRequested, download.state) } } - func testEnqueueSetsPropertiesCorrectly() throws { - let downloadQueueItem = try sampleDownloadQueueItem() + func testEnqueueSetsPropertiesCorrectly() async throws { + let downloadQueueItem = try await sampleDownloadQueueItem var download = downloadQueueItem.download // Update to include the URL download.remoteURL = URL(string: "https://example.com/video.mp4") download.state = .readyForDownload - try database.write { db in - try download.save(db) + download = try await database.write { [download] db in + try download.saved(db) } let newQueueItem = PersistenceStore.DownloadQueueItem(download: download, content: downloadQueueItem.content) - downloadService.enqueue(downloadQueueItem: newQueueItem) + await downloadService.enqueue(downloadQueueItem: newQueueItem) - try database.read { db in - let refreshedDownload = try Download.fetchOne(db, key: download.id)! + try await database.read { [key = download.id] db in + let refreshedDownload = try Download.fetchOne(db, key: key).unwrapped XCTAssertNotNil(refreshedDownload.localURL) XCTAssertNotNil(refreshedDownload.fileName) XCTAssertEqual(Download.State.enqueued, refreshedDownload.state) } } - func testEnqueueDoesNothingForADownloadWithoutARemoteURL() throws { - let downloadQueueItem = try sampleDownloadQueueItem() + func testEnqueueDoesNothingForADownloadWithoutARemoteURL() async throws { + let downloadQueueItem = try await sampleDownloadQueueItem var download = downloadQueueItem.download download.state = .urlRequested - try database.write { db in - try download.save(db) + download = try await database.write { [download] db in + try download.saved(db) } let newQueueItem = PersistenceStore.DownloadQueueItem(download: download, content: downloadQueueItem.content) - downloadService.enqueue(downloadQueueItem: newQueueItem) + await downloadService.enqueue(downloadQueueItem: newQueueItem) - try database.read { db in - let refreshedDownload = try Download.fetchOne(db, key: download.id)! + try await database.read { [key = download.id] db in + let refreshedDownload = try Download.fetchOne(db, key: key).unwrapped XCTAssertNil(refreshedDownload.fileName) XCTAssertNil(refreshedDownload.localURL) XCTAssertEqual(Download.State.urlRequested, refreshedDownload.state) } } - func testEnqueueDoesNothingForDownloadInTheWrongState() throws { - let downloadQueueItem = try sampleDownloadQueueItem() + func testEnqueueDoesNothingForDownloadInTheWrongState() async throws { + let downloadQueueItem = try await sampleDownloadQueueItem var download = downloadQueueItem.download download.remoteURL = URL(string: "https://example.com/amazing.mp4") download.state = .pending - try database.write { db in - try download.save(db) + download = try await database.write { [download] db in + try download.saved(db) } let newQueueItem = PersistenceStore.DownloadQueueItem(download: download, content: downloadQueueItem.content) - downloadService.enqueue(downloadQueueItem: newQueueItem) + await downloadService.enqueue(downloadQueueItem: newQueueItem) - try database.read { db in - let refreshedDownload = try Download.fetchOne(db, key: download.id)! + try await database.read { [key = download.id] db in + let refreshedDownload = try Download.fetchOne(db, key: key).unwrapped XCTAssertNil(refreshedDownload.fileName) XCTAssertNil(refreshedDownload.localURL) XCTAssertEqual(Download.State.pending, refreshedDownload.state) } } } + +// MARK: - private +private extension DownloadServiceTest { + var allDownloadQueueItems: [PersistenceStore.DownloadQueueItem] { + get throws { + try database.read { db in + let request = Download.including(required: Download.content) + return try PersistenceStore.DownloadQueueItem.fetchAll(db, request) + } + } + } + + var sampleDownloadQueueItem: PersistenceStore.DownloadQueueItem { + get async throws { + let screencast = ContentTest.Mocks.screencast + let result = try await downloadService.requestDownload(contentID: screencast.0.id) { _ in + .init(content: screencast.0, cacheUpdate: screencast.1) + } + + XCTAssertEqual(result, .downloadRequestedButQueueInactive) + + return .init( + download: try XCTUnwrap(allDownloads.first), + content: try XCTUnwrap(allContents.first) + ) + } + } + + var sampleDownload: Download { + get async throws { try await sampleDownloadQueueItem.download } + } + + var sampleFileURL: URL { + .downloadsDirectory.appendingPathComponent("sample_file") + } + + var sampleFileExists: Bool { + FileManager.default.fileExists(atPath: sampleFileURL.path) + } + + func createSampleFile() { + XCTAssertFalse(sampleFileExists) + FileManager.default.createFile(atPath: sampleFileURL.path, contents: nil) + XCTAssert(sampleFileExists) + } +} diff --git a/Emitron/emitronTests/Models/Mocks/BookmarksModelTest.json b/Emitron/emitronTests/Models/Mocks/BookmarksModelTest.json index e8382d34..6ac28f17 100644 --- a/Emitron/emitronTests/Models/Mocks/BookmarksModelTest.json +++ b/Emitron/emitronTests/Models/Mocks/BookmarksModelTest.json @@ -13,12 +13,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3658341" + "self": "https://api.kodeco.com/api/contents/3658341" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/bookmarks/72281" + "self": "https://api.kodeco.com/api/bookmarks/72281" } }, { @@ -34,12 +34,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3967613" + "self": "https://api.kodeco.com/api/contents/3967613" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/bookmarks/72282" + "self": "https://api.kodeco.com/api/bookmarks/72282" } }, { @@ -55,12 +55,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3401119" + "self": "https://api.kodeco.com/api/contents/3401119" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/bookmarks/72283" + "self": "https://api.kodeco.com/api/bookmarks/72283" } }, { @@ -76,12 +76,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/1940773" + "self": "https://api.kodeco.com/api/contents/1940773" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/bookmarks/72284" + "self": "https://api.kodeco.com/api/bookmarks/72284" } }, { @@ -97,12 +97,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/9340" + "self": "https://api.kodeco.com/api/contents/9340" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/bookmarks/72510" + "self": "https://api.kodeco.com/api/bookmarks/72510" } } ], @@ -124,7 +124,7 @@ "technology_triple_string":"Kotlin 1.3, Android 8.1, Android Studio 3", "contributor_string":"Aleksandra Kizevska, Bhavna Thacker, Victoria Gonda, Eric Soto, Tyler Bos \u0026 Aldo Olivares", "ordinal":null, - "card_artwork_url":"https://koenig-media.raywenderlich.com/uploads/2019/06/Picasso-feature.png" + "card_artwork_url":"https://koenig-media.kodeco.com/uploads/2019/06/Picasso-feature.png" }, "relationships":{ "domains":{ @@ -151,7 +151,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3658341-picasso-tutorial-for-android-getting-started" + "self": "https://api.kodeco.com/api/contents/3658341-picasso-tutorial-for-android-getting-started" } }, { @@ -171,7 +171,7 @@ "technology_triple_string":"Swift 5.1, iOS 13.0 Beta, Xcode 11.0 Beta", "contributor_string":"Adriana Kutenko, Katie Collins \u0026 Josh Steele", "ordinal":null, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/videos/2898/75a50e1d-f681-4d36-8477-08d42cc9c146.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/videos/2898/75a50e1d-f681-4d36-8477-08d42cc9c146.png" }, "relationships":{ "domains":{ @@ -198,7 +198,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3967613-swift-ui-working-with-state" + "self": "https://api.kodeco.com/api/contents/3967613-swift-ui-working-with-state" } }, { @@ -218,7 +218,7 @@ "technology_triple_string":"Swift 5, iOS 12, Xcode 10", "contributor_string":"Ray Fix, JORGE R. MOUKEL \u0026 Katie Collins", "ordinal":2, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/185/9094700a-2c30-4bf6-9640-33732f552c2f.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/185/9094700a-2c30-4bf6-9640-33732f552c2f.png" }, "relationships":{ "domains":{ @@ -252,7 +252,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3401119-advanced-swift-unsafe-memory-access-memory-sizing" + "self": "https://api.kodeco.com/api/contents/3401119-advanced-swift-unsafe-memory-access-memory-sizing" } }, { @@ -272,7 +272,7 @@ "technology_triple_string":"Swift 5, iOS 12, Xcode 10", "contributor_string":"Katie Collins, JORGE R. MOUKEL \u0026 Ray Fix", "ordinal":null, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/185/9094700a-2c30-4bf6-9640-33732f552c2f.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/185/9094700a-2c30-4bf6-9640-33732f552c2f.png" }, "relationships":{ "domains":{ @@ -306,7 +306,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/1940773-advanced-swift-unsafe-memory-access" + "self": "https://api.kodeco.com/api/contents/1940773-advanced-swift-unsafe-memory-access" } }, { @@ -326,7 +326,7 @@ "technology_triple_string":"Swift 4, iOS 12, Xcode 10", "contributor_string":"Josh Steele", "ordinal":null, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/videos/2339/039e9bd1-eb3c-4fa0-825a-af61f5162f7e.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/videos/2339/039e9bd1-eb3c-4fa0-825a-af61f5162f7e.png" }, "relationships":{ "domains":{ @@ -353,16 +353,16 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/9340-contacts-searching-contacts" + "self": "https://api.kodeco.com/api/contents/9340-contacts-searching-contacts" } } ], "links":{ - "self":"https://api.raywenderlich.com/api/bookmarks?page%5Bnumber%5D=1\u0026page%5Bsize%5D=20", - "first":"https://api.raywenderlich.com/api/bookmarks?page%5Bnumber%5D=1\u0026page%5Bsize%5D=20", + "self":"https://api.kodeco.com/api/bookmarks?page%5Bnumber%5D=1\u0026page%5Bsize%5D=20", + "first":"https://api.kodeco.com/api/bookmarks?page%5Bnumber%5D=1\u0026page%5Bsize%5D=20", "prev":null, "next":null, - "last":"https://api.raywenderlich.com/api/bookmarks?page%5Bnumber%5D=1\u0026page%5Bsize%5D=20" + "last":"https://api.kodeco.com/api/bookmarks?page%5Bnumber%5D=1\u0026page%5Bsize%5D=20" }, "meta":{ "total_result_count":5 diff --git a/Emitron/emitronTests/Models/Mocks/Category+Mocks.swift b/Emitron/emitronTests/Models/Mocks/Category+Mocks.swift index b3235d94..9c5e16c1 100644 --- a/Emitron/emitronTests/Models/Mocks/Category+Mocks.swift +++ b/Emitron/emitronTests/Models/Mocks/Category+Mocks.swift @@ -32,14 +32,14 @@ import SwiftyJSON @testable import Emitron extension Emitron.Category { - static func loadAndSaveMocks(db: DatabaseWriter) throws { + static func loadAndSaveMocks(db: TestDatabase) throws { let categories = loadMocksFrom(filename: "Categories") try db.write { db in try categories.forEach { try $0.save(db) } } } - private static func loadMocksFrom(filename: String) -> ([Emitron.Category]) { + private static func loadMocksFrom(filename: String) -> [Emitron.Category] { do { let bundle = Bundle(for: AttachmentTest.self) let fileURL = bundle.url(forResource: filename, withExtension: "json") diff --git a/Emitron/emitronTests/Models/Mocks/ContentDetailsModelTest.json b/Emitron/emitronTests/Models/Mocks/ContentDetailsModelTest.json index 135cfe98..22a41cef 100644 --- a/Emitron/emitronTests/Models/Mocks/ContentDetailsModelTest.json +++ b/Emitron/emitronTests/Models/Mocks/ContentDetailsModelTest.json @@ -13,7 +13,7 @@ "duration":342, "popularity":727.0, "bookmarked?":false, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/176/e1086f63-bc0f-4710-869a-2fca4e280463.png", + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/176/e1086f63-bc0f-4710-869a-2fca4e280463.png", "technology_triple_string":"Swift 5, iOS 13.0 Beta, Xcode 11.0 Beta", "contributor_string":"Katie Collins \u0026 Jessy Catterwaul", "video_identifier":2546 @@ -53,9 +53,9 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/1320588-machine-learning-in-ios-introduction", - "video_stream":"http://api.raywenderlich.com/api/videos/2546/stream", - "video_download":"http://api.raywenderlich.com/api/videos/2546/download" + "self": "https://api.kodeco.com/api/contents/1320588-machine-learning-in-ios-introduction", + "video_stream": "https://api.kodeco.com/api/videos/2546/stream", + "video_download": "https://api.kodeco.com/api/videos/2546/download" } }, "included":[ diff --git a/Emitron/emitronTests/Models/Mocks/ContentDetails_Collection.json b/Emitron/emitronTests/Models/Mocks/ContentDetails_Collection.json index bcaa12d7..91ed983b 100644 --- a/Emitron/emitronTests/Models/Mocks/ContentDetails_Collection.json +++ b/Emitron/emitronTests/Models/Mocks/ContentDetails_Collection.json @@ -19,7 +19,7 @@ "description_plain_text": "Learn about Apple’s open source programming language, Swift, through hands-on examples! \n\nIf you’ve watched Programming in Swift: Fundamentals, you can skip the following episodes in this course:\n\n\nPart 1 - All Episodes\nPart 2 - Episodes 1-5\nPart 3 - Episodes 1-3, and 5-9\nPart 4 - Episodes 1-6\n\n", "video_identifier": null, "parent_name": null, - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -90,7 +90,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/5994-programming-in-swift" + "self": "https://api.kodeco.com/api/contents/5994-programming-in-swift" } }, "included": [ @@ -134,12 +134,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/5994" + "self": "https://api.kodeco.com/api/contents/5994" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/16089926" + "self": "https://api.kodeco.com/api/progressions/16089926" } }, { @@ -213,7 +213,7 @@ "description_plain_text": "Let's take a look at what you'll be learning in this part of the course, and why it's important.\n", "video_identifier": 2056, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -237,7 +237,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7039-programming-in-swift-introduction" + "self": "https://api.kodeco.com/api/contents/7039-programming-in-swift-introduction" } }, { @@ -260,7 +260,7 @@ "description_plain_text": "Learn how to create your first Swift playground, and see how useful it can be to learn Swift, and use in day-to-day development.\n", "video_identifier": 2057, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -287,7 +287,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7042-programming-in-swift-swift-playgrounds" + "self": "https://api.kodeco.com/api/contents/7042-programming-in-swift-swift-playgrounds" } }, { @@ -308,12 +308,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7042" + "self": "https://api.kodeco.com/api/contents/7042" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/16089925" + "self": "https://api.kodeco.com/api/progressions/16089925" } }, { @@ -336,7 +336,7 @@ "description_plain_text": "Learn the various ways to add comments to your Swift code - a useful way to document your work or add notes for future reference.\n", "video_identifier": 2058, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -360,7 +360,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7043-programming-in-swift-comments" + "self": "https://api.kodeco.com/api/contents/7043-programming-in-swift-comments" } }, { @@ -383,7 +383,7 @@ "description_plain_text": "Learn the group related data together into a single unit, through the power of a Swift type called Tuples.\n", "video_identifier": 2059, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -407,7 +407,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7044-programming-in-swift-tuples" + "self": "https://api.kodeco.com/api/contents/7044-programming-in-swift-tuples" } }, { @@ -430,7 +430,7 @@ "description_plain_text": "Practice using tuples on your own, through a series of hands-on challenges.\n", "video_identifier": 2060, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -454,7 +454,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7045-programming-in-swift-challenge-tuples" + "self": "https://api.kodeco.com/api/contents/7045-programming-in-swift-challenge-tuples" } }, { @@ -477,7 +477,7 @@ "description_plain_text": "Learn how to use a Swift type called Booleans, which represent true or false values, and a bunch of new operators.\n", "video_identifier": 2061, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -501,7 +501,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7046-programming-in-swift-booleans-and-operators" + "self": "https://api.kodeco.com/api/contents/7046-programming-in-swift-booleans-and-operators" } }, { @@ -524,7 +524,7 @@ "description_plain_text": "Practice using booleans on your own, through a series of hands-on challenges.\n", "video_identifier": 2062, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -551,7 +551,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7047-programming-in-swift-challenge-booleans" + "self": "https://api.kodeco.com/api/contents/7047-programming-in-swift-challenge-booleans" } }, { @@ -572,12 +572,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7047" + "self": "https://api.kodeco.com/api/contents/7047" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/16091245" + "self": "https://api.kodeco.com/api/progressions/16091245" } }, { @@ -600,7 +600,7 @@ "description_plain_text": "Take another look at the if statement, and learn what the concept of scope means in Swift.\n", "video_identifier": 2063, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -627,7 +627,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7049-programming-in-swift-scope" + "self": "https://api.kodeco.com/api/contents/7049-programming-in-swift-scope" } }, { @@ -648,12 +648,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7049" + "self": "https://api.kodeco.com/api/contents/7049" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/16090541" + "self": "https://api.kodeco.com/api/progressions/16090541" } }, { @@ -676,7 +676,7 @@ "description_plain_text": "Let's review where you are with your Swift core concepts, and discuss what's next.\n", "video_identifier": 2064, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -700,7 +700,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7050-programming-in-swift-conclusion" + "self": "https://api.kodeco.com/api/contents/7050-programming-in-swift-conclusion" } }, { @@ -774,7 +774,7 @@ "description_plain_text": "Let's review what you'll be learning in this part of the course, and why control flow is important.\n", "video_identifier": 2065, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -798,7 +798,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7051-programming-in-swift-introduction" + "self": "https://api.kodeco.com/api/contents/7051-programming-in-swift-introduction" } }, { @@ -821,7 +821,7 @@ "description_plain_text": "Learn how to make Swift repeat your code multiple times with while loops, repeat while loops, and break statements.\n", "video_identifier": 2066, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -845,7 +845,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7052-programming-in-swift-while-loops" + "self": "https://api.kodeco.com/api/contents/7052-programming-in-swift-while-loops" } }, { @@ -868,7 +868,7 @@ "description_plain_text": "Practice using while loops on your own, through a hands-on challenge.\n", "video_identifier": 2067, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -895,7 +895,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7053-programming-in-swift-challenge-while-loops" + "self": "https://api.kodeco.com/api/contents/7053-programming-in-swift-challenge-while-loops" } }, { @@ -916,12 +916,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7053" + "self": "https://api.kodeco.com/api/contents/7053" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/16091472" + "self": "https://api.kodeco.com/api/progressions/16091472" } }, { @@ -944,7 +944,7 @@ "description_plain_text": "Learn how to use for loops in Swift, along with ranges, continue, and labeled statements.\n", "video_identifier": 2068, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -968,7 +968,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7054-programming-in-swift-for-loops" + "self": "https://api.kodeco.com/api/contents/7054-programming-in-swift-for-loops" } }, { @@ -991,7 +991,7 @@ "description_plain_text": "Practice using for loops on your own, through a hands-on challenge.\n", "video_identifier": 2069, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -1015,7 +1015,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7055-programming-in-swift-challenge-for-loops" + "self": "https://api.kodeco.com/api/contents/7055-programming-in-swift-challenge-for-loops" } }, { @@ -1038,7 +1038,7 @@ "description_plain_text": "Learn how to use switch statements in Swift, including some of its more powerful features.\n", "video_identifier": 2070, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -1062,7 +1062,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7056-programming-in-swift-switch-statements" + "self": "https://api.kodeco.com/api/contents/7056-programming-in-swift-switch-statements" } }, { @@ -1085,7 +1085,7 @@ "description_plain_text": "Practice using switch statements on your own, through a hands-on challenge.\n", "video_identifier": 2071, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -1109,7 +1109,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7057-programming-in-swift-challenge-switch-statements" + "self": "https://api.kodeco.com/api/contents/7057-programming-in-swift-challenge-switch-statements" } }, { @@ -1132,7 +1132,7 @@ "description_plain_text": "Learn about Enums, a powerful tool in Swift that can help with your switch statements and so much more!\n", "video_identifier": 2072, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -1159,7 +1159,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7058-programming-in-swift-enumerations" + "self": "https://api.kodeco.com/api/contents/7058-programming-in-swift-enumerations" } }, { @@ -1180,12 +1180,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7058" + "self": "https://api.kodeco.com/api/contents/7058" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/16091738" + "self": "https://api.kodeco.com/api/progressions/16091738" } }, { @@ -1208,7 +1208,7 @@ "description_plain_text": "Let's review what you learned about control flow in this part, and discuss what's next.\n", "video_identifier": 2073, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -1232,7 +1232,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7059-programming-in-swift-conclusion" + "self": "https://api.kodeco.com/api/contents/7059-programming-in-swift-conclusion" } }, { @@ -1306,7 +1306,7 @@ "description_plain_text": "Review what you'll be learning in this part of the course about functions and optionals.\n", "video_identifier": 2074, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -1330,7 +1330,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7061-programming-in-swift-introduction" + "self": "https://api.kodeco.com/api/contents/7061-programming-in-swift-introduction" } }, { @@ -1353,7 +1353,7 @@ "description_plain_text": "Learn how to write your own functions in Swift, and see for yourself how Swift makes them easy to use.\n", "video_identifier": 2075, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -1380,7 +1380,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7062-programming-in-swift-introduction-to-functions" + "self": "https://api.kodeco.com/api/contents/7062-programming-in-swift-introduction-to-functions" } }, { @@ -1401,12 +1401,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7062" + "self": "https://api.kodeco.com/api/contents/7062" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/16092046" + "self": "https://api.kodeco.com/api/progressions/16092046" } }, { @@ -1429,7 +1429,7 @@ "description_plain_text": "Practice writing functions on your own, through a hands-on challenge.\n", "video_identifier": 2076, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -1453,7 +1453,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7063-programming-in-swift-challenge-introduction-to-functions" + "self": "https://api.kodeco.com/api/contents/7063-programming-in-swift-challenge-introduction-to-functions" } }, { @@ -1476,7 +1476,7 @@ "description_plain_text": "Learn some more advanced features of functions, such as overloading, inout parameters, and functions as variables.\n", "video_identifier": 2077, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -1500,7 +1500,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7064-programming-in-swift-more-functions" + "self": "https://api.kodeco.com/api/contents/7064-programming-in-swift-more-functions" } }, { @@ -1523,7 +1523,7 @@ "description_plain_text": "Learn about one of the most important aspects of Swift development - optionals - through a fun analogy.\n", "video_identifier": 2078, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -1547,7 +1547,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7065-programming-in-swift-introduction-to-optionals" + "self": "https://api.kodeco.com/api/contents/7065-programming-in-swift-introduction-to-optionals" } }, { @@ -1570,7 +1570,7 @@ "description_plain_text": "Practice using optionals on your own, through a hands-on challenge.\n", "video_identifier": 2079, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -1594,7 +1594,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7066-programming-in-swift-challenge-introduction-to-optionals" + "self": "https://api.kodeco.com/api/contents/7066-programming-in-swift-challenge-introduction-to-optionals" } }, { @@ -1617,7 +1617,7 @@ "description_plain_text": "Learn how to unwrap optionals, force unwrap optionals, use optional binding, and use the guard statement.\n", "video_identifier": 2080, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -1641,7 +1641,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7067-programming-in-swift-more-optionals" + "self": "https://api.kodeco.com/api/contents/7067-programming-in-swift-more-optionals" } }, { @@ -1664,7 +1664,7 @@ "description_plain_text": "Practice unwrapping optionals on your own, through a hands-on challenge.\n", "video_identifier": 2081, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -1691,7 +1691,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7068-programming-in-swift-challenge-more-optionals" + "self": "https://api.kodeco.com/api/contents/7068-programming-in-swift-challenge-more-optionals" } }, { @@ -1712,12 +1712,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7068" + "self": "https://api.kodeco.com/api/contents/7068" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/16092358" + "self": "https://api.kodeco.com/api/progressions/16092358" } }, { @@ -1740,7 +1740,7 @@ "description_plain_text": "Let's review where you are with your Swift core concepts, and discuss what's next.\n", "video_identifier": 2082, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -1764,7 +1764,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7069-programming-in-swift-conclusion" + "self": "https://api.kodeco.com/api/contents/7069-programming-in-swift-conclusion" } }, { @@ -1842,7 +1842,7 @@ "description_plain_text": "Let's review what you'll be learning in this part of the course, and why it's important.\n", "video_identifier": 2083, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -1869,7 +1869,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7070-programming-in-swift-introduction" + "self": "https://api.kodeco.com/api/contents/7070-programming-in-swift-introduction" } }, { @@ -1890,12 +1890,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7070" + "self": "https://api.kodeco.com/api/contents/7070" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/16093019" + "self": "https://api.kodeco.com/api/progressions/16093019" } }, { @@ -1918,7 +1918,7 @@ "description_plain_text": "Learn how to use arrays in Swift to store and manipulate an ordered list of values.\n", "video_identifier": 2084, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -1942,7 +1942,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7071-programming-in-swift-arrays" + "self": "https://api.kodeco.com/api/contents/7071-programming-in-swift-arrays" } }, { @@ -1965,7 +1965,7 @@ "description_plain_text": "Practice using arrays on your own, through a hands-on challenge.\n", "video_identifier": 2085, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -1989,7 +1989,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7072-programming-in-swift-challenge-arrays" + "self": "https://api.kodeco.com/api/contents/7072-programming-in-swift-challenge-arrays" } }, { @@ -2012,7 +2012,7 @@ "description_plain_text": "Learn how to use dictionaries in Swift to store an unordered collection of pairs.\n", "video_identifier": 2086, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -2036,7 +2036,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7073-programming-in-swift-dictionaries" + "self": "https://api.kodeco.com/api/contents/7073-programming-in-swift-dictionaries" } }, { @@ -2059,7 +2059,7 @@ "description_plain_text": "Practice using dictionaries on your own, through a hands-on challenge.\n", "video_identifier": 2087, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -2083,7 +2083,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7074-programming-in-swift-challenge-dictionaries" + "self": "https://api.kodeco.com/api/contents/7074-programming-in-swift-challenge-dictionaries" } }, { @@ -2106,7 +2106,7 @@ "description_plain_text": "Learn how to use Sets in Swift to store an unordered collection of unique values.\n", "video_identifier": 2088, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -2133,7 +2133,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7075-programming-in-swift-sets" + "self": "https://api.kodeco.com/api/contents/7075-programming-in-swift-sets" } }, { @@ -2154,12 +2154,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7075" + "self": "https://api.kodeco.com/api/contents/7075" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/16093347" + "self": "https://api.kodeco.com/api/progressions/16093347" } }, { @@ -2182,7 +2182,7 @@ "description_plain_text": "Learn how to create closures in Swift - which you can think of as a function without a name.\n", "video_identifier": 2089, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -2206,7 +2206,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7076-programming-in-swift-closures" + "self": "https://api.kodeco.com/api/contents/7076-programming-in-swift-closures" } }, { @@ -2229,7 +2229,7 @@ "description_plain_text": "Learn how you can use closures to sort collections, filter collections, run calculations on elements within a collection, and more.\n", "video_identifier": 2090, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -2253,7 +2253,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7077-programming-in-swift-closures-and-collections" + "self": "https://api.kodeco.com/api/contents/7077-programming-in-swift-closures-and-collections" } }, { @@ -2276,7 +2276,7 @@ "description_plain_text": "Practice using closures on your own, through a hands-on challenge.\n", "video_identifier": 2091, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -2300,7 +2300,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7078-programming-in-swift-challenge-closures" + "self": "https://api.kodeco.com/api/contents/7078-programming-in-swift-challenge-closures" } }, { @@ -2323,7 +2323,7 @@ "description_plain_text": "Let's review what you learned about collections in this part of the course, and discuss what's next.\n", "video_identifier": 2092, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -2347,7 +2347,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7079-programming-in-swift-conclusion" + "self": "https://api.kodeco.com/api/contents/7079-programming-in-swift-conclusion" } }, { @@ -2421,7 +2421,7 @@ "description_plain_text": "Let's review what you'll be learning about structures in this part of the course, and why it's important.\n", "video_identifier": 2093, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -2445,7 +2445,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7080-programming-in-swift-introduction" + "self": "https://api.kodeco.com/api/contents/7080-programming-in-swift-introduction" } }, { @@ -2468,7 +2468,7 @@ "description_plain_text": "Learn how to group data and functionality together in Swift, using a value type called structures.\n", "video_identifier": 2094, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -2495,7 +2495,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7081-programming-in-swift-structures" + "self": "https://api.kodeco.com/api/contents/7081-programming-in-swift-structures" } }, { @@ -2516,12 +2516,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7081" + "self": "https://api.kodeco.com/api/contents/7081" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/16093900" + "self": "https://api.kodeco.com/api/progressions/16093900" } }, { @@ -2544,7 +2544,7 @@ "description_plain_text": "Practice using structures on your own, through a hands-on challenge.\n", "video_identifier": 2095, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -2571,7 +2571,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7082-programming-in-swift-challenge-structures" + "self": "https://api.kodeco.com/api/contents/7082-programming-in-swift-challenge-structures" } }, { @@ -2592,12 +2592,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7082" + "self": "https://api.kodeco.com/api/contents/7082" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/16094712" + "self": "https://api.kodeco.com/api/progressions/16094712" } }, { @@ -2620,7 +2620,7 @@ "description_plain_text": "Learn how to add two types of properties to your types: stored properties, and computed properties.\n", "video_identifier": 2096, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -2647,7 +2647,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7083-programming-in-swift-properties" + "self": "https://api.kodeco.com/api/contents/7083-programming-in-swift-properties" } }, { @@ -2668,12 +2668,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7083" + "self": "https://api.kodeco.com/api/contents/7083" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/29999050" + "self": "https://api.kodeco.com/api/progressions/29999050" } }, { @@ -2696,7 +2696,7 @@ "description_plain_text": "Practice creating properties on your own, through a hands-on challenge.\n", "video_identifier": 2097, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -2723,7 +2723,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7084-programming-in-swift-challenge-properties" + "self": "https://api.kodeco.com/api/contents/7084-programming-in-swift-challenge-properties" } }, { @@ -2744,12 +2744,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7084" + "self": "https://api.kodeco.com/api/contents/7084" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/29999042" + "self": "https://api.kodeco.com/api/progressions/29999042" } }, { @@ -2772,7 +2772,7 @@ "description_plain_text": "Learn when it's best to use computed properties, and when it's best to use methods.\n", "video_identifier": 2098, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -2796,7 +2796,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7085-programming-in-swift-computed-properties-vs-methods" + "self": "https://api.kodeco.com/api/contents/7085-programming-in-swift-computed-properties-vs-methods" } }, { @@ -2819,7 +2819,7 @@ "description_plain_text": "Take a deep dive into methods, including writing initializers, mutating methods, extensions, and more.\n", "video_identifier": 2099, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -2846,7 +2846,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7086-programming-in-swift-methods" + "self": "https://api.kodeco.com/api/contents/7086-programming-in-swift-methods" } }, { @@ -2867,12 +2867,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7086" + "self": "https://api.kodeco.com/api/contents/7086" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/30000379" + "self": "https://api.kodeco.com/api/progressions/30000379" } }, { @@ -2895,7 +2895,7 @@ "description_plain_text": "Practice writing methods on your own, through a hands-on challenge.\n", "video_identifier": 2100, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -2922,7 +2922,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7087-programming-in-swift-challenge-methods" + "self": "https://api.kodeco.com/api/contents/7087-programming-in-swift-challenge-methods" } }, { @@ -2943,12 +2943,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7087" + "self": "https://api.kodeco.com/api/contents/7087" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/16095031" + "self": "https://api.kodeco.com/api/progressions/16095031" } }, { @@ -2971,7 +2971,7 @@ "description_plain_text": "Let's review what you learned about structures in this part of the course, and discuss what's next.\n", "video_identifier": 2101, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -2995,7 +2995,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7088-programming-in-swift-conclusion" + "self": "https://api.kodeco.com/api/contents/7088-programming-in-swift-conclusion" } }, { @@ -3073,7 +3073,7 @@ "description_plain_text": "Let's review what you'll be learning about classes in this part of the course, and why it's important.\n", "video_identifier": 2102, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -3097,7 +3097,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7089-programming-in-swift-introduction" + "self": "https://api.kodeco.com/api/contents/7089-programming-in-swift-introduction" } }, { @@ -3120,7 +3120,7 @@ "description_plain_text": "Learn about the differences between classes and structures in Swift, and when you should use which.\n", "video_identifier": 2103, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -3147,7 +3147,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7090-programming-in-swift-classes-vs-structures" + "self": "https://api.kodeco.com/api/contents/7090-programming-in-swift-classes-vs-structures" } }, { @@ -3168,12 +3168,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7090" + "self": "https://api.kodeco.com/api/contents/7090" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/16095318" + "self": "https://api.kodeco.com/api/progressions/16095318" } }, { @@ -3196,7 +3196,7 @@ "description_plain_text": "Practice working with classes and understanding when to use them vs. structures, through a hands-on challenge.\n", "video_identifier": 2104, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -3223,7 +3223,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7091-programming-in-swift-challenge-classes-vs-structures" + "self": "https://api.kodeco.com/api/contents/7091-programming-in-swift-challenge-classes-vs-structures" } }, { @@ -3244,12 +3244,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7091" + "self": "https://api.kodeco.com/api/contents/7091" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/16095396" + "self": "https://api.kodeco.com/api/progressions/16095396" } }, { @@ -3272,7 +3272,7 @@ "description_plain_text": "Learn how you can inherit functionality from another class in Swift.\n", "video_identifier": 2105, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -3296,7 +3296,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7092-programming-in-swift-inheritance" + "self": "https://api.kodeco.com/api/contents/7092-programming-in-swift-inheritance" } }, { @@ -3319,7 +3319,7 @@ "description_plain_text": "Learn how to create your own class initializers, including two-phase initialization, and required vs. convenience initializers.\n", "video_identifier": 2106, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -3343,7 +3343,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7093-programming-in-swift-initializers" + "self": "https://api.kodeco.com/api/contents/7093-programming-in-swift-initializers" } }, { @@ -3366,7 +3366,7 @@ "description_plain_text": "Practice creating your own class initializers, through a hands-on challenge.\n", "video_identifier": 2107, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -3393,7 +3393,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7094-programming-in-swift-challenge-initializers" + "self": "https://api.kodeco.com/api/contents/7094-programming-in-swift-challenge-initializers" } }, { @@ -3414,12 +3414,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7094" + "self": "https://api.kodeco.com/api/contents/7094" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/16095719" + "self": "https://api.kodeco.com/api/progressions/16095719" } }, { @@ -3442,7 +3442,7 @@ "description_plain_text": "Learn five concepts to help you decide when you should subclass, and when you shouldn't.\n", "video_identifier": 2108, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -3466,7 +3466,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7095-programming-in-swift-when-should-you-subclass" + "self": "https://api.kodeco.com/api/contents/7095-programming-in-swift-when-should-you-subclass" } }, { @@ -3489,7 +3489,7 @@ "description_plain_text": "Learn how to make your types conform to protocols in Swift, which you can think of as a to-do list for your types.\n", "video_identifier": 2109, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -3513,7 +3513,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7096-programming-in-swift-protocols" + "self": "https://api.kodeco.com/api/contents/7096-programming-in-swift-protocols" } }, { @@ -3536,7 +3536,7 @@ "description_plain_text": "Learn how Swift manages memory under the hood, how you can tell when an object is deinitialized, and how you can avoid a nasty memory leak in your apps.\n", "video_identifier": 2110, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -3563,7 +3563,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7097-programming-in-swift-memory-management" + "self": "https://api.kodeco.com/api/contents/7097-programming-in-swift-memory-management" } }, { @@ -3584,12 +3584,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7097" + "self": "https://api.kodeco.com/api/contents/7097" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/16096116" + "self": "https://api.kodeco.com/api/progressions/16096116" } }, { @@ -3612,7 +3612,7 @@ "description_plain_text": "Let's review where you're at with your Swift core concepts, and give you some advice about where to go next.\n", "video_identifier": 2111, "parent_name": "Programming in Swift", - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/collections/142/c4950afd-5ace-470e-8924-4ccce0aadb45.png" }, "relationships": { "domains": { @@ -3636,7 +3636,7 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/7098-programming-in-swift-conclusion" + "self": "https://api.kodeco.com/api/contents/7098-programming-in-swift-conclusion" } }, { diff --git a/Emitron/emitronTests/Models/Mocks/ContentDetails_Screencast.json b/Emitron/emitronTests/Models/Mocks/ContentDetails_Screencast.json index 57ef6557..b7fac6cf 100644 --- a/Emitron/emitronTests/Models/Mocks/ContentDetails_Screencast.json +++ b/Emitron/emitronTests/Models/Mocks/ContentDetails_Screencast.json @@ -20,7 +20,7 @@ "description_plain_text": "Explore how Xcode 11 changes your workflow. Learn how to make the most out of multiple editors and Xcode's source control changes.", "video_identifier": 3067, "parent_name": null, - "card_artwork_url": "https://files.betamax.raywenderlich.com/attachments/videos/3067/8a1744a8-5d44-4dd1-addc-ca7e1245db20.png" + "card_artwork_url": "https://files.betamax.kodeco.com/attachments/videos/3067/8a1744a8-5d44-4dd1-addc-ca7e1245db20.png" }, "relationships": { "domains": { @@ -61,9 +61,9 @@ } }, "links": { - "self": "http://api.raywenderlich.com/api/contents/5148647-what-s-new-in-xcode-11-workflow", - "video_stream": "http://api.raywenderlich.com/api/videos/3067/stream", - "video_download": "http://api.raywenderlich.com/api/videos/3067/download" + "self": "https://api.kodeco.com/api/contents/5148647-what-s-new-in-xcode-11-workflow", + "video_stream": "https://api.kodeco.com/api/videos/3067/stream", + "video_download": "https://api.kodeco.com/api/videos/3067/download" } }, "included": [ @@ -96,12 +96,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/5148647" + "self": "https://api.kodeco.com/api/contents/5148647" } } }, "links": { - "self": "http://api.raywenderlich.com/api/progressions/97194622" + "self": "https://api.kodeco.com/api/progressions/97194622" } }, { @@ -117,12 +117,12 @@ "type": "contents" }, "links": { - "self": "http://api.raywenderlich.com/api/contents/5148647" + "self": "https://api.kodeco.com/api/contents/5148647" } } }, "links": { - "self": "http://api.raywenderlich.com/api/bookmarks/101222" + "self": "https://api.kodeco.com/api/bookmarks/101222" } }, { diff --git a/Emitron/emitronTests/Models/Mocks/Domain+Mocks.swift b/Emitron/emitronTests/Models/Mocks/Domain+Mocks.swift index 8e12b0c5..865557c4 100644 --- a/Emitron/emitronTests/Models/Mocks/Domain+Mocks.swift +++ b/Emitron/emitronTests/Models/Mocks/Domain+Mocks.swift @@ -32,14 +32,14 @@ import GRDB @testable import Emitron extension Domain { - static func loadAndSaveMocks(db: DatabaseWriter) throws { + static func loadAndSaveMocks(db: TestDatabase) throws { let domains = loadMocksFrom(filename: "Domains") try db.write { db in try domains.forEach { try $0.save(db) } } } - private static func loadMocksFrom(filename: String) -> ([Domain]) { + private static func loadMocksFrom(filename: String) -> [Domain] { do { let bundle = Bundle(for: AttachmentTest.self) let fileURL = bundle.url(forResource: filename, withExtension: "json") diff --git a/Emitron/emitronTests/Models/Mocks/ProgressionsModelTest.json b/Emitron/emitronTests/Models/Mocks/ProgressionsModelTest.json index 4f9bb304..e0b95c3b 100644 --- a/Emitron/emitronTests/Models/Mocks/ProgressionsModelTest.json +++ b/Emitron/emitronTests/Models/Mocks/ProgressionsModelTest.json @@ -18,12 +18,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/4279893" + "self": "https://api.kodeco.com/api/contents/4279893" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/progressions/70620502" + "self": "https://api.kodeco.com/api/progressions/70620502" } }, { @@ -44,12 +44,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3858252" + "self": "https://api.kodeco.com/api/contents/3858252" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/progressions/68900924" + "self": "https://api.kodeco.com/api/progressions/68900924" } }, { @@ -70,12 +70,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3401119" + "self": "https://api.kodeco.com/api/contents/3401119" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/progressions/59404885" + "self": "https://api.kodeco.com/api/progressions/59404885" } }, { @@ -96,12 +96,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/1940773" + "self": "https://api.kodeco.com/api/contents/1940773" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/progressions/59404731" + "self": "https://api.kodeco.com/api/progressions/59404731" } }, { @@ -122,12 +122,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/1940801" + "self": "https://api.kodeco.com/api/contents/1940801" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/progressions/59404730" + "self": "https://api.kodeco.com/api/progressions/59404730" } }, { @@ -148,12 +148,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3615" + "self": "https://api.kodeco.com/api/contents/3615" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/progressions/57457344" + "self": "https://api.kodeco.com/api/progressions/57457344" } }, { @@ -174,12 +174,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3570" + "self": "https://api.kodeco.com/api/contents/3570" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/progressions/57131058" + "self": "https://api.kodeco.com/api/progressions/57131058" } }, { @@ -200,12 +200,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3562" + "self": "https://api.kodeco.com/api/contents/3562" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/progressions/57128256" + "self": "https://api.kodeco.com/api/progressions/57128256" } }, { @@ -226,12 +226,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3564" + "self": "https://api.kodeco.com/api/contents/3564" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/progressions/57129227" + "self": "https://api.kodeco.com/api/progressions/57129227" } }, { @@ -252,12 +252,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3560" + "self": "https://api.kodeco.com/api/contents/3560" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/progressions/57127754" + "self": "https://api.kodeco.com/api/progressions/57127754" } }, { @@ -278,12 +278,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3558" + "self": "https://api.kodeco.com/api/contents/3558" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/progressions/57127293" + "self": "https://api.kodeco.com/api/progressions/57127293" } }, { @@ -304,12 +304,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3556" + "self": "https://api.kodeco.com/api/contents/3556" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/progressions/57126344" + "self": "https://api.kodeco.com/api/progressions/57126344" } }, { @@ -330,12 +330,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3554" + "self": "https://api.kodeco.com/api/contents/3554" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/progressions/57125772" + "self": "https://api.kodeco.com/api/progressions/57125772" } }, { @@ -356,12 +356,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3552" + "self": "https://api.kodeco.com/api/contents/3552" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/progressions/57125110" + "self": "https://api.kodeco.com/api/progressions/57125110" } }, { @@ -382,12 +382,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3634" + "self": "https://api.kodeco.com/api/contents/3634" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/progressions/57457312" + "self": "https://api.kodeco.com/api/progressions/57457312" } }, { @@ -408,12 +408,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3603" + "self": "https://api.kodeco.com/api/contents/3603" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/progressions/57457142" + "self": "https://api.kodeco.com/api/progressions/57457142" } }, { @@ -434,12 +434,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3599" + "self": "https://api.kodeco.com/api/contents/3599" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/progressions/57456592" + "self": "https://api.kodeco.com/api/progressions/57456592" } }, { @@ -460,12 +460,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3591" + "self": "https://api.kodeco.com/api/contents/3591" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/progressions/57455706" + "self": "https://api.kodeco.com/api/progressions/57455706" } }, { @@ -486,12 +486,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3588" + "self": "https://api.kodeco.com/api/contents/3588" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/progressions/57455341" + "self": "https://api.kodeco.com/api/progressions/57455341" } }, { @@ -512,12 +512,12 @@ "type":"contents" }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3585" + "self": "https://api.kodeco.com/api/contents/3585" } } }, "links":{ - "self":"http://api.raywenderlich.com/api/progressions/57454734" + "self": "https://api.kodeco.com/api/progressions/57454734" } } ], @@ -539,7 +539,7 @@ "technology_triple_string":"Swift 5.1, iOS 13.0 Beta, Xcode 11.0 Beta", "contributor_string":"Adriana Kutenko, Katie Collins \u0026 Josh Steele", "ordinal":null, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/videos/2906/b4432796-dac8-401e-8518-b47c44dc925b.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/videos/2906/b4432796-dac8-401e-8518-b47c44dc925b.png" }, "relationships":{ "domains":{ @@ -566,7 +566,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/4279893-swift-ui-working-with-uikit" + "self": "https://api.kodeco.com/api/contents/4279893-swift-ui-working-with-uikit" } }, { @@ -586,7 +586,7 @@ "technology_triple_string":"Swift 5, macOS 10.14, Xcode 10", "contributor_string":"Cesare Rocchi, Tim Condon, David Okun, Darren Ferguson, April Rames \u0026 Brian Schick", "ordinal":null, - "card_artwork_url":"https://koenig-media.raywenderlich.com/uploads/2019/07/HowToThink-feature.png" + "card_artwork_url":"https://koenig-media.kodeco.com/uploads/2019/07/HowToThink-feature.png" }, "relationships":{ "domains":{ @@ -613,7 +613,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3858252-how-to-think-in-server-side-swift" + "self": "https://api.kodeco.com/api/contents/3858252-how-to-think-in-server-side-swift" } }, { @@ -633,7 +633,7 @@ "technology_triple_string":"Swift 5, iOS 12, Xcode 10", "contributor_string":"Ray Fix, JORGE R. MOUKEL \u0026 Katie Collins", "ordinal":2, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/185/9094700a-2c30-4bf6-9640-33732f552c2f.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/185/9094700a-2c30-4bf6-9640-33732f552c2f.png" }, "relationships":{ "domains":{ @@ -667,7 +667,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3401119-advanced-swift-unsafe-memory-access-memory-sizing" + "self": "https://api.kodeco.com/api/contents/3401119-advanced-swift-unsafe-memory-access-memory-sizing" } }, { @@ -687,7 +687,7 @@ "technology_triple_string":"Swift 5, iOS 12, Xcode 10", "contributor_string":"Katie Collins, JORGE R. MOUKEL \u0026 Ray Fix", "ordinal":null, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/185/9094700a-2c30-4bf6-9640-33732f552c2f.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/185/9094700a-2c30-4bf6-9640-33732f552c2f.png" }, "relationships":{ "domains":{ @@ -721,7 +721,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/1940773-advanced-swift-unsafe-memory-access" + "self": "https://api.kodeco.com/api/contents/1940773-advanced-swift-unsafe-memory-access" } }, { @@ -741,7 +741,7 @@ "technology_triple_string":"Swift 5, iOS 12, Xcode 10", "contributor_string":"Ray Fix, JORGE R. MOUKEL \u0026 Katie Collins", "ordinal":1, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/185/9094700a-2c30-4bf6-9640-33732f552c2f.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/185/9094700a-2c30-4bf6-9640-33732f552c2f.png" }, "relationships":{ "domains":{ @@ -772,7 +772,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/1940801-advanced-swift-unsafe-memory-access-introduction" + "self": "https://api.kodeco.com/api/contents/1940801-advanced-swift-unsafe-memory-access-introduction" } }, { @@ -792,7 +792,7 @@ "technology_triple_string":"Swift 4, iOS 11, Xcode 9", "contributor_string":"Brian Moakley \u0026 Fahim Farook", "ordinal":22, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" }, "relationships":{ "domains":{ @@ -819,7 +819,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3615-testing-in-ios-introduction" + "self": "https://api.kodeco.com/api/contents/3615-testing-in-ios-introduction" } }, { @@ -839,7 +839,7 @@ "technology_triple_string":"Swift 4, iOS 11, Xcode 9", "contributor_string":"Brian Moakley \u0026 Fahim Farook", "ordinal":11, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" }, "relationships":{ "domains":{ @@ -866,7 +866,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3570-testing-in-ios-conclusion" + "self": "https://api.kodeco.com/api/contents/3570-testing-in-ios-conclusion" } }, { @@ -886,7 +886,7 @@ "technology_triple_string":"Swift 4, iOS 11, Xcode 9", "contributor_string":"Brian Moakley \u0026 Fahim Farook", "ordinal":7, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" }, "relationships":{ "domains":{ @@ -913,7 +913,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3562-testing-in-ios-fixing-your-second-test" + "self": "https://api.kodeco.com/api/contents/3562-testing-in-ios-fixing-your-second-test" } }, { @@ -933,7 +933,7 @@ "technology_triple_string":"Swift 4, iOS 11, Xcode 9", "contributor_string":"Brian Moakley \u0026 Fahim Farook", "ordinal":8, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" }, "relationships":{ "domains":{ @@ -960,7 +960,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3564-testing-in-ios-red-green-refactor" + "self": "https://api.kodeco.com/api/contents/3564-testing-in-ios-red-green-refactor" } }, { @@ -980,7 +980,7 @@ "technology_triple_string":"Swift 4, iOS 11, Xcode 9", "contributor_string":"Brian Moakley \u0026 Fahim Farook", "ordinal":6, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" }, "relationships":{ "domains":{ @@ -1007,7 +1007,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3560-testing-in-ios-challenge-writing-your-first-test" + "self": "https://api.kodeco.com/api/contents/3560-testing-in-ios-challenge-writing-your-first-test" } }, { @@ -1027,7 +1027,7 @@ "technology_triple_string":"Swift 4, iOS 11, Xcode 9", "contributor_string":"Brian Moakley \u0026 Fahim Farook", "ordinal":5, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" }, "relationships":{ "domains":{ @@ -1054,7 +1054,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3558-testing-in-ios-running-your-first-test" + "self": "https://api.kodeco.com/api/contents/3558-testing-in-ios-running-your-first-test" } }, { @@ -1074,7 +1074,7 @@ "technology_triple_string":"Swift 4, iOS 11, Xcode 9", "contributor_string":"Brian Moakley \u0026 Fahim Farook", "ordinal":4, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" }, "relationships":{ "domains":{ @@ -1101,7 +1101,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3556-testing-in-ios-test-case-structure" + "self": "https://api.kodeco.com/api/contents/3556-testing-in-ios-test-case-structure" } }, { @@ -1121,7 +1121,7 @@ "technology_triple_string":"Swift 4, iOS 11, Xcode 9", "contributor_string":"Brian Moakley \u0026 Fahim Farook", "ordinal":3, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" }, "relationships":{ "domains":{ @@ -1148,7 +1148,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3554-testing-in-ios-importing-modules" + "self": "https://api.kodeco.com/api/contents/3554-testing-in-ios-importing-modules" } }, { @@ -1168,7 +1168,7 @@ "technology_triple_string":"Swift 4, iOS 11, Xcode 9", "contributor_string":"Brian Moakley \u0026 Fahim Farook", "ordinal":2, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" }, "relationships":{ "domains":{ @@ -1195,7 +1195,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3552-testing-in-ios-getting-started" + "self": "https://api.kodeco.com/api/contents/3552-testing-in-ios-getting-started" } }, { @@ -1215,7 +1215,7 @@ "technology_triple_string":"Swift 4, iOS 11, Xcode 9", "contributor_string":"Brian Moakley \u0026 Fahim Farook", "ordinal":28, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" }, "relationships":{ "domains":{ @@ -1242,7 +1242,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3634-testing-in-ios-challenge-about-screen-test" + "self": "https://api.kodeco.com/api/contents/3634-testing-in-ios-challenge-about-screen-test" } }, { @@ -1262,7 +1262,7 @@ "technology_triple_string":"Swift 4, iOS 11, Xcode 9", "contributor_string":"Brian Moakley \u0026 Fahim Farook", "ordinal":21, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" }, "relationships":{ "domains":{ @@ -1289,7 +1289,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3603-testing-in-ios-conclusion" + "self": "https://api.kodeco.com/api/contents/3603-testing-in-ios-conclusion" } }, { @@ -1309,7 +1309,7 @@ "technology_triple_string":"Swift 4, iOS 11, Xcode 9", "contributor_string":"Brian Moakley \u0026 Fahim Farook", "ordinal":20, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" }, "relationships":{ "domains":{ @@ -1336,7 +1336,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3599-testing-in-ios-performance-testing" + "self": "https://api.kodeco.com/api/contents/3599-testing-in-ios-performance-testing" } }, { @@ -1356,7 +1356,7 @@ "technology_triple_string":"Swift 4, iOS 11, Xcode 9", "contributor_string":"Brian Moakley \u0026 Fahim Farook", "ordinal":18, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" }, "relationships":{ "domains":{ @@ -1383,7 +1383,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3591-testing-in-ios-code-coverage" + "self": "https://api.kodeco.com/api/contents/3591-testing-in-ios-code-coverage" } }, { @@ -1403,7 +1403,7 @@ "technology_triple_string":"Swift 4, iOS 11, Xcode 9", "contributor_string":"Brian Moakley \u0026 Fahim Farook", "ordinal":17, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" }, "relationships":{ "domains":{ @@ -1430,7 +1430,7 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3588-testing-in-ios-mocking-tests" + "self": "https://api.kodeco.com/api/contents/3588-testing-in-ios-mocking-tests" } }, { @@ -1450,7 +1450,7 @@ "technology_triple_string":"Swift 4, iOS 11, Xcode 9", "contributor_string":"Brian Moakley \u0026 Fahim Farook", "ordinal":16, - "card_artwork_url":"https://files.betamax.raywenderlich.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" + "card_artwork_url":"https://files.betamax.kodeco.com/attachments/collections/105/e3e29dcf-2eb3-4289-8114-dae0b0a8fca5.png" }, "relationships":{ "domains":{ @@ -1477,16 +1477,16 @@ } }, "links":{ - "self":"http://api.raywenderlich.com/api/contents/3585-testing-in-ios-mocking" + "self": "https://api.kodeco.com/api/contents/3585-testing-in-ios-mocking" } } ], "links":{ - "self":"https://api.raywenderlich.com/api/progressions?page%5Bnumber%5D=1\u0026page%5Bsize%5D=20", - "first":"https://api.raywenderlich.com/api/progressions?page%5Bnumber%5D=1\u0026page%5Bsize%5D=20", + "self":"https://api.kodeco.com/api/progressions?page%5Bnumber%5D=1\u0026page%5Bsize%5D=20", + "first":"https://api.kodeco.com/api/progressions?page%5Bnumber%5D=1\u0026page%5Bsize%5D=20", "prev":null, - "next":"https://api.raywenderlich.com/api/progressions?page%5Bnumber%5D=2\u0026page%5Bsize%5D=20", - "last":"https://api.raywenderlich.com/api/progressions?page%5Bnumber%5D=12\u0026page%5Bsize%5D=20" + "next":"https://api.kodeco.com/api/progressions?page%5Bnumber%5D=2\u0026page%5Bsize%5D=20", + "last":"https://api.kodeco.com/api/progressions?page%5Bnumber%5D=12\u0026page%5Bsize%5D=20" }, "meta":{ "total_result_count":232 diff --git a/Emitron/emitronTests/Models/UserTest.swift b/Emitron/emitronTests/Models/UserTest.swift index 05bb7702..9987e834 100644 --- a/Emitron/emitronTests/Models/UserTest.swift +++ b/Emitron/emitronTests/Models/UserTest.swift @@ -41,13 +41,12 @@ class UserTest: XCTestCase { "username": "sample_username", "avatar_url": "http://example.com/avatar.jpg", "name": "Sample Name", - "token": "Samaple.Token" + "token": "Sample.Token" ] func testUserCorrectlyPopulatesWithDictionary() { guard let user = User(dictionary: userDictionary) else { - XCTFail("User should be correctly populated") - return + return XCTFail("User should be correctly populated") } XCTAssertEqual(userDictionary["external_id"], user.externalID) diff --git a/Emitron/emitronTests/Networking/Adapters/EntityAdapters/ContentAdapterTest.swift b/Emitron/emitronTests/Networking/Adapters/EntityAdapters/ContentAdapterTest.swift index 936c0064..adb576a9 100644 --- a/Emitron/emitronTests/Networking/Adapters/EntityAdapters/ContentAdapterTest.swift +++ b/Emitron/emitronTests/Networking/Adapters/EntityAdapters/ContentAdapterTest.swift @@ -87,9 +87,9 @@ class ContentAdapterTest: XCTestCase { ] ], "links": [ - "self": "http://api.raywenderlich.com/api/contents/1320588-machine-learning-in-ios-introduction", - "video_stream": "http://api.raywenderlich.com/api/videos/2546/stream", - "video_download": "http://api.raywenderlich.com/api/videos/2546/download" + "self": "http://api.kodeco.com/api/contents/1320588-machine-learning-in-ios-introduction", + "video_stream": "http://api.kodeco.com/api/videos/2546/stream", + "video_download": "http://api.kodeco.com/api/videos/2546/download" ] ] diff --git a/Emitron/emitronTests/Persistence/EmitronDatabaseTest.swift b/Emitron/emitronTests/Persistence/EmitronDatabaseTest.swift index a8be3a87..fe97ac3f 100644 --- a/Emitron/emitronTests/Persistence/EmitronDatabaseTest.swift +++ b/Emitron/emitronTests/Persistence/EmitronDatabaseTest.swift @@ -29,16 +29,36 @@ import GRDB @testable import Emitron +typealias TestDatabase = DatabaseQueue + extension EmitronDatabase { - static func testDatabase() throws -> DatabaseWriter { - // In memory database - let dbQueue = DatabaseQueue() - // And migrate - try migrator.migrate(dbQueue) - // Load important mocks - try Emitron.Category.loadAndSaveMocks(db: dbQueue) - try Domain.loadAndSaveMocks(db: dbQueue) - - return dbQueue + static var test: TestDatabase { + get throws { + // In memory database + let dbQueue = DatabaseQueue() + // And migrate + try migrator.migrate(dbQueue) + // Load important mocks + try Emitron.Category.loadAndSaveMocks(db: dbQueue) + try Domain.loadAndSaveMocks(db: dbQueue) + + return dbQueue + } + } +} + +import class XCTest.XCTestCase + +protocol DatabaseTestCase: XCTestCase { + var database: TestDatabase! { get } +} + +extension DatabaseTestCase { + var allContents: [Content] { + get throws { try database.read(Content.fetchAll) } + } + + var allDownloads: [Download] { + get throws { try database.read(Download.fetchAll) } } } diff --git a/Emitron/emitronTests/Persistence/Models/ContentTest.swift b/Emitron/emitronTests/Persistence/Models/ContentTest.swift index 252d7716..810a9a8f 100644 --- a/Emitron/emitronTests/Persistence/Models/ContentTest.swift +++ b/Emitron/emitronTests/Persistence/Models/ContentTest.swift @@ -30,44 +30,27 @@ import XCTest import GRDB @testable import Emitron -class ContentTest: XCTestCase { - private var database: DatabaseWriter! +class ContentTest: XCTestCase, DatabaseTestCase { + private(set) var database: TestDatabase! - override func setUp() { - super.setUp() - // swiftlint:disable:next force_try - database = try! EmitronDatabase.testDatabase() - } - - func getAllContents() -> [Content] { - // swiftlint:disable:next force_try - try! database.read { db in - try Content.fetchAll(db) - } - } - - func getAllDownloads() -> [Download] { - // swiftlint:disable:next force_try - try! database.read { db in - try Download.fetchAll(db) - } + override func setUpWithError() throws { + try super.setUpWithError() + database = try EmitronDatabase.test } func testCanCreateContentWithoutADownload() throws { // Start with no content - XCTAssertEqual(0, getAllContents().count) + XCTAssert(try allContents.isEmpty) // Create contents let content = PersistenceMocks.content - try database.write { db in - try content.save(db) - } + try database.write(content.save) // Should have one item of content - XCTAssertEqual(1, getAllContents().count) + XCTAssertEqual(1, try allContents.count) // It should be the right one - XCTAssertEqual(content.uri, getAllContents().first!.uri) - XCTAssertEqual(content, getAllContents().first!) + XCTAssertEqual(content.uri, try allContents.first!.uri) + XCTAssertEqual(content, try allContents.first) } func testCanAssignContentToADownload() throws { @@ -82,20 +65,18 @@ class ContentTest: XCTestCase { } // Should have one item of content - XCTAssertEqual(1, getAllContents().count) + XCTAssertEqual(1, try allContents.count) // It should be the right one - XCTAssertEqual(content, getAllContents().first!) + XCTAssertEqual(content, try allContents.first) // There should be a single download - XCTAssertEqual(1, getAllDownloads().count) + XCTAssertEqual(1, try allDownloads.count) // It too should be the right one - XCTAssertEqual(download, getAllDownloads().first!) + XCTAssertEqual(download, try allDownloads.first) } func testDeletingTheContentDeletesTheDownload() throws { let content = PersistenceMocks.content - try database.write { db in - try content.save(db) - } + try database.write(content.save) var download = PersistenceMocks.download(for: content) try database.write { db in @@ -103,21 +84,21 @@ class ContentTest: XCTestCase { } // Should have one item of content - XCTAssertEqual(1, getAllContents().count) + XCTAssertEqual(1, try allContents.count) // It should be the right one - XCTAssertEqual(content, getAllContents().first!) + XCTAssertEqual(content, try allContents.first) // There should be a single download - XCTAssertEqual(1, getAllDownloads().count) + XCTAssertEqual(1, try allDownloads.count) // It too should be the right one - XCTAssertEqual(download, getAllDownloads().first!) + XCTAssertEqual(download, try allDownloads.first) _ = try database.write { db in try content.delete(db) } // Check it was deleted - XCTAssertEqual(0, getAllContents().count) + XCTAssertEqual(0, try allContents.count) // And that the download was deleted too - XCTAssertEqual(0, getAllDownloads().count) + XCTAssertEqual(0, try allDownloads.count) } } diff --git a/Emitron/emitronTests/Persistence/Models/DownloadTest.swift b/Emitron/emitronTests/Persistence/Models/DownloadTest.swift index 8cbefa17..86d0d723 100644 --- a/Emitron/emitronTests/Persistence/Models/DownloadTest.swift +++ b/Emitron/emitronTests/Persistence/Models/DownloadTest.swift @@ -30,27 +30,12 @@ import XCTest import GRDB @testable import Emitron -class DownloadTest: XCTestCase { - private var database: DatabaseWriter! +class DownloadTest: XCTestCase, DatabaseTestCase { + private(set) var database: TestDatabase! - override func setUp() { - super.setUp() - // swiftlint:disable:next force_try - database = try! EmitronDatabase.testDatabase() - } - - func getAllContents() -> [Content] { - // swiftlint:disable:next force_try - try! database.read { db in - try Content.fetchAll(db) - } - } - - func getAllDownloads() -> [Download] { - // swiftlint:disable:next force_try - try! database.read { db in - try Download.fetchAll(db) - } + override func setUpWithError() throws { + try super.setUpWithError() + database = try EmitronDatabase.test } func testDeletingDownloadDoesNotDeleteContents() throws { @@ -65,22 +50,20 @@ class DownloadTest: XCTestCase { } // Should have one item of content - XCTAssertEqual(1, getAllContents().count) + XCTAssertEqual(1, try allContents.count) // It should be the right one - XCTAssertEqual(content, getAllContents().first!) + XCTAssertEqual(content, try allContents.first) // There should be a single download - XCTAssertEqual(1, getAllDownloads().count) + XCTAssertEqual(1, try allDownloads.count) // It too should be the right one - XCTAssertEqual(download, getAllDownloads().first!) + XCTAssertEqual(download, try allDownloads.first) - _ = try database.write { db in - try download.delete(db) - } + _ = try database.write(download.delete) // Check it was deleted - XCTAssertEqual(0, getAllDownloads().count) + XCTAssertEqual(0, try allDownloads.count) // And that the contents was not deleted - XCTAssertEqual(1, getAllContents().count) - XCTAssertEqual(content, getAllContents().first!) + XCTAssertEqual(1, try allContents.count) + XCTAssertEqual(content, try allContents.first) } } diff --git a/Emitron/emitronTests/Persistence/Models/PersistenceMocks.swift b/Emitron/emitronTests/Persistence/Models/PersistenceMocks.swift index 87b5dfa0..553a4d53 100644 --- a/Emitron/emitronTests/Persistence/Models/PersistenceMocks.swift +++ b/Emitron/emitronTests/Persistence/Models/PersistenceMocks.swift @@ -53,7 +53,7 @@ enum PersistenceMocks { } @discardableResult static func download(for content: Content) -> Download { - Download( + .init( id: .init(), requestedAt: .now, state: .pending, diff --git a/Emitron/emitronTests/Persistence/PersistenceStore+DownloadsTest.swift b/Emitron/emitronTests/Persistence/PersistenceStore+DownloadsTest.swift index 2f57b94a..8cf47c02 100644 --- a/Emitron/emitronTests/Persistence/PersistenceStore+DownloadsTest.swift +++ b/Emitron/emitronTests/Persistence/PersistenceStore+DownloadsTest.swift @@ -30,400 +30,337 @@ import XCTest import GRDB @testable import Emitron -class PersistenceStore_DownloadsTest: XCTestCase { - private var database: DatabaseWriter! +class PersistenceStore_DownloadsTest: XCTestCase, DatabaseTestCase { + private(set) var database: TestDatabase! private var persistenceStore: PersistenceStore! - override func setUp() { - super.setUp() - // swiftlint:disable:next force_try - database = try! EmitronDatabase.testDatabase() - persistenceStore = PersistenceStore(db: database) + override func setUpWithError() throws { + try super.setUpWithError() + database = try EmitronDatabase.test + persistenceStore = .init(db: database) // Check it's all empty - XCTAssertEqual(0, getAllContents().count) - XCTAssertEqual(0, getAllDownloads().count) + XCTAssert(try allContents.isEmpty) + XCTAssert(try allDownloads.isEmpty) } - func getAllContents() -> [Content] { - // swiftlint:disable:next force_try - try! database.read { db in - try Content.fetchAll(db) - } - } - - func getAllDownloads() -> [Download] { - // swiftlint:disable:next force_try - try! database.read { db in - try Download.fetchAll(db) - } - } - - func populateSampleScreencast() throws -> Content { + func populateSampleScreencast() async throws -> Content { let screencast = ContentTest.Mocks.screencast - let fullState = ContentPersistableState.persistableState(for: screencast.0, with: screencast.1) - let recorder = persistenceStore.persistContentGraph(for: fullState) { contentID -> (ContentPersistableState?) in - ContentPersistableState.persistableState(for: contentID, with: screencast.1) + let fullState = ContentPersistableState(content: screencast.0, cacheUpdate: screencast.1) + try await persistenceStore.persistContentGraph(for: fullState) { contentID in + .init(contentID: contentID, cacheUpdate: screencast.1) } - .record() - - _ = try wait(for: recorder.completion, timeout: 10) return screencast.0 } - func populateSampleCollection() throws -> Content { + func populateSampleCollection() async throws -> Content { let collection = ContentTest.Mocks.collection - let fullState = ContentPersistableState.persistableState(for: collection.0, with: collection.1) - let recorder = persistenceStore.persistContentGraph(for: fullState) { contentID -> (ContentPersistableState?) in - ContentPersistableState.persistableState(for: contentID, with: collection.1) + let fullState = ContentPersistableState(content: collection.0, cacheUpdate: collection.1) + try await persistenceStore.persistContentGraph(for: fullState) { contentID in + .init(contentID: contentID, cacheUpdate: collection.1) } - .record() - - _ = try wait(for: recorder.completion, timeout: 10) - return collection.0 } // MARK: - Download Transitions - func testTransitionEpisodeToInProgressUpdatesCollection() throws { - let collection = try populateSampleCollection() - let episode = getAllContents().first { $0.id != collection.id } + func testTransitionEpisodeToInProgressUpdatesCollection() async throws { + let collection = try await populateSampleCollection() + let episode = try allContents.first { $0.id != collection.id } var collectionDownload = PersistenceMocks.download(for: collection) var episodeDownload = PersistenceMocks.download(for: episode!) - try database.write { db in - try collectionDownload.save(db) - try episodeDownload.save(db) + (collectionDownload, episodeDownload) = try await database.write { [collectionDownload, episodeDownload] db in + try (collectionDownload.saved(db), episodeDownload.saved(db)) } - try persistenceStore.transitionDownload(withID: episodeDownload.id, to: .inProgress) - - let collectionExpectation = XCTestExpectation() - - persistenceStore.workerQueue.async { - let updatedCollectionDownload = try! self.database.read { db in // swiftlint:disable:this force_try - try! Download.filter(key: collectionDownload.id).fetchOne(db) // swiftlint:disable:this force_try - } - - XCTAssertEqual(.inProgress, updatedCollectionDownload?.state) - XCTAssertEqual(0, updatedCollectionDownload?.progress) - - collectionExpectation.fulfill() - } - - wait(for: [collectionExpectation], timeout: 15) + try await persistenceStore.transitionDownload(withID: episodeDownload.id, to: .inProgress) + + let updatedCollectionDownload = try await database.read { [key = collectionDownload.id] db in + try Download.filter(key: key).fetchOne(db) + }.unwrapped + XCTAssertEqual(updatedCollectionDownload.state, .inProgress) + XCTAssertEqual(updatedCollectionDownload.progress, 0) } - func testTransitionEpisodeToDownloadedUpdatesCollection() throws { - let collection = try populateSampleCollection() - let episodes = getAllContents().filter { $0.id != collection.id } + func testTransitionEpisodeToDownloadedUpdatesCollection() async throws { + let collection = try await populateSampleCollection() + let episodes = try allContents.filter { $0.id != collection.id } var collectionDownload = PersistenceMocks.download(for: collection) var episodeDownload = PersistenceMocks.download(for: episodes[0]) var episodeDownload2 = PersistenceMocks.download(for: episodes[1]) - - try database.write { db in - try collectionDownload.save(db) - try episodeDownload.save(db) - try episodeDownload2.save(db) - } - - try persistenceStore.transitionDownload(withID: episodeDownload.id, to: .inProgress) - try persistenceStore.transitionDownload(withID: episodeDownload2.id, to: .complete) - - let collectionExpectation = XCTestExpectation() - - persistenceStore.workerQueue.async { - let updatedCollectionDownload = try! self.database.read { db in // swiftlint:disable:this force_try - try! Download.filter(key: collectionDownload.id).fetchOne(db) // swiftlint:disable:this force_try - } - - XCTAssertEqual(.inProgress, updatedCollectionDownload?.state) - XCTAssertEqual(0.5, updatedCollectionDownload?.progress) - - collectionExpectation.fulfill() + + (collectionDownload, episodeDownload, episodeDownload2) = try await database.write { + [collectionDownload, episodeDownload, episodeDownload2] db in + try (collectionDownload.saved(db), episodeDownload.saved(db), episodeDownload2.saved(db)) } - wait(for: [collectionExpectation], timeout: 10) + try await persistenceStore.transitionDownload(withID: episodeDownload.id, to: .inProgress) + try await persistenceStore.transitionDownload(withID: episodeDownload2.id, to: .complete) + + let updatedCollectionDownload = try await database.read { [key = collectionDownload.id] db in + try Download.filter(key: key).fetchOne(db) + }.unwrapped + XCTAssertEqual(.inProgress, updatedCollectionDownload.state) + XCTAssertEqual(0.5, updatedCollectionDownload.progress) } - func testTransitionFinalEpisdeToDownloadedUpdatesCollection() throws { - let collection = try populateSampleCollection() - let episodes = getAllContents().filter { $0.id != collection.id } + func testTransitionFinalEpisodeToDownloadedUpdatesCollection() async throws { + let collection = try await populateSampleCollection() + let episodes = try allContents.filter { $0.id != collection.id } var collectionDownload = PersistenceMocks.download(for: collection) let episodeDownloads = episodes.map(PersistenceMocks.download) - try database.write { db in - try collectionDownload.save(db) + collectionDownload = try await database.write { [collectionDownload] db in + try collectionDownload.saved(db) } - try database.write { db in + try await database.write { db in try episodeDownloads.forEach { download in var mutableDownload = download try mutableDownload.save(db) } } - - try episodeDownloads.forEach { - try persistenceStore.transitionDownload(withID: $0.id, to: .complete) - } - - let collectionExpectation = XCTestExpectation() - - persistenceStore.workerQueue.async { - let updatedCollectionDownload = try! self.database.read { db in // swiftlint:disable:this force_try - try! Download.filter(key: collectionDownload.id).fetchOne(db) // swiftlint:disable:this force_try - } - - XCTAssertEqual(.complete, updatedCollectionDownload?.state) - XCTAssertEqual(1, updatedCollectionDownload?.progress) - - collectionExpectation.fulfill() + + for episode in episodeDownloads { + try await persistenceStore.transitionDownload(withID: episode.id, to: .complete) } - - wait(for: [collectionExpectation], timeout: 10) + + let updatedCollectionDownload = try await database.read { [key = collectionDownload.id] db in + try Download.filter(key: key).fetchOne(db) + }.unwrapped + + XCTAssertEqual(updatedCollectionDownload.state, .complete) + XCTAssertEqual(updatedCollectionDownload.progress, 1) } - func testTransitionNonFinalEpisodeToDownloadedUpdatesCollection() throws { - let collection = try populateSampleCollection() - let episodes = getAllContents().filter { $0.id != collection.id } + func testTransitionNonFinalEpisodeToDownloadedUpdatesCollection() async throws { + let collection = try await populateSampleCollection() + let episodes = try allContents.filter { $0.id != collection.id } var collectionDownload = PersistenceMocks.download(for: collection) var episodeDownload = PersistenceMocks.download(for: episodes[0]) var episodeDownload2 = PersistenceMocks.download(for: episodes[1]) - - try database.write { db in - try collectionDownload.save(db) - try episodeDownload.save(db) - try episodeDownload2.save(db) - } - - try persistenceStore.transitionDownload(withID: episodeDownload.id, to: .complete) - try persistenceStore.transitionDownload(withID: episodeDownload2.id, to: .complete) - - let collectionExpectation = XCTestExpectation() - - persistenceStore.workerQueue.async { - let updatedCollectionDownload = try! self.database.read { db in // swiftlint:disable:this force_try - try! Download.filter(key: collectionDownload.id).fetchOne(db) // swiftlint:disable:this force_try - } - - XCTAssertEqual(.paused, updatedCollectionDownload?.state) - XCTAssertEqual(1, updatedCollectionDownload?.progress) - - collectionExpectation.fulfill() + + (collectionDownload, episodeDownload, episodeDownload2) = try await database.write { [collectionDownload, episodeDownload, episodeDownload2] db in + try (collectionDownload.saved(db), episodeDownload.saved(db), episodeDownload2.saved(db)) } - wait(for: [collectionExpectation], timeout: 10) + try await persistenceStore.transitionDownload(withID: episodeDownload.id, to: .complete) + try await persistenceStore.transitionDownload(withID: episodeDownload2.id, to: .complete) + + let updatedCollectionDownload = try await database.read { [key = collectionDownload.id] db in + try Download.filter(key: key).fetchOne(db) + }.unwrapped + + XCTAssertEqual(updatedCollectionDownload.state, .paused) + XCTAssertEqual(updatedCollectionDownload.progress, 1) } // MARK: - Collection Download Utilities - func testCollectionDownloadSummaryWorksForInProgress() throws { - let collection = try populateSampleCollection() - let episodes = getAllContents().filter { $0.id != collection.id } - - PersistenceMocks.download(for: collection) + func testCollectionDownloadSummaryWorksForInProgress() async throws { + let collection = try await populateSampleCollection() + let episodes = try allContents.filter { $0.id != collection.id } + let episodeDownloads = episodes.map(PersistenceMocks.download) - try database.write { db in + try await database.write { db in try episodeDownloads.forEach { download in var mutableDownload = download try mutableDownload.save(db) } } - - try episodeDownloads[0..<5].forEach { - try persistenceStore.transitionDownload(withID: $0.id, to: .complete) + + for episodeDownload in episodeDownloads[0..<5] { + try await persistenceStore.transitionDownload(withID: episodeDownload.id, to: .complete) } - let collectionDownloadSummary = try persistenceStore.collectionDownloadSummary(forContentID: collection.id) - + let summary = try await persistenceStore.collectionDownloadSummary(forContentID: collection.id) XCTAssertEqual( - PersistenceStore.CollectionDownloadSummary( + summary, + .init( totalChildren: episodes.count, childrenRequested: episodes.count, - childrenCompleted: 5), - collectionDownloadSummary) + childrenCompleted: 5 + ) + ) } - func testCollectionDownloadSummaryWorksForPartialRequest() throws { - let collection = try populateSampleCollection() - let episodes = getAllContents().filter { $0.id != collection.id } + func testCollectionDownloadSummaryWorksForPartialRequest() async throws { + let collection = try await populateSampleCollection() + let episodes = try allContents.filter { $0.id != collection.id } PersistenceMocks.download(for: collection) let episodeDownloads = episodes[0..<10].map(PersistenceMocks.download) - try database.write { db in + try await database.write { db in try episodeDownloads.forEach { download in var mutableDownload = download try mutableDownload.save(db) } } - try episodeDownloads[0..<5].forEach { - try persistenceStore.transitionDownload(withID: $0.id, to: .complete) + for episodeDownload in episodeDownloads[0..<5] { + try await persistenceStore.transitionDownload( + withID: episodeDownload.id, + to: .complete + ) } - let collectionDownloadSummary = try persistenceStore.collectionDownloadSummary(forContentID: collection.id) - + let summary = try await persistenceStore.collectionDownloadSummary(forContentID: collection.id) XCTAssertEqual( - PersistenceStore.CollectionDownloadSummary( + summary, + .init( totalChildren: episodes.count, childrenRequested: 10, - childrenCompleted: 5), - collectionDownloadSummary) + childrenCompleted: 5 + ) + ) } - func testCollectionDownloadSummaryWorksForCompletedPartialRequest() throws { - let collection = try populateSampleCollection() - let episodes = getAllContents().filter { $0.id != collection.id } + func testCollectionDownloadSummaryWorksForCompletedPartialRequest() async throws { + let collection = try await populateSampleCollection() + let episodes = try allContents.filter { $0.id != collection.id } PersistenceMocks.download(for: collection) let episodeDownloads = episodes[0..<10].map(PersistenceMocks.download) - try database.write { db in + try await database.write { db in try episodeDownloads.forEach { download in var mutableDownload = download try mutableDownload.save(db) } } - try episodeDownloads.forEach { - try persistenceStore.transitionDownload(withID: $0.id, to: .complete) + for episodeDownload in episodeDownloads { + try await persistenceStore.transitionDownload( + withID: episodeDownload.id, + to: .complete + ) } - - let collectionDownloadSummary = try persistenceStore.collectionDownloadSummary(forContentID: collection.id) - + + let summary = try await persistenceStore.collectionDownloadSummary(forContentID: collection.id) XCTAssertEqual( - PersistenceStore.CollectionDownloadSummary( + summary, + .init( totalChildren: episodes.count, childrenRequested: 10, - childrenCompleted: 10), - collectionDownloadSummary) + childrenCompleted: 10 + ) + ) } - func testCollectionDownloadSummaryWorksForCompletedEntireRequest() throws { - let collection = try populateSampleCollection() - let episodes = getAllContents().filter { $0.id != collection.id } + func testCollectionDownloadSummaryWorksForCompletedEntireRequest() async throws { + let collection = try await populateSampleCollection() + let episodes = try allContents.filter { $0.id != collection.id } PersistenceMocks.download(for: collection) let episodeDownloads = episodes.map(PersistenceMocks.download) - try database.write { db in + try await database.write { db in try episodeDownloads.forEach { download in var mutableDownload = download try mutableDownload.save(db) } } - try episodeDownloads.forEach { - try persistenceStore.transitionDownload(withID: $0.id, to: .complete) + for episodeDownload in episodeDownloads { + try await persistenceStore.transitionDownload( + withID: episodeDownload.id, + to: .complete + ) } - - let collectionDownloadSummary = try persistenceStore.collectionDownloadSummary(forContentID: collection.id) - + + let summary = try await persistenceStore.collectionDownloadSummary(forContentID: collection.id) XCTAssertEqual( - PersistenceStore.CollectionDownloadSummary( + summary, + .init( totalChildren: episodes.count, childrenRequested: episodes.count, childrenCompleted: episodes.count - ), - collectionDownloadSummary + ) ) } - func testCollectionDownloadSummaryThrowsForNonCollection() throws { - let screencast = try populateSampleScreencast() + func testCollectionDownloadSummaryThrowsForNonCollection() async throws { + let screencast = try await populateSampleScreencast() var download = PersistenceMocks.download(for: screencast) - try database.write { db in - try download.save(db) + download = try await database.write { [download] db in + try download.saved(db) } - - XCTAssertThrowsError(try persistenceStore.collectionDownloadSummary(forContentID: screencast.id)) { error in - XCTAssertEqual(.argumentError, error as! PersistenceStoreError) + + do { + _ = try await persistenceStore.collectionDownloadSummary(forContentID: screencast.id) + XCTFail() + } catch { + guard case PersistenceStore.Error.argumentError = error + else { return XCTFail() } } } // MARK: - Creating Downloads - private func createDownloads(for content: Content) throws { - let recorder = persistenceStore.createDownloads(for: content).record() - - let completion = try wait(for: recorder.completion, timeout: 10) - if case .failure = completion { - XCTFail("Failed to create downloads") - } + private func createDownloads(for content: Content) async throws { + try await persistenceStore.createDownloads(for: content) } - func testCreateDownloadsCreatesSingleDownloadForScreencast() throws { - let screencast = try populateSampleScreencast() - - XCTAssertEqual(0, getAllDownloads().count) - - try createDownloads(for: screencast) - - XCTAssertEqual(1, getAllDownloads().count) + func testCreateDownloadsCreatesSingleDownloadForScreencast() async throws { + let screencast = try await populateSampleScreencast() + XCTAssert(try allDownloads.isEmpty) + try await createDownloads(for: screencast) + XCTAssertEqual(1, try allDownloads.count) } - func testCreateDownloadsCreatesTwoDownloadsForEpisode() throws { - let collection = try populateSampleCollection() - let episodes = getAllContents().filter { $0.id != collection.id } - - XCTAssertEqual(0, getAllDownloads().count) + func testCreateDownloadsCreatesTwoDownloadsForEpisode() async throws { + let collection = try await populateSampleCollection() + let episodes = try allContents.filter { $0.id != collection.id } - try createDownloads(for: episodes.first!) - - XCTAssertEqual(2, getAllDownloads().count) + XCTAssert(try allDownloads.isEmpty) + try await createDownloads(for: XCTUnwrap(episodes.first)) + XCTAssertEqual(2, try allDownloads.count) } - func testCreateDownloadsCreatesOneAdditionalDownloadForEpisodeInPartiallyDownloadedCollection() throws { - let collection = try populateSampleCollection() - let episodes = getAllContents().filter { $0.id != collection.id } - - XCTAssertEqual(0, getAllDownloads().count) - - try createDownloads(for: episodes.first!) - - XCTAssertEqual(2, getAllDownloads().count) - - try createDownloads(for: episodes[2]) - - XCTAssertEqual(3, getAllDownloads().count) + func testCreateDownloadsCreatesOneAdditionalDownloadForEpisodeInPartiallyDownloadedCollection() async throws { + let collection = try await populateSampleCollection() + let episodes = try allContents.filter { $0.id != collection.id } + + XCTAssert(try allDownloads.isEmpty) + try await createDownloads(for: XCTUnwrap(episodes.first)) + XCTAssertEqual(2, try allDownloads.count) + try await createDownloads(for: episodes[2]) + XCTAssertEqual(3, try allDownloads.count) } - func testCreateDownloadsForExistingDownloadMakesNoChange() throws { - let collection = try populateSampleCollection() - let episodes = getAllContents().filter { $0.id != collection.id } - - XCTAssertEqual(0, getAllDownloads().count) - - try createDownloads(for: episodes.first!) - - XCTAssertEqual(2, getAllDownloads().count) - - try createDownloads(for: episodes.first!) - - XCTAssertEqual(2, getAllDownloads().count) + func testCreateDownloadsForExistingDownloadMakesNoChange() async throws { + let collection = try await populateSampleCollection() + let episodes = try allContents.filter { $0.id != collection.id } + + XCTAssert(try allDownloads.isEmpty) + let episode = try XCTUnwrap(episodes.first) + try await createDownloads(for: episode) + XCTAssertEqual(try allDownloads.count, 2) + try await createDownloads(for: episode) + XCTAssertEqual(try allDownloads.count, 2) } - func testCreateDownloadsForCollectionCreateManyDownloads() throws { - let collection = try populateSampleCollection() + func testCreateDownloadsForCollectionCreateManyDownloads() async throws { + let collection = try await populateSampleCollection() - XCTAssertEqual(0, getAllDownloads().count) + XCTAssert(try allDownloads.isEmpty) - try createDownloads(for: collection) + try await createDownloads(for: collection) - XCTAssertEqual(getAllContents().count, getAllDownloads().count) - XCTAssertGreaterThan(getAllContents().count, 0) + XCTAssertEqual(try allContents.count, try allDownloads.count) + XCTAssertFalse(try allContents.isEmpty) } // MARK: - Queue management - func testDownloadListDoesNotContainEpisodes() throws { - let collection = try populateSampleCollection() - try createDownloads(for: collection) + func testDownloadListDoesNotContainEpisodes() async throws { + let collection = try await populateSampleCollection() + try await createDownloads(for: collection) let recorder = persistenceStore.downloadList().record() @@ -432,49 +369,49 @@ class PersistenceStore_DownloadsTest: XCTestCase { XCTAssertNotNil(list) XCTAssertEqual(1, list.count) - XCTAssertEqual([], list.filter { $0.contentType == .episode }) + XCTAssert(list.filter { $0.contentType == .episode }.isEmpty) } - func testDownloadsInStateDoesNotContainCollections() throws { - let collection = try populateSampleCollection() - try createDownloads(for: collection) + func testDownloadsInStateDoesNotContainCollections() async throws { + let collection = try await populateSampleCollection() + try await createDownloads(for: collection) let recorder = persistenceStore.downloads(in: .inProgress).record() - let downloads = getAllDownloads().sorted { $0.requestedAt < $1.requestedAt } - let episodes = getAllContents().filter { $0.contentType == .episode } - try downloads.forEach { download in - try persistenceStore.transitionDownload(withID: download.id, to: .inProgress) + let downloads = try allDownloads.sorted { $0.requestedAt < $1.requestedAt } + let episodes = try allContents.filter { $0.contentType == .episode } + for download in downloads { + try await persistenceStore.transitionDownload(withID: download.id, to: .inProgress) } - try downloads.forEach { download in - try persistenceStore.transitionDownload(withID: download.id, to: .complete) + for download in downloads { + try await persistenceStore.transitionDownload(withID: download.id, to: .complete) } // Will start with a nil let inProgressQueue = try wait(for: recorder.next(episodes.count + 1), timeout: 10) - XCTAssertEqual(0, inProgressQueue.filter { $0?.content.contentType == .collection }.count) + XCTAssert(inProgressQueue.filter { $0?.content.contentType == .collection }.isEmpty) XCTAssertEqual( episodes.map(\.id).sorted(), inProgressQueue.compactMap { $0?.content.id }.sorted() ) } - func testDownloadQueueDoesNotContainCollections() throws { - let collection = try populateSampleCollection() - try createDownloads(for: collection) + func testDownloadQueueDoesNotContainCollections() async throws { + let collection = try await populateSampleCollection() + try await createDownloads(for: collection) let recorder = persistenceStore.downloadQueue(withMaxLength: 4).record() - let episodes = getAllContents().filter({ $0.contentType == .episode }) + let episodes = try allContents.filter({ $0.contentType == .episode }) let episodeIDs = episodes.map(\.id) - let collectionDownload = getAllDownloads().first { !episodeIDs.contains($0.contentID) } - let episodeDownloads = getAllDownloads().filter { episodeIDs.contains($0.contentID) } + let collectionDownload = try allDownloads.first { !episodeIDs.contains($0.contentID) } + let episodeDownloads = try allDownloads.filter { episodeIDs.contains($0.contentID) } - try persistenceStore.transitionDownload(withID: episodeDownloads[1].id, to: .inProgress) - try persistenceStore.transitionDownload(withID: collectionDownload!.id, to: .inProgress) - try persistenceStore.transitionDownload(withID: episodeDownloads[0].id, to: .inProgress) + try await persistenceStore.transitionDownload(withID: episodeDownloads[1].id, to: .inProgress) + try await persistenceStore.transitionDownload(withID: collectionDownload!.id, to: .inProgress) + try await persistenceStore.transitionDownload(withID: episodeDownloads[0].id, to: .inProgress) let downloadQueue = try wait(for: recorder.next(3), timeout: 10) @@ -484,24 +421,24 @@ class PersistenceStore_DownloadsTest: XCTestCase { XCTAssertEqual([episodeDownloads[0].id, episodeDownloads[1].id], downloadQueue[2].map(\.download.id)) } - func testDownloadQueueReturnsCorrectNumberOfItems() throws { - let collection = try populateSampleCollection() - try createDownloads(for: collection) + func testDownloadQueueReturnsCorrectNumberOfItems() async throws { + let collection = try await populateSampleCollection() + try await createDownloads(for: collection) let recorder = persistenceStore.downloadQueue(withMaxLength: 4).record() - let episodes = getAllContents().filter({ $0.contentType == .episode }) + let episodes = try allContents.filter({ $0.contentType == .episode }) let episodeIDs = episodes.map(\.id) - let collectionDownload = getAllDownloads().first { !episodeIDs.contains($0.contentID) } - let episodeDownloads = getAllDownloads().filter { episodeIDs.contains($0.contentID) } + let collectionDownload = try allDownloads.first { !episodeIDs.contains($0.contentID) } + let episodeDownloads = try allDownloads.filter { episodeIDs.contains($0.contentID) } - try persistenceStore.transitionDownload(withID: episodeDownloads[1].id, to: .inProgress) - try persistenceStore.transitionDownload(withID: collectionDownload!.id, to: .inProgress) - try persistenceStore.transitionDownload(withID: episodeDownloads[0].id, to: .inProgress) - try persistenceStore.transitionDownload(withID: episodeDownloads[5].id, to: .inProgress) - try persistenceStore.transitionDownload(withID: episodeDownloads[4].id, to: .inProgress) - try persistenceStore.transitionDownload(withID: episodeDownloads[3].id, to: .inProgress) - try persistenceStore.transitionDownload(withID: episodeDownloads[2].id, to: .inProgress) + try await persistenceStore.transitionDownload(withID: episodeDownloads[1].id, to: .inProgress) + try await persistenceStore.transitionDownload(withID: collectionDownload!.id, to: .inProgress) + try await persistenceStore.transitionDownload(withID: episodeDownloads[0].id, to: .inProgress) + try await persistenceStore.transitionDownload(withID: episodeDownloads[5].id, to: .inProgress) + try await persistenceStore.transitionDownload(withID: episodeDownloads[4].id, to: .inProgress) + try await persistenceStore.transitionDownload(withID: episodeDownloads[3].id, to: .inProgress) + try await persistenceStore.transitionDownload(withID: episodeDownloads[2].id, to: .inProgress) let downloadQueue = try wait(for: recorder.next(7), timeout: 10) @@ -515,12 +452,12 @@ class PersistenceStore_DownloadsTest: XCTestCase { XCTAssertEqual([0, 1, 2, 3].map { episodeDownloads[$0].id }, downloadQueue[6].map(\.download.id)) } - func testDownloadWithIDReturnsCorrectDownload() throws { - let screencast = try populateSampleScreencast() + func testDownloadWithIDReturnsCorrectDownload() async throws { + let screencast = try await populateSampleScreencast() var download = PersistenceMocks.download(for: screencast) - try database.write { db in - try download.save(db) + download = try await database.write { [download] db in + try download.saved(db) } XCTAssertEqual(download, try persistenceStore.download(withID: download.id)) @@ -530,19 +467,19 @@ class PersistenceStore_DownloadsTest: XCTestCase { XCTAssertNil(try persistenceStore.download(withID: UUID())) } - func testDownloadForContentIDReturnsCorrectDownload() throws { - let screencast = try populateSampleScreencast() + func testDownloadForContentIDReturnsCorrectDownload() async throws { + let screencast = try await populateSampleScreencast() var download = PersistenceMocks.download(for: screencast) - try database.write { db in - try download.save(db) + download = try await database.write { [download] db in + try download.saved(db) } XCTAssertEqual(download, try persistenceStore.download(forContentID: screencast.id)) } - func testDownloadForContentIDReturnsNilForNoDownload() throws { - let screencast = try populateSampleScreencast() + func testDownloadForContentIDReturnsNilForNoDownload() async throws { + let screencast = try await populateSampleScreencast() XCTAssertNil(try persistenceStore.download(forContentID: screencast.id)) } diff --git a/Emitron/emitronTests/Persistence/PersistenceStore+UserKeychainTest.swift b/Emitron/emitronTests/Persistence/PersistenceStore+UserKeychainTest.swift index a4abdc34..ec55852c 100644 --- a/Emitron/emitronTests/Persistence/PersistenceStore+UserKeychainTest.swift +++ b/Emitron/emitronTests/Persistence/PersistenceStore+UserKeychainTest.swift @@ -38,28 +38,26 @@ class PersistenceStore_UserKeychainTest: XCTestCase { "username": "sample_username", "avatar_url": "http://example.com/avatar.jpg", "name": "Sample Name", - "token": "Samaple.Token" + "token": "Sample.Token" ] - override func setUp() { - super.setUp() - // swiftlint:disable:next force_try - let database = try! EmitronDatabase.testDatabase() - persistenceStore = PersistenceStore(db: database) + override func setUpWithError() throws { + try super.setUpWithError() + persistenceStore = PersistenceStore(db: try EmitronDatabase.test) } override func tearDown() { super.tearDown() // Put teardown code here. This method is called after the invocation of each test method in the class. - persistenceStore.removeUserFromKeychain() + try? persistenceStore.removeUserFromKeychain() } - func testPersistenceToKeychain() { + func testPersistenceToKeychain() throws { guard let user = User(dictionary: userDictionary) else { return XCTFail("User not found") } - XCTAssert(persistenceStore.persistUserToKeychain(user: user)) + try persistenceStore.persistUserToKeychain(user: user) guard let restoredUser = persistenceStore.userFromKeychain() else { return XCTFail("Unable to restore user from Keychain") @@ -68,18 +66,16 @@ class PersistenceStore_UserKeychainTest: XCTestCase { XCTAssertEqual(user, restoredUser) } - func testRemovalOfUserFromKeychain() { + func testRemovalOfUserFromKeychain() throws { XCTAssertNil(persistenceStore.userFromKeychain()) guard let user = User(dictionary: userDictionary) else { return XCTFail("User not found") } - XCTAssert(persistenceStore.persistUserToKeychain(user: user)) + try persistenceStore.persistUserToKeychain(user: user) XCTAssertNotNil(persistenceStore.userFromKeychain()) - - XCTAssert(persistenceStore.removeUserFromKeychain()) - + try persistenceStore.removeUserFromKeychain() XCTAssertNil(persistenceStore.userFromKeychain()) } } diff --git a/Emitron/emitronTests/Protocols/RefreshableTestCase.swift b/Emitron/emitronTests/Protocols/RefreshableTestCase.swift index f1673226..14b91036 100644 --- a/Emitron/emitronTests/Protocols/RefreshableTestCase.swift +++ b/Emitron/emitronTests/Protocols/RefreshableTestCase.swift @@ -34,11 +34,11 @@ final class RefreshableTestCase: XCTestCase { XCTAssertEqual( DomainRepository( repository: .init( - persistenceStore: .init( db: try EmitronDatabase.testDatabase() ), + persistenceStore: .init(db: try EmitronDatabase.test), dataCache: .init() ), service: .init( - client: .init( authToken: .init() ) + networkClient: .init(authToken: .init()) ) ).refreshableUserDefaultsKey, "UserDefaultsRefreshableDomainRepository" diff --git a/Emitron/emitronTests/Services/Mock/VideosServiceMock.swift b/Emitron/emitronTests/Services/Mock/VideosServiceMock.swift index ae8f9828..53dd3e02 100644 --- a/Emitron/emitronTests/Services/Mock/VideosServiceMock.swift +++ b/Emitron/emitronTests/Services/Mock/VideosServiceMock.swift @@ -28,27 +28,39 @@ @testable import Emitron -class VideosServiceMock: VideosService { +import class Foundation.URLSession + +final class VideosServiceMock: Service { + init() { + networkClient = .init(authToken: .init()) + } + + let networkClient: RWAPI + let session = URLSession(configuration: .default) + private(set) var videoRequestedCount = 0 private(set) var getVideoStreamCount = 0 private(set) var getVideoDownloadCount = 0 - - init() { - super.init(client: RWAPI(authToken: "")) - } - +} + +// MARK: - internal +extension VideosServiceMock { func reset() { videoRequestedCount = 0 getVideoStreamCount = 0 getVideoDownloadCount = 0 } - - override func getVideoStream(for id: Int, completion: @escaping (Result) -> Void) { +} + +// MARK: - VideosServiceProtocol +extension VideosServiceMock: VideosServiceProtocol { + func videoStream(for id: Int) async throws -> StreamVideoRequest.Response { getVideoStreamCount += 1 + return AttachmentTest.Mocks.stream.0 } - - override func getVideoStreamDownload(for id: Int, completion: @escaping (Result) -> Void) { + + func videoStreamDownload(for id: Int) async throws -> StreamVideoRequest.Response { getVideoDownloadCount += 1 - completion(.success(AttachmentTest.Mocks.download.0)) + return AttachmentTest.Mocks.download.0 } } diff --git a/README.md b/README.md index 02d996ce..20758833 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # emitron (iOS) -__emitron__ is the code name for the raywenderlich.com app. This repo contains the code for the iOS version of the app. +__emitron__ is the code name for the kodeco.com app. This repo contains the code for the iOS version of the app. ## Contributing @@ -17,13 +17,13 @@ There is more info about contributing in [CONTRIBUTING.md](CONTRIBUTING.md). __emitron__ runs on iOS 13.3 and greater. It uses SwiftUI and Combine extensively; and since these two technologies were very new at the time of creation, there are plenty of places in the code that could benefit from some refactoring. -Currently, only people that hold an active raywenderlich.com subscription may use emitron. Non-subscribers will be shown a "no access" page on login. Subscribers have access to streaming videos, and a subset of subscribers (ones with a "Professional" subscription) is allowed to download videos for offline playback. +Currently, only people that hold an active kodeco.com subscription may use emitron. Non-subscribers will be shown a "no access" page on login. Subscribers have access to streaming videos, and a subset of subscribers (ones with a "Professional" subscription) is allowed to download videos for offline playback. ### Secrets Management __emitron__ requires 2 secrets: -- `SSO_SECRET`. This is used to ensure secure communication with `guardpost`, the raywenderlich.com authentication service. Although this is secret, a sample secret is provided inside this repo. This shouldn't be used to create a beta or production build. +- `SSO_SECRET`. This is used to ensure secure communication with `guardpost`, the kodeco.com authentication service. Although this is secret, a sample secret is provided inside this repo. This shouldn't be used to create a beta or production build. - `APP_TOKEN`. Required in order to enable downloads. This is not provided in the repo, and is not generally available. The secrets are stored in __Emitron/Emitron/Configuration/secrets.*.xcconfig__ files, with one file for each deployment stage. These files have entries in the .gitignore, so they won't appear when you first download the repo. diff --git a/SECURITY.md b/SECURITY.md index cdfebbb0..6b2065c7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Reporting a Vulnerability -If you come across a security vulnerability in __emitron__, or any of the raywenderlich.com infrastructure that it connects to, please do not file an +If you come across a security vulnerability in __emitron__, or any of the kodeco.com infrastructure that it connects to, please do not file an issue on GitHub. Instead, please document your issue as fully as you can, and email your issue report directly to emitron@razeware.com. diff --git a/SUPPORT.md b/SUPPORT.md index 3ea1ecd1..cb0bd87f 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,7 +1,7 @@ # emitron Support -If you need help or assistance with using __emitron__ (the raywenderlich.com app), please don't file an issue on the GitHub repo. +If you need help or assistance with using __emitron__ (the kodeco.com app), please don't file an issue on the GitHub repo. -Instead, check out [help.raywenderlich.com](https://help.raywenderlich.com/) for assistance, and in particular, [https://help.raywenderlich.com/faq](https://help.raywenderlich.com/faq) which has some details on the operation of __emitron__. +Instead, check out [help.kodeco.com](https://help.kodeco.com/) for assistance, and in particular, [https://help.kodeco.com/faq](https://help.kodeco.com/faq) which has some details on the operation of __emitron__. Thanks!