diff --git a/Authorization/Authorization.xcodeproj/project.pbxproj b/Authorization/Authorization.xcodeproj/project.pbxproj index 013667223..cac4d0445 100644 --- a/Authorization/Authorization.xcodeproj/project.pbxproj +++ b/Authorization/Authorization.xcodeproj/project.pbxproj @@ -719,7 +719,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugStage; @@ -829,7 +829,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseStage; @@ -1054,7 +1054,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugDev; @@ -1146,7 +1146,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseDev; @@ -1245,7 +1245,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugProd; @@ -1337,7 +1337,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseProd; @@ -1494,7 +1494,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -1528,7 +1528,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -1605,7 +1605,7 @@ repositoryURL = "https://github.com/openedx/openedx-app-foundation-ios/"; requirement = { kind = exactVersion; - version = 1.0.0; + version = 1.0.1; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Authorization/Authorization/Presentation/AuthorizationRouter.swift b/Authorization/Authorization/Presentation/AuthorizationRouter.swift index 2d41d7683..cf2289b12 100644 --- a/Authorization/Authorization/Presentation/AuthorizationRouter.swift +++ b/Authorization/Authorization/Presentation/AuthorizationRouter.swift @@ -9,6 +9,7 @@ import Foundation import Core //sourcery: AutoMockable +@MainActor public protocol AuthorizationRouter: BaseRouter { func showUpdateRequiredView(showAccountLink: Bool) } diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index e4bb7335b..ccf070b51 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -258,7 +258,7 @@ public struct SignInView: View { .transition(.move(edge: .top)) .onAppear { doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.alertMessage = nil + viewModel.alertMessage = nil } } } @@ -271,7 +271,7 @@ public struct SignInView: View { }.transition(.move(edge: .bottom)) .onAppear { doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil + viewModel.errorMessage = nil } } } diff --git a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift index b719a9ede..b2ef69786 100644 --- a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift +++ b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift @@ -15,6 +15,7 @@ import FacebookLogin import GoogleSignIn import MSAL +@MainActor public class SignInViewModel: ObservableObject { @Published private(set) var isShowProgress = false diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index c4d46caf2..1651b6060 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -197,7 +197,7 @@ public struct SignUpView: View { .ignoresSafeArea(.all, edges: .horizontal) .background(Theme.Colors.background.ignoresSafeArea(.all)) .navigationBarHidden(true) - .onFirstAppear{ + .onFirstAppear { viewModel.trackScreenEvent() } } diff --git a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift index a53403f9d..67a2c930e 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift @@ -14,7 +14,8 @@ import FacebookLogin import GoogleSignIn import MSAL -public class SignUpViewModel: ObservableObject { +@MainActor +public final class SignUpViewModel: ObservableObject { @Published var isShowProgress = false @Published var scrollTo: Int? diff --git a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordViewModel.swift b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordViewModel.swift index 8310e2e64..467e18503 100644 --- a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordViewModel.swift +++ b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordViewModel.swift @@ -9,7 +9,8 @@ import SwiftUI import Core import OEXFoundation -public class ResetPasswordViewModel: ObservableObject { +@MainActor +public final class ResetPasswordViewModel: ObservableObject { @Published private(set) var isShowProgress = false @Published private(set) var showError: Bool = false diff --git a/Authorization/Authorization/Presentation/SSO/SSOHelper.swift b/Authorization/Authorization/Presentation/SSO/SSOHelper.swift index 1f195a153..c64a43e99 100644 --- a/Authorization/Authorization/Presentation/SSO/SSOHelper.swift +++ b/Authorization/Authorization/Presentation/SSO/SSOHelper.swift @@ -14,7 +14,7 @@ import KeychainSwift A Helper for some of the SSO preferences. Keeps data under the UserDefaults. */ -public class SSOHelper: NSObject { +public final class SSOHelper: NSObject { private let keychain: KeychainSwift public enum SSOHelperKeys: String, CaseIterable { @@ -77,4 +77,3 @@ public class SSOHelper: NSObject { cookieSignature = nil } } - diff --git a/Authorization/Authorization/Presentation/SSO/SSOWebViewModel.swift b/Authorization/Authorization/Presentation/SSO/SSOWebViewModel.swift index 07e0faa55..2437254d5 100644 --- a/Authorization/Authorization/Presentation/SSO/SSOWebViewModel.swift +++ b/Authorization/Authorization/Presentation/SSO/SSOWebViewModel.swift @@ -15,6 +15,7 @@ import FacebookLogin import GoogleSignIn import MSAL +@MainActor public class SSOWebViewModel: ObservableObject { @Published private(set) var isShowProgress = false diff --git a/Authorization/Authorization/Presentation/SocialAuth/SocialAuthViewModel.swift b/Authorization/Authorization/Presentation/SocialAuth/SocialAuthViewModel.swift index 24e8ca5b8..5dbe3cfd0 100644 --- a/Authorization/Authorization/Presentation/SocialAuth/SocialAuthViewModel.swift +++ b/Authorization/Authorization/Presentation/SocialAuth/SocialAuthViewModel.swift @@ -56,6 +56,7 @@ enum SocialAuthDetails { } } +@MainActor final public class SocialAuthViewModel: ObservableObject { // MARK: - Properties diff --git a/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift b/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift index b4cf50091..ed3254c30 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift @@ -8,6 +8,7 @@ import Foundation import Core +@MainActor public class StartupViewModel: ObservableObject { let router: AuthorizationRouter let analytics: CoreAnalytics diff --git a/Authorization/AuthorizationTests/Presentation/Login/ResetPasswordViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/Login/ResetPasswordViewModelTests.swift index e5ffa55e7..131fd2086 100644 --- a/Authorization/AuthorizationTests/Presentation/Login/ResetPasswordViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/Login/ResetPasswordViewModelTests.swift @@ -13,6 +13,7 @@ import OEXFoundation import Alamofire import SwiftUI +@MainActor final class ResetPasswordViewModelTests: XCTestCase { func testResetPasswordValidationEmailError() async throws { diff --git a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift index c91d48adc..a75a51d4c 100644 --- a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift @@ -13,6 +13,7 @@ import OEXFoundation import Alamofire import SwiftUI +@MainActor final class SignInViewModelTests: XCTestCase { override func setUpWithError() throws { diff --git a/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift index 7e8236d27..3d680dc63 100644 --- a/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift @@ -13,6 +13,7 @@ import OEXFoundation import Alamofire import SwiftUI +@MainActor final class SignUpViewModelTests: XCTestCase { override func setUpWithError() throws { diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index e58070797..4f2728434 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -150,6 +150,8 @@ BAFB99902B14B377007D09F9 /* GoogleConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB998F2B14B377007D09F9 /* GoogleConfig.swift */; }; BAFB99922B14E23D007D09F9 /* AppleSignInConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB99912B14E23D007D09F9 /* AppleSignInConfig.swift */; }; C8C446EF233F81B9FABB77D2 /* Pods_App_Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 349B90CD6579F7B8D257E515 /* Pods_App_Core.framework */; }; + CE09B2B62CE796AE0090DB53 /* InvalidCoreDataContextError.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE09B2B52CE796AE0090DB53 /* InvalidCoreDataContextError.swift */; }; + CE1D5B7D2CE65D360019CA34 /* Protected.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1D5B7C2CE65D360019CA34 /* Protected.swift */; }; CE54C2D22CC80D8500E529F9 /* DownloadManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE54C2D12CC80D8500E529F9 /* DownloadManagerTests.swift */; }; CE57127C2CD109DB00D4AB17 /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CE57127B2CD109DB00D4AB17 /* OEXFoundation */; }; CE7CAF392CC1561E00E0AC9D /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CE7CAF382CC1561E00E0AC9D /* OEXFoundation */; }; @@ -359,6 +361,8 @@ BAFB99912B14E23D007D09F9 /* AppleSignInConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleSignInConfig.swift; sourceTree = ""; }; C28D4872BAB1276B9AD24A33 /* Pods-CoreTests.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CoreTests.debugdev.xcconfig"; path = "Target Support Files/Pods-CoreTests/Pods-CoreTests.debugdev.xcconfig"; sourceTree = ""; }; C7E5BCE79CE297B20777B27A /* Pods-App-Core.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugprod.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugprod.xcconfig"; sourceTree = ""; }; + CE09B2B52CE796AE0090DB53 /* InvalidCoreDataContextError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvalidCoreDataContextError.swift; sourceTree = ""; }; + CE1D5B7C2CE65D360019CA34 /* Protected.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Protected.swift; sourceTree = ""; }; CE54C2D12CC80D8500E529F9 /* DownloadManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManagerTests.swift; sourceTree = ""; }; CE953A3A2CD0DA940023D667 /* CoreMock.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreMock.generated.swift; sourceTree = ""; }; CFC84951299F8B890055E497 /* Debounce.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debounce.swift; sourceTree = ""; }; @@ -496,6 +500,7 @@ 02A4833629B8A8F800D33F33 /* CoreDataModel.xcdatamodeld */, 02A4833429B8A73400D33F33 /* CorePersistenceProtocol.swift */, 02CF46C729546AA200A698EE /* NoCachedDataError.swift */, + CE09B2B52CE796AE0090DB53 /* InvalidCoreDataContextError.swift */, ); path = Persistence; sourceTree = ""; @@ -657,6 +662,7 @@ 0727876E28D233EC002E9142 /* Configuration */, 0770DE2828D0928B006D8A5D /* Network */, 0770DE7628D0C491006D8A5D /* View */, + CE4AB5942CE2504500E27A00 /* System */, 0770DE5D28D0B209006D8A5D /* Localizable.strings */, 0770DE5128D0ADFF006D8A5D /* Assets.xcassets */, 071009CF28D1E3A600344290 /* Constants.swift */, @@ -816,6 +822,14 @@ path = ../Pods; sourceTree = ""; }; + CE4AB5942CE2504500E27A00 /* System */ = { + isa = PBXGroup; + children = ( + CE1D5B7C2CE65D360019CA34 /* Protected.swift */, + ); + path = System; + sourceTree = ""; + }; CE54C2CE2CC80B4A00E529F9 /* DownloadManager */ = { isa = PBXGroup; children = ( @@ -926,7 +940,6 @@ 0770DE0428D07831006D8A5D /* Sources */, 0770DE0528D07831006D8A5D /* Frameworks */, 0770DE0628D07831006D8A5D /* Resources */, - 49BAD0663C27D73B9115401F /* [CP] Copy Pods Resources */, CE57127E2CD109DB00D4AB17 /* Embed Frameworks */, ); buildRules = ( @@ -1027,23 +1040,6 @@ shellPath = /bin/sh; shellScript = "if [[ -f \"${PODS_ROOT}/SwiftGen/bin/swiftgen\" ]]; then\n \"${PODS_ROOT}/SwiftGen/bin/swiftgen\"\nelse\n echo \"warning: SwiftGen is not installed. Run 'pod install --repo-update' to install it.\"\nfi\n"; }; - 49BAD0663C27D73B9115401F /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-App-Core/Pods-App-Core-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-App-Core/Pods-App-Core-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App-Core/Pods-App-Core-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; C9AA9371F83D4B112F310DB8 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -1130,6 +1126,7 @@ DBF6F2462B01DAFE0098414B /* AgreementConfig.swift in Sources */, 027BD3AF2909475000392132 /* DismissKeyboardTapHandler.swift in Sources */, 027F1BF72C071C820001A24C /* NavigationTitle.swift in Sources */, + CE1D5B7D2CE65D360019CA34 /* Protected.swift in Sources */, 06619EAA2B8F2936001FAADE /* ReadabilityModifier.swift in Sources */, BAFB99902B14B377007D09F9 /* GoogleConfig.swift in Sources */, 029A132C2C2471F8005FB830 /* OfflineSyncEndpoint.swift in Sources */, @@ -1151,6 +1148,7 @@ 027BD3B32909475900392132 /* Publishers+KeyboardState.swift in Sources */, 06DEA4A32BBD66A700110D20 /* BackNavigationButton.swift in Sources */, 0727877D28D25212002E9142 /* ProgressBar.swift in Sources */, + CE09B2B62CE796AE0090DB53 /* InvalidCoreDataContextError.swift in Sources */, BA981BD02B91ED50005707C2 /* FullScreenProgressView.swift in Sources */, 0236961F28F9A2F600EEF206 /* AuthEndpoint.swift in Sources */, BAD9CA332B28A8F300DE790A /* AjaxProvider.swift in Sources */, @@ -1383,7 +1381,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugStage; @@ -1498,7 +1496,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseStage; @@ -1754,7 +1752,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugDev; @@ -1846,7 +1844,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseDev; @@ -1945,7 +1943,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugProd; @@ -2037,7 +2035,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseProd; @@ -2194,7 +2192,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -2228,7 +2226,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -2297,15 +2295,7 @@ repositoryURL = "https://github.com/openedx/openedx-app-foundation-ios/"; requirement = { kind = exactVersion; - version = 1.0.0; - }; - }; - BA8FA6712AD6ABA300EA029A /* XCRemoteSwiftPackageReference "facebook-ios-sdk" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/facebook/facebook-ios-sdk"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 16.3.1; + version = 1.0.1; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Core/Core/AvoidingHelpers/Scroller/DismissKeyboardTapHandler.swift b/Core/Core/AvoidingHelpers/Scroller/DismissKeyboardTapHandler.swift index 73cadf64d..7168eeded 100644 --- a/Core/Core/AvoidingHelpers/Scroller/DismissKeyboardTapHandler.swift +++ b/Core/Core/AvoidingHelpers/Scroller/DismissKeyboardTapHandler.swift @@ -5,6 +5,8 @@ import UIKit /** Applies keyboard dismissal tap to the whole view */ + +@MainActor final class DismissKeyboardTapHandler: NSObject { var isEnabled: Bool { didSet { diff --git a/Core/Core/AvoidingHelpers/Scroller/KeyboardScroller.swift b/Core/Core/AvoidingHelpers/Scroller/KeyboardScroller.swift index 2d5fe602e..8062c13f1 100644 --- a/Core/Core/AvoidingHelpers/Scroller/KeyboardScroller.swift +++ b/Core/Core/AvoidingHelpers/Scroller/KeyboardScroller.swift @@ -3,6 +3,7 @@ import UIKit import OEXFoundation +@MainActor final class KeyboardScroller { static func scroll( keyboardState: KeyboardState, diff --git a/Core/Core/AvoidingHelpers/State/KeyboardState.swift b/Core/Core/AvoidingHelpers/State/KeyboardState.swift index 6b9aca949..005ada41a 100644 --- a/Core/Core/AvoidingHelpers/State/KeyboardState.swift +++ b/Core/Core/AvoidingHelpers/State/KeyboardState.swift @@ -3,7 +3,7 @@ import SwiftUI import UIKit -public struct KeyboardState { +public struct KeyboardState: Sendable { public let animationDuration: TimeInterval /// Keyboard notification return a private curve value - 7. @@ -25,6 +25,7 @@ public struct KeyboardState { // MARK: - Static +@MainActor extension KeyboardState { static let `default` = KeyboardState( animationDuration: 0, diff --git a/Core/Core/AvoidingHelpers/State/KeyboardStateObserver.swift b/Core/Core/AvoidingHelpers/State/KeyboardStateObserver.swift index 28457fe17..8a1f094a4 100644 --- a/Core/Core/AvoidingHelpers/State/KeyboardStateObserver.swift +++ b/Core/Core/AvoidingHelpers/State/KeyboardStateObserver.swift @@ -2,6 +2,7 @@ import Combine +@MainActor final class KeyboardStateObserver: ObservableObject { @Published private(set) var keyboardState: KeyboardState = .default diff --git a/Core/Core/AvoidingHelpers/State/Publishers+KeyboardState.swift b/Core/Core/AvoidingHelpers/State/Publishers+KeyboardState.swift index 0cd6d88ce..e11689050 100644 --- a/Core/Core/AvoidingHelpers/State/Publishers+KeyboardState.swift +++ b/Core/Core/AvoidingHelpers/State/Publishers+KeyboardState.swift @@ -3,6 +3,7 @@ import Combine import SwiftUI +@MainActor public extension Publishers { static var keyboardStatePublisher: AnyPublisher { let notificationCenter: NotificationCenter = .default diff --git a/Core/Core/Configuration/BaseRouter.swift b/Core/Core/Configuration/BaseRouter.swift index 4f2ae5bba..f36868494 100644 --- a/Core/Core/Configuration/BaseRouter.swift +++ b/Core/Core/Configuration/BaseRouter.swift @@ -9,7 +9,8 @@ import Foundation import SwiftUI //sourcery: AutoMockable -public protocol BaseRouter { +@MainActor +public protocol BaseRouter: Sendable { func backToRoot(animated: Bool) diff --git a/Core/Core/Configuration/Config/Config.swift b/Core/Core/Configuration/Config/Config.swift index dbd29f0de..43fd8c70d 100644 --- a/Core/Core/Configuration/Config/Config.swift +++ b/Core/Core/Configuration/Config/Config.swift @@ -8,7 +8,7 @@ import Foundation //sourcery: AutoMockable -public protocol ConfigProtocol { +public protocol ConfigProtocol: Sendable { var baseURL: URL { get } var baseSSOURL: URL { get } var ssoFinishedURL: URL { get } @@ -36,12 +36,12 @@ public protocol ConfigProtocol { var URIScheme: String { get } } -public enum TokenType: String { +public enum TokenType: String, Sendable { case jwt = "JWT" case bearer = "BEARER" } -private enum ConfigKeys: String { +private enum ConfigKeys: String, Sendable { case baseURL = "API_HOST_URL" case ssoBaseURL = "SSO_URL" case ssoFinishedURL = "SSO_FINISHED_URL" @@ -57,7 +57,7 @@ private enum ConfigKeys: String { case URIScheme = "URI_SCHEME" } -public class Config { +public class Config: @unchecked Sendable { let configFileName = "config" internal var properties: [String: Any] = [:] @@ -196,7 +196,7 @@ extension Config: ConfigProtocol { public class ConfigMock: Config { private let config: [String: Any] = [ "API_HOST_URL": "https://www.example.com", - "SSO_URL" : "https://www.example.com", + "SSO_URL": "https://www.example.com", "OAUTH_CLIENT_ID": "oauth_client_id", "FEEDBACK_EMAIL_ADDRESS": "example@mail.com", "PLATFORM_NAME": "OpenEdx", diff --git a/Core/Core/Configuration/Connectivity.swift b/Core/Core/Configuration/Connectivity.swift index c69ee18b9..864e10e9f 100644 --- a/Core/Core/Configuration/Connectivity.swift +++ b/Core/Core/Configuration/Connectivity.swift @@ -9,18 +9,20 @@ import Alamofire import Combine import Foundation -public enum InternetState { +public enum InternetState: Sendable { case reachable case notReachable } //sourcery: AutoMockable -public protocol ConnectivityProtocol { +@MainActor +public protocol ConnectivityProtocol: Sendable { var isInternetAvaliable: Bool { get } var isMobileData: Bool { get } var internetReachableSubject: CurrentValueSubject { get } } +@MainActor public class Connectivity: ConnectivityProtocol { let networkManager = NetworkReachabilityManager() diff --git a/Core/Core/Data/CoreDataHandlerProtocol.swift b/Core/Core/Data/CoreDataHandlerProtocol.swift index 3dc8c67a5..0ac2b3982 100644 --- a/Core/Core/Data/CoreDataHandlerProtocol.swift +++ b/Core/Core/Data/CoreDataHandlerProtocol.swift @@ -7,6 +7,6 @@ import Foundation -public protocol CoreDataHandlerProtocol { - func clear() +public protocol CoreDataHandlerProtocol: Sendable { + func clear() async } diff --git a/Core/Core/Data/CoreStorage.swift b/Core/Core/Data/CoreStorage.swift index 021d32ec8..c7fedbd52 100644 --- a/Core/Core/Data/CoreStorage.swift +++ b/Core/Core/Data/CoreStorage.swift @@ -8,7 +8,7 @@ import Foundation //sourcery: AutoMockable -public protocol CoreStorage { +public protocol CoreStorage: Sendable { var accessToken: String? {get set} var refreshToken: String? {get set} var pushToken: String? {get set} @@ -25,7 +25,7 @@ public protocol CoreStorage { } #if DEBUG -public class CoreStorageMock: CoreStorage { +public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public var accessToken: String? public var refreshToken: String? public var pushToken: String? diff --git a/Core/Core/Data/Model/Data_Certificate.swift b/Core/Core/Data/Model/Data_Certificate.swift index 0b6475ed1..1df784f26 100644 --- a/Core/Core/Data/Model/Data_Certificate.swift +++ b/Core/Core/Data/Model/Data_Certificate.swift @@ -8,7 +8,7 @@ import Foundation public extension DataLayer { - struct Certificate: Codable { + struct Certificate: Codable, Sendable { public let url: String? public init(url: String?) { diff --git a/Core/Core/Data/Model/Data_CourseDates.swift b/Core/Core/Data/Model/Data_CourseDates.swift index e17f767ce..71092107f 100644 --- a/Core/Core/Data/Model/Data_CourseDates.swift +++ b/Core/Core/Data/Model/Data_CourseDates.swift @@ -93,7 +93,7 @@ public extension DataLayer { } } - enum BannerInfoStatus { + enum BannerInfoStatus: Sendable { case datesTabInfoBanner case upgradeToCompleteGradedBanner case upgradeToResetBanner diff --git a/Core/Core/Data/Model/Data_Media.swift b/Core/Core/Data/Model/Data_Media.swift index 62cf646af..8509573ac 100644 --- a/Core/Core/Data/Model/Data_Media.swift +++ b/Core/Core/Data/Model/Data_Media.swift @@ -10,7 +10,11 @@ import Foundation public extension DataLayer { // MARK: - CourseMedia - struct CourseMedia: Decodable { + struct CourseMedia: Decodable, Sendable, Equatable { + public static func == (lhs: DataLayer.CourseMedia, rhs: DataLayer.CourseMedia) -> Bool { + lhs.image == rhs.image + } + public let image: DataLayer.Image public init(image: DataLayer.Image) { @@ -61,7 +65,7 @@ public extension DataLayer { public extension DataLayer { // MARK: - Image - struct Image: Codable { + struct Image: Codable, Sendable, Equatable { public let raw: String public let small: String public let large: String diff --git a/Core/Core/Data/Model/Data_PrimaryEnrollment.swift b/Core/Core/Data/Model/Data_PrimaryEnrollment.swift index 16af30373..d252b60e5 100644 --- a/Core/Core/Data/Model/Data_PrimaryEnrollment.swift +++ b/Core/Core/Data/Model/Data_PrimaryEnrollment.swift @@ -164,7 +164,7 @@ public extension DataLayer { } // MARK: - CourseProgress - struct CourseProgress: Codable { + struct CourseProgress: Codable, Sendable { public let assignmentsCompleted: Int? public let totalAssignmentsCount: Int? diff --git a/Core/Core/Data/Model/Data_User.swift b/Core/Core/Data/Model/Data_User.swift index dbc4cb527..1ea5515d4 100644 --- a/Core/Core/Data/Model/Data_User.swift +++ b/Core/Core/Data/Model/Data_User.swift @@ -10,7 +10,7 @@ import Foundation // MARK: "/api/mobile/v0.5/my_user_info" public extension DataLayer { - struct User: Codable { + struct User: Codable, Sendable { public let id: Int public let username: String? public let email: String? diff --git a/Core/Core/Data/Model/Data_UserProfile.swift b/Core/Core/Data/Model/Data_UserProfile.swift index fe0e675bf..6beb1ff91 100644 --- a/Core/Core/Data/Model/Data_UserProfile.swift +++ b/Core/Core/Data/Model/Data_UserProfile.swift @@ -11,7 +11,7 @@ import Foundation // MARK: - UserProfile public extension DataLayer { - struct UserProfile: Codable { + struct UserProfile: Codable, Sendable { public let id: Int? public let accountPrivacy: AccountPrivacy? public let profileImage: ProfileImage? @@ -67,7 +67,7 @@ public extension DataLayer { } // MARK: - AccountPrivacy -public enum AccountPrivacy: String, Codable { +public enum AccountPrivacy: String, Codable, Sendable { case privateAccess = "private" case allUsers = "all_users" case allUsersBig = "ALL_USERS" @@ -84,14 +84,14 @@ public enum AccountPrivacy: String, Codable { // MARK: - LanguageProficiency public extension DataLayer { - struct LanguageProficiency: Codable { + struct LanguageProficiency: Codable, Sendable { public let code: String } } // MARK: - ProfileImage public extension DataLayer { - struct ProfileImage: Codable { + struct ProfileImage: Codable, Sendable { public let hasImage: Bool? public let imageURLFull: String? public let imageURLLarge: String? @@ -110,7 +110,7 @@ public extension DataLayer { // MARK: - SocialLink public extension DataLayer { - struct SocialLink: Codable { + struct SocialLink: Codable, Sendable { public let platform: String public let socialLink: String diff --git a/Core/Core/Data/Model/UserSettings.swift b/Core/Core/Data/Model/UserSettings.swift index 52ce12557..ec6decdfc 100644 --- a/Core/Core/Data/Model/UserSettings.swift +++ b/Core/Core/Data/Model/UserSettings.swift @@ -7,7 +7,7 @@ import Foundation -public struct UserSettings: Codable, Hashable { +public struct UserSettings: Codable, Hashable, Sendable { public var wifiOnly: Bool public var streamingQuality: StreamingQuality public var downloadQuality: DownloadQuality @@ -23,7 +23,7 @@ public struct UserSettings: Codable, Hashable { } } -public enum StreamingQuality: Codable { +public enum StreamingQuality: Codable, Sendable { case auto case low case medium @@ -47,7 +47,7 @@ public enum StreamingQuality: Codable { } } -public enum DownloadQuality: Codable, CaseIterable { +public enum DownloadQuality: Codable, CaseIterable, Sendable { case auto case low case medium diff --git a/Core/Core/Data/Persistence/CorePersistenceProtocol.swift b/Core/Core/Data/Persistence/CorePersistenceProtocol.swift index b17c62a1f..9aca9e9e8 100644 --- a/Core/Core/Data/Persistence/CorePersistenceProtocol.swift +++ b/Core/Core/Data/Persistence/CorePersistenceProtocol.swift @@ -9,47 +9,46 @@ import CoreData import Combine //sourcery: AutoMockable -public protocol CorePersistenceProtocol { +public protocol CorePersistenceProtocol: Sendable { func set(userId: Int) func getUserID() -> Int? - func publisher() -> AnyPublisher - func addToDownloadQueue(tasks: [DownloadDataTask]) - func saveOfflineProgress(progress: OfflineProgress) - func loadProgress(for blockID: String) -> OfflineProgress? - func loadAllOfflineProgress() -> [OfflineProgress] - func deleteProgress(for blockID: String) - func deleteAllProgress() + @MainActor func publisher() throws -> AnyPublisher + func addToDownloadQueue(tasks: [DownloadDataTask]) async + func saveOfflineProgress(progress: OfflineProgress) async + func loadProgress(for blockID: String) async -> OfflineProgress? + func loadAllOfflineProgress() async -> [OfflineProgress] + func deleteProgress(for blockID: String) async + func deleteAllProgress() async func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) async func nextBlockForDownloading() async -> DownloadDataTask? - func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) + func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) async func deleteDownloadDataTask(id: String) async throws - func saveDownloadDataTask(_ task: DownloadDataTask) - func downloadDataTask(for blockId: String) -> DownloadDataTask? + func saveDownloadDataTask(_ task: DownloadDataTask) async + func downloadDataTask(for blockId: String) async -> DownloadDataTask? func getDownloadDataTasks() async -> [DownloadDataTask] func getDownloadDataTasksForCourse(_ courseId: String) async -> [DownloadDataTask] } #if DEBUG -public class CorePersistenceMock: CorePersistenceProtocol { +public final class CorePersistenceMock: CorePersistenceProtocol, @unchecked Sendable { public init() {} - public func set(userId: Int) {} public func getUserID() -> Int? {1} public func publisher() -> AnyPublisher { Just(0).eraseToAnyPublisher() } - public func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) {} - public func addToDownloadQueue(tasks: [DownloadDataTask]) {} - public func nextBlockForDownloading() -> DownloadDataTask? { nil } - public func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) {} - public func deleteDownloadDataTask(id: String) throws {} - public func downloadDataTask(for blockId: String) -> DownloadDataTask? { nil } - public func saveOfflineProgress(progress: OfflineProgress) {} - public func loadProgress(for blockID: String) -> OfflineProgress? { nil } - public func loadAllOfflineProgress() -> [OfflineProgress] { [] } - public func deleteProgress(for blockID: String) {} - public func deleteAllProgress() {} - public func saveDownloadDataTask(_ task: DownloadDataTask) {} + public func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) async {} + public func addToDownloadQueue(tasks: [DownloadDataTask]) async {} + public func nextBlockForDownloading() async -> DownloadDataTask? { nil } + public func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) async {} + public func deleteDownloadDataTask(id: String) async throws {} + public func downloadDataTask(for blockId: String) async -> DownloadDataTask? { nil } + public func saveOfflineProgress(progress: OfflineProgress) async {} + public func loadProgress(for blockID: String) async -> OfflineProgress? { nil } + public func loadAllOfflineProgress() async -> [OfflineProgress] { [] } + public func deleteProgress(for blockID: String) async {} + public func deleteAllProgress() async {} + public func saveDownloadDataTask(_ task: DownloadDataTask) async {} public func getDownloadDataTasks() async -> [DownloadDataTask] {[]} public func getDownloadDataTasksForCourse(_ courseId: String) async -> [DownloadDataTask] {[]} } diff --git a/Core/Core/Data/Persistence/InvalidCoreDataContextError.swift b/Core/Core/Data/Persistence/InvalidCoreDataContextError.swift new file mode 100644 index 000000000..05089a99c --- /dev/null +++ b/Core/Core/Data/Persistence/InvalidCoreDataContextError.swift @@ -0,0 +1,12 @@ +// +// InvalidCoreDataContextError.swift +// Core +// +// Created by Ivan Stepanok on 15.11.2024. +// + +import Foundation + +public class InvalidCoreDataContextError: LocalizedError, @unchecked Sendable { + public init() {} +} diff --git a/Core/Core/Data/Persistence/NoCachedDataError.swift b/Core/Core/Data/Persistence/NoCachedDataError.swift index d00d8e3bf..efbb01c1d 100644 --- a/Core/Core/Data/Persistence/NoCachedDataError.swift +++ b/Core/Core/Data/Persistence/NoCachedDataError.swift @@ -7,6 +7,6 @@ import Foundation -public class NoCachedDataError: LocalizedError { +public class NoCachedDataError: LocalizedError, @unchecked Sendable { public init() {} } diff --git a/Core/Core/Data/Repository/AuthRepository.swift b/Core/Core/Data/Repository/AuthRepository.swift index 20e89e603..18671e890 100644 --- a/Core/Core/Data/Repository/AuthRepository.swift +++ b/Core/Core/Data/Repository/AuthRepository.swift @@ -8,7 +8,7 @@ import Foundation import OEXFoundation -public protocol AuthRepositoryProtocol { +public protocol AuthRepositoryProtocol: Sendable { func login(username: String, password: String) async throws -> User func login(externalToken: String, backend: String) async throws -> User func login(ssoToken: String) async throws -> User @@ -19,7 +19,7 @@ public protocol AuthRepositoryProtocol { func resetPassword(email: String) async throws -> ResetPassword } -public class AuthRepository: AuthRepositoryProtocol { +public actor AuthRepository: AuthRepositoryProtocol { private let api: API private var appStorage: CoreStorage @@ -83,7 +83,7 @@ public class AuthRepository: AuthRepositoryProtocol { } public func login(ssoToken: String) async throws -> User { - if appStorage.accessToken == nil { + if appStorage.accessToken == nil { appStorage.accessToken = ssoToken } @@ -140,7 +140,7 @@ public class AuthRepository: AuthRepositoryProtocol { // Mark - For testing and SwiftUI preview #if DEBUG -class AuthRepositoryMock: AuthRepositoryProtocol { +final class AuthRepositoryMock: AuthRepositoryProtocol { func login(username: String, password: String) async throws -> User { User(id: 1, username: "User", email: "email@gmail.com", name: "User Name", userAvatar: "") } diff --git a/Core/Core/Data/Repository/OfflineSyncRepository.swift b/Core/Core/Data/Repository/OfflineSyncRepository.swift index 67a535719..40642e6f6 100644 --- a/Core/Core/Data/Repository/OfflineSyncRepository.swift +++ b/Core/Core/Data/Repository/OfflineSyncRepository.swift @@ -8,11 +8,11 @@ import Foundation import OEXFoundation -public protocol OfflineSyncRepositoryProtocol { +public protocol OfflineSyncRepositoryProtocol: Sendable { func submitOfflineProgress(courseID: String, blockID: String, data: String) async throws -> Bool } -public class OfflineSyncRepository: OfflineSyncRepositoryProtocol { +public actor OfflineSyncRepository: OfflineSyncRepositoryProtocol { private let api: API @@ -35,6 +35,7 @@ public class OfflineSyncRepository: OfflineSyncRepositoryProtocol { // Mark - For testing and SwiftUI preview #if DEBUG +@MainActor class OfflineSyncRepositoryMock: OfflineSyncRepositoryProtocol { public func submitOfflineProgress(courseID: String, blockID: String, data: String) async throws -> Bool { true diff --git a/Core/Core/Domain/AuthInteractor.swift b/Core/Core/Domain/AuthInteractor.swift index 6b3562f20..4c10910bb 100644 --- a/Core/Core/Domain/AuthInteractor.swift +++ b/Core/Core/Domain/AuthInteractor.swift @@ -8,7 +8,7 @@ import Foundation //sourcery: AutoMockable -public protocol AuthInteractorProtocol { +public protocol AuthInteractorProtocol: Sendable { @discardableResult func login(username: String, password: String) async throws -> User @discardableResult @@ -21,7 +21,7 @@ public protocol AuthInteractorProtocol { func validateRegistrationFields(fields: [String: String]) async throws -> [String: String] } -public class AuthInteractor: AuthInteractorProtocol { +public actor AuthInteractor: AuthInteractorProtocol { private let repository: AuthRepositoryProtocol public init(repository: AuthRepositoryProtocol) { @@ -66,6 +66,7 @@ public class AuthInteractor: AuthInteractorProtocol { // Mark - For testing and SwiftUI preview #if DEBUG +@MainActor public extension AuthInteractor { static let mock: AuthInteractor = .init(repository: AuthRepositoryMock()) } diff --git a/Core/Core/Domain/Model/Certificate.swift b/Core/Core/Domain/Model/Certificate.swift index e85a51a31..17e40dc17 100644 --- a/Core/Core/Domain/Model/Certificate.swift +++ b/Core/Core/Domain/Model/Certificate.swift @@ -7,7 +7,7 @@ import Foundation -public struct Certificate: Codable, Hashable { +public struct Certificate: Codable, Hashable, Sendable { public let url: String? public init(url: String?) { diff --git a/Core/Core/Domain/Model/CourseBlockModel.swift b/Core/Core/Domain/Model/CourseBlockModel.swift index 731e47176..91ff9801f 100644 --- a/Core/Core/Domain/Model/CourseBlockModel.swift +++ b/Core/Core/Domain/Model/CourseBlockModel.swift @@ -7,7 +7,7 @@ import Foundation -public struct CourseStructure: Equatable { +public struct CourseStructure: Equatable, Sendable { public static func == (lhs: CourseStructure, rhs: CourseStructure) -> Bool { return lhs.id == rhs.id } @@ -81,7 +81,7 @@ public struct CourseStructure: Equatable { } } -public struct CourseProgress { +public struct CourseProgress: Sendable { public let totalAssignmentsCount: Int? public let assignmentsCompleted: Int? @@ -91,7 +91,11 @@ public struct CourseProgress { } } -public struct CourseChapter: Identifiable { +public struct CourseChapter: Identifiable, Sendable, Equatable { + public static func == (lhs: CourseChapter, rhs: CourseChapter) -> Bool { + lhs.id == rhs.id && + lhs.blockId == rhs.blockId + } public let blockId: String public let id: String @@ -114,7 +118,11 @@ public struct CourseChapter: Identifiable { } } -public struct CourseSequential: Identifiable { +public struct CourseSequential: Identifiable, Sendable, Equatable { + public static func == (lhs: CourseSequential, rhs: CourseSequential) -> Bool { + lhs.id == rhs.id && + lhs.blockId == rhs.blockId + } public let blockId: String public let id: String @@ -154,7 +162,7 @@ public struct CourseSequential: Identifiable { } } -public struct CourseVertical: Identifiable, Hashable { +public struct CourseVertical: Identifiable, Hashable, Sendable, Equatable { public func hash(into hasher: inout Hasher) { hasher.combine(id) } @@ -193,7 +201,7 @@ public struct CourseVertical: Identifiable, Hashable { } } -public struct SubtitleUrl: Equatable { +public struct SubtitleUrl: Equatable, Sendable { public let language: String public let url: String @@ -203,7 +211,7 @@ public struct SubtitleUrl: Equatable { } } -public struct SequentialProgress { +public struct SequentialProgress: Sendable { public let assignmentType: String? public let numPointsEarned: Int? public let numPointsPossible: Int? @@ -215,7 +223,7 @@ public struct SequentialProgress { } } -public struct CourseBlock: Hashable, Identifiable { +public struct CourseBlock: Hashable, Identifiable, Sendable, Equatable { public static func == (lhs: CourseBlock, rhs: CourseBlock) -> Bool { lhs.id == rhs.id && lhs.blockId == rhs.blockId && @@ -302,7 +310,7 @@ public struct CourseBlock: Hashable, Identifiable { } } -public struct OfflineDownload { +public struct OfflineDownload: Sendable { public let fileUrl: String public var lastModified: String public let fileSize: Int @@ -318,7 +326,7 @@ public struct OfflineDownload { } } -public struct CourseBlockEncodedVideo { +public struct CourseBlockEncodedVideo: Sendable { public let fallback: CourseBlockVideo? public let desktopMP4: CourseBlockVideo? @@ -401,7 +409,7 @@ public struct CourseBlockEncodedVideo { } -public struct CourseBlockVideo: Equatable { +public struct CourseBlockVideo: Equatable, Sendable { public let url: String? public let fileSize: Int? public let streamPriority: Int? diff --git a/Core/Core/Domain/Model/CourseDates.swift b/Core/Core/Domain/Model/CourseDates.swift index 11c0e943d..b7b0b8fd1 100644 --- a/Core/Core/Domain/Model/CourseDates.swift +++ b/Core/Core/Domain/Model/CourseDates.swift @@ -8,7 +8,7 @@ import Foundation import CryptoKit -public struct CourseDates { +public struct CourseDates: Sendable { public let datesBannerInfo: DatesBannerInfo public let courseDateBlocks: [CourseDateBlock] public let hasEnded, learnerIsFullAccess: Bool @@ -143,7 +143,7 @@ public extension Date { } } -public struct CourseDateBlock: Identifiable { +public struct CourseDateBlock: Identifiable, Sendable { public let id: UUID = UUID() public let assignmentType: String? @@ -266,7 +266,7 @@ public struct CourseDateBlock: Identifiable { } } -public struct DatesBannerInfo { +public struct DatesBannerInfo: Sendable { public let missedDeadlines, contentTypeGatingEnabled, missedGatedContent: Bool public let verifiedUpgradeLink: String? public let status: DataLayer.BannerInfoStatus? @@ -286,7 +286,7 @@ public struct DatesBannerInfo { } } -public struct CourseDateBanner { +public struct CourseDateBanner: Sendable { public let datesBannerInfo: DatesBannerInfo public let hasEnded: Bool @@ -296,7 +296,7 @@ public struct CourseDateBanner { } } -public enum BlockStatus { +public enum BlockStatus: Sendable { case completed case pastDue case dueNext @@ -325,7 +325,7 @@ public enum BlockStatus { } } -public enum CompletionStatus: String { +public enum CompletionStatus: String, Sendable { case completed = "Completed" case pastDue = "Past Due" case today = "Today" diff --git a/Core/Core/Domain/Model/CourseDetailBlock.swift b/Core/Core/Domain/Model/CourseDetailBlock.swift index 31fb4f9f9..594170708 100644 --- a/Core/Core/Domain/Model/CourseDetailBlock.swift +++ b/Core/Core/Domain/Model/CourseDetailBlock.swift @@ -30,7 +30,7 @@ public struct StudentViewData { } } -public enum BlockType: String { +public enum BlockType: String, Sendable { case course case sequential case vertical diff --git a/Core/Core/Domain/Model/CourseForSync.swift b/Core/Core/Domain/Model/CourseForSync.swift index 5f3a6c343..f77aebe23 100644 --- a/Core/Core/Domain/Model/CourseForSync.swift +++ b/Core/Core/Domain/Model/CourseForSync.swift @@ -8,7 +8,7 @@ import Foundation // MARK: - CourseForSync -public struct CourseForSync: Identifiable { +public struct CourseForSync: Identifiable, Sendable { public let id: UUID public let courseID: String public let name: String diff --git a/Core/Core/Domain/Model/CourseItem.swift b/Core/Core/Domain/Model/CourseItem.swift index 19bd1f612..2cc71cebd 100644 --- a/Core/Core/Domain/Model/CourseItem.swift +++ b/Core/Core/Domain/Model/CourseItem.swift @@ -7,7 +7,7 @@ import Foundation -public struct CourseItem: Hashable { +public struct CourseItem: Hashable, Sendable { public let name: String public let org: String public let shortDescription: String diff --git a/Core/Core/Domain/Model/OfflineProgress.swift b/Core/Core/Domain/Model/OfflineProgress.swift index efb1a982c..8838b7c3d 100644 --- a/Core/Core/Domain/Model/OfflineProgress.swift +++ b/Core/Core/Domain/Model/OfflineProgress.swift @@ -7,7 +7,7 @@ import Foundation -public struct OfflineProgress { +public struct OfflineProgress: Sendable { public let blockID: String public let data: String public let courseID: String diff --git a/Core/Core/Domain/Model/Pagination.swift b/Core/Core/Domain/Model/Pagination.swift index 9cbad7b18..c1249afe6 100644 --- a/Core/Core/Domain/Model/Pagination.swift +++ b/Core/Core/Domain/Model/Pagination.swift @@ -7,7 +7,7 @@ import Foundation -public struct Pagination { +public struct Pagination: Sendable { public let next: String? public let previous: String? public let count: Int diff --git a/Core/Core/Domain/Model/PickerFields.swift b/Core/Core/Domain/Model/PickerFields.swift index 8791f10ef..a7f6d5beb 100644 --- a/Core/Core/Domain/Model/PickerFields.swift +++ b/Core/Core/Domain/Model/PickerFields.swift @@ -7,7 +7,7 @@ import Foundation -public enum RegistrationFieldType: String, Hashable { +public enum RegistrationFieldType: String, Hashable, Sendable { case text case email case confirm_email @@ -19,7 +19,7 @@ public enum RegistrationFieldType: String, Hashable { case unknown } -public struct PickerFields { +public struct PickerFields: Sendable { public let type: RegistrationFieldType public let label: String public let required: Bool @@ -31,7 +31,7 @@ public struct PickerFields { name == "honor_code" } - public struct Option { + public struct Option: Sendable { public let value: String public let name: String public var optionDefault: Bool diff --git a/Core/Core/Domain/Model/PrimaryEnrollment.swift b/Core/Core/Domain/Model/PrimaryEnrollment.swift index 3f213aae5..9d7e4695d 100644 --- a/Core/Core/Domain/Model/PrimaryEnrollment.swift +++ b/Core/Core/Domain/Model/PrimaryEnrollment.swift @@ -7,7 +7,7 @@ import Foundation -public struct PrimaryEnrollment: Hashable { +public struct PrimaryEnrollment: Hashable, Sendable { public let primaryCourse: PrimaryCourse? public var courses: [CourseItem] public let totalPages: Int @@ -21,7 +21,7 @@ public struct PrimaryEnrollment: Hashable { } } -public struct PrimaryCourse: Hashable { +public struct PrimaryCourse: Hashable, Sendable { public let name: String public let org: String public let courseID: String @@ -67,7 +67,7 @@ public struct PrimaryCourse: Hashable { } } -public struct Assignment: Hashable { +public struct Assignment: Hashable, Sendable { public let type: String public let title: String public let description: String? diff --git a/Core/Core/Domain/Model/ResetPassword.swift b/Core/Core/Domain/Model/ResetPassword.swift index e6a269bf1..708810789 100644 --- a/Core/Core/Domain/Model/ResetPassword.swift +++ b/Core/Core/Domain/Model/ResetPassword.swift @@ -7,7 +7,7 @@ import Foundation -public struct ResetPassword { +public struct ResetPassword: Sendable { public let success: Bool public let responseText: String diff --git a/Core/Core/Domain/Model/SyncStatus.swift b/Core/Core/Domain/Model/SyncStatus.swift index a32d0cc4f..5891bf2a5 100644 --- a/Core/Core/Domain/Model/SyncStatus.swift +++ b/Core/Core/Domain/Model/SyncStatus.swift @@ -7,7 +7,7 @@ import Foundation -public enum SyncStatus { +public enum SyncStatus: Sendable { case synced case failed case offline diff --git a/Core/Core/Domain/Model/User.swift b/Core/Core/Domain/Model/User.swift index 62fa6c4dc..48fa92f7e 100644 --- a/Core/Core/Domain/Model/User.swift +++ b/Core/Core/Domain/Model/User.swift @@ -7,7 +7,7 @@ import Foundation -public struct User { +public struct User: Sendable { public let id: Int public let username: String public let email: String diff --git a/Core/Core/Domain/Model/UserProfile.swift b/Core/Core/Domain/Model/UserProfile.swift index 2ad1b6456..6929c312a 100644 --- a/Core/Core/Domain/Model/UserProfile.swift +++ b/Core/Core/Domain/Model/UserProfile.swift @@ -7,7 +7,7 @@ import Foundation -public struct UserProfile: Hashable { +public struct UserProfile: Hashable, Sendable { public let avatarUrl: String public let name: String public let username: String diff --git a/Core/Core/Domain/OfflineSyncInteractor.swift b/Core/Core/Domain/OfflineSyncInteractor.swift index 36bb39766..84f427022 100644 --- a/Core/Core/Domain/OfflineSyncInteractor.swift +++ b/Core/Core/Domain/OfflineSyncInteractor.swift @@ -8,11 +8,11 @@ import Foundation //sourcery: AutoMockable -public protocol OfflineSyncInteractorProtocol { +public protocol OfflineSyncInteractorProtocol: Sendable { func submitOfflineProgress(courseID: String, blockID: String, data: String) async throws -> Bool } -public class OfflineSyncInteractor: OfflineSyncInteractorProtocol { +public actor OfflineSyncInteractor: OfflineSyncInteractorProtocol { private let repository: OfflineSyncRepositoryProtocol public init(repository: OfflineSyncRepositoryProtocol) { diff --git a/Core/Core/Extensions/DateExtension.swift b/Core/Core/Extensions/DateExtension.swift index cb07e81f6..a4a49e462 100644 --- a/Core/Core/Extensions/DateExtension.swift +++ b/Core/Core/Extensions/DateExtension.swift @@ -16,7 +16,7 @@ public extension Date { dateFormatter = DateFormatter() dateFormatter?.locale = .current - date = formats.compactMap { format in + date = formats.compactMap { format -> Date? in dateFormatter?.dateFormat = format guard let formattedDate = dateFormatter?.date(from: iso8601) else { return nil } let components = calender.dateComponents( diff --git a/Core/Core/Extensions/ViewExtension.swift b/Core/Core/Extensions/ViewExtension.swift index cec65c994..92766f5e6 100644 --- a/Core/Core/Extensions/ViewExtension.swift +++ b/Core/Core/Extensions/ViewExtension.swift @@ -10,6 +10,7 @@ import Foundation import SwiftUI import Theme +@MainActor public extension View { func cardStyle( top: CGFloat? = 0, @@ -163,6 +164,7 @@ public extension View { } public extension Image { + @MainActor func backButtonStyle(topPadding: CGFloat = -10, color: Color = Theme.Colors.accentColor) -> some View { return self .renderingMode(.template) diff --git a/Core/Core/Network/AuthEndpoint.swift b/Core/Core/Network/AuthEndpoint.swift index c4f70adc3..d5fe55296 100644 --- a/Core/Core/Network/AuthEndpoint.swift +++ b/Core/Core/Network/AuthEndpoint.swift @@ -66,7 +66,7 @@ enum AuthEndpoint: EndPointType { var task: HTTPTask { switch self { case let .getAccessToken(username, password, clientId, tokenType): - let params: [String: Encodable] = [ + let params: [String: Encodable & Sendable] = [ "grant_type": Constants.GrantTypePassword, "client_id": clientId, "username": username, @@ -76,7 +76,7 @@ enum AuthEndpoint: EndPointType { ] return .requestParameters(parameters: params, encoding: URLEncoding.httpBody) case let .exchangeAccessToken(externalToken, _, clientId, tokenType): - let params: [String: Encodable] = [ + let params: [String: Encodable & Sendable] = [ "client_id": clientId, "token_type": tokenType, "access_token": externalToken, diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index d7e277066..0c7b7ee8a 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -6,12 +6,12 @@ // import SwiftUI -import Combine +@preconcurrency import Combine import ZipArchive import OEXFoundation import Alamofire -public enum DownloadState: String { +public enum DownloadState: String, Sendable { case waiting case inProgress case finished @@ -28,12 +28,12 @@ public enum DownloadState: String { } } -public enum DownloadType: String { +public enum DownloadType: String, Sendable { case video case html, problem } -public struct DownloadDataTask: Identifiable, Hashable { +public struct DownloadDataTask: Identifiable, Hashable, Sendable { public let id: String public let courseId: String public let blockId: String @@ -103,14 +103,15 @@ public struct DownloadDataTask: Identifiable, Hashable { } } -public class NoWiFiError: LocalizedError { +public class NoWiFiError: LocalizedError, @unchecked Sendable { public init() {} } //sourcery: AutoMockable -public protocol DownloadManagerProtocol { +@MainActor +public protocol DownloadManagerProtocol: Sendable { var currentDownloadTask: DownloadDataTask? { get } - func publisher() -> AnyPublisher + func publisher() throws -> AnyPublisher func eventPublisher() -> AnyPublisher func addToDownloadQueue(blocks: [CourseBlock]) async throws @@ -126,8 +127,8 @@ public protocol DownloadManagerProtocol { func deleteFile(blocks: [CourseBlock]) async func deleteAllFiles() async - func fileUrl(for blockId: String) -> URL? - func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] + func fileUrl(for blockId: String) async -> URL? + func updateUnzippedFileSize(for sequentials: [CourseSequential]) async -> [CourseSequential] func resumeDownloading() async throws func isLargeVideosSize(blocks: [CourseBlock]) -> Bool @@ -135,7 +136,7 @@ public protocol DownloadManagerProtocol { func removeAppSupportDirectoryUnusedContent() } -public enum DownloadManagerEvent { +public enum DownloadManagerEvent: Sendable { case added case started(DownloadDataTask) case progress(Double, DownloadDataTask) @@ -151,16 +152,16 @@ public enum DownloadManagerEvent { public class DownloadManager: DownloadManagerProtocol { // MARK: - Properties - public var currentDownloadTask: DownloadDataTask? + public nonisolated(unsafe) var currentDownloadTask: DownloadDataTask? private let persistence: CorePersistenceProtocol private let appStorage: CoreStorage private let connectivity: ConnectivityProtocol private var downloadRequest: DownloadRequest? private var isDownloadingInProgress: Bool = false - private var currentDownloadEventPublisher: PassthroughSubject = .init() + private nonisolated(unsafe) var currentDownloadEventPublisher: PassthroughSubject = .init() private let backgroundTaskProvider = BackgroundTaskProvider() private var cancellables = Set() - private var failedDownloads: [DownloadDataTask] = [] + private nonisolated(unsafe) var failedDownloads: [DownloadDataTask] = [] private let indexPage = "index.html" @@ -169,33 +170,35 @@ public class DownloadManager: DownloadManagerProtocol { } // MARK: - Init - public init( persistence: CorePersistenceProtocol, appStorage: CoreStorage, connectivity: ConnectivityProtocol ) { self.persistence = persistence - if let userId = appStorage.user?.id { - self.persistence.set(userId: userId) - } self.appStorage = appStorage self.connectivity = connectivity - self.backgroundTask() - Task { - try? await self.resumeDownloading() + + if let userId = appStorage.user?.id { + persistence.set(userId: userId) + self.backgroundTask() + Task { + try? await resumeDownloading() + } } NotificationCenter.default.publisher(for: .tryDownloadAgain) .compactMap { $0.object as? [DownloadDataTask] } .sink { [weak self] downloads in - self?.tryDownloadAgain(downloads: downloads) + Task { + await self?.tryDownloadAgain(downloads: downloads) + } } .store(in: &cancellables) } - private func tryDownloadAgain(downloads: [DownloadDataTask]) { - persistence.addToDownloadQueue(tasks: downloads) + private func tryDownloadAgain(downloads: [DownloadDataTask]) async { + await persistence.addToDownloadQueue(tasks: downloads) Task { try? await newDownload() } @@ -203,8 +206,8 @@ public class DownloadManager: DownloadManagerProtocol { // MARK: - Publishers - public func publisher() -> AnyPublisher { - persistence.publisher() + public func publisher() throws -> AnyPublisher { + try persistence.publisher() } public func eventPublisher() -> AnyPublisher { @@ -268,7 +271,7 @@ public class DownloadManager: DownloadManagerProtocol { public func cancelDownloading(task: DownloadDataTask) async throws { downloadRequest?.cancel() do { - if let fileUrl = fileUrl(for: task.id) { + if let fileUrl = await fileUrl(for: task.id) { try FileManager.default.removeItem(at: fileUrl) } try await persistence.deleteDownloadDataTask(id: task.id) @@ -298,7 +301,7 @@ public class DownloadManager: DownloadManagerProtocol { public func deleteFile(blocks: [CourseBlock]) async { for block in blocks { do { - if let fileURL = fileOrFolderUrl(for: block.id), + if let fileURL = await fileOrFolderUrl(for: block.id), FileManager.default.fileExists(atPath: fileURL.path) { try FileManager.default.removeItem(at: fileURL) } @@ -310,14 +313,14 @@ public class DownloadManager: DownloadManagerProtocol { } } - public func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] { + public func updateUnzippedFileSize(for sequentials: [CourseSequential]) async -> [CourseSequential] { var updatedSequentials = sequentials for i in 0.. URL? { - guard let data = persistence.downloadDataTask(for: blockId), + public func fileUrl(for blockId: String) async -> URL? { + guard let data = await persistence.downloadDataTask(for: blockId), data.url.count > 0, data.state == .finished else { return nil } let path = filesFolderUrl @@ -397,8 +400,8 @@ public class DownloadManager: DownloadManagerProtocol { } } - public func fileOrFolderUrl(for blockId: String) -> URL? { - guard let data = persistence.downloadDataTask(for: blockId), + public func fileOrFolderUrl(for blockId: String) async -> URL? { + guard let data = await persistence.downloadDataTask(for: blockId), data.url.count > 0, data.state == .finished else { return nil } let path = filesFolderUrl @@ -417,6 +420,7 @@ public class DownloadManager: DownloadManagerProtocol { // MARK: - Private Intents + @MainActor private func newDownload() async throws { guard userCanDownload() else { throw NoWiFiError() @@ -432,6 +436,7 @@ public class DownloadManager: DownloadManagerProtocol { self.failedDownloads = [] } } + print(">>> IS NIL") return } if !connectivity.isInternetAvaliable { @@ -442,9 +447,9 @@ public class DownloadManager: DownloadManagerProtocol { currentDownloadTask = downloadTask if downloadTask.type == .html || downloadTask.type == .problem { - try downloadHTMLWithProgress(downloadTask) + try await downloadHTMLWithProgress(downloadTask) } else { - try downloadFileWithProgress(downloadTask) + try await downloadFileWithProgress(downloadTask) } currentDownloadEventPublisher.send(.started(downloadTask)) } @@ -461,12 +466,12 @@ public class DownloadManager: DownloadManagerProtocol { } } - private func downloadFileWithProgress(_ download: DownloadDataTask) throws { + private func downloadFileWithProgress(_ download: DownloadDataTask) async throws { guard let url = URL(string: download.url), let folderURL = self.filesFolderUrl else { return } - persistence.updateDownloadState( + await persistence.updateDownloadState( id: download.id, state: .inProgress, resumeData: download.resumeData @@ -484,7 +489,7 @@ public class DownloadManager: DownloadManagerProtocol { downloadRequest = AF.download(url, to: destination) } - downloadRequest?.downloadProgress { [weak self] prog in + downloadRequest?.downloadProgress { @Sendable [weak self] prog in guard let self = self else { return } let fractionCompleted = prog.fractionCompleted self.currentDownloadTask?.progress = fractionCompleted @@ -506,26 +511,26 @@ public class DownloadManager: DownloadManagerProtocol { } } if response.fileURL != nil { - self.persistence.updateDownloadState( - id: download.id, - state: .finished, - resumeData: nil - ) - self.currentDownloadTask?.state = .finished - self.currentDownloadEventPublisher.send(.finished(download)) Task { + await self.persistence.updateDownloadState( + id: download.id, + state: .finished, + resumeData: nil + ) + self.currentDownloadTask?.state = .finished + self.currentDownloadEventPublisher.send(.finished(download)) try? await self.newDownload() } } } } - private func downloadHTMLWithProgress(_ download: DownloadDataTask) throws { + private func downloadHTMLWithProgress(_ download: DownloadDataTask) async throws { guard let url = URL(string: download.url), let folderURL = self.filesFolderUrl else { return } - persistence.updateDownloadState( + await persistence.updateDownloadState( id: download.id, state: .inProgress, resumeData: download.resumeData @@ -566,15 +571,15 @@ public class DownloadManager: DownloadManagerProtocol { } } if let fileURL = response.fileURL { - self.unzipFile(url: fileURL) - self.persistence.updateDownloadState( - id: download.id, - state: .finished, - resumeData: nil - ) - self.currentDownloadTask?.state = .finished - self.currentDownloadEventPublisher.send(.finished(download)) Task { + await unzipFile(url: fileURL) + await self.persistence.updateDownloadState( + id: download.id, + state: .finished, + resumeData: nil + ) + self.currentDownloadTask?.state = .finished + self.currentDownloadEventPublisher.send(.finished(download)) try? await self.newDownload() } } @@ -584,7 +589,7 @@ public class DownloadManager: DownloadManagerProtocol { private func waitingAll() async { let tasks = await persistence.getDownloadDataTasks() for task in tasks.filter({ $0.state == .inProgress }) { - self.persistence.updateDownloadState( + await self.persistence.updateDownloadState( id: task.id, state: .waiting, resumeData: nil @@ -597,7 +602,7 @@ public class DownloadManager: DownloadManagerProtocol { private func cancel(tasks: [DownloadDataTask]) async { for task in tasks { do { - if let fileUrl = fileUrl(for: task.id) { + if let fileUrl = await fileUrl(for: task.id) { try FileManager.default.removeItem(at: fileUrl) } try await persistence.deleteDownloadDataTask(id: task.id) @@ -742,7 +747,7 @@ public class DownloadManager: DownloadManagerProtocol { } @available(iOSApplicationExtension, unavailable) -public final class BackgroundTaskProvider { +public final class BackgroundTaskProvider: @unchecked Sendable { private var backgroundTask: UIBackgroundTaskIdentifier = .invalid private var currentEventPublisher: PassthroughSubject = .init() @@ -788,12 +793,14 @@ public final class BackgroundTaskProvider { ) } + @MainActor @objc func didEnterBackgroundNotification() { registerBackgroundTask() currentEventPublisher.send(.didEnterBackground) } + @MainActor @objc func didBecomeActiveNotification() { endBackgroundTaskIfActive() @@ -802,13 +809,17 @@ public final class BackgroundTaskProvider { // MARK: - Background Task - + @MainActor private func registerBackgroundTask() { backgroundTask = UIApplication.shared.beginBackgroundTask { [weak self] in debugLog("iOS has signaled time has expired") - self?.endBackgroundTaskIfActive() + Task { @MainActor in + self?.endBackgroundTaskIfActive() + } } } + @MainActor private func endBackgroundTaskIfActive() { let isBackgroundTaskActive = backgroundTask != .invalid if isBackgroundTaskActive { diff --git a/Core/Core/Network/OfflineSyncManager.swift b/Core/Core/Network/OfflineSyncManager.swift index 295d79bec..bb4d995f5 100644 --- a/Core/Core/Network/OfflineSyncManager.swift +++ b/Core/Core/Network/OfflineSyncManager.swift @@ -6,16 +6,17 @@ // import Foundation -import WebKit -import Combine +@preconcurrency import WebKit +@preconcurrency import Combine import Swinject import OEXFoundation -public protocol OfflineSyncManagerProtocol { - func handleMessage(message: WKScriptMessage, blockID: String) +public protocol OfflineSyncManagerProtocol: Sendable { + func handleMessage(message: WKScriptMessage, blockID: String) async func syncOfflineProgress() async } +@MainActor public class OfflineSyncManager: OfflineSyncManagerProtocol { let persistence: CorePersistenceProtocol @@ -44,26 +45,26 @@ public class OfflineSyncManager: OfflineSyncManagerProtocol { }).store(in: &cancellables) } - public func handleMessage(message: WKScriptMessage, blockID: String) { + public func handleMessage(message: WKScriptMessage, blockID: String) async { if message.name == "IOSBridge", let progressJson = message.body as? String { - persistence.saveOfflineProgress( + await persistence.saveOfflineProgress( progress: OfflineProgress( progressJson: progressJson ) ) var correctedProgressJson = progressJson correctedProgressJson = correctedProgressJson.removingPercentEncoding ?? correctedProgressJson - message.webView?.evaluateJavaScript("markProblemCompleted('\(correctedProgressJson)')") - } else if let offlineProgress = persistence.loadProgress(for: blockID) { + _ = message.webView?.evaluateJavaScript("markProblemCompleted('\(correctedProgressJson)')") { _, _ in } + } else if let offlineProgress = await persistence.loadProgress(for: blockID) { var correctedProgressJson = offlineProgress.progressJson correctedProgressJson = correctedProgressJson.removingPercentEncoding ?? correctedProgressJson - message.webView?.evaluateJavaScript("markProblemCompleted('\(correctedProgressJson)')") + _ = message.webView?.evaluateJavaScript("markProblemCompleted('\(correctedProgressJson)')") { _, _ in } } } public func syncOfflineProgress() async { - let offlineProgress = persistence.loadAllOfflineProgress() + let offlineProgress = await persistence.loadAllOfflineProgress() let cookies = HTTPCookieStorage.shared.cookies HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } for progress in offlineProgress { @@ -73,7 +74,7 @@ public class OfflineSyncManager: OfflineSyncManagerProtocol { blockID: progress.blockID, data: progress.data ) { - persistence.deleteProgress(for: progress.blockID) + await persistence.deleteProgress(for: progress.blockID) } if let config = Container.shared.resolve(ConfigProtocol.self), let cookies { HTTPCookieStorage.shared.setCookies(cookies, for: config.baseURL, mainDocumentURL: nil) diff --git a/Core/Core/Network/RequestInterceptor.swift b/Core/Core/Network/RequestInterceptor.swift index 860fa070d..73fe5970c 100644 --- a/Core/Core/Network/RequestInterceptor.swift +++ b/Core/Core/Network/RequestInterceptor.swift @@ -8,10 +8,15 @@ import Foundation import Alamofire +private struct MutableState { + var isRefreshing = false + var requestsToRetry: [@Sendable (RetryResult) -> Void] = [] +} + final public class RequestInterceptor: Alamofire.RequestInterceptor { private let config: ConfigProtocol - private var storage: CoreStorage + private let storage: CoreStorage public init(config: ConfigProtocol, storage: CoreStorage) { self.config = config @@ -19,9 +24,7 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { } private let lock = NSLock() - - private var isRefreshing = false - private var requestsToRetry: [(RetryResult) -> Void] = [] + private let mutableState = Protected(MutableState()) public func adapt( _ urlRequest: URLRequest, @@ -62,7 +65,7 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { _ request: Request, for session: Session, dueTo error: Error, - completion: @escaping (RetryResult) -> Void) { + completion: @escaping @Sendable (RetryResult) -> Void) { lock.lock(); defer { lock.unlock() } guard let response = request.task?.response as? HTTPURLResponse, @@ -78,9 +81,9 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { return completion(.doNotRetryWithError(error)) } - requestsToRetry.append(completion) + mutableState.requestsToRetry.append(completion) - if !isRefreshing { + if !mutableState.isRefreshing { refreshToken(refreshToken: token) { [weak self] succeeded in guard let self = self else { return } @@ -88,10 +91,10 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { if succeeded { //Retry all requests - self.requestsToRetry.forEach { request in + self.mutableState.requestsToRetry.forEach { request in request(.retry) } - self.requestsToRetry.removeAll() + self.mutableState.requestsToRetry.removeAll() } else { NotificationCenter.default.post( name: .userLoggedOut, @@ -105,15 +108,15 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { private func refreshToken( refreshToken: String, - completion: @escaping (_ succeeded: Bool) -> Void + completion: @escaping @Sendable (_ succeeded: Bool) -> Void ) { - guard !isRefreshing else { return } + guard !mutableState.isRefreshing else { return } - isRefreshing = true + mutableState.isRefreshing = true let url = config.baseURL.appendingPathComponent("/oauth2/access_token") - let parameters: [String: Encodable] = [ + let parameters: [String: Encodable & Sendable] = [ "grant_type": Constants.GrantTypeRefreshToken, "client_id": config.oAuthClientId, "refresh_token": refreshToken, @@ -141,8 +144,9 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { refreshToken.count > 0 else { return completion(false) } - self.storage.accessToken = accessToken - self.storage.refreshToken = refreshToken + var localStorage = self.storage + localStorage.accessToken = accessToken + localStorage.refreshToken = refreshToken completion(true) } catch { completion(false) @@ -150,7 +154,7 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { case .failure: completion(false) } - self.isRefreshing = false + self.mutableState.isRefreshing = false } } } diff --git a/Core/Core/SwiftGen/Assets.swift b/Core/Core/SwiftGen/Assets.swift index 48ebcc9cc..14c34ddcf 100644 --- a/Core/Core/SwiftGen/Assets.swift +++ b/Core/Core/SwiftGen/Assets.swift @@ -144,8 +144,8 @@ public enum CoreAssets { // MARK: - Implementation Details -public final class ColorAsset { - public fileprivate(set) var name: String +public final class ColorAsset: Sendable { + public let name: String #if os(macOS) public typealias Color = NSColor @@ -154,12 +154,7 @@ public final class ColorAsset { #endif @available(iOS 11.0, tvOS 11.0, watchOS 4.0, macOS 10.13, *) - public private(set) lazy var color: Color = { - guard let color = Color(asset: self) else { - fatalError("Unable to load color asset named \(name).") - } - return color - }() + public let color: Color #if os(iOS) || os(tvOS) @available(iOS 11.0, tvOS 11.0, *) @@ -174,26 +169,30 @@ public final class ColorAsset { #if canImport(SwiftUI) @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) - public private(set) lazy var swiftUIColor: SwiftUI.Color = { - SwiftUI.Color(asset: self) - }() + public var swiftUIColor: SwiftUI.Color { + SwiftUI.Color(uiColor: color) + } #endif fileprivate init(name: String) { self.name = name + guard let color = Color(assetName: name) else { + fatalError("Unable to load color asset named \(name).") + } + self.color = color } } public extension ColorAsset.Color { @available(iOS 11.0, tvOS 11.0, watchOS 4.0, macOS 10.13, *) - convenience init?(asset: ColorAsset) { + convenience init?(assetName: String) { let bundle = BundleToken.bundle #if os(iOS) || os(tvOS) - self.init(named: asset.name, in: bundle, compatibleWith: nil) + self.init(named: assetName, in: bundle, compatibleWith: nil) #elseif os(macOS) - self.init(named: NSColor.Name(asset.name), bundle: bundle) + self.init(named: NSColor.Name(assetName), bundle: bundle) #elseif os(watchOS) - self.init(named: asset.name) + self.init(named: assetName) #endif } } @@ -201,15 +200,15 @@ public extension ColorAsset.Color { #if canImport(SwiftUI) @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) public extension SwiftUI.Color { - init(asset: ColorAsset) { + init(assetName: String) { let bundle = BundleToken.bundle - self.init(asset.name, bundle: bundle) + self.init(assetName, bundle: bundle) } } #endif -public struct ImageAsset { - public fileprivate(set) var name: String +public struct ImageAsset: Sendable { + public let name: String #if os(macOS) public typealias Image = NSImage diff --git a/Core/Core/System/Protected.swift b/Core/Core/System/Protected.swift new file mode 100644 index 000000000..9c1c1e188 --- /dev/null +++ b/Core/Core/System/Protected.swift @@ -0,0 +1,133 @@ +// +// Protected.swift +// Core +// +// Created by Ivan Stepanok on 14.11.2024. +// + + +import Foundation + +private protocol Lock: Sendable { + func lock() + func unlock() +} + +extension Lock { + /// Executes a closure returning a value while acquiring the lock. + /// + /// - Parameter closure: The closure to run. + /// + /// - Returns: The value the closure generated. + func around(_ closure: () throws -> T) rethrows -> T { + lock(); defer { unlock() } + return try closure() + } + + /// Execute a closure while acquiring the lock. + /// + /// - Parameter closure: The closure to run. + func around(_ closure: () throws -> Void) rethrows { + lock(); defer { unlock() } + try closure() + } +} + +#if canImport(Darwin) +// Number of Apple engineers who insisted on inspecting this: 5 +/// An `os_unfair_lock` wrapper. +final class UnfairLock: Lock, @unchecked Sendable { + private let unfairLock: os_unfair_lock_t + + init() { + unfairLock = .allocate(capacity: 1) + unfairLock.initialize(to: os_unfair_lock()) + } + + deinit { + unfairLock.deinitialize(count: 1) + unfairLock.deallocate() + } + + fileprivate func lock() { + os_unfair_lock_lock(unfairLock) + } + + fileprivate func unlock() { + os_unfair_lock_unlock(unfairLock) + } +} + +#elseif canImport(Foundation) +extension NSLock: Lock {} +#else +#error("This platform needs a Lock-conforming type without Foundation.") +#endif + +/// A thread-safe wrapper around a value. +@dynamicMemberLookup +public final class Protected: Sendable { + #if canImport(Darwin) + private let lock = UnfairLock() + #elseif canImport(Foundation) + private let lock = NSLock() + #else + #error("This platform needs a Lock-conforming type without Foundation.") + #endif + #if compiler(>=6) + private nonisolated(unsafe) var value: Value + #else + private var value: Value + #endif + + public init(_ value: Value) { + self.value = value + } + + /// Synchronously read or transform the contained value. + /// + /// - Parameter closure: The closure to execute. + /// + /// - Returns: The return value of the closure passed. + func read(_ closure: (Value) throws -> U) rethrows -> U { + try lock.around { try closure(self.value) } + } + + /// Synchronously modify the protected value. + /// + /// - Parameter closure: The closure to execute. + /// + /// - Returns: The modified value. + @discardableResult + func write(_ closure: (inout Value) throws -> U) rethrows -> U { + try lock.around { try closure(&self.value) } + } + + /// Synchronously update the protected value. + /// + /// - Parameter value: The `Value`. + func write(_ value: Value) { + write { $0 = value } + } + + public subscript(dynamicMember keyPath: WritableKeyPath) -> Property { + get { lock.around { value[keyPath: keyPath] } } + set { lock.around { value[keyPath: keyPath] = newValue } } + } + + public subscript(dynamicMember keyPath: KeyPath) -> Property { + lock.around { value[keyPath: keyPath] } + } +} + +extension Protected: Equatable where Value: Equatable { + public static func == (lhs: Protected, rhs: Protected) -> Bool { + lhs.read { left in rhs.read { right in left == right }} + } +} + +extension Protected: Hashable where Value: Hashable { + public func hash(into hasher: inout Hasher) { + read { hasher.combine($0) } + } +} diff --git a/Core/Core/View/Base/AppReview/AppReviewViewModel.swift b/Core/Core/View/Base/AppReview/AppReviewViewModel.swift index 69026248c..4b9f66c64 100644 --- a/Core/Core/View/Base/AppReview/AppReviewViewModel.swift +++ b/Core/Core/View/Base/AppReview/AppReviewViewModel.swift @@ -8,6 +8,7 @@ import SwiftUI import StoreKit +@MainActor public class AppReviewViewModel: ObservableObject { enum ReviewState { diff --git a/Core/Core/View/Base/AppReview/ThirdPartyMailer/ThirdPartyMailer.swift b/Core/Core/View/Base/AppReview/ThirdPartyMailer/ThirdPartyMailer.swift index ded79fcda..6f17ac02d 100644 --- a/Core/Core/View/Base/AppReview/ThirdPartyMailer/ThirdPartyMailer.swift +++ b/Core/Core/View/Base/AppReview/ThirdPartyMailer/ThirdPartyMailer.swift @@ -9,6 +9,7 @@ import UIKit /// Tests third party mail clients availability, and opens third party mail clients in compose mode. @available(iOSApplicationExtension, unavailable) +@MainActor open class ThirdPartyMailer { /// Tests the availability of a third-party mail client. @@ -30,7 +31,10 @@ open class ThirdPartyMailer { /// - Parameters: /// - client: The third-party client to open. /// - completion: The block to execute with the results (optional, default value is `nil`). - open class func open(_ client: ThirdPartyMailClient = .systemDefault, completionHandler completion: ((Bool) -> Void)? = nil) { + open class func open( + _ client: ThirdPartyMailClient = .systemDefault, + completionHandler completion: ( @Sendable (Bool) -> Void)? = nil + ) { let url = client.openURL() let application = UIApplication.shared application.open(url, options: [:], completionHandler: completion) @@ -45,9 +49,21 @@ open class ThirdPartyMailer { /// - cc: The email address of the recipient carbon copy (optional, default value is `nil`). /// - bcc: The email address of the recipient blind carbon copy (optional, default value is `nil`). /// - completion: The block to execute with the results (optional, default value is `nil`). - open class func openCompose(_ client: ThirdPartyMailClient = .systemDefault, recipient: String? = nil, subject: String? = nil, body: String? = nil, cc: String? = nil, bcc: String? = nil, with application: UIApplication = .shared, completionHandler completion: ((Bool) -> Void)? = nil) { + open class func openCompose( + _ client: ThirdPartyMailClient = .systemDefault, + recipient: String? = nil, + subject: String? = nil, + body: String? = nil, + cc: String? = nil, + bcc: String? = nil, + with application: UIApplication = .shared, + completionHandler completion: ( + @Sendable (Bool) -> Void + )? = nil + ) { let url = client.composeURL(to: recipient, subject: subject, body: body, cc: cc, bcc: bcc) let application = UIApplication.shared application.open(url, options: [:], completionHandler: completion) } + } diff --git a/Core/Core/View/Base/BackNavigationButtonViewModel.swift b/Core/Core/View/Base/BackNavigationButtonViewModel.swift index 59dfe97a5..cce158e2c 100644 --- a/Core/Core/View/Base/BackNavigationButtonViewModel.swift +++ b/Core/Core/View/Base/BackNavigationButtonViewModel.swift @@ -9,6 +9,7 @@ import Swinject import UIKit import OEXFoundation +@MainActor public protocol BackNavigationProtocol { func getBackMenuItems() -> [BackNavigationMenuItem] func navigateTo(item: BackNavigationMenuItem) @@ -24,6 +25,7 @@ public struct BackNavigationMenuItem: Identifiable { } } +@MainActor class BackNavigationButtonViewModel: ObservableObject { private let helper: BackNavigationProtocol @Published var items: [BackNavigationMenuItem] = [] diff --git a/Core/Core/View/Base/CalendarManagerProtocol.swift b/Core/Core/View/Base/CalendarManagerProtocol.swift index 152c0dc21..51bbe874d 100644 --- a/Core/Core/View/Base/CalendarManagerProtocol.swift +++ b/Core/Core/View/Base/CalendarManagerProtocol.swift @@ -8,16 +8,17 @@ import Foundation //sourcery: AutoMockable -public protocol CalendarManagerProtocol { +@MainActor +public protocol CalendarManagerProtocol: Sendable { func createCalendarIfNeeded() func filterCoursesBySelected(fetchedCourses: [CourseForSync]) async -> [CourseForSync] func removeOldCalendar() func removeOutdatedEvents(courseID: String) async func syncCourse(courseID: String, courseName: String, dates: CourseDates) async func requestAccess() async -> Bool - func courseStatus(courseID: String) -> SyncStatus - func clearAllData(removeCalendar: Bool) - func isDatesChanged(courseID: String, checksum: String) -> Bool + func courseStatus(courseID: String) async -> SyncStatus + func clearAllData(removeCalendar: Bool) async + func isDatesChanged(courseID: String, checksum: String) async -> Bool } #if DEBUG @@ -30,7 +31,7 @@ public struct CalendarManagerMock: CalendarManagerProtocol { public func requestAccess() async -> Bool { true } public func courseStatus(courseID: String) -> SyncStatus { .synced } public func clearAllData(removeCalendar: Bool) {} - public func isDatesChanged(courseID: String, checksum: String) -> Bool {false} + public func isDatesChanged(courseID: String, checksum: String) async -> Bool {false} public init() {} } diff --git a/Core/Core/View/Base/DownloadView.swift b/Core/Core/View/Base/DownloadView.swift index ede7c2912..5b6165c08 100644 --- a/Core/Core/View/Base/DownloadView.swift +++ b/Core/Core/View/Base/DownloadView.swift @@ -8,7 +8,7 @@ import SwiftUI import Theme -public enum DownloadViewState { +public enum DownloadViewState: Sendable { case available case downloading case finished diff --git a/Core/Core/View/Base/FlexibleKeyboardInputView.swift b/Core/Core/View/Base/FlexibleKeyboardInputView.swift index fdf47efdc..ab4524405 100644 --- a/Core/Core/View/Base/FlexibleKeyboardInputView.swift +++ b/Core/Core/View/Base/FlexibleKeyboardInputView.swift @@ -118,7 +118,7 @@ struct FlexibleKeyboardInputView_Previews: PreviewProvider { } private struct ViewSizePreferenceKey: PreferenceKey { - public static var defaultValue: CGSize = .zero + public static let defaultValue: CGSize = .zero public static func reduce(value: inout CGSize, nextValue: () -> CGSize) { value = value.width + value.height > nextValue().width + nextValue().height ? value : nextValue() } diff --git a/Core/Core/View/Base/HTMLFormattedText.swift b/Core/Core/View/Base/HTMLFormattedText.swift index 08131bfe0..0f52c11e3 100644 --- a/Core/Core/View/Base/HTMLFormattedText.swift +++ b/Core/Core/View/Base/HTMLFormattedText.swift @@ -68,7 +68,7 @@ public struct HTMLFormattedText: UIViewRepresentable { Coordinator(self) } - private func convertHTML(text: String) -> NSAttributedString? { + private nonisolated(unsafe) func convertHTML(text: String) -> NSAttributedString? { guard let data = text.data(using: .utf8) else { return nil } if let attributedString = try? NSAttributedString( diff --git a/Core/Core/View/Base/LogistrationBottomView.swift b/Core/Core/View/Base/LogistrationBottomView.swift index faa3aaa35..9b17d6539 100644 --- a/Core/Core/View/Base/LogistrationBottomView.swift +++ b/Core/Core/View/Base/LogistrationBottomView.swift @@ -9,7 +9,7 @@ import Foundation import SwiftUI import Theme -public enum LogistrationSourceScreen: Equatable { +public enum LogistrationSourceScreen: Equatable, Sendable { case `default` case startup case discovery @@ -17,7 +17,7 @@ public enum LogistrationSourceScreen: Equatable { case programDetails(String) } -public enum LogistrationAction { +public enum LogistrationAction: Sendable { case signIn case signInWithSSO case register diff --git a/Core/Core/View/Base/PickerMenu.swift b/Core/Core/View/Base/PickerMenu.swift index c425191c0..2840b24a1 100644 --- a/Core/Core/View/Base/PickerMenu.swift +++ b/Core/Core/View/Base/PickerMenu.swift @@ -8,7 +8,7 @@ import SwiftUI import Theme -public struct PickerItem: Hashable { +public struct PickerItem: Hashable, Sendable { public let key: String public let value: String diff --git a/Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift b/Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift index 5cc0ff18f..2a83304de 100644 --- a/Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift +++ b/Core/Core/View/Base/ScrollSlidingTabBar/FrameReader.swift @@ -32,7 +32,7 @@ extension View { } private struct FramePreferenceKey: PreferenceKey { - static var defaultValue: [PreferenceValueKey: CGRect] = [:] + nonisolated(unsafe) static var defaultValue: [PreferenceValueKey: CGRect] = [:] static func reduce(value: inout [PreferenceValueKey: CGRect], nextValue: () -> [PreferenceValueKey: CGRect]) { value.merge(nextValue()) { $1 } diff --git a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift index ef300e279..61e432771 100644 --- a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift +++ b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift @@ -161,8 +161,9 @@ extension ScrollSlidingTabBar { } } +@MainActor extension ScrollSlidingTabBar { - public struct Style { + public struct Style: Sendable { public let font: Font public let selectedFont: Font diff --git a/Core/Core/View/Base/WebUnitView.swift b/Core/Core/View/Base/WebUnitView.swift index fda1e5040..bb143560f 100644 --- a/Core/Core/View/Base/WebUnitView.swift +++ b/Core/Core/View/Base/WebUnitView.swift @@ -95,7 +95,9 @@ public struct WebUnitView: View { }, connectivity: connectivity, message: { message in - viewModel.syncManager.handleMessage(message: message, blockID: blockID) + Task { + await viewModel.syncManager.handleMessage(message: message, blockID: blockID) + } } ) .frame( diff --git a/Core/Core/View/Base/WebUnitViewModel.swift b/Core/Core/View/Base/WebUnitViewModel.swift index e8bc585c3..693a2e4b6 100644 --- a/Core/Core/View/Base/WebUnitViewModel.swift +++ b/Core/Core/View/Base/WebUnitViewModel.swift @@ -8,7 +8,7 @@ import Foundation import SwiftUI -public class WebUnitViewModel: ObservableObject, WebviewCookiesUpdateProtocol { +public final class WebUnitViewModel: ObservableObject, WebviewCookiesUpdateProtocol { public let authInteractor: AuthInteractorProtocol let config: ConfigProtocol diff --git a/Core/Core/View/Base/Webview/Models/AccessibilityInjection.swift b/Core/Core/View/Base/Webview/Models/AccessibilityInjection.swift index 908580f01..e7554a5bd 100644 --- a/Core/Core/View/Base/Webview/Models/AccessibilityInjection.swift +++ b/Core/Core/View/Base/Webview/Models/AccessibilityInjection.swift @@ -10,7 +10,7 @@ import Foundation import Combine import WebKit -public struct AccessibilityInjection: WebViewScriptInjectionProtocol, CSSInjectionProtocol { +public struct AccessibilityInjection: @preconcurrency WebViewScriptInjectionProtocol, CSSInjectionProtocol { public var id: String = "AccessibilityInjection" public var script: String { return """ @@ -22,7 +22,7 @@ public struct AccessibilityInjection: WebViewScriptInjectionProtocol, CSSInjecti }); """ } - public var messages: [WebviewMessage]? { + @MainActor public var messages: [WebviewMessage]? { let message = WebviewMessage(name: "accessibility") { _, webview in guard let webview = webview else { return } webview.evaluateJavaScript(getScript()) diff --git a/Core/Core/View/Base/Webview/Models/ReadabilityInjection.swift b/Core/Core/View/Base/Webview/Models/ReadabilityInjection.swift index b9d1a895a..ef2e14f07 100644 --- a/Core/Core/View/Base/Webview/Models/ReadabilityInjection.swift +++ b/Core/Core/View/Base/Webview/Models/ReadabilityInjection.swift @@ -7,7 +7,7 @@ import WebKit -public struct ReadabilityInjection: WebViewScriptInjectionProtocol, CSSInjectionProtocol { +public struct ReadabilityInjection: @preconcurrency WebViewScriptInjectionProtocol, CSSInjectionProtocol { public var id: String = "ReadabilityInjection" public var script: String { let uniqueId = UUID().uuidString.replacingOccurrences(of: "-", with: "") @@ -27,6 +27,8 @@ public struct ReadabilityInjection: WebViewScriptInjectionProtocol, CSSInjection }); """ } + + @MainActor public var messages: [WebviewMessage]? { let message = WebviewMessage(name: "readability") { _, webview in guard let webview = webview else { return } diff --git a/Core/Core/View/Base/Webview/WebView.swift b/Core/Core/View/Base/Webview/WebView.swift index c5b21e7ef..2d91fb652 100644 --- a/Core/Core/View/Base/Webview/WebView.swift +++ b/Core/Core/View/Base/Webview/WebView.swift @@ -11,6 +11,7 @@ import SwiftUI import Theme import WebKit +@MainActor public protocol WebViewNavigationDelegate: AnyObject { func webView( _ webView: WKWebView, @@ -176,7 +177,7 @@ public struct WebView: UIViewRepresentable { return .cancel } - let baseURL = await parent.viewModel.baseURL + let baseURL = parent.viewModel.baseURL switch navigationAction.navigationType { case .other, .formSubmitted, .formResubmitted: return .allow @@ -205,7 +206,7 @@ public struct WebView: UIViewRepresentable { let url = response.url else { return .cancel } - let baseURL = await parent.viewModel.baseURL + let baseURL = parent.viewModel.baseURL if (401...404).contains(response.statusCode) || url.absoluteString.hasPrefix(baseURL + "/login") { await parent.refreshCookies() @@ -364,6 +365,8 @@ extension WKWebView { } extension Array where Element == WebviewInjection { + + @MainActor func handle(message: WKScriptMessage) { let messages = compactMap { $0.messages } .flatMap { $0 } diff --git a/Core/Core/View/Base/Webview/WebviewCookiesUpdateProtocol.swift b/Core/Core/View/Base/Webview/WebviewCookiesUpdateProtocol.swift index 1e36c4d4e..707c615ef 100644 --- a/Core/Core/View/Base/Webview/WebviewCookiesUpdateProtocol.swift +++ b/Core/Core/View/Base/Webview/WebviewCookiesUpdateProtocol.swift @@ -8,6 +8,8 @@ import Foundation //sourcery: AutoMockable + +@MainActor public protocol WebviewCookiesUpdateProtocol: AnyObject { var authInteractor: AuthInteractorProtocol { get } var cookiesReady: Bool { get set } diff --git a/Core/CoreTests/CoreMock.generated.swift b/Core/CoreTests/CoreMock.generated.swift index 261c6be74..9b2d098b5 100644 --- a/Core/CoreTests/CoreMock.generated.swift +++ b/Core/CoreTests/CoreMock.generated.swift @@ -506,7 +506,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { } // MARK: - BaseRouter - +@MainActor open class BaseRouterMock: BaseRouter, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -978,7 +978,7 @@ open class BaseRouterMock: BaseRouter, Mock { } // MARK: - CalendarManagerProtocol - +@MainActor open class CalendarManagerProtocolMock: CalendarManagerProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -1845,7 +1845,7 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { } // MARK: - ConnectivityProtocol - +@MainActor open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -2454,16 +2454,19 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { return __value } - open func publisher() -> AnyPublisher { + @MainActor + open func publisher() throws -> AnyPublisher { addInvocation(.m_publisher) let perform = methodPerformValue(.m_publisher) as? () -> Void perform?() var __value: AnyPublisher do { __value = try methodReturnValue(.m_publisher).casted() - } catch { + } catch MockError.notStubed { onFatalFailure("Stub return value not specified for publisher(). Use given") Failure("Stub return value not specified for publisher(). Use given") + } catch { + throw error } return __value } @@ -2755,7 +2758,8 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { public static func getUserID(willReturn: Int?...) -> MethodStub { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + @MainActor + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) } public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { @@ -2783,13 +2787,6 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { willProduce(stubber) return given } - public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { - let willReturn: [AnyPublisher] = [] - let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (AnyPublisher).self) - willProduce(stubber) - return given - } public static func loadProgress(for blockID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [OfflineProgress?] = [] let given: Given = { return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -2832,6 +2829,18 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { willProduce(stubber) return given } + @MainActor + public static func publisher(willThrow: Error...) -> MethodStub { + return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) + } + @MainActor + public static func publisher(willProduce: (StubberThrows>) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (AnyPublisher).self) + willProduce(stubber) + return given + } public static func deleteDownloadDataTask(id: Parameter, willThrow: Error...) -> MethodStub { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -2849,7 +2858,8 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { public static func set(userId: Parameter) -> Verify { return Verify(method: .m_set__userId_userId(`userId`))} public static func getUserID() -> Verify { return Verify(method: .m_getUserID)} - public static func publisher() -> Verify { return Verify(method: .m_publisher)} + @MainActor + public static func publisher() -> Verify { return Verify(method: .m_publisher)} public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>) -> Verify { return Verify(method: .m_addToDownloadQueue__tasks_tasks(`tasks`))} public static func saveOfflineProgress(progress: Parameter) -> Verify { return Verify(method: .m_saveOfflineProgress__progress_progress(`progress`))} public static func loadProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_loadProgress__for_blockID(`blockID`))} @@ -2876,7 +2886,8 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { public static func getUserID(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_getUserID, performs: perform) } - public static func publisher(perform: @escaping () -> Void) -> Perform { + @MainActor + public static func publisher(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_publisher, performs: perform) } public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>, perform: @escaping ([DownloadDataTask]) -> Void) -> Perform { @@ -3397,7 +3408,7 @@ open class CoreStorageMock: CoreStorage, Mock { } // MARK: - DownloadManagerProtocol - +@MainActor open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -3445,16 +3456,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { - open func publisher() -> AnyPublisher { + open func publisher() throws -> AnyPublisher { addInvocation(.m_publisher) let perform = methodPerformValue(.m_publisher) as? () -> Void perform?() var __value: AnyPublisher do { __value = try methodReturnValue(.m_publisher).casted() - } catch { + } catch MockError.notStubed { onFatalFailure("Stub return value not specified for publisher(). Use given") Failure("Stub return value not specified for publisher(). Use given") + } catch { + throw error } return __value } @@ -3801,13 +3814,6 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { - let willReturn: [AnyPublisher] = [] - let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (AnyPublisher).self) - willProduce(stubber) - return given - } public static func eventPublisher(willProduce: (Stubber>) -> Void) -> MethodStub { let willReturn: [AnyPublisher] = [] let given: Given = { return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -3850,6 +3856,16 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func publisher(willThrow: Error...) -> MethodStub { + return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func publisher(willProduce: (StubberThrows>) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (AnyPublisher).self) + willProduce(stubber) + return given + } public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -4260,249 +4276,3 @@ open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, Moc } } -// MARK: - WebviewCookiesUpdateProtocol - -open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { - public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { - SwiftyMockyTestObserver.setup() - self.sequencingPolicy = sequencingPolicy - self.stubbingPolicy = stubbingPolicy - self.file = file - self.line = line - } - - var matcher: Matcher = Matcher.default - var stubbingPolicy: StubbingPolicy = .wrap - var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst - - private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) - private var invocations: [MethodType] = [] - private var methodReturnValues: [Given] = [] - private var methodPerformValues: [Perform] = [] - private var file: StaticString? - private var line: UInt? - - public typealias PropertyStub = Given - public typealias MethodStub = Given - public typealias SubscriptStub = Given - - /// Convenience method - call setupMock() to extend debug information when failure occurs - public func setupMock(file: StaticString = #file, line: UInt = #line) { - self.file = file - self.line = line - } - - /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals - public func resetMock(_ scopes: MockScope...) { - let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes - if scopes.contains(.invocation) { invocations = [] } - if scopes.contains(.given) { methodReturnValues = [] } - if scopes.contains(.perform) { methodPerformValues = [] } - } - - public var authInteractor: AuthInteractorProtocol { - get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } - } - private var __p_authInteractor: (AuthInteractorProtocol)? - - public var cookiesReady: Bool { - get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } - set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } - } - private var __p_cookiesReady: (Bool)? - - public var updatingCookies: Bool { - get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } - set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } - } - private var __p_updatingCookies: (Bool)? - - public var errorMessage: String? { - get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } - set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } - } - private var __p_errorMessage: (String)? - - - - - - open func updateCookies(force: Bool, retryCount: Int) { - addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) - let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void - perform?(`force`, `retryCount`) - } - - - fileprivate enum MethodType { - case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) - case p_authInteractor_get - case p_cookiesReady_get - case p_cookiesReady_set(Parameter) - case p_updatingCookies_get - case p_updatingCookies_set(Parameter) - case p_errorMessage_get - case p_errorMessage_set(Parameter) - - static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { - switch (lhs, rhs) { - case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) - return Matcher.ComparisonResult(results) - case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match - case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match - case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - default: return .none - } - } - - func intValue() -> Int { - switch self { - case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue - case .p_authInteractor_get: return 0 - case .p_cookiesReady_get: return 0 - case .p_cookiesReady_set(let newValue): return newValue.intValue - case .p_updatingCookies_get: return 0 - case .p_updatingCookies_set(let newValue): return newValue.intValue - case .p_errorMessage_get: return 0 - case .p_errorMessage_set(let newValue): return newValue.intValue - } - } - func assertionName() -> String { - switch self { - case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" - case .p_authInteractor_get: return "[get] .authInteractor" - case .p_cookiesReady_get: return "[get] .cookiesReady" - case .p_cookiesReady_set: return "[set] .cookiesReady" - case .p_updatingCookies_get: return "[get] .updatingCookies" - case .p_updatingCookies_set: return "[set] .updatingCookies" - case .p_errorMessage_get: return "[get] .errorMessage" - case .p_errorMessage_set: return "[set] .errorMessage" - } - } - } - - open class Given: StubbedMethod { - fileprivate var method: MethodType - - private init(method: MethodType, products: [StubProduct]) { - self.method = method - super.init(products) - } - - public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { - return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { - return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - - } - - public struct Verify { - fileprivate var method: MethodType - - public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} - public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } - public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } - public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } - public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } - public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } - public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } - public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } - } - - public struct Perform { - fileprivate var method: MethodType - var performs: Any - - public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { - return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) - } - } - - public func given(_ method: Given) { - methodReturnValues.append(method) - } - - public func perform(_ method: Perform) { - methodPerformValues.append(method) - methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } - } - - public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { - let fullMatches = matchingCalls(method, file: file, line: line) - let success = count.matches(fullMatches) - let assertionName = method.method.assertionName() - let feedback: String = { - guard !success else { return "" } - return Utils.closestCallsMessage( - for: self.invocations.map { invocation in - matcher.set(file: file, line: line) - defer { matcher.clearFileAndLine() } - return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) - }, - name: assertionName - ) - }() - MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) - } - - private func addInvocation(_ call: MethodType) { - self.queue.sync { invocations.append(call) } - } - private func methodReturnValue(_ method: MethodType) throws -> StubProduct { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) - let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) - guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } - return product - } - private func methodPerformValue(_ method: MethodType) -> Any? { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } - return matched?.performs - } - private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { - matcher.set(file: file ?? self.file, line: line ?? self.line) - defer { matcher.clearFileAndLine() } - return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } - } - private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { - return matchingCalls(method.method, file: file, line: line).count - } - private func givenGetterValue(_ method: MethodType, _ message: String) -> T { - do { - return try methodReturnValue(method).casted() - } catch { - onFatalFailure(message) - Failure(message) - } - } - private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { - do { - return try methodReturnValue(method).casted() - } catch { - return nil - } - } - private func onFatalFailure(_ message: String) { - guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully - SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) - } -} - diff --git a/Core/CoreTests/DownloadManager/DownloadManagerTests.swift b/Core/CoreTests/DownloadManager/DownloadManagerTests.swift index 67e0aa644..5eb8d8b63 100644 --- a/Core/CoreTests/DownloadManager/DownloadManagerTests.swift +++ b/Core/CoreTests/DownloadManager/DownloadManagerTests.swift @@ -9,6 +9,7 @@ import XCTest import SwiftyMocky @testable import Core +@MainActor final class DownloadManagerTests: XCTestCase { var persistence: CorePersistenceProtocolMock! @@ -101,10 +102,10 @@ final class DownloadManagerTests: XCTestCase { try await downloadManager.resumeDownloading() // Wait a bit for async operations to complete - try? await Task.sleep(nanoseconds: 100_000_000) + await Task.yield() // Then - Verify(persistence, 2, .nextBlockForDownloading()) + Verify(persistence, 1, .nextBlockForDownloading()) XCTAssertEqual(downloadManager.currentDownloadTask?.id, mockTask.id) } @@ -177,7 +178,7 @@ final class DownloadManagerTests: XCTestCase { Verify(persistence, 1, .deleteDownloadDataTask(id: .value(block.id))) } - func testFileUrl_ForFinishedTask_ShouldReturnCorrectUrl() { + func testFileUrl_ForFinishedTask_ShouldReturnCorrectUrl() async { // Given let task = createMockDownloadTask(state: .finished) let mockUser = DataLayer.User( @@ -199,7 +200,7 @@ final class DownloadManagerTests: XCTestCase { ) // When - let url = downloadManager.fileUrl(for: task.id) + let url = await downloadManager.fileUrl(for: task.id) // Then XCTAssertNotNil(url) diff --git a/Core/swiftgen.yml b/Core/swiftgen.yml index 9a3e2187d..b6eee1ab6 100644 --- a/Core/swiftgen.yml +++ b/Core/swiftgen.yml @@ -11,7 +11,7 @@ xcassets: inputs: - Core/Assets.xcassets outputs: - templateName: swift5 + templatePath: ../Template/structured-swift6.stencil params: publicAccess: true enumName: CoreAssets diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index 930494938..90bc7338e 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -1304,7 +1304,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -1338,7 +1338,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -1437,7 +1437,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugDev; @@ -1536,7 +1536,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugProd; @@ -1628,7 +1628,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseDev; @@ -1720,7 +1720,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseProd; @@ -1819,7 +1819,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugStage; @@ -1932,7 +1932,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseStage; @@ -2014,7 +2014,7 @@ repositoryURL = "https://github.com/openedx/openedx-app-foundation-ios/"; requirement = { kind = exactVersion; - version = 1.0.0; + version = 1.0.1; }; }; CEBCA4322CC13CDE00076589 /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */ = { diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index 7210a9f76..8545ecb0f 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -9,7 +9,7 @@ import Foundation import Core import OEXFoundation -public protocol CourseRepositoryProtocol { +public protocol CourseRepositoryProtocol: Sendable { func getCourseBlocks(courseID: String) async throws -> CourseStructure func getLoadedCourseBlocks(courseID: String) async throws -> CourseStructure func blockCompletionRequest(courseID: String, blockID: String) async throws @@ -23,7 +23,7 @@ public protocol CourseRepositoryProtocol { func shiftDueDates(courseID: String) async throws } -public class CourseRepository: CourseRepositoryProtocol { +public actor CourseRepository: CourseRepositoryProtocol { private let api: API private let coreStorage: CoreStorage @@ -46,7 +46,7 @@ public class CourseRepository: CourseRepositoryProtocol { let course = try await api.requestData( CourseEndpoint.getCourseBlocks(courseID: courseID, userName: coreStorage.user?.username ?? "") ).mapResponse(DataLayer.CourseStructure.self) - persistence.saveCourseStructure(structure: course) + await persistence.saveCourseStructure(structure: course) let parsedStructure = parseCourseStructure(course: course) return parsedStructure } @@ -94,7 +94,7 @@ public class CourseRepository: CourseRepositoryProtocol { selectedLanguage: selectedLanguage )) let subtitles = String(data: result, encoding: .utf8) ?? "" - persistence.saveSubtitles(url: url + selectedLanguage, subtitlesString: subtitles) + await persistence.saveSubtitles(url: url + selectedLanguage, subtitlesString: subtitles) return subtitles } } @@ -103,7 +103,7 @@ public class CourseRepository: CourseRepositoryProtocol { let courseDates = try await api.requestData( CourseEndpoint.getCourseDates(courseID: courseID) ).mapResponse(DataLayer.CourseDates.self).domain(useRelativeDates: coreStorage.useRelativeDates) - persistence.saveCourseDates(courseID: courseID, courseDates: courseDates) + await persistence.saveCourseDates(courseID: courseID, courseDates: courseDates) return courseDates } @@ -115,7 +115,7 @@ public class CourseRepository: CourseRepositoryProtocol { } public func getCourseDatesOffline(courseID: String) async throws -> CourseDates { - return try persistence.loadCourseDates(courseID: courseID) + return try await persistence.loadCourseDates(courseID: courseID) } private func parseCourseStructure(course: DataLayer.CourseStructure) -> CourseStructure { @@ -272,6 +272,7 @@ public class CourseRepository: CourseRepositoryProtocol { // Mark - For testing and SwiftUI preview // swiftlint:disable all #if DEBUG +@MainActor class CourseRepositoryMock: CourseRepositoryProtocol { func getCourseDatesOffline(courseID: String) async throws -> CourseDates { throw NoCachedDataError() diff --git a/Course/Course/Data/CourseStorage.swift b/Course/Course/Data/CourseStorage.swift index 1bfc64704..c576b6279 100644 --- a/Course/Course/Data/CourseStorage.swift +++ b/Course/Course/Data/CourseStorage.swift @@ -8,13 +8,13 @@ import Foundation import Core -public protocol CourseStorage { +public protocol CourseStorage: Sendable { var allowedDownloadLargeFile: Bool? { get set } var userSettings: UserSettings? { get set } } #if DEBUG -public class CourseStorageMock: CourseStorage { +public final class CourseStorageMock: CourseStorage, @unchecked Sendable { public var userSettings: UserSettings? diff --git a/Course/Course/Data/Model/Data_CourseOutlineResponse.swift b/Course/Course/Data/Model/Data_CourseOutlineResponse.swift index a4fe30b96..08adc10de 100644 --- a/Course/Course/Data/Model/Data_CourseOutlineResponse.swift +++ b/Course/Course/Data/Model/Data_CourseOutlineResponse.swift @@ -13,7 +13,7 @@ public extension DataLayer { typealias Blocks = [String: CourseBlock] - struct CourseStructure: Decodable { + struct CourseStructure: Decodable, Sendable { public let rootItem: String public var dict: Blocks public let id: String @@ -69,7 +69,7 @@ public extension DataLayer { } } public extension DataLayer { - struct CourseBlock: Decodable { + struct CourseBlock: Decodable, Sendable { public let blockId: String public let id: String public let graded: Bool @@ -134,7 +134,7 @@ public extension DataLayer { } } - struct AssignmentProgress: Codable { + struct AssignmentProgress: Codable, Sendable { public let assignmentType: String? public let numPointsEarned: Double? public let numPointsPossible: Double? @@ -152,7 +152,7 @@ public extension DataLayer { } } - struct OfflineDownload: Codable { + struct OfflineDownload: Codable, Sendable { public let fileUrl: String? public let lastModified: String? public let fileSize: Int? @@ -170,7 +170,7 @@ public extension DataLayer { } } - struct Transcripts: Codable { + struct Transcripts: Codable, Sendable { public let en: String? enum CodingKeys: String, CodingKey { @@ -182,7 +182,7 @@ public extension DataLayer { } } - struct CourseDetailUserViewData: Decodable { + struct CourseDetailUserViewData: Decodable, Sendable { public let transcripts: [String: String]? public let encodedVideo: CourseDetailEncodedVideoData? public let topicID: String? @@ -204,7 +204,7 @@ public extension DataLayer { } } - struct CourseDetailEncodedVideoData: Decodable { + struct CourseDetailEncodedVideoData: Decodable, Sendable { public let youTube: EncodedVideoData? public let fallback: EncodedVideoData? public let desktopMP4: EncodedVideoData? @@ -238,7 +238,7 @@ public extension DataLayer { } } - struct EncodedVideoData: Decodable { + struct EncodedVideoData: Decodable, Sendable { public let url: String? public let fileSize: Int? public let streamPriority: Int? diff --git a/Course/Course/Data/Network/CourseEndpoint.swift b/Course/Course/Data/Network/CourseEndpoint.swift index 62531a042..490248db7 100644 --- a/Course/Course/Data/Network/CourseEndpoint.swift +++ b/Course/Course/Data/Network/CourseEndpoint.swift @@ -79,7 +79,7 @@ enum CourseEndpoint: EndPointType { var task: HTTPTask { switch self { case let .getCourseBlocks(courseID, userName): - let params: [String: Encodable] = [ + let params: [String: Encodable & Sendable] = [ "username": userName, "course_id": courseID, "depth": "all", @@ -95,7 +95,7 @@ enum CourseEndpoint: EndPointType { case .pageHTML: return .request case let .blockCompletionRequest(username, courseID, blockID): - let params: [String: Any] = [ + let params: [String: any Any & Sendable] = [ "username": username, "course_key": courseID, "blocks": [blockID: 1.0] @@ -109,7 +109,7 @@ enum CourseEndpoint: EndPointType { return .requestParameters(encoding: JSONEncoding.default) case let .getSubtitles(_, subtitleLanguage): // let languageCode = Locale.current.languageCode ?? "en" - let params: [String: Any] = [ + let params: [String: any Any & Sendable] = [ "lang": subtitleLanguage ] return .requestParameters(parameters: params, encoding: URLEncoding.queryString) diff --git a/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift b/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift index ff3080a19..99f986687 100644 --- a/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift +++ b/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift @@ -8,15 +8,15 @@ import CoreData import Core -public protocol CoursePersistenceProtocol { +public protocol CoursePersistenceProtocol: Sendable { func loadEnrollments() async throws -> [Core.CourseItem] - func saveEnrollments(items: [Core.CourseItem]) + func saveEnrollments(items: [Core.CourseItem]) async func loadCourseStructure(courseID: String) async throws -> DataLayer.CourseStructure - func saveCourseStructure(structure: DataLayer.CourseStructure) - func saveSubtitles(url: String, subtitlesString: String) + func saveCourseStructure(structure: DataLayer.CourseStructure) async + func saveSubtitles(url: String, subtitlesString: String) async func loadSubtitles(url: String) async -> String? - func saveCourseDates(courseID: String, courseDates: CourseDates) - func loadCourseDates(courseID: String) throws -> CourseDates + func saveCourseDates(courseID: String, courseDates: CourseDates) async + func loadCourseDates(courseID: String) async throws -> CourseDates } public final class CourseBundle { diff --git a/Course/Course/Domain/CourseInteractor.swift b/Course/Course/Domain/CourseInteractor.swift index bcafab40d..032ab4e89 100644 --- a/Course/Course/Domain/CourseInteractor.swift +++ b/Course/Course/Domain/CourseInteractor.swift @@ -9,9 +9,9 @@ import Foundation import Core //sourcery: AutoMockable -public protocol CourseInteractorProtocol { +public protocol CourseInteractorProtocol: Sendable { func getCourseBlocks(courseID: String) async throws -> CourseStructure - func getCourseVideoBlocks(fullStructure: CourseStructure) -> CourseStructure + func getCourseVideoBlocks(fullStructure: CourseStructure) async -> CourseStructure func getLoadedCourseBlocks(courseID: String) async throws -> CourseStructure func getSequentialsContainsBlocks(blockIds: [String], courseID: String) async throws -> [CourseSequential] func blockCompletionRequest(courseID: String, blockID: String) async throws @@ -24,7 +24,7 @@ public protocol CourseInteractorProtocol { func shiftDueDates(courseID: String) async throws } -public class CourseInteractor: CourseInteractorProtocol { +public actor CourseInteractor: CourseInteractorProtocol { private let repository: CourseRepositoryProtocol @@ -36,7 +36,7 @@ public class CourseInteractor: CourseInteractorProtocol { return try await repository.getCourseBlocks(courseID: courseID) } - public func getCourseVideoBlocks(fullStructure course: CourseStructure) -> CourseStructure { + public func getCourseVideoBlocks(fullStructure course: CourseStructure) async -> CourseStructure { var newChilds = [CourseChapter]() for chapter in course.childs { let newChapter = filterChapter(chapter: chapter) @@ -91,7 +91,6 @@ public class CourseInteractor: CourseInteractorProtocol { } public func blockCompletionRequest(courseID: String, blockID: String) async throws { - NotificationCenter.default.post(name: .onblockCompletionRequested, object: courseID) return try await repository.blockCompletionRequest(courseID: courseID, blockID: blockID) } diff --git a/Course/Course/Domain/Model/CourseUpdate.swift b/Course/Course/Domain/Model/CourseUpdate.swift index b7d0dab06..ddeca560c 100644 --- a/Course/Course/Domain/Model/CourseUpdate.swift +++ b/Course/Course/Domain/Model/CourseUpdate.swift @@ -7,7 +7,7 @@ import Foundation -public struct CourseUpdate: Hashable { +public struct CourseUpdate: Hashable, Sendable { public let id: Int public let date: String public var content: String diff --git a/Course/Course/Domain/Model/ResumeBlock.swift b/Course/Course/Domain/Model/ResumeBlock.swift index 71738bbb3..e6976096c 100644 --- a/Course/Course/Domain/Model/ResumeBlock.swift +++ b/Course/Course/Domain/Model/ResumeBlock.swift @@ -7,7 +7,7 @@ import Foundation -public struct ResumeBlock { +public struct ResumeBlock: Sendable { let blockID: String public init(blockID: String) { diff --git a/Course/Course/Presentation/Container/BaseCourseViewModel.swift b/Course/Course/Presentation/Container/BaseCourseViewModel.swift index 04cd5a05a..cd1df40c7 100644 --- a/Course/Course/Presentation/Container/BaseCourseViewModel.swift +++ b/Course/Course/Presentation/Container/BaseCourseViewModel.swift @@ -10,6 +10,7 @@ import SwiftUI import Core import Combine +@MainActor open class BaseCourseViewModel: ObservableObject { let manager: DownloadManagerProtocol diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 725f98ace..7f773561e 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -132,7 +132,6 @@ public struct CourseContainerView: View { } } } - switch courseDatesViewModel.eventState { case .removedCalendar: showDatesSuccessView( @@ -152,7 +151,7 @@ public struct CourseContainerView: View { private func showDatesSuccessView(title: String, message: String) -> some View { return DatesSuccessView( title: title, - message: message, + message: message, selectedTab: .dates ) { courseDatesViewModel.resetEventState() diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index e156dcb1a..54d7dca48 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -11,7 +11,7 @@ import Core import OEXFoundation import Combine -public enum CourseTab: Int, CaseIterable, Identifiable { +public enum CourseTab: Int, CaseIterable, Identifiable, Sendable { public var id: Int { rawValue } @@ -59,7 +59,8 @@ extension CourseTab { } } -public class CourseContainerViewModel: BaseCourseViewModel { +@MainActor +public final class CourseContainerViewModel: BaseCourseViewModel { @Published public var selection: Int @Published var isShowProgress = true @@ -218,7 +219,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { ) } } - courseVideosStructure = interactor.getCourseVideoBlocks(fullStructure: courseStructure!) + courseVideosStructure = await interactor.getCourseVideoBlocks(fullStructure: courseStructure!) await getDownloadingProgress() isShowProgress = false isShowRefresh = false @@ -407,7 +408,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { sequentialID: String, verticalID: String, blockID: String - ) { + ) async { guard let chapterIndex = courseStructure? .childs.firstIndex(where: { $0.id == chapterID }) else { return @@ -438,8 +439,9 @@ public class CourseContainerViewModel: BaseCourseViewModel { .childs[sequentialIndex] .childs[verticalIndex] .childs[blockIndex].completion = 1 - courseStructure.map { - courseVideosStructure = interactor.getCourseVideoBlocks(fullStructure: $0) + + if let courseStructure { + courseVideosStructure = await interactor.getCourseVideoBlocks(fullStructure: courseStructure) } } @@ -489,7 +491,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { case .downloading: try await manager.cancelDownloading(courseId: courseStructure?.id ?? "", blocks: blocks) case .finished: - presentRemoveDownloadAlert(blocks: blocks, sequentials: sequentials) + await presentRemoveDownloadAlert(blocks: blocks, sequentials: sequentials) } } catch let error { if error is NoWiFiError { @@ -584,7 +586,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { totalFileSize: Int, action: @escaping () -> Void = {} ) async { - router.presentView( + await router.presentView( transitionStyle: .coverVertical, view: DownloadActionView( actionType: .confirmDownload, @@ -611,8 +613,8 @@ public class CourseContainerViewModel: BaseCourseViewModel { ) } - private func presentRemoveDownloadAlert(blocks: [CourseBlock], sequentials: [CourseSequential]) { - router.presentView( + private func presentRemoveDownloadAlert(blocks: [CourseBlock], sequentials: [CourseSequential]) async { + await router.presentView( transitionStyle: .coverVertical, view: DownloadActionView( actionType: .remove, @@ -621,8 +623,9 @@ public class CourseContainerViewModel: BaseCourseViewModel { guard let self else { return } Task { await self.manager.deleteFile(blocks: blocks) + self.router.dismiss(animated: true) } - self.router.dismiss(animated: true) + }, cancel: { [weak self] in guard let self else { return } @@ -634,12 +637,17 @@ public class CourseContainerViewModel: BaseCourseViewModel { } @MainActor - func collectBlocks(chapter: CourseChapter, blockId: String, state: DownloadViewState) async -> [CourseBlock] { - let sequentials = chapter.childs.filter({ $0.id == blockId }) + func collectBlocks( + chapter: CourseChapter, + blockId: String, + state: DownloadViewState, + videoOnly: Bool = false + ) async -> [CourseBlock] { + let sequentials = chapter.childs.filter { $0.id == blockId } guard !sequentials.isEmpty else { return [] } let blocks = sequentials.flatMap { $0.childs.flatMap { $0.childs } } - .filter { $0.isDownloadable } + .filter { $0.isDownloadable && (!videoOnly || $0.type == .video) } if state == .available, isShowedAllowLargeDownloadAlert(blocks: blocks) { return [] @@ -755,12 +763,15 @@ public class CourseContainerViewModel: BaseCourseViewModel { } } - @MainActor - func filterNotDownloadedBlocks(_ blocks: [CourseBlock]) -> [CourseBlock] { - return blocks.filter { block in - let fileUrl = manager.fileUrl(for: block.id) - return fileUrl == nil + func filterNotDownloadedBlocks(_ blocks: [CourseBlock]) async -> [CourseBlock] { + var result: [CourseBlock] = [] + for block in blocks { + let fileUrl = await manager.fileUrl(for: block.id) + if fileUrl == nil { + result.append(block) + } } + return result } @MainActor @@ -816,14 +827,14 @@ public class CourseContainerViewModel: BaseCourseViewModel { } if connectivity.isInternetAvaliable { - let updatedSequentials = manager.updateUnzippedFileSize(for: sequentials) + let updatedSequentials = await manager.updateUnzippedFileSize(for: sequentials) realDownloadedFilesSize = updatedSequentials.flatMap { $0.childs.flatMap { $0.childs.compactMap { $0.actualFileSize } } }.reduce(0, { $0 + $1 }) } for task in courseDownloadTasks where task.state == .finished { - if let fileUrl = manager.fileUrl(for: task.blockId), + if let fileUrl = await manager.fileUrl(for: task.blockId), let fileSize = getFileSize(at: fileUrl), task.type == .video { if fileSize > 0 { @@ -1007,7 +1018,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { @MainActor func updateFileSizeIfNeeded(for block: CourseBlock) async -> CourseBlock { var updatedBlock = block - if let fileUrl = manager.fileUrl(for: block.id), + if let fileUrl = await manager.fileUrl(for: block.id), let fileSize = getFileSize(at: fileUrl), fileSize > 0, block.type == .video { updatedBlock.actualFileSize = fileSize @@ -1079,7 +1090,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { .sink { [weak self] state in guard let self else { return } if case .progress = state { return } - Task(priority: .background) { + Task { debugLog(state, "--- state ---") await self.setDownloadsStates(courseStructure: self.courseStructure) await self.getDownloadingProgress() diff --git a/Course/Course/Presentation/CourseRouter.swift b/Course/Course/Presentation/CourseRouter.swift index f3efe9cae..fb40a5ad9 100644 --- a/Course/Course/Presentation/CourseRouter.swift +++ b/Course/Course/Presentation/CourseRouter.swift @@ -8,6 +8,7 @@ import Foundation import Core +@MainActor public protocol CourseRouter: BaseRouter { func presentAppReview() diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift index 0bccd3853..0fd5f17b3 100644 --- a/Course/Course/Presentation/Dates/CourseDatesView.swift +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -196,8 +196,13 @@ struct CourseDateListView: View { ) VStack(alignment: .leading, spacing: 0) { - CalendarSyncStatusView(status: viewModel.syncStatus(), router: viewModel.router) + @State var status: SyncStatus = .offline + + CalendarSyncStatusView(status: status, router: viewModel.router) .padding(.bottom, 16) + .task { + status = await viewModel.syncStatus() + } if !courseDates.hasEnded { DatesStatusInfoView( diff --git a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift index 5ec8101c6..8ab0d022e 100644 --- a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift +++ b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift @@ -10,9 +10,10 @@ import Core import SwiftUI import OEXFoundation +@MainActor public class CourseDatesViewModel: ObservableObject { - enum EventState { + enum EventState: Sendable { case addedCalendar case removedCalendar case updatedCalendar @@ -124,8 +125,8 @@ public class CourseDatesViewModel: ObservableObject { } } - func syncStatus() -> SyncStatus { - return calendarManager.courseStatus(courseID: courseID) + func syncStatus() async -> SyncStatus { + return await calendarManager.courseStatus(courseID: courseID) } @MainActor diff --git a/Course/Course/Presentation/Downloads/DownloadsViewModel.swift b/Course/Course/Presentation/Downloads/DownloadsViewModel.swift index cf74c6c65..766101955 100644 --- a/Course/Course/Presentation/Downloads/DownloadsViewModel.swift +++ b/Course/Course/Presentation/Downloads/DownloadsViewModel.swift @@ -8,8 +8,9 @@ import Foundation import Core import OEXFoundation -import Combine +@preconcurrency import Combine +@MainActor final class DownloadsViewModel: ObservableObject { // MARK: - Properties @@ -32,7 +33,9 @@ final class DownloadsViewModel: ObservableObject { self.courseId = courseId self.manager = manager self.downloads = downloads - Task { await configure() } + Task { + await configure() + } observers() } diff --git a/Course/Course/Presentation/Handouts/HandoutsViewModel.swift b/Course/Course/Presentation/Handouts/HandoutsViewModel.swift index f4380dc6c..57f0f3c8e 100644 --- a/Course/Course/Presentation/Handouts/HandoutsViewModel.swift +++ b/Course/Course/Presentation/Handouts/HandoutsViewModel.swift @@ -9,7 +9,8 @@ import Foundation import Core import SwiftUI -public class HandoutsViewModel: ObservableObject { +@MainActor +public final class HandoutsViewModel: ObservableObject { @Published private(set) var isShowProgress = false @Published var showError: Bool = false @@ -45,7 +46,6 @@ public class HandoutsViewModel: ObservableObject { self.analytics = analytics } - @MainActor func getHandouts(courseID: String) async { isShowProgress = true do { @@ -58,7 +58,6 @@ public class HandoutsViewModel: ObservableObject { } } - @MainActor func getUpdates(courseID: String) async { isShowProgress = true do { diff --git a/Course/Course/Presentation/Offline/OfflineView.swift b/Course/Course/Presentation/Offline/OfflineView.swift index eab6e8331..852498383 100644 --- a/Course/Course/Presentation/Offline/OfflineView.swift +++ b/Course/Course/Presentation/Offline/OfflineView.swift @@ -169,7 +169,7 @@ struct OfflineView: View { && ((viewModel.totalFilesSize - viewModel.downloadedFilesSize != 0) || (viewModel.totalFilesSize == 0 && viewModel.downloadedFilesSize == 0)) { Button(action: { - Task(priority: .low) { + Task { switch viewModel.downloadAllButtonState { case .start: await viewModel.downloadAll() diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 37e14b589..3d04e8d0f 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -222,12 +222,14 @@ public struct CourseOutlineView: View { let blockID = userInfo["blockID"] as? String else { return } - viewModel.completeBlock( - chapterID: chapterID, - sequentialID: sequentialID, - verticalID: verticalID, - blockID: blockID - ) + Task { + await viewModel.completeBlock( + chapterID: chapterID, + sequentialID: sequentialID, + verticalID: verticalID, + blockID: blockID + ) + } } } diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift index e15cc024d..eda090f0f 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift @@ -8,8 +8,9 @@ import SwiftUI import Core import Combine +import OEXFoundation -public class CourseVerticalViewModel: BaseCourseViewModel { +public final class CourseVerticalViewModel: BaseCourseViewModel { let router: CourseRouter let analytics: CourseAnalytics let connectivity: ConnectivityProtocol @@ -46,17 +47,20 @@ public class CourseVerticalViewModel: BaseCourseViewModel { self.verticals = chapters[chapterIndex].childs[sequentialIndex].childs super.init(manager: manager) - manager.publisher() - .sink(receiveValue: { [weak self] _ in - guard let self else { return } - Task { - await self.setDownloadsStates() - } - }) - .store(in: &cancellables) - - Task { - await setDownloadsStates() + do { + try manager.publisher() + .sink(receiveValue: { [weak self] _ in + guard let self else { return } + Task { + await self.setDownloadsStates() + } + }) + .store(in: &cancellables) + Task { + await setDownloadsStates() + } + } catch { + debugLog(error) } } diff --git a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift index 41d80a109..2e259db28 100644 --- a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift +++ b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift @@ -10,6 +10,7 @@ import Core import OEXFoundation import Combine +@MainActor final class CourseVideoDownloadBarViewModel: ObservableObject { // MARK: - Properties diff --git a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift index f7ae7ea54..2fbcb9046 100644 --- a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift +++ b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift @@ -52,7 +52,7 @@ struct CustomDisclosureGroup: View { let state = downloadAllButtonState(for: chapter, videoOnly: isVideo) { Button( action: { - downloadAllSubsections(in: chapter, state: state) + downloadAllSubsections(in: chapter, state: state) }, label: { switch state { case .available: @@ -84,7 +84,7 @@ struct CustomDisclosureGroup: View { viewModel.router.showGatedContentError(url: courseVertical.webUrl) return } - + viewModel.trackSequentialClicked(sequential) if viewModel.config.uiComponents.courseDropDownNavigationEnabled { viewModel.router.showCourseUnit( @@ -142,9 +142,9 @@ struct CustomDisclosureGroup: View { \(numPointsPossible) """ ) - .font(Theme.Fonts.bodySmall) - .multilineTextAlignment(.leading) - .lineLimit(2) + .font(Theme.Fonts.bodySmall) + .multilineTextAlignment(.leading) + .lineLimit(2) } } .foregroundColor(Theme.Colors.textPrimary) @@ -162,7 +162,7 @@ struct CustomDisclosureGroup: View { } } } - + } } .padding(.horizontal, 16) @@ -212,11 +212,11 @@ struct CustomDisclosureGroup: View { for sequential in chapter.childs { if videoOnly { let isDownloadable = sequential.childs.flatMap { - $0.childs.filter({ $0.type == .video }) + $0.childs.filter { $0.type == .video } }.contains(where: { $0.isDownloadable }) - guard isDownloadable else { return false } + guard isDownloadable else { continue } } - if viewModel.sequentialsDownloadState[sequential.id] != nil { + if sequentialDownloadState(sequential, videoOnly: videoOnly) != nil { return true } } @@ -226,25 +226,38 @@ struct CustomDisclosureGroup: View { private func downloadAllSubsections(in chapter: CourseChapter, state: DownloadViewState) { Task { var allBlocks: [CourseBlock] = [] + var sequentialsToDownload: [CourseSequential] = [] for sequential in chapter.childs { - let blocks = await viewModel.collectBlocks(chapter: chapter, blockId: sequential.id, state: state) - allBlocks.append(contentsOf: blocks) + let blocks = await viewModel.collectBlocks( + chapter: chapter, + blockId: sequential.id, + state: state, + videoOnly: isVideo + ) + if !blocks.isEmpty { + allBlocks.append(contentsOf: blocks) + sequentialsToDownload.append(sequential) + } } await viewModel.download( state: state, blocks: allBlocks, - sequentials: chapter.childs.filter({ $0.isDownloadable }) + sequentials: sequentialsToDownload ) } } private func downloadAllButtonState(for chapter: CourseChapter, videoOnly: Bool) -> DownloadViewState? { if canDownloadAllSections(in: chapter, videoOnly: videoOnly) { - let downloads = chapter.childs.filter({ viewModel.sequentialsDownloadState[$0.id] != nil }) - - if downloads.contains(where: { viewModel.sequentialsDownloadState[$0.id] == .downloading }) { + var downloads: [DownloadViewState] = [] + for sequential in chapter.childs { + if let state = sequentialDownloadState(sequential, videoOnly: videoOnly) { + downloads.append(state) + } + } + if downloads.contains(.downloading) { return .downloading - } else if downloads.allSatisfy({ viewModel.sequentialsDownloadState[$0.id] == .finished }) { + } else if downloads.allSatisfy({ $0 == .finished }) { return .finished } else { return .available @@ -253,6 +266,35 @@ struct CustomDisclosureGroup: View { return nil } + private func sequentialDownloadState(_ sequential: CourseSequential, videoOnly: Bool) -> DownloadViewState? { + let blocks: [CourseBlock] + if videoOnly { + blocks = sequential.childs.flatMap { $0.childs }.filter { $0.isDownloadable && $0.type == .video } + } else { + blocks = sequential.childs.flatMap { $0.childs }.filter { $0.isDownloadable } + } + guard !blocks.isEmpty else { return nil } + var blockStates: [DownloadViewState] = [] + for block in blocks { + if let task = viewModel.courseDownloadTasks.first(where: { $0.blockId == block.id }) { + switch task.state { + case .waiting, .inProgress: + blockStates.append(.downloading) + case .finished: + blockStates.append(.finished) + } + } else { + blockStates.append(.available) + } + } + if blockStates.contains(.downloading) { + return .downloading + } else if blockStates.allSatisfy({ $0 == .finished }) { + return .finished + } else { + return .available + } + } } #if DEBUG diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index 2bbdc58cc..2e239d5c4 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -30,13 +30,13 @@ public struct CourseUnitView: View { @Environment(\.isHorizontal) private var isHorizontal public let playerStateSubject = CurrentValueSubject(nil) - //Dropdown parameters + // Dropdown parameters @State var showDropdown: Bool = false private let portraitTopSpacing: CGFloat = 60 private let landscapeTopSpacing: CGFloat = 75 - @State private var videoURL: URL? - @State private var webURL: URL? + @State private var videoURLs: [String: URL?] = [:] + @State private var webURLs: [String: URL?] = [:] let isDropdownActive: Bool @@ -70,7 +70,7 @@ public struct CourseUnitView: View { viewModel.loadIndex() viewModel.nextTitles() } - + public var body: some View { ZStack(alignment: .top) { // MARK: - Page Body @@ -122,9 +122,9 @@ public struct CourseUnitView: View { currentIndex: viewModel.verticalIndex, offsetY: isHorizontal ? landscapeTopSpacing : portraitTopSpacing, showDropdown: $showDropdown - ) { [weak viewModel] vertical in - let data = VerticalData.dataFor(blockId: vertical.childs.first?.id, in: viewModel?.chapters ?? []) - viewModel?.route(to: data) + ) { vertical in + let data = VerticalData.dataFor(blockId: vertical.childs.first?.id, in: viewModel.chapters) + viewModel.route(to: data) playerStateSubject.send(VideoPlayerState.kill) } } @@ -161,7 +161,6 @@ public struct CourseUnitView: View { ) } - // swiftlint:disable function_body_length @ViewBuilder private func content(reader: GeometryProxy) -> some View { let alignment = UnitAlignment(horizontalAlignment: .top, verticalAlignment: .leading) @@ -173,130 +172,7 @@ public struct CourseUnitView: View { if isDropdownActive { videoTitle(block: block, width: reader.size.width) } - switch LessonType.from(block, streamingQuality: viewModel.streamingQuality) { - // MARK: YouTube - case let .youtube(url, blockID): - if index == viewModel.index { - if viewModel.connectivity.isInternetAvaliable { - YouTubeView( - name: block.displayName, - url: url, - courseID: viewModel.courseID, - blockID: blockID, - playerStateSubject: playerStateSubject, - languages: block.subtitles ?? [], - isOnScreen: index == viewModel.index - ) - .frameLimit(width: reader.size.width) - - if !isHorizontal { - Spacer(minLength: 150) - } - } else { - OfflineContentView( - isDownloadable: false - ) - } - - } else { - EmptyView() - } - // MARK: Encoded Video - case let .video(encodedUrl, blockID): - if index == viewModel.index { - let url = viewModel.urlForVideoFileOrFallback( - blockId: blockID, - url: encodedUrl - ) - if viewModel.connectivity.isInternetAvaliable || url?.isFileURL == true { - EncodedVideoView( - name: block.displayName, - url: url, - courseID: viewModel.courseID, - blockID: blockID, - playerStateSubject: playerStateSubject, - languages: block.subtitles ?? [], - isOnScreen: index == viewModel.index - ) - .padding(.top, 5) - .frameLimit(width: reader.size.width) - - if !isHorizontal { - Spacer(minLength: 150) - } - } else { - OfflineContentView( - isDownloadable: true - ) - } - } - - // MARK: Web - case let .web(url, injections, blockId, isDownloadable): - if index >= viewModel.index - 1 && index <= viewModel.index + 1 { - let localUrl = viewModel.urlForOfflineContent(blockId: blockId)?.absoluteString - if viewModel.connectivity.isInternetAvaliable || localUrl != nil { - // not need to add frame limit there because we did that with injection - WebView( - url: url, - localUrl: viewModel.connectivity.isInternetAvaliable ? nil : localUrl, - injections: injections, - blockID: block.id, - roundedBackgroundEnabled: !viewModel.courseUnitProgressEnabled - ) - } else { - OfflineContentView( - isDownloadable: isDownloadable - ) - } - } else { - EmptyView() - } - - // MARK: Unknown - case .unknown(let url): - if index >= viewModel.index - 1 && index <= viewModel.index + 1 { - if viewModel.connectivity.isInternetAvaliable { - NotAvailableOnMobileView(url: url) - .frameLimit(width: reader.size.width) - } else { - OfflineContentView( - isDownloadable: false - ) - } - } else { - EmptyView() - } - // MARK: Discussion - case let .discussion(blockID, blockKey, title): - if index >= viewModel.index - 1 && index <= viewModel.index + 1 { - if viewModel.connectivity.isInternetAvaliable { - VStack { - if showDiscussion { - DiscussionView( - id: viewModel.courseID, - blockID: blockID, - blockKey: blockKey, - title: title, - viewModel: viewModel - ) - Spacer(minLength: 100) - } else { - VStack { - Color.clear - } - } - } - //No need iPad paddings there bacause they were added - //to PostsView that placed inside DiscussionView - } else { - FullScreenErrorView(type: .noInternet) - } - } else { - EmptyView() - } - } - + contentView(for: block, index: index, reader: reader) } .frame( width: isHorizontal ? reader.size.width - (isHorizontalNavigation ? 0 : 16) : reader.size.width, @@ -317,7 +193,6 @@ public struct CourseUnitView: View { } } ) - .onReceive( NotificationCenter.default.publisher( for: NSNotification.blockCompletion @@ -329,7 +204,261 @@ public struct CourseUnitView: View { } } } - // swiftlint:enable function_body_length + + @ViewBuilder + private func contentView(for block: CourseBlock, index: Int, reader: GeometryProxy) -> some View { + switch LessonType.from(block, streamingQuality: viewModel.streamingQuality) { + // MARK: YouTube + case let .youtube(url, blockID): + youtubeView( + block: block, + url: url, + blockID: blockID, + index: index, + reader: reader + ) + // MARK: Encoded Video + case let .video(encodedUrl, blockID): + videoView( + block: block, + encodedUrl: encodedUrl, + blockID: blockID, + index: index, + reader: reader + ) + // MARK: Web + case let .web(url, injections, blockId, isDownloadable): + webView( + block: block, + url: url, + injections: injections, + blockId: blockId, + isDownloadable: isDownloadable, + index: index, + reader: reader + ) + // MARK: Unknown + case .unknown(let url): + unknownView( + block: block, + url: url, + index: index, + reader: reader + ) + // MARK: Discussion + case let .discussion(blockID, blockKey, title): + discussionView( + block: block, + blockID: blockID, + blockKey: blockKey, + title: title, + index: index, + reader: reader + ) + } + } + + @ViewBuilder + private func youtubeView( + block: CourseBlock, + url: String, + blockID: String, + index: Int, + reader: GeometryProxy + ) -> some View { + if index == viewModel.index { + if viewModel.connectivity.isInternetAvaliable { + YouTubeView( + name: block.displayName, + url: url, + courseID: viewModel.courseID, + blockID: blockID, + playerStateSubject: playerStateSubject, + languages: block.subtitles ?? [], + isOnScreen: index == viewModel.index + ) + .frameLimit(width: reader.size.width) + + if !isHorizontal { + Spacer(minLength: 150) + } + } else { + OfflineContentView( + isDownloadable: false + ) + } + } else { + EmptyView() + } + } + + @ViewBuilder + private func videoView( + block: CourseBlock, + encodedUrl: String, + blockID: String, + index: Int, + reader: GeometryProxy + ) -> some View { + Group { + if index == viewModel.index { + if viewModel.connectivity.isInternetAvaliable { + EncodedVideoView( + name: block.displayName, + url: URL(string: encodedUrl), + courseID: viewModel.courseID, + blockID: blockID, + playerStateSubject: playerStateSubject, + languages: block.subtitles ?? [], + isOnScreen: index == viewModel.index + ) + .padding(.top, 5) + .frameLimit(width: reader.size.width) + + if !isHorizontal { + Spacer(minLength: 150) + } + } + else if let offlineURL = videoURLs[blockID] { + EncodedVideoView( + name: block.displayName, + url: offlineURL, + courseID: viewModel.courseID, + blockID: blockID, + playerStateSubject: playerStateSubject, + languages: block.subtitles ?? [], + isOnScreen: index == viewModel.index + ) + .padding(.top, 5) + .frameLimit(width: reader.size.width) + + if !isHorizontal { + Spacer(minLength: 150) + } + } else { + OfflineContentView( + isDownloadable: true + ) + } + } else { + EmptyView() + } + } + .onAppear { + Task { + if let url = await viewModel.urlForVideoFileOrFallback( + blockId: blockID, + url: encodedUrl + ) { + videoURLs[blockID] = url + } + } + } + } + + @ViewBuilder + private func webView( + block: CourseBlock, + url: String, + injections: [WebviewInjection], + blockId: String, + isDownloadable: Bool, + index: Int, + reader: GeometryProxy + ) -> some View { + Group { + if index >= viewModel.index - 1 && index <= viewModel.index + 1 { + if viewModel.connectivity.isInternetAvaliable { + WebView( + url: url, + localUrl: nil, + injections: injections, + blockID: block.id, + roundedBackgroundEnabled: !viewModel.courseUnitProgressEnabled + ) + } else if let offlineURL = webURLs[blockId] { + WebView( + url: url, + localUrl: offlineURL?.absoluteString, + injections: injections, + blockID: block.id, + roundedBackgroundEnabled: !viewModel.courseUnitProgressEnabled + ) + } else { + OfflineContentView( + isDownloadable: isDownloadable + ) + } + } else { + EmptyView() + } + } + .onAppear { + Task { + if let offlineURL = await viewModel.urlForOfflineContent(blockId: blockId) { + webURLs[blockId] = offlineURL + } + } + } + } + + @ViewBuilder + private func unknownView( + block: CourseBlock, + url: String, + index: Int, + reader: GeometryProxy + ) -> some View { + if index >= viewModel.index - 1 && index <= viewModel.index + 1 { + if viewModel.connectivity.isInternetAvaliable { + NotAvailableOnMobileView(url: url) + .frameLimit(width: reader.size.width) + } else { + OfflineContentView( + isDownloadable: false + ) + } + } else { + EmptyView() + } + } + + @ViewBuilder + private func discussionView( + block: CourseBlock, + blockID: String, + blockKey: String, + title: String, + index: Int, + reader: GeometryProxy + ) -> some View { + if index >= viewModel.index - 1 && index <= viewModel.index + 1 { + if viewModel.connectivity.isInternetAvaliable { + VStack { + if showDiscussion { + DiscussionView( + id: viewModel.courseID, + blockID: blockID, + blockKey: blockKey, + title: title, + viewModel: viewModel + ) + Spacer(minLength: 100) + } else { + VStack { + Color.clear + } + } + } + // No need for iPad paddings here because they were added + // to PostsView that is placed inside DiscussionView + } else { + FullScreenErrorView(type: .noInternet) + } + } else { + EmptyView() + } + } private func viewOffset(for index: Int, with size: CGSize, insets: EdgeInsets) -> CGPoint { let rightInset = (isHorizontal ? insets.trailing * CGFloat(index) : 0) @@ -438,8 +567,7 @@ public struct CourseUnitView: View { if isHorizontal { Spacer() } - }//.frame(height: isHorizontal ? nil : 44) - + } .padding(.bottom, isHorizontal ? 0 : 50) .padding(.top, isHorizontal ? 12 : 0) } @@ -447,7 +575,7 @@ public struct CourseUnitView: View { } #if DEBUG -//swiftlint:disable all +// swiftlint:disable all struct CourseUnitView_Previews: PreviewProvider { static var previews: some View { let blocks = [ @@ -549,7 +677,6 @@ struct CourseUnitView_Previews: PreviewProvider { ), due: Date() ) - ] ), CourseChapter( @@ -583,7 +710,6 @@ struct CourseUnitView_Previews: PreviewProvider { ), due: Date() ) - ]) ] @@ -605,5 +731,5 @@ struct CourseUnitView_Previews: PreviewProvider { )) } } -//swiftlint:enable all +// swiftlint:enable all #endif diff --git a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift index 8e3ef4497..b2623310e 100644 --- a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift +++ b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift @@ -119,9 +119,10 @@ public struct VerticalData: Equatable { } } -public class CourseUnitViewModel: ObservableObject { +@MainActor +public final class CourseUnitViewModel: ObservableObject { - enum LessonAction { + enum LessonAction: Sendable { case next case previous } @@ -263,16 +264,17 @@ public class CourseUnitViewModel: ObservableObject { } } - func urlForVideoFileOrFallback(blockId: String, url: String) -> URL? { - if let fileURL = manager.fileUrl(for: blockId) { + func urlForVideoFileOrFallback(blockId: String, url: String) async -> URL? { + guard !connectivity.isInternetAvaliable else { return URL(string: url) } + if let fileURL = await manager.fileUrl(for: blockId) { return fileURL } else { return URL(string: url) } } - func urlForOfflineContent(blockId: String) -> URL? { - return manager.fileUrl(for: blockId) + func urlForOfflineContent(blockId: String) async -> URL? { + return await manager.fileUrl(for: blockId) } func trackFinishVerticalBackToOutlineClicked() { diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index 72e0c5dde..51e4ef799 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -11,7 +11,7 @@ import Core import Swinject import Combine -public enum VideoPlayerState { +public enum VideoPlayerState: Sendable { case pause case kill } diff --git a/Course/Course/Presentation/Video/PipManagerProtocol.swift b/Course/Course/Presentation/Video/PipManagerProtocol.swift index c92550cb3..04789cc28 100644 --- a/Course/Course/Presentation/Video/PipManagerProtocol.swift +++ b/Course/Course/Presentation/Video/PipManagerProtocol.swift @@ -8,7 +8,8 @@ import Combine import Foundation -public protocol PipManagerProtocol { +@MainActor +public protocol PipManagerProtocol: Sendable { var isPipActive: Bool { get } var isPipPlaying: Bool { get } @@ -26,7 +27,7 @@ public protocol PipManagerProtocol { } #if DEBUG -public class PipManagerProtocolMock: PipManagerProtocol { +public final class PipManagerProtocolMock: PipManagerProtocol { public var isPipActive: Bool { false } diff --git a/Course/Course/Presentation/Video/PlayerControllerProtocol.swift b/Course/Course/Presentation/Video/PlayerControllerProtocol.swift index df376e466..69f6140b0 100644 --- a/Course/Course/Presentation/Video/PlayerControllerProtocol.swift +++ b/Course/Course/Presentation/Video/PlayerControllerProtocol.swift @@ -7,6 +7,7 @@ import Foundation +@MainActor public protocol PlayerControllerProtocol { func play() func pause() diff --git a/Course/Course/Presentation/Video/PlayerDelegateProtocol.swift b/Course/Course/Presentation/Video/PlayerDelegateProtocol.swift index 1297e9ddf..cad9d8d34 100644 --- a/Course/Course/Presentation/Video/PlayerDelegateProtocol.swift +++ b/Course/Course/Presentation/Video/PlayerDelegateProtocol.swift @@ -7,26 +7,30 @@ import AVKit -public protocol PlayerDelegateProtocol: AVPlayerViewControllerDelegate { +public protocol PlayerDelegateProtocol: AVPlayerViewControllerDelegate, Sendable { var isPlayingInPip: Bool { get } var playerHolder: PlayerViewControllerHolderProtocol? { get set } init(pipManager: PipManagerProtocol) } -public class PlayerDelegate: NSObject, PlayerDelegateProtocol { - private(set) public var isPlayingInPip: Bool = false +public final class PlayerDelegate: NSObject, PlayerDelegateProtocol { + private(set) public nonisolated(unsafe) var isPlayingInPip: Bool = false private let pipManager: PipManagerProtocol - weak public var playerHolder: PlayerViewControllerHolderProtocol? + weak public nonisolated(unsafe) var playerHolder: PlayerViewControllerHolderProtocol? required public init(pipManager: PipManagerProtocol) { self.pipManager = pipManager super.init() } - public func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) { + nonisolated public func playerViewControllerWillStartPictureInPicture( + _ playerViewController: AVPlayerViewController + ) { isPlayingInPip = true if let holder = playerHolder { - pipManager.set(holder: holder) + Task { @MainActor in + pipManager.set(holder: holder) + } } } @@ -34,16 +38,20 @@ public class PlayerDelegate: NSObject, PlayerDelegateProtocol { _ playerViewController: AVPlayerViewController, failedToStartPictureInPictureWithError error: any Error ) { - isPlayingInPip = false - if let holder = playerHolder { - pipManager.remove(holder: holder) + Task { @MainActor in + isPlayingInPip = false + if let holder = playerHolder { + pipManager.remove(holder: holder) + } } } public func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) { isPlayingInPip = false - if let holder = playerHolder { - pipManager.remove(holder: holder) + Task { @MainActor in + if let holder = playerHolder { + pipManager.remove(holder: holder) + } } } diff --git a/Course/Course/Presentation/Video/PlayerServiceProtocol.swift b/Course/Course/Presentation/Video/PlayerServiceProtocol.swift index 3619de512..a46dd59d7 100644 --- a/Course/Course/Presentation/Video/PlayerServiceProtocol.swift +++ b/Course/Course/Presentation/Video/PlayerServiceProtocol.swift @@ -7,17 +7,22 @@ import SwiftUI -public protocol PlayerServiceProtocol { +@MainActor +public protocol PlayerServiceProtocol: Sendable { var router: CourseRouter { get } init(courseID: String, blockID: String, interactor: CourseInteractorProtocol, router: CourseRouter) func blockCompletionRequest() async throws func presentAppReview() - func presentView(transitionStyle: UIModalTransitionStyle, animated: Bool, content: () -> any View) + func presentView( + transitionStyle: UIModalTransitionStyle, + animated: Bool, + content: @MainActor () -> any View + ) func getSubtitles(url: String, selectedLanguage: String) async throws -> [Subtitle] } -public class PlayerService: PlayerServiceProtocol { +public final class PlayerService: PlayerServiceProtocol { private let courseID: String private let blockID: String private let interactor: CourseInteractorProtocol @@ -35,8 +40,8 @@ public class PlayerService: PlayerServiceProtocol { self.router = router } - @MainActor public func blockCompletionRequest() async throws { + NotificationCenter.default.post(name: .onblockCompletionRequested, object: courseID) try await interactor.blockCompletionRequest(courseID: courseID, blockID: blockID) NotificationCenter.default.post( name: NSNotification.blockCompletion, @@ -44,13 +49,15 @@ public class PlayerService: PlayerServiceProtocol { ) } - @MainActor public func presentAppReview() { router.presentAppReview() } - @MainActor - public func presentView(transitionStyle: UIModalTransitionStyle, animated: Bool, content: () -> any View) { + public func presentView( + transitionStyle: UIModalTransitionStyle, + animated: Bool, + content: @MainActor () -> any View + ) { router.presentView(transitionStyle: transitionStyle, animated: animated, content: content) } diff --git a/Course/Course/Presentation/Video/PlayerTrackerProtocol.swift b/Course/Course/Presentation/Video/PlayerTrackerProtocol.swift index 775f8e0d6..43e95f9b7 100644 --- a/Course/Course/Presentation/Video/PlayerTrackerProtocol.swift +++ b/Course/Course/Presentation/Video/PlayerTrackerProtocol.swift @@ -6,10 +6,12 @@ // import AVKit -import Combine +@preconcurrency import Combine +@preconcurrency import YouTubePlayerKit import Foundation -public protocol PlayerTrackerProtocol { +@MainActor +public protocol PlayerTrackerProtocol: Sendable { associatedtype Player var player: Player? { get } var duration: Double { get } @@ -25,7 +27,7 @@ public protocol PlayerTrackerProtocol { } #if DEBUG -class PlayerTrackerProtocolMock: PlayerTrackerProtocol { +class PlayerTrackerProtocolMock: PlayerTrackerProtocol, @unchecked Sendable { let player: AVPlayer? var duration: Double { 1 @@ -78,7 +80,8 @@ class PlayerTrackerProtocolMock: PlayerTrackerProtocol { } #endif // MARK: Video -public class PlayerTracker: PlayerTrackerProtocol { +@MainActor +public final class PlayerTracker: PlayerTrackerProtocol { public var isReady: Bool = false public let player: AVPlayer? public var duration: Double { @@ -99,7 +102,7 @@ public class PlayerTracker: PlayerTrackerProtocol { } private var cancellations: [AnyCancellable] = [] - private var timeObserver: Any? + private nonisolated(unsafe) var timeObserver: Any? private let timePublisher: CurrentValueSubject private let ratePublisher: CurrentValueSubject private let finishPublisher: PassthroughSubject @@ -125,9 +128,13 @@ public class PlayerTracker: PlayerTrackerProtocol { } deinit { - clear() + if let observer = timeObserver { + player?.removeTimeObserver(observer) + } + cancellations.removeAll() } + @MainActor private func observe() { let interval = CMTime( seconds: 0.1, @@ -135,7 +142,9 @@ public class PlayerTracker: PlayerTrackerProtocol { ) timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) {[weak self] time in - self?.timePublisher.send(time.seconds) + MainActor.assumeIsolated { + self?.timePublisher.send(time.seconds) + } } player?.publisher(for: \.rate) @@ -164,13 +173,6 @@ public class PlayerTracker: PlayerTrackerProtocol { .store(in: &cancellations) } - private func clear() { - if let observer = timeObserver { - player?.removeTimeObserver(observer) - } - cancellations.removeAll() - } - public func getTimePublisher() -> AnyPublisher { timePublisher .receive(on: DispatchQueue.main) @@ -197,8 +199,7 @@ public class PlayerTracker: PlayerTrackerProtocol { } // MARK: YouTube -import YouTubePlayerKit -public class YoutubePlayerTracker: PlayerTrackerProtocol { +public class YoutubePlayerTracker: PlayerTrackerProtocol, @unchecked Sendable { public var isReady: Bool = false public let player: YouTubePlayer? @@ -249,7 +250,7 @@ public class YoutubePlayerTracker: PlayerTrackerProtocol { } deinit { - clear() + cancellations.removeAll() } private func observe() { @@ -294,10 +295,6 @@ public class YoutubePlayerTracker: PlayerTrackerProtocol { .store(in: &cancellations) } - private func clear() { - cancellations.removeAll() - } - public func getTimePublisher() -> AnyPublisher { timePublisher .receive(on: DispatchQueue.main) diff --git a/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift b/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift index a56e8dfb8..787423c4f 100644 --- a/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift +++ b/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift @@ -5,10 +5,11 @@ // Created by Vadim Kuznetsov on 20.03.24. // -import AVKit -import Combine +@preconcurrency import AVKit +@preconcurrency import Combine -public protocol PlayerViewControllerHolderProtocol: AnyObject { +@MainActor +public protocol PlayerViewControllerHolderProtocol: AnyObject, Sendable { var url: URL? { get } var blockID: String { get } var courseID: String { get } @@ -37,7 +38,8 @@ public protocol PlayerViewControllerHolderProtocol: AnyObject { func sendCompletion() async } -public class PlayerViewControllerHolder: PlayerViewControllerHolderProtocol { +@MainActor +public final class PlayerViewControllerHolder: PlayerViewControllerHolderProtocol { public let url: URL? public let blockID: String public let courseID: String @@ -110,33 +112,43 @@ public class PlayerViewControllerHolder: PlayerViewControllerHolderProtocol { addObservers() } + @MainActor private func addObservers() { timePublisher - .sink {[weak self] _ in - guard let strongSelf = self else { return } - if strongSelf.playerTracker.progress > 0.8 && !strongSelf.isViewedOnce { - strongSelf.isViewedOnce = true + .sink {[weak self] _ in + guard let self else { return } + if self.playerTracker.progress > 0.8 && !self.isViewedOnce { + self.isViewedOnce = true Task { - await strongSelf.sendCompletion() + await self.sendCompletion() } } } .store(in: &cancellations) playerTracker.getFinishPublisher() .sink { [weak self] in - self?.playerService.presentAppReview() + guard let self else { return } + MainActor.assumeIsolated { + self.playerService.presentAppReview() + } } .store(in: &cancellations) playerTracker.getRatePublisher() .sink {[weak self] rate in guard rate > 0 else { return } - self?.pausePipIfNeed() + guard let self else { return } + MainActor.assumeIsolated { + self.pausePipIfNeed() + } } .store(in: &cancellations) pipManager.pipRatePublisher()? .sink {[weak self] rate in - guard rate > 0, self?.isPlayingInPip == false else { return } - self?.playerController?.pause() + guard let self else { return } + MainActor.assumeIsolated { + guard rate > 0, self.isPlayingInPip == false else { return } + self.playerController?.pause() + } } .store(in: &cancellations) } @@ -169,6 +181,7 @@ public class PlayerViewControllerHolder: PlayerViewControllerHolderProtocol { playerService } + @MainActor public func sendCompletion() async { do { try await playerService.blockCompletionRequest() @@ -178,7 +191,7 @@ public class PlayerViewControllerHolder: PlayerViewControllerHolderProtocol { } } -extension AVPlayerViewController: PlayerControllerProtocol { +extension AVPlayerViewController: PlayerControllerProtocol, @retroactive Sendable { public func play() { player?.play() } @@ -197,6 +210,7 @@ extension AVPlayerViewController: PlayerControllerProtocol { } #if DEBUG +@MainActor extension PlayerViewControllerHolder { static var mock: PlayerViewControllerHolder { PlayerViewControllerHolder( diff --git a/Course/Course/Presentation/Video/SubtitlesView.swift b/Course/Course/Presentation/Video/SubtitlesView.swift index ef68d69b9..8ce15c50e 100644 --- a/Course/Course/Presentation/Video/SubtitlesView.swift +++ b/Course/Course/Presentation/Video/SubtitlesView.swift @@ -9,7 +9,7 @@ import SwiftUI import Core import Theme -public struct Subtitle { +public struct Subtitle: Sendable { var id: Int var fromTo: DateInterval var text: String diff --git a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift index c35f9df83..e5a67072e 100644 --- a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift @@ -11,6 +11,7 @@ import OEXFoundation import _AVKit_SwiftUI import Combine +@MainActor public class VideoPlayerViewModel: ObservableObject { @Published var pause: Bool = false @Published var currentTime: Double = 0 @@ -137,7 +138,7 @@ public class VideoPlayerViewModel: ObservableObject { private func generateSelectedLanguage() { if let selectedLanguage = languages.first(where: { - $0.language == Locale.current.languageCode + $0.language == Locale.current.language.languageCode?.identifier })?.language { self.selectedLanguage = selectedLanguage } else { diff --git a/Course/Course/Presentation/Video/YoutubePlayerViewControllerHolder.swift b/Course/Course/Presentation/Video/YoutubePlayerViewControllerHolder.swift index 16a4d9eca..f1273708c 100644 --- a/Course/Course/Presentation/Video/YoutubePlayerViewControllerHolder.swift +++ b/Course/Course/Presentation/Video/YoutubePlayerViewControllerHolder.swift @@ -5,11 +5,12 @@ // Created by Vadim Kuznetsov on 22.04.24. // -import Combine +@preconcurrency import Combine import Foundation -import YouTubePlayerKit +@preconcurrency import YouTubePlayerKit -public class YoutubePlayerViewControllerHolder: PlayerViewControllerHolderProtocol { +@MainActor +public final class YoutubePlayerViewControllerHolder: PlayerViewControllerHolderProtocol { public let url: URL? public let blockID: String public let courseID: String @@ -72,33 +73,43 @@ public class YoutubePlayerViewControllerHolder: PlayerViewControllerHolderProtoc addObservers() } + @MainActor private func addObservers() { timePublisher .sink {[weak self] _ in - guard let strongSelf = self else { return } - if strongSelf.playerTracker.progress > 0.8 && !strongSelf.isViewedOnce { - strongSelf.isViewedOnce = true + guard let self else { return } + if self.playerTracker.progress > 0.8 && !self.isViewedOnce { + self.isViewedOnce = true Task { - await strongSelf.sendCompletion() + await self.sendCompletion() } } } .store(in: &cancellations) playerTracker.getFinishPublisher() .sink { [weak self] in - self?.playerService.presentAppReview() + guard let self else { return } + MainActor.assumeIsolated { + self.playerService.presentAppReview() + } } .store(in: &cancellations) playerTracker.getRatePublisher() .sink {[weak self] rate in guard rate > 0 else { return } - self?.pausePipIfNeed() + guard let self else { return } + MainActor.assumeIsolated { + self.pausePipIfNeed() + } } .store(in: &cancellations) pipManager.pipRatePublisher()? .sink {[weak self] rate in - guard rate > 0, self?.isPlayingInPip == false else { return } - self?.playerController?.pause() + guard let self else { return } + MainActor.assumeIsolated { + guard rate > 0, self.isPlayingInPip == false else { return } + self.playerController?.pause() + } } .store(in: &cancellations) } @@ -131,6 +142,7 @@ public class YoutubePlayerViewControllerHolder: PlayerViewControllerHolderProtoc playerService } + @MainActor public func sendCompletion() async { do { try await playerService.blockCompletionRequest() @@ -162,6 +174,7 @@ extension YouTubePlayer: PlayerControllerProtocol { } #if DEBUG +@MainActor extension YoutubePlayerViewControllerHolder { static var mock: YoutubePlayerViewControllerHolder { YoutubePlayerViewControllerHolder( diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index b30530b11..35e6ce7c2 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -508,7 +508,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { } // MARK: - BaseRouter - +@MainActor open class BaseRouterMock: BaseRouter, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -980,7 +980,7 @@ open class BaseRouterMock: BaseRouter, Mock { } // MARK: - CalendarManagerProtocol - +@MainActor open class CalendarManagerProtocolMock: CalendarManagerProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -1847,7 +1847,7 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { } // MARK: - ConnectivityProtocol - +@MainActor open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -2456,16 +2456,19 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { return __value } - open func publisher() -> AnyPublisher { + @MainActor + open func publisher() throws -> AnyPublisher { addInvocation(.m_publisher) let perform = methodPerformValue(.m_publisher) as? () -> Void perform?() var __value: AnyPublisher do { __value = try methodReturnValue(.m_publisher).casted() - } catch { + } catch MockError.notStubed { onFatalFailure("Stub return value not specified for publisher(). Use given") Failure("Stub return value not specified for publisher(). Use given") + } catch { + throw error } return __value } @@ -2757,7 +2760,8 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { public static func getUserID(willReturn: Int?...) -> MethodStub { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + @MainActor + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) } public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { @@ -2785,13 +2789,6 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { willProduce(stubber) return given } - public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { - let willReturn: [AnyPublisher] = [] - let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (AnyPublisher).self) - willProduce(stubber) - return given - } public static func loadProgress(for blockID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [OfflineProgress?] = [] let given: Given = { return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -2834,6 +2831,18 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { willProduce(stubber) return given } + @MainActor + public static func publisher(willThrow: Error...) -> MethodStub { + return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) + } + @MainActor + public static func publisher(willProduce: (StubberThrows>) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (AnyPublisher).self) + willProduce(stubber) + return given + } public static func deleteDownloadDataTask(id: Parameter, willThrow: Error...) -> MethodStub { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -2851,7 +2860,8 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { public static func set(userId: Parameter) -> Verify { return Verify(method: .m_set__userId_userId(`userId`))} public static func getUserID() -> Verify { return Verify(method: .m_getUserID)} - public static func publisher() -> Verify { return Verify(method: .m_publisher)} + @MainActor + public static func publisher() -> Verify { return Verify(method: .m_publisher)} public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>) -> Verify { return Verify(method: .m_addToDownloadQueue__tasks_tasks(`tasks`))} public static func saveOfflineProgress(progress: Parameter) -> Verify { return Verify(method: .m_saveOfflineProgress__progress_progress(`progress`))} public static func loadProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_loadProgress__for_blockID(`blockID`))} @@ -2878,7 +2888,8 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { public static func getUserID(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_getUserID, performs: perform) } - public static func publisher(perform: @escaping () -> Void) -> Perform { + @MainActor + public static func publisher(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_publisher, performs: perform) } public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>, perform: @escaping ([DownloadDataTask]) -> Void) -> Perform { @@ -4699,7 +4710,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { } // MARK: - DownloadManagerProtocol - +@MainActor open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -4747,16 +4758,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { - open func publisher() -> AnyPublisher { + open func publisher() throws -> AnyPublisher { addInvocation(.m_publisher) let perform = methodPerformValue(.m_publisher) as? () -> Void perform?() var __value: AnyPublisher do { __value = try methodReturnValue(.m_publisher).casted() - } catch { + } catch MockError.notStubed { onFatalFailure("Stub return value not specified for publisher(). Use given") Failure("Stub return value not specified for publisher(). Use given") + } catch { + throw error } return __value } @@ -5103,13 +5116,6 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { - let willReturn: [AnyPublisher] = [] - let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (AnyPublisher).self) - willProduce(stubber) - return given - } public static func eventPublisher(willProduce: (Stubber>) -> Void) -> MethodStub { let willReturn: [AnyPublisher] = [] let given: Given = { return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -5152,6 +5158,16 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func publisher(willThrow: Error...) -> MethodStub { + return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func publisher(willProduce: (StubberThrows>) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (AnyPublisher).self) + willProduce(stubber) + return given + } public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -5562,249 +5578,3 @@ open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, Moc } } -// MARK: - WebviewCookiesUpdateProtocol - -open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { - public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { - SwiftyMockyTestObserver.setup() - self.sequencingPolicy = sequencingPolicy - self.stubbingPolicy = stubbingPolicy - self.file = file - self.line = line - } - - var matcher: Matcher = Matcher.default - var stubbingPolicy: StubbingPolicy = .wrap - var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst - - private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) - private var invocations: [MethodType] = [] - private var methodReturnValues: [Given] = [] - private var methodPerformValues: [Perform] = [] - private var file: StaticString? - private var line: UInt? - - public typealias PropertyStub = Given - public typealias MethodStub = Given - public typealias SubscriptStub = Given - - /// Convenience method - call setupMock() to extend debug information when failure occurs - public func setupMock(file: StaticString = #file, line: UInt = #line) { - self.file = file - self.line = line - } - - /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals - public func resetMock(_ scopes: MockScope...) { - let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes - if scopes.contains(.invocation) { invocations = [] } - if scopes.contains(.given) { methodReturnValues = [] } - if scopes.contains(.perform) { methodPerformValues = [] } - } - - public var authInteractor: AuthInteractorProtocol { - get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } - } - private var __p_authInteractor: (AuthInteractorProtocol)? - - public var cookiesReady: Bool { - get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } - set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } - } - private var __p_cookiesReady: (Bool)? - - public var updatingCookies: Bool { - get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } - set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } - } - private var __p_updatingCookies: (Bool)? - - public var errorMessage: String? { - get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } - set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } - } - private var __p_errorMessage: (String)? - - - - - - open func updateCookies(force: Bool, retryCount: Int) { - addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) - let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void - perform?(`force`, `retryCount`) - } - - - fileprivate enum MethodType { - case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) - case p_authInteractor_get - case p_cookiesReady_get - case p_cookiesReady_set(Parameter) - case p_updatingCookies_get - case p_updatingCookies_set(Parameter) - case p_errorMessage_get - case p_errorMessage_set(Parameter) - - static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { - switch (lhs, rhs) { - case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) - return Matcher.ComparisonResult(results) - case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match - case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match - case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - default: return .none - } - } - - func intValue() -> Int { - switch self { - case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue - case .p_authInteractor_get: return 0 - case .p_cookiesReady_get: return 0 - case .p_cookiesReady_set(let newValue): return newValue.intValue - case .p_updatingCookies_get: return 0 - case .p_updatingCookies_set(let newValue): return newValue.intValue - case .p_errorMessage_get: return 0 - case .p_errorMessage_set(let newValue): return newValue.intValue - } - } - func assertionName() -> String { - switch self { - case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" - case .p_authInteractor_get: return "[get] .authInteractor" - case .p_cookiesReady_get: return "[get] .cookiesReady" - case .p_cookiesReady_set: return "[set] .cookiesReady" - case .p_updatingCookies_get: return "[get] .updatingCookies" - case .p_updatingCookies_set: return "[set] .updatingCookies" - case .p_errorMessage_get: return "[get] .errorMessage" - case .p_errorMessage_set: return "[set] .errorMessage" - } - } - } - - open class Given: StubbedMethod { - fileprivate var method: MethodType - - private init(method: MethodType, products: [StubProduct]) { - self.method = method - super.init(products) - } - - public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { - return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { - return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - - } - - public struct Verify { - fileprivate var method: MethodType - - public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} - public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } - public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } - public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } - public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } - public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } - public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } - public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } - } - - public struct Perform { - fileprivate var method: MethodType - var performs: Any - - public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { - return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) - } - } - - public func given(_ method: Given) { - methodReturnValues.append(method) - } - - public func perform(_ method: Perform) { - methodPerformValues.append(method) - methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } - } - - public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { - let fullMatches = matchingCalls(method, file: file, line: line) - let success = count.matches(fullMatches) - let assertionName = method.method.assertionName() - let feedback: String = { - guard !success else { return "" } - return Utils.closestCallsMessage( - for: self.invocations.map { invocation in - matcher.set(file: file, line: line) - defer { matcher.clearFileAndLine() } - return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) - }, - name: assertionName - ) - }() - MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) - } - - private func addInvocation(_ call: MethodType) { - self.queue.sync { invocations.append(call) } - } - private func methodReturnValue(_ method: MethodType) throws -> StubProduct { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) - let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) - guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } - return product - } - private func methodPerformValue(_ method: MethodType) -> Any? { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } - return matched?.performs - } - private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { - matcher.set(file: file ?? self.file, line: line ?? self.line) - defer { matcher.clearFileAndLine() } - return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } - } - private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { - return matchingCalls(method.method, file: file, line: line).count - } - private func givenGetterValue(_ method: MethodType, _ message: String) -> T { - do { - return try methodReturnValue(method).casted() - } catch { - onFatalFailure(message) - Failure(message) - } - } - private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { - do { - return try methodReturnValue(method).casted() - } catch { - return nil - } - } - private func onFatalFailure(_ message: String) { - guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully - SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) - } -} - diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index c87824041..39ccbd8b8 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -13,6 +13,7 @@ import Alamofire import SwiftUI import Combine +@MainActor final class CourseContainerViewModelTests: XCTestCase { func testGetCourseBlocksSuccess() async throws { @@ -492,12 +493,7 @@ final class CourseContainerViewModelTests: XCTestCase { sequentials: [sequential] ) - let exp = expectation(description: "Task Starting") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - exp.fulfill() - } - - wait(for: [exp], timeout: 1) + await Task.yield() XCTAssertEqual(viewModel.sequentialsDownloadState[blockId], .downloading) } @@ -623,12 +619,7 @@ final class CourseContainerViewModelTests: XCTestCase { sequentials: [sequential] ) - let exp = expectation(description: "Task Starting") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - exp.fulfill() - } - - wait(for: [exp], timeout: 1) + await Task.yield() XCTAssertEqual(viewModel.sequentialsDownloadState[blockId], .available) } @@ -753,12 +744,7 @@ final class CourseContainerViewModelTests: XCTestCase { sequentials: [sequential] ) - let exp = expectation(description: "Task Starting") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - exp.fulfill() - } - - wait(for: [exp], timeout: 1) + await Task.yield() XCTAssertEqual(viewModel.sequentialsDownloadState[blockId], .available) @@ -878,12 +864,7 @@ final class CourseContainerViewModelTests: XCTestCase { viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates(courseStructure: courseStructure) - let exp = expectation(description: "Task Starting") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - exp.fulfill() - } - - wait(for: [exp], timeout: 1) + await Task.yield() XCTAssertEqual(viewModel.sequentialsDownloadState[sequential.id], .available) } @@ -1018,12 +999,7 @@ final class CourseContainerViewModelTests: XCTestCase { viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates(courseStructure: courseStructure) - let exp = expectation(description: "Task Starting") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - exp.fulfill() - } - - wait(for: [exp], timeout: 1) + await Task.yield() XCTAssertEqual(viewModel.sequentialsDownloadState[sequential.id], .downloading) } @@ -1158,12 +1134,8 @@ final class CourseContainerViewModelTests: XCTestCase { viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates(courseStructure: courseStructure) - let exp = expectation(description: "Task Starting") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - exp.fulfill() - } - - wait(for: [exp], timeout: 1) + await Task.yield() + XCTAssertEqual(viewModel.sequentialsDownloadState[sequential.id], .finished) } @@ -1320,12 +1292,7 @@ final class CourseContainerViewModelTests: XCTestCase { viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates(courseStructure: courseStructure) - let exp = expectation(description: "Task Starting") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - exp.fulfill() - } - - wait(for: [exp], timeout: 1) + await Task.yield() XCTAssertEqual(viewModel.sequentialsDownloadState[sequential.id], .available) } diff --git a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift index 34cde8e6e..1fb78183f 100644 --- a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift @@ -11,6 +11,7 @@ import SwiftyMocky @testable import Core @testable import Course +@MainActor final class CourseDateViewModelTests: XCTestCase { func testGetCourseDatesSuccess() async throws { let interactor = CourseInteractorProtocolMock() diff --git a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift index 8a43f206e..630a39e29 100644 --- a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift @@ -12,6 +12,7 @@ import XCTest import Alamofire import SwiftUI +@MainActor final class CourseUnitViewModelTests: XCTestCase { var config = Config() diff --git a/Course/CourseTests/Presentation/Unit/HandoutsViewModelTests.swift b/Course/CourseTests/Presentation/Unit/HandoutsViewModelTests.swift index bad7ec2f6..9ce931149 100644 --- a/Course/CourseTests/Presentation/Unit/HandoutsViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/HandoutsViewModelTests.swift @@ -12,6 +12,7 @@ import XCTest import Alamofire import SwiftUI +@MainActor final class HandoutsViewModelTests: XCTestCase { func testGetHandoutsSuccess() async throws { diff --git a/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift b/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift index a083fa577..9c7871224 100644 --- a/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift @@ -12,6 +12,7 @@ import XCTest import Alamofire import SwiftUI +@MainActor final class VideoPlayerViewModelTests: XCTestCase { let subtitles = [ @@ -99,16 +100,27 @@ final class VideoPlayerViewModelTests: XCTestCase { func testBlockCompletionRequest() async throws { let interactor = CourseInteractorProtocolMock() let router = CourseRouterMock() - let connectivity = ConnectivityProtocolMock() let tracker = PlayerTrackerProtocolMock(url: nil) let service = PlayerService(courseID: "", blockID: "", interactor: interactor, router: router) - let playerHolder = PlayerViewControllerHolder(url: nil, blockID: "", courseID: "", selectedCourseTab: 0, videoResolution: .zero, pipManager: PipManagerProtocolMock(), playerTracker: tracker, playerDelegate: nil, playerService: service) + let playerHolder = PlayerViewControllerHolder( + url: nil, + blockID: "", + courseID: "", + selectedCourseTab: 0, + videoResolution: .zero, + pipManager: PipManagerProtocolMock(), + playerTracker: tracker, + playerDelegate: nil, + playerService: service + ) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, willProduce: {_ in})) await playerHolder.sendCompletion() + await Task.yield() + Verify(interactor, .blockCompletionRequest(courseID: .any, blockID: .any)) } @@ -127,13 +139,7 @@ final class VideoPlayerViewModelTests: XCTestCase { await playerHolder.sendCompletion() Verify(interactor, .blockCompletionRequest(courseID: .any, blockID: .any)) - - let expectation = XCTestExpectation(description: "Wait for combine") - - DispatchQueue.main.asyncAfter(deadline: .now()+0.1) { - expectation.fulfill() - } - await fulfillment(of: [expectation], timeout: 1) + await Task.yield() XCTAssertTrue(viewModel.showError) XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) @@ -155,12 +161,7 @@ final class VideoPlayerViewModelTests: XCTestCase { await playerHolder.sendCompletion() - let expectation = XCTestExpectation(description: "Wait for combine") - - DispatchQueue.main.asyncAfter(deadline: .now()+0.1) { - expectation.fulfill() - } - await fulfillment(of: [expectation], timeout: 1) + await Task.yield() Verify(interactor, .blockCompletionRequest(courseID: .any, blockID: .any)) diff --git a/Dashboard/Dashboard.xcodeproj/project.pbxproj b/Dashboard/Dashboard.xcodeproj/project.pbxproj index 5688cafb4..3d950ad7b 100644 --- a/Dashboard/Dashboard.xcodeproj/project.pbxproj +++ b/Dashboard/Dashboard.xcodeproj/project.pbxproj @@ -771,7 +771,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugStage; @@ -884,7 +884,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseStage; @@ -1062,7 +1062,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -1096,7 +1096,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -1195,7 +1195,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugDev; @@ -1287,7 +1287,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseDev; @@ -1386,7 +1386,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugProd; @@ -1478,7 +1478,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseProd; @@ -1539,7 +1539,7 @@ repositoryURL = "https://github.com/openedx/openedx-app-foundation-ios/"; requirement = { kind = exactVersion; - version = 1.0.0; + version = 1.0.1; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Dashboard/Dashboard/Data/DashboardRepository.swift b/Dashboard/Dashboard/Data/DashboardRepository.swift index 9487caab4..028b5cd3f 100644 --- a/Dashboard/Dashboard/Data/DashboardRepository.swift +++ b/Dashboard/Dashboard/Data/DashboardRepository.swift @@ -9,7 +9,7 @@ import Foundation import Core import OEXFoundation -public protocol DashboardRepositoryProtocol { +public protocol DashboardRepositoryProtocol: Sendable { func getEnrollments(page: Int) async throws -> [CourseItem] func getEnrollmentsOffline() async throws -> [CourseItem] func getPrimaryEnrollment(pageSize: Int) async throws -> PrimaryEnrollment @@ -17,7 +17,7 @@ public protocol DashboardRepositoryProtocol { func getAllCourses(filteredBy: String, page: Int) async throws -> PrimaryEnrollment } -public class DashboardRepository: DashboardRepositoryProtocol { +public actor DashboardRepository: DashboardRepositoryProtocol { private let api: API private let storage: CoreStorage @@ -37,7 +37,7 @@ public class DashboardRepository: DashboardRepositoryProtocol { ) .mapResponse(DataLayer.CourseEnrollments.self) .domain(baseURL: config.baseURL.absoluteString) - persistence.saveEnrollments(items: result) + await persistence.saveEnrollments(items: result) return result } @@ -55,7 +55,7 @@ public class DashboardRepository: DashboardRepositoryProtocol { ) .mapResponse(DataLayer.PrimaryEnrollment.self) .domain(baseURL: config.baseURL.absoluteString) - persistence.savePrimaryEnrollment(enrollments: result) + await persistence.savePrimaryEnrollment(enrollments: result) return result } @@ -80,7 +80,7 @@ public class DashboardRepository: DashboardRepositoryProtocol { // swiftlint:disable all // Mark - For testing and SwiftUI preview #if DEBUG -class DashboardRepositoryMock: DashboardRepositoryProtocol { +final class DashboardRepositoryMock: DashboardRepositoryProtocol { func getEnrollments(page: Int) async throws -> [CourseItem] { var models: [CourseItem] = [] diff --git a/Dashboard/Dashboard/Data/Persistence/DashboardPersistenceProtocol.swift b/Dashboard/Dashboard/Data/Persistence/DashboardPersistenceProtocol.swift index 2257c8238..0cc00d3b5 100644 --- a/Dashboard/Dashboard/Data/Persistence/DashboardPersistenceProtocol.swift +++ b/Dashboard/Dashboard/Data/Persistence/DashboardPersistenceProtocol.swift @@ -8,11 +8,11 @@ import CoreData import Core -public protocol DashboardPersistenceProtocol { +public protocol DashboardPersistenceProtocol: Sendable { func loadEnrollments() async throws -> [CourseItem] - func saveEnrollments(items: [CourseItem]) + func saveEnrollments(items: [CourseItem]) async func loadPrimaryEnrollment() async throws -> PrimaryEnrollment - func savePrimaryEnrollment(enrollments: PrimaryEnrollment) + func savePrimaryEnrollment(enrollments: PrimaryEnrollment) async } public final class DashboardBundle { diff --git a/Dashboard/Dashboard/Domain/DashboardInteractor.swift b/Dashboard/Dashboard/Domain/DashboardInteractor.swift index 60a920eaf..ec859296c 100644 --- a/Dashboard/Dashboard/Domain/DashboardInteractor.swift +++ b/Dashboard/Dashboard/Domain/DashboardInteractor.swift @@ -9,7 +9,7 @@ import Foundation import Core //sourcery: AutoMockable -public protocol DashboardInteractorProtocol { +public protocol DashboardInteractorProtocol: Sendable { func getEnrollments(page: Int) async throws -> [CourseItem] func getEnrollmentsOffline() async throws -> [CourseItem] func getPrimaryEnrollment(pageSize: Int) async throws -> PrimaryEnrollment @@ -17,7 +17,7 @@ public protocol DashboardInteractorProtocol { func getAllCourses(filteredBy: String, page: Int) async throws -> PrimaryEnrollment } -public class DashboardInteractor: DashboardInteractorProtocol { +public actor DashboardInteractor: DashboardInteractorProtocol { private let repository: DashboardRepositoryProtocol diff --git a/Dashboard/Dashboard/Presentation/AllCoursesView.swift b/Dashboard/Dashboard/Presentation/AllCoursesView.swift index caf44c036..0a5554ad1 100644 --- a/Dashboard/Dashboard/Presentation/AllCoursesView.swift +++ b/Dashboard/Dashboard/Presentation/AllCoursesView.swift @@ -10,6 +10,7 @@ import Core import OEXFoundation import Theme +@MainActor public struct AllCoursesView: View { @ObservedObject diff --git a/Dashboard/Dashboard/Presentation/AllCoursesViewModel.swift b/Dashboard/Dashboard/Presentation/AllCoursesViewModel.swift index 439f329f7..dda60e937 100644 --- a/Dashboard/Dashboard/Presentation/AllCoursesViewModel.swift +++ b/Dashboard/Dashboard/Presentation/AllCoursesViewModel.swift @@ -10,6 +10,7 @@ import Core import SwiftUI import Combine +@MainActor public class AllCoursesViewModel: ObservableObject { var nextPage = 1 diff --git a/Dashboard/Dashboard/Presentation/DashboardRouter.swift b/Dashboard/Dashboard/Presentation/DashboardRouter.swift index 0d38f3199..61766d0e6 100644 --- a/Dashboard/Dashboard/Presentation/DashboardRouter.swift +++ b/Dashboard/Dashboard/Presentation/DashboardRouter.swift @@ -8,6 +8,7 @@ import Foundation import Core +@MainActor public protocol DashboardRouter: BaseRouter { func showCourseScreens(courseID: String, diff --git a/Dashboard/Dashboard/Presentation/ListDashboardView.swift b/Dashboard/Dashboard/Presentation/ListDashboardView.swift index d5d925bd0..8f668dcc5 100644 --- a/Dashboard/Dashboard/Presentation/ListDashboardView.swift +++ b/Dashboard/Dashboard/Presentation/ListDashboardView.swift @@ -60,7 +60,7 @@ public struct ListDashboardView: View { model: course, type: .dashboard, index: index, - cellsCount: viewModel.courses.count, + cellsCount: viewModel.courses.count, useRelativeDates: useRelativeDates ) .padding(.horizontal, 20) @@ -164,7 +164,7 @@ struct ListDashboardView_Previews: PreviewProvider { let vm = ListDashboardViewModel( interactor: DashboardInteractor.mock, connectivity: Connectivity(), - analytics: DashboardAnalyticsMock(), + analytics: DashboardAnalyticsMock(), storage: CoreStorageMock() ) let router = DashboardRouterMock() diff --git a/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift b/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift index 112865e86..8fc917b00 100644 --- a/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift +++ b/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift @@ -10,6 +10,7 @@ import Core import SwiftUI import Combine +@MainActor public class ListDashboardViewModel: ObservableObject { public var nextPage = 1 diff --git a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift index f1a74f773..5159bc8c7 100644 --- a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift +++ b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift @@ -10,6 +10,7 @@ import Core import SwiftUI import Combine +@MainActor public class PrimaryCourseDashboardViewModel: ObservableObject { var nextPage = 1 diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index 975fac8b3..4fc3fb8d6 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -508,7 +508,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { } // MARK: - BaseRouter - +@MainActor open class BaseRouterMock: BaseRouter, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -980,7 +980,7 @@ open class BaseRouterMock: BaseRouter, Mock { } // MARK: - CalendarManagerProtocol - +@MainActor open class CalendarManagerProtocolMock: CalendarManagerProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -1847,7 +1847,7 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { } // MARK: - ConnectivityProtocol - +@MainActor open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -2456,16 +2456,19 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { return __value } - open func publisher() -> AnyPublisher { + @MainActor + open func publisher() throws -> AnyPublisher { addInvocation(.m_publisher) let perform = methodPerformValue(.m_publisher) as? () -> Void perform?() var __value: AnyPublisher do { __value = try methodReturnValue(.m_publisher).casted() - } catch { + } catch MockError.notStubed { onFatalFailure("Stub return value not specified for publisher(). Use given") Failure("Stub return value not specified for publisher(). Use given") + } catch { + throw error } return __value } @@ -2757,7 +2760,8 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { public static func getUserID(willReturn: Int?...) -> MethodStub { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + @MainActor + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) } public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { @@ -2785,13 +2789,6 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { willProduce(stubber) return given } - public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { - let willReturn: [AnyPublisher] = [] - let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (AnyPublisher).self) - willProduce(stubber) - return given - } public static func loadProgress(for blockID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [OfflineProgress?] = [] let given: Given = { return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -2834,6 +2831,18 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { willProduce(stubber) return given } + @MainActor + public static func publisher(willThrow: Error...) -> MethodStub { + return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) + } + @MainActor + public static func publisher(willProduce: (StubberThrows>) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (AnyPublisher).self) + willProduce(stubber) + return given + } public static func deleteDownloadDataTask(id: Parameter, willThrow: Error...) -> MethodStub { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -2851,7 +2860,8 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { public static func set(userId: Parameter) -> Verify { return Verify(method: .m_set__userId_userId(`userId`))} public static func getUserID() -> Verify { return Verify(method: .m_getUserID)} - public static func publisher() -> Verify { return Verify(method: .m_publisher)} + @MainActor + public static func publisher() -> Verify { return Verify(method: .m_publisher)} public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>) -> Verify { return Verify(method: .m_addToDownloadQueue__tasks_tasks(`tasks`))} public static func saveOfflineProgress(progress: Parameter) -> Verify { return Verify(method: .m_saveOfflineProgress__progress_progress(`progress`))} public static func loadProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_loadProgress__for_blockID(`blockID`))} @@ -2878,7 +2888,8 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { public static func getUserID(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_getUserID, performs: perform) } - public static func publisher(perform: @escaping () -> Void) -> Perform { + @MainActor + public static func publisher(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_publisher, performs: perform) } public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>, perform: @escaping ([DownloadDataTask]) -> Void) -> Perform { @@ -3931,7 +3942,7 @@ open class DashboardInteractorProtocolMock: DashboardInteractorProtocol, Mock { } // MARK: - DownloadManagerProtocol - +@MainActor open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -3979,16 +3990,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { - open func publisher() -> AnyPublisher { + open func publisher() throws -> AnyPublisher { addInvocation(.m_publisher) let perform = methodPerformValue(.m_publisher) as? () -> Void perform?() var __value: AnyPublisher do { __value = try methodReturnValue(.m_publisher).casted() - } catch { + } catch MockError.notStubed { onFatalFailure("Stub return value not specified for publisher(). Use given") Failure("Stub return value not specified for publisher(). Use given") + } catch { + throw error } return __value } @@ -4335,13 +4348,6 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { - let willReturn: [AnyPublisher] = [] - let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (AnyPublisher).self) - willProduce(stubber) - return given - } public static func eventPublisher(willProduce: (Stubber>) -> Void) -> MethodStub { let willReturn: [AnyPublisher] = [] let given: Given = { return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -4384,6 +4390,16 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func publisher(willThrow: Error...) -> MethodStub { + return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func publisher(willProduce: (StubberThrows>) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (AnyPublisher).self) + willProduce(stubber) + return given + } public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -4794,249 +4810,3 @@ open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, Moc } } -// MARK: - WebviewCookiesUpdateProtocol - -open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { - public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { - SwiftyMockyTestObserver.setup() - self.sequencingPolicy = sequencingPolicy - self.stubbingPolicy = stubbingPolicy - self.file = file - self.line = line - } - - var matcher: Matcher = Matcher.default - var stubbingPolicy: StubbingPolicy = .wrap - var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst - - private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) - private var invocations: [MethodType] = [] - private var methodReturnValues: [Given] = [] - private var methodPerformValues: [Perform] = [] - private var file: StaticString? - private var line: UInt? - - public typealias PropertyStub = Given - public typealias MethodStub = Given - public typealias SubscriptStub = Given - - /// Convenience method - call setupMock() to extend debug information when failure occurs - public func setupMock(file: StaticString = #file, line: UInt = #line) { - self.file = file - self.line = line - } - - /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals - public func resetMock(_ scopes: MockScope...) { - let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes - if scopes.contains(.invocation) { invocations = [] } - if scopes.contains(.given) { methodReturnValues = [] } - if scopes.contains(.perform) { methodPerformValues = [] } - } - - public var authInteractor: AuthInteractorProtocol { - get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } - } - private var __p_authInteractor: (AuthInteractorProtocol)? - - public var cookiesReady: Bool { - get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } - set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } - } - private var __p_cookiesReady: (Bool)? - - public var updatingCookies: Bool { - get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } - set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } - } - private var __p_updatingCookies: (Bool)? - - public var errorMessage: String? { - get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } - set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } - } - private var __p_errorMessage: (String)? - - - - - - open func updateCookies(force: Bool, retryCount: Int) { - addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) - let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void - perform?(`force`, `retryCount`) - } - - - fileprivate enum MethodType { - case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) - case p_authInteractor_get - case p_cookiesReady_get - case p_cookiesReady_set(Parameter) - case p_updatingCookies_get - case p_updatingCookies_set(Parameter) - case p_errorMessage_get - case p_errorMessage_set(Parameter) - - static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { - switch (lhs, rhs) { - case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) - return Matcher.ComparisonResult(results) - case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match - case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match - case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - default: return .none - } - } - - func intValue() -> Int { - switch self { - case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue - case .p_authInteractor_get: return 0 - case .p_cookiesReady_get: return 0 - case .p_cookiesReady_set(let newValue): return newValue.intValue - case .p_updatingCookies_get: return 0 - case .p_updatingCookies_set(let newValue): return newValue.intValue - case .p_errorMessage_get: return 0 - case .p_errorMessage_set(let newValue): return newValue.intValue - } - } - func assertionName() -> String { - switch self { - case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" - case .p_authInteractor_get: return "[get] .authInteractor" - case .p_cookiesReady_get: return "[get] .cookiesReady" - case .p_cookiesReady_set: return "[set] .cookiesReady" - case .p_updatingCookies_get: return "[get] .updatingCookies" - case .p_updatingCookies_set: return "[set] .updatingCookies" - case .p_errorMessage_get: return "[get] .errorMessage" - case .p_errorMessage_set: return "[set] .errorMessage" - } - } - } - - open class Given: StubbedMethod { - fileprivate var method: MethodType - - private init(method: MethodType, products: [StubProduct]) { - self.method = method - super.init(products) - } - - public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { - return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { - return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - - } - - public struct Verify { - fileprivate var method: MethodType - - public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} - public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } - public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } - public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } - public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } - public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } - public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } - public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } - } - - public struct Perform { - fileprivate var method: MethodType - var performs: Any - - public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { - return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) - } - } - - public func given(_ method: Given) { - methodReturnValues.append(method) - } - - public func perform(_ method: Perform) { - methodPerformValues.append(method) - methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } - } - - public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { - let fullMatches = matchingCalls(method, file: file, line: line) - let success = count.matches(fullMatches) - let assertionName = method.method.assertionName() - let feedback: String = { - guard !success else { return "" } - return Utils.closestCallsMessage( - for: self.invocations.map { invocation in - matcher.set(file: file, line: line) - defer { matcher.clearFileAndLine() } - return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) - }, - name: assertionName - ) - }() - MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) - } - - private func addInvocation(_ call: MethodType) { - self.queue.sync { invocations.append(call) } - } - private func methodReturnValue(_ method: MethodType) throws -> StubProduct { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) - let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) - guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } - return product - } - private func methodPerformValue(_ method: MethodType) -> Any? { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } - return matched?.performs - } - private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { - matcher.set(file: file ?? self.file, line: line ?? self.line) - defer { matcher.clearFileAndLine() } - return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } - } - private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { - return matchingCalls(method.method, file: file, line: line).count - } - private func givenGetterValue(_ method: MethodType, _ message: String) -> T { - do { - return try methodReturnValue(method).casted() - } catch { - onFatalFailure(message) - Failure(message) - } - } - private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { - do { - return try methodReturnValue(method).casted() - } catch { - return nil - } - } - private func onFatalFailure(_ message: String) { - guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully - SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) - } -} - diff --git a/Dashboard/DashboardTests/Presentation/AllCoursesViewModelTests.swift b/Dashboard/DashboardTests/Presentation/AllCoursesViewModelTests.swift index abd7c7e6e..54fc3dd46 100644 --- a/Dashboard/DashboardTests/Presentation/AllCoursesViewModelTests.swift +++ b/Dashboard/DashboardTests/Presentation/AllCoursesViewModelTests.swift @@ -13,6 +13,7 @@ import XCTest import Combine import SwiftUI +@MainActor final class AllCoursesViewModelTests: XCTestCase { var interactor: DashboardInteractorProtocolMock! diff --git a/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift b/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift index e053c19c7..700b96c54 100644 --- a/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift +++ b/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift @@ -12,6 +12,7 @@ import XCTest import Alamofire import SwiftUI +@MainActor final class ListDashboardViewModelTests: XCTestCase { func testGetMyCoursesSuccess() async throws { diff --git a/Dashboard/DashboardTests/Presentation/PrimaryCourseDashboardViewModelTests.swift b/Dashboard/DashboardTests/Presentation/PrimaryCourseDashboardViewModelTests.swift index 0f6aff8c5..58bcb5bee 100644 --- a/Dashboard/DashboardTests/Presentation/PrimaryCourseDashboardViewModelTests.swift +++ b/Dashboard/DashboardTests/Presentation/PrimaryCourseDashboardViewModelTests.swift @@ -13,6 +13,7 @@ import XCTest import Combine import SwiftUI +@MainActor final class PrimaryCourseDashboardViewModelTests: XCTestCase { var interactor: DashboardInteractorProtocolMock! diff --git a/Discovery/Discovery.xcodeproj/project.pbxproj b/Discovery/Discovery.xcodeproj/project.pbxproj index 8c3d40080..d7ef9d351 100644 --- a/Discovery/Discovery.xcodeproj/project.pbxproj +++ b/Discovery/Discovery.xcodeproj/project.pbxproj @@ -848,7 +848,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugStage; @@ -962,7 +962,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseStage; @@ -1141,7 +1141,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -1176,7 +1176,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -1276,7 +1276,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugProd; @@ -1376,7 +1376,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugDev; @@ -1469,7 +1469,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseProd; @@ -1562,7 +1562,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseDev; @@ -1623,7 +1623,7 @@ repositoryURL = "https://github.com/openedx/openedx-app-foundation-ios/"; requirement = { kind = exactVersion; - version = 1.0.0; + version = 1.0.1; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Discovery/Discovery/Data/DiscoveryRepository.swift b/Discovery/Discovery/Data/DiscoveryRepository.swift index d08571071..9ce20c9c3 100644 --- a/Discovery/Discovery/Data/DiscoveryRepository.swift +++ b/Discovery/Discovery/Data/DiscoveryRepository.swift @@ -11,7 +11,7 @@ import OEXFoundation import CoreData import Alamofire -public protocol DiscoveryRepositoryProtocol { +public protocol DiscoveryRepositoryProtocol: Sendable { func getDiscovery(page: Int) async throws -> [CourseItem] func searchCourses(page: Int, searchTerm: String) async throws -> [CourseItem] func getDiscoveryOffline() async throws -> [CourseItem] @@ -20,17 +20,19 @@ public protocol DiscoveryRepositoryProtocol { func enrollToCourse(courseID: String) async throws -> Bool } -public class DiscoveryRepository: DiscoveryRepositoryProtocol { +public actor DiscoveryRepository: DiscoveryRepositoryProtocol { private let api: API private let coreStorage: CoreStorage private let config: ConfigProtocol private let persistence: DiscoveryPersistenceProtocol - public init(api: API, - appStorage: CoreStorage, - config: ConfigProtocol, - persistence: DiscoveryPersistenceProtocol) { + public init( + api: API, + appStorage: CoreStorage, + config: ConfigProtocol, + persistence: DiscoveryPersistenceProtocol + ) { self.api = api self.coreStorage = appStorage self.config = config @@ -41,7 +43,7 @@ public class DiscoveryRepository: DiscoveryRepositoryProtocol { let discoveryResponse = try await api.requestData(DiscoveryEndpoint.getDiscovery( username: coreStorage.user?.username ?? "", page: page) ).mapResponse(DataLayer.DiscoveryResponce.self).domain - persistence.saveDiscovery(items: discoveryResponse) + await persistence.saveDiscovery(items: discoveryResponse) return discoveryResponse } @@ -63,7 +65,7 @@ public class DiscoveryRepository: DiscoveryRepositoryProtocol { ).mapResponse(DataLayer.CourseDetailsResponse.self) .domain(baseURL: config.baseURL.absoluteString) - persistence.saveCourseDetails(course: response) + await persistence.saveCourseDetails(course: response) return response } @@ -80,7 +82,7 @@ public class DiscoveryRepository: DiscoveryRepositoryProtocol { // Mark - For testing and SwiftUI preview #if DEBUG -class DiscoveryRepositoryMock: DiscoveryRepositoryProtocol { +final class DiscoveryRepositoryMock: DiscoveryRepositoryProtocol { public func getCourseDetails(courseID: String) async throws -> CourseDetails { return CourseDetails( diff --git a/Discovery/Discovery/Data/Model/CourseDetails.swift b/Discovery/Discovery/Data/Model/CourseDetails.swift index fb67340aa..997be56da 100644 --- a/Discovery/Discovery/Data/Model/CourseDetails.swift +++ b/Discovery/Discovery/Data/Model/CourseDetails.swift @@ -7,7 +7,7 @@ import Foundation -public struct CourseDetails { +public struct CourseDetails: Sendable { public let courseID: String public let org: String public let courseTitle: String diff --git a/Discovery/Discovery/Data/Network/DiscoveryEndpoint.swift b/Discovery/Discovery/Data/Network/DiscoveryEndpoint.swift index 2d111b847..823af5471 100644 --- a/Discovery/Discovery/Data/Network/DiscoveryEndpoint.swift +++ b/Discovery/Discovery/Data/Network/DiscoveryEndpoint.swift @@ -66,16 +66,19 @@ enum DiscoveryEndpoint: EndPointType { return .requestParameters(parameters: params, encoding: URLEncoding.queryString) case .enrollToCourse(courseID: let courseID): - let params: [String: Any] = [ - "course_details": [ - "course_id": courseID, - "email_opt_in": true - ] + + let details: [String: any Any & Sendable] = [ + "course_id": courseID, + "email_opt_in": true + ] + + let params: [String: any Any & Sendable] = [ + "course_details": details ] return .requestParameters(parameters: params, encoding: JSONEncoding.default) case let .getCourseDetail(_, username): - let params: [String: Encodable] = ["username": username] + let params: [String: Encodable & Sendable] = ["username": username] return .requestParameters(parameters: params, encoding: URLEncoding.queryString) } } diff --git a/Discovery/Discovery/Data/Persistence/DiscoveryPersistenceProtocol.swift b/Discovery/Discovery/Data/Persistence/DiscoveryPersistenceProtocol.swift index 0445a690c..0b7a6388d 100644 --- a/Discovery/Discovery/Data/Persistence/DiscoveryPersistenceProtocol.swift +++ b/Discovery/Discovery/Data/Persistence/DiscoveryPersistenceProtocol.swift @@ -8,11 +8,11 @@ import CoreData import Core -public protocol DiscoveryPersistenceProtocol { +public protocol DiscoveryPersistenceProtocol: Sendable { func loadDiscovery() async throws -> [CourseItem] - func saveDiscovery(items: [CourseItem]) + func saveDiscovery(items: [CourseItem]) async func loadCourseDetails(courseID: String) async throws -> CourseDetails - func saveCourseDetails(course: CourseDetails) + func saveCourseDetails(course: CourseDetails) async } public final class DiscoveryBundle { diff --git a/Discovery/Discovery/Domain/DiscoveryInteractor.swift b/Discovery/Discovery/Domain/DiscoveryInteractor.swift index 403463dc5..480ba05b4 100644 --- a/Discovery/Discovery/Domain/DiscoveryInteractor.swift +++ b/Discovery/Discovery/Domain/DiscoveryInteractor.swift @@ -9,7 +9,7 @@ import Foundation import Core //sourcery: AutoMockable -public protocol DiscoveryInteractorProtocol { +public protocol DiscoveryInteractorProtocol: Sendable { func discovery(page: Int) async throws -> [CourseItem] func discoveryOffline() async throws -> [CourseItem] func search(page: Int, searchTerm: String) async throws -> [CourseItem] @@ -18,7 +18,7 @@ public protocol DiscoveryInteractorProtocol { func enrollToCourse(courseID: String) async throws -> Bool } -public class DiscoveryInteractor: DiscoveryInteractorProtocol { +public actor DiscoveryInteractor: DiscoveryInteractorProtocol { private let repository: DiscoveryRepositoryProtocol diff --git a/Discovery/Discovery/Presentation/DiscoveryRouter.swift b/Discovery/Discovery/Presentation/DiscoveryRouter.swift index 6c9651c78..8ba09740a 100644 --- a/Discovery/Discovery/Presentation/DiscoveryRouter.swift +++ b/Discovery/Discovery/Presentation/DiscoveryRouter.swift @@ -8,6 +8,7 @@ import Foundation import Core +@MainActor public protocol DiscoveryRouter: BaseRouter { func showCourseDetais(courseID: String, title: String) func showWebDiscoveryDetails( diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsViewModel.swift b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsViewModel.swift index eafab2542..6f4aa814c 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsViewModel.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsViewModel.swift @@ -15,7 +15,8 @@ public enum CourseState { case alreadyEnrolled } -public class CourseDetailsViewModel: ObservableObject { +@MainActor +public final class CourseDetailsViewModel: ObservableObject { @Published var courseDetails: CourseDetails? @Published private(set) var isShowProgress = false diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryViewModel.swift b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryViewModel.swift index 9c091f847..232953b4d 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryViewModel.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryViewModel.swift @@ -9,7 +9,8 @@ import Combine import Core import SwiftUI -public class DiscoveryViewModel: ObservableObject { +@MainActor +public final class DiscoveryViewModel: ObservableObject { var nextPage = 1 var totalPages = 1 diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/SearchViewModel.swift b/Discovery/Discovery/Presentation/NativeDiscovery/SearchViewModel.swift index 76f3ea137..05c9b7ac4 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/SearchViewModel.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/SearchViewModel.swift @@ -10,7 +10,8 @@ import Core import SwiftUI import Combine -public class SearchViewModel: ObservableObject { +@MainActor +public final class SearchViewModel: ObservableObject { var nextPage = 1 var totalPages = 1 @Published private(set) var fetchInProgress = false @@ -58,8 +59,10 @@ public class SearchViewModel: ObservableObject { .trimmingCharacters(in: .whitespaces) Task.detached(priority: .high) { if !term.isEmpty { - if term == self.prevQuery { return } - self.nextPage = 1 + if await term == self.prevQuery { return } + await MainActor.run { + self.nextPage = 1 + } await self.search(page: self.nextPage, searchTerm: str) } else { await MainActor.run { diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift index 8b9163dc1..86f17ef5a 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift @@ -10,7 +10,8 @@ import Core import SwiftUI import WebKit -public class DiscoveryWebviewViewModel: ObservableObject { +@MainActor +public final class DiscoveryWebviewViewModel: ObservableObject { @Published var courseDetails: CourseDetails? @Published private(set) var showProgress = false @Published var showError: Bool = false @@ -75,7 +76,7 @@ public class DiscoveryWebviewViewModel: ObservableObject { if courseDetails?.isEnrolled ?? false || courseState == .alreadyEnrolled { showProgress = false - showCourseDetails() + await showCourseDetails() return } @@ -85,7 +86,7 @@ public class DiscoveryWebviewViewModel: ObservableObject { courseDetails?.isEnrolled = true showProgress = false NotificationCenter.default.post(name: .onCourseEnrolled, object: courseID) - showCourseDetails() + await showCourseDetails() } catch let error { showProgress = false if error.isInternetError || error is NoCachedDataError { @@ -174,7 +175,7 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { sourceScreen: sourceScreen ) case .enrolledCourseDetail: - return showCourseDetails() + return await showCourseDetails() case .programDetail: guard let pathID = programDetailPathId(from: url) else { return false } @@ -217,7 +218,7 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { return path } - @discardableResult private func showCourseDetails() -> Bool { + @discardableResult private func showCourseDetails() async -> Bool { guard let courseDetails = courseDetails else { return false } router.showCourseScreens( diff --git a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift index ed636378b..13153015f 100644 --- a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift +++ b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift @@ -10,7 +10,7 @@ import Core import SwiftUI import WebKit -public class ProgramWebviewViewModel: ObservableObject, WebviewCookiesUpdateProtocol { +public final class ProgramWebviewViewModel: ObservableObject, WebviewCookiesUpdateProtocol { @Published var courseDetails: CourseDetails? @Published private(set) var showProgress = false @Published var showError: Bool = false diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index 285c4e619..52d805914 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -508,7 +508,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { } // MARK: - BaseRouter - +@MainActor open class BaseRouterMock: BaseRouter, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -980,7 +980,7 @@ open class BaseRouterMock: BaseRouter, Mock { } // MARK: - CalendarManagerProtocol - +@MainActor open class CalendarManagerProtocolMock: CalendarManagerProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -1847,7 +1847,7 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { } // MARK: - ConnectivityProtocol - +@MainActor open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -2456,16 +2456,19 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { return __value } - open func publisher() -> AnyPublisher { + @MainActor + open func publisher() throws -> AnyPublisher { addInvocation(.m_publisher) let perform = methodPerformValue(.m_publisher) as? () -> Void perform?() var __value: AnyPublisher do { __value = try methodReturnValue(.m_publisher).casted() - } catch { + } catch MockError.notStubed { onFatalFailure("Stub return value not specified for publisher(). Use given") Failure("Stub return value not specified for publisher(). Use given") + } catch { + throw error } return __value } @@ -2757,7 +2760,8 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { public static func getUserID(willReturn: Int?...) -> MethodStub { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + @MainActor + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) } public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { @@ -2785,13 +2789,6 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { willProduce(stubber) return given } - public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { - let willReturn: [AnyPublisher] = [] - let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (AnyPublisher).self) - willProduce(stubber) - return given - } public static func loadProgress(for blockID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [OfflineProgress?] = [] let given: Given = { return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -2834,6 +2831,18 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { willProduce(stubber) return given } + @MainActor + public static func publisher(willThrow: Error...) -> MethodStub { + return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) + } + @MainActor + public static func publisher(willProduce: (StubberThrows>) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (AnyPublisher).self) + willProduce(stubber) + return given + } public static func deleteDownloadDataTask(id: Parameter, willThrow: Error...) -> MethodStub { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -2851,7 +2860,8 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { public static func set(userId: Parameter) -> Verify { return Verify(method: .m_set__userId_userId(`userId`))} public static func getUserID() -> Verify { return Verify(method: .m_getUserID)} - public static func publisher() -> Verify { return Verify(method: .m_publisher)} + @MainActor + public static func publisher() -> Verify { return Verify(method: .m_publisher)} public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>) -> Verify { return Verify(method: .m_addToDownloadQueue__tasks_tasks(`tasks`))} public static func saveOfflineProgress(progress: Parameter) -> Verify { return Verify(method: .m_saveOfflineProgress__progress_progress(`progress`))} public static func loadProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_loadProgress__for_blockID(`blockID`))} @@ -2878,7 +2888,8 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { public static func getUserID(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_getUserID, performs: perform) } - public static func publisher(perform: @escaping () -> Void) -> Perform { + @MainActor + public static func publisher(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_publisher, performs: perform) } public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>, perform: @escaping ([DownloadDataTask]) -> Void) -> Perform { @@ -4125,7 +4136,7 @@ open class DiscoveryInteractorProtocolMock: DiscoveryInteractorProtocol, Mock { } // MARK: - DownloadManagerProtocol - +@MainActor open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -4173,16 +4184,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { - open func publisher() -> AnyPublisher { + open func publisher() throws -> AnyPublisher { addInvocation(.m_publisher) let perform = methodPerformValue(.m_publisher) as? () -> Void perform?() var __value: AnyPublisher do { __value = try methodReturnValue(.m_publisher).casted() - } catch { + } catch MockError.notStubed { onFatalFailure("Stub return value not specified for publisher(). Use given") Failure("Stub return value not specified for publisher(). Use given") + } catch { + throw error } return __value } @@ -4529,13 +4542,6 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { - let willReturn: [AnyPublisher] = [] - let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (AnyPublisher).self) - willProduce(stubber) - return given - } public static func eventPublisher(willProduce: (Stubber>) -> Void) -> MethodStub { let willReturn: [AnyPublisher] = [] let given: Given = { return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -4578,6 +4584,16 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func publisher(willThrow: Error...) -> MethodStub { + return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func publisher(willProduce: (StubberThrows>) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (AnyPublisher).self) + willProduce(stubber) + return given + } public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -4988,249 +5004,3 @@ open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, Moc } } -// MARK: - WebviewCookiesUpdateProtocol - -open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { - public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { - SwiftyMockyTestObserver.setup() - self.sequencingPolicy = sequencingPolicy - self.stubbingPolicy = stubbingPolicy - self.file = file - self.line = line - } - - var matcher: Matcher = Matcher.default - var stubbingPolicy: StubbingPolicy = .wrap - var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst - - private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) - private var invocations: [MethodType] = [] - private var methodReturnValues: [Given] = [] - private var methodPerformValues: [Perform] = [] - private var file: StaticString? - private var line: UInt? - - public typealias PropertyStub = Given - public typealias MethodStub = Given - public typealias SubscriptStub = Given - - /// Convenience method - call setupMock() to extend debug information when failure occurs - public func setupMock(file: StaticString = #file, line: UInt = #line) { - self.file = file - self.line = line - } - - /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals - public func resetMock(_ scopes: MockScope...) { - let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes - if scopes.contains(.invocation) { invocations = [] } - if scopes.contains(.given) { methodReturnValues = [] } - if scopes.contains(.perform) { methodPerformValues = [] } - } - - public var authInteractor: AuthInteractorProtocol { - get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } - } - private var __p_authInteractor: (AuthInteractorProtocol)? - - public var cookiesReady: Bool { - get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } - set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } - } - private var __p_cookiesReady: (Bool)? - - public var updatingCookies: Bool { - get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } - set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } - } - private var __p_updatingCookies: (Bool)? - - public var errorMessage: String? { - get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } - set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } - } - private var __p_errorMessage: (String)? - - - - - - open func updateCookies(force: Bool, retryCount: Int) { - addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) - let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void - perform?(`force`, `retryCount`) - } - - - fileprivate enum MethodType { - case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) - case p_authInteractor_get - case p_cookiesReady_get - case p_cookiesReady_set(Parameter) - case p_updatingCookies_get - case p_updatingCookies_set(Parameter) - case p_errorMessage_get - case p_errorMessage_set(Parameter) - - static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { - switch (lhs, rhs) { - case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) - return Matcher.ComparisonResult(results) - case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match - case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match - case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - default: return .none - } - } - - func intValue() -> Int { - switch self { - case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue - case .p_authInteractor_get: return 0 - case .p_cookiesReady_get: return 0 - case .p_cookiesReady_set(let newValue): return newValue.intValue - case .p_updatingCookies_get: return 0 - case .p_updatingCookies_set(let newValue): return newValue.intValue - case .p_errorMessage_get: return 0 - case .p_errorMessage_set(let newValue): return newValue.intValue - } - } - func assertionName() -> String { - switch self { - case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" - case .p_authInteractor_get: return "[get] .authInteractor" - case .p_cookiesReady_get: return "[get] .cookiesReady" - case .p_cookiesReady_set: return "[set] .cookiesReady" - case .p_updatingCookies_get: return "[get] .updatingCookies" - case .p_updatingCookies_set: return "[set] .updatingCookies" - case .p_errorMessage_get: return "[get] .errorMessage" - case .p_errorMessage_set: return "[set] .errorMessage" - } - } - } - - open class Given: StubbedMethod { - fileprivate var method: MethodType - - private init(method: MethodType, products: [StubProduct]) { - self.method = method - super.init(products) - } - - public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { - return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { - return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - - } - - public struct Verify { - fileprivate var method: MethodType - - public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} - public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } - public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } - public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } - public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } - public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } - public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } - public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } - } - - public struct Perform { - fileprivate var method: MethodType - var performs: Any - - public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { - return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) - } - } - - public func given(_ method: Given) { - methodReturnValues.append(method) - } - - public func perform(_ method: Perform) { - methodPerformValues.append(method) - methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } - } - - public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { - let fullMatches = matchingCalls(method, file: file, line: line) - let success = count.matches(fullMatches) - let assertionName = method.method.assertionName() - let feedback: String = { - guard !success else { return "" } - return Utils.closestCallsMessage( - for: self.invocations.map { invocation in - matcher.set(file: file, line: line) - defer { matcher.clearFileAndLine() } - return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) - }, - name: assertionName - ) - }() - MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) - } - - private func addInvocation(_ call: MethodType) { - self.queue.sync { invocations.append(call) } - } - private func methodReturnValue(_ method: MethodType) throws -> StubProduct { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) - let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) - guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } - return product - } - private func methodPerformValue(_ method: MethodType) -> Any? { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } - return matched?.performs - } - private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { - matcher.set(file: file ?? self.file, line: line ?? self.line) - defer { matcher.clearFileAndLine() } - return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } - } - private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { - return matchingCalls(method.method, file: file, line: line).count - } - private func givenGetterValue(_ method: MethodType, _ message: String) -> T { - do { - return try methodReturnValue(method).casted() - } catch { - onFatalFailure(message) - Failure(message) - } - } - private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { - do { - return try methodReturnValue(method).casted() - } catch { - return nil - } - } - private func onFatalFailure(_ message: String) { - guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully - SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) - } -} - diff --git a/Discovery/DiscoveryTests/Presentation/CourseDetailsViewModelTests.swift b/Discovery/DiscoveryTests/Presentation/CourseDetailsViewModelTests.swift index 35d61d57e..7c09ffbaa 100644 --- a/Discovery/DiscoveryTests/Presentation/CourseDetailsViewModelTests.swift +++ b/Discovery/DiscoveryTests/Presentation/CourseDetailsViewModelTests.swift @@ -12,6 +12,7 @@ import XCTest import Alamofire import SwiftUI +@MainActor final class CourseDetailsViewModelTests: XCTestCase { func testGetCourseDetailSuccess() async throws { diff --git a/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift b/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift index b41d901be..f9918498d 100644 --- a/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift +++ b/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift @@ -12,6 +12,7 @@ import XCTest import Alamofire import SwiftUI +@MainActor final class DiscoveryViewModelTests: XCTestCase { override func setUpWithError() throws { diff --git a/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift b/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift index aac3408c5..a1851efcc 100644 --- a/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift +++ b/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift @@ -12,6 +12,7 @@ import XCTest import Alamofire import SwiftUI +@MainActor final class SearchViewModelTests: XCTestCase { override func setUpWithError() throws { @@ -73,12 +74,9 @@ final class SearchViewModelTests: XCTestCase { viewModel.searchText = "Test" - let exp = expectation(description: "Task Starting") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - exp.fulfill() - } - - wait(for: [exp], timeout: 1) + // Wait for debounce + next event loop iteration + try await Task.sleep(nanoseconds: UInt64(0.5 * Double(NSEC_PER_SEC))) + await Task.yield() Verify(interactor, .search(page: 1, searchTerm: .any)) Verify(analytics, .discoveryCoursesSearch(label: .any, coursesCount: .any)) @@ -103,12 +101,7 @@ final class SearchViewModelTests: XCTestCase { viewModel.searchText = "" - let exp = expectation(description: "Task Starting") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - exp.fulfill() - } - - wait(for: [exp], timeout: 1) + await Task.yield() Verify(interactor, 0, .search(page: 1, searchTerm: .any)) @@ -136,12 +129,10 @@ final class SearchViewModelTests: XCTestCase { viewModel.searchText = "Test" - let exp = expectation(description: "Task Starting") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - exp.fulfill() - } + // Wait for debounce + next event loop iteration + try await Task.sleep(nanoseconds: UInt64(0.5 * Double(NSEC_PER_SEC))) + await Task.yield() - wait(for: [exp], timeout: 1) Verify(interactor, 1, .search(page: 1, searchTerm: .any)) @@ -170,12 +161,9 @@ final class SearchViewModelTests: XCTestCase { viewModel.searchText = "Test" - let exp = expectation(description: "Task Starting") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - exp.fulfill() - } - - wait(for: [exp], timeout: 1) + // Wait for debounce + next event loop iteration + try await Task.sleep(nanoseconds: UInt64(0.5 * Double(NSEC_PER_SEC))) + await Task.yield() Verify(interactor, 1, .search(page: 1, searchTerm: .any)) diff --git a/Discussion/Discussion.xcodeproj/project.pbxproj b/Discussion/Discussion.xcodeproj/project.pbxproj index c0ee0a64c..06568f18f 100644 --- a/Discussion/Discussion.xcodeproj/project.pbxproj +++ b/Discussion/Discussion.xcodeproj/project.pbxproj @@ -931,7 +931,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -964,7 +964,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -1062,7 +1062,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugProd; @@ -1160,7 +1160,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugDev; @@ -1251,7 +1251,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseProd; @@ -1342,7 +1342,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseDev; @@ -1566,7 +1566,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugStage; @@ -1678,7 +1678,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseStage; @@ -1760,7 +1760,7 @@ repositoryURL = "https://github.com/openedx/openedx-app-foundation-ios/"; requirement = { kind = exactVersion; - version = 1.0.0; + version = 1.0.1; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Discussion/Discussion/Data/Model/Data_DiscussionInfo.swift b/Discussion/Discussion/Data/Model/Data_DiscussionInfo.swift index a0264897b..2a6060167 100644 --- a/Discussion/Discussion/Data/Model/Data_DiscussionInfo.swift +++ b/Discussion/Discussion/Data/Model/Data_DiscussionInfo.swift @@ -8,7 +8,7 @@ import Foundation import Core -public struct DiscussionBlackout { +public struct DiscussionBlackout: Sendable { var start: String var end: String } @@ -19,7 +19,7 @@ public extension DataLayer { var blackouts: [DiscussionBlackout]? } - struct DiscussionBlackout: Codable { + struct DiscussionBlackout: Codable, Sendable { var start: String var end: String } diff --git a/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift b/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift index efa48f022..ab8733743 100644 --- a/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift +++ b/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift @@ -139,7 +139,7 @@ enum DiscussionEndpoint: EndPointType { case .getCourseDiscussionInfo: return .requestParameters(encoding: URLEncoding.queryString) case let .getThreads(courseID, type, sort, filter, page): - var parameters: [String: Encodable] + var parameters: [String: Encodable & Sendable] switch type { case .allPosts: parameters = [ @@ -194,19 +194,19 @@ enum DiscussionEndpoint: EndPointType { case .getTopics: return .requestParameters(encoding: URLEncoding.queryString) case let .getTopic(_, topicID): - let parameters: [String: Encodable] = [ + let parameters: [String: Encodable & Sendable] = [ "topic_id": topicID ] return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) case let .getDiscussionComments(threadID, page): - let parameters: [String: Encodable] = [ + let parameters: [String: Encodable & Sendable] = [ "thread_id": threadID, "requested_fields": "profile_image", "page": page ] return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) case let .getQuestionComments(threadID, page): - let parameters: [String: Encodable] = [ + let parameters: [String: Encodable & Sendable] = [ "thread_id": threadID, "endorsed": false, "requested_fields": "profile_image", @@ -214,7 +214,7 @@ enum DiscussionEndpoint: EndPointType { ] return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) case let .getCommentResponses(_, page): - let parameters: [String: Encodable] = [ + let parameters: [String: Encodable & Sendable] = [ "requested_fields": "profile_image", "page": page ] @@ -222,7 +222,7 @@ enum DiscussionEndpoint: EndPointType { case .getResponse: return .requestParameters(parameters: [:], encoding: URLEncoding.queryString) case let .addCommentTo(threadID, rawBody, parentID): - var parameters: [String: Encodable] = [ + var parameters: [String: Encodable & Sendable] = [ "thread_id": threadID, "raw_body": rawBody ] @@ -231,32 +231,32 @@ enum DiscussionEndpoint: EndPointType { } return .requestParameters(parameters: parameters, encoding: JSONEncoding.default) case let .voteThread(voted, _): - let parameters: [String: Encodable] = [ + let parameters: [String: Encodable & Sendable] = [ "voted": !voted ] return .requestParameters(parameters: parameters, encoding: JSONEncoding.default) case let .voteResponse(voted, _): - let parameters: [String: Encodable] = [ + let parameters: [String: Encodable & Sendable] = [ "voted": !voted ] return .requestParameters(parameters: parameters, encoding: JSONEncoding.default) case let .flagThread(abuseFlagged, _): - let parameters: [String: Encodable] = [ + let parameters: [String: Encodable & Sendable] = [ "abuse_flagged": !abuseFlagged ] return .requestParameters(parameters: parameters, encoding: JSONEncoding.default) case let .flagComment(abuseFlagged, _): - let parameters: [String: Encodable] = [ + let parameters: [String: Encodable & Sendable] = [ "abuse_flagged": !abuseFlagged ] return .requestParameters(parameters: parameters, encoding: JSONEncoding.default) case let .followThread(following, _): - let parameters: [String: Encodable] = [ + let parameters: [String: Encodable & Sendable] = [ "following": !following ] return .requestParameters(parameters: parameters, encoding: JSONEncoding.default) case let .createNewThread(newThread): - let parameters: [String: Encodable] = [ + let parameters: [String: Encodable & Sendable] = [ "course_id": newThread.courseID, "topic_id": newThread.topicID, "type": newThread.type.rawValue, @@ -266,12 +266,12 @@ enum DiscussionEndpoint: EndPointType { ] return .requestParameters(parameters: parameters, encoding: JSONEncoding.default) case .readBody: - let parameters: [String: Encodable] = [ + let parameters: [String: Encodable & Sendable] = [ "read": true ] return .requestParameters(parameters: parameters, encoding: JSONEncoding.default) case .searchThreads(courseID: let courseID, searchText: let searchText, pageNumber: let pageNumber): - let parameters: [String: Encodable] = [ + let parameters: [String: Encodable & Sendable] = [ "course_id": courseID, "text_search": searchText, "page": pageNumber, diff --git a/Discussion/Discussion/Data/Network/DiscussionRepository.swift b/Discussion/Discussion/Data/Network/DiscussionRepository.swift index ce8b8170a..b347a68c7 100644 --- a/Discussion/Discussion/Data/Network/DiscussionRepository.swift +++ b/Discussion/Discussion/Data/Network/DiscussionRepository.swift @@ -10,7 +10,7 @@ import Core import OEXFoundation import Combine -public protocol DiscussionRepositoryProtocol { +public protocol DiscussionRepositoryProtocol: Sendable { func getCourseDiscussionInfo(courseID: String) async throws -> DiscussionInfo func getThreads(courseID: String, type: ThreadType, @@ -35,7 +35,7 @@ public protocol DiscussionRepositoryProtocol { func readBody(threadID: String) async throws } -public class DiscussionRepository: DiscussionRepositoryProtocol { +public actor DiscussionRepository: DiscussionRepositoryProtocol { private let api: API private let appStorage: CoreStorage @@ -227,7 +227,7 @@ public class DiscussionRepository: DiscussionRepositoryProtocol { // Mark - For testing and SwiftUI preview // swiftlint:disable all #if DEBUG -public class DiscussionRepositoryMock: DiscussionRepositoryProtocol { +public actor DiscussionRepositoryMock: DiscussionRepositoryProtocol { public func getCourseDiscussionInfo(courseID: String) async throws -> DiscussionInfo { DiscussionInfo(discussionID: nil, blackouts: []) @@ -260,7 +260,7 @@ public class DiscussionRepositoryMock: DiscussionRepositoryProtocol { } - var comments = [ + let comments = [ UserComment(authorName: "Bill", authorAvatar: "", postDate: Date(), diff --git a/Discussion/Discussion/Domain/DiscussionInteractor.swift b/Discussion/Discussion/Domain/DiscussionInteractor.swift index 561a15687..c6c915883 100644 --- a/Discussion/Discussion/Domain/DiscussionInteractor.swift +++ b/Discussion/Discussion/Domain/DiscussionInteractor.swift @@ -9,7 +9,7 @@ import Foundation import Core //sourcery: AutoMockable -public protocol DiscussionInteractorProtocol { +public protocol DiscussionInteractorProtocol: Sendable { func getCourseDiscussionInfo(courseID: String) async throws -> DiscussionInfo func getThreadsList(courseID: String, type: ThreadType, @@ -34,7 +34,7 @@ public protocol DiscussionInteractorProtocol { func readBody(threadID: String) async throws } -public class DiscussionInteractor: DiscussionInteractorProtocol { +public actor DiscussionInteractor: DiscussionInteractorProtocol { private let repository: DiscussionRepositoryProtocol diff --git a/Discussion/Discussion/Domain/Model/DiscussionInfo.swift b/Discussion/Discussion/Domain/Model/DiscussionInfo.swift index c04b936c4..11d276710 100644 --- a/Discussion/Discussion/Domain/Model/DiscussionInfo.swift +++ b/Discussion/Discussion/Domain/Model/DiscussionInfo.swift @@ -7,7 +7,7 @@ import Foundation -public struct DiscussionInfo { +public struct DiscussionInfo: Sendable { public var discussionID: String? public var blackouts: [DiscussionBlackout]? diff --git a/Discussion/Discussion/Domain/Model/DiscussionNewThread.swift b/Discussion/Discussion/Domain/Model/DiscussionNewThread.swift index 3009c90db..717ed5756 100644 --- a/Discussion/Discussion/Domain/Model/DiscussionNewThread.swift +++ b/Discussion/Discussion/Domain/Model/DiscussionNewThread.swift @@ -7,7 +7,7 @@ import Foundation -public struct DiscussionNewThread { +public struct DiscussionNewThread: Sendable { public let courseID: String public let topicID: String public let type: PostType diff --git a/Discussion/Discussion/Domain/Model/DiscussionPost.swift b/Discussion/Discussion/Domain/Model/DiscussionPost.swift index e73bc686c..b4d939500 100644 --- a/Discussion/Discussion/Domain/Model/DiscussionPost.swift +++ b/Discussion/Discussion/Domain/Model/DiscussionPost.swift @@ -9,7 +9,7 @@ import Foundation import SwiftUI import Core -public enum PostType: String, Codable { +public enum PostType: String, Codable, Sendable { case question case discussion @@ -32,7 +32,7 @@ public enum PostType: String, Codable { } } -public struct DiscussionPost: Equatable { +public struct DiscussionPost: Equatable, Sendable { public static func == (lhs: DiscussionPost, rhs: DiscussionPost) -> Bool { return lhs.id == rhs.id } @@ -45,14 +45,27 @@ public struct DiscussionPost: Equatable { public var isFavorite: Bool public let type: PostType public let unreadCommentCount: Int - public let action: (() -> Void) + public let action: (@MainActor @Sendable () -> Void) public let hasEndorsed: Bool public let voteCount: Int public let numPages: Int - public init(id: String, title: String, replies: Int, lastPostDate: Date, lastPostDateFormatted: String, - isFavorite: Bool, type: PostType, unreadCommentCount: Int, action: @escaping () -> Void, - hasEndorsed: Bool, voteCount: Int, numPages: Int) { + public init( + id: String, + title: String, + replies: Int, + lastPostDate: Date, + lastPostDateFormatted: String, + isFavorite: Bool, + type: PostType, + unreadCommentCount: Int, + action: @escaping ( + @MainActor @Sendable () -> Void + ), + hasEndorsed: Bool, + voteCount: Int, + numPages: Int + ) { self.id = id self.title = title self.replies = replies diff --git a/Discussion/Discussion/Domain/Model/DiscussionTopic.swift b/Discussion/Discussion/Domain/Model/DiscussionTopic.swift index ac1ab5996..139844e13 100644 --- a/Discussion/Discussion/Domain/Model/DiscussionTopic.swift +++ b/Discussion/Discussion/Domain/Model/DiscussionTopic.swift @@ -7,7 +7,7 @@ import Foundation -public enum DiscussionTopicStyle { +public enum DiscussionTopicStyle: Sendable { case title case basic case followed @@ -26,7 +26,7 @@ public struct DiscussionTopic { } } -public struct Topics { +public struct Topics: Sendable { public let coursewareTopics: [CoursewareTopics] public let nonCoursewareTopics: [CoursewareTopics] @@ -36,7 +36,7 @@ public struct Topics { } } -public struct CoursewareTopics: Hashable, Identifiable { +public struct CoursewareTopics: Hashable, Identifiable, Sendable { public let id: String public let name: String public let threadListURL: String diff --git a/Discussion/Discussion/Domain/Model/Post.swift b/Discussion/Discussion/Domain/Model/Post.swift index ec034e61f..21f5fb144 100644 --- a/Discussion/Discussion/Domain/Model/Post.swift +++ b/Discussion/Discussion/Domain/Model/Post.swift @@ -7,7 +7,7 @@ import Foundation -public struct Post { +public struct Post: Sendable { public let authorName: String public var authorAvatar: String public let postDate: Date diff --git a/Discussion/Discussion/Domain/Model/ThreadType.swift b/Discussion/Discussion/Domain/Model/ThreadType.swift index d744b4552..47b74790a 100644 --- a/Discussion/Discussion/Domain/Model/ThreadType.swift +++ b/Discussion/Discussion/Domain/Model/ThreadType.swift @@ -7,14 +7,14 @@ import Foundation -public enum ThreadType { +public enum ThreadType: Sendable { case allPosts case followingPosts case nonCourseTopics case courseTopics(topicID: String) } -public enum ThreadsFilter: Identifiable { +public enum ThreadsFilter: Identifiable, Sendable { public var id: String { localizedValue } @@ -35,7 +35,7 @@ public enum ThreadsFilter: Identifiable { } } -public enum SortType: Identifiable { +public enum SortType: Identifiable, Sendable { public var id: String { localizedValue } diff --git a/Discussion/Discussion/Domain/Model/UserComment.swift b/Discussion/Discussion/Domain/Model/UserComment.swift index 4f3ef35af..179a72a6c 100644 --- a/Discussion/Discussion/Domain/Model/UserComment.swift +++ b/Discussion/Discussion/Domain/Model/UserComment.swift @@ -7,7 +7,7 @@ import Foundation -public struct UserComment: Hashable { +public struct UserComment: Hashable, Sendable { public let authorName: String public let authorAvatar: String public let postDate: Date diff --git a/Discussion/Discussion/Domain/Model/UserThread.swift b/Discussion/Discussion/Domain/Model/UserThread.swift index f28e2a532..502ac2586 100644 --- a/Discussion/Discussion/Domain/Model/UserThread.swift +++ b/Discussion/Discussion/Domain/Model/UserThread.swift @@ -8,7 +8,7 @@ import Foundation import Core -public struct ThreadLists { +public struct ThreadLists: Sendable { public var threads: [UserThread] public init(threads: [UserThread]) { @@ -16,7 +16,7 @@ public struct ThreadLists { } } -public struct UserThread { +public struct UserThread: Sendable { public let id: String public let author: String public let authorLabel: String @@ -87,7 +87,7 @@ public struct UserThread { } public extension UserThread { - func discussionPost(useRelativeDates: Bool, action: @escaping () -> Void) -> DiscussionPost { + func discussionPost(useRelativeDates: Bool, action: @escaping (@MainActor @Sendable () -> Void)) -> DiscussionPost { return DiscussionPost( id: id, title: title, diff --git a/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift b/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift index eacf00109..4722b958a 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift @@ -11,6 +11,7 @@ import Core import Combine import Swinject +@MainActor public class BaseResponsesViewModel { @Published public var postComments: Post? diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift index b73697264..b4f9b5002 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift @@ -10,7 +10,7 @@ import SwiftUI import Core import Combine -public class ResponsesViewModel: BaseResponsesViewModel, ObservableObject { +public final class ResponsesViewModel: BaseResponsesViewModel, ObservableObject { @Published var scrollTrigger: Bool = false private let threadStateSubject: CurrentValueSubject diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift index 452ea98f2..6a3385012 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift @@ -9,7 +9,7 @@ import Foundation import Combine import Core -public class ThreadViewModel: BaseResponsesViewModel, ObservableObject { +public final class ThreadViewModel: BaseResponsesViewModel, ObservableObject { @Published var scrollTrigger: Bool = false diff --git a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadViewModel.swift b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadViewModel.swift index 0fc051b23..3f986d705 100644 --- a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadViewModel.swift +++ b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadViewModel.swift @@ -8,6 +8,7 @@ import Core import SwiftUI +@MainActor public class CreateNewThreadViewModel: ObservableObject { @Published private(set) var isShowProgress = false diff --git a/Discussion/Discussion/Presentation/DiscussionRouter.swift b/Discussion/Discussion/Presentation/DiscussionRouter.swift index 57cbf37ab..27ea2067c 100644 --- a/Discussion/Discussion/Presentation/DiscussionRouter.swift +++ b/Discussion/Discussion/Presentation/DiscussionRouter.swift @@ -10,6 +10,7 @@ import Core import Combine //sourcery: AutoMockable +@MainActor public protocol DiscussionRouter: BaseRouter { func showUserDetails(username: String) diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift index 83b7b2741..641f768c6 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift @@ -8,9 +8,10 @@ import Foundation import SwiftUI import Core -import Combine +@preconcurrency import Combine -public class DiscussionSearchTopicsViewModel: ObservableObject { +@MainActor +public final class DiscussionSearchTopicsViewModel: ObservableObject { @Published private(set) var fetchInProgress = false @Published var isSearchActive = false @@ -76,15 +77,18 @@ public class DiscussionSearchTopicsViewModel: ObservableObject { $searchText .debounce(for: debounce.dueTime, scheduler: debounce.scheduler) .removeDuplicates() - .sink { str in + .sink { [weak self] str in + guard let self else { return } let term = str .trimmingCharacters(in: .whitespaces) Task.detached(priority: .high) { if !term.isEmpty { - if term == self.prevQuery { + if await term == self.prevQuery { return } - self.nextPage = 1 + await MainActor.run { + self.nextPage = 1 + } await self.search(page: self.nextPage, searchTerm: str) } else { await MainActor.run { @@ -97,7 +101,6 @@ public class DiscussionSearchTopicsViewModel: ObservableObject { .store(in: &subscription) } - @MainActor func searchCourses(index: Int, searchTerm: String) async { if !fetchInProgress { if totalPages > 1 { @@ -112,7 +115,6 @@ public class DiscussionSearchTopicsViewModel: ObservableObject { } } - @MainActor private func search(page: Int, searchTerm: String) async { self.prevQuery = searchTerm fetchInProgress = true @@ -160,15 +162,20 @@ public class DiscussionSearchTopicsViewModel: ObservableObject { private func generatePosts(threads: [UserThread]) -> [DiscussionPost] { var result: [DiscussionPost] = [] for thread in threads { - result.append(thread.discussionPost(useRelativeDates: storage.useRelativeDates, action: { [weak self] in - guard let self else { return } - self.router.showThread( - thread: thread, - postStateSubject: self.postStateSubject, - isBlackedOut: false, - animated: true + result + .append( + thread.discussionPost( + useRelativeDates: storage.useRelativeDates, + action: { [weak self] in + guard let self else { return } + self.router.showThread( + thread: thread, + postStateSubject: self.postStateSubject, + isBlackedOut: false, + animated: true + ) + }) ) - })) } return result } diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift index 0984ebb6b..3a5dfffd0 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift @@ -10,7 +10,8 @@ import SwiftUI import Core // swiftlint:disable function_body_length -public class DiscussionTopicsViewModel: ObservableObject { +@MainActor +public final class DiscussionTopicsViewModel: ObservableObject { @Published var topics: Topics? @Published var isShowProgress = true @@ -106,7 +107,6 @@ public class DiscussionTopicsViewModel: ObservableObject { isBlackedOut: self.isBlackedOut, animated: true ) - }, style: .basic) ) @@ -169,7 +169,6 @@ public class DiscussionTopicsViewModel: ObservableObject { return result } - @MainActor public func getTopics(courseID: String, withProgress: Bool = true) async { self.courseID = courseID isShowProgress = withProgress diff --git a/Discussion/Discussion/Presentation/Posts/PostState.swift b/Discussion/Discussion/Presentation/Posts/PostState.swift index 1a489bc4f..5d5fb2046 100644 --- a/Discussion/Discussion/Presentation/Posts/PostState.swift +++ b/Discussion/Discussion/Presentation/Posts/PostState.swift @@ -7,7 +7,7 @@ import Foundation -public enum PostState { +public enum PostState: Sendable { case followed(id: String, Bool) case liked(id: String, Bool, Int) case reported(id: String, Bool) diff --git a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift index 8115ea0f2..2ac959a93 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift @@ -10,7 +10,8 @@ import SwiftUI import Combine import Core -public class PostsViewModel: ObservableObject { +@MainActor +public final class PostsViewModel: ObservableObject { public var nextPage = 1 public var totalPages = 1 @@ -136,8 +137,7 @@ public class PostsViewModel: ObservableObject { result.append( thread.discussionPost( useRelativeDates: storage.useRelativeDates, - action: { - [weak self] in + action: { [weak self] in guard let self, let actualThread = self.threads.threads .first(where: {$0.id == thread.id }) else { return } @@ -157,7 +157,6 @@ public class PostsViewModel: ObservableObject { return result } - @MainActor func getPostsPagination(index: Int, withProgress: Bool = true) async { guard !fetchInProgress else { return } if totalPages > 1, index >= filteredPosts.count - 3, nextPage <= totalPages { @@ -168,7 +167,6 @@ public class PostsViewModel: ObservableObject { } } - @MainActor public func getPosts(pageNumber: Int, withProgress: Bool = true) async -> Bool { fetchInProgress = true isShowProgress = withProgress @@ -208,7 +206,6 @@ public class PostsViewModel: ObservableObject { } } - @MainActor private func getThreadsList(type: ThreadType, page: Int) async throws -> [UserThread] { guard let courseID else { return [] } return try await interactor.getThreadsList( diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index dbe21843f..e55c19cd2 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -508,7 +508,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { } // MARK: - BaseRouter - +@MainActor open class BaseRouterMock: BaseRouter, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -980,7 +980,7 @@ open class BaseRouterMock: BaseRouter, Mock { } // MARK: - CalendarManagerProtocol - +@MainActor open class CalendarManagerProtocolMock: CalendarManagerProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -1847,7 +1847,7 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { } // MARK: - ConnectivityProtocol - +@MainActor open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -2456,16 +2456,19 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { return __value } - open func publisher() -> AnyPublisher { + @MainActor + open func publisher() throws -> AnyPublisher { addInvocation(.m_publisher) let perform = methodPerformValue(.m_publisher) as? () -> Void perform?() var __value: AnyPublisher do { __value = try methodReturnValue(.m_publisher).casted() - } catch { + } catch MockError.notStubed { onFatalFailure("Stub return value not specified for publisher(). Use given") Failure("Stub return value not specified for publisher(). Use given") + } catch { + throw error } return __value } @@ -2757,7 +2760,8 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { public static func getUserID(willReturn: Int?...) -> MethodStub { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + @MainActor + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) } public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { @@ -2785,13 +2789,6 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { willProduce(stubber) return given } - public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { - let willReturn: [AnyPublisher] = [] - let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (AnyPublisher).self) - willProduce(stubber) - return given - } public static func loadProgress(for blockID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [OfflineProgress?] = [] let given: Given = { return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -2834,6 +2831,18 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { willProduce(stubber) return given } + @MainActor + public static func publisher(willThrow: Error...) -> MethodStub { + return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) + } + @MainActor + public static func publisher(willProduce: (StubberThrows>) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (AnyPublisher).self) + willProduce(stubber) + return given + } public static func deleteDownloadDataTask(id: Parameter, willThrow: Error...) -> MethodStub { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -2851,7 +2860,8 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { public static func set(userId: Parameter) -> Verify { return Verify(method: .m_set__userId_userId(`userId`))} public static func getUserID() -> Verify { return Verify(method: .m_getUserID)} - public static func publisher() -> Verify { return Verify(method: .m_publisher)} + @MainActor + public static func publisher() -> Verify { return Verify(method: .m_publisher)} public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>) -> Verify { return Verify(method: .m_addToDownloadQueue__tasks_tasks(`tasks`))} public static func saveOfflineProgress(progress: Parameter) -> Verify { return Verify(method: .m_saveOfflineProgress__progress_progress(`progress`))} public static func loadProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_loadProgress__for_blockID(`blockID`))} @@ -2878,7 +2888,8 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { public static func getUserID(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_getUserID, performs: perform) } - public static func publisher(perform: @escaping () -> Void) -> Perform { + @MainActor + public static func publisher(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_publisher, performs: perform) } public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>, perform: @escaping ([DownloadDataTask]) -> Void) -> Perform { @@ -4485,7 +4496,7 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock } // MARK: - DiscussionRouter - +@MainActor open class DiscussionRouterMock: DiscussionRouter, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -5080,7 +5091,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { } // MARK: - DownloadManagerProtocol - +@MainActor open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -5128,16 +5139,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { - open func publisher() -> AnyPublisher { + open func publisher() throws -> AnyPublisher { addInvocation(.m_publisher) let perform = methodPerformValue(.m_publisher) as? () -> Void perform?() var __value: AnyPublisher do { __value = try methodReturnValue(.m_publisher).casted() - } catch { + } catch MockError.notStubed { onFatalFailure("Stub return value not specified for publisher(). Use given") Failure("Stub return value not specified for publisher(). Use given") + } catch { + throw error } return __value } @@ -5484,13 +5497,6 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { - let willReturn: [AnyPublisher] = [] - let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (AnyPublisher).self) - willProduce(stubber) - return given - } public static func eventPublisher(willProduce: (Stubber>) -> Void) -> MethodStub { let willReturn: [AnyPublisher] = [] let given: Given = { return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -5533,6 +5539,16 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func publisher(willThrow: Error...) -> MethodStub { + return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func publisher(willProduce: (StubberThrows>) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (AnyPublisher).self) + willProduce(stubber) + return given + } public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -5943,249 +5959,3 @@ open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, Moc } } -// MARK: - WebviewCookiesUpdateProtocol - -open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { - public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { - SwiftyMockyTestObserver.setup() - self.sequencingPolicy = sequencingPolicy - self.stubbingPolicy = stubbingPolicy - self.file = file - self.line = line - } - - var matcher: Matcher = Matcher.default - var stubbingPolicy: StubbingPolicy = .wrap - var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst - - private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) - private var invocations: [MethodType] = [] - private var methodReturnValues: [Given] = [] - private var methodPerformValues: [Perform] = [] - private var file: StaticString? - private var line: UInt? - - public typealias PropertyStub = Given - public typealias MethodStub = Given - public typealias SubscriptStub = Given - - /// Convenience method - call setupMock() to extend debug information when failure occurs - public func setupMock(file: StaticString = #file, line: UInt = #line) { - self.file = file - self.line = line - } - - /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals - public func resetMock(_ scopes: MockScope...) { - let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes - if scopes.contains(.invocation) { invocations = [] } - if scopes.contains(.given) { methodReturnValues = [] } - if scopes.contains(.perform) { methodPerformValues = [] } - } - - public var authInteractor: AuthInteractorProtocol { - get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } - } - private var __p_authInteractor: (AuthInteractorProtocol)? - - public var cookiesReady: Bool { - get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } - set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } - } - private var __p_cookiesReady: (Bool)? - - public var updatingCookies: Bool { - get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } - set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } - } - private var __p_updatingCookies: (Bool)? - - public var errorMessage: String? { - get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } - set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } - } - private var __p_errorMessage: (String)? - - - - - - open func updateCookies(force: Bool, retryCount: Int) { - addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) - let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void - perform?(`force`, `retryCount`) - } - - - fileprivate enum MethodType { - case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) - case p_authInteractor_get - case p_cookiesReady_get - case p_cookiesReady_set(Parameter) - case p_updatingCookies_get - case p_updatingCookies_set(Parameter) - case p_errorMessage_get - case p_errorMessage_set(Parameter) - - static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { - switch (lhs, rhs) { - case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) - return Matcher.ComparisonResult(results) - case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match - case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match - case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - default: return .none - } - } - - func intValue() -> Int { - switch self { - case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue - case .p_authInteractor_get: return 0 - case .p_cookiesReady_get: return 0 - case .p_cookiesReady_set(let newValue): return newValue.intValue - case .p_updatingCookies_get: return 0 - case .p_updatingCookies_set(let newValue): return newValue.intValue - case .p_errorMessage_get: return 0 - case .p_errorMessage_set(let newValue): return newValue.intValue - } - } - func assertionName() -> String { - switch self { - case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" - case .p_authInteractor_get: return "[get] .authInteractor" - case .p_cookiesReady_get: return "[get] .cookiesReady" - case .p_cookiesReady_set: return "[set] .cookiesReady" - case .p_updatingCookies_get: return "[get] .updatingCookies" - case .p_updatingCookies_set: return "[set] .updatingCookies" - case .p_errorMessage_get: return "[get] .errorMessage" - case .p_errorMessage_set: return "[set] .errorMessage" - } - } - } - - open class Given: StubbedMethod { - fileprivate var method: MethodType - - private init(method: MethodType, products: [StubProduct]) { - self.method = method - super.init(products) - } - - public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { - return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { - return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - - } - - public struct Verify { - fileprivate var method: MethodType - - public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} - public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } - public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } - public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } - public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } - public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } - public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } - public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } - } - - public struct Perform { - fileprivate var method: MethodType - var performs: Any - - public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { - return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) - } - } - - public func given(_ method: Given) { - methodReturnValues.append(method) - } - - public func perform(_ method: Perform) { - methodPerformValues.append(method) - methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } - } - - public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { - let fullMatches = matchingCalls(method, file: file, line: line) - let success = count.matches(fullMatches) - let assertionName = method.method.assertionName() - let feedback: String = { - guard !success else { return "" } - return Utils.closestCallsMessage( - for: self.invocations.map { invocation in - matcher.set(file: file, line: line) - defer { matcher.clearFileAndLine() } - return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) - }, - name: assertionName - ) - }() - MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) - } - - private func addInvocation(_ call: MethodType) { - self.queue.sync { invocations.append(call) } - } - private func methodReturnValue(_ method: MethodType) throws -> StubProduct { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) - let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) - guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } - return product - } - private func methodPerformValue(_ method: MethodType) -> Any? { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } - return matched?.performs - } - private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { - matcher.set(file: file ?? self.file, line: line ?? self.line) - defer { matcher.clearFileAndLine() } - return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } - } - private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { - return matchingCalls(method.method, file: file, line: line).count - } - private func givenGetterValue(_ method: MethodType, _ message: String) -> T { - do { - return try methodReturnValue(method).casted() - } catch { - onFatalFailure(message) - Failure(message) - } - } - private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { - do { - return try methodReturnValue(method).casted() - } catch { - return nil - } - } - private func onFatalFailure(_ message: String) { - guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully - SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) - } -} - diff --git a/Discussion/DiscussionTests/Presentation/Comment/Base/BaseResponsesViewModelTests.swift b/Discussion/DiscussionTests/Presentation/Comment/Base/BaseResponsesViewModelTests.swift index d862278fa..6835b166e 100644 --- a/Discussion/DiscussionTests/Presentation/Comment/Base/BaseResponsesViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/Comment/Base/BaseResponsesViewModelTests.swift @@ -12,6 +12,7 @@ import XCTest import Alamofire import SwiftUI +@MainActor final class BaseResponsesViewModelTests: XCTestCase { let post = Post(authorName: "1", diff --git a/Discussion/DiscussionTests/Presentation/Comment/ThreadViewModelTests.swift b/Discussion/DiscussionTests/Presentation/Comment/ThreadViewModelTests.swift index 97ab5f718..68efad3ea 100644 --- a/Discussion/DiscussionTests/Presentation/Comment/ThreadViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/Comment/ThreadViewModelTests.swift @@ -12,6 +12,7 @@ import XCTest import Alamofire import SwiftUI +@MainActor final class ThreadViewModelTests: XCTestCase { let userComments = [ diff --git a/Discussion/DiscussionTests/Presentation/CreateNewThread/CreateNewThreadViewModelTests.swift b/Discussion/DiscussionTests/Presentation/CreateNewThread/CreateNewThreadViewModelTests.swift index 436fc808c..f8fb82177 100644 --- a/Discussion/DiscussionTests/Presentation/CreateNewThread/CreateNewThreadViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/CreateNewThread/CreateNewThreadViewModelTests.swift @@ -12,6 +12,7 @@ import XCTest import Alamofire import SwiftUI +@MainActor final class CreateNewThreadViewModelTests: XCTestCase { let newThread = DiscussionNewThread( diff --git a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModelTests.swift b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModelTests.swift index d6c82f7aa..cb19a2756 100644 --- a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModelTests.swift @@ -12,6 +12,7 @@ import XCTest import Alamofire import SwiftUI +@MainActor final class DiscussionSearchTopicsViewModelTests: XCTestCase { func testSearchSuccess() async throws { @@ -54,13 +55,9 @@ final class DiscussionSearchTopicsViewModelTests: XCTestCase { viewModel.searchText = "Test" - - let exp = expectation(description: "Task Starting") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - exp.fulfill() - } - - wait(for: [exp], timeout: 1) + // Wait for debounce + next event loop iteration + try await Task.sleep(nanoseconds: UInt64(0.5 * Double(NSEC_PER_SEC))) + await Task.yield() Verify(interactor, .searchThreads(courseID: .any, searchText: .any, pageNumber: .any)) @@ -83,13 +80,9 @@ final class DiscussionSearchTopicsViewModelTests: XCTestCase { viewModel.searchText = "Test" - - let exp = expectation(description: "Task Starting") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - exp.fulfill() - } - - wait(for: [exp], timeout: 1) + // Wait for debounce + next event loop iteration + try await Task.sleep(nanoseconds: UInt64(0.5 * Double(NSEC_PER_SEC))) + await Task.yield() Verify(interactor, .searchThreads(courseID: .any, searchText: .any, pageNumber: .any)) @@ -111,13 +104,9 @@ final class DiscussionSearchTopicsViewModelTests: XCTestCase { viewModel.searchText = "Test" - - let exp = expectation(description: "Task Starting") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - exp.fulfill() - } - - wait(for: [exp], timeout: 1) + // Wait for debounce + next event loop iteration + try await Task.sleep(nanoseconds: UInt64(0.5 * Double(NSEC_PER_SEC))) + await Task.yield() Verify(interactor, .searchThreads(courseID: .any, searchText: .any, pageNumber: .any)) @@ -137,13 +126,9 @@ final class DiscussionSearchTopicsViewModelTests: XCTestCase { viewModel.searchText = "" - - let exp = expectation(description: "Task Starting") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - exp.fulfill() - } - - wait(for: [exp], timeout: 1) + // Wait for debounce + next event loop iteration + try await Task.sleep(nanoseconds: UInt64(0.5 * Double(NSEC_PER_SEC))) + await Task.yield() Verify(interactor, 0, .searchThreads(courseID: .any, searchText: .any, pageNumber: .any)) diff --git a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift index eea0bb06f..036a096dd 100644 --- a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift @@ -12,6 +12,7 @@ import XCTest import Alamofire import SwiftUI +@MainActor final class DiscussionTopicsViewModelTests: XCTestCase { let topics = Topics(coursewareTopics: [ diff --git a/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift b/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift index e55d82227..352d550bd 100644 --- a/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift @@ -12,6 +12,7 @@ import XCTest import Alamofire import SwiftUI +@MainActor final class PostViewModelTests: XCTestCase { let threads = ThreadLists(threads: [ diff --git a/Discussion/DiscussionTests/Presentation/Responses/ResponsesViewModelTests.swift b/Discussion/DiscussionTests/Presentation/Responses/ResponsesViewModelTests.swift index 2b010617d..2aaba39ee 100644 --- a/Discussion/DiscussionTests/Presentation/Responses/ResponsesViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/Responses/ResponsesViewModelTests.swift @@ -12,6 +12,7 @@ import XCTest import Alamofire import SwiftUI +@MainActor final class ResponsesViewModelTests: XCTestCase { let userComments = [ diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 464e642a1..80e519d62 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -47,7 +47,7 @@ 07D5DA3528D075AA00752FD9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */; }; 07D5DA3E28D075AB00752FD9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 07D5DA3D28D075AB00752FD9 /* Assets.xcassets */; }; 149FF39E2B9F1AB50034B33F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 149FF39C2B9F1AB50034B33F /* LaunchScreen.storyboard */; }; - 1924EDE8164C7AB17AD4946B /* Pods_App_OpenEdX.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC49621A0942E5EE74BDC895 /* Pods_App_OpenEdX.framework */; }; + 3ACA3A1E886F3F9B2735B9AF /* Pods_App_OpenEdX.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D27E034A83DDEBF18D53B04 /* Pods_App_OpenEdX.framework */; }; A500668B2B613ED10024680B /* PushNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500668A2B613ED10024680B /* PushNotificationsManager.swift */; }; A500668D2B6143000024680B /* FCMProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500668C2B6143000024680B /* FCMProvider.swift */; }; A50066912B61467B0024680B /* BrazeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066902B61467B0024680B /* BrazeProvider.swift */; }; @@ -59,11 +59,12 @@ A59568992B616D9400ED4F90 /* PushLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568982B616D9400ED4F90 /* PushLink.swift */; }; BA7468762B96201D00793145 /* DeepLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA7468752B96201D00793145 /* DeepLinkRouter.swift */; }; CE0BF0BA2CD9203A00D10289 /* MSAL in Frameworks */ = {isa = PBXBuildFile; productRef = CE0BF0B92CD9203A00D10289 /* MSAL */; }; + CE1D5B7B2CE60E000019CA34 /* ContainerMainActor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1D5B7A2CE60E000019CA34 /* ContainerMainActor.swift */; }; CE3BD14E2CBEB0DA0026F4E3 /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE3BD14D2CBEB0DA0026F4E3 /* PluginManager.swift */; }; - CE5712792CD1099B00D4AB17 /* OEXFirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = CE5712782CD1099B00D4AB17 /* OEXFirebaseAnalytics */; }; CE57127A2CD109A800D4AB17 /* OEXFoundation in Embed Frameworks */ = {isa = PBXBuildFile; productRef = CE9C07D72CD104E5009C44D1 /* OEXFoundation */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; CE924BE72CD8FAB3000137CA /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = CE924BE62CD8FAB3000137CA /* FirebaseMessaging */; }; CE9C07D82CD104E5009C44D1 /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CE9C07D72CD104E5009C44D1 /* OEXFoundation */; }; + CEBA52772CEBB69100619E2B /* OEXFirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = CEBA52762CEBB69100619E2B /* OEXFirebaseAnalytics */; }; E0D6E6A32B1626B10089F9C9 /* Theme.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; }; E0D6E6A42B1626D60089F9C9 /* Theme.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ @@ -92,6 +93,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 005D1F4D92679D24B3BAA8FE /* Pods-App-OpenEdX.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasedev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasedev.xcconfig"; sourceTree = ""; }; 020CA5D82AA0A25300970AAF /* AppStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorage.swift; sourceTree = ""; }; 0218196328F734FA00202564 /* Discussion.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Discussion.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0219C67628F4347600D64452 /* Course.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Course.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -132,8 +134,9 @@ 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 07D5DA3D28D075AB00752FD9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 149FF39D2B9F1AB50034B33F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 1BB1F1D0FABF8788646FBAF2 /* Pods-App-OpenEdX.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugstage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugstage.xcconfig"; sourceTree = ""; }; - 37C50995093E34142FDE0ED9 /* Pods-App-OpenEdX.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasedev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasedev.xcconfig"; sourceTree = ""; }; + 3D27E034A83DDEBF18D53B04 /* Pods_App_OpenEdX.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_OpenEdX.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 4449C4D4F119C87B452DDFCD /* Pods-App-OpenEdX.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releaseprod.xcconfig"; sourceTree = ""; }; + 8A45E2C9AF0CBE70A09FB37B /* Pods-App-OpenEdX.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugstage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugstage.xcconfig"; sourceTree = ""; }; A500668A2B613ED10024680B /* PushNotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationsManager.swift; sourceTree = ""; }; A500668C2B6143000024680B /* FCMProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FCMProvider.swift; sourceTree = ""; }; A50066902B61467B0024680B /* BrazeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrazeProvider.swift; sourceTree = ""; }; @@ -143,14 +146,13 @@ A59568942B61630500ED4F90 /* DeepLinkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkManager.swift; sourceTree = ""; }; A59568962B61653700ED4F90 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; A59568982B616D9400ED4F90 /* PushLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushLink.swift; sourceTree = ""; }; - A681C3929FC384F83BCB6648 /* Pods-App-OpenEdX.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugprod.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugprod.xcconfig"; sourceTree = ""; }; - AA8BE99557031F3F33F8037C /* Pods-App-OpenEdX.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasestage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasestage.xcconfig"; sourceTree = ""; }; BA7468752B96201D00793145 /* DeepLinkRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkRouter.swift; sourceTree = ""; }; + CCE1E0F850D3E25C0D6C6702 /* Pods-App-OpenEdX.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugdev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugdev.xcconfig"; sourceTree = ""; }; + CE1D5B7A2CE60E000019CA34 /* ContainerMainActor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerMainActor.swift; sourceTree = ""; }; CE3BD14D2CBEB0DA0026F4E3 /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = ""; }; - DAD1882A21DDAF1F67E4C546 /* Pods-App-OpenEdX.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugdev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugdev.xcconfig"; sourceTree = ""; }; + D70D30110012B7D52D05E876 /* Pods-App-OpenEdX.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugprod.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugprod.xcconfig"; sourceTree = ""; }; E0D6E6A22B1626B10089F9C9 /* Theme.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Theme.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - FC49621A0942E5EE74BDC895 /* Pods_App_OpenEdX.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_OpenEdX.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - FC8F87F82A110A0F7A1B0725 /* Pods-App-OpenEdX.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releaseprod.xcconfig"; sourceTree = ""; }; + FD0CE8D22B755B4003A113BB /* Pods-App-OpenEdX.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasestage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasestage.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -159,7 +161,6 @@ buildActionMask = 2147483647; files = ( E0D6E6A32B1626B10089F9C9 /* Theme.framework in Frameworks */, - CE5712792CD1099B00D4AB17 /* OEXFirebaseAnalytics in Frameworks */, 07A7D78F28F5C9060000BE81 /* Core.framework in Frameworks */, 028A37362ADFF404008CA604 /* WhatsNew.framework in Frameworks */, CE9C07D82CD104E5009C44D1 /* OEXFoundation in Frameworks */, @@ -170,8 +171,9 @@ CE924BE72CD8FAB3000137CA /* FirebaseMessaging in Frameworks */, 0218196428F734FA00202564 /* Discussion.framework in Frameworks */, 0219C67728F4347600D64452 /* Course.framework in Frameworks */, + CEBA52772CEBB69100619E2B /* OEXFirebaseAnalytics in Frameworks */, 027DB33028D8A063002B6862 /* Dashboard.framework in Frameworks */, - 1924EDE8164C7AB17AD4946B /* Pods_App_OpenEdX.framework in Frameworks */, + 3ACA3A1E886F3F9B2735B9AF /* Pods_App_OpenEdX.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -208,6 +210,7 @@ 0770DE1D28D084E8006D8A5D /* AppAssembly.swift */, 0770DE4F28D0A707006D8A5D /* NetworkAssembly.swift */, 071009C828D1DB3F00344290 /* ScreenAssembly.swift */, + CE1D5B7A2CE60E000019CA34 /* ContainerMainActor.swift */, ); path = DI; sourceTree = ""; @@ -274,7 +277,7 @@ 072787B028D34D83002E9142 /* Discovery.framework */, 0770DE4A28D0A462006D8A5D /* Authorization.framework */, 0770DE1228D07845006D8A5D /* Core.framework */, - FC49621A0942E5EE74BDC895 /* Pods_App_OpenEdX.framework */, + 3D27E034A83DDEBF18D53B04 /* Pods_App_OpenEdX.framework */, ); name = Frameworks; sourceTree = ""; @@ -282,12 +285,12 @@ 55A895025FB07897BA68E063 /* Pods */ = { isa = PBXGroup; children = ( - A681C3929FC384F83BCB6648 /* Pods-App-OpenEdX.debugprod.xcconfig */, - 1BB1F1D0FABF8788646FBAF2 /* Pods-App-OpenEdX.debugstage.xcconfig */, - DAD1882A21DDAF1F67E4C546 /* Pods-App-OpenEdX.debugdev.xcconfig */, - FC8F87F82A110A0F7A1B0725 /* Pods-App-OpenEdX.releaseprod.xcconfig */, - AA8BE99557031F3F33F8037C /* Pods-App-OpenEdX.releasestage.xcconfig */, - 37C50995093E34142FDE0ED9 /* Pods-App-OpenEdX.releasedev.xcconfig */, + D70D30110012B7D52D05E876 /* Pods-App-OpenEdX.debugprod.xcconfig */, + 8A45E2C9AF0CBE70A09FB37B /* Pods-App-OpenEdX.debugstage.xcconfig */, + CCE1E0F850D3E25C0D6C6702 /* Pods-App-OpenEdX.debugdev.xcconfig */, + 4449C4D4F119C87B452DDFCD /* Pods-App-OpenEdX.releaseprod.xcconfig */, + FD0CE8D22B755B4003A113BB /* Pods-App-OpenEdX.releasestage.xcconfig */, + 005D1F4D92679D24B3BAA8FE /* Pods-App-OpenEdX.releasedev.xcconfig */, ); path = Pods; sourceTree = ""; @@ -384,7 +387,7 @@ isa = PBXNativeTarget; buildConfigurationList = 07D5DA4528D075AB00752FD9 /* Build configuration list for PBXNativeTarget "OpenEdX" */; buildPhases = ( - B9442FD26CE9A85A43FC2CFA /* [CP] Check Pods Manifest.lock */, + B2F937DF587D9697AF13B3F9 /* [CP] Check Pods Manifest.lock */, 0770DE2328D08647006D8A5D /* SwiftLint */, 07D5DA2D28D075AA00752FD9 /* Sources */, 07D5DA2E28D075AA00752FD9 /* Frameworks */, @@ -400,9 +403,9 @@ name = OpenEdX; packageProductDependencies = ( CE9C07D72CD104E5009C44D1 /* OEXFoundation */, - CE5712782CD1099B00D4AB17 /* OEXFirebaseAnalytics */, CE924BE62CD8FAB3000137CA /* FirebaseMessaging */, CE0BF0B92CD9203A00D10289 /* MSAL */, + CEBA52762CEBB69100619E2B /* OEXFirebaseAnalytics */, ); productName = OpenEdX; productReference = 07D5DA3128D075AA00752FD9 /* OpenEdX.app */; @@ -435,9 +438,9 @@ mainGroup = 07D5DA2828D075AA00752FD9; packageReferences = ( CE9C07D62CD104E5009C44D1 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */, - CE9C07D92CD10581009C44D1 /* XCRemoteSwiftPackageReference "openedx-app-firebase-analytics-ios" */, CE924BE52CD8FAB3000137CA /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, CE0BF0B82CD9203A00D10289 /* XCRemoteSwiftPackageReference "microsoft-authentication-library-for-objc" */, + CEBA52752CEBB69100619E2B /* XCRemoteSwiftPackageReference "openedx-app-firebase-analytics-ios" */, ); productRefGroup = 07D5DA3228D075AA00752FD9 /* Products */; projectDirPath = ""; @@ -505,7 +508,7 @@ shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/SwiftLint/swiftlint\"\n"; }; - B9442FD26CE9A85A43FC2CFA /* [CP] Check Pods Manifest.lock */ = { + B2F937DF587D9697AF13B3F9 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -564,6 +567,7 @@ 02F175312A4DA95B0019CD70 /* MainScreenAnalytics.swift in Sources */, BA7468762B96201D00793145 /* DeepLinkRouter.swift in Sources */, 0727878E28D347C7002E9142 /* MainScreenView.swift in Sources */, + CE1D5B7B2CE60E000019CA34 /* ContainerMainActor.swift in Sources */, 0770DE5028D0A707006D8A5D /* NetworkAssembly.swift in Sources */, 0293A2032A6FCA590090A336 /* CorePersistence.swift in Sources */, 0770DE1E28D084E8006D8A5D /* AppAssembly.swift in Sources */, @@ -688,7 +692,7 @@ }; 02DD1C9629E80CC200F35DCE /* DebugStage */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 1BB1F1D0FABF8788646FBAF2 /* Pods-App-OpenEdX.debugstage.xcconfig */; + baseConfigurationReference = 8A45E2C9AF0CBE70A09FB37B /* Pods-App-OpenEdX.debugstage.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -719,7 +723,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugStage; @@ -780,7 +784,7 @@ }; 02DD1C9829E80CCB00F35DCE /* ReleaseStage */ = { isa = XCBuildConfiguration; - baseConfigurationReference = AA8BE99557031F3F33F8037C /* Pods-App-OpenEdX.releasestage.xcconfig */; + baseConfigurationReference = FD0CE8D22B755B4003A113BB /* Pods-App-OpenEdX.releasestage.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -811,7 +815,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseStage; @@ -878,7 +882,7 @@ }; 0727875928D231FD002E9142 /* DebugDev */ = { isa = XCBuildConfiguration; - baseConfigurationReference = DAD1882A21DDAF1F67E4C546 /* Pods-App-OpenEdX.debugdev.xcconfig */; + baseConfigurationReference = CCE1E0F850D3E25C0D6C6702 /* Pods-App-OpenEdX.debugdev.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -909,7 +913,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugDev; @@ -970,7 +974,7 @@ }; 0727875B28D23204002E9142 /* ReleaseDev */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 37C50995093E34142FDE0ED9 /* Pods-App-OpenEdX.releasedev.xcconfig */; + baseConfigurationReference = 005D1F4D92679D24B3BAA8FE /* Pods-App-OpenEdX.releasedev.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -1001,7 +1005,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseDev; @@ -1122,7 +1126,7 @@ }; 07D5DA4628D075AB00752FD9 /* DebugProd */ = { isa = XCBuildConfiguration; - baseConfigurationReference = A681C3929FC384F83BCB6648 /* Pods-App-OpenEdX.debugprod.xcconfig */; + baseConfigurationReference = D70D30110012B7D52D05E876 /* Pods-App-OpenEdX.debugprod.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -1153,14 +1157,14 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugProd; }; 07D5DA4728D075AB00752FD9 /* ReleaseProd */ = { isa = XCBuildConfiguration; - baseConfigurationReference = FC8F87F82A110A0F7A1B0725 /* Pods-App-OpenEdX.releaseprod.xcconfig */; + baseConfigurationReference = 4449C4D4F119C87B452DDFCD /* Pods-App-OpenEdX.releaseprod.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -1191,7 +1195,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseProd; @@ -1249,15 +1253,15 @@ repositoryURL = "https://github.com/openedx/openedx-app-foundation-ios/"; requirement = { kind = exactVersion; - version = 1.0.0; + version = 1.0.1; }; }; - CE9C07D92CD10581009C44D1 /* XCRemoteSwiftPackageReference "openedx-app-firebase-analytics-ios" */ = { + CEBA52752CEBB69100619E2B /* XCRemoteSwiftPackageReference "openedx-app-firebase-analytics-ios" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/openedx/openedx-app-firebase-analytics-ios"; requirement = { kind = exactVersion; - version = 1.0.0; + version = 1.0.1; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -1268,11 +1272,6 @@ package = CE0BF0B82CD9203A00D10289 /* XCRemoteSwiftPackageReference "microsoft-authentication-library-for-objc" */; productName = MSAL; }; - CE5712782CD1099B00D4AB17 /* OEXFirebaseAnalytics */ = { - isa = XCSwiftPackageProductDependency; - package = CE9C07D92CD10581009C44D1 /* XCRemoteSwiftPackageReference "openedx-app-firebase-analytics-ios" */; - productName = OEXFirebaseAnalytics; - }; CE924BE62CD8FAB3000137CA /* FirebaseMessaging */ = { isa = XCSwiftPackageProductDependency; package = CE924BE52CD8FAB3000137CA /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; @@ -1283,6 +1282,11 @@ package = CE9C07D62CD104E5009C44D1 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */; productName = OEXFoundation; }; + CEBA52762CEBB69100619E2B /* OEXFirebaseAnalytics */ = { + isa = XCSwiftPackageProductDependency; + package = CEBA52752CEBB69100619E2B /* XCRemoteSwiftPackageReference "openedx-app-firebase-analytics-ios" */; + productName = OEXFirebaseAnalytics; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 07D5DA2928D075AA00752FD9 /* Project object */; diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index d828970d6..e97f573d9 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -20,7 +20,7 @@ import FirebaseMessaging import Theme import BackgroundTasks -@UIApplicationMain +@main class AppDelegate: UIResponder, UIApplicationDelegate { static let bgAppTaskId = "openEdx.offlineProgressSync" @@ -171,13 +171,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { analyticsManager?.userLogout(force: true) lastForceLogoutTime = Date().timeIntervalSince1970 - Container.shared.resolve(CoreStorage.self)?.clear() - Container.shared.resolve(CorePersistenceProtocol.self)?.deleteAllProgress() + Task { + await Container.shared.resolve(CorePersistenceProtocol.self)?.deleteAllProgress() await Container.shared.resolve(DownloadManagerProtocol.self)?.deleteAllFiles() + await Container.shared.resolve(CoreDataHandlerProtocol.self)?.clear() } - Container.shared.resolve(CoreDataHandlerProtocol.self)?.clear() window?.rootViewController = RouteController() } diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index ffa6fb450..b7b592ec1 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -8,8 +8,7 @@ import UIKit import Core import OEXFoundation -import OEXFirebaseAnalytics -import Swinject +@preconcurrency import Swinject import KeychainSwift import Discovery import Dashboard @@ -39,7 +38,7 @@ class AppAssembly: Assembly { self.pluginManager }.inObjectScope(.container) - container.register(Router.self) { r in + container.register(Router.self) { @MainActor r in Router(navigationController: r.resolve(UINavigationController.self)!, container: container) } @@ -83,7 +82,7 @@ class AppAssembly: Assembly { r.resolve(AnalyticsManager.self)! }.inObjectScope(.container) - container.register(ConnectivityProtocol.self) { _ in + container.register(ConnectivityProtocol.self) { @MainActor _ in Connectivity() } @@ -96,14 +95,16 @@ class AppAssembly: Assembly { }.inObjectScope(.container) container.register(CorePersistenceProtocol.self) { r in - CorePersistence(context: r.resolve(DatabaseManager.self)!.context) + CorePersistence(container: r.resolve(DatabaseManager.self)!.getPersistentContainer()) }.inObjectScope(.container) - container.register(DownloadManagerProtocol.self, factory: { r in - DownloadManager(persistence: r.resolve(CorePersistenceProtocol.self)!, - appStorage: r.resolve(CoreStorage.self)!, - connectivity: r.resolve(ConnectivityProtocol.self)!) - }).inObjectScope(.container) + container.register(DownloadManagerProtocol.self) { @MainActor r in + DownloadManager( + persistence: r.resolve(CorePersistenceProtocol.self)!, + appStorage: r.resolve(CoreStorage.self)!, + connectivity: r.resolve(ConnectivityProtocol.self)! + ) + }.inObjectScope(.container) container.register(AuthorizationRouter.self) { r in r.resolve(Router.self)! @@ -184,7 +185,7 @@ class AppAssembly: Assembly { Validator() }.inObjectScope(.container) - container.register(PushNotificationsManager.self) { r in + container.register(PushNotificationsManager.self) { @MainActor r in PushNotificationsManager( deepLinkManager: r.resolve(DeepLinkManager.self)!, storage: r.resolve(CoreStorage.self)!, @@ -193,7 +194,7 @@ class AppAssembly: Assembly { ) }.inObjectScope(.container) - container.register(CalendarManagerProtocol.self) { r in + container.register(CalendarManagerProtocol.self) { @MainActor r in CalendarManager( persistence: r.resolve(ProfilePersistenceProtocol.self)!, interactor: r.resolve(ProfileInteractorProtocol.self)!, @@ -202,7 +203,7 @@ class AppAssembly: Assembly { } .inObjectScope(.container) - container.register(DeepLinkManager.self) { r in + container.register(DeepLinkManager.self) { @MainActor r in DeepLinkManager( config: r.resolve(ConfigProtocol.self)!, router: r.resolve(Router.self)!, @@ -214,11 +215,7 @@ class AppAssembly: Assembly { ) }.inObjectScope(.container) - container.register(FirebaseAnalyticsService.self) { _ in - FirebaseAnalyticsService() - }.inObjectScope(.container) - - container.register(PipManagerProtocol.self) { r in + container.register(PipManagerProtocol.self) { @MainActor r in let config = r.resolve(ConfigProtocol.self)! return PipManager( router: r.resolve(Router.self)!, diff --git a/OpenEdX/DI/ContainerMainActor.swift b/OpenEdX/DI/ContainerMainActor.swift new file mode 100644 index 000000000..294dbe9fd --- /dev/null +++ b/OpenEdX/DI/ContainerMainActor.swift @@ -0,0 +1,279 @@ +// +// Container.MainActor +// OpenEdX +// +// Created by Ivan Stepanok on 14.11.2024. +// + +import Foundation +@preconcurrency import Swinject + +// MARK: - MainActor registration +@available(iOS 13.0, macOS 10.15, *) +extension Container { + + /// Adds a registration for the specified service with the factory closure to specify how the service is + /// resolved with dependencies which must be resolved on the main actor. + /// + /// - Parameters: + /// - serviceType: The service type to register. + /// - name: A registration name, which is used to differentiate from other registrations + /// that have the same service and factory types. + /// - mainActorFactory: The @MainActor closure to specify how the service type is resolved with the dependencies of the type. + /// It is invoked when the ``Container`` needs to instantiate the instance. + /// It takes a ``Resolver`` to inject dependencies to the instance, + /// and returns the instance of the component type for the service. + /// + /// - Returns: A registered ``ServiceEntry`` to configure more settings with method chaining. + @discardableResult + public func register( + _ serviceType: Service.Type, + name: String? = nil, + mainActorFactory: @escaping @MainActor (Resolver) -> Service + ) -> ServiceEntry { + return register(serviceType, name: name) { r in + MainActor.assumeIsolated { + return mainActorFactory(r) + } + } + } +} + +// MARK: - MainActor registration with Arguments +@available(iOS 13.0, macOS 10.15, *) +extension Container { + /// Adds a registration for the specified service with the factory closure to specify how the service is + /// resolved with dependencies which must be resolved on the main actor. + /// + /// - Parameters: + /// - serviceType: The service type to register. + /// - name: A registration name, which is used to differentiate from other registrations + /// that have the same service and factory types. + /// - mainActorFactory: The @MainActor closure to specify how the service type is resolved with the dependencies of the type. + /// It is invoked when the ``Container`` needs to instantiate the instance. + /// It takes a `Resolver` instance and 1 argument to inject dependencies to the instance, + /// and returns the instance of the component type for the service. + /// + /// - Returns: A registered ``ServiceEntry`` to configure more settings with method chaining. + @discardableResult + public func register( + _ serviceType: Service.Type, + name: String? = nil, + mainActorFactory: @escaping @MainActor (Resolver, Arg1) -> Service + ) -> ServiceEntry { + return register(serviceType, name: name) { (resolver: Resolver, arg1: Arg1) in + MainActor.assumeIsolated { + return mainActorFactory(resolver, arg1) + } + } + } + + /// Adds a registration for the specified service with the factory closure to specify how the service is + /// resolved with dependencies which must be resolved on the main actor. + /// + /// - Parameters: + /// - serviceType: The service type to register. + /// - name: A registration name, which is used to differentiate from other registrations + /// that have the same service and factory types. + /// - mainActorFactory: The @MainActor closure to specify how the service type is resolved with the dependencies of the type. + /// It is invoked when the ``Container`` needs to instantiate the instance. + /// It takes a `Resolver` instance and 2 arguments to inject dependencies to the instance, + /// and returns the instance of the component type for the service. + /// + /// - Returns: A registered ``ServiceEntry`` to configure more settings with method chaining. + @discardableResult + public func register( + _ serviceType: Service.Type, + name: String? = nil, + mainActorFactory: @escaping @MainActor (Resolver, Arg1, Arg2) -> Service + ) -> ServiceEntry { + return register(serviceType, name: name) { (resolver: Resolver, arg1: Arg1, arg2: Arg2) in + MainActor.assumeIsolated { + return mainActorFactory(resolver, arg1, arg2) + } + } + } + + /// Adds a registration for the specified service with the factory closure to specify how the service is + /// resolved with dependencies which must be resolved on the main actor. + /// + /// - Parameters: + /// - serviceType: The service type to register. + /// - name: A registration name, which is used to differentiate from other registrations + /// that have the same service and factory types. + /// - mainActorFactory: The @MainActor closure to specify how the service type is resolved with the dependencies of the type. + /// It is invoked when the ``Container`` needs to instantiate the instance. + /// It takes a `Resolver` instance and 3 arguments to inject dependencies to the instance, + /// and returns the instance of the component type for the service. + /// + /// - Returns: A registered ``ServiceEntry`` to configure more settings with method chaining. + @discardableResult + public func register( + _ serviceType: Service.Type, + name: String? = nil, + mainActorFactory: @escaping @MainActor (Resolver, Arg1, Arg2, Arg3) -> Service + ) -> ServiceEntry { + return register(serviceType, name: name) { (resolver: Resolver, arg1: Arg1, arg2: Arg2, arg3: Arg3) in + MainActor.assumeIsolated { + return mainActorFactory(resolver, arg1, arg2, arg3) + } + } + } + + /// Adds a registration for the specified service with the factory closure to specify how the service is + /// resolved with dependencies which must be resolved on the main actor. + /// + /// - Parameters: + /// - serviceType: The service type to register. + /// - name: A registration name, which is used to differentiate from other registrations + /// that have the same service and factory types. + /// - mainActorFactory: The @MainActor closure to specify how the service type is resolved with the dependencies of the type. + /// It is invoked when the ``Container`` needs to instantiate the instance. + /// It takes a `Resolver` instance and 4 arguments to inject dependencies to the instance, + /// and returns the instance of the component type for the service. + /// + /// - Returns: A registered ``ServiceEntry`` to configure more settings with method chaining. + @discardableResult + public func register( + _ serviceType: Service.Type, + name: String? = nil, + mainActorFactory: @escaping @MainActor (Resolver, Arg1, Arg2, Arg3, Arg4) -> Service + ) -> ServiceEntry { + return register(serviceType, name: name) { (resolver: Resolver, arg1: Arg1, arg2: Arg2, arg3: Arg3, arg4: Arg4) in + MainActor.assumeIsolated { + return mainActorFactory(resolver, arg1, arg2, arg3, arg4) + } + } + } + + /// Adds a registration for the specified service with the factory closure to specify how the service is + /// resolved with dependencies which must be resolved on the main actor. + /// + /// - Parameters: + /// - serviceType: The service type to register. + /// - name: A registration name, which is used to differentiate from other registrations + /// that have the same service and factory types. + /// - mainActorFactory: The @MainActor closure to specify how the service type is resolved with the dependencies of the type. + /// It is invoked when the ``Container`` needs to instantiate the instance. + /// It takes a `Resolver` instance and 5 arguments to inject dependencies to the instance, + /// and returns the instance of the component type for the service. + /// + /// - Returns: A registered ``ServiceEntry`` to configure more settings with method chaining. + @discardableResult + public func register( + _ serviceType: Service.Type, + name: String? = nil, + mainActorFactory: @escaping @MainActor (Resolver, Arg1, Arg2, Arg3, Arg4, Arg5) -> Service + ) -> ServiceEntry { + return register(serviceType, name: name) { (resolver: Resolver, arg1: Arg1, arg2: Arg2, arg3: Arg3, arg4: Arg4, arg5: Arg5) in + MainActor.assumeIsolated { + return mainActorFactory(resolver, arg1, arg2, arg3, arg4, arg5) + } + } + } + + /// Adds a registration for the specified service with the factory closure to specify how the service is + /// resolved with dependencies which must be resolved on the main actor. + /// + /// - Parameters: + /// - serviceType: The service type to register. + /// - name: A registration name, which is used to differentiate from other registrations + /// that have the same service and factory types. + /// - mainActorFactory: The @MainActor closure to specify how the service type is resolved with the dependencies of the type. + /// It is invoked when the ``Container`` needs to instantiate the instance. + /// It takes a `Resolver` instance and 6 arguments to inject dependencies to the instance, + /// and returns the instance of the component type for the service. + /// + /// - Returns: A registered ``ServiceEntry`` to configure more settings with method chaining. + @discardableResult + public func register( + _ serviceType: Service.Type, + name: String? = nil, + mainActorFactory: @escaping @MainActor (Resolver, Arg1, Arg2, Arg3, Arg4, Arg5, Arg6) -> Service + ) -> ServiceEntry { + return register(serviceType, name: name) { (resolver: Resolver, arg1: Arg1, arg2: Arg2, arg3: Arg3, arg4: Arg4, arg5: Arg5, arg6: Arg6) in + MainActor.assumeIsolated { + return mainActorFactory(resolver, arg1, arg2, arg3, arg4, arg5, arg6) + } + } + } + + /// Adds a registration for the specified service with the factory closure to specify how the service is + /// resolved with dependencies which must be resolved on the main actor. + /// + /// - Parameters: + /// - serviceType: The service type to register. + /// - name: A registration name, which is used to differentiate from other registrations + /// that have the same service and factory types. + /// - mainActorFactory: The @MainActor closure to specify how the service type is resolved with the dependencies of the type. + /// It is invoked when the ``Container`` needs to instantiate the instance. + /// It takes a `Resolver` instance and 7 arguments to inject dependencies to the instance, + /// and returns the instance of the component type for the service. + /// + /// - Returns: A registered ``ServiceEntry`` to configure more settings with method chaining. + @discardableResult + public func register( + _ serviceType: Service.Type, + name: String? = nil, + mainActorFactory: @escaping @MainActor (Resolver, Arg1, Arg2, Arg3, Arg4, Arg5, Arg6, Arg7) -> Service + ) -> ServiceEntry { + return register(serviceType, name: name) { (resolver: Resolver, arg1: Arg1, arg2: Arg2, arg3: Arg3, arg4: Arg4, arg5: Arg5, arg6: Arg6, arg7: Arg7) in + MainActor.assumeIsolated { + return mainActorFactory(resolver, arg1, arg2, arg3, arg4, arg5, arg6, arg7) + } + } + } + + /// Adds a registration for the specified service with the factory closure to specify how the service is + /// resolved with dependencies which must be resolved on the main actor. + /// + /// - Parameters: + /// - serviceType: The service type to register. + /// - name: A registration name, which is used to differentiate from other registrations + /// that have the same service and factory types. + /// - mainActorFactory: The @MainActor closure to specify how the service type is resolved with the dependencies of the type. + /// It is invoked when the ``Container`` needs to instantiate the instance. + /// It takes a `Resolver` instance and 8 arguments to inject dependencies to the instance, + /// and returns the instance of the component type for the service. + /// + /// - Returns: A registered ``ServiceEntry`` to configure more settings with method chaining. + @discardableResult + public func register( + _ serviceType: Service.Type, + name: String? = nil, + mainActorFactory: @escaping @MainActor (Resolver, Arg1, Arg2, Arg3, Arg4, Arg5, Arg6, Arg7, Arg8) -> Service + ) -> ServiceEntry { + return register(serviceType, name: name) { (resolver: Resolver, arg1: Arg1, arg2: Arg2, arg3: Arg3, arg4: Arg4, arg5: Arg5, arg6: Arg6, arg7: Arg7, arg8: Arg8) in + MainActor.assumeIsolated { + return mainActorFactory(resolver, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8) + } + } + } + + /// Adds a registration for the specified service with the factory closure to specify how the service is + /// resolved with dependencies which must be resolved on the main actor. + /// + /// - Parameters: + /// - serviceType: The service type to register. + /// - name: A registration name, which is used to differentiate from other registrations + /// that have the same service and factory types. + /// - mainActorFactory: The @MainActor closure to specify how the service type is resolved with the dependencies of the type. + /// It is invoked when the ``Container`` needs to instantiate the instance. + /// It takes a `Resolver` instance and 9 arguments to inject dependencies to the instance, + /// and returns the instance of the component type for the service. + /// + /// - Returns: A registered ``ServiceEntry`` to configure more settings with method chaining. + @discardableResult + public func register( + _ serviceType: Service.Type, + name: String? = nil, + mainActorFactory: @escaping @MainActor (Resolver, Arg1, Arg2, Arg3, Arg4, Arg5, Arg6, Arg7, Arg8, Arg9) -> Service + ) -> ServiceEntry { + return register(serviceType, name: name) { (resolver: Resolver, arg1: Arg1, arg2: Arg2, arg3: Arg3, arg4: Arg4, arg5: Arg5, arg6: Arg6, arg7: Arg7, arg8: Arg8, arg9: Arg9) in + MainActor.assumeIsolated { + return mainActorFactory(resolver, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9) + } + } + } + +} diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 551483b91..e46faaa92 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -6,7 +6,8 @@ // import Foundation -import Swinject +@preconcurrency import Swinject +import CoreData import Core import OEXFoundation import Authorization @@ -15,7 +16,7 @@ import Dashboard import Profile import Course import Discussion -import Combine +@preconcurrency import Combine // swiftlint:disable function_body_length type_body_length class ScreenAssembly: Assembly { @@ -33,14 +34,14 @@ class ScreenAssembly: Assembly { ) } - container.register(OfflineSyncManagerProtocol.self) { r in + container.register(OfflineSyncManagerProtocol.self) { @MainActor r in OfflineSyncManager( persistence: r.resolve(CorePersistenceProtocol.self)!, interactor: r.resolve(OfflineSyncInteractorProtocol.self)!, connectivity: r.resolve(ConnectivityProtocol.self)! ) } - + // MARK: Auth container.register(AuthRepositoryProtocol.self) { r in AuthRepository( @@ -56,7 +57,7 @@ class ScreenAssembly: Assembly { } // MARK: MainScreenView - container.register(MainScreenViewModel.self) { r, sourceScreen in + container.register(MainScreenViewModel.self) { @MainActor r, sourceScreen in MainScreenViewModel( analytics: r.resolve(MainScreenAnalytics.self)!, config: r.resolve(ConfigProtocol.self)!, @@ -70,7 +71,7 @@ class ScreenAssembly: Assembly { ) } // MARK: Startup screen - container.register(StartupViewModel.self) { r in + container.register(StartupViewModel.self) { @MainActor r in StartupViewModel( router: r.resolve(AuthorizationRouter.self)!, analytics: r.resolve(CoreAnalytics.self)! @@ -78,7 +79,7 @@ class ScreenAssembly: Assembly { } // MARK: SignIn - container.register(SignInViewModel.self) { r, sourceScreen in + container.register(SignInViewModel.self) { @MainActor r, sourceScreen in SignInViewModel( interactor: r.resolve(AuthInteractorProtocol.self)!, router: r.resolve(AuthorizationRouter.self)!, @@ -88,7 +89,7 @@ class ScreenAssembly: Assembly { sourceScreen: sourceScreen ) } - container.register(SSOWebViewModel.self) { r in + container.register(SSOWebViewModel.self) { @MainActor r in SSOWebViewModel( interactor: r.resolve(AuthInteractorProtocol.self)!, router: r.resolve(AuthorizationRouter.self)!, @@ -97,7 +98,7 @@ class ScreenAssembly: Assembly { ssoHelper: r.resolve(SSOHelper.self)! ) } - container.register(SignUpViewModel.self) { r, sourceScreen in + container.register(SignUpViewModel.self) { @MainActor r, sourceScreen in SignUpViewModel( interactor: r.resolve(AuthInteractorProtocol.self)!, router: r.resolve(AuthorizationRouter.self)!, @@ -108,7 +109,7 @@ class ScreenAssembly: Assembly { sourceScreen: sourceScreen ) } - container.register(ResetPasswordViewModel.self) { r in + container.register(ResetPasswordViewModel.self) { @MainActor r in ResetPasswordViewModel( interactor: r.resolve(AuthInteractorProtocol.self)!, router: r.resolve(AuthorizationRouter.self)!, @@ -119,7 +120,7 @@ class ScreenAssembly: Assembly { // MARK: Discovery container.register(DiscoveryPersistenceProtocol.self) { r in - DiscoveryPersistence(context: r.resolve(DatabaseManager.self)!.context) + return DiscoveryPersistence(container: r.resolve(DatabaseManager.self)!.getPersistentContainer()) } container.register(DiscoveryRepositoryProtocol.self) { r in @@ -135,7 +136,7 @@ class ScreenAssembly: Assembly { repository: r.resolve(DiscoveryRepositoryProtocol.self)! ) } - container.register(DiscoveryViewModel.self) { r in + container.register(DiscoveryViewModel.self) { @MainActor r in DiscoveryViewModel( router: r.resolve(DiscoveryRouter.self)!, config: r.resolve(ConfigProtocol.self)!, @@ -146,7 +147,7 @@ class ScreenAssembly: Assembly { ) } - container.register(DiscoveryWebviewViewModel.self) { r, sourceScreen in + container.register(DiscoveryWebviewViewModel.self) { @MainActor r, sourceScreen in DiscoveryWebviewViewModel( router: r.resolve(DiscoveryRouter.self)!, config: r.resolve(ConfigProtocol.self)!, @@ -158,7 +159,7 @@ class ScreenAssembly: Assembly { ) } - container.register(ProgramWebviewViewModel.self) { r in + container.register(ProgramWebviewViewModel.self) { @MainActor r in ProgramWebviewViewModel( router: r.resolve(DiscoveryRouter.self)!, config: r.resolve(ConfigProtocol.self)!, @@ -169,7 +170,7 @@ class ScreenAssembly: Assembly { ) } - container.register(SearchViewModel.self) { r in + container.register(SearchViewModel.self) { @MainActor r in SearchViewModel( interactor: r.resolve(DiscoveryInteractorProtocol.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, @@ -182,7 +183,7 @@ class ScreenAssembly: Assembly { // MARK: Dashboard container.register(DashboardPersistenceProtocol.self) { r in - DashboardPersistence(context: r.resolve(DatabaseManager.self)!.context) + DashboardPersistence(container: r.resolve(DatabaseManager.self)!.getPersistentContainer()) } container.register(DashboardRepositoryProtocol.self) { r in @@ -198,7 +199,7 @@ class ScreenAssembly: Assembly { repository: r.resolve(DashboardRepositoryProtocol.self)! ) } - container.register(ListDashboardViewModel.self) { r in + container.register(ListDashboardViewModel.self) { @MainActor r in ListDashboardViewModel( interactor: r.resolve(DashboardInteractorProtocol.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, @@ -207,7 +208,7 @@ class ScreenAssembly: Assembly { ) } - container.register(PrimaryCourseDashboardViewModel.self) { r in + container.register(PrimaryCourseDashboardViewModel.self) { @MainActor r in PrimaryCourseDashboardViewModel( interactor: r.resolve(DashboardInteractorProtocol.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, @@ -217,7 +218,7 @@ class ScreenAssembly: Assembly { ) } - container.register(AllCoursesViewModel.self) { r in + container.register(AllCoursesViewModel.self) { @MainActor r in AllCoursesViewModel( interactor: r.resolve(DashboardInteractorProtocol.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, @@ -230,7 +231,7 @@ class ScreenAssembly: Assembly { // MARK: Course container.register(ProfilePersistenceProtocol.self) { r in - ProfilePersistence(context: r.resolve(DatabaseManager.self)!.context) + ProfilePersistence(container: r.resolve(DatabaseManager.self)!.getPersistentContainer()) } container.register(ProfileRepositoryProtocol.self) { r in @@ -247,7 +248,7 @@ class ScreenAssembly: Assembly { repository: r.resolve(ProfileRepositoryProtocol.self)! ) } - container.register(ProfileViewModel.self) { r in + container.register(ProfileViewModel.self) { @MainActor r in ProfileViewModel( interactor: r.resolve(ProfileInteractorProtocol.self)!, router: r.resolve(ProfileRouter.self)!, @@ -256,17 +257,16 @@ class ScreenAssembly: Assembly { connectivity: r.resolve(ConnectivityProtocol.self)! ) } - container.register(EditProfileViewModel.self) { r, userModel in + container.register(EditProfileViewModel.self) { @MainActor r, userModel in EditProfileViewModel( userModel: userModel, interactor: r.resolve(ProfileInteractorProtocol.self)!, router: r.resolve(ProfileRouter.self)!, analytics: r.resolve(ProfileAnalytics.self)! - ) } - container.register(SettingsViewModel.self) { r in + container.register(SettingsViewModel.self) { @MainActor r in SettingsViewModel( interactor: r.resolve(ProfileInteractorProtocol.self)!, downloadManager: r.resolve(DownloadManagerProtocol.self)!, @@ -279,7 +279,7 @@ class ScreenAssembly: Assembly { ) } - container.register(DatesAndCalendarViewModel.self) { r in + container.register(DatesAndCalendarViewModel.self) { @MainActor r in DatesAndCalendarViewModel( router: r.resolve(ProfileRouter.self)!, interactor: r.resolve(ProfileInteractorProtocol.self)!, @@ -291,7 +291,7 @@ class ScreenAssembly: Assembly { } .inObjectScope(.weak) - container.register(ManageAccountViewModel.self) { r in + container.register(ManageAccountViewModel.self) { @MainActor r in ManageAccountViewModel( router: r.resolve(ProfileRouter.self)!, analytics: r.resolve(ProfileAnalytics.self)!, @@ -301,7 +301,7 @@ class ScreenAssembly: Assembly { ) } - container.register(DeleteAccountViewModel.self) { r in + container.register(DeleteAccountViewModel.self) { @MainActor r in DeleteAccountViewModel( interactor: r.resolve(ProfileInteractorProtocol.self)!, router: r.resolve(ProfileRouter.self)!, @@ -312,23 +312,10 @@ class ScreenAssembly: Assembly { // MARK: Course container.register(CoursePersistenceProtocol.self) { r in - CoursePersistence(context: r.resolve(DatabaseManager.self)!.context) + CoursePersistence(container: r.resolve(DatabaseManager.self)!.getPersistentContainer()) } - container.register(CourseRepositoryProtocol.self) { r in - CourseRepository( - api: r.resolve(API.self)!, - coreStorage: r.resolve(CoreStorage.self)!, - config: r.resolve(ConfigProtocol.self)!, - persistence: r.resolve(CoursePersistenceProtocol.self)! - ) - } - container.register(CourseInteractorProtocol.self) { r in - CourseInteractor( - repository: r.resolve(CourseRepositoryProtocol.self)! - ) - } - container.register(CourseDetailsViewModel.self) { r in + container.register(CourseDetailsViewModel.self) { @MainActor r in CourseDetailsViewModel( interactor: r.resolve(DiscoveryInteractorProtocol.self)!, router: r.resolve(DiscoveryRouter.self)!, @@ -343,7 +330,7 @@ class ScreenAssembly: Assembly { // MARK: CourseScreensView container.register( CourseContainerViewModel.self - ) { r, isActive, courseStart, courseEnd, enrollmentStart, enrollmentEnd, selection, lastVisitedBlockID in + ) { @MainActor r, isActive, courseStart, courseEnd, enrollmentStart, enrollmentEnd, selection, lastVisitedBlockID in CourseContainerViewModel( interactor: r.resolve(CourseInteractorProtocol.self)!, authInteractor: r.resolve(AuthInteractorProtocol.self)!, @@ -364,7 +351,7 @@ class ScreenAssembly: Assembly { ) } - container.register(CourseVerticalViewModel.self) { r, chapters, chapterIndex, sequentialIndex in + container.register(CourseVerticalViewModel.self) { @MainActor r, chapters, chapterIndex, sequentialIndex in CourseVerticalViewModel( chapters: chapters, chapterIndex: chapterIndex, @@ -378,7 +365,7 @@ class ScreenAssembly: Assembly { container.register( CourseUnitViewModel.self - ) { r, blockId, courseId, courseName, chapters, chapterIndex, sequentialIndex, verticalIndex in + ) { @MainActor r, blockId, courseId, courseName, chapters, chapterIndex, sequentialIndex, verticalIndex in CourseUnitViewModel( lessonID: blockId, courseID: courseId, @@ -397,17 +384,18 @@ class ScreenAssembly: Assembly { ) } - container.register(WebUnitViewModel.self) { r in + container.register(WebUnitViewModel.self) { @MainActor r in WebUnitViewModel( authInteractor: r.resolve(AuthInteractorProtocol.self)!, config: r.resolve(ConfigProtocol.self)!, syncManager: r.resolve(OfflineSyncManagerProtocol.self)! ) } - container.register( - YouTubeVideoPlayerViewModel.self - ) { (r, url: URL?, blockID: String, courseID: String, languages: [SubtitleUrl], playerStateSubject: CurrentValueSubject) in + YouTubeVideoPlayerViewModel.self, + mainActorFactory: { ( r, url: URL?, blockID: String, courseID: String, languages: [SubtitleUrl], + playerStateSubject: CurrentValueSubject + ) in let router: Router = r.resolve(Router.self)! return YouTubeVideoPlayerViewModel( languages: languages, @@ -421,9 +409,19 @@ class ScreenAssembly: Assembly { router.currentCourseTabSelection )! ) - } + }) - container.register(EncodedVideoPlayerViewModel.self) { (r, url: URL?, blockID: String, courseID: String, languages: [SubtitleUrl], playerStateSubject: CurrentValueSubject) in + container.register( + EncodedVideoPlayerViewModel.self, + mainActorFactory: { + ( + r, + url: URL?, + blockID: String, + courseID: String, + languages: [SubtitleUrl], + playerStateSubject: CurrentValueSubject + ) in let router: Router = r.resolve(Router.self)! let holder = r.resolve( @@ -439,23 +437,23 @@ class ScreenAssembly: Assembly { connectivity: r.resolve(ConnectivityProtocol.self)!, playerHolder: holder ) - } + }) - container.register(PlayerDelegateProtocol.self) { _, manager in - PlayerDelegate(pipManager: manager) + container.register(PlayerDelegateProtocol.self) { r in + PlayerDelegate(pipManager: r.resolve(PipManagerProtocol.self)!) } - container.register(YoutubePlayerTracker.self) { (_, url) in + container.register(YoutubePlayerTracker.self, mainActorFactory: { (_, url) in YoutubePlayerTracker(url: url) - } + }) - container.register(PlayerTracker.self) { (_, url) in + container.register(PlayerTracker.self, mainActorFactory: { (_, url) in PlayerTracker(url: url) - } + }) container.register( YoutubePlayerViewControllerHolder.self - ) { r, url, blockID, courseID, selectedCourseTab in + ) { @MainActor r, url, blockID, courseID, selectedCourseTab in YoutubePlayerViewControllerHolder( url: url, blockID: blockID, @@ -470,44 +468,44 @@ class ScreenAssembly: Assembly { } container.register( - PlayerViewControllerHolder.self - ) { (r, url: URL?, blockID: String, courseID: String, selectedCourseTab: Int) in - let pipManager = r.resolve(PipManagerProtocol.self)! - if let holder = pipManager.holder( - for: url, - blockID: blockID, - courseID: courseID, - selectedCourseTab: selectedCourseTab - ) as? PlayerViewControllerHolder { + PlayerViewControllerHolder.self, + mainActorFactory: { (r, url: URL?, blockID: String, courseID: String, selectedCourseTab: Int) in + let pipManager = r.resolve(PipManagerProtocol.self)! + if let holder = pipManager.holder( + for: url, + blockID: blockID, + courseID: courseID, + selectedCourseTab: selectedCourseTab + ) as? PlayerViewControllerHolder { + return holder + } + + let storage = r.resolve(CoreStorage.self)! + let quality = storage.userSettings?.streamingQuality ?? .auto + let tracker = r.resolve(PlayerTracker.self, argument: url)! + let delegate = r.resolve(PlayerDelegateProtocol.self)! + let holder = PlayerViewControllerHolder( + url: url, + blockID: blockID, + courseID: courseID, + selectedCourseTab: selectedCourseTab, + videoResolution: quality.resolution, + pipManager: pipManager, + playerTracker: tracker, + playerDelegate: delegate, + playerService: r.resolve(PlayerServiceProtocol.self, arguments: courseID, blockID)! + ) + delegate.playerHolder = holder return holder - } - - let storage = r.resolve(CoreStorage.self)! - let quality = storage.userSettings?.streamingQuality ?? .auto - let tracker = r.resolve(PlayerTracker.self, argument: url)! - let delegate = r.resolve(PlayerDelegateProtocol.self, argument: pipManager)! - let holder = PlayerViewControllerHolder( - url: url, - blockID: blockID, - courseID: courseID, - selectedCourseTab: selectedCourseTab, - videoResolution: quality.resolution, - pipManager: pipManager, - playerTracker: tracker, - playerDelegate: delegate, - playerService: r.resolve(PlayerServiceProtocol.self, arguments: courseID, blockID)! - ) - delegate.playerHolder = holder - return holder - } + }) - container.register(PlayerServiceProtocol.self) { r, courseID, blockID in + container.register(PlayerServiceProtocol.self) { @MainActor r, courseID, blockID in let interactor = r.resolve(CourseInteractorProtocol.self)! let router = r.resolve(CourseRouter.self)! return PlayerService(courseID: courseID, blockID: blockID, interactor: interactor, router: router) } - container.register(HandoutsViewModel.self) { r, courseID in + container.register(HandoutsViewModel.self) { @MainActor r, courseID in HandoutsViewModel( interactor: r.resolve(CourseInteractorProtocol.self)!, router: r.resolve(CourseRouter.self)!, @@ -518,7 +516,7 @@ class ScreenAssembly: Assembly { ) } - container.register(CourseDatesViewModel.self) { r, courseID, courseName in + container.register(CourseDatesViewModel.self) { @MainActor r, courseID, courseName in CourseDatesViewModel( interactor: r.resolve(CourseInteractorProtocol.self)!, router: r.resolve(CourseRouter.self)!, @@ -548,7 +546,7 @@ class ScreenAssembly: Assembly { ) } - container.register(DiscussionTopicsViewModel.self) { r, title in + container.register(DiscussionTopicsViewModel.self) { @MainActor r, title in DiscussionTopicsViewModel( title: title, interactor: r.resolve(DiscussionInteractorProtocol.self)!, @@ -558,7 +556,7 @@ class ScreenAssembly: Assembly { ) } - container.register(DiscussionSearchTopicsViewModel.self) { r, courseID in + container.register(DiscussionSearchTopicsViewModel.self) { @MainActor r, courseID in DiscussionSearchTopicsViewModel( courseID: courseID, interactor: r.resolve(DiscussionInteractorProtocol.self)!, @@ -568,7 +566,7 @@ class ScreenAssembly: Assembly { ) } - container.register(PostsViewModel.self) { r in + container.register(PostsViewModel.self) { @MainActor r in PostsViewModel( interactor: r.resolve(DiscussionInteractorProtocol.self)!, router: r.resolve(DiscussionRouter.self)!, @@ -577,7 +575,7 @@ class ScreenAssembly: Assembly { ) } - container.register(ThreadViewModel.self) { r, subject in + container.register(ThreadViewModel.self) { @MainActor r, subject in ThreadViewModel( interactor: r.resolve(DiscussionInteractorProtocol.self)!, router: r.resolve(DiscussionRouter.self)!, @@ -587,7 +585,7 @@ class ScreenAssembly: Assembly { ) } - container.register(ResponsesViewModel.self) { r, subject in + container.register(ResponsesViewModel.self) { @MainActor r, subject in ResponsesViewModel( interactor: r.resolve(DiscussionInteractorProtocol.self)!, router: r.resolve(DiscussionRouter.self)!, @@ -597,7 +595,7 @@ class ScreenAssembly: Assembly { ) } - container.register(CreateNewThreadViewModel.self) { r in + container.register(CreateNewThreadViewModel.self) { @MainActor r in CreateNewThreadViewModel( interactor: r.resolve(DiscussionInteractorProtocol.self)!, router: r.resolve(DiscussionRouter.self)!, @@ -605,6 +603,20 @@ class ScreenAssembly: Assembly { ) } + container.register(CourseRepositoryProtocol.self) { r in + CourseRepository( + api: r.resolve(API.self)!, + coreStorage: r.resolve(CoreStorage.self)!, + config: r.resolve(ConfigProtocol.self)!, + persistence: r.resolve(CoursePersistenceProtocol.self)! + ) + } + container.register(CourseInteractorProtocol.self) { r in + CourseInteractor( + repository: r.resolve(CourseRepositoryProtocol.self)! + ) + } + container.register(BackNavigationProtocol.self) { r in r.resolve(Router.self)! } diff --git a/OpenEdX/Data/AppStorage.swift b/OpenEdX/Data/AppStorage.swift index 2d595cd0d..e5076de13 100644 --- a/OpenEdX/Data/AppStorage.swift +++ b/OpenEdX/Data/AppStorage.swift @@ -13,10 +13,10 @@ import WhatsNew import Course import Theme -public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseStorage { +public final class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseStorage { - private let keychain: KeychainSwift - private let userDefaults: UserDefaults + private nonisolated(unsafe) let keychain: KeychainSwift + private nonisolated(unsafe) let userDefaults: UserDefaults public init(keychain: KeychainSwift, userDefaults: UserDefaults) { self.keychain = keychain diff --git a/OpenEdX/Data/CorePersistence.swift b/OpenEdX/Data/CorePersistence.swift index 80ffcf83c..d77ab2a7e 100644 --- a/OpenEdX/Data/CorePersistence.swift +++ b/OpenEdX/Data/CorePersistence.swift @@ -8,10 +8,11 @@ import Core import OEXFoundation import Foundation -import CoreData -import Combine +@preconcurrency import CoreData +@preconcurrency import Combine -public class CorePersistence: CorePersistenceProtocol { +public final class CorePersistence: CorePersistenceProtocol { + struct CorePersistenceHelper { static func fetchCDDownloadData( predicate: CDPredicate? = nil, @@ -57,11 +58,12 @@ public class CorePersistence: CorePersistenceProtocol { // MARK: - Properties - private var context: NSManagedObjectContext - private var userId: Int? + private nonisolated(unsafe) var userId: Int? - public init(context: NSManagedObjectContext) { - self.context = context + private let container: NSPersistentContainer + + public init(container: NSPersistentContainer) { + self.container = container } public func set(userId: Int) { @@ -79,14 +81,12 @@ public class CorePersistence: CorePersistenceProtocol { downloadQuality: DownloadQuality ) async { let userId = getUserId32() ?? 0 - for block in blocks { - let downloadDataId = downloadDataId(from: block.id) - - await context.perform { [weak self] in - guard let self else { return } + await container.performBackgroundTask { context in + for block in blocks { + let downloadDataId = self.downloadDataId(from: block.id) let data = try? CorePersistenceHelper.fetchCDDownloadData( predicate: CDPredicate.id(downloadDataId), - context: self.context, + context: context, userId: userId ) guard data?.first == nil else { return } @@ -105,7 +105,7 @@ public class CorePersistence: CorePersistenceProtocol { let folderUrl = URL(string: folderName)?.deletingPathExtension() { fileName = folderUrl.absoluteString } - saveDownloadData() + saveDownloadData(context) } else if let encodedVideo = block.encodedVideo, let video = encodedVideo.video(downloadQuality: downloadQuality), let videoUrl = video.url { @@ -115,10 +115,10 @@ public class CorePersistence: CorePersistenceProtocol { } fileExtension = URL(string: videoUrl)?.pathExtension fileName = "\(block.id).\(fileExtension ?? "")" - saveDownloadData() + saveDownloadData(context) } else { return } - func saveDownloadData() { + func saveDownloadData(_ context: NSManagedObjectContext) { let newDownloadData = CDDownloadData(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump newDownloadData.id = downloadDataId @@ -140,12 +140,18 @@ public class CorePersistence: CorePersistenceProtocol { newDownloadData.fileSize = Int32(fileSize ?? 0) } } + + do { + try context.save() + } catch { + debugLog("⛔️⛔️⛔️⛔️⛔️", error) + } } } - public func addToDownloadQueue(tasks: [DownloadDataTask]) { + public func addToDownloadQueue(tasks: [DownloadDataTask]) async { + await container.performBackgroundTask { context in for task in tasks { - context.performAndWait { let newDownloadData = CDDownloadData(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump newDownloadData.id = task.id @@ -167,7 +173,7 @@ public class CorePersistence: CorePersistenceProtocol { public func getDownloadDataTasks() async -> [DownloadDataTask] { let userId = getUserId32() ?? 0 - return await context.perform {[context] in + return await container.performBackgroundTask { context in guard let data = try? CorePersistenceHelper.fetchCDDownloadData( context: context, userId: userId @@ -186,7 +192,7 @@ public class CorePersistence: CorePersistenceProtocol { ) async -> [DownloadDataTask] { let uID = userId let int32Id = getUserId32() - return await context.perform {[context] in + return await container.performBackgroundTask { context in guard let data = try? CorePersistenceHelper.fetchCDDownloadData( predicate: .courseId(courseId), context: context, @@ -207,10 +213,10 @@ public class CorePersistence: CorePersistenceProtocol { } } - public func downloadDataTask(for blockId: String) -> DownloadDataTask? { + public func downloadDataTask(for blockId: String) async -> DownloadDataTask? { let dataId = downloadDataId(from: blockId) let userId = getUserId32() - return context.performAndWait {[context] in + return await container.performBackgroundTask { context in let data = try? CorePersistenceHelper.fetchCDDownloadData( predicate: .id(dataId), context: context, @@ -227,7 +233,7 @@ public class CorePersistence: CorePersistenceProtocol { public func nextBlockForDownloading() async -> DownloadDataTask? { let userId = getUserId32() - return await context.perform {[context] in + return await container.performBackgroundTask { context in let data = try? CorePersistenceHelper.fetchCDDownloadData( predicate: .state(DownloadState.finished.rawValue), fetchLimit: 1, @@ -247,10 +253,10 @@ public class CorePersistence: CorePersistenceProtocol { id: String, state: DownloadState, resumeData: Data? - ) { + ) async { let dataId = downloadDataId(from: id) let userId = getUserId32() - context.perform {[context] in + return await container.performBackgroundTask { context in guard let data = try? CorePersistenceHelper.fetchCDDownloadData( predicate: .id(dataId), context: context, @@ -276,7 +282,7 @@ public class CorePersistence: CorePersistenceProtocol { public func deleteDownloadDataTask(id: String) async throws { let dataId = downloadDataId(from: id) let userId = getUserId32() - return await context.perform {[context] in + await container.performBackgroundTask { context in do { let records = try CorePersistenceHelper.fetchCDDownloadData( predicate: .id(dataId), @@ -296,8 +302,8 @@ public class CorePersistence: CorePersistenceProtocol { } } - public func saveDownloadDataTask(_ task: DownloadDataTask) { - context.perform {[context] in + public func saveDownloadDataTask(_ task: DownloadDataTask) async { + await container.performBackgroundTask { context in let newDownloadData = CDDownloadData(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump newDownloadData.id = task.id @@ -321,9 +327,9 @@ public class CorePersistence: CorePersistenceProtocol { } } - public func publisher() -> AnyPublisher { + public func publisher() throws -> AnyPublisher { let notification = NSManagedObjectContext.didChangeObjectsNotification - return NotificationCenter.default.publisher(for: notification, object: context) + return NotificationCenter.default.publisher(for: notification, object: container.viewContext) .compactMap({ notification in guard let userInfo = notification.userInfo else { return nil } @@ -345,8 +351,8 @@ public class CorePersistence: CorePersistenceProtocol { } // MARK: - Offline Progress - public func saveOfflineProgress(progress: OfflineProgress) { - context.performAndWait { + public func saveOfflineProgress(progress: OfflineProgress) async { + await container.performBackgroundTask { context in let progressForSaving = CDOfflineProgress(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump progressForSaving.blockID = progress.blockID @@ -360,8 +366,8 @@ public class CorePersistence: CorePersistenceProtocol { } } - public func loadProgress(for blockID: String) -> OfflineProgress? { - context.performAndWait { + public func loadProgress(for blockID: String) async -> OfflineProgress? { + return await container.performBackgroundTask { context in let request = CDOfflineProgress.fetchRequest() request.predicate = NSPredicate(format: "blockID = %@", blockID) guard let progress = try? context.fetch(request).first, @@ -375,8 +381,8 @@ public class CorePersistence: CorePersistenceProtocol { } } - public func loadAllOfflineProgress() -> [OfflineProgress] { - context.performAndWait { + public func loadAllOfflineProgress() async -> [OfflineProgress] { + return await container.performBackgroundTask { context in let result = try? context.fetch(CDOfflineProgress.fetchRequest()) .map { OfflineProgress( @@ -390,8 +396,8 @@ public class CorePersistence: CorePersistenceProtocol { } } - public func deleteProgress(for blockID: String) { - context.performAndWait { + public func deleteProgress(for blockID: String) async { + return await container.performBackgroundTask { context in let request = CDOfflineProgress.fetchRequest() request.predicate = NSPredicate(format: "blockID = %@", blockID) guard let progress = try? context.fetch(request).first else { return } @@ -406,8 +412,8 @@ public class CorePersistence: CorePersistenceProtocol { } } - public func deleteAllProgress() { - context.performAndWait { + public func deleteAllProgress() async { + return await container.performBackgroundTask { context in let request = CDOfflineProgress.fetchRequest() guard let allProgress = try? context.fetch(request) else { return } @@ -425,35 +431,6 @@ public class CorePersistence: CorePersistenceProtocol { // MARK: - Private Intents - private func fetchCDDownloadData( - predicate: CDPredicate? = nil, - fetchLimit: Int? = nil - ) throws -> [CDDownloadData] { - let request = CDDownloadData.fetchRequest() - - var predicates = [NSPredicate]() - - if let predicate = predicate { - predicates.append(predicate.predicate) - } - - if let userId = getUserId32() { - let userIdNumber = NSNumber(value: userId) - let userIdPredicate = NSPredicate(format: "userId == %@", userIdNumber) - predicates.append(userIdPredicate) - } - - if !predicates.isEmpty { - request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) - } - - if let fetchLimit = fetchLimit { - request.fetchLimit = fetchLimit - } - - return try context.fetch(request) - } - private func getUserId32() -> Int32? { guard let userId else { return nil diff --git a/OpenEdX/Data/CoursePersistence.swift b/OpenEdX/Data/CoursePersistence.swift index 4115de9f2..f2f10d2eb 100644 --- a/OpenEdX/Data/CoursePersistence.swift +++ b/OpenEdX/Data/CoursePersistence.swift @@ -6,20 +6,21 @@ // import Foundation -import CoreData +import OEXFoundation +@preconcurrency import CoreData import Course import Core -public class CoursePersistence: CoursePersistenceProtocol { +public final class CoursePersistence: CoursePersistenceProtocol { - private var context: NSManagedObjectContext + private let container: NSPersistentContainer - public init(context: NSManagedObjectContext) { - self.context = context + public init(container: NSPersistentContainer) { + self.container = container } public func loadEnrollments() async throws -> [CourseItem] { - try await context.perform { [context] in + return try await container.performBackgroundTask { context in let result = try? context.fetch(CDCourseItem.fetchRequest()) .map { CourseItem(name: $0.name ?? "", @@ -46,8 +47,8 @@ public class CoursePersistence: CoursePersistenceProtocol { } } - public func saveEnrollments(items: [CourseItem]) { - context.perform {[context] in + public func saveEnrollments(items: [CourseItem]) async { + await container.performBackgroundTask { context in for item in items { let newItem = CDCourseItem(context: context) newItem.name = item.name @@ -62,20 +63,19 @@ public class CoursePersistence: CoursePersistenceProtocol { newItem.numPages = Int32(item.numPages) newItem.courseID = item.courseID newItem.courseCount = Int32(item.coursesCount) - - do { - try context.save() - } catch { - print("⛔️⛔️⛔️⛔️⛔️", error) - } + } + do { + try context.save() + } catch { + debugLog(error) } } } public func loadCourseStructure(courseID: String) async throws -> DataLayer.CourseStructure { - try await context.perform {[context] in - let request = CDCourseStructure.fetchRequest() - request.predicate = NSPredicate(format: "id = %@", courseID) + let request = CDCourseStructure.fetchRequest() + request.predicate = NSPredicate(format: "id = %@", courseID) + return try await container.performBackgroundTask { context in guard let structure = try? context.fetch(request).first else { throw NoCachedDataError() } let requestBlocks = CDCourseBlock.fetchRequest() @@ -163,13 +163,12 @@ public class CoursePersistence: CoursePersistenceProtocol { ) ) } - } - - public func saveCourseStructure(structure: DataLayer.CourseStructure) { - context.perform {[context] in + + public func saveCourseStructure(structure: DataLayer.CourseStructure) async { + await container.performBackgroundTask { context in context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - let newStructure = CDCourseStructure(context: self.context) + let newStructure = CDCourseStructure(context: context) newStructure.certificate = structure.certificate?.url newStructure.mediaSmall = structure.media.image.small newStructure.mediaLarge = structure.media.image.large @@ -179,9 +178,8 @@ public class CoursePersistence: CoursePersistenceProtocol { newStructure.isSelfPaced = structure.isSelfPaced newStructure.totalAssignmentsCount = Int32(structure.courseProgress?.totalAssignmentsCount ?? 0) newStructure.assignmentsCompleted = Int32(structure.courseProgress?.assignmentsCompleted ?? 0) - for block in Array(structure.dict.values) { - let courseDetail = CDCourseBlock(context: self.context) + let courseDetail = CDCourseBlock(context: context) courseDetail.allSources = block.allSources courseDetail.descendants = block.descendants courseDetail.graded = block.graded @@ -205,79 +203,70 @@ public class CoursePersistence: CoursePersistenceProtocol { if let due = block.due { courseDetail.due = due } - if let offlineDownload = block.offlineDownload, - let fileSize = offlineDownload.fileSize, - let fileUrl = offlineDownload.fileUrl, - let lastModified = offlineDownload.lastModified { + let fileSize = offlineDownload.fileSize, + let fileUrl = offlineDownload.fileUrl, + let lastModified = offlineDownload.lastModified { courseDetail.fileSize = Int64(fileSize) courseDetail.fileUrl = fileUrl courseDetail.lastModified = lastModified } - if block.userViewData?.encodedVideo?.youTube != nil { - let youTube = CDCourseBlockVideo(context: self.context) + let youTube = CDCourseBlockVideo(context: context) youTube.url = block.userViewData?.encodedVideo?.youTube?.url youTube.fileSize = Int32(block.userViewData?.encodedVideo?.youTube?.fileSize ?? 0) youTube.streamPriority = Int32(block.userViewData?.encodedVideo?.youTube?.streamPriority ?? 0) courseDetail.youTube = youTube } - if block.userViewData?.encodedVideo?.fallback != nil { - let fallback = CDCourseBlockVideo(context: self.context) + let fallback = CDCourseBlockVideo(context: context) fallback.url = block.userViewData?.encodedVideo?.fallback?.url fallback.fileSize = Int32(block.userViewData?.encodedVideo?.fallback?.fileSize ?? 0) fallback.streamPriority = Int32(block.userViewData?.encodedVideo?.fallback?.streamPriority ?? 0) courseDetail.fallback = fallback } - if block.userViewData?.encodedVideo?.desktopMP4 != nil { - let desktopMP4 = CDCourseBlockVideo(context: self.context) + let desktopMP4 = CDCourseBlockVideo(context: context) desktopMP4.url = block.userViewData?.encodedVideo?.desktopMP4?.url desktopMP4.fileSize = Int32(block.userViewData?.encodedVideo?.desktopMP4?.fileSize ?? 0) desktopMP4.streamPriority = Int32(block.userViewData?.encodedVideo?.desktopMP4?.streamPriority ?? 0) courseDetail.desktopMP4 = desktopMP4 } - if block.userViewData?.encodedVideo?.mobileHigh != nil { - let mobileHigh = CDCourseBlockVideo(context: self.context) + let mobileHigh = CDCourseBlockVideo(context: context) mobileHigh.url = block.userViewData?.encodedVideo?.mobileHigh?.url mobileHigh.fileSize = Int32(block.userViewData?.encodedVideo?.mobileHigh?.fileSize ?? 0) mobileHigh.streamPriority = Int32(block.userViewData?.encodedVideo?.mobileHigh?.streamPriority ?? 0) courseDetail.mobileHigh = mobileHigh } - if block.userViewData?.encodedVideo?.mobileLow != nil { - let mobileLow = CDCourseBlockVideo(context: self.context) + let mobileLow = CDCourseBlockVideo(context: context) mobileLow.url = block.userViewData?.encodedVideo?.mobileLow?.url mobileLow.fileSize = Int32(block.userViewData?.encodedVideo?.mobileLow?.fileSize ?? 0) mobileLow.streamPriority = Int32(block.userViewData?.encodedVideo?.mobileLow?.streamPriority ?? 0) courseDetail.mobileLow = mobileLow } - if block.userViewData?.encodedVideo?.hls != nil { - let hls = CDCourseBlockVideo(context: self.context) + let hls = CDCourseBlockVideo(context: context) hls.url = block.userViewData?.encodedVideo?.hls?.url hls.fileSize = Int32(block.userViewData?.encodedVideo?.hls?.fileSize ?? 0) hls.streamPriority = Int32(block.userViewData?.encodedVideo?.hls?.streamPriority ?? 0) courseDetail.hls = hls } - if let transcripts = block.userViewData?.transcripts { courseDetail.transcripts = transcripts.toJson() } - - do { - try context.save() - } catch { - print("⛔️⛔️⛔️⛔️⛔️", error) - } + } + do { + try context.save() + } catch { + debugLog(error) } } } - public func saveSubtitles(url: String, subtitlesString: String) { - context.perform {[context] in + public func saveSubtitles(url: String, subtitlesString: String) async { + await container.performBackgroundTask { context in let newSubtitle = CDSubtitle(context: context) newSubtitle.url = url newSubtitle.subtitle = subtitlesString @@ -286,16 +275,15 @@ public class CoursePersistence: CoursePersistenceProtocol { do { try context.save() } catch { - print("⛔️⛔️⛔️⛔️⛔️", error) + debugLog(error) } } } public func loadSubtitles(url: String) async -> String? { - await context.perform {[context] in - let request = CDSubtitle.fetchRequest() - request.predicate = NSPredicate(format: "url = %@", url) - + let request = CDSubtitle.fetchRequest() + request.predicate = NSPredicate(format: "url = %@", url) + return await container.performBackgroundTask { context in guard let subtitle = try? context.fetch(request).first, let loaded = subtitle.uploadedAt else { return nil } if Date().timeIntervalSince1970 - loaded.timeIntervalSince1970 < 5 * 3600 { @@ -305,11 +293,11 @@ public class CoursePersistence: CoursePersistenceProtocol { } } - public func saveCourseDates(courseID: String, courseDates: CourseDates) { + public func saveCourseDates(courseID: String, courseDates: CourseDates) async { } - public func loadCourseDates(courseID: String) throws -> CourseDates { + public func loadCourseDates(courseID: String) async throws -> CourseDates { throw NoCachedDataError() } } diff --git a/OpenEdX/Data/DashboardPersistence.swift b/OpenEdX/Data/DashboardPersistence.swift index 9cac9921b..668c1beaa 100644 --- a/OpenEdX/Data/DashboardPersistence.swift +++ b/OpenEdX/Data/DashboardPersistence.swift @@ -8,18 +8,18 @@ import Dashboard import Core import Foundation -import CoreData +@preconcurrency import CoreData -public class DashboardPersistence: DashboardPersistenceProtocol { +public final class DashboardPersistence: DashboardPersistenceProtocol { - private var context: NSManagedObjectContext + private let container: NSPersistentContainer - public init(context: NSManagedObjectContext) { - self.context = context + public init(container: NSPersistentContainer) { + self.container = container } public func loadEnrollments() async throws -> [CourseItem] { - try await context.perform {[context] in + return try await container.performBackgroundTask { context in let result = try? context.fetch(CDDashboardCourse.fetchRequest()) .map { CourseItem(name: $0.name ?? "", org: $0.org ?? "", @@ -44,10 +44,10 @@ public class DashboardPersistence: DashboardPersistenceProtocol { } } - public func saveEnrollments(items: [CourseItem]) { - for item in items { - context.perform {[context] in - let newItem = CDDashboardCourse(context: self.context) + public func saveEnrollments(items: [CourseItem]) async { + await container.performBackgroundTask { context in + for item in items { + let newItem = CDDashboardCourse(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump newItem.name = item.name newItem.org = item.org @@ -61,22 +61,21 @@ public class DashboardPersistence: DashboardPersistenceProtocol { newItem.numPages = Int32(item.numPages) newItem.courseID = item.courseID newItem.courseRawImage = item.courseRawImage - - do { - try context.save() - } catch { - print("⛔️⛔️⛔️⛔️⛔️", error) - } + } + do { + try context.save() + } catch { + print("⛔️⛔️⛔️⛔️⛔️", error) } } } public func loadPrimaryEnrollment() async throws -> PrimaryEnrollment { - try await context.perform {[context] in - let request = CDMyEnrollments.fetchRequest() + let request = CDMyEnrollments.fetchRequest() + return try await container.performBackgroundTask { context in if let result = try context.fetch(request).first { let primaryCourse = result.primaryCourse.flatMap { cdPrimaryCourse -> PrimaryCourse? in - + let futureAssignments = (cdPrimaryCourse.futureAssignments as? Set ?? []) .map { future in return Assignment( @@ -100,7 +99,7 @@ public class DashboardPersistence: DashboardPersistenceProtocol { firstComponentBlockId: past.firstComponentBlockId ) } - + return PrimaryCourse( name: cdPrimaryCourse.name ?? "", org: cdPrimaryCourse.org ?? "", @@ -117,7 +116,7 @@ public class DashboardPersistence: DashboardPersistenceProtocol { resumeTitle: cdPrimaryCourse.resumeTitle ) } - + let courses = (result.courses as? Set ?? []) .map { cdCourse in return CourseItem( @@ -138,7 +137,7 @@ public class DashboardPersistence: DashboardPersistenceProtocol { progressPossible: Int(cdCourse.progressPossible) ) } - + return PrimaryEnrollment( primaryCourse: primaryCourse, courses: courses, @@ -152,16 +151,16 @@ public class DashboardPersistence: DashboardPersistenceProtocol { } // swiftlint:disable function_body_length - public func savePrimaryEnrollment(enrollments: PrimaryEnrollment) { + public func savePrimaryEnrollment(enrollments: PrimaryEnrollment) async { // Deleting all old data before saving new ones - clearOldEnrollmentsData() - context.perform {[context] in + await clearOldEnrollmentsData() + await container.performBackgroundTask { context in let newEnrollment = CDMyEnrollments(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - + // Saving new courses newEnrollment.courses = NSSet(array: enrollments.courses.map { course in - let cdCourse = CDDashboardCourse(context: self.context) + let cdCourse = CDDashboardCourse(context: context) cdCourse.name = course.name cdCourse.org = course.org cdCourse.desc = course.shortDescription @@ -177,13 +176,13 @@ public class DashboardPersistence: DashboardPersistenceProtocol { cdCourse.progressPossible = Int32(course.progressPossible) return cdCourse }) - + // Saving PrimaryCourse if let primaryCourse = enrollments.primaryCourse { let cdPrimaryCourse = CDPrimaryCourse(context: context) let futureAssignments = primaryCourse.futureAssignments.map { assignment in - let cdAssignment = CDAssignment(context: self.context) + let cdAssignment = CDAssignment(context: context) cdAssignment.type = assignment.type cdAssignment.title = assignment.title cdAssignment.descript = assignment.description @@ -195,7 +194,7 @@ public class DashboardPersistence: DashboardPersistenceProtocol { cdPrimaryCourse.futureAssignments = NSSet(array: futureAssignments) let pastAssignments = primaryCourse.pastAssignments.map { assignment in - let cdAssignment = CDAssignment(context: self.context) + let cdAssignment = CDAssignment(context: context) cdAssignment.type = assignment.type cdAssignment.title = assignment.title cdAssignment.descript = assignment.description @@ -217,13 +216,13 @@ public class DashboardPersistence: DashboardPersistenceProtocol { cdPrimaryCourse.progressPossible = Int32(primaryCourse.progressPossible) cdPrimaryCourse.lastVisitedBlockID = primaryCourse.lastVisitedBlockID cdPrimaryCourse.resumeTitle = primaryCourse.resumeTitle - + newEnrollment.primaryCourse = cdPrimaryCourse } - + newEnrollment.totalPages = Int32(enrollments.totalPages) newEnrollment.count = Int32(enrollments.count) - + do { try context.save() } catch { @@ -233,8 +232,8 @@ public class DashboardPersistence: DashboardPersistenceProtocol { } // swiftlint:enable function_body_length - func clearOldEnrollmentsData() { - context.performAndWait {[context] in + func clearOldEnrollmentsData() async { + await container.performBackgroundTask { context in let fetchRequest1: NSFetchRequest = CDDashboardCourse.fetchRequest() let batchDeleteRequest1 = NSBatchDeleteRequest(fetchRequest: fetchRequest1) diff --git a/OpenEdX/Data/DatabaseManager.swift b/OpenEdX/Data/DatabaseManager.swift index 57cd98d45..ae59d95ce 100644 --- a/OpenEdX/Data/DatabaseManager.swift +++ b/OpenEdX/Data/DatabaseManager.swift @@ -6,14 +6,14 @@ // import Foundation -import CoreData +@preconcurrency import CoreData import Core import Discovery import Dashboard import Course import Profile -class DatabaseManager: CoreDataHandlerProtocol { +final class DatabaseManager: CoreDataHandlerProtocol { private let databaseName: String @@ -24,14 +24,15 @@ class DatabaseManager: CoreDataHandlerProtocol { Bundle(for: CourseBundle.self), Bundle(for: ProfileBundle.self) ] - - private lazy var persistentContainer: NSPersistentContainer = { - return createContainer() - }() + + private nonisolated(unsafe) var persistentContainer: NSPersistentContainer? - public lazy var context: NSManagedObjectContext = { - return createContext() - }() + public func getPersistentContainer() -> NSPersistentContainer { + if persistentContainer == nil { + persistentContainer = createContainer() + } + return persistentContainer! + } init(databaseName: String) { self.databaseName = databaseName @@ -56,13 +57,13 @@ class DatabaseManager: CoreDataHandlerProtocol { } private func createContext() -> NSManagedObjectContext { - let context = persistentContainer.newBackgroundContext() + let context = getPersistentContainer().newBackgroundContext() context.automaticallyMergesChangesFromParent = true return context } public func clear() { - let storeContainer = persistentContainer.persistentStoreCoordinator + let storeContainer = getPersistentContainer().persistentStoreCoordinator for store in storeContainer.persistentStores { do { try storeContainer.destroyPersistentStore( @@ -76,7 +77,7 @@ class DatabaseManager: CoreDataHandlerProtocol { } // Re-create the persistent container - persistentContainer.loadPersistentStores { _, error in + getPersistentContainer().loadPersistentStores { _, error in if let error = error { print("Unresolved error \(error)") fatalError() diff --git a/OpenEdX/Data/DiscoveryPersistence.swift b/OpenEdX/Data/DiscoveryPersistence.swift index 282b107ee..0fadd5ed1 100644 --- a/OpenEdX/Data/DiscoveryPersistence.swift +++ b/OpenEdX/Data/DiscoveryPersistence.swift @@ -7,19 +7,19 @@ import Foundation import Discovery -import CoreData +@preconcurrency import CoreData import Core -public class DiscoveryPersistence: DiscoveryPersistenceProtocol { +public final class DiscoveryPersistence: DiscoveryPersistenceProtocol { - private var context: NSManagedObjectContext + private let container: NSPersistentContainer - public init(context: NSManagedObjectContext) { - self.context = context + public init(container: NSPersistentContainer) { + self.container = container } public func loadDiscovery() async throws -> [CourseItem] { - try await context.perform {[context] in + return try await container.performBackgroundTask { context in let result = try? context.fetch(CDDiscoveryCourse.fetchRequest()) .map { CourseItem(name: $0.name ?? "", org: $0.org ?? "", @@ -44,9 +44,9 @@ public class DiscoveryPersistence: DiscoveryPersistenceProtocol { } } - public func saveDiscovery(items: [CourseItem]) { - for item in items { - context.perform {[context] in + public func saveDiscovery(items: [CourseItem]) async { + await container.performBackgroundTask { context in + for item in items { let newItem = CDDiscoveryCourse(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump newItem.name = item.name @@ -61,20 +61,19 @@ public class DiscoveryPersistence: DiscoveryPersistenceProtocol { newItem.numPages = Int32(item.numPages) newItem.courseID = item.courseID newItem.courseRawImage = item.courseRawImage - - do { - try context.save() - } catch { - print("⛔️⛔️⛔️⛔️⛔️", error) - } + } + do { + try context.save() + } catch { + print("⛔️⛔️⛔️⛔️⛔️", error) } } } public func loadCourseDetails(courseID: String) async throws -> CourseDetails { - try await context.perform {[context] in - let request = CDCourseDetails.fetchRequest() - request.predicate = NSPredicate(format: "courseID = %@", courseID) + let request = CDCourseDetails.fetchRequest() + request.predicate = NSPredicate(format: "courseID = %@", courseID) + return try await container.performBackgroundTask { context in guard let courseDetails = try? context.fetch(request).first else { throw NoCachedDataError() } return CourseDetails( courseID: courseDetails.courseID ?? "", @@ -94,9 +93,9 @@ public class DiscoveryPersistence: DiscoveryPersistenceProtocol { } } - public func saveCourseDetails(course: CourseDetails) { - context.perform {[context] in - let newCourseDetails = CDCourseDetails(context: self.context) + public func saveCourseDetails(course: CourseDetails) async { + await container.performBackgroundTask { context in + let newCourseDetails = CDCourseDetails(context: context) newCourseDetails.courseID = course.courseID newCourseDetails.org = course.org newCourseDetails.courseTitle = course.courseTitle diff --git a/OpenEdX/Data/Network/NotificationsEndpoints.swift b/OpenEdX/Data/Network/NotificationsEndpoints.swift index 4af73fb69..54499b5e6 100644 --- a/OpenEdX/Data/Network/NotificationsEndpoints.swift +++ b/OpenEdX/Data/Network/NotificationsEndpoints.swift @@ -35,7 +35,7 @@ enum NotificationsEndpoints: EndPointType { var task: HTTPTask { switch self { case let .syncFirebaseToken(token): - let params: [String: Encodable] = [ + let params: [String: Encodable & Sendable] = [ "registration_id": token, "active": true ] diff --git a/OpenEdX/Data/ProfilePersistence.swift b/OpenEdX/Data/ProfilePersistence.swift index afc4f28ec..e594b74ae 100644 --- a/OpenEdX/Data/ProfilePersistence.swift +++ b/OpenEdX/Data/ProfilePersistence.swift @@ -9,18 +9,18 @@ import Profile import Core import OEXFoundation import Foundation -import CoreData +@preconcurrency import CoreData -public class ProfilePersistence: ProfilePersistenceProtocol { +public final class ProfilePersistence: ProfilePersistenceProtocol { - private var context: NSManagedObjectContext + private let container: NSPersistentContainer - public init(context: NSManagedObjectContext) { - self.context = context + public init(container: NSPersistentContainer) { + self.container = container } - public func getCourseState(courseID: String) -> CourseCalendarState? { - context.performAndWait { + public func getCourseState(courseID: String) async -> CourseCalendarState? { + return await container.performBackgroundTask { context in let fetchRequest: NSFetchRequest = CDCourseCalendarState.fetchRequest() fetchRequest.predicate = NSPredicate(format: "courseID == %@", courseID) do { @@ -36,13 +36,12 @@ public class ProfilePersistence: ProfilePersistenceProtocol { } } - public func getAllCourseStates() -> [CourseCalendarState] { - var states: [CourseCalendarState] = [] - context.performAndWait { + public func getAllCourseStates() async -> [CourseCalendarState] { + return await container.performBackgroundTask { context in let fetchRequest: NSFetchRequest = CDCourseCalendarState.fetchRequest() do { let results = try context.fetch(fetchRequest) - states = results.compactMap { result in + return results.compactMap { result in if let courseID = result.courseID, let checksum = result.checksum { return CourseCalendarState(courseID: courseID, checksum: checksum) } @@ -50,13 +49,13 @@ public class ProfilePersistence: ProfilePersistenceProtocol { } } catch { debugLog("⛔️ Error fetching CourseCalendarEvents: \(error)") + return [] } } - return states } - public func saveCourseState(state: CourseCalendarState) { - context.performAndWait { + public func saveCourseState(state: CourseCalendarState) async { + await container.performBackgroundTask { context in let newState = CDCourseCalendarState(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump newState.courseID = state.courseID @@ -69,10 +68,11 @@ public class ProfilePersistence: ProfilePersistenceProtocol { } } - public func removeCourseState(courseID: String) { - context.performAndWait { + public func removeCourseState(courseID: String) async { + await container.performBackgroundTask { context in let fetchRequest: NSFetchRequest = CDCourseCalendarState.fetchRequest() fetchRequest.predicate = NSPredicate(format: "courseID == %@", courseID) + do { if let result = try context.fetch(fetchRequest).first { if let object = result as? NSManagedObject { @@ -86,23 +86,25 @@ public class ProfilePersistence: ProfilePersistenceProtocol { } } - public func deleteAllCourseStatesAndEvents() { - let fetchRequestCalendarStates: NSFetchRequest = CDCourseCalendarState.fetchRequest() - let deleteRequestCalendarStates = NSBatchDeleteRequest(fetchRequest: fetchRequestCalendarStates) - let fetchRequestCalendarEvents: NSFetchRequest = CDCourseCalendarEvent.fetchRequest() - let deleteRequestCalendarEvents = NSBatchDeleteRequest(fetchRequest: fetchRequestCalendarEvents) - - do { - try context.execute(deleteRequestCalendarStates) - try context.execute(deleteRequestCalendarEvents) - try context.save() - } catch { - debugLog("⛔️⛔️⛔️⛔️⛔️", error) + public func deleteAllCourseStatesAndEvents() async { + await container.performBackgroundTask { context in + let fetchRequestCalendarStates: NSFetchRequest = CDCourseCalendarState.fetchRequest() + let deleteRequestCalendarStates = NSBatchDeleteRequest(fetchRequest: fetchRequestCalendarStates) + let fetchRequestCalendarEvents: NSFetchRequest = CDCourseCalendarEvent.fetchRequest() + let deleteRequestCalendarEvents = NSBatchDeleteRequest(fetchRequest: fetchRequestCalendarEvents) + + do { + try context.execute(deleteRequestCalendarStates) + try context.execute(deleteRequestCalendarEvents) + try context.save() + } catch { + debugLog("⛔️⛔️⛔️⛔️⛔️", error) + } } } - public func saveCourseCalendarEvent(_ event: CourseCalendarEvent) { - context.performAndWait { + public func saveCourseCalendarEvent(_ event: CourseCalendarEvent) async { + await container.performBackgroundTask { context in let newEvent = CDCourseCalendarEvent(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump newEvent.courseID = event.courseID @@ -114,9 +116,9 @@ public class ProfilePersistence: ProfilePersistenceProtocol { } } } - - public func removeCourseCalendarEvents(for courseId: String) { - context.performAndWait { + + public func removeCourseCalendarEvents(for courseId: String) async { + await container.performBackgroundTask { context in let fetchRequest: NSFetchRequest = CDCourseCalendarEvent.fetchRequest() fetchRequest.predicate = NSPredicate(format: "courseID == %@", courseId) do { @@ -133,8 +135,8 @@ public class ProfilePersistence: ProfilePersistenceProtocol { } } - public func removeAllCourseCalendarEvents() { - context.performAndWait { + public func removeAllCourseCalendarEvents() async { + await container.performBackgroundTask { context in let fetchRequest: NSFetchRequest = CDCourseCalendarEvent.fetchRequest() let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) do { @@ -145,15 +147,14 @@ public class ProfilePersistence: ProfilePersistenceProtocol { } } } - - public func getCourseCalendarEvents(for courseId: String) -> [CourseCalendarEvent] { - var events: [CourseCalendarEvent] = [] - context.performAndWait { + + public func getCourseCalendarEvents(for courseId: String) async -> [CourseCalendarEvent] { + return await container.performBackgroundTask { context in let fetchRequest: NSFetchRequest = CDCourseCalendarEvent.fetchRequest() fetchRequest.predicate = NSPredicate(format: "courseID == %@", courseId) do { let results = try context.fetch(fetchRequest) - events = results.compactMap { result in + return results.compactMap { result in if let courseID = result.courseID, let eventIdentifier = result.eventIdentifier { return CourseCalendarEvent(courseID: courseID, eventIdentifier: eventIdentifier) } @@ -161,9 +162,8 @@ public class ProfilePersistence: ProfilePersistenceProtocol { } } catch { debugLog("⛔️ Error fetching CourseCalendarEvents: \(error)") + return [] } } - return events } - } diff --git a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift index f7bfa05b8..2487a8c7a 100644 --- a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift +++ b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift @@ -16,7 +16,6 @@ import Discussion import WhatsNew import Swinject import OEXFoundation -import OEXFirebaseAnalytics // swiftlint:disable type_body_length file_length class AnalyticsManager: AuthorizationAnalytics, diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift index 20262a0ef..8ac2cb107 100644 --- a/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift @@ -15,6 +15,7 @@ import Profile // swiftlint:disable function_body_length type_body_length //sourcery: AutoMockable +@MainActor public protocol DeepLinkService { func configureWith( manager: DeepLinkManager, @@ -29,6 +30,7 @@ public protocol DeepLinkService { ) -> Bool } +@MainActor public class DeepLinkManager { private var services: [DeepLinkService] = [] private let config: ConfigProtocol @@ -107,7 +109,7 @@ public class DeepLinkManager { Task { if isAppActive { - await showNotificationAlert(link) + showNotificationAlert(link) } else { await navigateToScreen(with: link.type, link: link) } @@ -124,8 +126,7 @@ public class DeepLinkManager { } } } - - @MainActor + private func showNotificationAlert(_ link: PushLink) { router.dismissPresentedViewController() diff --git a/OpenEdX/Managers/DeepLinkManager/Services/BranchService.swift b/OpenEdX/Managers/DeepLinkManager/Services/BranchService.swift index 6d1f27899..dc38e3dad 100644 --- a/OpenEdX/Managers/DeepLinkManager/Services/BranchService.swift +++ b/OpenEdX/Managers/DeepLinkManager/Services/BranchService.swift @@ -10,7 +10,7 @@ import UIKit import Core import BranchSDK -class BranchService: DeepLinkService { +@preconcurrency final class BranchService: DeepLinkService { // configure service func configureWith( manager: DeepLinkManager, diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index 6938ae9a2..fc848f04d 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -7,11 +7,12 @@ import Course import Core -import Combine +@preconcurrency import Combine import Discovery import SwiftUI -public class PipManager: PipManagerProtocol { +@MainActor +public final class PipManager: PipManagerProtocol { var controllerHolder: PlayerViewControllerHolderProtocol? let discoveryInteractor: DiscoveryInteractorProtocol let courseInteractor: CourseInteractorProtocol @@ -20,6 +21,7 @@ public class PipManager: PipManagerProtocol { public var isPipActive: Bool { controllerHolder != nil } + public var isPipPlaying: Bool { controllerHolder?.isPlaying ?? false } @@ -72,7 +74,6 @@ public class PipManager: PipManagerProtocol { controllerHolder?.getRatePublisher() } - @MainActor public func restore(holder: PlayerViewControllerHolderProtocol) async throws { let courseID = holder.courseID @@ -95,7 +96,6 @@ public class PipManager: PipManagerProtocol { holder.playerController?.pause() } - @MainActor private func navigate(to holder: PlayerViewControllerHolderProtocol) async throws { let currentControllers = router.getNavigationController().viewControllers guard let mainController = currentControllers.first as? UIHostingController else { @@ -123,13 +123,12 @@ public class PipManager: PipManagerProtocol { router.getNavigationController().setViewControllers(viewControllers, animated: true) } - @MainActor private func courseVerticalController( for holder: PlayerViewControllerHolderProtocol ) async throws -> UIHostingController { var courseStructure = try await courseInteractor.getLoadedCourseBlocks(courseID: holder.courseID) if holder.selectedCourseTab == CourseTab.videos.rawValue { - courseStructure = courseInteractor.getCourseVideoBlocks(fullStructure: courseStructure) + courseStructure = await courseInteractor.getCourseVideoBlocks(fullStructure: courseStructure) } if let data = VerticalData.dataFor(blockId: holder.blockID, in: courseStructure.childs) { @@ -146,14 +145,13 @@ public class PipManager: PipManagerProtocol { throw PipManagerError.cantCreateCourseVerticalView } - @MainActor private func courseUnitController( for holder: PlayerViewControllerHolderProtocol ) async throws -> UIHostingController { var courseStructure = try await courseInteractor.getLoadedCourseBlocks(courseID: holder.courseID) if holder.selectedCourseTab == CourseTab.videos.rawValue { - courseStructure = courseInteractor.getCourseVideoBlocks(fullStructure: courseStructure) + courseStructure = await courseInteractor.getCourseVideoBlocks(fullStructure: courseStructure) } if let data = VerticalData.dataFor(blockId: holder.blockID, in: courseStructure.childs) { let chapter = courseStructure.childs[data.chapterIndex] @@ -174,7 +172,6 @@ public class PipManager: PipManagerProtocol { throw PipManagerError.cantCreateCourseUnitView } - @MainActor private func containerController( for holder: PlayerViewControllerHolderProtocol ) async throws -> UIHostingController { diff --git a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift index 3ef708cb1..c0b08be6a 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift @@ -9,7 +9,7 @@ import Foundation import Swinject import OEXFoundation -class BrazeProvider: PushNotificationsProvider { +final class BrazeProvider: PushNotificationsProvider { func didRegisterWithDeviceToken(deviceToken: Data) { // Removed as part of the move to a plugin architecture, this code should be called from the plugin. diff --git a/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift b/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift index 71b0c82c3..3501e0008 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift @@ -11,9 +11,9 @@ import OEXFoundation import FirebaseCore import FirebaseMessaging -class FCMProvider: NSObject, PushNotificationsProvider, MessagingDelegate { +final class FCMProvider: NSObject, PushNotificationsProvider, MessagingDelegate { - private var storage: CoreStorage + private let storage: CoreStorage private let api: API init(storage: CoreStorage, api: API) { @@ -50,7 +50,8 @@ class FCMProvider: NSObject, PushNotificationsProvider, MessagingDelegate { } func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { - storage.pushToken = fcmToken + var localStorage = storage + localStorage.pushToken = fcmToken guard let fcmToken, storage.user != nil else { return } sendFCMToken(fcmToken) diff --git a/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift b/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift index 2f9c3c1b4..9bf9b0d8b 100644 --- a/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift +++ b/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift @@ -13,18 +13,20 @@ import UserNotifications import FirebaseCore import FirebaseMessaging -public protocol PushNotificationsProvider { +public protocol PushNotificationsProvider: Sendable { func didRegisterWithDeviceToken(deviceToken: Data) func didFailToRegisterForRemoteNotificationsWithError(error: Error) func synchronizeToken() func refreshToken() } -protocol PushNotificationsListener { +@MainActor +protocol PushNotificationsListener: Sendable { func shouldListenNotification(userinfo: [AnyHashable: Any]) -> Bool func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) } +@MainActor class PushNotificationsManager: NSObject { private let deepLinkManager: DeepLinkManager @@ -77,15 +79,18 @@ class PushNotificationsManager: NSObject { } // Register for push notifications - public func performRegistration() { - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { (granted, error) in + public func performRegistration() async { + do { + let granted = try await UNUserNotificationCenter.current().requestAuthorization( + options: [.alert, .sound, .badge] + ) if granted { debugLog("Permission for push notifications granted.") - } else if let error = error { - debugLog("Push notifications permission error: \(error.localizedDescription)") } else { debugLog("Permission for push notifications denied.") } + } catch { + debugLog("Push notifications permission error: \(error.localizedDescription)") } } @@ -120,7 +125,7 @@ class PushNotificationsManager: NSObject { } // MARK: - MessagingDelegate -extension PushNotificationsManager: MessagingDelegate { +extension PushNotificationsManager: @preconcurrency MessagingDelegate { func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { for provider in providers where provider is MessagingDelegate { (provider as? MessagingDelegate)?.messaging?(messaging, didReceiveRegistrationToken: fcmToken) @@ -129,8 +134,7 @@ extension PushNotificationsManager: MessagingDelegate { } // MARK: - UNUserNotificationCenterDelegate -extension PushNotificationsManager: UNUserNotificationCenterDelegate { - @MainActor +extension PushNotificationsManager: @preconcurrency UNUserNotificationCenterDelegate { func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification @@ -143,7 +147,6 @@ extension PushNotificationsManager: UNUserNotificationCenterDelegate { return [[.list, .banner, .sound]] } - @MainActor func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 13f99ed02..721dac5d3 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -383,8 +383,9 @@ public class Router: AuthorizationRouter, ) navigationController.pushViewController(controller, animated: true) - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - Container.shared.resolve(PushNotificationsManager.self)?.performRegistration() + Task { + try? await Task.sleep(for: .seconds(1)) + await Container.shared.resolve(PushNotificationsManager.self)?.performRegistration() } } @@ -506,7 +507,6 @@ public class Router: AuthorizationRouter, courseStructure: CourseStructure, blockLink: String) { var courseBlock: CourseBlock? - var courseName: String? var chapterPosition: Int? var sequentialPosition: Int? var verticalPosition: Int? @@ -517,7 +517,6 @@ public class Router: AuthorizationRouter, vertical.childs.forEach { block in if block.id == componentID { courseBlock = block - courseName = sequential.displayName chapterPosition = chapterIndex sequentialPosition = sequentialIndex verticalPosition = verticalIndex diff --git a/OpenEdX/View/MainScreenViewModel.swift b/OpenEdX/View/MainScreenViewModel.swift index 7c2d17f17..84fe435ab 100644 --- a/OpenEdX/View/MainScreenViewModel.swift +++ b/OpenEdX/View/MainScreenViewModel.swift @@ -20,6 +20,7 @@ public enum MainTab { case profile } +@MainActor final class MainScreenViewModel: ObservableObject { private let analytics: MainScreenAnalytics @@ -115,7 +116,7 @@ final class MainScreenViewModel: ObservableObject { @MainActor func prefetchDataForOffline() async { - if profileInteractor.getMyProfileOffline() == nil { + if await profileInteractor.getMyProfileOffline() == nil { _ = try? await profileInteractor.getMyProfile() } } @@ -157,7 +158,7 @@ extension MainScreenViewModel { for course in selectedCourses { if let courseDates = try? await profileInteractor.getCourseDates(courseID: course.courseID), - calendarManager.isDatesChanged(courseID: course.courseID, checksum: courseDates.checksum) { + await calendarManager.isDatesChanged(courseID: course.courseID, checksum: courseDates.checksum) { debugLog("Calendar needs update for courseID: \(course.courseID)") await calendarManager.removeOutdatedEvents(courseID: course.courseID) await calendarManager.syncCourse( @@ -173,13 +174,13 @@ extension MainScreenViewModel { } } else { appStorage.lastLoginUsername = username - calendarManager.clearAllData(removeCalendar: false) + await calendarManager.clearAllData(removeCalendar: false) } } private func updateCourseDates(courseID: String, courseName: String) async { if let courseDates = try? await profileInteractor.getCourseDates(courseID: courseID), - calendarManager.isDatesChanged(courseID: courseID, checksum: courseDates.checksum) { + await calendarManager.isDatesChanged(courseID: courseID, checksum: courseDates.checksum) { debugLog("Calendar update needed for courseID: \(courseID)") await calendarManager.removeOutdatedEvents(courseID: courseID) await calendarManager.syncCourse(courseID: courseID, courseName: courseName, dates: courseDates) diff --git a/Podfile b/Podfile index a207a2f84..6d75f7891 100644 --- a/Podfile +++ b/Podfile @@ -16,8 +16,6 @@ abstract_target "App" do target "Core" do project './Core/Core.xcodeproj' workspace './Core/Core.xcodeproj' - #Keychain - pod 'KeychainSwift', '~> 24.0' target 'CoreTests' do pod 'SwiftyMocky', :git => 'https://github.com/MakeAWishFoundation/SwiftyMocky.git', :tag => '4.2.0' diff --git a/Podfile.lock b/Podfile.lock index 32a960dfc..a14c3e35d 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,5 +1,4 @@ PODS: - - KeychainSwift (24.0.0) - Sourcery (1.8.0): - Sourcery/CLI-Only (= 1.8.0) - Sourcery/CLI-Only (1.8.0) @@ -9,14 +8,12 @@ PODS: - Sourcery (= 1.8.0) DEPENDENCIES: - - KeychainSwift (~> 24.0) - SwiftGen (~> 6.6) - SwiftLint (~> 0.57.0) - SwiftyMocky (from `https://github.com/MakeAWishFoundation/SwiftyMocky.git`, tag `4.2.0`) SPEC REPOS: trunk: - - KeychainSwift - Sourcery - SwiftGen - SwiftLint @@ -32,12 +29,11 @@ CHECKOUT OPTIONS: :tag: 4.2.0 SPEC CHECKSUMS: - KeychainSwift: 007c4647486e4563adca839cf02cef00deb3b670 Sourcery: 6f5fe49b82b7e02e8c65560cbd52e1be67a1af2e SwiftGen: 4993cbf71cbc4886f775e26f8d5c3a1188ec9f99 SwiftLint: eb47480d47c982481592c195c221d11013a679cc SwiftyMocky: c5e96e4ff76ec6dbf5a5941aeb039b5a546954a0 -PODFILE CHECKSUM: fe79196bcbd67eb66f3dd20e3a90c1210980722d +PODFILE CHECKSUM: 231e03216f446b4695e43a4c85a29e2127fd674b COCOAPODS: 1.15.2 diff --git a/Profile/Profile.xcodeproj/project.pbxproj b/Profile/Profile.xcodeproj/project.pbxproj index 2fc6aba89..063020b3b 100644 --- a/Profile/Profile.xcodeproj/project.pbxproj +++ b/Profile/Profile.xcodeproj/project.pbxproj @@ -59,8 +59,8 @@ 25B36FF48C1307888A3890DA /* Pods_App_Profile.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BEA369C38362C1A91A012F70 /* Pods_App_Profile.framework */; }; BAD9CA3F2B29BF5C00DE790A /* ProfileSupportInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA3E2B29BF5C00DE790A /* ProfileSupportInfoView.swift */; }; CE1735042CD23D7A00F9606A /* DatesAndCalendarViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1735032CD23D7A00F9606A /* DatesAndCalendarViewModelTests.swift */; }; - CE961F032CD163FD00799B9F /* CalendarManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE961F022CD163FD00799B9F /* CalendarManagerTests.swift */; }; CE7CAF3D2CC1562C00E0AC9D /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CE7CAF3C2CC1562C00E0AC9D /* OEXFoundation */; }; + CE961F032CD163FD00799B9F /* CalendarManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE961F022CD163FD00799B9F /* CalendarManagerTests.swift */; }; CEB1E2702CC14EB000921517 /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CEB1E26F2CC14EB000921517 /* OEXFoundation */; }; CEBCA4312CC13CB900076589 /* BranchSDK in Frameworks */ = {isa = PBXBuildFile; productRef = CEBCA4302CC13CB900076589 /* BranchSDK */; }; E8264C634DD8AD314ECE8905 /* Pods_App_Profile_ProfileTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C85ADF87135E03275A980E07 /* Pods_App_Profile_ProfileTests.framework */; }; @@ -918,7 +918,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -952,7 +952,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -1050,7 +1050,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugProd; @@ -1148,7 +1148,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugDev; @@ -1239,7 +1239,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseDev; @@ -1330,7 +1330,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseProd; @@ -1554,7 +1554,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugStage; @@ -1666,7 +1666,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseStage; @@ -1748,7 +1748,7 @@ repositoryURL = "https://github.com/openedx/openedx-app-foundation-ios/"; requirement = { kind = exactVersion; - version = 1.0.0; + version = 1.0.1; }; }; CEBCA42F2CC13CB900076589 /* XCRemoteSwiftPackageReference "ios-branch-sdk-spm" */ = { diff --git a/Profile/Profile/Data/Network/ProfileEndpoint.swift b/Profile/Profile/Data/Network/ProfileEndpoint.swift index c169ec575..6e99e9d3c 100644 --- a/Profile/Profile/Data/Network/ProfileEndpoint.swift +++ b/Profile/Profile/Data/Network/ProfileEndpoint.swift @@ -12,7 +12,7 @@ import Alamofire enum ProfileEndpoint: EndPointType { case getUserProfile(username: String) - case updateUserProfile(username: String, parameters: [String: Any]) + case updateUserProfile(username: String, parameters: [String: any Any & Sendable]) case uploadProfilePicture(username: String, pictureData: Data) case deleteProfilePicture(username: String) case logOut(refreshToken: String, clientID: String) diff --git a/Profile/Profile/Data/Persistence/ProfilePersistenceProtocol.swift b/Profile/Profile/Data/Persistence/ProfilePersistenceProtocol.swift index e87f1aa19..a68940057 100644 --- a/Profile/Profile/Data/Persistence/ProfilePersistenceProtocol.swift +++ b/Profile/Profile/Data/Persistence/ProfilePersistenceProtocol.swift @@ -9,29 +9,29 @@ import CoreData import Core //sourcery: AutoMockable -public protocol ProfilePersistenceProtocol { - func getCourseState(courseID: String) -> CourseCalendarState? - func getAllCourseStates() -> [CourseCalendarState] - func saveCourseState(state: CourseCalendarState) - func removeCourseState(courseID: String) - func deleteAllCourseStatesAndEvents() - func saveCourseCalendarEvent(_ event: CourseCalendarEvent) - func removeCourseCalendarEvents(for courseId: String) - func removeAllCourseCalendarEvents() - func getCourseCalendarEvents(for courseId: String) -> [CourseCalendarEvent] +public protocol ProfilePersistenceProtocol: Sendable { + func getCourseState(courseID: String) async -> CourseCalendarState? + func getAllCourseStates() async -> [CourseCalendarState] + func saveCourseState(state: CourseCalendarState) async + func removeCourseState(courseID: String) async + func deleteAllCourseStatesAndEvents() async + func saveCourseCalendarEvent(_ event: CourseCalendarEvent) async + func removeCourseCalendarEvents(for courseId: String) async + func removeAllCourseCalendarEvents() async + func getCourseCalendarEvents(for courseId: String) async -> [CourseCalendarEvent] } #if DEBUG public struct ProfilePersistenceMock: ProfilePersistenceProtocol { - public func getCourseState(courseID: String) -> CourseCalendarState? { nil } - public func getAllCourseStates() -> [CourseCalendarState] {[]} - public func saveCourseState(state: CourseCalendarState) {} - public func removeCourseState(courseID: String) {} - public func deleteAllCourseStatesAndEvents() {} - public func saveCourseCalendarEvent(_ event: CourseCalendarEvent) {} - public func removeCourseCalendarEvents(for courseId: String) {} - public func removeAllCourseCalendarEvents() {} - public func getCourseCalendarEvents(for courseId: String) -> [CourseCalendarEvent] { [] } + public func getCourseState(courseID: String) async -> CourseCalendarState? { nil } + public func getAllCourseStates() async -> [CourseCalendarState] {[]} + public func saveCourseState(state: CourseCalendarState) async {} + public func removeCourseState(courseID: String) async {} + public func deleteAllCourseStatesAndEvents() async {} + public func saveCourseCalendarEvent(_ event: CourseCalendarEvent) async {} + public func removeCourseCalendarEvents(for courseId: String) async {} + public func removeAllCourseCalendarEvents() async {} + public func getCourseCalendarEvents(for courseId: String) async -> [CourseCalendarEvent] { [] } } #endif diff --git a/Profile/Profile/Data/ProfileRepository.swift b/Profile/Profile/Data/ProfileRepository.swift index 8bad93b00..a51859f6a 100644 --- a/Profile/Profile/Data/ProfileRepository.swift +++ b/Profile/Profile/Data/ProfileRepository.swift @@ -10,27 +10,27 @@ import Core import OEXFoundation import Alamofire -public protocol ProfileRepositoryProtocol { +public protocol ProfileRepositoryProtocol: Sendable { func getUserProfile(username: String) async throws -> UserProfile func getMyProfile() async throws -> UserProfile - func getMyProfileOffline() -> UserProfile? + func getMyProfileOffline() async -> UserProfile? func logOut() async throws func uploadProfilePicture(pictureData: Data) async throws func deleteProfilePicture() async throws -> Bool - func updateUserProfile(parameters: [String: Any]) async throws -> UserProfile + func updateUserProfile(parameters: [String: any Any & Sendable]) async throws -> UserProfile func getSpokenLanguages() -> [PickerFields.Option] func getCountries() -> [PickerFields.Option] func deleteAccount(password: String) async throws -> Bool func getSettings() -> UserSettings - func saveSettings(_ settings: UserSettings) + func saveSettings(_ settings: UserSettings) async func enrollmentsStatus() async throws -> [CourseForSync] func getCourseDates(courseID: String) async throws -> CourseDates } -public class ProfileRepository: ProfileRepositoryProtocol { +public actor ProfileRepository: ProfileRepositoryProtocol { private let api: API - private var storage: CoreStorage & ProfileStorage + private let storage: CoreStorage & ProfileStorage private let downloadManager: DownloadManagerProtocol private let coreDataHandler: CoreDataHandlerProtocol private let config: ConfigProtocol @@ -60,11 +60,12 @@ public class ProfileRepository: ProfileRepositoryProtocol { let user = try await api.requestData( ProfileEndpoint.getUserProfile(username: storage.user?.username ?? "") ).mapResponse(DataLayer.UserProfile.self) - storage.userProfile = user + var localStorage = storage + localStorage.userProfile = user return user.domain } - public func getMyProfileOffline() -> UserProfile? { + public func getMyProfileOffline() async -> UserProfile? { return storage.userProfile?.domain } @@ -76,7 +77,7 @@ public class ProfileRepository: ProfileRepositoryProtocol { storage.clear() } - public func getSpokenLanguages() -> [PickerFields.Option] { + public nonisolated func getSpokenLanguages() -> [PickerFields.Option] { guard let url = Bundle.main.url(forResource: "languages", withExtension: "json") else { print("Json file not found") @@ -96,7 +97,7 @@ public class ProfileRepository: ProfileRepositoryProtocol { return elements } - public func getCountries() -> [PickerFields.Option] { + public nonisolated func getCountries() -> [PickerFields.Option] { guard let url = Bundle.main.url(forResource: "сountries", withExtension: "json") else { print("Json file not found") @@ -128,7 +129,7 @@ public class ProfileRepository: ProfileRepositoryProtocol { return response.statusCode == 204 } - public func updateUserProfile(parameters: [String: Any]) async throws -> UserProfile { + public func updateUserProfile(parameters: [String: any Any & Sendable]) async throws -> UserProfile { let response = try await api.requestData( ProfileEndpoint.updateUserProfile(username: storage.user?.username ?? "", parameters: parameters)) @@ -141,7 +142,7 @@ public class ProfileRepository: ProfileRepositoryProtocol { return response.statusCode == 204 } - public func getSettings() -> UserSettings { + nonisolated public func getSettings() -> UserSettings { if let userSettings = storage.userSettings { return userSettings } else { @@ -149,8 +150,9 @@ public class ProfileRepository: ProfileRepositoryProtocol { } } - public func saveSettings(_ settings: UserSettings) { - storage.userSettings = settings + public func saveSettings(_ settings: UserSettings) async { + var localStorage = storage + localStorage.userSettings = settings } public func enrollmentsStatus() async throws -> [CourseForSync] { @@ -171,7 +173,7 @@ public class ProfileRepository: ProfileRepositoryProtocol { // Mark - For testing and SwiftUI preview #if DEBUG // swiftlint:disable all -class ProfileRepositoryMock: ProfileRepositoryProtocol { +actor ProfileRepositoryMock: ProfileRepositoryProtocol { public func getUserProfile(username: String) async throws -> Core.UserProfile { return Core.UserProfile(avatarUrl: "", @@ -185,7 +187,7 @@ class ProfileRepositoryMock: ProfileRepositoryProtocol { email: "") } - func getMyProfileOffline() -> Core.UserProfile? { + func getMyProfileOffline() async -> Core.UserProfile? { return UserProfile( avatarUrl: "", name: "John Lennon", @@ -227,15 +229,15 @@ class ProfileRepositoryMock: ProfileRepositoryProtocol { func logOut() async throws {} - func getSpokenLanguages() -> [PickerFields.Option] { return [] } + nonisolated func getSpokenLanguages() -> [PickerFields.Option] { return [] } - func getCountries() -> [PickerFields.Option] { return [] } + nonisolated func getCountries() -> [PickerFields.Option] { return [] } func uploadProfilePicture(pictureData: Data) async throws {} public func deleteProfilePicture() async throws -> Bool { return true } - func updateUserProfile(parameters: [String: Any]) async throws -> UserProfile { + func updateUserProfile(parameters: [String: any Any & Sendable]) async throws -> UserProfile { return UserProfile( avatarUrl: "", name: "John Smith", @@ -251,10 +253,10 @@ class ProfileRepositoryMock: ProfileRepositoryProtocol { public func deleteAccount(password: String) async throws -> Bool { return false } - public func getSettings() -> UserSettings { + nonisolated public func getSettings() -> UserSettings { return UserSettings(wifiOnly: true, streamingQuality: .auto, downloadQuality: .auto) } - public func saveSettings(_ settings: UserSettings) {} + public func saveSettings(_ settings: UserSettings) async {} public func enrollmentsStatus() async throws -> [CourseForSync] { let result = [ diff --git a/Profile/Profile/Data/ProfileStorage.swift b/Profile/Profile/Data/ProfileStorage.swift index 68347091f..1070c18ac 100644 --- a/Profile/Profile/Data/ProfileStorage.swift +++ b/Profile/Profile/Data/ProfileStorage.swift @@ -10,7 +10,7 @@ import Core import UIKit //sourcery: AutoMockable -public protocol ProfileStorage { +public protocol ProfileStorage: Sendable { var userProfile: DataLayer.UserProfile? {get set} var useRelativeDates: Bool {get set} var calendarSettings: CalendarSettings? {get set} @@ -22,7 +22,7 @@ public protocol ProfileStorage { } #if DEBUG -public class ProfileStorageMock: ProfileStorage { +public final class ProfileStorageMock: ProfileStorage, @unchecked Sendable { public var userProfile: DataLayer.UserProfile? public var useRelativeDates: Bool = true diff --git a/Profile/Profile/Domain/Model/ProfileType.swift b/Profile/Profile/Domain/Model/ProfileType.swift index 261c5c735..a7ec9ceb3 100644 --- a/Profile/Profile/Domain/Model/ProfileType.swift +++ b/Profile/Profile/Domain/Model/ProfileType.swift @@ -7,7 +7,7 @@ import Foundation -public enum ProfileType { +public enum ProfileType: Sendable { case full case limited diff --git a/Profile/Profile/Domain/ProfileInteractor.swift b/Profile/Profile/Domain/ProfileInteractor.swift index bce7cf620..e1813637d 100644 --- a/Profile/Profile/Domain/ProfileInteractor.swift +++ b/Profile/Profile/Domain/ProfileInteractor.swift @@ -10,24 +10,24 @@ import Core import UIKit //sourcery: AutoMockable -public protocol ProfileInteractorProtocol { +public protocol ProfileInteractorProtocol: Sendable { func getUserProfile(username: String) async throws -> UserProfile func getMyProfile() async throws -> UserProfile - func getMyProfileOffline() -> UserProfile? + func getMyProfileOffline() async -> UserProfile? func logOut() async throws func getSpokenLanguages() -> [PickerFields.Option] func getCountries() -> [PickerFields.Option] func uploadProfilePicture(pictureData: Data) async throws func deleteProfilePicture() async throws -> Bool - func updateUserProfile(parameters: [String: Any]) async throws -> UserProfile + func updateUserProfile(parameters: [String: any Any & Sendable]) async throws -> UserProfile func deleteAccount(password: String) async throws -> Bool func getSettings() -> UserSettings - func saveSettings(_ settings: UserSettings) + func saveSettings(_ settings: UserSettings) async func enrollmentsStatus() async throws -> [CourseForSync] func getCourseDates(courseID: String) async throws -> CourseDates } -public class ProfileInteractor: ProfileInteractorProtocol { +public actor ProfileInteractor: ProfileInteractorProtocol { private let repository: ProfileRepositoryProtocol @@ -43,19 +43,19 @@ public class ProfileInteractor: ProfileInteractorProtocol { return try await repository.getMyProfile() } - public func getMyProfileOffline() -> UserProfile? { - return repository.getMyProfileOffline() + public func getMyProfileOffline() async -> UserProfile? { + return await repository.getMyProfileOffline() } public func logOut() async throws { try await repository.logOut() } - public func getSpokenLanguages() -> [PickerFields.Option] { + public nonisolated func getSpokenLanguages() -> [PickerFields.Option] { return repository.getSpokenLanguages() } - public func getCountries() -> [PickerFields.Option] { + public nonisolated func getCountries() -> [PickerFields.Option] { return repository.getCountries() } @@ -67,7 +67,7 @@ public class ProfileInteractor: ProfileInteractorProtocol { try await repository.deleteProfilePicture() } - public func updateUserProfile(parameters: [String: Any]) async throws -> UserProfile { + public func updateUserProfile(parameters: [String: any Any & Sendable]) async throws -> UserProfile { return try await repository.updateUserProfile(parameters: parameters) } @@ -75,12 +75,12 @@ public class ProfileInteractor: ProfileInteractorProtocol { return try await repository.deleteAccount(password: password) } - public func getSettings() -> UserSettings { + nonisolated public func getSettings() -> UserSettings { return repository.getSettings() } - public func saveSettings(_ settings: UserSettings) { - return repository.saveSettings(settings) + public func saveSettings(_ settings: UserSettings) async { + return await repository.saveSettings(settings) } public func enrollmentsStatus() async throws -> [CourseForSync] { @@ -95,6 +95,6 @@ public class ProfileInteractor: ProfileInteractorProtocol { // Mark - For testing and SwiftUI preview #if DEBUG public extension ProfileInteractor { - static let mock = ProfileInteractor(repository: ProfileRepositoryMock()) + @MainActor static let mock = ProfileInteractor(repository: ProfileRepositoryMock()) } #endif diff --git a/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift b/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift index da8920ed1..047cf9511 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift @@ -6,8 +6,8 @@ // import SwiftUI -import Combine -import EventKit +@preconcurrency import Combine +@preconcurrency import EventKit import Theme import BranchSDK import CryptoKit @@ -15,12 +15,13 @@ import Core import OEXFoundation // MARK: - CalendarManager -public class CalendarManager: CalendarManagerProtocol { +public final class CalendarManager: CalendarManagerProtocol { + let eventStore = EKEventStore() private let alertOffset = -1 - private var persistence: ProfilePersistenceProtocol - private var interactor: ProfileInteractorProtocol - private var profileStorage: ProfileStorage + private let persistence: ProfilePersistenceProtocol + private let interactor: ProfileInteractorProtocol + private nonisolated(unsafe) var profileStorage: ProfileStorage public init( persistence: ProfilePersistenceProtocol, @@ -73,8 +74,8 @@ public class CalendarManager: CalendarManagerProtocol { eventStore.calendars(for: .event).first(where: { $0.title == calendarName }) } - public func courseStatus(courseID: String) -> SyncStatus { - let states = persistence.getAllCourseStates() + public func courseStatus(courseID: String) async -> SyncStatus { + let states = await persistence.getAllCourseStates() if states.contains(where: { $0.courseID == courseID }) { return .synced } else { @@ -103,8 +104,8 @@ public class CalendarManager: CalendarManagerProtocol { } } - public func isDatesChanged(courseID: String, checksum: String) -> Bool { - guard let oldState = persistence.getCourseState(courseID: courseID) else { return false } + public func isDatesChanged(courseID: String, checksum: String) async -> Bool { + guard let oldState = await persistence.getCourseState(courseID: courseID) else { return false } return checksum != oldState.checksum } @@ -112,21 +113,21 @@ public class CalendarManager: CalendarManagerProtocol { createCalendarIfNeeded() guard let calendar else { return } if saveEvents(for: dates.dateBlocks, courseID: courseID, courseName: courseName, calendar: calendar) { - saveCourseDatesChecksum(courseID: courseID, checksum: dates.checksum) + await saveCourseDatesChecksum(courseID: courseID, checksum: dates.checksum) } else { debugLog("Failed to sync calendar for courseID: \(courseID)") } } public func removeOutdatedEvents(courseID: String) async { - let events = persistence.getCourseCalendarEvents(for: courseID) + let events = await persistence.getCourseCalendarEvents(for: courseID) for event in events { deleteEventFromCalendar(eventIdentifier: event.eventIdentifier) } - if var state = persistence.getCourseState(courseID: courseID) { - persistence.saveCourseState(state: CourseCalendarState(courseID: state.courseID, checksum: "")) + if let state = await persistence.getCourseState(courseID: courseID) { + await persistence.saveCourseState(state: CourseCalendarState(courseID: state.courseID, checksum: "")) } - persistence.removeCourseCalendarEvents(for: courseID) + await persistence.removeCourseCalendarEvents(for: courseID) } func deleteEventFromCalendar(eventIdentifier: String) { @@ -139,21 +140,29 @@ public class CalendarManager: CalendarManagerProtocol { } } - @MainActor public func requestAccess() async -> Bool { - await withCheckedContinuation { continuation in - eventStore.requestAccess(to: .event) { granted, _ in - if granted { - continuation.resume(returning: true) - } else { - continuation.resume(returning: false) + if #available(iOS 17.0, *) { + do { + return try await eventStore.requestFullAccessToEvents() + } catch { + debugLog(error) + return false + } + } else { + return await withCheckedContinuation { continuation in + eventStore.requestAccess(to: .event) { granted, _ in + if granted { + continuation.resume(returning: true) + } else { + continuation.resume(returning: false) + } } } } } - public func clearAllData(removeCalendar: Bool) { - persistence.deleteAllCourseStatesAndEvents() + public func clearAllData(removeCalendar: Bool) async { + await persistence.deleteAllCourseStatesAndEvents() if removeCalendar { removeOldCalendar() } @@ -164,11 +173,11 @@ public class CalendarManager: CalendarManagerProtocol { profileStorage.lastCalendarUpdateDate = nil } - private func saveCourseDatesChecksum(courseID: String, checksum: String) { - var states = persistence.getAllCourseStates() + private func saveCourseDatesChecksum(courseID: String, checksum: String) async { + var states = await persistence.getAllCourseStates() states.append(CourseCalendarState(courseID: courseID, checksum: checksum)) for state in states { - persistence.saveCourseState(state: state) + await persistence.saveCourseState(state: state) } } @@ -184,9 +193,11 @@ public class CalendarManager: CalendarManagerProtocol { if !eventExists(event, in: calendar) { do { try eventStore.save(event, span: .thisEvent) - persistence.saveCourseCalendarEvent( - CourseCalendarEvent(courseID: courseID, eventIdentifier: event.eventIdentifier) - ) + Task { + await persistence.saveCourseCalendarEvent( + CourseCalendarEvent(courseID: courseID, eventIdentifier: event.eventIdentifier) + ) + } } catch { saveSuccessful = false } @@ -212,7 +223,7 @@ public class CalendarManager: CalendarManagerProtocol { } public func filterCoursesBySelected(fetchedCourses: [CourseForSync]) async -> [CourseForSync] { - let courseCalendarStates = persistence.getAllCourseStates() + let courseCalendarStates = await persistence.getAllCourseStates() if !courseCalendarStates.isEmpty { let coursesToDelete = courseCalendarStates.filter { course in !fetchedCourses.contains { $0.courseID == course.courseID } @@ -318,7 +329,9 @@ public class CalendarManager: CalendarManagerProtocol { guard let calendar = localCalendar(for: courseID, calendarName: calendarName) else { completion?(true); return } do { try eventStore.removeCalendar(calendar, commit: true) - persistence.removeCourseCalendarEvents(for: courseID) + Task { + await persistence.removeCourseCalendarEvents(for: courseID) + } completion?(true) } catch { completion?(false) diff --git a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift index ff6ad8c60..ca90966c0 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift @@ -16,7 +16,8 @@ import OEXFoundation // MARK: - DatesAndCalendarViewModel -public class DatesAndCalendarViewModel: ObservableObject { +@MainActor +public final class DatesAndCalendarViewModel: ObservableObject { @Published var showCalendaAccessDenied: Bool = false @Published var showDisableCalendarSync: Bool = false @Published var showError: Bool = false @@ -98,7 +99,6 @@ public class DatesAndCalendarViewModel: ObservableObject { self.calendarNameHint = ProfileLocalization.Calendar.courseDates((Bundle.main.applicationName ?? "")) } - @MainActor var isInternetAvaliable: Bool { let avaliable = connectivity.isInternetAvaliable if !avaliable { @@ -124,10 +124,10 @@ public class DatesAndCalendarViewModel: ObservableObject { $hideInactiveCourses .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] hide in - guard let self = self else { return } - self.profileStorage.hideInactiveCourses = hide - }) - .store(in: &cancellables) + guard let self = self else { return } + self.profileStorage.hideInactiveCourses = hide + }) + .store(in: &cancellables) $courseCalendarSync .receive(on: DispatchQueue.main) @@ -144,8 +144,8 @@ public class DatesAndCalendarViewModel: ObservableObject { updateCoursesCount() } - func clearAllData() { - calendarManager.clearAllData(removeCalendar: true) + func clearAllData() async { + await calendarManager.clearAllData(removeCalendar: true) router.back(animated: false) courseCalendarSync = true showDisableCalendarSync = false @@ -193,9 +193,8 @@ public class DatesAndCalendarViewModel: ObservableObject { } } } - + // MARK: - Fetch Courses and Sync - @MainActor func fetchCourses() async { guard connectivity.isInternetAvaliable else { return } assignmentStatus = .loading @@ -207,7 +206,7 @@ public class DatesAndCalendarViewModel: ObservableObject { do { let fetchedCourses = try await interactor.enrollmentsStatus() self.coursesForSync = fetchedCourses - let courseCalendarStates = persistence.getAllCourseStates() + let courseCalendarStates = await persistence.getAllCourseStates() if profileStorage.firstCalendarUpdate == false && courseCalendarStates.isEmpty { await syncAllActiveCourses() } else { @@ -218,9 +217,9 @@ public class DatesAndCalendarViewModel: ObservableObject { } && course.recentlyActive return updatedCourse } - + let addingIDs = Set(coursesForAdding.map { $0.courseID }) - + coursesForSync = coursesForSync.map { course in var updatedCourse = course if addingIDs.contains(course.courseID) { @@ -257,7 +256,6 @@ public class DatesAndCalendarViewModel: ObservableObject { syncingCoursesCount = coursesForSync.filter { $0.recentlyActive && $0.synced }.count } - @MainActor private func syncAllActiveCourses() async { guard profileStorage.firstCalendarUpdate == false else { coursesForAdding = [] @@ -296,7 +294,7 @@ public class DatesAndCalendarViewModel: ObservableObject { func deleteOldCalendarIfNeeded() async { guard let calSettings = profileStorage.calendarSettings else { return } - let courseCalendarStates = persistence.getAllCourseStates() + let courseCalendarStates = await persistence.getAllCourseStates() let courseCountChanges = courseCalendarStates.count != coursesForSync.count let nameChanged = oldCalendarName != calendarName let colorChanged = colorSelection != colors.first(where: { $0.colorString == calSettings.colorSelection }) @@ -306,7 +304,7 @@ public class DatesAndCalendarViewModel: ObservableObject { calendarManager.removeOldCalendar() saveCalendarOptions() - persistence.removeAllCourseCalendarEvents() + await persistence.removeAllCourseCalendarEvents() await fetchCourses() } @@ -316,9 +314,7 @@ public class DatesAndCalendarViewModel: ObservableObject { courseDates: CourseDates, active: Bool ) async { - await MainActor.run { - self.assignmentStatus = .loading - } + assignmentStatus = .loading await calendarManager.removeOutdatedEvents(courseID: courseID) guard active else { @@ -327,24 +323,21 @@ public class DatesAndCalendarViewModel: ObservableObject { } return } - + await calendarManager.syncCourse(courseID: courseID, courseName: courseName, dates: courseDates) - if let index = self.coursesForSync.firstIndex(where: { $0.courseID == courseID && $0.recentlyActive }) { - await MainActor.run { + Task { + if let index = self.coursesForSync.firstIndex(where: { $0.courseID == courseID && $0.recentlyActive }) { self.coursesForSync[index].synced = true } - } - await MainActor.run { self.assignmentStatus = .synced } } - - @MainActor + func removeDeselectedCoursesFromCalendar() async { for course in coursesForDeleting { await calendarManager.removeOutdatedEvents(courseID: course.courseID) - persistence.removeCourseState(courseID: course.courseID) - persistence.removeCourseCalendarEvents(for: course.courseID) + await persistence.removeCourseState(courseID: course.courseID) + await persistence.removeCourseCalendarEvents(for: course.courseID) if let index = self.coursesForSync.firstIndex(where: { $0.courseID == course.courseID }) { self.coursesForSync[index].synced = false } @@ -364,7 +357,7 @@ public class DatesAndCalendarViewModel: ObservableObject { updateCoursesForSyncAndDeletion(course: coursesForSync[index]) } } - + private func updateCoursesForSyncAndDeletion(course: CourseForSync) { guard let initialCourse = coursesForSyncBeforeChanges.first(where: { $0.courseID == course.courseID @@ -397,7 +390,6 @@ public class DatesAndCalendarViewModel: ObservableObject { } // MARK: - Request Calendar Permission - @MainActor func requestCalendarPermission() async { if await calendarManager.requestAccess() { await showNewCalendarSetup() @@ -406,25 +398,22 @@ public class DatesAndCalendarViewModel: ObservableObject { } } - @MainActor private func showCalendarAccessDenied() async { - withAnimation(.bouncy(duration: 0.3)) { - self.showCalendaAccessDenied = true - } + withAnimation(.bouncy(duration: 0.3)) { + self.showCalendaAccessDenied = true + } } - @MainActor private func showDisableCalendarSync() async { withAnimation(.bouncy(duration: 0.3)) { self.showDisableCalendarSync = true } } - @MainActor private func showNewCalendarSetup() async { - withAnimation(.bouncy(duration: 0.3)) { - self.openNewCalendarView = true - } + withAnimation(.bouncy(duration: 0.3)) { + self.openNewCalendarView = true + } } func openAppSettings() { diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarEvent.swift b/Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarEvent.swift index 3ebe6c737..84282073e 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarEvent.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarEvent.swift @@ -7,7 +7,7 @@ import Foundation -public struct CourseCalendarEvent { +public struct CourseCalendarEvent: Sendable { public let courseID: String public let eventIdentifier: String diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarState.swift b/Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarState.swift index 4bdfa2310..6740ea6db 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarState.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarState.swift @@ -7,7 +7,7 @@ import Foundation -public struct CourseCalendarState { +public struct CourseCalendarState: Sendable { public let courseID: String public var checksum: String diff --git a/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift b/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift index 154c869e3..3942949be 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift @@ -176,7 +176,9 @@ public struct SyncCalendarOptionsView: View { calendarCircleColor: viewModel.colorSelection?.color, calendarName: viewModel.calendarName, action: { - viewModel.clearAllData() + Task { + await viewModel.clearAllData() + } }, onCloseTapped: { viewModel.showDisableCalendarSync = false diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountViewModel.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountViewModel.swift index 298f30ca7..9ffb9cb1b 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountViewModel.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountViewModel.swift @@ -9,7 +9,8 @@ import Foundation import Core import SwiftUI -public class DeleteAccountViewModel: ObservableObject { +@MainActor +public final class DeleteAccountViewModel: ObservableObject { @Published private(set) var isShowProgress = false @Published var showError: Bool = false diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift b/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift index c877a6ecd..11bdbd4d0 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift @@ -10,7 +10,7 @@ import Core import SwiftUI // swiftlint:disable type_body_length -public struct Changes: Equatable { +public struct Changes: Equatable, Sendable { public var shortBiography: String public var profileType: ProfileType public var isAvatarChanged: Bool @@ -18,6 +18,7 @@ public struct Changes: Equatable { public var isAvatarSaved: Bool } +@MainActor public class EditProfileViewModel: ObservableObject { @Published private(set) var userModel: UserProfile @@ -201,7 +202,7 @@ public class EditProfileViewModel: ObservableObject { @MainActor func saveProfileUpdates() async { - var parameters: [String: Any] = [:] + var parameters: [String: any Any & Sendable] = [:] if userModel.isFullProfile != profileChanges.profileType.boolValue { parameters["account_privacy"] = profileChanges.profileType.param @@ -232,7 +233,7 @@ public class EditProfileViewModel: ObservableObject { } @MainActor - func uploadData(parameters: [String: Any]) async { + func uploadData(parameters: [String: any Any & Sendable]) async { do { if profileChanges.isAvatarDeleted { try await deleteAvatar() diff --git a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift index 39854471e..267b595eb 100644 --- a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift +++ b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift @@ -9,7 +9,8 @@ import Combine import Core import SwiftUI -public class ProfileViewModel: ObservableObject { +@MainActor +public final class ProfileViewModel: ObservableObject { @Published public var userModel: UserProfile? @Published public var updatedAvatar: UIImage? @@ -47,7 +48,7 @@ public class ProfileViewModel: ObservableObject { @MainActor public func getMyProfile(withProgress: Bool = true) async { do { - let userModel = interactor.getMyProfileOffline() + let userModel = await interactor.getMyProfileOffline() if userModel == nil && connectivity.isInternetAvaliable { isShowProgress = withProgress } else { diff --git a/Profile/Profile/Presentation/ProfileRouter.swift b/Profile/Profile/Presentation/ProfileRouter.swift index dca085668..246e0cdf1 100644 --- a/Profile/Profile/Presentation/ProfileRouter.swift +++ b/Profile/Profile/Presentation/ProfileRouter.swift @@ -10,6 +10,7 @@ import Core import UIKit //sourcery: AutoMockable +@MainActor public protocol ProfileRouter: BaseRouter { func showEditProfile( diff --git a/Profile/Profile/Presentation/Settings/ManageAccountViewModel.swift b/Profile/Profile/Presentation/Settings/ManageAccountViewModel.swift index 55014a340..16415b17f 100644 --- a/Profile/Profile/Presentation/Settings/ManageAccountViewModel.swift +++ b/Profile/Profile/Presentation/Settings/ManageAccountViewModel.swift @@ -9,7 +9,8 @@ import Foundation import Core import SwiftUI -public class ManageAccountViewModel: ObservableObject { +@MainActor +public final class ManageAccountViewModel: ObservableObject { @Published public var userModel: UserProfile? @Published public var updatedAvatar: UIImage? @@ -46,7 +47,7 @@ public class ManageAccountViewModel: ObservableObject { @MainActor public func getMyProfile(withProgress: Bool = true) async { do { - let userModel = interactor.getMyProfileOffline() + let userModel = await interactor.getMyProfileOffline() if userModel == nil && connectivity.isInternetAvaliable { isShowProgress = withProgress } else { diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index e257cb967..69635963f 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -244,30 +244,20 @@ public struct SettingsView: View { } #if DEBUG -struct SettingsView_Previews: PreviewProvider { - static var previews: some View { - let router = ProfileRouterMock() - let vm = SettingsViewModel( - interactor: ProfileInteractor.mock, - downloadManager: DownloadManagerMock(), - router: router, - analytics: ProfileAnalyticsMock(), - coreAnalytics: CoreAnalyticsMock(), - config: ConfigMock(), - corePersistence: CorePersistenceMock(), - connectivity: Connectivity() - ) - - SettingsView(viewModel: vm) - .preferredColorScheme(.light) - .previewDisplayName("SettingsView Light") - .loadFonts() - - SettingsView(viewModel: vm) - .preferredColorScheme(.dark) - .previewDisplayName("SettingsView Dark") - .loadFonts() - } +#Preview { + let router = ProfileRouterMock() + let vm = SettingsViewModel( + interactor: ProfileInteractor.mock, + downloadManager: DownloadManagerMock(), + router: router, + analytics: ProfileAnalyticsMock(), + coreAnalytics: CoreAnalyticsMock(), + config: ConfigMock(), + corePersistence: CorePersistenceMock(), + connectivity: Connectivity() + ) + + SettingsView(viewModel: vm) } #endif diff --git a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift index 98885f15c..fa868f163 100644 --- a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift +++ b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift @@ -10,7 +10,8 @@ import Core import SwiftUI import Combine -public class SettingsViewModel: ObservableObject { +@MainActor +public final class SettingsViewModel: ObservableObject { @Published private(set) var isShowProgress = false @Published var showError: Bool = false @@ -18,7 +19,9 @@ public class SettingsViewModel: ObservableObject { willSet { if newValue != wifiOnly { userSettings.wifiOnly = newValue - interactor.saveSettings(userSettings) + Task { + await interactor.saveSettings(userSettings) + } } } } @@ -27,7 +30,9 @@ public class SettingsViewModel: ObservableObject { willSet { if newValue != selectedQuality { userSettings.streamingQuality = newValue - interactor.saveSettings(userSettings) + Task { + await interactor.saveSettings(userSettings) + } } } } @@ -106,7 +111,7 @@ public class SettingsViewModel: ObservableObject { NotificationCenter.default.publisher(for: .onActualVersionReceived) .sink { [weak self] notification in guard let latestVersion = notification.object as? String else { return } - DispatchQueue.main.async { [weak self] in + Task { self?.latestVersion = latestVersion if latestVersion != currentVersion { @@ -129,9 +134,9 @@ public class SettingsViewModel: ObservableObject { return emailURL } - func update(downloadQuality: DownloadQuality) { + func update(downloadQuality: DownloadQuality) async { self.userSettings.downloadQuality = downloadQuality - interactor.saveSettings(userSettings) + await interactor.saveSettings(userSettings) } func openAppStore() { @@ -139,11 +144,10 @@ public class SettingsViewModel: ObservableObject { UIApplication.shared.open(appStoreURL) } - @MainActor func logOut() async { try? await interactor.logOut() try? await downloadManager.cancelAllDownloading() - corePersistence.deleteAllProgress() + await corePersistence.deleteAllProgress() router.showStartupScreen() analytics.userLogout(force: false) NotificationCenter.default.post( diff --git a/Profile/Profile/Presentation/Settings/VideoQualityView.swift b/Profile/Profile/Presentation/Settings/VideoQualityView.swift index 4f95e139c..4c366920a 100644 --- a/Profile/Profile/Presentation/Settings/VideoQualityView.swift +++ b/Profile/Profile/Presentation/Settings/VideoQualityView.swift @@ -124,29 +124,20 @@ public struct VideoQualityView: View { } #if DEBUG -struct VideoQualityView_Previews: PreviewProvider { - static var previews: some View { - let router = ProfileRouterMock() - let vm = SettingsViewModel( - interactor: ProfileInteractor.mock, - downloadManager: DownloadManagerMock(), - router: router, - analytics: ProfileAnalyticsMock(), - coreAnalytics: CoreAnalyticsMock(), - config: ConfigMock(), - corePersistence: CorePersistenceMock(), - connectivity: Connectivity() - ) - - VideoQualityView(viewModel: vm) - .preferredColorScheme(.light) - .previewDisplayName("VideoQualityView Light") - .loadFonts() - - VideoQualityView(viewModel: vm) - .preferredColorScheme(.dark) - .previewDisplayName("VideoQualityView Dark") - .loadFonts() - } +#Preview { + let router = ProfileRouterMock() + let vm = SettingsViewModel( + interactor: ProfileInteractor.mock, + downloadManager: DownloadManagerMock(), + router: router, + analytics: ProfileAnalyticsMock(), + coreAnalytics: CoreAnalyticsMock(), + config: ConfigMock(), + corePersistence: CorePersistenceMock(), + connectivity: Connectivity() + ) + + VideoQualityView(viewModel: vm) + .loadFonts() } #endif diff --git a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift index 561c9cab1..61f9e626f 100644 --- a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift +++ b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift @@ -90,7 +90,11 @@ public struct VideoSettingsView: View { Button { viewModel.router.showVideoDownloadQualityView( downloadQuality: viewModel.userSettings.downloadQuality, - didSelect: viewModel.update(downloadQuality:), + didSelect: { quality in + Task { + await viewModel.update(downloadQuality: quality) + } + }, analytics: viewModel.coreAnalytics ) } label: { @@ -127,8 +131,7 @@ public struct VideoSettingsView: View { } #if DEBUG -struct VideoSettingsView_Previews: PreviewProvider { - static var previews: some View { +#Preview { let router = ProfileRouterMock() let vm = SettingsViewModel( interactor: ProfileInteractor.mock, @@ -136,15 +139,12 @@ struct VideoSettingsView_Previews: PreviewProvider { router: router, analytics: ProfileAnalyticsMock(), coreAnalytics: CoreAnalyticsMock(), - config: ConfigMock(), + config: ConfigMock(), corePersistence: CorePersistenceMock(), connectivity: Connectivity() ) VideoSettingsView(viewModel: vm) - .preferredColorScheme(.light) - .previewDisplayName("SettingsView Light") .loadFonts() } -} #endif diff --git a/Profile/ProfileTests/CalendarManagerTests.swift b/Profile/ProfileTests/CalendarManagerTests.swift index de245eaea..a9af756c6 100644 --- a/Profile/ProfileTests/CalendarManagerTests.swift +++ b/Profile/ProfileTests/CalendarManagerTests.swift @@ -14,9 +14,10 @@ import EventKit import Theme import SwiftUICore +@MainActor final class CalendarManagerTests: XCTestCase { - func testCourseStatusSynced() { + func testCourseStatusSynced() async { let persistence = ProfilePersistenceProtocolMock() let interactor = ProfileInteractorProtocolMock() let profileStorage = ProfileStorageMock() @@ -30,13 +31,13 @@ final class CalendarManagerTests: XCTestCase { let states = [CourseCalendarState(courseID: "course-1", checksum: "checksum-1")] Given(persistence, .getAllCourseStates(willReturn: states)) - let status = manager.courseStatus(courseID: "course-1") + let status = await manager.courseStatus(courseID: "course-1") Verify(persistence, 1, .getAllCourseStates()) XCTAssertEqual(status, .synced) } - func testCourseStatusOffline() { + func testCourseStatusOffline() async { let persistence = ProfilePersistenceProtocolMock() let interactor = ProfileInteractorProtocolMock() let profileStorage = ProfileStorageMock() @@ -50,13 +51,13 @@ final class CalendarManagerTests: XCTestCase { let states = [CourseCalendarState(courseID: "course-2", checksum: "checksum-2")] Given(persistence, .getAllCourseStates(willReturn: states)) - let status = manager.courseStatus(courseID: "course-1") + let status = await manager.courseStatus(courseID: "course-1") Verify(persistence, 1, .getAllCourseStates()) XCTAssertEqual(status, .offline) } - func testIsDatesChanged() { + func testIsDatesChanged() async { let persistence = ProfilePersistenceProtocolMock() let interactor = ProfileInteractorProtocolMock() let profileStorage = ProfileStorageMock() @@ -70,13 +71,13 @@ final class CalendarManagerTests: XCTestCase { let state = CourseCalendarState(courseID: "course-1", checksum: "old-checksum") Given(persistence, .getCourseState(courseID: .value("course-1"), willReturn: state)) - let changed = manager.isDatesChanged(courseID: "course-1", checksum: "new-checksum") + let changed = await manager.isDatesChanged(courseID: "course-1", checksum: "new-checksum") Verify(persistence, 1, .getCourseState(courseID: .value("course-1"))) XCTAssertTrue(changed) } - func testIsDatesNotChanged() { + func testIsDatesNotChanged() async { let persistence = ProfilePersistenceProtocolMock() let interactor = ProfileInteractorProtocolMock() let profileStorage = ProfileStorageMock() @@ -90,13 +91,13 @@ final class CalendarManagerTests: XCTestCase { let state = CourseCalendarState(courseID: "course-1", checksum: "same-checksum") Given(persistence, .getCourseState(courseID: .value("course-1"), willReturn: state)) - let changed = manager.isDatesChanged(courseID: "course-1", checksum: "same-checksum") + let changed = await manager.isDatesChanged(courseID: "course-1", checksum: "same-checksum") Verify(persistence, 1, .getCourseState(courseID: .value("course-1"))) XCTAssertFalse(changed) } - func testClearAllData() { + func testClearAllData() async { let persistence = ProfilePersistenceProtocolMock() let interactor = ProfileInteractorProtocolMock() let profileStorage = ProfileStorageMock() @@ -126,7 +127,7 @@ final class CalendarManagerTests: XCTestCase { profileStorage: profileStorage ) - manager.clearAllData(removeCalendar: true) + await manager.clearAllData(removeCalendar: true) // Verify persistence method was called Verify(persistence, 1, .deleteAllCourseStatesAndEvents()) diff --git a/Profile/ProfileTests/DatesAndCalendarViewModelTests.swift b/Profile/ProfileTests/DatesAndCalendarViewModelTests.swift index 443dcf875..575fb3592 100644 --- a/Profile/ProfileTests/DatesAndCalendarViewModelTests.swift +++ b/Profile/ProfileTests/DatesAndCalendarViewModelTests.swift @@ -15,6 +15,7 @@ import Theme import SwiftUICore import Combine +@MainActor final class DatesAndCalendarViewModelTests: XCTestCase { var cancellables: Set! @@ -64,7 +65,7 @@ final class DatesAndCalendarViewModelTests: XCTestCase { XCTAssertTrue(viewModel.hideInactiveCourses) } - func testClearAllData() { + func testClearAllData() async { // Given let router = ProfileRouterMock() let interactor = ProfileInteractorProtocolMock() @@ -83,7 +84,7 @@ final class DatesAndCalendarViewModelTests: XCTestCase { ) // When - viewModel.clearAllData() + await viewModel.clearAllData() // Then Verify(calendarManager, 1, .clearAllData(removeCalendar: .value(true))) diff --git a/Profile/ProfileTests/Presentation/DeleteAccount/DeleteAccountViewModelTests.swift b/Profile/ProfileTests/Presentation/DeleteAccount/DeleteAccountViewModelTests.swift index 2d5b2ed61..b7c7e6701 100644 --- a/Profile/ProfileTests/Presentation/DeleteAccount/DeleteAccountViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/DeleteAccount/DeleteAccountViewModelTests.swift @@ -13,6 +13,7 @@ import OEXFoundation import Alamofire import SwiftUI +@MainActor final class DeleteAccountViewModelTests: XCTestCase { func testDeletingAccountSuccess() async throws { diff --git a/Profile/ProfileTests/Presentation/EditProfile/EditProfileViewModelTests.swift b/Profile/ProfileTests/Presentation/EditProfile/EditProfileViewModelTests.swift index 50f77a155..41f563a50 100644 --- a/Profile/ProfileTests/Presentation/EditProfile/EditProfileViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/EditProfile/EditProfileViewModelTests.swift @@ -13,6 +13,7 @@ import Alamofire import SwiftUI // swiftlint:disable type_body_length file_length +@MainActor final class EditProfileViewModelTests: XCTestCase { func testResizeVerticalImage() async throws { @@ -480,6 +481,8 @@ final class EditProfileViewModelTests: XCTestCase { await viewModel.saveProfileUpdates() + await Task.yield() + Verify(interactor, 1, .uploadProfilePicture(pictureData: .any)) Verify(interactor, 1, .updateUserProfile(parameters: .any)) } @@ -522,6 +525,8 @@ final class EditProfileViewModelTests: XCTestCase { await viewModel.saveProfileUpdates() + await Task.yield() + // Verify(interactor, 0, .uploadProfilePicture(pictureData: .any)) Verify(interactor, 1, .deleteProfilePicture()) Verify(interactor, 1, .updateUserProfile(parameters: .any)) @@ -631,6 +636,8 @@ final class EditProfileViewModelTests: XCTestCase { await viewModel.saveProfileUpdates() + await Task.yield() + Verify(interactor, 1, .uploadProfilePicture(pictureData: .any)) Verify(interactor, 1, .updateUserProfile(parameters: .any)) @@ -778,6 +785,8 @@ final class EditProfileViewModelTests: XCTestCase { viewModel.loadLocationsAndSpokenLanguages() + await Task.yield() + Verify(interactor, 1, .getSpokenLanguages()) Verify(interactor, 1, .getCountries()) } diff --git a/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift b/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift index 44d3b96be..731a1382f 100644 --- a/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift @@ -12,6 +12,7 @@ import XCTest import Alamofire import SwiftUI +@MainActor final class ProfileViewModelTests: XCTestCase { func testGetUserProfileSuccess() async throws { diff --git a/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift b/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift index de7d52a4c..23264dccb 100644 --- a/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift @@ -12,6 +12,7 @@ import XCTest import Alamofire import SwiftUI +@MainActor final class SettingsViewModelTests: XCTestCase { func testLogOutSuccess() async throws { diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 38e43e5da..123c0940a 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -508,7 +508,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { } // MARK: - BaseRouter - +@MainActor open class BaseRouterMock: BaseRouter, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -980,7 +980,7 @@ open class BaseRouterMock: BaseRouter, Mock { } // MARK: - CalendarManagerProtocol - +@MainActor open class CalendarManagerProtocolMock: CalendarManagerProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -1847,7 +1847,7 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { } // MARK: - ConnectivityProtocol - +@MainActor open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -2456,16 +2456,19 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { return __value } - open func publisher() -> AnyPublisher { + @MainActor + open func publisher() throws -> AnyPublisher { addInvocation(.m_publisher) let perform = methodPerformValue(.m_publisher) as? () -> Void perform?() var __value: AnyPublisher do { __value = try methodReturnValue(.m_publisher).casted() - } catch { + } catch MockError.notStubed { onFatalFailure("Stub return value not specified for publisher(). Use given") Failure("Stub return value not specified for publisher(). Use given") + } catch { + throw error } return __value } @@ -2757,7 +2760,8 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { public static func getUserID(willReturn: Int?...) -> MethodStub { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + @MainActor + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) } public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { @@ -2785,13 +2789,6 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { willProduce(stubber) return given } - public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { - let willReturn: [AnyPublisher] = [] - let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (AnyPublisher).self) - willProduce(stubber) - return given - } public static func loadProgress(for blockID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [OfflineProgress?] = [] let given: Given = { return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -2834,6 +2831,18 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { willProduce(stubber) return given } + @MainActor + public static func publisher(willThrow: Error...) -> MethodStub { + return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) + } + @MainActor + public static func publisher(willProduce: (StubberThrows>) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (AnyPublisher).self) + willProduce(stubber) + return given + } public static func deleteDownloadDataTask(id: Parameter, willThrow: Error...) -> MethodStub { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -2851,7 +2860,8 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { public static func set(userId: Parameter) -> Verify { return Verify(method: .m_set__userId_userId(`userId`))} public static func getUserID() -> Verify { return Verify(method: .m_getUserID)} - public static func publisher() -> Verify { return Verify(method: .m_publisher)} + @MainActor + public static func publisher() -> Verify { return Verify(method: .m_publisher)} public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>) -> Verify { return Verify(method: .m_addToDownloadQueue__tasks_tasks(`tasks`))} public static func saveOfflineProgress(progress: Parameter) -> Verify { return Verify(method: .m_saveOfflineProgress__progress_progress(`progress`))} public static func loadProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_loadProgress__for_blockID(`blockID`))} @@ -2878,7 +2888,8 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { public static func getUserID(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_getUserID, performs: perform) } - public static func publisher(perform: @escaping () -> Void) -> Perform { + @MainActor + public static func publisher(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_publisher, performs: perform) } public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>, perform: @escaping ([DownloadDataTask]) -> Void) -> Perform { @@ -3399,7 +3410,7 @@ open class CoreStorageMock: CoreStorage, Mock { } // MARK: - DownloadManagerProtocol - +@MainActor open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -3447,16 +3458,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { - open func publisher() -> AnyPublisher { + open func publisher() throws -> AnyPublisher { addInvocation(.m_publisher) let perform = methodPerformValue(.m_publisher) as? () -> Void perform?() var __value: AnyPublisher do { __value = try methodReturnValue(.m_publisher).casted() - } catch { + } catch MockError.notStubed { onFatalFailure("Stub return value not specified for publisher(). Use given") Failure("Stub return value not specified for publisher(). Use given") + } catch { + throw error } return __value } @@ -3803,13 +3816,6 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { - let willReturn: [AnyPublisher] = [] - let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (AnyPublisher).self) - willProduce(stubber) - return given - } public static func eventPublisher(willProduce: (Stubber>) -> Void) -> MethodStub { let willReturn: [AnyPublisher] = [] let given: Given = { return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -3852,6 +3858,16 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func publisher(willThrow: Error...) -> MethodStub { + return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func publisher(willProduce: (StubberThrows>) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_publisher, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (AnyPublisher).self) + willProduce(stubber) + return given + } public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -4853,16 +4869,16 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { return __value } - open func updateUserProfile(parameters: [String: Any]) throws -> UserProfile { - addInvocation(.m_updateUserProfile__parameters_parameters(Parameter<[String: Any]>.value(`parameters`))) - let perform = methodPerformValue(.m_updateUserProfile__parameters_parameters(Parameter<[String: Any]>.value(`parameters`))) as? ([String: Any]) -> Void + open func updateUserProfile(parameters: [String: any Any & Sendable]) throws -> UserProfile { + addInvocation(.m_updateUserProfile__parameters_parameters(Parameter<[String: any Any & Sendable]>.value(`parameters`))) + let perform = methodPerformValue(.m_updateUserProfile__parameters_parameters(Parameter<[String: any Any & Sendable]>.value(`parameters`))) as? ([String: any Any & Sendable]) -> Void perform?(`parameters`) var __value: UserProfile do { - __value = try methodReturnValue(.m_updateUserProfile__parameters_parameters(Parameter<[String: Any]>.value(`parameters`))).casted() + __value = try methodReturnValue(.m_updateUserProfile__parameters_parameters(Parameter<[String: any Any & Sendable]>.value(`parameters`))).casted() } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for updateUserProfile(parameters: [String: Any]). Use given") - Failure("Stub return value not specified for updateUserProfile(parameters: [String: Any]). Use given") + onFatalFailure("Stub return value not specified for updateUserProfile(parameters: [String: any Any & Sendable]). Use given") + Failure("Stub return value not specified for updateUserProfile(parameters: [String: any Any & Sendable]). Use given") } catch { throw error } @@ -4947,7 +4963,7 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { case m_getCountries case m_uploadProfilePicture__pictureData_pictureData(Parameter) case m_deleteProfilePicture - case m_updateUserProfile__parameters_parameters(Parameter<[String: Any]>) + case m_updateUserProfile__parameters_parameters(Parameter<[String: any Any & Sendable]>) case m_deleteAccount__password_password(Parameter) case m_getSettings case m_saveSettings__settings(Parameter) @@ -5070,7 +5086,7 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { public static func deleteProfilePicture(willReturn: Bool...) -> MethodStub { return Given(method: .m_deleteProfilePicture, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func updateUserProfile(parameters: Parameter<[String: Any]>, willReturn: UserProfile...) -> MethodStub { + public static func updateUserProfile(parameters: Parameter<[String: any Any & Sendable]>, willReturn: UserProfile...) -> MethodStub { return Given(method: .m_updateUserProfile__parameters_parameters(`parameters`), products: willReturn.map({ StubProduct.return($0 as Any) })) } public static func deleteAccount(password: Parameter, willReturn: Bool...) -> MethodStub { @@ -5163,10 +5179,10 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { willProduce(stubber) return given } - public static func updateUserProfile(parameters: Parameter<[String: Any]>, willThrow: Error...) -> MethodStub { + public static func updateUserProfile(parameters: Parameter<[String: any Any & Sendable]>, willThrow: Error...) -> MethodStub { return Given(method: .m_updateUserProfile__parameters_parameters(`parameters`), products: willThrow.map({ StubProduct.throw($0) })) } - public static func updateUserProfile(parameters: Parameter<[String: Any]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + public static func updateUserProfile(parameters: Parameter<[String: any Any & Sendable]>, willProduce: (StubberThrows) -> Void) -> MethodStub { let willThrow: [Error] = [] let given: Given = { return Given(method: .m_updateUserProfile__parameters_parameters(`parameters`), products: willThrow.map({ StubProduct.throw($0) })) }() let stubber = given.stubThrows(for: (UserProfile).self) @@ -5216,7 +5232,7 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { public static func getCountries() -> Verify { return Verify(method: .m_getCountries)} public static func uploadProfilePicture(pictureData: Parameter) -> Verify { return Verify(method: .m_uploadProfilePicture__pictureData_pictureData(`pictureData`))} public static func deleteProfilePicture() -> Verify { return Verify(method: .m_deleteProfilePicture)} - public static func updateUserProfile(parameters: Parameter<[String: Any]>) -> Verify { return Verify(method: .m_updateUserProfile__parameters_parameters(`parameters`))} + public static func updateUserProfile(parameters: Parameter<[String: any Any & Sendable]>) -> Verify { return Verify(method: .m_updateUserProfile__parameters_parameters(`parameters`))} public static func deleteAccount(password: Parameter) -> Verify { return Verify(method: .m_deleteAccount__password_password(`password`))} public static func getSettings() -> Verify { return Verify(method: .m_getSettings)} public static func saveSettings(_ settings: Parameter) -> Verify { return Verify(method: .m_saveSettings__settings(`settings`))} @@ -5252,7 +5268,7 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { public static func deleteProfilePicture(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_deleteProfilePicture, performs: perform) } - public static func updateUserProfile(parameters: Parameter<[String: Any]>, perform: @escaping ([String: Any]) -> Void) -> Perform { + public static func updateUserProfile(parameters: Parameter<[String: any Any & Sendable]>, perform: @escaping ([String: any Any & Sendable]) -> Void) -> Perform { return Perform(method: .m_updateUserProfile__parameters_parameters(`parameters`), performs: perform) } public static func deleteAccount(password: Parameter, perform: @escaping (String) -> Void) -> Perform { @@ -5709,7 +5725,7 @@ open class ProfilePersistenceProtocolMock: ProfilePersistenceProtocol, Mock { } // MARK: - ProfileRouter - +@MainActor open class ProfileRouterMock: ProfileRouter, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() @@ -6652,249 +6668,3 @@ open class ProfileStorageMock: ProfileStorage, Mock { } } -// MARK: - WebviewCookiesUpdateProtocol - -open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { - public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { - SwiftyMockyTestObserver.setup() - self.sequencingPolicy = sequencingPolicy - self.stubbingPolicy = stubbingPolicy - self.file = file - self.line = line - } - - var matcher: Matcher = Matcher.default - var stubbingPolicy: StubbingPolicy = .wrap - var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst - - private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) - private var invocations: [MethodType] = [] - private var methodReturnValues: [Given] = [] - private var methodPerformValues: [Perform] = [] - private var file: StaticString? - private var line: UInt? - - public typealias PropertyStub = Given - public typealias MethodStub = Given - public typealias SubscriptStub = Given - - /// Convenience method - call setupMock() to extend debug information when failure occurs - public func setupMock(file: StaticString = #file, line: UInt = #line) { - self.file = file - self.line = line - } - - /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals - public func resetMock(_ scopes: MockScope...) { - let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes - if scopes.contains(.invocation) { invocations = [] } - if scopes.contains(.given) { methodReturnValues = [] } - if scopes.contains(.perform) { methodPerformValues = [] } - } - - public var authInteractor: AuthInteractorProtocol { - get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } - } - private var __p_authInteractor: (AuthInteractorProtocol)? - - public var cookiesReady: Bool { - get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } - set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } - } - private var __p_cookiesReady: (Bool)? - - public var updatingCookies: Bool { - get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } - set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } - } - private var __p_updatingCookies: (Bool)? - - public var errorMessage: String? { - get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } - set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } - } - private var __p_errorMessage: (String)? - - - - - - open func updateCookies(force: Bool, retryCount: Int) { - addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) - let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void - perform?(`force`, `retryCount`) - } - - - fileprivate enum MethodType { - case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) - case p_authInteractor_get - case p_cookiesReady_get - case p_cookiesReady_set(Parameter) - case p_updatingCookies_get - case p_updatingCookies_set(Parameter) - case p_errorMessage_get - case p_errorMessage_set(Parameter) - - static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { - switch (lhs, rhs) { - case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) - return Matcher.ComparisonResult(results) - case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match - case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match - case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - default: return .none - } - } - - func intValue() -> Int { - switch self { - case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue - case .p_authInteractor_get: return 0 - case .p_cookiesReady_get: return 0 - case .p_cookiesReady_set(let newValue): return newValue.intValue - case .p_updatingCookies_get: return 0 - case .p_updatingCookies_set(let newValue): return newValue.intValue - case .p_errorMessage_get: return 0 - case .p_errorMessage_set(let newValue): return newValue.intValue - } - } - func assertionName() -> String { - switch self { - case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" - case .p_authInteractor_get: return "[get] .authInteractor" - case .p_cookiesReady_get: return "[get] .cookiesReady" - case .p_cookiesReady_set: return "[set] .cookiesReady" - case .p_updatingCookies_get: return "[get] .updatingCookies" - case .p_updatingCookies_set: return "[set] .updatingCookies" - case .p_errorMessage_get: return "[get] .errorMessage" - case .p_errorMessage_set: return "[set] .errorMessage" - } - } - } - - open class Given: StubbedMethod { - fileprivate var method: MethodType - - private init(method: MethodType, products: [StubProduct]) { - self.method = method - super.init(products) - } - - public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { - return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { - return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - - } - - public struct Verify { - fileprivate var method: MethodType - - public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} - public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } - public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } - public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } - public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } - public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } - public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } - public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } - } - - public struct Perform { - fileprivate var method: MethodType - var performs: Any - - public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { - return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) - } - } - - public func given(_ method: Given) { - methodReturnValues.append(method) - } - - public func perform(_ method: Perform) { - methodPerformValues.append(method) - methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } - } - - public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { - let fullMatches = matchingCalls(method, file: file, line: line) - let success = count.matches(fullMatches) - let assertionName = method.method.assertionName() - let feedback: String = { - guard !success else { return "" } - return Utils.closestCallsMessage( - for: self.invocations.map { invocation in - matcher.set(file: file, line: line) - defer { matcher.clearFileAndLine() } - return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) - }, - name: assertionName - ) - }() - MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) - } - - private func addInvocation(_ call: MethodType) { - self.queue.sync { invocations.append(call) } - } - private func methodReturnValue(_ method: MethodType) throws -> StubProduct { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) - let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) - guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } - return product - } - private func methodPerformValue(_ method: MethodType) -> Any? { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } - return matched?.performs - } - private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { - matcher.set(file: file ?? self.file, line: line ?? self.line) - defer { matcher.clearFileAndLine() } - return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } - } - private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { - return matchingCalls(method.method, file: file, line: line).count - } - private func givenGetterValue(_ method: MethodType, _ message: String) -> T { - do { - return try methodReturnValue(method).casted() - } catch { - onFatalFailure(message) - Failure(message) - } - } - private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { - do { - return try methodReturnValue(method).casted() - } catch { - return nil - } - } - private func onFatalFailure(_ message: String) { - guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully - SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) - } -} - diff --git a/Template/structured-swift6.stencil b/Template/structured-swift6.stencil new file mode 100644 index 000000000..e8a9a10ca --- /dev/null +++ b/Template/structured-swift6.stencil @@ -0,0 +1,436 @@ +// swiftlint:disable all +// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen + +{% if catalogs %} +{% macro hasValuesBlock assets filter %} + {%- for asset in assets -%} + {%- if asset.type == filter -%} + 1 + {%- elif asset.items -%} + {% call hasValuesBlock asset.items filter %} + {%- endif -%} + {%- endfor -%} +{% endmacro %} +{% set enumName %}{{param.enumName|default:"Asset"}}{% endset %} +{% set arResourceGroupType %}{{param.arResourceGroupTypeName|default:"ARResourceGroupAsset"}}{% endset %} +{% set colorType %}{{param.colorTypeName|default:"ColorAsset"}}{% endset %} +{% set dataType %}{{param.dataTypeName|default:"DataAsset"}}{% endset %} +{% set imageType %}{{param.imageTypeName|default:"ImageAsset"}}{% endset %} +{% set symbolType %}{{param.symbolTypeName|default:"SymbolAsset"}}{% endset %} +{% set forceNamespaces %}{{param.forceProvidesNamespaces|default:"false"}}{% endset %} +{% set accessModifier %}{% if param.publicAccess %}public{% else %}internal{% endif %}{% endset %} +{% set hasARResourceGroup %}{% for catalog in catalogs %}{% call hasValuesBlock catalog.assets "arresourcegroup" %}{% endfor %}{% endset %} +{% set hasColor %}{% for catalog in catalogs %}{% call hasValuesBlock catalog.assets "color" %}{% endfor %}{% endset %} +{% set hasData %}{% for catalog in catalogs %}{% call hasValuesBlock catalog.assets "data" %}{% endfor %}{% endset %} +{% set hasImage %}{% for catalog in catalogs %}{% call hasValuesBlock catalog.assets "image" %}{% endfor %}{% endset %} +{% set hasSymbol %}{% for catalog in catalogs %}{% call hasValuesBlock catalog.assets "symbol" %}{% endfor %}{% endset %} +#if os(macOS) + import AppKit +#elseif os(iOS) +{% if hasARResourceGroup %} + import ARKit +{% endif %} + import UIKit +#elseif os(tvOS) || os(watchOS) + import UIKit +#endif +#if canImport(SwiftUI) + import SwiftUI +#endif + +// Deprecated typealiases +{% if hasColor %} +@available(*, deprecated, renamed: "{{colorType}}.Color", message: "This typealias will be removed in SwiftGen 7.0") +{{accessModifier}} typealias {{param.colorAliasName|default:"AssetColorTypeAlias"}} = {{colorType}}.Color +{% endif %} +{% if hasImage %} +@available(*, deprecated, renamed: "{{imageType}}.Image", message: "This typealias will be removed in SwiftGen 7.0") +{{accessModifier}} typealias {{param.imageAliasName|default:"AssetImageTypeAlias"}} = {{imageType}}.Image +{% endif %} + +// swiftlint:disable superfluous_disable_command file_length implicit_return + +// MARK: - Asset Catalogs + +{% macro enumBlock assets %} + {% call casesBlock assets %} + {% if param.allValues %} + + // swiftlint:disable trailing_comma + {% set hasItems %}{% call hasValuesBlock assets "arresourcegroup" %}{% endset %} + {% if hasItems %} + @available(*, deprecated, message: "All values properties are now deprecated") + {{accessModifier}} static let allResourceGroups: [{{arResourceGroupType}}] = [ + {% filter indent:2," ",true %}{% call allValuesBlock assets "arresourcegroup" "" %}{% endfilter %} + ] + {% endif %} + {% set hasItems %}{% call hasValuesBlock assets "color" %}{% endset %} + {% if hasItems %} + @available(*, deprecated, message: "All values properties are now deprecated") + {{accessModifier}} static let allColors: [{{colorType}}] = [ + {% filter indent:2," ",true %}{% call allValuesBlock assets "color" "" %}{% endfilter %} + ] + {% endif %} + {% set hasItems %}{% call hasValuesBlock assets "data" %}{% endset %} + {% if hasItems %} + @available(*, deprecated, message: "All values properties are now deprecated") + {{accessModifier}} static let allDataAssets: [{{dataType}}] = [ + {% filter indent:2," ",true %}{% call allValuesBlock assets "data" "" %}{% endfilter %} + ] + {% endif %} + {% set hasItems %}{% call hasValuesBlock assets "image" %}{% endset %} + {% if hasItems %} + @available(*, deprecated, message: "All values properties are now deprecated") + {{accessModifier}} static let allImages: [{{imageType}}] = [ + {% filter indent:2," ",true %}{% call allValuesBlock assets "image" "" %}{% endfilter %} + ] + {% endif %} + {% set hasItems %}{% call hasValuesBlock assets "symbol" %}{% endset %} + {% if hasItems %} + @available(*, deprecated, message: "All values properties are now deprecated") + {{accessModifier}} static let allSymbols: [{{symbolType}}] = [ + {% filter indent:2," ",true %}{% call allValuesBlock assets "symbol" "" %}{% endfilter %} + ] + {% endif %} + // swiftlint:enable trailing_comma + {% endif %} +{% endmacro %} +{% macro casesBlock assets %} + {% for asset in assets %} + {% if asset.type == "arresourcegroup" %} + {{accessModifier}} static let {{asset.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}} = {{arResourceGroupType}}(name: "{{asset.value}}") + {% elif asset.type == "color" %} + {{accessModifier}} static let {{asset.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}} = {{colorType}}(name: "{{asset.value}}") + {% elif asset.type == "data" %} + {{accessModifier}} static let {{asset.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}} = {{dataType}}(name: "{{asset.value}}") + {% elif asset.type == "image" %} + {{accessModifier}} static let {{asset.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}} = {{imageType}}(name: "{{asset.value}}") + {% elif asset.type == "symbol" %} + {{accessModifier}} static let {{asset.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}} = {{symbolType}}(name: "{{asset.value}}") + {% elif asset.items and ( forceNamespaces == "true" or asset.isNamespaced == "true" ) %} + {{accessModifier}} enum {{asset.name|swiftIdentifier:"pretty"|escapeReservedKeywords}} { + {% filter indent:2," ",true %}{% call casesBlock asset.items %}{% endfilter %} + } + {% elif asset.items %} + {% call casesBlock asset.items %} + {% endif %} + {% endfor %} +{% endmacro %} +{% macro allValuesBlock assets filter prefix %} + {% for asset in assets %} + {% if asset.type == filter %} + {{prefix}}{{asset.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}}, + {% elif asset.items and ( forceNamespaces == "true" or asset.isNamespaced == "true" ) %} + {% set prefix2 %}{{prefix}}{{asset.name|swiftIdentifier:"pretty"|escapeReservedKeywords}}.{% endset %} + {% call allValuesBlock asset.items filter prefix2 %} + {% elif asset.items %} + {% call allValuesBlock asset.items filter prefix %} + {% endif %} + {% endfor %} +{% endmacro %} +// swiftlint:disable identifier_name line_length nesting type_body_length type_name +{{accessModifier}} enum {{enumName}} { + {% if catalogs.count > 1 or param.forceFileNameEnum %} + {% for catalog in catalogs %} + {{accessModifier}} enum {{catalog.name|swiftIdentifier:"pretty"|escapeReservedKeywords}} { + {% if catalog.assets %} + {% filter indent:2," ",true %}{% call enumBlock catalog.assets %}{% endfilter %} + {% endif %} + } + {% endfor %} + {% else %} + {% call enumBlock catalogs.first.assets %} + {% endif %} +} +// swiftlint:enable identifier_name line_length nesting type_body_length type_name + +// MARK: - Implementation Details +{% if hasARResourceGroup %} + +{{accessModifier}} struct {{arResourceGroupType}}: Sendable { + {{accessModifier}} let name: String + + #if os(iOS) + @available(iOS 11.3, *) + {{accessModifier}} var referenceImages: Set { + return ARReferenceImage.referenceImages(in: self) + } + + @available(iOS 12.0, *) + {{accessModifier}} var referenceObjects: Set { + return ARReferenceObject.referenceObjects(in: self) + } + #endif +} + +#if os(iOS) +@available(iOS 11.3, *) +{{accessModifier}} extension ARReferenceImage { + static func referenceImages(in asset: {{arResourceGroupType}}) -> Set { + let bundle = {{param.bundle|default:"BundleToken.bundle"}} + return referenceImages(inGroupNamed: asset.name, bundle: bundle) ?? Set() + } +} + +@available(iOS 12.0, *) +{{accessModifier}} extension ARReferenceObject { + static func referenceObjects(in asset: {{arResourceGroupType}}) -> Set { + let bundle = {{param.bundle|default:"BundleToken.bundle"}} + return referenceObjects(inGroupNamed: asset.name, bundle: bundle) ?? Set() + } +} +#endif +{% endif %} +{% if hasColor %} + +{{accessModifier}} final class {{colorType}}: Sendable { + {{accessModifier}} let name: String + + #if os(macOS) + {{accessModifier}} typealias Color = NSColor + #elseif os(iOS) || os(tvOS) || os(watchOS) + {{accessModifier}} typealias Color = UIColor + #endif + + @available(iOS 11.0, tvOS 11.0, watchOS 4.0, macOS 10.13, *) + {{accessModifier}} let color: Color + + #if os(iOS) || os(tvOS) + @available(iOS 11.0, tvOS 11.0, *) + {{accessModifier}} func color(compatibleWith traitCollection: UITraitCollection) -> Color { + let bundle = {{param.bundle|default:"BundleToken.bundle"}} + guard let color = Color(named: name, in: bundle, compatibleWith: traitCollection) else { + fatalError("Unable to load color asset named \(name).") + } + return color + } + #endif + + #if canImport(SwiftUI) + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) + {{accessModifier}} var swiftUIColor: SwiftUI.Color { + SwiftUI.Color(uiColor: color) + } + #endif + + fileprivate init(name: String) { + self.name = name + guard let color = Color(assetName: name) else { + fatalError("Unable to load color asset named \(name).") + } + self.color = color + } +} + +{{accessModifier}} extension {{colorType}}.Color { + @available(iOS 11.0, tvOS 11.0, watchOS 4.0, macOS 10.13, *) + convenience init?(assetName: String) { + let bundle = {{param.bundle|default:"BundleToken.bundle"}} + #if os(iOS) || os(tvOS) + self.init(named: assetName, in: bundle, compatibleWith: nil) + #elseif os(macOS) + self.init(named: NSColor.Name(assetName), bundle: bundle) + #elseif os(watchOS) + self.init(named: assetName) + #endif + } +} + +#if canImport(SwiftUI) +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) +{{accessModifier}} extension SwiftUI.Color { + init(assetName: String) { + let bundle = {{param.bundle|default:"BundleToken.bundle"}} + self.init(assetName, bundle: bundle) + } +} +#endif +{% endif %} +{% if hasData %} + +{{accessModifier}} struct {{dataType}}: Sendable { + {{accessModifier}} let name: String + + @available(iOS 9.0, tvOS 9.0, watchOS 6.0, macOS 10.11, *) + {{accessModifier}} var data: NSDataAsset { + guard let data = NSDataAsset(asset: self) else { + fatalError("Unable to load data asset named \(name).") + } + return data + } +} + +@available(iOS 9.0, tvOS 9.0, watchOS 6.0, macOS 10.11, *) +{{accessModifier}} extension NSDataAsset { + convenience init?(asset: {{dataType}}) { + let bundle = {{param.bundle|default:"BundleToken.bundle"}} + #if os(iOS) || os(tvOS) || os(watchOS) + self.init(name: asset.name, bundle: bundle) + #elseif os(macOS) + self.init(name: NSDataAsset.Name(asset.name), bundle: bundle) + #endif + } +} +{% endif %} +{% if hasImage %} + +{{accessModifier}} struct {{imageType}}: Sendable { + {{accessModifier}} let name: String + + #if os(macOS) + {{accessModifier}} typealias Image = NSImage + #elseif os(iOS) || os(tvOS) || os(watchOS) + {{accessModifier}} typealias Image = UIImage + #endif + + @available(iOS 8.0, tvOS 9.0, watchOS 2.0, macOS 10.7, *) + {{accessModifier}} var image: Image { + let bundle = {{param.bundle|default:"BundleToken.bundle"}} + #if os(iOS) || os(tvOS) + let image = Image(named: name, in: bundle, compatibleWith: nil) + #elseif os(macOS) + let name = NSImage.Name(self.name) + let image = (bundle == .main) ? NSImage(named: name) : bundle.image(forResource: name) + #elseif os(watchOS) + let image = Image(named: name) + #endif + guard let result = image else { + fatalError("Unable to load image asset named \(name).") + } + return result + } + + #if os(iOS) || os(tvOS) + @available(iOS 8.0, tvOS 9.0, *) + {{accessModifier}} func image(compatibleWith traitCollection: UITraitCollection) -> Image { + let bundle = {{param.bundle|default:"BundleToken.bundle"}} + guard let result = Image(named: name, in: bundle, compatibleWith: traitCollection) else { + fatalError("Unable to load image asset named \(name).") + } + return result + } + #endif + + #if canImport(SwiftUI) + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) + {{accessModifier}} var swiftUIImage: SwiftUI.Image { + SwiftUI.Image(asset: self) + } + #endif +} + +{{accessModifier}} extension {{imageType}}.Image { + @available(iOS 8.0, tvOS 9.0, watchOS 2.0, *) + @available(macOS, deprecated, + message: "This initializer is unsafe on macOS, please use the {{imageType}}.image property") + convenience init?(asset: {{imageType}}) { + #if os(iOS) || os(tvOS) + let bundle = {{param.bundle|default:"BundleToken.bundle"}} + self.init(named: asset.name, in: bundle, compatibleWith: nil) + #elseif os(macOS) + self.init(named: NSImage.Name(asset.name)) + #elseif os(watchOS) + self.init(named: asset.name) + #endif + } +} + +#if canImport(SwiftUI) +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) +{{accessModifier}} extension SwiftUI.Image { + init(asset: {{imageType}}) { + let bundle = {{param.bundle|default:"BundleToken.bundle"}} + self.init(asset.name, bundle: bundle) + } + + init(asset: {{imageType}}, label: Text) { + let bundle = {{param.bundle|default:"BundleToken.bundle"}} + self.init(asset.name, bundle: bundle, label: label) + } + + init(decorative asset: {{imageType}}) { + let bundle = {{param.bundle|default:"BundleToken.bundle"}} + self.init(decorative: asset.name, bundle: bundle) + } +} +#endif +{% endif %} +{% if hasSymbol %} + +{{accessModifier}} struct {{symbolType}}: Sendable { + {{accessModifier}} let name: String + + #if os(iOS) || os(tvOS) || os(watchOS) + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, *) + {{accessModifier}} typealias Configuration = UIImage.SymbolConfiguration + {{accessModifier}} typealias Image = UIImage + + @available(iOS 12.0, tvOS 12.0, watchOS 5.0, *) + {{accessModifier}} var image: Image { + let bundle = {{param.bundle|default:"BundleToken.bundle"}} + #if os(iOS) || os(tvOS) + let image = Image(named: name, in: bundle, compatibleWith: nil) + #elseif os(watchOS) + let image = Image(named: name) + #endif + guard let result = image else { + fatalError("Unable to load symbol asset named \(name).") + } + return result + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, *) + {{accessModifier}} func image(with configuration: Configuration) -> Image { + let bundle = {{param.bundle|default:"BundleToken.bundle"}} + guard let result = Image(named: name, in: bundle, with: configuration) else { + fatalError("Unable to load symbol asset named \(name).") + } + return result + } + #endif + + #if canImport(SwiftUI) + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) + {{accessModifier}} var swiftUIImage: SwiftUI.Image { + SwiftUI.Image(asset: self) + } + #endif +} + +#if canImport(SwiftUI) +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) +{{accessModifier}} extension SwiftUI.Image { + init(asset: {{symbolType}}) { + let bundle = {{param.bundle|default:"BundleToken.bundle"}} + self.init(asset.name, bundle: bundle) + } + + init(asset: {{symbolType}}, label: Text) { + let bundle = {{param.bundle|default:"BundleToken.bundle"}} + self.init(asset.name, bundle: bundle, label: label) + } + + init(decorative asset: {{symbolType}}) { + let bundle = {{param.bundle|default:"BundleToken.bundle"}} + self.init(decorative: asset.name, bundle: bundle) + } +} +#endif +{% endif %} +{% if not param.bundle %} + +// swiftlint:disable convenience_type +private final class BundleToken { + static let bundle: Bundle = { + #if SWIFT_PACKAGE + return Bundle.module + #else + return Bundle(for: BundleToken.self) + #endif + }() +} +// swiftlint:enable convenience_type +{% endif %} +{% else %} +// No assets found +{% endif %} diff --git a/Theme/Theme.xcodeproj/project.pbxproj b/Theme/Theme.xcodeproj/project.pbxproj index 6178a2a2a..1fc77b799 100644 --- a/Theme/Theme.xcodeproj/project.pbxproj +++ b/Theme/Theme.xcodeproj/project.pbxproj @@ -524,7 +524,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugStage; @@ -616,7 +616,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseStage; @@ -715,7 +715,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugDev; @@ -807,7 +807,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseDev; @@ -906,7 +906,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugProd; @@ -998,7 +998,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseProd; @@ -1155,7 +1155,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -1189,7 +1189,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; diff --git a/Theme/Theme/Fonts/FontParser.swift b/Theme/Theme/Fonts/FontParser.swift index d7ed99b24..29e59dc2d 100644 --- a/Theme/Theme/Fonts/FontParser.swift +++ b/Theme/Theme/Fonts/FontParser.swift @@ -7,18 +7,18 @@ import Foundation -public enum FontIdentifier: String { +public enum FontIdentifier: String, Sendable { case light, regular, medium, semiBold, bold } -public class FontParser { +public struct FontParser: Sendable { private var fonts: [String: String] = [:] public init() { - fonts = loadANdParseFonts() + fonts = loadAndParseFonts() } - private func loadANdParseFonts() -> [String: String] { + private func loadAndParseFonts() -> [String: String] { if let path = Bundle(for: ThemeBundle.self).path(forResource: "fonts", ofType: "json") { do { let data = try Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) diff --git a/Theme/Theme/SwiftGen/ThemeAssets.swift b/Theme/Theme/SwiftGen/ThemeAssets.swift index c7c49e9b2..9b6caa87e 100644 --- a/Theme/Theme/SwiftGen/ThemeAssets.swift +++ b/Theme/Theme/SwiftGen/ThemeAssets.swift @@ -93,8 +93,8 @@ public enum ThemeAssets { // MARK: - Implementation Details -public final class ColorAsset { - public fileprivate(set) var name: String +public final class ColorAsset: Sendable { + public let name: String #if os(macOS) public typealias Color = NSColor @@ -103,12 +103,7 @@ public final class ColorAsset { #endif @available(iOS 11.0, tvOS 11.0, watchOS 4.0, macOS 10.13, *) - public private(set) lazy var color: Color = { - guard let color = Color(asset: self) else { - fatalError("Unable to load color asset named \(name).") - } - return color - }() + public let color: Color #if os(iOS) || os(tvOS) @available(iOS 11.0, tvOS 11.0, *) @@ -123,26 +118,30 @@ public final class ColorAsset { #if canImport(SwiftUI) @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) - public private(set) lazy var swiftUIColor: SwiftUI.Color = { - SwiftUI.Color(asset: self) - }() + public var swiftUIColor: SwiftUI.Color { + SwiftUI.Color(uiColor: color) + } #endif fileprivate init(name: String) { self.name = name + guard let color = Color(assetName: name) else { + fatalError("Unable to load color asset named \(name).") + } + self.color = color } } public extension ColorAsset.Color { @available(iOS 11.0, tvOS 11.0, watchOS 4.0, macOS 10.13, *) - convenience init?(asset: ColorAsset) { + convenience init?(assetName: String) { let bundle = BundleToken.bundle #if os(iOS) || os(tvOS) - self.init(named: asset.name, in: bundle, compatibleWith: nil) + self.init(named: assetName, in: bundle, compatibleWith: nil) #elseif os(macOS) - self.init(named: NSColor.Name(asset.name), bundle: bundle) + self.init(named: NSColor.Name(assetName), bundle: bundle) #elseif os(watchOS) - self.init(named: asset.name) + self.init(named: assetName) #endif } } @@ -150,15 +149,15 @@ public extension ColorAsset.Color { #if canImport(SwiftUI) @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) public extension SwiftUI.Color { - init(asset: ColorAsset) { + init(assetName: String) { let bundle = BundleToken.bundle - self.init(asset.name, bundle: bundle) + self.init(assetName, bundle: bundle) } } #endif -public struct ImageAsset { - public fileprivate(set) var name: String +public struct ImageAsset: Sendable { + public let name: String #if os(macOS) public typealias Image = NSImage diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index ddff37c66..dc8c1438e 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -8,74 +8,74 @@ import Foundation import SwiftUI -private var fontsParser = FontParser() +private let fontsParser = FontParser() -public struct Theme { +public struct Theme: Sendable { // swiftlint:disable line_length - public struct Colors { - public private(set) static var accentColor = ThemeAssets.accentColor.swiftUIColor - public private(set) static var accentXColor = ThemeAssets.accentXColor.swiftUIColor - public private(set) static var accentButtonColor = ThemeAssets.accentButtonColor.swiftUIColor - public private(set) static var alert = ThemeAssets.alert.swiftUIColor - public private(set) static var avatarStroke = ThemeAssets.avatarStroke.swiftUIColor - public private(set) static var background = ThemeAssets.background.swiftUIColor - public private(set) static var loginBackground = ThemeAssets.loginBackground.swiftUIColor - public private(set) static var backgroundStroke = ThemeAssets.backgroundStroke.swiftUIColor - public private(set) static var cardViewBackground = ThemeAssets.cardViewBackground.swiftUIColor - public private(set) static var cardViewStroke = ThemeAssets.cardViewStroke.swiftUIColor - public private(set) static var certificateForeground = ThemeAssets.certificateForeground.swiftUIColor - public private(set) static var commentCellBackground = ThemeAssets.commentCellBackground.swiftUIColor - public private(set) static var nextWeekTimelineColor = ThemeAssets.nextWeekTimelineColor.swiftUIColor - public private(set) static var pastDueTimelineColor = ThemeAssets.pastDueTimelineColor.swiftUIColor - public private(set) static var thisWeekTimelineColor = ThemeAssets.thisWeekTimelineColor.swiftUIColor - public private(set) static var todayTimelineColor = ThemeAssets.todayTimelineColor.swiftUIColor - public private(set) static var upcomingTimelineColor = ThemeAssets.upcomingTimelineColor.swiftUIColor - public private(set) static var shadowColor = ThemeAssets.shadowColor.swiftUIColor - public private(set) static var snackbarErrorColor = ThemeAssets.snackbarErrorColor.swiftUIColor - public private(set) static var snackbarWarningColor = ThemeAssets.snackbarWarningColor.swiftUIColor - public private(set) static var snackbarInfoColor = ThemeAssets.snackbarInfoColor.swiftUIColor - public private(set) static var snackbarTextColor = ThemeAssets.snackbarTextColor.swiftUIColor - public private(set) static var styledButtonText = ThemeAssets.styledButtonText.swiftUIColor - public private(set) static var disabledButton = ThemeAssets.disabledButton.swiftUIColor - public private(set) static var disabledButtonText = ThemeAssets.disabledButtonText.swiftUIColor - public private(set) static var textPrimary = ThemeAssets.textPrimary.swiftUIColor - public private(set) static var textSecondary = ThemeAssets.textSecondary.swiftUIColor - public private(set) static var textSecondaryLight = ThemeAssets.textSecondaryLight.swiftUIColor - public private(set) static var textInputBackground = ThemeAssets.textInputBackground.swiftUIColor - public private(set) static var textInputStroke = ThemeAssets.textInputStroke.swiftUIColor - public private(set) static var textInputUnfocusedBackground = ThemeAssets.textInputUnfocusedBackground.swiftUIColor - public private(set) static var textInputUnfocusedStroke = ThemeAssets.textInputUnfocusedStroke.swiftUIColor - public private(set) static var warning = ThemeAssets.warning.swiftUIColor - public private(set) static var warningText = ThemeAssets.warningText.swiftUIColor - public private(set) static var white = ThemeAssets.white.swiftUIColor - public private(set) static var onProgress = ThemeAssets.onProgress.swiftUIColor - public private(set) static var progressDone = ThemeAssets.progressDone.swiftUIColor - public private(set) static var progressSkip = ThemeAssets.progressSkip.swiftUIColor - public private(set) static var progressSelectedAndDone = ThemeAssets.selectedAndDone.swiftUIColor - public private(set) static var loginNavigationText = ThemeAssets.loginNavigationText.swiftUIColor - public private(set) static var datesSectionBackground = ThemeAssets.datesSectionBackground.swiftUIColor - public private(set) static var datesSectionStroke = ThemeAssets.datesSectionStroke.swiftUIColor - public private(set) static var navigationBarTintColor = ThemeAssets.navigationBarTintColor.swiftUIColor - public private(set) static var secondaryButtonBorderColor = ThemeAssets.secondaryButtonBorderColor.swiftUIColor - public private(set) static var secondaryButtonTextColor = ThemeAssets.secondaryButtonTextColor.swiftUIColor - public private(set) static var secondaryButtonBGColor = ThemeAssets.secondaryButtonBGColor.swiftUIColor - public private(set) static var success = ThemeAssets.success.swiftUIColor - public private(set) static var tabbarColor = ThemeAssets.tabbarColor.swiftUIColor - public private(set) static var primaryButtonTextColor = ThemeAssets.primaryButtonTextColor.swiftUIColor - public private(set) static var toggleSwitchColor = ThemeAssets.toggleSwitchColor.swiftUIColor - public private(set) static var textInputTextColor = ThemeAssets.textInputTextColor.swiftUIColor - public private(set) static var textInputPlaceholderColor = ThemeAssets.textInputPlaceholderColor.swiftUIColor - public private(set) static var infoColor = ThemeAssets.infoColor.swiftUIColor - public private(set) static var irreversibleAlert = ThemeAssets.irreversibleAlert.swiftUIColor - public private(set) static var slidingTextColor = ThemeAssets.slidingTextColor.swiftUIColor - public private(set) static var slidingSelectedTextColor = ThemeAssets.slidingSelectedTextColor.swiftUIColor - public private(set) static var slidingStrokeColor = ThemeAssets.slidingStrokeColor.swiftUIColor - public private(set) static var primaryHeaderColor = ThemeAssets.primaryHeaderColor.swiftUIColor - public private(set) static var secondaryHeaderColor = ThemeAssets.secondaryHeaderColor.swiftUIColor - public private(set) static var courseCardShadow = ThemeAssets.courseCardShadow.swiftUIColor - public private(set) static var shade = ThemeAssets.shade.swiftUIColor - public private(set) static var courseCardBackground = ThemeAssets.courseCardBackground.swiftUIColor + public struct Colors: Sendable { + nonisolated(unsafe) public private(set) static var accentColor = ThemeAssets.accentColor.swiftUIColor + nonisolated(unsafe) public private(set) static var accentXColor = ThemeAssets.accentXColor.swiftUIColor + nonisolated(unsafe) public private(set) static var accentButtonColor = ThemeAssets.accentButtonColor.swiftUIColor + nonisolated(unsafe) public private(set) static var alert = ThemeAssets.alert.swiftUIColor + nonisolated(unsafe) public private(set) static var avatarStroke = ThemeAssets.avatarStroke.swiftUIColor + nonisolated(unsafe) public private(set) static var background = ThemeAssets.background.swiftUIColor + nonisolated(unsafe) public private(set) static var loginBackground = ThemeAssets.loginBackground.swiftUIColor + nonisolated(unsafe) public private(set) static var backgroundStroke = ThemeAssets.backgroundStroke.swiftUIColor + nonisolated(unsafe) public private(set) static var cardViewBackground = ThemeAssets.cardViewBackground.swiftUIColor + nonisolated(unsafe) public private(set) static var cardViewStroke = ThemeAssets.cardViewStroke.swiftUIColor + nonisolated(unsafe) public private(set) static var certificateForeground = ThemeAssets.certificateForeground.swiftUIColor + nonisolated(unsafe) public private(set) static var commentCellBackground = ThemeAssets.commentCellBackground.swiftUIColor + nonisolated(unsafe) public private(set) static var nextWeekTimelineColor = ThemeAssets.nextWeekTimelineColor.swiftUIColor + nonisolated(unsafe) public private(set) static var pastDueTimelineColor = ThemeAssets.pastDueTimelineColor.swiftUIColor + nonisolated(unsafe) public private(set) static var thisWeekTimelineColor = ThemeAssets.thisWeekTimelineColor.swiftUIColor + nonisolated(unsafe) public private(set) static var todayTimelineColor = ThemeAssets.todayTimelineColor.swiftUIColor + nonisolated(unsafe) public private(set) static var upcomingTimelineColor = ThemeAssets.upcomingTimelineColor.swiftUIColor + nonisolated(unsafe) public private(set) static var shadowColor = ThemeAssets.shadowColor.swiftUIColor + nonisolated(unsafe) public private(set) static var snackbarErrorColor = ThemeAssets.snackbarErrorColor.swiftUIColor + nonisolated(unsafe) public private(set) static var snackbarWarningColor = ThemeAssets.snackbarWarningColor.swiftUIColor + nonisolated(unsafe) public private(set) static var snackbarInfoColor = ThemeAssets.snackbarInfoColor.swiftUIColor + nonisolated(unsafe) public private(set) static var snackbarTextColor = ThemeAssets.snackbarTextColor.swiftUIColor + nonisolated(unsafe) public private(set) static var styledButtonText = ThemeAssets.styledButtonText.swiftUIColor + nonisolated(unsafe) public private(set) static var disabledButton = ThemeAssets.disabledButton.swiftUIColor + nonisolated(unsafe) public private(set) static var disabledButtonText = ThemeAssets.disabledButtonText.swiftUIColor + nonisolated(unsafe) public private(set) static var textPrimary = ThemeAssets.textPrimary.swiftUIColor + nonisolated(unsafe) public private(set) static var textSecondary = ThemeAssets.textSecondary.swiftUIColor + nonisolated(unsafe) public private(set) static var textSecondaryLight = ThemeAssets.textSecondaryLight.swiftUIColor + nonisolated(unsafe) public private(set) static var textInputBackground = ThemeAssets.textInputBackground.swiftUIColor + nonisolated(unsafe) public private(set) static var textInputStroke = ThemeAssets.textInputStroke.swiftUIColor + nonisolated(unsafe) public private(set) static var textInputUnfocusedBackground = ThemeAssets.textInputUnfocusedBackground.swiftUIColor + nonisolated(unsafe) public private(set) static var textInputUnfocusedStroke = ThemeAssets.textInputUnfocusedStroke.swiftUIColor + nonisolated(unsafe) public private(set) static var warning = ThemeAssets.warning.swiftUIColor + nonisolated(unsafe) public private(set) static var warningText = ThemeAssets.warningText.swiftUIColor + nonisolated(unsafe) public private(set) static var white = ThemeAssets.white.swiftUIColor + nonisolated(unsafe) public private(set) static var onProgress = ThemeAssets.onProgress.swiftUIColor + nonisolated(unsafe) public private(set) static var progressDone = ThemeAssets.progressDone.swiftUIColor + nonisolated(unsafe) public private(set) static var progressSkip = ThemeAssets.progressSkip.swiftUIColor + nonisolated(unsafe) public private(set) static var progressSelectedAndDone = ThemeAssets.selectedAndDone.swiftUIColor + nonisolated(unsafe) public private(set) static var loginNavigationText = ThemeAssets.loginNavigationText.swiftUIColor + nonisolated(unsafe) public private(set) static var datesSectionBackground = ThemeAssets.datesSectionBackground.swiftUIColor + nonisolated(unsafe) public private(set) static var datesSectionStroke = ThemeAssets.datesSectionStroke.swiftUIColor + nonisolated(unsafe) public private(set) static var navigationBarTintColor = ThemeAssets.navigationBarTintColor.swiftUIColor + nonisolated(unsafe) public private(set) static var secondaryButtonBorderColor = ThemeAssets.secondaryButtonBorderColor.swiftUIColor + nonisolated(unsafe) public private(set) static var secondaryButtonTextColor = ThemeAssets.secondaryButtonTextColor.swiftUIColor + nonisolated(unsafe) public private(set) static var secondaryButtonBGColor = ThemeAssets.secondaryButtonBGColor.swiftUIColor + nonisolated(unsafe) public private(set) static var success = ThemeAssets.success.swiftUIColor + nonisolated(unsafe) public private(set) static var tabbarColor = ThemeAssets.tabbarColor.swiftUIColor + nonisolated(unsafe) public private(set) static var primaryButtonTextColor = ThemeAssets.primaryButtonTextColor.swiftUIColor + nonisolated(unsafe) public private(set) static var toggleSwitchColor = ThemeAssets.toggleSwitchColor.swiftUIColor + nonisolated(unsafe) public private(set) static var textInputTextColor = ThemeAssets.textInputTextColor.swiftUIColor + nonisolated(unsafe) public private(set) static var textInputPlaceholderColor = ThemeAssets.textInputPlaceholderColor.swiftUIColor + nonisolated(unsafe) public private(set) static var infoColor = ThemeAssets.infoColor.swiftUIColor + nonisolated(unsafe) public private(set) static var irreversibleAlert = ThemeAssets.irreversibleAlert.swiftUIColor + nonisolated(unsafe) public private(set) static var slidingTextColor = ThemeAssets.slidingTextColor.swiftUIColor + nonisolated(unsafe) public private(set) static var slidingSelectedTextColor = ThemeAssets.slidingSelectedTextColor.swiftUIColor + nonisolated(unsafe) public private(set) static var slidingStrokeColor = ThemeAssets.slidingStrokeColor.swiftUIColor + nonisolated(unsafe) public private(set) static var primaryHeaderColor = ThemeAssets.primaryHeaderColor.swiftUIColor + nonisolated(unsafe) public private(set) static var secondaryHeaderColor = ThemeAssets.secondaryHeaderColor.swiftUIColor + nonisolated(unsafe) public private(set) static var courseCardShadow = ThemeAssets.courseCardShadow.swiftUIColor + nonisolated(unsafe) public private(set) static var shade = ThemeAssets.shade.swiftUIColor + nonisolated(unsafe) public private(set) static var courseCardBackground = ThemeAssets.courseCardBackground.swiftUIColor public static func update( accentColor: Color = ThemeAssets.accentColor.swiftUIColor, @@ -175,10 +175,10 @@ public struct Theme { // Use this structure where the computed Color.uiColor() extension is not appropriate. public struct UIColors { - public private(set) static var textPrimary = ThemeAssets.textPrimary.color - public private(set) static var accentColor = ThemeAssets.accentColor.color - public private(set) static var accentXColor = ThemeAssets.accentXColor.color - public private(set) static var navigationBarTintColor = ThemeAssets.navigationBarTintColor.color + nonisolated(unsafe) public private(set) static var textPrimary = ThemeAssets.textPrimary.color + nonisolated(unsafe) public private(set) static var accentColor = ThemeAssets.accentColor.color + nonisolated(unsafe) public private(set) static var accentXColor = ThemeAssets.accentXColor.color + nonisolated(unsafe) public private(set) static var navigationBarTintColor = ThemeAssets.navigationBarTintColor.color public static func update( textPrimary: UIColor = ThemeAssets.textPrimary.color, @@ -193,7 +193,7 @@ public struct Theme { } } - public struct Fonts { + public struct Fonts: Sendable { public static let displayLarge: Font = .custom(fontsParser.fontName(for: .regular), size: 57) public static let displayMedium: Font = .custom(fontsParser.fontName(for: .regular), size: 45) @@ -217,7 +217,7 @@ public struct Theme { public static let labelSmall: Font = .custom(fontsParser.fontName(for: .regular), size: 10) } - public struct UIFonts { + public struct UIFonts: Sendable { public static func labelSmall() -> UIFont { guard let font = UIFont(name: fontsParser.fontName(for: .regular), size: 10) else { assert(false, "Could not find the required font") @@ -246,8 +246,8 @@ public struct Theme { } } - public struct Shapes { - public static var isRoundedCorners: Bool = true + public struct Shapes: Sendable { + nonisolated(unsafe) public static var isRoundedCorners: Bool = true public static let screenBackgroundRadius = 24.0 public static let cardImageRadius = 10.0 public static let textInputShape = { diff --git a/Theme/swiftgen.yml b/Theme/swiftgen.yml index ec5f05e3c..e6b73ca5e 100644 --- a/Theme/swiftgen.yml +++ b/Theme/swiftgen.yml @@ -2,7 +2,7 @@ xcassets: inputs: - Theme/Assets.xcassets outputs: - templateName: swift5 + templatePath: ../Template/structured-swift6.stencil params: publicAccess: true enumName: ThemeAssets diff --git a/WhatsNew/WhatsNew.xcodeproj/project.pbxproj b/WhatsNew/WhatsNew.xcodeproj/project.pbxproj index 86fca97de..dbf013488 100644 --- a/WhatsNew/WhatsNew.xcodeproj/project.pbxproj +++ b/WhatsNew/WhatsNew.xcodeproj/project.pbxproj @@ -659,7 +659,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -697,7 +697,7 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -844,7 +844,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugDev; @@ -970,7 +970,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugProd; @@ -1096,7 +1096,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = DebugStage; @@ -1214,7 +1214,7 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseDev; @@ -1332,7 +1332,7 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseProd; @@ -1450,7 +1450,7 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = ReleaseStage; @@ -1532,7 +1532,7 @@ repositoryURL = "https://github.com/openedx/openedx-app-foundation-ios/"; requirement = { kind = exactVersion; - version = 1.0.0; + version = 1.0.1; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/WhatsNew/WhatsNew/Data/WhatsNewStorage.swift b/WhatsNew/WhatsNew/Data/WhatsNewStorage.swift index 35d35d998..57599838e 100644 --- a/WhatsNew/WhatsNew/Data/WhatsNewStorage.swift +++ b/WhatsNew/WhatsNew/Data/WhatsNewStorage.swift @@ -7,12 +7,12 @@ import Foundation -public protocol WhatsNewStorage { +public protocol WhatsNewStorage: Sendable { var whatsNewVersion: String? {get set} } #if DEBUG -public class WhatsNewStorageMock: WhatsNewStorage { +public final class WhatsNewStorageMock: WhatsNewStorage, @unchecked Sendable { public var whatsNewVersion: String? diff --git a/WhatsNew/WhatsNew/Presentation/WhatsNewRouter.swift b/WhatsNew/WhatsNew/Presentation/WhatsNewRouter.swift index 4416e24fd..777a81be1 100644 --- a/WhatsNew/WhatsNew/Presentation/WhatsNewRouter.swift +++ b/WhatsNew/WhatsNew/Presentation/WhatsNewRouter.swift @@ -8,6 +8,7 @@ import Foundation import Core +@MainActor public protocol WhatsNewRouter: BaseRouter { } diff --git a/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift b/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift index 212ea92fb..4cedf506b 100644 --- a/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift +++ b/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift @@ -9,6 +9,7 @@ import SwiftUI import Core import Swinject +@MainActor public class WhatsNewViewModel: ObservableObject { @Published var index: Int = 0 @Published var newItems: [WhatsNewPage] = [] diff --git a/WhatsNew/WhatsNewTests/Presentation/WhatsNewTests.swift b/WhatsNew/WhatsNewTests/Presentation/WhatsNewTests.swift index f7453a662..26bdc4ba8 100644 --- a/WhatsNew/WhatsNewTests/Presentation/WhatsNewTests.swift +++ b/WhatsNew/WhatsNewTests/Presentation/WhatsNewTests.swift @@ -9,6 +9,7 @@ import XCTest import Core @testable import WhatsNew +@MainActor final class WhatsNewTests: XCTestCase { func testGetVersion() throws {