From 23c219c7c705823d60350c794232836d947c18e7 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 30 Sep 2024 11:19:03 +0300 Subject: [PATCH 1/6] feat: [FC-0047] xBlock offline mode (#474) * feat: course offline mode * Merge branch 'develop' into feat/course-offline-mode * fix: address feedback * fix: address feedback * fix: address feedback * fix: resolve merge conflicts * fix: update mocks * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback --- .../AuthorizationMock.generated.swift | 840 ++++++++++++++++ Core/Core.xcodeproj/project.pbxproj | 45 + Core/Core/Analytics/CoreAnalytics.swift | 2 + .../check_circle.imageset/Contents.json | 15 + .../check_circle.imageset/check_circle.svg | 3 + .../download.imageset/Contents.json | 15 + .../download.imageset/download.svg | 12 + .../remove.imageset/Contents.json | 15 + .../remove.imageset/remove.svg | 3 + .../report_octagon.imageset/Contents.json | 15 + .../report_octagon.imageset/report.svg | 3 + .../visibility.imageset/Contents.json | 15 + .../visibility.imageset/visibility.svg | 3 + .../CoreDataModel.xcdatamodel/contents | 12 +- .../Persistence/CorePersistenceProtocol.swift | 33 + .../Repository/OfflineSyncRepository.swift | 42 + Core/Core/Domain/Model/CourseBlockModel.swift | 48 +- Core/Core/Domain/Model/OfflineProgress.swift | 50 + Core/Core/Domain/OfflineSyncInteractor.swift | 29 + Core/Core/Extensions/IntExtension.swift | 25 + Core/Core/Extensions/Notification.swift | 3 + Core/Core/Network/DownloadManager.swift | 292 ++++-- Core/Core/Network/OfflineSyncEndpoint.swift | 64 ++ Core/Core/Network/OfflineSyncManager.swift | 85 ++ Core/Core/SwiftGen/Assets.swift | 5 + Core/Core/View/Base/FileWebView.swift | 65 ++ Core/Core/View/Base/WebBrowser.swift | 16 +- Core/Core/View/Base/WebUnitView.swift | 62 +- Core/Core/View/Base/WebUnitViewModel.swift | 8 +- Core/Core/View/Base/Webview/WebView.swift | 58 +- Course/Course.xcodeproj/project.pbxproj | 57 +- Course/Course/Data/CourseRepository.swift | 33 +- .../Model/Data_CourseOutlineResponse.swift | 24 +- .../Course/Data/Network/CourseEndpoint.swift | 4 +- .../CourseCoreModel.xcdatamodel/contents | 7 +- Course/Course/Domain/CourseInteractor.swift | 25 +- .../Container/CourseContainerView.swift | 13 + .../Container/CourseContainerViewModel.swift | 633 ++++++++++-- .../Course/Presentation/CourseAnalytics.swift | 2 + .../Presentation/Offline/OfflineView.swift | 268 ++++++ .../Subviews/LargestDownloadsView.swift | 178 ++++ .../TotalDownloadedProgressView.swift | 105 ++ .../Outline/ContinueWithView.swift | 6 +- .../Outline/CourseOutlineView.swift | 4 +- .../CourseVerticalImageView.swift | 15 +- .../CourseVertical/CourseVerticalView.swift | 42 - .../CourseVerticalViewModel.swift | 26 +- .../DeviceStorageFullAlertView.swift | 235 +++++ .../ActionViews/DownloadActionView.swift | 326 +++++++ .../ActionViews/DownloadErrorAlertView.swift | 298 ++++++ .../CourseVideoDownloadBarViewModel.swift | 12 +- .../Subviews/CustomDisclosureGroup.swift | 62 +- .../Presentation/Unit/CourseUnitView.swift | 46 +- .../Unit/CourseUnitViewModel.swift | 60 +- .../DropdownList/CourseUnitDropDownCell.swift | 3 +- .../DropdownList/CourseUnitDropDownList.swift | 12 +- .../CourseUnitVerticalsDropdownView.swift | 12 +- .../Unit/Subviews/OfflineContentView.swift | 65 ++ .../Presentation/Unit/Subviews/WebView.swift | 9 +- Course/Course/SwiftGen/Strings.swift | 112 +++ Course/Course/en.lproj/Localizable.strings | 54 ++ Course/CourseTests/CourseMock.generated.swift | 901 ++++++++++++++++++ .../CourseContainerViewModelTests.swift | 87 +- .../Unit/CourseUnitViewModelTests.swift | 12 +- .../DashboardCoreModel.xcdatamodel/contents | 10 +- .../DashboardMock.generated.swift | 840 ++++++++++++++++ .../WebDiscovery/DiscoveryWebview.swift | 4 +- .../WebPrograms/ProgramWebviewView.swift | 8 +- .../DiscoveryMock.generated.swift | 840 ++++++++++++++++ .../DiscussionMock.generated.swift | 840 ++++++++++++++++ OpenEdX.xcodeproj/project.pbxproj | 12 + OpenEdX/AppDelegate.swift | 46 + OpenEdX/DI/ScreenAssembly.swift | 36 +- OpenEdX/Data/CorePersistence.swift | 192 +++- OpenEdX/Data/CoursePersistence.swift | 26 +- OpenEdX/Info.plist | 14 +- .../AnalyticsManager/AnalyticsManager.swift | 9 + OpenEdX/Router.swift | 3 +- OpenEdX/View/MainScreenView.swift | 7 + OpenEdX/View/MainScreenViewModel.swift | 43 +- .../Subviews/ProfileSupportInfoView.swift | 3 +- .../Presentation/Settings/SettingsView.swift | 4 +- .../Settings/SettingsViewModel.swift | 9 +- .../Settings/VideoQualityView.swift | 4 +- .../Settings/VideoSettingsView.swift | 4 +- .../Settings/SettingsViewModelTests.swift | 24 +- .../ProfileTests/ProfileMock.generated.swift | 840 ++++++++++++++++ .../Colors/Background.colorset/Contents.json | 6 +- .../Contents.json | 6 +- .../Contents.json | 6 +- .../disabledButton.colorset/Contents.json | 38 + .../disabledButtonText.colorset/Contents.json | 38 + .../Contents.json | 6 +- .../Colors/shade.colorset/Contents.json | 38 + Theme/Theme/SwiftGen/ThemeAssets.swift | 3 + Theme/Theme/Theme.swift | 3 + 96 files changed, 9193 insertions(+), 400 deletions(-) create mode 100644 Core/Core/Assets.xcassets/check_circle.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/check_circle.imageset/check_circle.svg create mode 100644 Core/Core/Assets.xcassets/download.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/download.imageset/download.svg create mode 100644 Core/Core/Assets.xcassets/remove.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/remove.imageset/remove.svg create mode 100644 Core/Core/Assets.xcassets/report_octagon.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/report_octagon.imageset/report.svg create mode 100644 Core/Core/Assets.xcassets/visibility.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/visibility.imageset/visibility.svg create mode 100644 Core/Core/Data/Repository/OfflineSyncRepository.swift create mode 100644 Core/Core/Domain/Model/OfflineProgress.swift create mode 100644 Core/Core/Domain/OfflineSyncInteractor.swift create mode 100644 Core/Core/Extensions/IntExtension.swift create mode 100644 Core/Core/Network/OfflineSyncEndpoint.swift create mode 100644 Core/Core/Network/OfflineSyncManager.swift create mode 100644 Core/Core/View/Base/FileWebView.swift create mode 100644 Course/Course/Presentation/Offline/OfflineView.swift create mode 100644 Course/Course/Presentation/Offline/Subviews/LargestDownloadsView.swift create mode 100644 Course/Course/Presentation/Offline/Subviews/TotalDownloadedProgressView.swift create mode 100644 Course/Course/Presentation/Subviews/ActionViews/DeviceStorageFullAlertView.swift create mode 100644 Course/Course/Presentation/Subviews/ActionViews/DownloadActionView.swift create mode 100644 Course/Course/Presentation/Subviews/ActionViews/DownloadErrorAlertView.swift create mode 100644 Course/Course/Presentation/Unit/Subviews/OfflineContentView.swift create mode 100644 Theme/Theme/Assets.xcassets/Colors/StyledButton/disabledButton.colorset/Contents.json create mode 100644 Theme/Theme/Assets.xcassets/Colors/StyledButton/disabledButtonText.colorset/Contents.json create mode 100644 Theme/Theme/Assets.xcassets/Colors/shade.colorset/Contents.json diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index 34ce42889..a6520c298 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -2278,6 +2278,611 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { } } +// MARK: - CorePersistenceProtocol + +open class CorePersistenceProtocolMock: CorePersistenceProtocol, 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 = [] } + } + + + + + + open func set(userId: Int) { + addInvocation(.m_set__userId_userId(Parameter.value(`userId`))) + let perform = methodPerformValue(.m_set__userId_userId(Parameter.value(`userId`))) as? (Int) -> Void + perform?(`userId`) + } + + open func getUserID() -> Int? { + addInvocation(.m_getUserID) + let perform = methodPerformValue(.m_getUserID) as? () -> Void + perform?() + var __value: Int? = nil + do { + __value = try methodReturnValue(.m_getUserID).casted() + } catch { + // do nothing + } + return __value + } + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func addToDownloadQueue(tasks: [DownloadDataTask]) { + addInvocation(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) as? ([DownloadDataTask]) -> Void + perform?(`tasks`) + } + + open func saveOfflineProgress(progress: OfflineProgress) { + addInvocation(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) + let perform = methodPerformValue(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) as? (OfflineProgress) -> Void + perform?(`progress`) + } + + open func loadProgress(for blockID: String) -> OfflineProgress? { + addInvocation(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + var __value: OfflineProgress? = nil + do { + __value = try methodReturnValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))).casted() + } catch { + // do nothing + } + return __value + } + + open func loadAllOfflineProgress() -> [OfflineProgress] { + addInvocation(.m_loadAllOfflineProgress) + let perform = methodPerformValue(.m_loadAllOfflineProgress) as? () -> Void + perform?() + var __value: [OfflineProgress] + do { + __value = try methodReturnValue(.m_loadAllOfflineProgress).casted() + } catch { + onFatalFailure("Stub return value not specified for loadAllOfflineProgress(). Use given") + Failure("Stub return value not specified for loadAllOfflineProgress(). Use given") + } + return __value + } + + open func deleteProgress(for blockID: String) { + addInvocation(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + } + + open func deleteAllProgress() { + addInvocation(.m_deleteAllProgress) + let perform = methodPerformValue(.m_deleteAllProgress) as? () -> Void + perform?() + } + + open func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) { + addInvocation(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) as? ([CourseBlock], DownloadQuality) -> Void + perform?(`blocks`, `downloadQuality`) + } + + open func nextBlockForDownloading() -> DownloadDataTask? { + addInvocation(.m_nextBlockForDownloading) + let perform = methodPerformValue(.m_nextBlockForDownloading) as? () -> Void + perform?() + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_nextBlockForDownloading).casted() + } catch { + // do nothing + } + return __value + } + + open func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) { + addInvocation(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) + let perform = methodPerformValue(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) as? (String, DownloadState, Data?) -> Void + perform?(`id`, `state`, `resumeData`) + } + + open func deleteDownloadDataTask(id: String) throws { + addInvocation(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) + let perform = methodPerformValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) as? (String) -> Void + perform?(`id`) + do { + _ = try methodReturnValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func saveDownloadDataTask(_ task: DownloadDataTask) { + addInvocation(.m_saveDownloadDataTask__task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_saveDownloadDataTask__task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) + } + + open func downloadDataTask(for blockId: String) -> DownloadDataTask? { + addInvocation(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + open func getDownloadDataTasks() -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasks) + let perform = methodPerformValue(.m_getDownloadDataTasks) as? () -> Void + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasks(). Use given") + Failure("Stub return value not specified for getDownloadDataTasks(). Use given") + } + return __value + } + + open func getDownloadDataTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + } + return __value + } + + + fileprivate enum MethodType { + case m_set__userId_userId(Parameter) + case m_getUserID + case m_publisher + case m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>) + case m_saveOfflineProgress__progress_progress(Parameter) + case m_loadProgress__for_blockID(Parameter) + case m_loadAllOfflineProgress + case m_deleteProgress__for_blockID(Parameter) + case m_deleteAllProgress + case m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>, Parameter) + case m_nextBlockForDownloading + case m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter, Parameter, Parameter) + case m_deleteDownloadDataTask__id_id(Parameter) + case m_saveDownloadDataTask__task(Parameter) + case m_downloadDataTask__for_blockId(Parameter) + case m_getDownloadDataTasks + case m_getDownloadDataTasksForCourse__courseId(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_set__userId_userId(let lhsUserid), .m_set__userId_userId(let rhsUserid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUserid, rhs: rhsUserid, with: matcher), lhsUserid, rhsUserid, "userId")) + return Matcher.ComparisonResult(results) + + case (.m_getUserID, .m_getUserID): return .match + + case (.m_publisher, .m_publisher): return .match + + case (.m_addToDownloadQueue__tasks_tasks(let lhsTasks), .m_addToDownloadQueue__tasks_tasks(let rhsTasks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTasks, rhs: rhsTasks, with: matcher), lhsTasks, rhsTasks, "tasks")) + return Matcher.ComparisonResult(results) + + case (.m_saveOfflineProgress__progress_progress(let lhsProgress), .m_saveOfflineProgress__progress_progress(let rhsProgress)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsProgress, rhs: rhsProgress, with: matcher), lhsProgress, rhsProgress, "progress")) + return Matcher.ComparisonResult(results) + + case (.m_loadProgress__for_blockID(let lhsBlockid), .m_loadProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_loadAllOfflineProgress, .m_loadAllOfflineProgress): return .match + + case (.m_deleteProgress__for_blockID(let lhsBlockid), .m_deleteProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_deleteAllProgress, .m_deleteAllProgress): return .match + + case (.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let lhsBlocks, let lhsDownloadquality), .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let rhsBlocks, let rhsDownloadquality)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDownloadquality, rhs: rhsDownloadquality, with: matcher), lhsDownloadquality, rhsDownloadquality, "downloadQuality")) + return Matcher.ComparisonResult(results) + + case (.m_nextBlockForDownloading, .m_nextBlockForDownloading): return .match + + case (.m_updateDownloadState__id_idstate_stateresumeData_resumeData(let lhsId, let lhsState, let lhsResumedata), .m_updateDownloadState__id_idstate_stateresumeData_resumeData(let rhsId, let rhsState, let rhsResumedata)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsState, rhs: rhsState, with: matcher), lhsState, rhsState, "state")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsResumedata, rhs: rhsResumedata, with: matcher), lhsResumedata, rhsResumedata, "resumeData")) + return Matcher.ComparisonResult(results) + + case (.m_deleteDownloadDataTask__id_id(let lhsId), .m_deleteDownloadDataTask__id_id(let rhsId)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + return Matcher.ComparisonResult(results) + + case (.m_saveDownloadDataTask__task(let lhsTask), .m_saveDownloadDataTask__task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "_ task")) + return Matcher.ComparisonResult(results) + + case (.m_downloadDataTask__for_blockId(let lhsBlockid), .m_downloadDataTask__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + + case (.m_getDownloadDataTasks, .m_getDownloadDataTasks): return .match + + case (.m_getDownloadDataTasksForCourse__courseId(let lhsCourseid), .m_getDownloadDataTasksForCourse__courseId(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "_ courseId")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_set__userId_userId(p0): return p0.intValue + case .m_getUserID: return 0 + case .m_publisher: return 0 + case let .m_addToDownloadQueue__tasks_tasks(p0): return p0.intValue + case let .m_saveOfflineProgress__progress_progress(p0): return p0.intValue + case let .m_loadProgress__for_blockID(p0): return p0.intValue + case .m_loadAllOfflineProgress: return 0 + case let .m_deleteProgress__for_blockID(p0): return p0.intValue + case .m_deleteAllProgress: return 0 + case let .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(p0, p1): return p0.intValue + p1.intValue + case .m_nextBlockForDownloading: return 0 + case let .m_updateDownloadState__id_idstate_stateresumeData_resumeData(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_deleteDownloadDataTask__id_id(p0): return p0.intValue + case let .m_saveDownloadDataTask__task(p0): return p0.intValue + case let .m_downloadDataTask__for_blockId(p0): return p0.intValue + case .m_getDownloadDataTasks: return 0 + case let .m_getDownloadDataTasksForCourse__courseId(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_set__userId_userId: return ".set(userId:)" + case .m_getUserID: return ".getUserID()" + case .m_publisher: return ".publisher()" + case .m_addToDownloadQueue__tasks_tasks: return ".addToDownloadQueue(tasks:)" + case .m_saveOfflineProgress__progress_progress: return ".saveOfflineProgress(progress:)" + case .m_loadProgress__for_blockID: return ".loadProgress(for:)" + case .m_loadAllOfflineProgress: return ".loadAllOfflineProgress()" + case .m_deleteProgress__for_blockID: return ".deleteProgress(for:)" + case .m_deleteAllProgress: return ".deleteAllProgress()" + case .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality: return ".addToDownloadQueue(blocks:downloadQuality:)" + case .m_nextBlockForDownloading: return ".nextBlockForDownloading()" + case .m_updateDownloadState__id_idstate_stateresumeData_resumeData: return ".updateDownloadState(id:state:resumeData:)" + case .m_deleteDownloadDataTask__id_id: return ".deleteDownloadDataTask(id:)" + case .m_saveDownloadDataTask__task: return ".saveDownloadDataTask(_:)" + case .m_downloadDataTask__for_blockId: return ".downloadDataTask(for:)" + case .m_getDownloadDataTasks: return ".getDownloadDataTasks()" + case .m_getDownloadDataTasksForCourse__courseId: return ".getDownloadDataTasksForCourse(_:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + 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 { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { + return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadAllOfflineProgress(willReturn: [OfflineProgress]...) -> MethodStub { + return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func nextBlockForDownloading(willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func downloadDataTask(for blockId: Parameter, willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getUserID(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Int?] = [] + let given: Given = { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Int?).self) + 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) })) }() + let stubber = given.stub(for: (OfflineProgress?).self) + willProduce(stubber) + return given + } + public static func loadAllOfflineProgress(willProduce: (Stubber<[OfflineProgress]>) -> Void) -> MethodStub { + let willReturn: [[OfflineProgress]] = [] + let given: Given = { return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([OfflineProgress]).self) + willProduce(stubber) + return given + } + public static func nextBlockForDownloading(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func downloadDataTask(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).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) })) + } + public static func deleteDownloadDataTask(id: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + 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)} + 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`))} + public static func loadAllOfflineProgress() -> Verify { return Verify(method: .m_loadAllOfflineProgress)} + public static func deleteProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_deleteProgress__for_blockID(`blockID`))} + public static func deleteAllProgress() -> Verify { return Verify(method: .m_deleteAllProgress)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`))} + public static func nextBlockForDownloading() -> Verify { return Verify(method: .m_nextBlockForDownloading)} + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter) -> Verify { return Verify(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`))} + public static func deleteDownloadDataTask(id: Parameter) -> Verify { return Verify(method: .m_deleteDownloadDataTask__id_id(`id`))} + public static func saveDownloadDataTask(_ task: Parameter) -> Verify { return Verify(method: .m_saveDownloadDataTask__task(`task`))} + public static func downloadDataTask(for blockId: Parameter) -> Verify { return Verify(method: .m_downloadDataTask__for_blockId(`blockId`))} + public static func getDownloadDataTasks() -> Verify { return Verify(method: .m_getDownloadDataTasks)} + public static func getDownloadDataTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func set(userId: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_set__userId_userId(`userId`), performs: perform) + } + public static func getUserID(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getUserID, performs: perform) + } + 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 { + return Perform(method: .m_addToDownloadQueue__tasks_tasks(`tasks`), performs: perform) + } + public static func saveOfflineProgress(progress: Parameter, perform: @escaping (OfflineProgress) -> Void) -> Perform { + return Perform(method: .m_saveOfflineProgress__progress_progress(`progress`), performs: perform) + } + public static func loadProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_loadProgress__for_blockID(`blockID`), performs: perform) + } + public static func loadAllOfflineProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_loadAllOfflineProgress, performs: perform) + } + public static func deleteProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteProgress__for_blockID(`blockID`), performs: perform) + } + public static func deleteAllProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllProgress, performs: perform) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter, perform: @escaping ([CourseBlock], DownloadQuality) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`), performs: perform) + } + public static func nextBlockForDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_nextBlockForDownloading, performs: perform) + } + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter, perform: @escaping (String, DownloadState, Data?) -> Void) -> Perform { + return Perform(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`), performs: perform) + } + public static func deleteDownloadDataTask(id: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteDownloadDataTask__id_id(`id`), performs: perform) + } + public static func saveDownloadDataTask(_ task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_saveDownloadDataTask__task(`task`), performs: perform) + } + public static func downloadDataTask(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_downloadDataTask__for_blockId(`blockId`), performs: perform) + } + public static func getDownloadDataTasks(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasks, performs: perform) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), 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) + } +} + // MARK: - DownloadManagerProtocol open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { @@ -2473,6 +3078,20 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] { + addInvocation(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) + let perform = methodPerformValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) as? ([CourseSequential]) -> Void + perform?(`sequentials`) + var __value: [CourseSequential] + do { + __value = try methodReturnValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))).casted() + } catch { + onFatalFailure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + Failure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + } + return __value + } + open func resumeDownloading() throws { addInvocation(.m_resumeDownloading) let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void @@ -2520,6 +3139,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) + case m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case m_removeAppSupportDirectoryUnusedContent @@ -2573,6 +3193,11 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) return Matcher.ComparisonResult(results) + case (.m_updateUnzippedFileSize__for_sequentials(let lhsSequentials), .m_updateUnzippedFileSize__for_sequentials(let rhsSequentials)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSequentials, rhs: rhsSequentials, with: matcher), lhsSequentials, rhsSequentials, "for sequentials")) + return Matcher.ComparisonResult(results) + case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): @@ -2600,6 +3225,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case let .m_deleteFile__blocks_blocks(p0): return p0.intValue case .m_deleteAllFiles: return 0 case let .m_fileUrl__for_blockId(p0): return p0.intValue + case let .m_updateUnzippedFileSize__for_sequentials(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .m_removeAppSupportDirectoryUnusedContent: return 0 @@ -2620,6 +3246,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + case .m_updateUnzippedFileSize__for_sequentials: return ".updateUnzippedFileSize(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" @@ -2655,6 +3282,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willReturn: [CourseSequential]...) -> MethodStub { + return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } 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) })) } @@ -2693,6 +3323,13 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willProduce: (Stubber<[CourseSequential]>) -> Void) -> MethodStub { + let willReturn: [[CourseSequential]] = [] + let given: Given = { return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseSequential]).self) + willProduce(stubber) + return given + } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [Bool] = [] let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -2777,6 +3414,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} public static func deleteAllFiles() -> Verify { return Verify(method: .m_deleteAllFiles)} public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>) -> Verify { return Verify(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`))} public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} public static func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} @@ -2823,6 +3461,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, perform: @escaping ([CourseSequential]) -> Void) -> Perform { + return Perform(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), performs: perform) + } public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_resumeDownloading, performs: perform) } @@ -2907,6 +3548,205 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } +// MARK: - OfflineSyncInteractorProtocol + +open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, 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 = [] } + } + + + + + + open func submitOfflineProgress(courseID: String, blockID: String, data: String) throws -> Bool { + addInvocation(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) + let perform = methodPerformValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) as? (String, String, String) -> Void + perform?(`courseID`, `blockID`, `data`) + var __value: Bool + do { + __value = try methodReturnValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + Failure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + } catch { + throw error + } + return __value + } + + + fileprivate enum MethodType { + case m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter, Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let lhsCourseid, let lhsBlockid, let lhsData), .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let rhsCourseid, let rhsBlockid, let rhsData)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "blockID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsData, rhs: rhsData, with: matcher), lhsData, rhsData, "data")) + return Matcher.ComparisonResult(results) + } + } + + func intValue() -> Int { + switch self { + case let .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + } + } + func assertionName() -> String { + switch self { + case .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data: return ".submitOfflineProgress(courseID:blockID:data:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Bool).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter) -> Verify { return Verify(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { + return Perform(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), 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) + } +} + // MARK: - WebviewCookiesUpdateProtocol open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 5e0328b90..d68bf217b 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 021D924828DC860C00ACC565 /* Data_UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D924728DC860C00ACC565 /* Data_UserProfile.swift */; }; 021D925028DC89D100ACC565 /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D924F28DC89D100ACC565 /* UserProfile.swift */; }; 021D925728DCF12900ACC565 /* AlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D925628DCF12900ACC565 /* AlertView.swift */; }; + 02228B312C2232D2009A5F28 /* IntExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02228B302C2232D2009A5F28 /* IntExtension.swift */; }; 022020462C11BB2200D15795 /* Data_CourseDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022020452C11BB2200D15795 /* Data_CourseDates.swift */; }; 02280F5B294B4E6F0032823A /* Connectivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02280F5A294B4E6F0032823A /* Connectivity.swift */; }; 02284C182A3B1AE00007117F /* UIApplicationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02284C172A3B1AE00007117F /* UIApplicationExtension.swift */; }; @@ -44,6 +45,7 @@ 025B36752A13B7D5001A640E /* UnitButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025B36742A13B7D5001A640E /* UnitButtonView.swift */; }; 025EF2F62971740000B838AB /* YouTubePlayerKit in Frameworks */ = {isa = PBXBuildFile; productRef = 025EF2F52971740000B838AB /* YouTubePlayerKit */; }; 0260E58028FD792800BBBE18 /* WebUnitViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0260E57F28FD792800BBBE18 /* WebUnitViewModel.swift */; }; + 0267F8512C3C256F0089D810 /* FileWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0267F8502C3C256F0089D810 /* FileWebView.swift */; }; 027BD3922907D88F00392132 /* Data_RegistrationFields.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3912907D88F00392132 /* Data_RegistrationFields.swift */; }; 027BD39C2908810C00392132 /* RegisterUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD39B2908810C00392132 /* RegisterUser.swift */; }; 027BD3A82909474200392132 /* KeyboardAvoidingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3A62909474100392132 /* KeyboardAvoidingViewController.swift */; }; @@ -70,12 +72,18 @@ 02935B732BCECAD000B22F66 /* Data_PrimaryEnrollment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02935B722BCECAD000B22F66 /* Data_PrimaryEnrollment.swift */; }; 02935B752BCEE6D600B22F66 /* PrimaryEnrollment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02935B742BCEE6D600B22F66 /* PrimaryEnrollment.swift */; }; 0295C885299B99DD00ABE571 /* RefreshableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */; }; + 029A13262C2457D9005FB830 /* OfflineProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029A13252C2457D9005FB830 /* OfflineProgress.swift */; }; + 029A13282C246AE6005FB830 /* OfflineSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029A13272C246AE6005FB830 /* OfflineSyncManager.swift */; }; + 029A132A2C2471DF005FB830 /* OfflineSyncRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029A13292C2471DF005FB830 /* OfflineSyncRepository.swift */; }; + 029A132C2C2471F8005FB830 /* OfflineSyncEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029A132B2C2471F8005FB830 /* OfflineSyncEndpoint.swift */; }; + 029A13302C2479E7005FB830 /* OfflineSyncInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029A132F2C2479E7005FB830 /* OfflineSyncInteractor.swift */; }; 029EE3ED2BF6650500F64F33 /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029EE3EC2BF6650500F64F33 /* Bundle.swift */; }; 02A463112AEA966C00331037 /* AppReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A463102AEA966C00331037 /* AppReviewView.swift */; }; 02A4833529B8A73400D33F33 /* CorePersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833429B8A73400D33F33 /* CorePersistenceProtocol.swift */; }; 02A4833829B8A8F900D33F33 /* CoreDataModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833629B8A8F800D33F33 /* CoreDataModel.xcdatamodeld */; }; 02A4833A29B8A9AB00D33F33 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833929B8A9AB00D33F33 /* DownloadManager.swift */; }; 02A4833C29B8C57800D33F33 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833B29B8C57800D33F33 /* DownloadView.swift */; }; + 02AA27942C2C1B88006F5B6A /* ZipArchive in Frameworks */ = {isa = PBXBuildFile; productRef = 02AA27932C2C1B88006F5B6A /* ZipArchive */; }; 02A8C5812C05DBB4004B91FF /* Data_EnrollmentsStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A8C5802C05DBB4004B91FF /* Data_EnrollmentsStatus.swift */; }; 02AFCC182AEFDB24000360F0 /* ThirdPartyMailClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AFCC172AEFDB24000360F0 /* ThirdPartyMailClient.swift */; }; 02AFCC1A2AEFDC18000360F0 /* ThirdPartyMailer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AFCC192AEFDC18000360F0 /* ThirdPartyMailer.swift */; }; @@ -211,6 +219,7 @@ 021D924728DC860C00ACC565 /* Data_UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_UserProfile.swift; sourceTree = ""; }; 021D924F28DC89D100ACC565 /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; 021D925628DCF12900ACC565 /* AlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertView.swift; sourceTree = ""; }; + 02228B302C2232D2009A5F28 /* IntExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntExtension.swift; sourceTree = ""; }; 022020452C11BB2200D15795 /* Data_CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_CourseDates.swift; sourceTree = ""; }; 02280F5A294B4E6F0032823A /* Connectivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.swift; sourceTree = ""; }; 02284C172A3B1AE00007117F /* UIApplicationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationExtension.swift; sourceTree = ""; }; @@ -240,6 +249,7 @@ 025A204F2C071EB0003EA08D /* ErrorAlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlertView.swift; sourceTree = ""; }; 025B36742A13B7D5001A640E /* UnitButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitButtonView.swift; sourceTree = ""; }; 0260E57F28FD792800BBBE18 /* WebUnitViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebUnitViewModel.swift; sourceTree = ""; }; + 0267F8502C3C256F0089D810 /* FileWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileWebView.swift; sourceTree = ""; }; 027BD3912907D88F00392132 /* Data_RegistrationFields.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_RegistrationFields.swift; sourceTree = ""; }; 027BD39B2908810C00392132 /* RegisterUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterUser.swift; sourceTree = ""; }; 027BD3A62909474100392132 /* KeyboardAvoidingViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardAvoidingViewController.swift; sourceTree = ""; }; @@ -266,6 +276,11 @@ 02935B722BCECAD000B22F66 /* Data_PrimaryEnrollment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_PrimaryEnrollment.swift; sourceTree = ""; }; 02935B742BCEE6D600B22F66 /* PrimaryEnrollment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryEnrollment.swift; sourceTree = ""; }; 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableScrollView.swift; sourceTree = ""; }; + 029A13252C2457D9005FB830 /* OfflineProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineProgress.swift; sourceTree = ""; }; + 029A13272C246AE6005FB830 /* OfflineSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineSyncManager.swift; sourceTree = ""; }; + 029A13292C2471DF005FB830 /* OfflineSyncRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineSyncRepository.swift; sourceTree = ""; }; + 029A132B2C2471F8005FB830 /* OfflineSyncEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineSyncEndpoint.swift; sourceTree = ""; }; + 029A132F2C2479E7005FB830 /* OfflineSyncInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineSyncInteractor.swift; sourceTree = ""; }; 029EE3EC2BF6650500F64F33 /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; 02A463102AEA966C00331037 /* AppReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReviewView.swift; sourceTree = ""; }; 02A4833429B8A73400D33F33 /* CorePersistenceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorePersistenceProtocol.swift; sourceTree = ""; }; @@ -413,6 +428,7 @@ C8C446EF233F81B9FABB77D2 /* Pods_App_Core.framework in Frameworks */, 142EDD6C2B831D1400F9F320 /* BranchSDK in Frameworks */, BA8FA66C2AD59BBC00EA029A /* GoogleSignIn in Frameworks */, + 02AA27942C2C1B88006F5B6A /* ZipArchive in Frameworks */, E055A5392B18DC95008D9E5E /* Theme.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -434,6 +450,7 @@ isa = PBXGroup; children = ( 0236961828F9A26900EEF206 /* AuthRepository.swift */, + 029A13292C2471DF005FB830 /* OfflineSyncRepository.swift */, ); path = Repository; sourceTree = ""; @@ -498,6 +515,7 @@ 0283347F28D4DCD200C828FC /* ViewExtension.swift */, 02F6EF4928D9F0A700835477 /* DateExtension.swift */, 02F98A7E28F81EE900DE94C0 /* Container+App.swift */, + 02228B302C2232D2009A5F28 /* IntExtension.swift */, 027BD3BB2909478B00392132 /* UIResponder+CurrentResponder.swift */, 027BD3BA2909478B00392132 /* UIView+EnclosingScrollView.swift */, 02E225AF291D29EB0067769A /* UrlExtension.swift */, @@ -633,6 +651,7 @@ children = ( 0727878728D3172D002E9142 /* Model */, 0236961A28F9A28B00EEF206 /* AuthInteractor.swift */, + 029A132F2C2479E7005FB830 /* OfflineSyncInteractor.swift */, ); path = Domain; sourceTree = ""; @@ -652,6 +671,7 @@ 020C31C8290AC3F700D6DEA2 /* PickerFields.swift */, 02EBC75A2C19DE3D00BE182C /* CourseForSync.swift */, 076F297E2A1F80C800967E7D /* Pagination.swift */, + 029A13252C2457D9005FB830 /* OfflineProgress.swift */, 02286D152C106393005EEC8D /* CourseDates.swift */, 02EBC7562C19DCDB00BE182C /* SyncStatus.swift */, ); @@ -710,8 +730,10 @@ 0727877A28D24A1D002E9142 /* HeadersRedirectHandler.swift */, 0727877E28D25B24002E9142 /* Alamofire+Error.swift */, 0236961E28F9A2F600EEF206 /* AuthEndpoint.swift */, + 029A132B2C2471F8005FB830 /* OfflineSyncEndpoint.swift */, 0255D55729362839004DBC1A /* UploadBodyEncoding.swift */, 02A4833929B8A9AB00D33F33 /* DownloadManager.swift */, + 029A13272C246AE6005FB830 /* OfflineSyncManager.swift */, ); path = Network; sourceTree = ""; @@ -778,6 +800,7 @@ 020D72F32BB76DFE00773319 /* VisualEffectView.swift */, 06DEA4A22BBD66A700110D20 /* BackNavigationButton.swift */, 06DEA4A42BBD66D700110D20 /* BackNavigationButtonViewModel.swift */, + 0267F8502C3C256F0089D810 /* FileWebView.swift */, ); path = Base; sourceTree = ""; @@ -972,6 +995,7 @@ BA8FA66B2AD59BBC00EA029A /* GoogleSignIn */, BAF0D4CA2AD6AE14007AC334 /* FacebookLogin */, 142EDD6B2B831D1400F9F320 /* BranchSDK */, + 02AA27932C2C1B88006F5B6A /* ZipArchive */, ); productName = Core; productReference = 0770DE0828D07831006D8A5D /* Core.framework */; @@ -1012,6 +1036,7 @@ BA8FA65E2AD574D700EA029A /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */, BA8FA6712AD6ABA300EA029A /* XCRemoteSwiftPackageReference "facebook-ios-sdk" */, 142EDD6A2B831D1400F9F320 /* XCRemoteSwiftPackageReference "ios-branch-sdk-spm" */, + 02AA27922C2C1B61006F5B6A /* XCRemoteSwiftPackageReference "ZipArchive" */, ); productRefGroup = 0770DE0928D07831006D8A5D /* Products */; projectDirPath = ""; @@ -1112,6 +1137,7 @@ 027F1BF72C071C820001A24C /* NavigationTitle.swift in Sources */, 06619EAA2B8F2936001FAADE /* ReadabilityModifier.swift in Sources */, BAFB99902B14B377007D09F9 /* GoogleConfig.swift in Sources */, + 029A132C2C2471F8005FB830 /* OfflineSyncEndpoint.swift in Sources */, 02E225B0291D29EB0067769A /* UrlExtension.swift in Sources */, 02CF46C829546AA200A698EE /* NoCachedDataError.swift in Sources */, 0727877728D23847002E9142 /* DataLayer.swift in Sources */, @@ -1125,6 +1151,7 @@ 064987972B4D69FF0071642A /* WebView.swift in Sources */, 0770DE2A28D0929E006D8A5D /* HTTPTask.swift in Sources */, 06619EAD2B90918B001FAADE /* ReadabilityInjection.swift in Sources */, + 029A13262C2457D9005FB830 /* OfflineProgress.swift in Sources */, 02284C182A3B1AE00007117F /* UIApplicationExtension.swift in Sources */, 0255D5582936283A004DBC1A /* UploadBodyEncoding.swift in Sources */, 06619EAF2B973B25001FAADE /* AccessibilityInjection.swift in Sources */, @@ -1187,6 +1214,7 @@ 021D925728DCF12900ACC565 /* AlertView.swift in Sources */, 027BD3A82909474200392132 /* KeyboardAvoidingViewController.swift in Sources */, 025A20502C071EB0003EA08D /* ErrorAlertView.swift in Sources */, + 029A13282C246AE6005FB830 /* OfflineSyncManager.swift in Sources */, 02E93F852AEBAEBC006C4750 /* AppReviewViewModel.swift in Sources */, A595689B2B6173DF00ED4F90 /* BranchConfig.swift in Sources */, 0770DE2528D08FBA006D8A5D /* CoreStorage.swift in Sources */, @@ -1209,6 +1237,7 @@ 0259104A29C4A5B6004B5A55 /* UserSettings.swift in Sources */, 021D925028DC89D100ACC565 /* UserProfile.swift in Sources */, 071009D028D1E3A600344290 /* Constants.swift in Sources */, + 02228B312C2232D2009A5F28 /* IntExtension.swift in Sources */, 0770DE1928D0847D006D8A5D /* BaseRouter.swift in Sources */, BA30427F2B20B320009B64B7 /* SocialAuthError.swift in Sources */, 02286D162C106393005EEC8D /* CourseDates.swift in Sources */, @@ -1218,6 +1247,7 @@ DBF6F2412B014ADA0098414B /* FirebaseConfig.swift in Sources */, 072787B628D37A0E002E9142 /* Validator.swift in Sources */, 0236961D28F9A2D200EEF206 /* Data_AuthResponse.swift in Sources */, + 0267F8512C3C256F0089D810 /* FileWebView.swift in Sources */, A5F4E7B52B61544A00ACD166 /* BrazeConfig.swift in Sources */, 06DEA4A52BBD66D700110D20 /* BackNavigationButtonViewModel.swift in Sources */, 02AFCC182AEFDB24000360F0 /* ThirdPartyMailClient.swift in Sources */, @@ -1256,12 +1286,14 @@ 064987982B4D69FF0071642A /* CSSInjectionProtocol.swift in Sources */, 029EE3ED2BF6650500F64F33 /* Bundle.swift in Sources */, BADB3F5B2AD6EC56004D5CFA /* ResultExtension.swift in Sources */, + 029A132A2C2471DF005FB830 /* OfflineSyncRepository.swift in Sources */, 0236961928F9A26900EEF206 /* AuthRepository.swift in Sources */, 023A1136291432B200D0D354 /* RegistrationTextField.swift in Sources */, 02A463112AEA966C00331037 /* AppReviewView.swift in Sources */, 025B36752A13B7D5001A640E /* UnitButtonView.swift in Sources */, 028F9F39293A452B00DE65D0 /* ResetPassword.swift in Sources */, 0233D56F2AF13EB200BAC8BD /* StarRatingView.swift in Sources */, + 029A13302C2479E7005FB830 /* OfflineSyncInteractor.swift in Sources */, 0604C9AA2B22FACF00AD5DBF /* UIComponentsConfig.swift in Sources */, 027BD3B82909476200392132 /* DismissKeyboardTapViewModifier.swift in Sources */, 024BE3DF29B2615500BCDEE2 /* CGColorExtension.swift in Sources */, @@ -2294,6 +2326,14 @@ minimumVersion = 1.8.0; }; }; + 02AA27922C2C1B61006F5B6A /* XCRemoteSwiftPackageReference "ZipArchive" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ZipArchive/ZipArchive.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.5.5; + }; + }; 142EDD6A2B831D1400F9F320 /* XCRemoteSwiftPackageReference "ios-branch-sdk-spm" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/BranchMetrics/ios-branch-sdk-spm"; @@ -2326,6 +2366,11 @@ package = 025EF2F42971740000B838AB /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */; productName = YouTubePlayerKit; }; + 02AA27932C2C1B88006F5B6A /* ZipArchive */ = { + isa = XCSwiftPackageProductDependency; + package = 02AA27922C2C1B61006F5B6A /* XCRemoteSwiftPackageReference "ZipArchive" */; + productName = ZipArchive; + }; 142EDD6B2B831D1400F9F320 /* BranchSDK */ = { isa = XCSwiftPackageProductDependency; package = 142EDD6A2B831D1400F9F320 /* XCRemoteSwiftPackageReference "ios-branch-sdk-spm" */; diff --git a/Core/Core/Analytics/CoreAnalytics.swift b/Core/Core/Analytics/CoreAnalytics.swift index 137bf094f..006b41c7d 100644 --- a/Core/Core/Analytics/CoreAnalytics.swift +++ b/Core/Core/Analytics/CoreAnalytics.swift @@ -112,6 +112,7 @@ public enum AnalyticsEvent: String { case finishVerticalBackToOutlineClicked = "Course:Unit Finish Back To Outline Clicked" case courseOutlineCourseTabClicked = "Course:Home Tab" case courseOutlineVideosTabClicked = "Course:Videos Tab" + case courseOutlineOfflineTabClicked = "Course:Offline Tab" case courseOutlineDatesTabClicked = "Course:Dates Tab" case courseOutlineDiscussionTabClicked = "Course:Discussion Tab" case courseOutlineHandoutsTabClicked = "Course:Handouts Tab" @@ -189,6 +190,7 @@ public enum EventBIValue: String { case bulkDeleteVideosSubsection = "edx.bi.app.video.delete.subsection" case dashboardCourseClicked = "edx.bi.app.course.dashboard" case courseOutlineVideosTabClicked = "edx.bi.app.course.video_tab" + case courseOutlineOfflineTabClicked = "edx.bi.app.course.offline_tab" case courseOutlineDatesTabClicked = "edx.bi.app.course.dates_tab" case courseOutlineDiscussionTabClicked = "edx.bi.app.course.discussion_tab" case courseOutlineHandoutsTabClicked = "edx.bi.app.course.handouts_tab" diff --git a/Core/Core/Assets.xcassets/check_circle.imageset/Contents.json b/Core/Core/Assets.xcassets/check_circle.imageset/Contents.json new file mode 100644 index 000000000..5a764afa0 --- /dev/null +++ b/Core/Core/Assets.xcassets/check_circle.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "check_circle.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/check_circle.imageset/check_circle.svg b/Core/Core/Assets.xcassets/check_circle.imageset/check_circle.svg new file mode 100644 index 000000000..d95b7f0fe --- /dev/null +++ b/Core/Core/Assets.xcassets/check_circle.imageset/check_circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/download.imageset/Contents.json b/Core/Core/Assets.xcassets/download.imageset/Contents.json new file mode 100644 index 000000000..b9e339ad9 --- /dev/null +++ b/Core/Core/Assets.xcassets/download.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "download.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/download.imageset/download.svg b/Core/Core/Assets.xcassets/download.imageset/download.svg new file mode 100644 index 000000000..94fb61149 --- /dev/null +++ b/Core/Core/Assets.xcassets/download.imageset/download.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Core/Core/Assets.xcassets/remove.imageset/Contents.json b/Core/Core/Assets.xcassets/remove.imageset/Contents.json new file mode 100644 index 000000000..d72a4557b --- /dev/null +++ b/Core/Core/Assets.xcassets/remove.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "remove.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/remove.imageset/remove.svg b/Core/Core/Assets.xcassets/remove.imageset/remove.svg new file mode 100644 index 000000000..a896b5a5a --- /dev/null +++ b/Core/Core/Assets.xcassets/remove.imageset/remove.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/report_octagon.imageset/Contents.json b/Core/Core/Assets.xcassets/report_octagon.imageset/Contents.json new file mode 100644 index 000000000..c24699e07 --- /dev/null +++ b/Core/Core/Assets.xcassets/report_octagon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "report.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/report_octagon.imageset/report.svg b/Core/Core/Assets.xcassets/report_octagon.imageset/report.svg new file mode 100644 index 000000000..4eeadf69b --- /dev/null +++ b/Core/Core/Assets.xcassets/report_octagon.imageset/report.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/visibility.imageset/Contents.json b/Core/Core/Assets.xcassets/visibility.imageset/Contents.json new file mode 100644 index 000000000..af9f4586d --- /dev/null +++ b/Core/Core/Assets.xcassets/visibility.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "visibility.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/visibility.imageset/visibility.svg b/Core/Core/Assets.xcassets/visibility.imageset/visibility.svg new file mode 100644 index 000000000..2710fa5d1 --- /dev/null +++ b/Core/Core/Assets.xcassets/visibility.imageset/visibility.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents index 2fd252f0d..acc66b8db 100644 --- a/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -7,6 +7,7 @@ + @@ -19,4 +20,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/Core/Core/Data/Persistence/CorePersistenceProtocol.swift b/Core/Core/Data/Persistence/CorePersistenceProtocol.swift index 49d69b1c3..b17c62a1f 100644 --- a/Core/Core/Data/Persistence/CorePersistenceProtocol.swift +++ b/Core/Core/Data/Persistence/CorePersistenceProtocol.swift @@ -8,10 +8,18 @@ import CoreData import Combine +//sourcery: AutoMockable public protocol CorePersistenceProtocol { 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() + func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) async func nextBlockForDownloading() async -> DownloadDataTask? func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) @@ -22,6 +30,31 @@ public protocol CorePersistenceProtocol { func getDownloadDataTasksForCourse(_ courseId: String) async -> [DownloadDataTask] } +#if DEBUG +public class CorePersistenceMock: CorePersistenceProtocol { + + 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 getDownloadDataTasks() async -> [DownloadDataTask] {[]} + public func getDownloadDataTasksForCourse(_ courseId: String) async -> [DownloadDataTask] {[]} +} +#endif + public final class CoreBundle { private init() {} } diff --git a/Core/Core/Data/Repository/OfflineSyncRepository.swift b/Core/Core/Data/Repository/OfflineSyncRepository.swift new file mode 100644 index 000000000..386daf7ab --- /dev/null +++ b/Core/Core/Data/Repository/OfflineSyncRepository.swift @@ -0,0 +1,42 @@ +// +// OfflineSyncRepository.swift +// Core +// +// Created by  Stepanok Ivan on 20.06.2024. +// + +import Foundation + +public protocol OfflineSyncRepositoryProtocol { + func submitOfflineProgress(courseID: String, blockID: String, data: String) async throws -> Bool +} + +public class OfflineSyncRepository: OfflineSyncRepositoryProtocol { + + private let api: API + + public init(api: API) { + self.api = api + } + + public func submitOfflineProgress(courseID: String, blockID: String, data: String) async throws -> Bool { + let request = try await api.request( + OfflineSyncEndpoint.submitOfflineProgress( + courseID: courseID, + blockID: blockID, + data: data + ) + ) + + return request.statusCode == 200 + } +} + +// Mark - For testing and SwiftUI preview +#if DEBUG +class OfflineSyncRepositoryMock: OfflineSyncRepositoryProtocol { + public func submitOfflineProgress(courseID: String, blockID: String, data: String) async throws -> Bool { + true + } +} +#endif diff --git a/Core/Core/Domain/Model/CourseBlockModel.swift b/Core/Core/Domain/Model/CourseBlockModel.swift index 96ef3ccde..c2525627b 100644 --- a/Core/Core/Domain/Model/CourseBlockModel.swift +++ b/Core/Core/Domain/Model/CourseBlockModel.swift @@ -129,6 +129,10 @@ public struct CourseSequential: Identifiable { return childs.first(where: { $0.isDownloadable }) != nil } + public var totalSize: Int { + childs.flatMap { $0.childs.filter({ $0.isDownloadable }) }.reduce(0) { $0 + ($1.fileSize ?? 0) } + } + public init( blockId: String, id: String, @@ -233,9 +237,31 @@ public struct CourseBlock: Hashable, Identifiable { public let subtitles: [SubtitleUrl]? public let encodedVideo: CourseBlockEncodedVideo? public let multiDevice: Bool? + public var offlineDownload: OfflineDownload? + public var actualFileSize: Int? public var isDownloadable: Bool { - encodedVideo?.isDownloadable ?? false + encodedVideo?.isDownloadable ?? false || offlineDownload?.isDownloadable ?? false + } + + public var fileSize: Int? { + if let actualFileSize { + return actualFileSize + } else if let fileSize = encodedVideo?.desktopMP4?.fileSize { + return fileSize + } else if let fileSize = encodedVideo?.fallback?.fileSize { + return fileSize + } else if let fileSize = encodedVideo?.hls?.fileSize { + return fileSize + } else if let fileSize = encodedVideo?.mobileHigh?.fileSize { + return fileSize + } else if let fileSize = encodedVideo?.mobileLow?.fileSize { + return fileSize + } else if let fileSize = offlineDownload?.fileSize { + return fileSize + } else { + return nil + } } public init( @@ -252,7 +278,8 @@ public struct CourseBlock: Hashable, Identifiable { webUrl: String, subtitles: [SubtitleUrl]? = nil, encodedVideo: CourseBlockEncodedVideo?, - multiDevice: Bool? + multiDevice: Bool?, + offlineDownload: OfflineDownload? ) { self.blockId = blockId self.id = id @@ -268,6 +295,23 @@ public struct CourseBlock: Hashable, Identifiable { self.subtitles = subtitles self.encodedVideo = encodedVideo self.multiDevice = multiDevice + self.offlineDownload = offlineDownload + } +} + +public struct OfflineDownload { + public let fileUrl: String + public var lastModified: String + public let fileSize: Int + + public init(fileUrl: String, lastModified: String, fileSize: Int) { + self.fileUrl = fileUrl + self.lastModified = lastModified + self.fileSize = fileSize + } + + public var isDownloadable: Bool { + [".zip"].contains(where: { fileUrl.contains($0) == true }) } } diff --git a/Core/Core/Domain/Model/OfflineProgress.swift b/Core/Core/Domain/Model/OfflineProgress.swift new file mode 100644 index 000000000..efb1a982c --- /dev/null +++ b/Core/Core/Domain/Model/OfflineProgress.swift @@ -0,0 +1,50 @@ +// +// OfflineProgress.swift +// Core +// +// Created by  Stepanok Ivan on 20.06.2024. +// + +import Foundation + +public struct OfflineProgress { + public let blockID: String + public let data: String + public let courseID: String + public let progressJson: String + + public init(progressJson: String) { + self.progressJson = progressJson + if let jsonData = progressJson.data(using: .utf8) { + if let jsonObject = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] { + if let url = jsonObject["url"] as? String, + let data = jsonObject["data"] as? String { + self.blockID = extractBlockID(from: url) + self.data = data + self.courseID = extractCourseID(from: url) + return + } + } + } + // Default values if parsing fails + self.blockID = "" + self.data = "" + self.courseID = "" + + func extractBlockID(from url: String) -> String { + if let range = url.range(of: "xblock/")?.upperBound, + let endRange = url.range(of: "/handler", range: range.. String { + if let range = url.range(of: "courses/")?.upperBound, + let endRange = url.range(of: "/xblock", range: range.. Bool +} + +public class OfflineSyncInteractor: OfflineSyncInteractorProtocol { + private let repository: OfflineSyncRepositoryProtocol + + public init(repository: OfflineSyncRepositoryProtocol) { + self.repository = repository + } + + public func submitOfflineProgress(courseID: String, blockID: String, data: String) async throws -> Bool { + return try await repository.submitOfflineProgress( + courseID: courseID, + blockID: blockID, + data: data + ) + } +} diff --git a/Core/Core/Extensions/IntExtension.swift b/Core/Core/Extensions/IntExtension.swift new file mode 100644 index 000000000..46fe6ea0e --- /dev/null +++ b/Core/Core/Extensions/IntExtension.swift @@ -0,0 +1,25 @@ +// +// IntExtension.swift +// Core +// +// Created by  Stepanok Ivan on 19.06.2024. +// + +import Foundation + +public extension Int { + func formattedFileSize() -> String { + if self == 0 { + return "0MB" + } + let sizeInMB = Double(self) / 1_048_576 + let sizeInGB = Double(self) / 1_073_741_824 + let formattedString: String + if sizeInGB >= 1 { + formattedString = String(format: "%.1fGB", sizeInGB).replacingOccurrences(of: ".0", with: "") + } else { + formattedString = String(format: "%.1fMB", sizeInMB).replacingOccurrences(of: ".0", with: "") + } + return formattedString + } +} diff --git a/Core/Core/Extensions/Notification.swift b/Core/Core/Extensions/Notification.swift index 1a4aeb1db..f70c71e3d 100644 --- a/Core/Core/Extensions/Notification.swift +++ b/Core/Core/Extensions/Notification.swift @@ -21,6 +21,8 @@ public extension Notification.Name { static let shiftCourseDates = Notification.Name("shiftCourseDates") static let profileUpdated = Notification.Name("profileUpdated") static let getCourseDates = Notification.Name("getCourseDates") + static let showDownloadFailed = Notification.Name("showDownloadFailed") + static let tryDownloadAgain = Notification.Name("tryDownloadAgain") static let refreshEnrollments = Notification.Name("refreshEnrollments") } @@ -29,3 +31,4 @@ public extension Notification { case isForced } } + diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index bc3701695..dd58deb4f 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -8,6 +8,7 @@ import Alamofire import SwiftUI import Combine +import ZipArchive public enum DownloadState: String { case waiting @@ -17,17 +18,18 @@ public enum DownloadState: String { public var order: Int { switch self { case .inProgress: - 1 + return 1 case .waiting: - 2 + return 2 case .finished: - 3 + return 3 } } } public enum DownloadType: String { case video + case html, problem } public struct DownloadDataTask: Identifiable, Hashable { @@ -43,6 +45,7 @@ public struct DownloadDataTask: Identifiable, Hashable { public var state: DownloadState public let type: DownloadType public let fileSize: Int + public var lastModified: String? public var fileSizeInMb: Double { Double(fileSize) / 1024.0 / 1024.0 @@ -64,7 +67,8 @@ public struct DownloadDataTask: Identifiable, Hashable { resumeData: Data?, state: DownloadState, type: DownloadType, - fileSize: Int + fileSize: Int, + lastModified: String ) { self.id = id self.courseId = courseId @@ -78,6 +82,7 @@ public struct DownloadDataTask: Identifiable, Hashable { self.state = state self.type = type self.fileSize = fileSize + self.lastModified = lastModified } public init(sourse: CDDownloadData) { @@ -93,6 +98,7 @@ public struct DownloadDataTask: Identifiable, Hashable { self.state = DownloadState(rawValue: sourse.state ?? "") ?? .waiting self.type = DownloadType(rawValue: sourse.type ?? "") ?? .video self.fileSize = Int(sourse.fileSize) + self.lastModified = sourse.lastModified } } @@ -120,10 +126,11 @@ public protocol DownloadManagerProtocol { func deleteAllFiles() async func fileUrl(for blockId: String) -> URL? + func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] func resumeDownloading() async throws func isLargeVideosSize(blocks: [CourseBlock]) -> Bool - + func removeAppSupportDirectoryUnusedContent() } @@ -152,6 +159,9 @@ public class DownloadManager: DownloadManagerProtocol { private var currentDownloadEventPublisher: PassthroughSubject = .init() private let backgroundTaskProvider = BackgroundTaskProvider() private var cancellables = Set() + private var failedDownloads: [DownloadDataTask] = [] + + private let indexPage = "index.html" private var downloadQuality: DownloadQuality { appStorage.userSettings?.downloadQuality ?? .auto @@ -174,6 +184,20 @@ public class DownloadManager: DownloadManagerProtocol { Task { try? await self.resumeDownloading() } + + NotificationCenter.default.publisher(for: .tryDownloadAgain) + .compactMap { $0.object as? [DownloadDataTask] } + .sink { [weak self] downloads in + self?.tryDownloadAgain(downloads: downloads) + } + .store(in: &cancellables) + } + + private func tryDownloadAgain(downloads: [DownloadDataTask]) { + persistence.addToDownloadQueue(tasks: downloads) + Task { + try? await newDownload() + } } // MARK: - Publishers @@ -279,10 +303,65 @@ public class DownloadManager: DownloadManagerProtocol { } } + public func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] { + var updatedSequentials = sequentials + + for i in 0.. Int { + let fileManager = FileManager.default + let resourceKeys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey] + var totalSize: Int64 = 0 + + if let enumerator = fileManager.enumerator( + at: url, + includingPropertiesForKeys: resourceKeys, + options: [], + errorHandler: nil + ) { + for case let fileUrl as URL in enumerator { + let resourceValues = try fileUrl.resourceValues(forKeys: Set(resourceKeys)) + if resourceValues.isDirectory == false { + if let fileSize = resourceValues.fileSize { + totalSize += Int64(fileSize) + } + } + } + } + + return Int(totalSize) + } + public func deleteAllFiles() async { let downloadsData = await getDownloadTasks() for downloadData in downloadsData { - if let fileURL = await fileUrl(for: downloadData.id) { + if let fileURL = fileUrl(for: downloadData.id) { do { try FileManager.default.removeItem(at: fileURL) } catch { @@ -292,17 +371,23 @@ public class DownloadManager: DownloadManagerProtocol { } currentDownloadEventPublisher.send(.clearedAll) } - + public func fileUrl(for blockId: String) -> URL? { guard let data = persistence.downloadDataTask(for: blockId), data.url.count > 0, - data.state == .finished - else { - return nil + data.state == .finished else { return nil } + let path = filesFolderUrl + switch data.type { + case .html, .problem: + if let folderUrl = URL(string: data.url) { + let folder = folderUrl.deletingPathExtension().lastPathComponent + return path?.appendingPathComponent(folder).appendingPathComponent(indexPage) + } else { + return nil + } + case .video: + return path?.appendingPathComponent(data.fileName) } - let path = videosFolderUrl - let fileName = data.fileName - return path?.appendingPathComponent(fileName) } // MARK: - Private Intents @@ -313,10 +398,29 @@ public class DownloadManager: DownloadManagerProtocol { } guard let downloadTask = await persistence.nextBlockForDownloading() else { isDownloadingInProgress = false + if !failedDownloads.isEmpty { + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .showDownloadFailed, + object: self.failedDownloads + ) + self.failedDownloads = [] + } + } + return + } + if !connectivity.isInternetAvaliable { + failedDownloads.append(downloadTask) + try await cancelDownloading(task: downloadTask) return } + currentDownloadTask = downloadTask - try downloadFileWithProgress(downloadTask) + if downloadTask.type == .html || downloadTask.type == .problem { + try downloadHTMLWithProgress(downloadTask) + } else { + try downloadFileWithProgress(downloadTask) + } currentDownloadEventPublisher.send(.started(downloadTask)) } @@ -349,7 +453,7 @@ public class DownloadManager: DownloadManagerProtocol { downloadRequest = AF.download(url) } - downloadRequest?.downloadProgress { [weak self] prog in + downloadRequest?.downloadProgress { [weak self] prog in guard let self else { return } let fractionCompleted = prog.fractionCompleted self.currentDownloadTask?.progress = fractionCompleted @@ -361,7 +465,16 @@ public class DownloadManager: DownloadManagerProtocol { downloadRequest?.responseData { [weak self] data in guard let self else { return } - if let data = data.value, let url = self.videosFolderUrl { + if let error = data.error { + if error.asAFError?.isExplicitlyCancelledError == false { + failedDownloads.append(download) + Task { + try? await self.newDownload() + } + return + } + } + if let data = data.value, let url = self.filesFolderUrl { self.saveFile(fileName: download.fileName, data: data, folderURL: url) self.persistence.updateDownloadState( id: download.id, @@ -377,6 +490,62 @@ public class DownloadManager: DownloadManagerProtocol { } } + private func downloadHTMLWithProgress(_ download: DownloadDataTask) throws { + guard let url = URL(string: download.url) else { + return + } + + persistence.updateDownloadState( + id: download.id, + state: .inProgress, + resumeData: download.resumeData + ) + self.isDownloadingInProgress = true + if let resumeData = download.resumeData { + downloadRequest = AF.download(resumingWith: resumeData) + } else { + downloadRequest = AF.download(url) + } + + downloadRequest?.downloadProgress { [weak self] prog in + guard let self else { return } + let fractionCompleted = prog.fractionCompleted + self.currentDownloadTask?.progress = fractionCompleted + self.currentDownloadTask?.state = .inProgress + self.currentDownloadEventPublisher.send(.progress(fractionCompleted, download)) + let completed = Double(fractionCompleted * 100) + debugLog(">>>>> Downloading", download.url, completed, "%") + } + + downloadRequest?.responseData { [weak self] data in + guard let self else { return } + if let error = data.error { + if error.asAFError?.isExplicitlyCancelledError == false { + failedDownloads.append(download) + Task { + try? await self.newDownload() + } + return + } + } + if let data = data.value, let url = self.filesFolderUrl, + let fileName = URL(string: download.url)?.lastPathComponent { + self.saveFile(fileName: fileName, data: data, folderURL: url) + self.unzipFile(url: url.appendingPathComponent(fileName)) + self.persistence.updateDownloadState( + id: download.id, + state: .finished, + resumeData: nil + ) + self.currentDownloadTask?.state = .finished + self.currentDownloadEventPublisher.send(.finished(download)) + Task { + try? await self.newDownload() + } + } + } + } + private func waitingAll() async { let tasks = await persistence.getDownloadDataTasks() for task in tasks.filter({ $0.state == .inProgress }) { @@ -417,8 +586,9 @@ public class DownloadManager: DownloadManagerProtocol { .store(in: &cancellables) } - lazy var videosFolderUrl: URL? = { + var filesFolderUrl: URL? { let documentDirectoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + guard let folderPathComponent else { return nil } let directoryURL = documentDirectoryURL.appendingPathComponent(folderPathComponent, isDirectory: true) if FileManager.default.fileExists(atPath: directoryURL.path) { @@ -436,13 +606,13 @@ public class DownloadManager: DownloadManagerProtocol { return nil } } - }() + } - private var folderPathComponent: String { + private var folderPathComponent: String? { if let id = appStorage.user?.id { return "\(id)_Files" } - return "Files" + return nil } private func saveFile(fileName: String, data: Data, folderURL: URL) { @@ -453,11 +623,38 @@ public class DownloadManager: DownloadManagerProtocol { debugLog("SaveFile Error", error.localizedDescription) } } - + + private func unzipFile(url: URL) { + let fileName = url.deletingPathExtension().lastPathComponent + guard let directoryURL = filesFolderUrl else { + return + } + let uniqueDirectory = directoryURL.appendingPathComponent(fileName, isDirectory: true) + + try? FileManager.default.removeItem(at: uniqueDirectory) + + do { + try FileManager.default.createDirectory( + at: uniqueDirectory, + withIntermediateDirectories: true, + attributes: nil + ) + } catch { + debugLog("Error creating temporary directory: \(error.localizedDescription)") + } + SSZipArchive.unzipFile(atPath: url.path, toDestination: uniqueDirectory.path) + + do { + try FileManager.default.removeItem(at: url) + } catch { + debugLog("Error removing file: \(error.localizedDescription)") + } + } + public func removeAppSupportDirectoryUnusedContent() { deleteMD5HashedFolders() } - + private func getApplicationSupportDirectory() -> URL? { let fileManager = FileManager.default do { @@ -473,18 +670,18 @@ public class DownloadManager: DownloadManagerProtocol { return nil } } - + private func isMD5Hash(_ folderName: String) -> Bool { let md5Regex = "^[a-fA-F0-9]{32}$" let predicate = NSPredicate(format: "SELF MATCHES %@", md5Regex) return predicate.evaluate(with: folderName) } - + private func deleteMD5HashedFolders() { guard let appSupportDirectory = getApplicationSupportDirectory() else { return } - + let fileManager = FileManager.default do { let folderContents = try fileManager.contentsOfDirectory( @@ -592,9 +789,9 @@ public final class BackgroundTaskProvider { #if DEBUG public class DownloadManagerMock: DownloadManagerProtocol { - public init() { - - } + public init() {} + + public func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] {[]} public var currentDownloadTask: DownloadDataTask? { return nil @@ -619,15 +816,14 @@ public class DownloadManagerMock: DownloadManagerProtocol { resumeData: nil, state: .inProgress, type: .video, - fileSize: 0 + fileSize: 0, + lastModified: "" ) ) ).eraseToAnyPublisher() } - public func addToDownloadQueue(blocks: [CourseBlock]) { - - } + public func addToDownloadQueue(blocks: [CourseBlock]) {} public func getDownloadTasks() -> [DownloadDataTask] { [] @@ -639,34 +835,20 @@ public class DownloadManagerMock: DownloadManagerProtocol { } } - public func cancelDownloading(courseId: String, blocks: [CourseBlock]) async throws { - - } + public func cancelDownloading(courseId: String, blocks: [CourseBlock]) async throws {} - public func cancelDownloading(task: DownloadDataTask) { + public func cancelDownloading(task: DownloadDataTask) {} - } + public func cancelDownloading(courseId: String) async {} - public func cancelDownloading(courseId: String) async { + public func cancelAllDownloading() async throws {} - } + public func resumeDownloading() {} - public func cancelAllDownloading() async throws { + public func deleteFile(blocks: [CourseBlock]) {} - } + public func deleteAllFiles() {} - public func resumeDownloading() { - - } - - public func deleteFile(blocks: [CourseBlock]) { - - } - - public func deleteAllFiles() { - - } - public func fileUrl(for blockId: String) -> URL? { return nil } @@ -675,9 +857,7 @@ public class DownloadManagerMock: DownloadManagerProtocol { false } - public func removeAppSupportDirectoryUnusedContent() { - - } + public func removeAppSupportDirectoryUnusedContent() {} } #endif // swiftlint:enable file_length diff --git a/Core/Core/Network/OfflineSyncEndpoint.swift b/Core/Core/Network/OfflineSyncEndpoint.swift new file mode 100644 index 000000000..ce3f680ac --- /dev/null +++ b/Core/Core/Network/OfflineSyncEndpoint.swift @@ -0,0 +1,64 @@ +// +// OfflineSyncEndpoint.swift +// Core +// +// Created by  Stepanok Ivan on 20.06.2024. +// + +import Foundation +import Alamofire + +enum OfflineSyncEndpoint: EndPointType { + case submitOfflineProgress(courseID: String, blockID: String, data: String) + + var path: String { + switch self { + case let .submitOfflineProgress(courseID, blockID, _): + return "/courses/\(courseID)/xblock/\(blockID)/handler/xmodule_handler/problem_check" + } + } + + var httpMethod: HTTPMethod { + switch self { + case .submitOfflineProgress: + return .post + } + } + + var headers: HTTPHeaders? { + nil + } + + var task: HTTPTask { + switch self { + case let .submitOfflineProgress(_, _, data): + return .requestParameters(parameters: decode(query: data), encoding: URLEncoding.httpBody) + } + } + + func decode(query: String) -> Parameters { + var parameters: Parameters = [:] + + let pairs = query.split(separator: "&") + for pair in pairs { + let keyValue = pair.split(separator: "=") + if keyValue.count == 2 { + let key = String(keyValue[0]).removingPercentEncoding! + let value = String(keyValue[1]).removingPercentEncoding! + + if key.hasSuffix("[]") { + let trimmedKey = String(key.dropLast(2)) + if parameters[trimmedKey] == nil { + parameters[trimmedKey] = [value] + } else if var existingArray = parameters[trimmedKey] as? [String] { + existingArray.append(value) + parameters[trimmedKey] = existingArray + } + } else { + parameters[key] = value + } + } + } + return parameters + } +} diff --git a/Core/Core/Network/OfflineSyncManager.swift b/Core/Core/Network/OfflineSyncManager.swift new file mode 100644 index 000000000..6e9cdd30f --- /dev/null +++ b/Core/Core/Network/OfflineSyncManager.swift @@ -0,0 +1,85 @@ +// +// OfflineSyncManager.swift +// Core +// +// Created by  Stepanok Ivan on 20.06.2024. +// + +import Foundation +import WebKit +import Combine +import Swinject + +public protocol OfflineSyncManagerProtocol { + func handleMessage(message: WKScriptMessage, blockID: String) + func syncOfflineProgress() async +} + +public class OfflineSyncManager: OfflineSyncManagerProtocol { + + let persistence: CorePersistenceProtocol + let interactor: OfflineSyncInteractorProtocol + let connectivity: ConnectivityProtocol + private var cancellables = Set() + + public init( + persistence: CorePersistenceProtocol, + interactor: OfflineSyncInteractorProtocol, + connectivity: ConnectivityProtocol + ) { + self.persistence = persistence + self.interactor = interactor + self.connectivity = connectivity + + self.connectivity.internetReachableSubject.sink(receiveValue: { state in + switch state { + case .reachable: + Task(priority: .low) { + await self.syncOfflineProgress() + } + case .notReachable, nil: + break + } + }).store(in: &cancellables) + } + + public func handleMessage(message: WKScriptMessage, blockID: String) { + if message.name == "IOSBridge", + let progressJson = message.body as? String { + 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) { + var correctedProgressJson = offlineProgress.progressJson + correctedProgressJson = correctedProgressJson.removingPercentEncoding ?? correctedProgressJson + message.webView?.evaluateJavaScript("markProblemCompleted('\(correctedProgressJson)')") + } + } + + public func syncOfflineProgress() async { + let offlineProgress = persistence.loadAllOfflineProgress() + let cookies = HTTPCookieStorage.shared.cookies + HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } + for progress in offlineProgress { + do { + if try await interactor.submitOfflineProgress( + courseID: progress.courseID, + blockID: progress.blockID, + data: progress.data + ) { + persistence.deleteProgress(for: progress.blockID) + } + if let config = Container.shared.resolve(ConfigProtocol.self), let cookies { + HTTPCookieStorage.shared.setCookies(cookies, for: config.baseURL, mainDocumentURL: nil) + } + } catch { + debugLog("Error submitting offline progress: \(error.localizedDescription)") + } + } + } +} diff --git a/Core/Core/SwiftGen/Assets.swift b/Core/Core/SwiftGen/Assets.swift index 6f38ed569..f5bede856 100644 --- a/Core/Core/SwiftGen/Assets.swift +++ b/Core/Core/SwiftGen/Assets.swift @@ -103,8 +103,10 @@ public enum CoreAssets { public static let certificateBadge = ImageAsset(name: "certificateBadge") public static let check = ImageAsset(name: "check") public static let checkEmail = ImageAsset(name: "checkEmail") + public static let checkCircle = ImageAsset(name: "check_circle") public static let chevronRight = ImageAsset(name: "chevron_right") public static let clearInput = ImageAsset(name: "clearInput") + public static let download = ImageAsset(name: "download") public static let edit = ImageAsset(name: "edit") public static let favorite = ImageAsset(name: "favorite") public static let finishedSequence = ImageAsset(name: "finished_sequence") @@ -123,11 +125,14 @@ public enum CoreAssets { public static let noWifiMini = ImageAsset(name: "noWifiMini") public static let notAvaliable = ImageAsset(name: "notAvaliable") public static let playVideo = ImageAsset(name: "playVideo") + public static let remove = ImageAsset(name: "remove") + public static let reportOctagon = ImageAsset(name: "report_octagon") public static let resumeCourse = ImageAsset(name: "resumeCourse") public static let settings = ImageAsset(name: "settings") public static let star = ImageAsset(name: "star") public static let starOutline = ImageAsset(name: "star_outline") public static let viewAll = ImageAsset(name: "viewAll") + public static let visibility = ImageAsset(name: "visibility") public static let warning = ImageAsset(name: "warning") public static let warningFilled = ImageAsset(name: "warning_filled") } diff --git a/Core/Core/View/Base/FileWebView.swift b/Core/Core/View/Base/FileWebView.swift new file mode 100644 index 000000000..b6b98f4e5 --- /dev/null +++ b/Core/Core/View/Base/FileWebView.swift @@ -0,0 +1,65 @@ +// +// FileWebView.swift +// Core +// +// Created by  Stepanok Ivan on 08.07.2024. +// + +import Foundation +import WebKit +import SwiftUI + +public struct FileWebView: UIViewRepresentable { + public func makeUIView(context: Context) -> WKWebView { + let webview = WKWebView() + webview.scrollView.bounces = false + webview.scrollView.alwaysBounceHorizontal = false + webview.scrollView.showsHorizontalScrollIndicator = false + webview.scrollView.isScrollEnabled = true + webview.configuration.suppressesIncrementalRendering = true + webview.isOpaque = false + webview.configuration.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") + webview.configuration.defaultWebpagePreferences.allowsContentJavaScript = true + webview.backgroundColor = .clear + webview.scrollView.backgroundColor = UIColor.white + webview.scrollView.alwaysBounceVertical = false + webview.scrollView.layer.cornerRadius = 24 + webview.scrollView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + + if let url = URL(string: viewModel.url) { + + if let fileURL = URL(string: url.absoluteString) { + let fileAccessURL = fileURL.deletingLastPathComponent() + if let pdfData = try? Data(contentsOf: url) { + webview.load( + pdfData, + mimeType: "application/pdf", + characterEncodingName: "", + baseURL: fileAccessURL + ) + } + } + } + + return webview + } + + public func updateUIView(_ webview: WKWebView, context: Context) { + + } + + public class ViewModel: ObservableObject { + + @Published var url: String + + public init(url: String) { + self.url = url + } + } + + @ObservedObject var viewModel: ViewModel + + public init(viewModel: ViewModel) { + self.viewModel = viewModel + } +} diff --git a/Core/Core/View/Base/WebBrowser.swift b/Core/Core/View/Base/WebBrowser.swift index 9d6a113ac..cd61dcdb6 100644 --- a/Core/Core/View/Base/WebBrowser.swift +++ b/Core/Core/View/Base/WebBrowser.swift @@ -17,11 +17,18 @@ public struct WebBrowser: View { private var url: String private var pageTitle: String private var showProgress: Bool + private let connectivity: ConnectivityProtocol - public init(url: String, pageTitle: String, showProgress: Bool = false) { + public init( + url: String, + pageTitle: String, + showProgress: Bool = false, + connectivity: ConnectivityProtocol + ) { self.url = url self.pageTitle = pageTitle self.showProgress = showProgress + self.connectivity = connectivity } public var body: some View { @@ -57,10 +64,13 @@ public struct WebBrowser: View { viewModel: .init( url: url, baseURL: "", + openFile: {_ in}, injections: [.colorInversionCss, .readability, .accessibility] ), isLoading: $isLoading, - refreshCookies: {} + refreshCookies: { + }, + connectivity: connectivity ) .accessibilityIdentifier("web_browser") } @@ -71,6 +81,6 @@ public struct WebBrowser: View { struct WebBrowser_Previews: PreviewProvider { static var previews: some View { - WebBrowser(url: "", pageTitle: "") + WebBrowser(url: "", pageTitle: "", connectivity: Connectivity()) } } diff --git a/Core/Core/View/Base/WebUnitView.swift b/Core/Core/View/Base/WebUnitView.swift index a00b3a4b2..56d392abf 100644 --- a/Core/Core/View/Base/WebUnitView.swift +++ b/Core/Core/View/Base/WebUnitView.swift @@ -10,23 +10,38 @@ import SwiftUI import Theme public struct WebUnitView: View { - + @StateObject private var viewModel: WebUnitViewModel @State private var isWebViewLoading = false - + private var url: String private var injections: [WebviewInjection]? - + private let connectivity: ConnectivityProtocol + private var blockID: String + @State private var isFileOpen: Bool = false + @State private var dataUrl: String? + @State private var fileUrl: String = "" + public init( url: String, + dataUrl: String?, viewModel: WebUnitViewModel, - injections: [WebviewInjection]? + connectivity: ConnectivityProtocol, + injections: [WebviewInjection]?, + blockID: String ) { self._viewModel = .init( wrappedValue: viewModel ) self.url = url + self.dataUrl = dataUrl + self.connectivity = connectivity self.injections = injections + self.blockID = blockID + + if !self.connectivity.isInternetAvaliable, let dataUrl { + self.url = dataUrl + } } @ViewBuilder @@ -62,11 +77,14 @@ public struct WebUnitView: View { ZStack(alignment: .center) { GeometryReader { reader in ScrollView { - if viewModel.cookiesReady { + if viewModel.cookiesReady || dataUrl != nil { WebView( viewModel: .init( url: url, baseURL: viewModel.config.baseURL.absoluteString, + openFile: { file in + self.fileUrl = file + }, injections: injections ), isLoading: $isWebViewLoading, @@ -74,6 +92,10 @@ public struct WebUnitView: View { await viewModel.updateCookies( force: true ) + }, + connectivity: connectivity, + message: { message in + viewModel.syncManager.handleMessage(message: message, blockID: blockID) } ) .frame( @@ -85,6 +107,32 @@ public struct WebUnitView: View { .introspect(.scrollView, on: .iOS(.v15...), customize: { scrollView in scrollView.isScrollEnabled = false }) + .onChange(of: self.fileUrl, perform: { file in + if file != "" { + self.isFileOpen = true + } + }) + .sheet(isPresented: $isFileOpen, onDismiss: { self.fileUrl = ""; isFileOpen = false }, content: { + GeometryReader { reader2 in + ZStack(alignment: .topTrailing) { + ScrollView { + FileWebView(viewModel: FileWebView.ViewModel(url: fileUrl)) + .frame(width: reader2.size.width, height: reader2.size.height) + } + Button(action: { + isFileOpen = false + }, label: { + ZStack { + Circle().frame(width: 32, height: 32) + .foregroundColor(.white) + .shadow(color: .black.opacity(0.2), radius: 12) + Image(systemName: "xmark").renderingMode(.template) + .foregroundColor(.black) + }.padding(16) + }) + } + } + }) if viewModel.updatingCookies || isWebViewLoading { VStack { ProgressBar(size: 40, lineWidth: 8) @@ -94,7 +142,9 @@ public struct WebUnitView: View { } }.onFirstAppear { Task { - await viewModel.updateCookies() + if dataUrl == nil { + await viewModel.updateCookies() + } } } } diff --git a/Core/Core/View/Base/WebUnitViewModel.swift b/Core/Core/View/Base/WebUnitViewModel.swift index 6a76a6ee2..e8bc585c3 100644 --- a/Core/Core/View/Base/WebUnitViewModel.swift +++ b/Core/Core/View/Base/WebUnitViewModel.swift @@ -12,6 +12,7 @@ public class WebUnitViewModel: ObservableObject, WebviewCookiesUpdateProtocol { public let authInteractor: AuthInteractorProtocol let config: ConfigProtocol + let syncManager: OfflineSyncManagerProtocol @Published public var updatingCookies: Bool = false @Published public var cookiesReady: Bool = false @@ -26,8 +27,13 @@ public class WebUnitViewModel: ObservableObject, WebviewCookiesUpdateProtocol { } } - public init(authInteractor: AuthInteractorProtocol, config: ConfigProtocol) { + public init( + authInteractor: AuthInteractorProtocol, + config: ConfigProtocol, + syncManager: OfflineSyncManagerProtocol + ) { self.authInteractor = authInteractor self.config = config + self.syncManager = syncManager } } diff --git a/Core/Core/View/Base/Webview/WebView.swift b/Core/Core/View/Base/Webview/WebView.swift index 1b12167db..78d974b29 100644 --- a/Core/Core/View/Base/Webview/WebView.swift +++ b/Core/Core/View/Base/Webview/WebView.swift @@ -28,10 +28,17 @@ public struct WebView: UIViewRepresentable { @Published var url: String let baseURL: String let injections: [WebviewInjection]? + var openFile: (String) -> Void - public init(url: String, baseURL: String, injections: [WebviewInjection]? = nil) { + public init( + url: String, + baseURL: String, + openFile: @escaping (String) -> Void, + injections: [WebviewInjection]? = nil + ) { self.url = url self.baseURL = baseURL + self.openFile = openFile self.injections = injections } } @@ -39,21 +46,28 @@ public struct WebView: UIViewRepresentable { @ObservedObject var viewModel: ViewModel @Binding public var isLoading: Bool var webViewNavDelegate: WebViewNavigationDelegate? + let connectivity: ConnectivityProtocol + var message: ((WKScriptMessage) -> Void) var refreshCookies: () async -> Void var webViewType: String? + private let userContentControllerName = "IOSBridge" public init( viewModel: ViewModel, isLoading: Binding, refreshCookies: @escaping () async -> Void, navigationDelegate: WebViewNavigationDelegate? = nil, + connectivity: ConnectivityProtocol, + message: @escaping ((WKScriptMessage) -> Void) = { _ in }, webViewType: String? = nil ) { self.viewModel = viewModel self._isLoading = isLoading self.refreshCookies = refreshCookies self.webViewNavDelegate = navigationDelegate + self.connectivity = connectivity + self.message = message self.webViewType = webViewType } @@ -133,6 +147,13 @@ public struct WebView: UIViewRepresentable { guard let url = navigationAction.request.url else { return .cancel } + if url.absoluteString.starts(with: "file:///") { + if url.pathExtension == "pdf" { + await parent.viewModel.openFile(url.absoluteString) + return .cancel + } + } + let isWebViewDelegateHandled = await ( parent.webViewNavDelegate?.webView( webView, @@ -165,18 +186,20 @@ public struct WebView: UIViewRepresentable { _ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse ) async -> WKNavigationResponsePolicy { - guard let response = (navigationResponse.response as? HTTPURLResponse), - let url = response.url else { - return .cancel - } - let baseURL = await parent.viewModel.baseURL - - if (401...404).contains(response.statusCode) || url.absoluteString.hasPrefix(baseURL + "/login") { - await parent.refreshCookies() - DispatchQueue.main.async { - if let url = webView.url { - let request = URLRequest(url: url) - webView.load(request) + if parent.connectivity.isInternetAvaliable { + guard let response = (navigationResponse.response as? HTTPURLResponse), + let url = response.url else { + return .cancel + } + let baseURL = await parent.viewModel.baseURL + + if (401...404).contains(response.statusCode) || url.absoluteString.hasPrefix(baseURL + "/login") { + await parent.refreshCookies() + DispatchQueue.main.async { + if let url = webView.url { + let request = URLRequest(url: url) + webView.load(request) + } } } } @@ -217,9 +240,14 @@ public struct WebView: UIViewRepresentable { _ userContentController: WKUserContentController, didReceive message: WKScriptMessage ) { + self.parent.message(message) parent.viewModel.injections?.handle(message: message) } } + + public func webView(_ webView: WKWebView, shouldPreviewElement elementInfo: WKContextMenuElementInfo) -> Bool { + return true + } private var userAgent: String { let info = Bundle.main.infoDictionary @@ -238,6 +266,9 @@ public struct WebView: UIViewRepresentable { public func makeUIView(context: UIViewRepresentableContext) -> WKWebView { let webViewConfig = WKWebViewConfiguration() + webViewConfig.userContentController.add(context.coordinator, name: userContentControllerName) + webViewConfig.defaultWebpagePreferences.allowsContentJavaScript = true + webViewConfig.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") let webView = WKWebView(frame: .zero, configuration: webViewConfig) #if DEBUG @@ -260,7 +291,6 @@ public struct WebView: UIViewRepresentable { webView.scrollView.backgroundColor = Theme.Colors.background.uiColor() webView.scrollView.alwaysBounceVertical = false webView.scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 200, right: 0) - // To add ability to change font size with webkitTextSizeAdjust need to set mode to mobile webView.configuration.defaultWebpagePreferences.preferredContentMode = .mobile webView.applyInjections(viewModel.injections, toHandler: context.coordinator) diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index 2ec932caa..bd2e05ca1 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -7,7 +7,8 @@ objects = { /* Begin PBXBuildFile section */ - 02197DC52C1AFC0600CC8FF2 /* CalendarSyncStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EBC7542C19CFCF00BE182C /* CalendarSyncStatusView.swift */; }; + 02228B2F2C221412009A5F28 /* LargestDownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02228B2E2C221412009A5F28 /* LargestDownloadsView.swift */; }; + 02197DC52C1AFC0600CC8FF2 /* CalendarSyncStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EBC7542C19CFCF00BE182C /* CalendarSyncStatusView.swift */; }; 02280F5E294B4FDA0032823A /* CourseCoreModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 02280F5C294B4FDA0032823A /* CourseCoreModel.xcdatamodeld */; }; 02280F60294B50030032823A /* CoursePersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02280F5F294B50030032823A /* CoursePersistenceProtocol.swift */; }; 022C64D829ACEC48000F532B /* HandoutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022C64D729ACEC48000F532B /* HandoutsView.swift */; }; @@ -37,6 +38,7 @@ 0270210328E736E700F54332 /* CourseOutlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0270210128E736E700F54332 /* CourseOutlineView.swift */; }; 0276D75B29DDA3890004CDF8 /* Data_ResumeBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276D75A29DDA3890004CDF8 /* Data_ResumeBlock.swift */; }; 0276D75D29DDA3F80004CDF8 /* ResumeBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276D75C29DDA3F80004CDF8 /* ResumeBlock.swift */; }; + 02868AE52C19FE0B0003E339 /* DownloadActionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02868AE42C19FE0B0003E339 /* DownloadActionView.swift */; }; 0289F90228E1C3E10064F8F3 /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = 0289F90128E1C3E00064F8F3 /* swiftgen.yml */; }; 0295B1D9297E6DF8003B0C65 /* CourseUnitViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0295B1D8297E6DF8003B0C65 /* CourseUnitViewModelTests.swift */; }; 0295C889299BBE8200ABE571 /* CourseNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0295C888299BBE8200ABE571 /* CourseNavigationView.swift */; }; @@ -48,14 +50,19 @@ 02B6B3C928E1E68100232911 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02B6B3C828E1E68100232911 /* Core.framework */; }; 02BB20182BFCE7B200364948 /* CustomDisclosureGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02BB20172BFCE7B200364948 /* CustomDisclosureGroup.swift */; }; 02C355392C08DCD700501342 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 02C355372C08DCD700501342 /* Localizable.stringsdict */; }; + 02C7B1D82C271A7000D2A7BB /* OfflineContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B1D72C271A7000D2A7BB /* OfflineContentView.swift */; }; 02D4FC2E2BBD7C9C00C47748 /* MessageSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D4FC2D2BBD7C9C00C47748 /* MessageSectionView.swift */; }; 02E3803E2BFF9F0A00815AFA /* CourseProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E3803D2BFF9F0A00815AFA /* CourseProgressView.swift */; }; 02F0144F28F46474002E513D /* CourseContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F0144E28F46474002E513D /* CourseContainerView.swift */; }; 02F0145728F4A2FF002E513D /* CourseContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F0145628F4A2FF002E513D /* CourseContainerViewModel.swift */; }; 02F3BFDD29252E900051930C /* CourseRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFDC29252E900051930C /* CourseRouter.swift */; }; + 02F71B4A2C1B163B00FF936A /* DownloadErrorAlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F71B492C1B163A00FF936A /* DownloadErrorAlertView.swift */; }; + 02F71B4C2C1B200900FF936A /* DeviceStorageFullAlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F71B4B2C1B200900FF936A /* DeviceStorageFullAlertView.swift */; }; 02F78AEB29E6BCA20038DE30 /* VideoPlayerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F78AEA29E6BCA20038DE30 /* VideoPlayerViewModelTests.swift */; }; 02F98A8128F8224200DE94C0 /* Discussion.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02F98A8028F8224200DE94C0 /* Discussion.framework */; }; 02FCB2B32BBEB36600373180 /* CourseHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FCB2B22BBEB36600373180 /* CourseHeaderView.swift */; }; + 02FF6FA72C20BFF800E44DD8 /* OfflineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FF6FA62C20BFF800E44DD8 /* OfflineView.swift */; }; + 02FF6FAA2C20D56A00E44DD8 /* TotalDownloadedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FF6FA92C20D56A00E44DD8 /* TotalDownloadedProgressView.swift */; }; 02FFAD0D29E4347300140E46 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FFAD0C29E4347300140E46 /* VideoPlayerViewModel.swift */; }; 060E8BCA2B5FD68C0080C952 /* UnitStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 060E8BC92B5FD68C0080C952 /* UnitStack.swift */; }; 065275352BB1B39C0093BCCA /* PlayerViewControllerHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 065275342BB1B39C0093BCCA /* PlayerViewControllerHolder.swift */; }; @@ -108,6 +115,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 02228B2E2C221412009A5F28 /* LargestDownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargestDownloadsView.swift; sourceTree = ""; }; 02280F5D294B4FDA0032823A /* CourseCoreModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CourseCoreModel.xcdatamodel; sourceTree = ""; }; 02280F5F294B50030032823A /* CoursePersistenceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoursePersistenceProtocol.swift; sourceTree = ""; }; 022C64D729ACEC48000F532B /* HandoutsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandoutsView.swift; sourceTree = ""; }; @@ -137,6 +145,7 @@ 0270210128E736E700F54332 /* CourseOutlineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseOutlineView.swift; sourceTree = ""; }; 0276D75A29DDA3890004CDF8 /* Data_ResumeBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_ResumeBlock.swift; sourceTree = ""; }; 0276D75C29DDA3F80004CDF8 /* ResumeBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResumeBlock.swift; sourceTree = ""; }; + 02868AE42C19FE0B0003E339 /* DownloadActionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadActionView.swift; sourceTree = ""; }; 0289F8EE28E1C3510064F8F3 /* Course.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Course.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0289F90128E1C3E00064F8F3 /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; 0295B1D8297E6DF8003B0C65 /* CourseUnitViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseUnitViewModelTests.swift; sourceTree = ""; }; @@ -151,15 +160,20 @@ 02BB20172BFCE7B200364948 /* CustomDisclosureGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDisclosureGroup.swift; sourceTree = ""; }; 02C355382C08DCD700501342 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; }; 02C3553A2C08DCE000501342 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = uk; path = uk.lproj/Localizable.stringsdict; sourceTree = ""; }; + 02C7B1D72C271A7000D2A7BB /* OfflineContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineContentView.swift; sourceTree = ""; }; 02D4FC2D2BBD7C9C00C47748 /* MessageSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSectionView.swift; sourceTree = ""; }; 02E3803D2BFF9F0A00815AFA /* CourseProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseProgressView.swift; sourceTree = ""; }; 02EBC7542C19CFCF00BE182C /* CalendarSyncStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSyncStatusView.swift; sourceTree = ""; }; 02F0144E28F46474002E513D /* CourseContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseContainerView.swift; sourceTree = ""; }; 02F0145628F4A2FF002E513D /* CourseContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseContainerViewModel.swift; sourceTree = ""; }; 02F3BFDC29252E900051930C /* CourseRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseRouter.swift; sourceTree = ""; }; + 02F71B492C1B163A00FF936A /* DownloadErrorAlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadErrorAlertView.swift; sourceTree = ""; }; + 02F71B4B2C1B200900FF936A /* DeviceStorageFullAlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStorageFullAlertView.swift; sourceTree = ""; }; 02F78AEA29E6BCA20038DE30 /* VideoPlayerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = VideoPlayerViewModelTests.swift; path = CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift; sourceTree = SOURCE_ROOT; }; 02F98A8028F8224200DE94C0 /* Discussion.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Discussion.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 02FCB2B22BBEB36600373180 /* CourseHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseHeaderView.swift; sourceTree = ""; }; + 02FF6FA62C20BFF800E44DD8 /* OfflineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineView.swift; sourceTree = ""; }; + 02FF6FA92C20D56A00E44DD8 /* TotalDownloadedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TotalDownloadedProgressView.swift; sourceTree = ""; }; 02FFAD0C29E4347300140E46 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; 060E8BC92B5FD68C0080C952 /* UnitStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitStack.swift; sourceTree = ""; }; 065275342BB1B39C0093BCCA /* PlayerViewControllerHolder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerViewControllerHolder.swift; sourceTree = ""; }; @@ -276,10 +290,21 @@ 02454CA92A2619B40043052A /* LessonProgressView.swift */, BAD9CA2C2B2736BB00DE790A /* LessonLineProgressView.swift */, 060E8BC92B5FD68C0080C952 /* UnitStack.swift */, + 02C7B1D72C271A7000D2A7BB /* OfflineContentView.swift */, ); path = Subviews; sourceTree = ""; }; + 02868AE32C19FDF10003E339 /* ActionViews */ = { + isa = PBXGroup; + children = ( + 02868AE42C19FE0B0003E339 /* DownloadActionView.swift */, + 02F71B492C1B163A00FF936A /* DownloadErrorAlertView.swift */, + 02F71B4B2C1B200900FF936A /* DeviceStorageFullAlertView.swift */, + ); + path = ActionViews; + sourceTree = ""; + }; 0289F8E428E1C3510064F8F3 = { isa = PBXGroup; children = ( @@ -397,6 +422,7 @@ 02EAE2CA28E1F0A700529644 /* Presentation */ = { isa = PBXGroup; children = ( + 02FF6FA52C20BFE100E44DD8 /* Offline */, DB7D6EAA2ADFCAA00036BB13 /* Dates */, 070019A828F6F33600D5FC78 /* Container */, 070019A728F6F2D600D5FC78 /* Outline */, @@ -411,6 +437,24 @@ path = Presentation; sourceTree = ""; }; + 02FF6FA52C20BFE100E44DD8 /* Offline */ = { + isa = PBXGroup; + children = ( + 02FF6FA62C20BFF800E44DD8 /* OfflineView.swift */, + 02FF6FA82C20D53C00E44DD8 /* Subviews */, + ); + path = Offline; + sourceTree = ""; + }; + 02FF6FA82C20D53C00E44DD8 /* Subviews */ = { + isa = PBXGroup; + children = ( + 02FF6FA92C20D56A00E44DD8 /* TotalDownloadedProgressView.swift */, + 02228B2E2C221412009A5F28 /* LargestDownloadsView.swift */, + ); + path = Subviews; + sourceTree = ""; + }; 068DDA5A2B1E198700FF8CCB /* DropdownList */ = { isa = PBXGroup; children = ( @@ -578,6 +622,7 @@ BAD9CA482B2C88D500DE790A /* Subviews */ = { isa = PBXGroup; children = ( + 02868AE32C19FDF10003E339 /* ActionViews */, 9784D4762BF39EFD00AFEFFF /* DatesSuccessView */, 9784D4752BF39EEF00AFEFFF /* CalendarSyncProgressView */, 02D4FC2C2BBD7C7500C47748 /* MessageSectionView */, @@ -853,6 +898,7 @@ BAD9CA4A2B2C88E000DE790A /* CourseVideoDownloadBarView.swift in Sources */, 022C64DE29AD167A000F532B /* HandoutsUpdatesDetailView.swift in Sources */, BA58CF612B471041005B102E /* VideoDownloadQualityBarView.swift in Sources */, + 02FF6FA72C20BFF800E44DD8 /* OfflineView.swift in Sources */, 0270210328E736E700F54332 /* CourseOutlineView.swift in Sources */, 068DDA602B1E198700FF8CCB /* CourseUnitVerticalsDropdownView.swift in Sources */, 067B7B512BED339200D1768F /* PipManagerProtocol.swift in Sources */, @@ -867,9 +913,12 @@ 02280F60294B50030032823A /* CoursePersistenceProtocol.swift in Sources */, 02454CAA2A2619B40043052A /* LessonProgressView.swift in Sources */, 975F475E2B6151FD00E5B031 /* CourseDatesMock.swift in Sources */, + DB7D6EB22ADFE9510036BB13 /* Data_CourseDates.swift in Sources */, + 02FF6FAA2C20D56A00E44DD8 /* TotalDownloadedProgressView.swift in Sources */, 975F47602B615DA700E5B031 /* CourseStructureMock.swift in Sources */, 02280F5E294B4FDA0032823A /* CourseCoreModel.xcdatamodeld in Sources */, 0766DFCE299AB26D00EBEF6A /* EncodedVideoPlayer.swift in Sources */, + 02C7B1D82C271A7000D2A7BB /* OfflineContentView.swift in Sources */, 0276D75B29DDA3890004CDF8 /* Data_ResumeBlock.swift in Sources */, 0276D75D29DDA3F80004CDF8 /* ResumeBlock.swift in Sources */, 02F3BFDD29252E900051930C /* CourseRouter.swift in Sources */, @@ -886,15 +935,19 @@ 067B7B4F2BED339200D1768F /* PlayerDelegateProtocol.swift in Sources */, 0231124D28EDA804002588FB /* CourseUnitView.swift in Sources */, 027020FC28E7362100F54332 /* Data_CourseOutlineResponse.swift in Sources */, + 02F71B4A2C1B163B00FF936A /* DownloadErrorAlertView.swift in Sources */, + DB7D6EB02ADFDA0E0036BB13 /* CourseDates.swift in Sources */, 067B7B532BED339200D1768F /* PlayerServiceProtocol.swift in Sources */, BAD9CA2D2B2736BB00DE790A /* LessonLineProgressView.swift in Sources */, 02197DC52C1AFC0600CC8FF2 /* CalendarSyncStatusView.swift in Sources */, 060E8BCA2B5FD68C0080C952 /* UnitStack.swift in Sources */, + 02228B2F2C221412009A5F28 /* LargestDownloadsView.swift in Sources */, 0295C889299BBE8200ABE571 /* CourseNavigationView.swift in Sources */, 06FD7EDF2B1F29F3008D632B /* CourseVerticalImageView.swift in Sources */, BAC0E0DB2B32F0AE006B68A9 /* CourseVideoDownloadBarViewModel.swift in Sources */, DB7D6EAE2ADFCB4A0036BB13 /* CourseDatesViewModel.swift in Sources */, 067B7B522BED339200D1768F /* SubtitlesView.swift in Sources */, + 02868AE52C19FE0B0003E339 /* DownloadActionView.swift in Sources */, 07DE59862BECB868001CBFBC /* CourseAnalytics.swift in Sources */, 02BB20182BFCE7B200364948 /* CustomDisclosureGroup.swift in Sources */, 022C64E229ADEB83000F532B /* CourseUpdate.swift in Sources */, @@ -917,6 +970,8 @@ 02E3803E2BFF9F0A00815AFA /* CourseProgressView.swift in Sources */, 022F8E162A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift in Sources */, 02B6B3BE28E1D15C00232911 /* CourseEndpoint.swift in Sources */, + 97EA4D862B85034D00663F58 /* CalendarManager.swift in Sources */, + 02F71B4C2C1B200900FF936A /* DeviceStorageFullAlertView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index dc90d9bd4..c068f4722 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -214,6 +214,20 @@ public class CourseRepository: CourseRepositoryProtocol { .replacingOccurrences(of: "?lang=\($0.key)", with: "") return SubtitleUrl(language: $0.key, url: url) } + + var offlineDownload: OfflineDownload? + + if let offlineData = block.offlineDownload, + let fileUrl = offlineData.fileUrl, + let lastModified = offlineData.lastModified, + let fileSize = offlineData.fileSize { + let fullUrl = fileUrl.starts(with: "http") ? fileUrl : config.baseURL.absoluteString + fileUrl + offlineDownload = OfflineDownload( + fileUrl: fullUrl, + lastModified: lastModified, + fileSize: fileSize + ) + } return CourseBlock( blockId: block.blockId, @@ -236,7 +250,8 @@ public class CourseRepository: CourseRepositoryProtocol { mobileLow: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.mobileLow), hls: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.hls) ), - multiDevice: block.multiDevice + multiDevice: block.multiDevice, + offlineDownload: offlineDownload ) } @@ -435,6 +450,19 @@ And there are various ways of describing it-- call it oral poetry or let url = $0.value return SubtitleUrl(language: $0.key, url: url) } + + var offlineDownload: OfflineDownload? + + if let offlineData = block.offlineDownload, + let fileUrl = offlineData.fileUrl, + let lastModified = offlineData.lastModified, + let fileSize = offlineData.fileSize { + offlineDownload = OfflineDownload( + fileUrl: fileUrl, + lastModified: lastModified, + fileSize: fileSize + ) + } return CourseBlock( blockId: block.blockId, @@ -457,7 +485,8 @@ And there are various ways of describing it-- call it oral poetry or mobileLow: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.mobileLow), hls: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.hls) ), - multiDevice: block.multiDevice + multiDevice: block.multiDevice, + offlineDownload: offlineDownload ) } diff --git a/Course/Course/Data/Model/Data_CourseOutlineResponse.swift b/Course/Course/Data/Model/Data_CourseOutlineResponse.swift index 5cee8c3e0..a4fe30b96 100644 --- a/Course/Course/Data/Model/Data_CourseOutlineResponse.swift +++ b/Course/Course/Data/Model/Data_CourseOutlineResponse.swift @@ -84,6 +84,7 @@ public extension DataLayer { public let userViewData: CourseDetailUserViewData? public let multiDevice: Bool? public let assignmentProgress: AssignmentProgress? + public let offlineDownload: OfflineDownload? public init( blockId: String, @@ -99,7 +100,8 @@ public extension DataLayer { allSources: [String]?, userViewData: CourseDetailUserViewData?, multiDevice: Bool?, - assignmentProgress: AssignmentProgress? + assignmentProgress: AssignmentProgress?, + offlineDownload: OfflineDownload? ) { self.blockId = blockId self.id = id @@ -115,6 +117,7 @@ public extension DataLayer { self.userViewData = userViewData self.multiDevice = multiDevice self.assignmentProgress = assignmentProgress + self.offlineDownload = offlineDownload } public enum CodingKeys: String, CodingKey { @@ -127,6 +130,7 @@ public extension DataLayer { case allSources = "all_sources" case multiDevice = "student_view_multi_device" case assignmentProgress = "assignment_progress" + case offlineDownload = "offline_download" } } @@ -147,6 +151,24 @@ public extension DataLayer { self.numPointsPossible = numPointsPossible } } + + struct OfflineDownload: Codable { + public let fileUrl: String? + public let lastModified: String? + public let fileSize: Int? + + public enum CodingKeys: String, CodingKey { + case fileUrl = "file_url" + case lastModified = "last_modified" + case fileSize = "file_size" + } + + public init(fileUrl: String?, lastModified: String?, fileSize: Int?) { + self.fileUrl = fileUrl + self.lastModified = lastModified + self.fileSize = fileSize + } + } struct Transcripts: Codable { public let en: String? diff --git a/Course/Course/Data/Network/CourseEndpoint.swift b/Course/Course/Data/Network/CourseEndpoint.swift index 6ce7a048a..323ae5e8d 100644 --- a/Course/Course/Data/Network/CourseEndpoint.swift +++ b/Course/Course/Data/Network/CourseEndpoint.swift @@ -24,7 +24,7 @@ enum CourseEndpoint: EndPointType { var path: String { switch self { case .getCourseBlocks: - return "/api/mobile/v3/course_info/blocks/" + return "/api/mobile/v4/course_info/blocks/" case .pageHTML(let url): return "/xblock/\(url)" case .blockCompletionRequest: @@ -82,7 +82,7 @@ enum CourseEndpoint: EndPointType { "username": userName, "course_id": courseID, "depth": "all", - "student_view_data": "video,discussion,html", + "student_view_data": "video,discussion,html,problem", "nav_depth": "4", "requested_fields": """ contains_gated_content,show_gated_sections,special_exam_info,graded, diff --git a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents index d8e99bd3e..cb4d84738 100644 --- a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents +++ b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -9,8 +9,11 @@ + + + @@ -107,4 +110,4 @@ - + \ No newline at end of file diff --git a/Course/Course/Domain/CourseInteractor.swift b/Course/Course/Domain/CourseInteractor.swift index dcd9eac1d..e4498d024 100644 --- a/Course/Course/Domain/CourseInteractor.swift +++ b/Course/Course/Domain/CourseInteractor.swift @@ -13,6 +13,7 @@ public protocol CourseInteractorProtocol { func getCourseBlocks(courseID: String) async throws -> CourseStructure func getCourseVideoBlocks(fullStructure: CourseStructure) -> 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 func getHandouts(courseID: String) async throws -> String? func getUpdates(courseID: String) async throws -> [CourseUpdate] @@ -67,6 +68,28 @@ public class CourseInteractor: CourseInteractorProtocol { return try await repository.getLoadedCourseBlocks(courseID: courseID) } + public func getSequentialsContainsBlocks(blockIds: [String], courseID: String) async throws -> [CourseSequential] { + let courseStructure = try await repository.getLoadedCourseBlocks(courseID: courseID) + var sequentials: [CourseSequential] = [] + + for chapter in courseStructure.childs { + for sequential in chapter.childs { + let filteredChilds = sequential.childs.filter { vertical in + vertical.childs.contains { block in + blockIds.contains(block.id) + } + } + if !filteredChilds.isEmpty { + var newSequential = sequential + newSequential.childs = filteredChilds + sequentials.append(newSequential) + } + } + } + + return sequentials + } + 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) @@ -133,7 +156,7 @@ public class CourseInteractor: CourseInteractorProtocol { type: sequential.type, completion: sequential.completion, childs: newChilds, - sequentialProgress: sequential.sequentialProgress, + sequentialProgress: sequential.sequentialProgress, due: sequential.due ) } diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 6ffa4cf88..0b7c36ca1 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -230,6 +230,19 @@ public struct CourseContainerView: View { } .tag(tab) .accentColor(Theme.Colors.accentColor) + case .offline: + OfflineView( + courseID: courseID, + coordinate: $coordinate, + collapsed: $collapsed, + viewModel: viewModel + ) + .tabItem { + tab.image + Text(tab.title) + } + .tag(tab) + .accentColor(Theme.Colors.accentColor) case .discussion: DiscussionTopicsView( courseID: courseID, diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 8f95d25b9..a15a53d98 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -17,6 +17,7 @@ public enum CourseTab: Int, CaseIterable, Identifiable { case course case videos case dates + case offline case discussion case handounds } @@ -30,13 +31,15 @@ extension CourseTab { return CourseLocalization.CourseContainer.videos case .dates: return CourseLocalization.CourseContainer.dates + case .offline: + return CourseLocalization.CourseContainer.offline case .discussion: return CourseLocalization.CourseContainer.discussions case .handounds: return CourseLocalization.CourseContainer.handouts } } - + public var image: Image { switch self { case .course: @@ -45,6 +48,8 @@ extension CourseTab { return CoreAssets.videos.swiftUIImage.renderingMode(.template) case .dates: return CoreAssets.dates.swiftUIImage.renderingMode(.template) + case .offline: + return CoreAssets.downloads.swiftUIImage.renderingMode(.template) case .discussion: return CoreAssets.discussions.swiftUIImage.renderingMode(.template) case .handounds: @@ -54,7 +59,7 @@ extension CourseTab { } public class CourseContainerViewModel: BaseCourseViewModel { - + @Published public var selection: Int @Published var isShowProgress = true @Published var isShowRefresh = false @@ -69,9 +74,14 @@ public class CourseContainerViewModel: BaseCourseViewModel { @Published var isInternetAvaliable: Bool = true @Published var dueDatesShifted: Bool = false @Published var updateCourseProgress: Bool = false + @Published var totalFilesSize: Int = 1 + @Published var downloadedFilesSize: Int = 0 + @Published var realDownloadedFilesSize: Int = 0 + @Published var largestDownloadBlocks: [CourseBlock] = [] + @Published var downloadAllButtonState: OfflineView.DownloadAllState = .start let completionPublisher = NotificationCenter.default.publisher(for: .onblockCompletionRequested) - + var errorMessage: String? { didSet { withAnimation { @@ -83,23 +93,25 @@ public class CourseContainerViewModel: BaseCourseViewModel { let router: CourseRouter let config: ConfigProtocol let connectivity: ConnectivityProtocol - + let isActive: Bool? let courseStart: Date? let courseEnd: Date? let enrollmentStart: Date? let enrollmentEnd: Date? let lastVisitedBlockID: String? - + var courseDownloadTasks: [DownloadDataTask] = [] private(set) var waitingDownloads: [CourseBlock]? - + private let interactor: CourseInteractorProtocol private let authInteractor: AuthInteractorProtocol let analytics: CourseAnalytics let coreAnalytics: CoreAnalytics private(set) var storage: CourseStorage - + + private let cellularFileSizeLimit: Int = 100 * 1024 * 1024 + public init( interactor: CourseInteractorProtocol, authInteractor: AuthInteractorProtocol, @@ -135,7 +147,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { self.lastVisitedBlockID = lastVisitedBlockID self.coreAnalytics = coreAnalytics self.selection = selection.rawValue - + super.init(manager: manager) addObservers() } @@ -146,7 +158,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { updateCourseProgress = false } } - + func openLastVisitedBlock() { guard let continueWith = continueWith, let courseStructure = courseStructure else { return } @@ -199,6 +211,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { } courseVideosStructure = interactor.getCourseVideoBlocks(fullStructure: courseStructure!) await setDownloadsStates() + await getDownloadingProgress() isShowProgress = false isShowRefresh = false @@ -228,7 +241,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { } } } - + @MainActor func shiftDueDates(courseID: String, withProgress: Bool = true, screen: DatesStatusInfoScreen, type: String) async { isShowProgress = withProgress @@ -272,7 +285,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { storage.userSettings?.downloadQuality = downloadQuality userSettings = storage.userSettings } - + @MainActor func tryToRefreshCookies() async { try? await authInteractor.getCookies(force: false) @@ -296,39 +309,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { } } } - - @MainActor - func onDownloadViewTap(chapter: CourseChapter, blockId: String, state: DownloadViewState) async { - guard let sequential = chapter.childs - .first(where: { $0.id == blockId }) else { - return - } - - let blocks = sequential.childs.flatMap { $0.childs } - .filter { $0.isDownloadable } - - if state == .available, isShowedAllowLargeDownloadAlert(blocks: blocks) { - return - } - - if state == .available { - analytics.bulkDownloadVideosSubsection( - courseID: courseStructure?.id ?? "", - sectionID: chapter.id, - subSectionID: sequential.id, - videos: blocks.count - ) - } else if state == .finished { - analytics.bulkDeleteVideosSubsection( - courseID: courseStructure?.id ?? "", - subSectionID: sequential.id, - videos: blocks.count - ) - } - - await download(state: state, blocks: blocks) - } - + func verticalsBlocksDownloadable(by courseSequential: CourseSequential) -> [CourseBlock] { let verticals = downloadableVerticals.filter { verticalState in courseSequential.childs.contains(where: { item in @@ -337,7 +318,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { } return verticals.flatMap { $0.vertical.childs.filter { $0.isDownloadable } } } - + func getTasks(sequential: CourseSequential) -> [DownloadDataTask] { let blocks = verticalsBlocksDownloadable(by: sequential) let tasks = blocks.compactMap { block in @@ -345,7 +326,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { } return tasks } - + func continueDownload() async { guard let blocks = waitingDownloads else { return @@ -358,7 +339,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { } } } - + func trackSelectedTab( selection: CourseTab, courseId: String, @@ -369,6 +350,8 @@ public class CourseContainerViewModel: BaseCourseViewModel { analytics.courseOutlineCourseTabClicked(courseId: courseId, courseName: courseName) case .videos: analytics.courseOutlineVideosTabClicked(courseId: courseId, courseName: courseName) + case .offline: + analytics.courseOutlineOfflineTabClicked(courseId: courseId, courseName: courseName) case .dates: analytics.courseOutlineDatesTabClicked(courseId: courseId, courseName: courseName) case .discussion: @@ -377,7 +360,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { analytics.courseOutlineHandoutsTabClicked(courseId: courseId, courseName: courseName) } } - + func trackVerticalClicked( courseId: String, courseName: String, @@ -398,7 +381,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { courseID: courseID ) } - + func trackSequentialClicked(_ sequential: CourseSequential) { guard let course = courseStructure else { return } analytics.sequentialClicked( @@ -417,7 +400,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { blockId: blockId ) } - + func completeBlock( chapterID: String, sequentialID: String, @@ -433,14 +416,14 @@ public class CourseContainerViewModel: BaseCourseViewModel { .childs.firstIndex(where: { $0.id == sequentialID }) else { return } - + guard let verticalIndex = courseStructure? .childs[chapterIndex] .childs[sequentialIndex] .childs.firstIndex(where: { $0.id == verticalID }) else { return } - + guard let blockIndex = courseStructure? .childs[chapterIndex] .childs[sequentialIndex] @@ -448,7 +431,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { .childs.firstIndex(where: { $0.id == blockID }) else { return } - + courseStructure? .childs[chapterIndex] .childs[sequentialIndex] @@ -458,7 +441,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { courseVideosStructure = interactor.getCourseVideoBlocks(fullStructure: $0) } } - + func hasVideoForDowbloads() -> Bool { guard let courseVideosStructure = courseVideosStructure else { return false @@ -467,7 +450,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { .flatMap { $0.childs } .contains(where: { $0.isDownloadable }) } - + func isAllDownloading() -> Bool { let totalCount = downloadableVerticals.count let downloadingCount = downloadableVerticals.filter { $0.state == .downloading }.count @@ -475,9 +458,29 @@ public class CourseContainerViewModel: BaseCourseViewModel { if finishedCount == totalCount { return false } return totalCount - finishedCount == downloadingCount } - + + @MainActor + func isAllDownloaded() -> Bool { + guard let course = courseStructure else { return false } + for chapter in course.childs { + for sequential in chapter.childs where sequential.isDownloadable { + let blocks = downloadableBlocks(from: sequential) + for block in blocks { + if let task = courseDownloadTasks.first(where: { $0.blockId == block.id }) { + if task.state != .finished { + return false + } + } else { + return false + } + } + } + } + return true + } + @MainActor - func download(state: DownloadViewState, blocks: [CourseBlock]) async { + func download(state: DownloadViewState, blocks: [CourseBlock], sequentials: [CourseSequential]) async { do { switch state { case .available: @@ -485,7 +488,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { case .downloading: try await manager.cancelDownloading(courseId: courseStructure?.id ?? "", blocks: blocks) case .finished: - await manager.deleteFile(blocks: blocks) + presentRemoveDownloadAlert(blocks: blocks, sequentials: sequentials) } } catch let error { if error is NoWiFiError { @@ -493,7 +496,176 @@ public class CourseContainerViewModel: BaseCourseViewModel { } } } - + + private func presentNoInternetAlert(sequentials: [CourseSequential]) { + router.presentView( + transitionStyle: .coverVertical, + view: DownloadErrorAlertView( + errorType: .noInternetConnection, + sequentials: sequentials, + close: { [weak self] in + guard let self else { return } + self.router.dismiss(animated: true) + } + ), + completion: {} + ) + } + + private func presentWifiRequiredAlert(sequentials: [CourseSequential]) { + router.presentView( + transitionStyle: .coverVertical, + view: DownloadErrorAlertView( + errorType: .wifiRequired, + sequentials: sequentials, + close: { [weak self] in + guard let self else { return } + self.router.dismiss(animated: true) + } + ), + completion: {} + ) + } + + @MainActor + private func presentConfirmDownloadCellularAlert( + blocks: [CourseBlock], + sequentials: [CourseSequential], + totalFileSize: Int, + action: @escaping () -> Void = {} + ) async { + router.presentView( + transitionStyle: .coverVertical, + view: DownloadActionView( + actionType: .confirmDownloadCellular, + sequentials: sequentials, + action: { [weak self] in + guard let self else { return } + if !self.isEnoughSpace(for: totalFileSize) { + self.presentStorageFullAlert(sequentials: sequentials) + } else { + Task { + try? await self.manager.addToDownloadQueue(blocks: blocks) + } + action() + } + self.router.dismiss(animated: true) + }, + cancel: { [weak self] in + guard let self else { return } + self.router.dismiss(animated: true) + } + ), + completion: {} + ) + } + + private func presentStorageFullAlert(sequentials: [CourseSequential]) { + router.presentView( + transitionStyle: .coverVertical, + view: DeviceStorageFullAlertView( + sequentials: sequentials, + usedSpace: getUsedDiskSpace() ?? 0, + freeSpace: getFreeDiskSpace() ?? 0, + close: { [weak self] in + guard let self else { return } + self.router.dismiss(animated: true) + } + ), + completion: {} + ) + } + + @MainActor + private func presentConfirmDownloadAlert( + blocks: [CourseBlock], + sequentials: [CourseSequential], + totalFileSize: Int, + action: @escaping () -> Void = {} + ) async { + router.presentView( + transitionStyle: .coverVertical, + view: DownloadActionView( + actionType: .confirmDownload, + sequentials: manager.updateUnzippedFileSize(for: sequentials), + action: { [weak self] in + guard let self else { return } + if !self.isEnoughSpace(for: totalFileSize) { + self.router.dismiss(animated: true) + self.presentStorageFullAlert(sequentials: sequentials) + } else { + Task { + try? await self.manager.addToDownloadQueue(blocks: blocks) + } + action() + } + self.router.dismiss(animated: true) + }, + cancel: { [weak self] in + guard let self else { return } + self.router.dismiss(animated: true) + } + ), + completion: {} + ) + } + + private func presentRemoveDownloadAlert(blocks: [CourseBlock], sequentials: [CourseSequential]) { + router.presentView( + transitionStyle: .coverVertical, + view: DownloadActionView( + actionType: .remove, + sequentials: manager.updateUnzippedFileSize(for: sequentials), + action: { [weak self] in + guard let self else { return } + Task { + await self.manager.deleteFile(blocks: blocks) + } + self.router.dismiss(animated: true) + }, + cancel: { [weak self] in + guard let self else { return } + self.router.dismiss(animated: true) + } + ), + completion: {} + ) + } + + @MainActor + func collectBlocks(chapter: CourseChapter, blockId: String, state: DownloadViewState) 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 } + + if state == .available, isShowedAllowLargeDownloadAlert(blocks: blocks) { + return [] + } + + guard let sequential = chapter.childs.first(where: { $0.id == blockId }) else { + return [] + } + + if state == .available { + analytics.bulkDownloadVideosSubsection( + courseID: courseStructure?.id ?? "", + sectionID: chapter.id, + subSectionID: sequential.id, + videos: blocks.count + ) + } else if state == .finished { + analytics.bulkDeleteVideosSubsection( + courseID: courseStructure?.id ?? "", + subSectionID: sequential.id, + videos: blocks.count + ) + } + + return blocks + } + @MainActor func isShowedAllowLargeDownloadAlert(blocks: [CourseBlock]) -> Bool { waitingDownloads = nil @@ -518,7 +690,94 @@ public class CourseContainerViewModel: BaseCourseViewModel { } return false } - + + @MainActor + func downloadAll() async { + guard let course = courseStructure else { return } + var blocksToDownload: [CourseBlock] = [] + var sequentialsToDownload: [CourseSequential] = [] + + for chapter in course.childs { + for sequential in chapter.childs where sequential.isDownloadable { + let blocks = downloadableBlocks(from: sequential) + let notDownloadedBlocks = blocks.filter { !isBlockDownloaded($0) } + if !notDownloadedBlocks.isEmpty { + var updatedSequential = sequential + updatedSequential.childs = updatedSequential.childs.map { vertical in + var updatedVertical = vertical + updatedVertical.childs = vertical.childs.filter { block in + notDownloadedBlocks.contains { $0.id == block.id } + } + return updatedVertical + } + blocksToDownload.append(contentsOf: notDownloadedBlocks) + sequentialsToDownload.append(updatedSequential) + } + } + } + + if !blocksToDownload.isEmpty { + let totalFileSize = blocksToDownload.reduce(0) { $0 + ($1.fileSize ?? 0) } + + if !connectivity.isInternetAvaliable { + presentNoInternetAlert(sequentials: sequentialsToDownload) + } else if connectivity.isMobileData { + if storage.userSettings?.wifiOnly == true { + presentWifiRequiredAlert(sequentials: sequentialsToDownload) + } else { + await presentConfirmDownloadCellularAlert( + blocks: blocksToDownload, + sequentials: sequentialsToDownload, + totalFileSize: totalFileSize, + action: { [weak self] in + guard let self else { return } + self.downloadAllButtonState = .cancel + } + ) + } + } else { + if totalFileSize > 100 * 1024 * 1024 { + await presentConfirmDownloadAlert( + blocks: blocksToDownload, + sequentials: sequentialsToDownload, + totalFileSize: totalFileSize, + action: { [weak self] in + guard let self else { return } + self.downloadAllButtonState = .cancel + } + ) + } else { + try? await self.manager.addToDownloadQueue(blocks: blocksToDownload) + self.downloadAllButtonState = .cancel + } + } + } + } + + @MainActor + func filterNotDownloadedBlocks(_ blocks: [CourseBlock]) -> [CourseBlock] { + return blocks.filter { block in + let fileUrl = manager.fileUrl(for: block.id) + return fileUrl == nil + } + } + + @MainActor + func isBlockDownloaded(_ block: CourseBlock) -> Bool { + courseDownloadTasks.contains { $0.blockId == block.id && $0.state == .finished } + } + + @MainActor + func stopAllDownloads() async { + do { + try await manager.cancelAllDownloading() + await setDownloadsStates() + await getDownloadingProgress() + } catch { + errorMessage = CoreLocalization.Error.unknownError + } + } + @MainActor func downloadableBlocks(from sequential: CourseSequential) -> [CourseBlock] { let verticals = sequential.childs @@ -527,7 +786,74 @@ public class CourseContainerViewModel: BaseCourseViewModel { .filter { $0.isDownloadable } return blocks } - + + @MainActor + func getDownloadingProgress() async { + guard let course = courseStructure else { return } + + var totalFilesSize: Int = 0 + var downloadedFilesSize: Int = 0 + var sequentials: [CourseSequential] = [] + + var updatedBlocks: [CourseBlock] = [] + for chapter in course.childs { + for sequential in chapter.childs { + sequentials.append(sequential) + for vertical in sequential.childs { + for block in vertical.childs { + let updatedBlock = await updateFileSizeIfNeeded(for: block) + updatedBlocks.append(updatedBlock) + } + } + } + } + + for block in updatedBlocks { + if let fileSize = block.fileSize { + totalFilesSize += fileSize + } + } + + if connectivity.isInternetAvaliable { + let updatedSequentials = 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), + let fileSize = getFileSize(at: fileUrl), + task.type == .video { + if fileSize > 0 { + downloadedFilesSize += fileSize + } + } else { + downloadedFilesSize += task.fileSize + } + } + + withAnimation(.linear(duration: 0.3)) { + self.downloadedFilesSize = downloadedFilesSize + } + withAnimation(.linear(duration: 0.3)) { + self.totalFilesSize = totalFilesSize + } + await fetchLargestDownloadBlocks() + } + + private func getFileSize(at url: URL) -> Int? { + do { + let fileAttributes = try FileManager.default.attributesOfItem(atPath: url.path) + if let fileSize = fileAttributes[.size] as? Int, fileSize > 0 { + return fileSize + } + } catch { + debugLog("Error getting file size: \(error.localizedDescription)") + } + return nil + } + @MainActor func setDownloadsStates() async { guard let course = courseStructure else { return } @@ -540,7 +866,19 @@ public class CourseContainerViewModel: BaseCourseViewModel { for vertical in sequential.childs where vertical.isDownloadable { var verticalsChilds: [DownloadViewState] = [] for block in vertical.childs where block.isDownloadable { - if let download = courseDownloadTasks.first(where: { $0.blockId == block.id }) { + if var download = courseDownloadTasks.first(where: { $0.blockId == block.id }) { + if let newDateOfLastModified = block.offlineDownload?.lastModified, + let oldDateOfLastModified = download.lastModified { + if Date(iso8601: newDateOfLastModified) > Date(iso8601: oldDateOfLastModified) { + guard isEnoughSpace(for: block.fileSize ?? 0) else { return } + download.lastModified = newDateOfLastModified + try? await manager.cancelDownloading(task: download) + sequentialsChilds.append(.available) + verticalsChilds.append(.available) + try? await self.manager.addToDownloadQueue(blocks: [block]) + continue + } + } switch download.state { case .waiting, .inProgress: sequentialsChilds.append(.downloading) @@ -570,6 +908,13 @@ public class CourseContainerViewModel: BaseCourseViewModel { sequentialsStates[sequential.id] = .available } } + let allStates = sequentialsStates.values + if allStates.contains(.downloading) { + downloadAllButtonState = .cancel + } else { + downloadAllButtonState = .start + } + self.sequentialsDownloadState = sequentialsStates } } @@ -594,7 +939,140 @@ public class CourseContainerViewModel: BaseCourseViewModel { } return nil } - + + private func isEnoughSpace(for fileSize: Int) -> Bool { + if let freeSpace = getFreeDiskSpace() { + return freeSpace > Int(Double(fileSize) * 1.2) + } + return false + } + + private func getFreeDiskSpace() -> Int? { + do { + let attributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory() as String) + if let freeSpace = attributes[.systemFreeSize] as? Int64 { + return Int(freeSpace) + } + } catch { + print("Error retrieving free disk space: \(error.localizedDescription)") + } + return nil + } + + private func getUsedDiskSpace() -> Int? { + do { + let attributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory() as String) + if let totalSpace = attributes[.systemSize] as? Int64, + let freeSpace = attributes[.systemFreeSize] as? Int64 { + return Int(totalSpace - freeSpace) + } + } catch { + print("Error retrieving used disk space: \(error.localizedDescription)") + } + return nil + } + + // MARK: Larges Downloads + + @MainActor + func fetchLargestDownloadBlocks() async { + let allBlocks = courseStructure?.childs.flatMap { $0.childs.flatMap { $0.childs.flatMap { $0.childs } } } ?? [] + let downloadedBlocks = allBlocks.filter { block in + if let task = courseDownloadTasks.first(where: { $0.blockId == block.id }) { + return task.state == .finished + } + return false + } + + var updatedDownloadedBlocks: [CourseBlock] = [] + + for block in downloadedBlocks { + let updatedBlock = await updateFileSizeIfNeeded(for: block) + updatedDownloadedBlocks.append(updatedBlock) + } + + let filteredBlocks = Array( + updatedDownloadedBlocks + .filter { $0.fileSize != nil } + .sorted { $0.fileSize! > $1.fileSize! } + .prefix(5) + ) + + withAnimation(.linear(duration: 0.3)) { + largestDownloadBlocks = filteredBlocks + } + } + + @MainActor + func updateFileSizeIfNeeded(for block: CourseBlock) async -> CourseBlock { + var updatedBlock = block + if let fileUrl = manager.fileUrl(for: block.id), + let fileSize = getFileSize(at: fileUrl), fileSize > 0, + block.type == .video { + updatedBlock.actualFileSize = fileSize + } + return updatedBlock + } + + @MainActor + func removeBlock(_ block: CourseBlock) async { + router.presentView( + transitionStyle: .coverVertical, + view: DownloadActionView( + actionType: .remove, + courseBlocks: [block], + action: { [weak self] in + guard let self else { return } + withAnimation(.linear(duration: 0.3)) { + self.largestDownloadBlocks.removeAll { $0.id == block.id } + } + Task { + await self.manager.deleteFile(blocks: [block]) + } + self.router.dismiss(animated: true) + }, + cancel: { [weak self] in + guard let self else { return } + self.router.dismiss(animated: true) + } + ), + completion: {} + ) + } + + @MainActor + func removeAllBlocks() async { + let allBlocks = courseStructure?.childs.flatMap { $0.childs.flatMap { $0.childs.flatMap { $0.childs } } } ?? [] + let blocksToRemove = allBlocks.filter { block in + if let task = courseDownloadTasks.first(where: { $0.blockId == block.id }) { + return task.state == .finished + } + return false + } + + router.presentView( + transitionStyle: .coverVertical, + view: DownloadActionView( + actionType: .remove, + courseBlocks: blocksToRemove, + courseName: courseStructure?.displayName ?? "", + action: { [weak self] in + guard let self else { return } + Task { + await self.stopAllDownloads() + await self.manager.deleteFile(blocks: blocksToRemove) + } + self.router.dismiss(animated: true) + }, + cancel: { [weak self] in + guard let self else { return } + self.router.dismiss(animated: true) + } + ), + completion: {} + ) + } + private func addObservers() { manager.eventPublisher() .sink { [weak self] state in @@ -603,16 +1081,17 @@ public class CourseContainerViewModel: BaseCourseViewModel { Task(priority: .background) { debugLog(state, "--- state ---") await self.setDownloadsStates() + await self.getDownloadingProgress() } } .store(in: &cancellables) - + connectivity.internetReachableSubject .sink { [weak self] _ in - guard let self else { return } + guard let self else { return } self.isInternetAvaliable = self.connectivity.isInternetAvaliable - } - .store(in: &cancellables) + } + .store(in: &cancellables) NotificationCenter.default.addObserver( self, @@ -621,11 +1100,17 @@ public class CourseContainerViewModel: BaseCourseViewModel { ) completionPublisher - .sink { [weak self] _ in - guard let self = self else { return } - updateCourseProgress = true - } - .store(in: &cancellables) + .sink { [weak self] _ in + guard let self = self else { return } + updateCourseProgress = true + } + .store(in: &cancellables) + + $sequentialsDownloadState.sink(receiveValue: { states in + if states.values.allSatisfy({ $0 == .available }) { + self.downloadAllButtonState = .start + } + }).store(in: &cancellables) } deinit { @@ -660,8 +1145,8 @@ extension CourseContainerViewModel { struct VerticalsDownloadState: Hashable { let vertical: CourseVertical let state: DownloadViewState - + var downloadableBlocks: [CourseBlock] { - vertical.childs.filter { $0.isDownloadable } + vertical.childs.filter { $0.isDownloadable && $0.type == .video } } } diff --git a/Course/Course/Presentation/CourseAnalytics.swift b/Course/Course/Presentation/CourseAnalytics.swift index 612f9249c..ea794754d 100644 --- a/Course/Course/Presentation/CourseAnalytics.swift +++ b/Course/Course/Presentation/CourseAnalytics.swift @@ -58,6 +58,7 @@ public protocol CourseAnalytics { func finishVerticalBackToOutlineClicked(courseId: String, courseName: String) func courseOutlineCourseTabClicked(courseId: String, courseName: String) func courseOutlineVideosTabClicked(courseId: String, courseName: String) + func courseOutlineOfflineTabClicked(courseId: String, courseName: String) func courseOutlineDatesTabClicked(courseId: String, courseName: String) func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) @@ -137,6 +138,7 @@ class CourseAnalyticsMock: CourseAnalytics { public func finishVerticalBackToOutlineClicked(courseId: String, courseName: String) {} public func courseOutlineCourseTabClicked(courseId: String, courseName: String) {} public func courseOutlineVideosTabClicked(courseId: String, courseName: String) {} + public func courseOutlineOfflineTabClicked(courseId: String, courseName: String) {} public func courseOutlineDatesTabClicked(courseId: String, courseName: String) {} public func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) {} public func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) {} diff --git a/Course/Course/Presentation/Offline/OfflineView.swift b/Course/Course/Presentation/Offline/OfflineView.swift new file mode 100644 index 000000000..02cf0ee3c --- /dev/null +++ b/Course/Course/Presentation/Offline/OfflineView.swift @@ -0,0 +1,268 @@ +// +// OfflineView.swift +// Course +// +// Created by  Stepanok Ivan on 17.06.2024. +// + +import SwiftUI +import Core +import Theme + +struct OfflineView: View { + + enum DownloadAllState: Equatable { + case start + case cancel + + var color: Color { + switch self { + case .start: + Theme.Colors.accentColor + case .cancel: + Theme.Colors.snackbarErrorColor + } + } + + var image: Image { + switch self { + case .start: + CoreAssets.startDownloading.swiftUIImage + case .cancel: + CoreAssets.stopDownloading.swiftUIImage + } + } + + var title: String { + switch self { + case .start: + CourseLocalization.Course.Offline.downloadAll + case .cancel: + CourseLocalization.Course.Offline.cancelCourseDownload + } + } + + var textColor: Color { + switch self { + case .start: + Theme.Colors.white + case .cancel: + Theme.Colors.snackbarErrorColor + } + } + } + + private let courseID: String + @Binding private var coordinate: CGFloat + @Binding private var collapsed: Bool + + @StateObject + private var viewModel: CourseContainerViewModel + + public init( + courseID: String, + coordinate: Binding, + collapsed: Binding, + viewModel: CourseContainerViewModel + ) { + self.courseID = courseID + self._coordinate = coordinate + self._collapsed = collapsed + self._viewModel = StateObject(wrappedValue: { viewModel }()) + } + + public var body: some View { + GeometryReader { proxy in + ZStack(alignment: .center) { + VStack(alignment: .center) { + + // MARK: - Page Body + if viewModel.isShowProgress { + HStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 200) + .padding(.horizontal) + } + } else { + ScrollView { + VStack(alignment: .leading) { + DynamicOffsetView( + coordinate: $coordinate, + collapsed: $collapsed + ) + TotalDownloadedProgressView( + downloadedFilesSize: viewModel.downloadedFilesSize, + totalFilesSize: viewModel.totalFilesSize, + isDownloading: Binding( + get: { viewModel.downloadAllButtonState == .cancel }, + set: { newValue in + viewModel.downloadAllButtonState = newValue ? .cancel : .start + } + ) + ) + .padding(.top, 36) + + if viewModel.downloadedFilesSize == 0 && viewModel.totalFilesSize != 0 { + Text(CourseLocalization.Course.Offline.youCanDownload) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.top, 8) + .padding(.bottom, 16) + } else if viewModel.downloadedFilesSize == 0 && viewModel.totalFilesSize == 0 { + Text(CourseLocalization.Course.Offline.youCantDownload) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.top, 8) + .padding(.bottom, 16) + } + downloadAll + + if !viewModel.largestDownloadBlocks.isEmpty { + LargestDownloadsView(viewModel: viewModel) + } + removeAllDownloads + + }.padding(.horizontal, 32) + Spacer(minLength: 84) + } + } + } + .frameLimit(width: proxy.size.width) + + // MARK: - Offline mode SnackBar + OfflineSnackBarView( + connectivity: viewModel.connectivity, + reloadAction: {} + ) + + // MARK: - Error Alert + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) + } + .padding(.bottom, viewModel.connectivity.isInternetAvaliable + ? 0 : OfflineSnackBarView.height) + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } + } + } + } + .background( + Theme.Colors.background + .ignoresSafeArea() + ) + } + } + + @ViewBuilder + private var downloadAll: some View { + if viewModel.connectivity.isInternetAvaliable + && ((viewModel.totalFilesSize - viewModel.downloadedFilesSize != 0) + || (viewModel.totalFilesSize == 0 && viewModel.downloadedFilesSize == 0)) { + Button(action: { + Task(priority: .low) { + switch viewModel.downloadAllButtonState { + case .start: + await viewModel.downloadAll() + case .cancel: + viewModel.downloadAllButtonState = .start + await viewModel.stopAllDownloads() + } + } + }) { + HStack { + viewModel.downloadAllButtonState.image + .renderingMode(.template) + Text(viewModel.downloadAllButtonState.title) + .font(Theme.Fonts.bodyMedium) + } + .foregroundStyle( + viewModel.totalFilesSize == 0 + ? Theme.Colors.disabledButtonText + : viewModel.downloadAllButtonState.textColor + ) + .frame(maxWidth: .infinity) + .frame(height: 42) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke( + viewModel.totalFilesSize == 0 + ? .clear + : viewModel.downloadAllButtonState.color, + lineWidth: 2 + ) + ) + .background( + viewModel.totalFilesSize == 0 + ? Theme.Colors.disabledButton + : viewModel.downloadAllButtonState == .start ? viewModel.downloadAllButtonState.color : .clear + ) + .cornerRadius(8) + } + } + } + + @ViewBuilder + private var removeAllDownloads: some View { + if viewModel.downloadAllButtonState == .start && !viewModel.largestDownloadBlocks.isEmpty { + VStack(spacing: 16) { + Button(action: { + Task { + await viewModel.removeAllBlocks() + } + }) { + HStack { + CoreAssets.remove.swiftUIImage + Text(CourseLocalization.Course.LargestDownloads.removeDownloads) + .font(Theme.Fonts.bodyMedium) + } + .foregroundStyle(Theme.Colors.snackbarErrorColor) + .frame(maxWidth: .infinity) + .frame(height: 42) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.snackbarErrorColor, lineWidth: 2) + ) + .background(Theme.Colors.background) + .cornerRadius(8) + } + } + .padding(.vertical, 4) + } + } +} + +#if DEBUG +#Preview { + let vm = CourseContainerViewModel( + interactor: CourseInteractor.mock, + authInteractor: AuthInteractor.mock, + router: CourseRouterMock(), + analytics: CourseAnalyticsMock(), + config: ConfigMock(), + connectivity: Connectivity(), + manager: DownloadManagerMock(), + storage: CourseStorageMock(), + isActive: true, + courseStart: nil, + courseEnd: nil, + enrollmentStart: nil, + enrollmentEnd: nil, + lastVisitedBlockID: nil, + coreAnalytics: CoreAnalyticsMock() + ) + + return OfflineView( + courseID: "123", + coordinate: .constant(0), + collapsed: .constant(false), + viewModel: vm + ).onAppear { + vm.isShowProgress = false + } +} +#endif diff --git a/Course/Course/Presentation/Offline/Subviews/LargestDownloadsView.swift b/Course/Course/Presentation/Offline/Subviews/LargestDownloadsView.swift new file mode 100644 index 000000000..6099d7e38 --- /dev/null +++ b/Course/Course/Presentation/Offline/Subviews/LargestDownloadsView.swift @@ -0,0 +1,178 @@ +// +// LargestDownloadsView.swift +// Course +// +// Created by  Stepanok Ivan on 18.06.2024. +// + +import SwiftUI +import Core +import Theme + +public struct LargestDownloadsView: View { + + @State private var isEditing = false + @ObservedObject + private var viewModel: CourseContainerViewModel + + init(viewModel: CourseContainerViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + VStack(alignment: .leading) { + HStack { + Text(CourseLocalization.Course.LargestDownloads.title) + .font(Theme.Fonts.titleMedium) + .foregroundColor(Theme.Colors.textPrimary) + Spacer() + if viewModel.downloadAllButtonState == .start { + Button(action: { + isEditing.toggle() + }) { + Text( + isEditing + ? CourseLocalization.Course.LargestDownloads.done + : CourseLocalization.Course.LargestDownloads.edit + ) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.accentColor) + } + } + } + .padding(.vertical) + + ForEach(viewModel.largestDownloadBlocks) { block in + HStack { + block.type.image + VStack(alignment: .leading) { + Text(block.displayName) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + if let fileSize = block.fileSize { + Text(fileSize.formattedFileSize()) + .font(Theme.Fonts.labelSmall) + .foregroundColor(Theme.Colors.textSecondary) + } + } + Spacer() + if isEditing { + Button(action: { + Task { + await viewModel.removeBlock(block) + } + }) { + CoreAssets.remove.swiftUIImage + .foregroundColor(Theme.Colors.alert) + } + } else { + CoreAssets.deleteDownloading.swiftUIImage + .foregroundColor(.green) + } + } + Divider() + .foregroundStyle(Theme.Colors.shade) + .padding(.vertical, 8) + } + } + .onChange(of: viewModel.downloadAllButtonState, perform: { state in + if state == .cancel { + self.isEditing = false + } + }) + .onAppear { + Task { + await viewModel.fetchLargestDownloadBlocks() + } + } + } +} + +#if DEBUG +struct LargestDownloadsView_Previews: PreviewProvider { + static var previews: some View { + + let vm = CourseContainerViewModel( + interactor: CourseInteractor.mock, + authInteractor: AuthInteractor.mock, + router: CourseRouterMock(), + analytics: CourseAnalyticsMock(), + config: ConfigMock(), + connectivity: Connectivity(), + manager: DownloadManagerMock(), + storage: CourseStorageMock(), + isActive: true, + courseStart: nil, + courseEnd: nil, + enrollmentStart: nil, + enrollmentEnd: nil, + lastVisitedBlockID: nil, + coreAnalytics: CoreAnalyticsMock() + ) + + LargestDownloadsView(viewModel: vm) + .loadFonts() + .onAppear { + vm.largestDownloadBlocks = [ + CourseBlock( + blockId: "", + id: "1", + courseId: "", + graded: false, + due: nil, + completion: 0, + type: .discussion, + displayName: "Welcome to Mobile Testing", + studentUrl: "", + webUrl: "", + encodedVideo: nil, + multiDevice: nil, + offlineDownload: OfflineDownload( + fileUrl: "123", + lastModified: "e", + fileSize: 3423123214 + ) + ), + CourseBlock( + blockId: "", + id: "2", + courseId: "", + graded: false, + due: nil, + completion: 0, + type: .video, + displayName: "Advanced Mobile Sketching", + studentUrl: "", + webUrl: "", + encodedVideo: nil, + multiDevice: nil, + offlineDownload: OfflineDownload( + fileUrl: "123", + lastModified: "e", + fileSize: 34213214 + ) + ), + CourseBlock( + blockId: "", + id: "3", + courseId: "", + graded: false, + due: nil, + completion: 0, + type: .problem, + displayName: "File Naming Conventions", + studentUrl: "", + webUrl: "", + encodedVideo: nil, + multiDevice: nil, + offlineDownload: OfflineDownload( + fileUrl: "123", + lastModified: "e", + fileSize: 742343214 + ) + ) + ] + } + } +} +#endif diff --git a/Course/Course/Presentation/Offline/Subviews/TotalDownloadedProgressView.swift b/Course/Course/Presentation/Offline/Subviews/TotalDownloadedProgressView.swift new file mode 100644 index 000000000..59bcd40b8 --- /dev/null +++ b/Course/Course/Presentation/Offline/Subviews/TotalDownloadedProgressView.swift @@ -0,0 +1,105 @@ +// +// TotalDownloadedProgressView.swift +// Course +// +// Created by  Stepanok Ivan on 17.06.2024. +// + +import SwiftUI +import Theme +import Core + +public struct TotalDownloadedProgressView: View { + + private let downloadedFilesSize: Int + private let readyToDownload: Int + private let totalFilesSize: Int + @Binding var isDownloading: Bool + + public init(downloadedFilesSize: Int, totalFilesSize: Int, isDownloading: Binding) { + self.downloadedFilesSize = downloadedFilesSize + self.totalFilesSize = totalFilesSize + self.readyToDownload = totalFilesSize - downloadedFilesSize + self._isDownloading = isDownloading + } + + public var body: some View { + VStack(alignment: .center, spacing: 6) { + HStack { + Text(downloadedFilesSize.formattedFileSize()) + .foregroundStyle( + totalFilesSize == 0 + ? Theme.Colors.textSecondaryLight + : Theme.Colors.success + ) + Spacer() + if totalFilesSize != 0 { + Text(readyToDownload.formattedFileSize()) + } + } + .font(Theme.Fonts.titleLarge) + HStack { + CoreAssets.deleteDownloading.swiftUIImage.renderingMode(.template) + .foregroundStyle( + totalFilesSize == 0 + ? Theme.Colors.textSecondaryLight + : Theme.Colors.success + ) + Text(totalFilesSize == 0 + ? CourseLocalization.Course.TotalProgress.avaliableToDownload + : CourseLocalization.Course.TotalProgress.downloaded) + .foregroundStyle( + totalFilesSize == 0 + ? Theme.Colors.textSecondaryLight + : Theme.Colors.success + ) + Spacer() + if totalFilesSize != 0 { + CoreAssets.startDownloading.swiftUIImage + Text(isDownloading ? + CourseLocalization.Course.TotalProgress.downloading + : CourseLocalization.Course.TotalProgress.readyToDownload) + } + } + .font(Theme.Fonts.labelLarge) + .padding(.bottom, 10) + if totalFilesSize != 0 { + ZStack(alignment: .leading) { + GeometryReader { geometry in + RoundedRectangle(cornerRadius: 2.5) + .fill(Theme.Colors.textSecondary.opacity(0.5)) + .frame(width: geometry.size.width, height: 5) + + RoundedCorners(tl: 2.5, tr: 0, bl: 2.5, br: 0) + .fill(Theme.Colors.success) + .frame( + width: geometry.size.width * CGFloat( + downloadedFilesSize + ) / CGFloat(totalFilesSize), + height: 5 + ) + } + .frame(height: 5) + } + .cornerRadius(5) + .padding(.bottom, 10) + } + } + .onChange(of: readyToDownload, perform: { size in + if size == 0 { + self.isDownloading = false + } + }) + } +} + +#if DEBUG +#Preview { + TotalDownloadedProgressView( + downloadedFilesSize: 24341324514, + totalFilesSize: 324324132413, + isDownloading: .constant(false) + ) + .loadFonts() +} +#endif diff --git a/Course/Course/Presentation/Outline/ContinueWithView.swift b/Course/Course/Presentation/Outline/ContinueWithView.swift index 45271c4ae..0380e51d7 100644 --- a/Course/Course/Presentation/Outline/ContinueWithView.swift +++ b/Course/Course/Presentation/Outline/ContinueWithView.swift @@ -90,7 +90,8 @@ struct ContinueWithView_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ), CourseBlock( blockId: "2", @@ -104,7 +105,8 @@ struct ContinueWithView_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ) ] diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index bb51fa74a..7cd3a1f28 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -101,6 +101,7 @@ public struct CourseOutlineView: View { // MARK: - Sections CustomDisclosureGroup( + isVideo: isVideo, course: course, proxy: proxy, viewModel: viewModel @@ -256,7 +257,8 @@ public struct CourseOutlineView: View { content: { WebBrowser( url: url, - pageTitle: CourseLocalization.Outline.certificate + pageTitle: CourseLocalization.Outline.certificate, + connectivity: viewModel.connectivity ) } ) diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift index bc3722da1..523c04e0a 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift @@ -42,7 +42,8 @@ struct CourseVerticalImageView_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ) ] @@ -60,7 +61,8 @@ struct CourseVerticalImageView_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ) ] let blocks3 = [ @@ -77,7 +79,8 @@ struct CourseVerticalImageView_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ) ] let blocks4 = [ @@ -94,7 +97,8 @@ struct CourseVerticalImageView_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ) ] let blocks5 = [ @@ -111,7 +115,8 @@ struct CourseVerticalImageView_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ) ] HStack { diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift index cd0c8d174..1614e075d 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift @@ -82,48 +82,6 @@ public struct CourseVerticalView: View { }).accessibilityElement(children: .ignore) .accessibilityLabel(vertical.displayName) Spacer() - if let state = viewModel.downloadState[vertical.id] { - switch state { - case .available: - DownloadAvailableView() - .accessibilityElement(children: .ignore) - .accessibilityLabel(CourseLocalization.Accessibility.download) - .onTapGesture { - Task { - await viewModel.onDownloadViewTap( - blockId: vertical.id, - state: state - ) - } - - } - case .downloading: - DownloadProgressView() - .accessibilityElement(children: .ignore) - .accessibilityLabel(CourseLocalization.Accessibility.cancelDownload) - .onTapGesture { - Task { - await viewModel.onDownloadViewTap( - blockId: vertical.id, - state: state - ) - } - - } - case .finished: - DownloadFinishedView() - .accessibilityElement(children: .ignore) - .accessibilityLabel(CourseLocalization.Accessibility.deleteDownload) - .onTapGesture { - Task { - await viewModel.onDownloadViewTap( - blockId: vertical.id, - state: state - ) - } - } - } - } Image(systemName: "chevron.right") .flipsForRightToLeftLayoutDirection(true) .padding(.vertical, 8) diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift index 5247ca700..e15cc024d 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift @@ -83,19 +83,6 @@ public class CourseVerticalViewModel: BaseCourseViewModel { } } } - - func trackVerticalClicked( - courseId: String, - courseName: String, - vertical: CourseVertical - ) { - analytics.verticalClicked( - courseId: courseId, - courseName: courseName, - blockId: vertical.blockId, - blockName: vertical.displayName - ) - } @MainActor private func setDownloadsStates() async { @@ -126,4 +113,17 @@ public class CourseVerticalViewModel: BaseCourseViewModel { } downloadState = states } + + func trackVerticalClicked( + courseId: String, + courseName: String, + vertical: CourseVertical + ) { + analytics.verticalClicked( + courseId: courseId, + courseName: courseName, + blockId: vertical.blockId, + blockName: vertical.displayName + ) + } } diff --git a/Course/Course/Presentation/Subviews/ActionViews/DeviceStorageFullAlertView.swift b/Course/Course/Presentation/Subviews/ActionViews/DeviceStorageFullAlertView.swift new file mode 100644 index 000000000..4e440283c --- /dev/null +++ b/Course/Course/Presentation/Subviews/ActionViews/DeviceStorageFullAlertView.swift @@ -0,0 +1,235 @@ +// +// DeviceStorageFullAlertView.swift +// Course +// +// Created by  Stepanok Ivan on 13.06.2024. +// + +import SwiftUI +import Core +import Theme + +public struct DeviceStorageFullAlertView: View { + private let sequentials: [CourseSequential] + private let usedSpace: Int + private let freeSpace: Int + private let close: () -> Void + @State private var fadeEffect: Bool = false + + @Environment(\.isHorizontal) private var isHorizontal + + init( + sequentials: [CourseSequential], + usedSpace: Int, + freeSpace: Int, + close: @escaping () -> Void + ) { + self.sequentials = sequentials + self.usedSpace = usedSpace + self.freeSpace = freeSpace + self.close = close + } + + public var body: some View { + ZStack(alignment: .bottom) { + Color.black.opacity(fadeEffect ? 0.15 : 0) + .onTapGesture { + close() + fadeEffect = false + } + content + .padding(.bottom, 20) + } + .ignoresSafeArea() + .onAppear { + withAnimation(Animation.linear(duration: 0.3).delay(0.2)) { + fadeEffect = true + } + } + } + + private var content: some View { + VStack { + HStack { + CoreAssets.reportOctagon.swiftUIImage + .scaledToFit() + .foregroundStyle(Theme.Colors.alert) + Text(CourseLocalization.Course.StorageAlert.title) + .font(Theme.Fonts.titleLarge) + } + .padding(.top, 16) + .padding(.horizontal, 16) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + + if sequentials.count <= 3 { + list + } else { + ScrollView { + list + } + .frame(maxHeight: isHorizontal ? 80 : 200) + } + + VStack(spacing: 4) { + StorageProgressBar( + usedSpace: usedSpace, + contentSize: totalSize + ) + .padding(.horizontal, 16) + .padding(.top, 8) + + HStack { + Text( + CourseLocalization.Course.StorageAlert.usedAndFree( + usedSpace.formattedFileSize(), + freeSpace.formattedFileSize() + ) + ) + .foregroundColor(Theme.Colors.textSecondaryLight) + .font(Theme.Fonts.bodySmall) + Spacer() + Text(totalSize.formattedFileSize()) + .foregroundColor(Theme.Colors.alert) + .font(Theme.Fonts.bodySmall) + } + .padding(.horizontal, 16) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + + Text(CourseLocalization.Course.StorageAlert.description) + .font(.subheadline) + .padding(.horizontal, 16) + .padding(.top, 8) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + + VStack(spacing: 16) { + Button(action: { + fadeEffect = false + close() + }) { + Text(CourseLocalization.Course.Alert.close) + .font(Theme.Fonts.bodyMedium) + .foregroundStyle(Theme.Colors.accentColor) + .frame(maxWidth: .infinity) + .frame(height: 42) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.accentColor, lineWidth: 2) + ) + .background(Theme.Colors.background) + .cornerRadius(8) + + } + } + .padding(16) + } + .background(Theme.Colors.background) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.datesSectionStroke, lineWidth: 2) + ) + .cornerRadius(8) + .padding(16) + .frame(maxWidth: 400) + } + + @ViewBuilder + var list: some View { + VStack(spacing: 8) { + ForEach(sequentials) { sequential in + HStack { + sequential.type.image + .renderingMode(.template) + .foregroundStyle(Theme.Colors.textPrimary) + Text(sequential.displayName) + .font(Theme.Fonts.bodyMedium) + .lineLimit(1) + if sequential.totalSize != 0 { + Text(sequential.totalSize.formattedFileSize()) + .foregroundColor(Theme.Colors.textSecondaryLight) + .font(Theme.Fonts.bodySmall) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + } + } + + private var totalSize: Int { + sequentials.reduce(0) { $0 + $1.totalSize } + } +} + +struct StorageProgressBar: View { + let usedSpace: Int + let contentSize: Int + + var body: some View { + GeometryReader { geometry in + let totalSpace = geometry.size.width + let usedSpace = Double(usedSpace) + let contentSize = Double(contentSize) + let total = usedSpace + contentSize + + let minSize: Double = 0.1 + let usedSpacePercentage = (usedSpace / total) + minSize + let contentSizePercentage = (contentSize / total) + minSize + let normalizationFactor = 1 / (usedSpacePercentage + contentSizePercentage) + + let normalizedUsedSpaceWidth = usedSpacePercentage * normalizationFactor + + ZStack { + RoundedRectangle(cornerRadius: 3) + .fill(Theme.Colors.datesSectionStroke) + .frame(width: totalSpace, height: 42) + + RoundedRectangle(cornerRadius: 2) + .fill(Theme.Colors.background) + .frame(width: totalSpace - 4, height: 38) + + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2) + .fill(Theme.Colors.alert) + .frame(width: totalSpace - 6, height: 36) + + HStack(spacing: 0) { + RoundedCorners(tl: 2, bl: 2) + .fill(Theme.Colors.datesSectionStroke) + .frame(width: (totalSpace - 6) * normalizedUsedSpaceWidth, height: 36) + Rectangle() + .fill(Theme.Colors.background) + .frame(width: 1, height: 36) + } + } + } + } + .frame(height: 44) + } +} + +#if DEBUG +struct DeviceStorageFullAlertView_Previews: PreviewProvider { + static var previews: some View { + DeviceStorageFullAlertView( + sequentials: [ + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ) + ], + usedSpace: 460580220928, + freeSpace: 33972756480, + close: { print("Close action triggered") } + ) + .loadFonts() + } +} +#endif diff --git a/Course/Course/Presentation/Subviews/ActionViews/DownloadActionView.swift b/Course/Course/Presentation/Subviews/ActionViews/DownloadActionView.swift new file mode 100644 index 000000000..1dbbce63c --- /dev/null +++ b/Course/Course/Presentation/Subviews/ActionViews/DownloadActionView.swift @@ -0,0 +1,326 @@ +// +// DownloadActionView.swift +// Course +// +// Created by  Stepanok Ivan on 12.06.2024. +// + +import SwiftUI +import Core +import Theme + +enum ContentActionType { + case remove + case confirmDownload + case confirmDownloadCellular +} + +struct Lesson: Identifiable { + let id = UUID() + let name: String + let size: Int + let image: Image +} + +public struct DownloadActionView: View { + private let actionType: ContentActionType + private let sequentials: [CourseSequential] + private let courseBlocks: [CourseBlock] + private let courseName: String? + private let action: () -> Void + private let cancel: () -> Void + @State private var fadeEffect: Bool = false + + @Environment(\.isHorizontal) private var isHorizontal + + init( + actionType: ContentActionType, + sequentials: [CourseSequential], + action: @escaping () -> Void, + cancel: @escaping () -> Void + ) { + self.actionType = actionType + self.sequentials = sequentials + self.courseName = nil + self.courseBlocks = [] + self.action = action + self.cancel = cancel + } + + init( + actionType: ContentActionType, + courseBlocks: [CourseBlock], + courseName: String? = nil, + action: @escaping () -> Void, + cancel: @escaping () -> Void + ) { + self.actionType = actionType + self.sequentials = [] + self.courseBlocks = courseBlocks + self.courseName = courseName + self.action = action + self.cancel = cancel + } + + public var body: some View { + ZStack(alignment: .bottom) { + Color.black.opacity(fadeEffect ? 0.15 : 0) + .onTapGesture { + cancel() + fadeEffect = false + } + content + .padding(.bottom, 20) + } + .ignoresSafeArea() + .onAppear { + withAnimation(Animation.linear(duration: 0.3).delay(0.2)) { + fadeEffect = true + } + } + } + + private var content: some View { + VStack { + HStack { + if actionType == .confirmDownloadCellular { + CoreAssets.warningFilled.swiftUIImage + .resizable() + .scaledToFit() + .frame(width: 22) + } + Text(headerTitle) + .font(Theme.Fonts.titleLarge) + } + .padding(.top, 16) + .padding(.horizontal, 16) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + + if let courseName { + HStack { + Image(systemName: "doc.text") + .renderingMode(.template) + .foregroundStyle(Theme.Colors.textPrimary) + Text(courseName) + .font(Theme.Fonts.bodyMedium) + .lineLimit(1) + } + .padding(.horizontal, 16) + .padding(.top, 8) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } else { + if sequentials.count <= 3 { + list + } else { + ScrollView { + list + } + .frame(maxHeight: isHorizontal ? 80 : 200) + } + } + + Text(descriptionText) + .font(.subheadline) + .padding(.horizontal, 16) + .padding(.top, 8) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + + VStack(spacing: 16) { + Button(action: { + fadeEffect = false + action() + }) { + HStack { + actionButtonImage + .renderingMode(.template) + Text(actionButtonText) + .font(Theme.Fonts.bodyMedium) + } + .foregroundStyle(Theme.Colors.white) + .frame(maxWidth: .infinity) + .frame(height: 42) + .background(actionButtonColor) + .cornerRadius(8) + } + + Button(action: { + fadeEffect = false + cancel() + }) { + Text(CourseLocalization.Course.Alert.cancel) + .font(Theme.Fonts.bodyMedium) + .foregroundStyle(Theme.Colors.accentColor) + .frame(maxWidth: .infinity) + .frame(height: 42) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.accentColor, lineWidth: 2) + ) + .background(Theme.Colors.background) + .cornerRadius(8) + + } + } + .padding([.leading, .trailing, .bottom], 16) + } + .background(Theme.Colors.background) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.datesSectionStroke, lineWidth: 2) + ) + .cornerRadius(8) + .padding(16) + .frame(maxWidth: 400) + } + + @ViewBuilder + var list: some View { + VStack(spacing: 8) { + if sequentials.isEmpty { + ForEach(Array(courseBlocks.enumerated()), id: \.offset) { _, block in + HStack { + block.type.image + .renderingMode(.template) + .foregroundStyle(Theme.Colors.textPrimary) + Text(block.displayName) + .font(Theme.Fonts.bodyMedium) + .lineLimit(1) + if let fileSize = block.fileSize, fileSize != 0 { + Text(fileSize.formattedFileSize()) + .foregroundColor(Theme.Colors.textSecondaryLight) + .font(Theme.Fonts.bodySmall) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + } else { + ForEach(sequentials) { sequential in + HStack { + sequential.type.image + .renderingMode(.template) + .foregroundStyle(Theme.Colors.textPrimary) + Text(sequential.displayName) + .font(Theme.Fonts.bodyMedium) + .lineLimit(1) + if sequential.totalSize != 0 { + Text(sequential.totalSize.formattedFileSize()) + .foregroundColor(Theme.Colors.textSecondaryLight) + .font(Theme.Fonts.bodySmall) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + } + } + } + + private var headerTitle: String { + switch actionType { + case .remove: + return CourseLocalization.Course.Alert.removeTitle + case .confirmDownload: + return CourseLocalization.Course.Alert.confirmDownloadTitle + case .confirmDownloadCellular: + return CourseLocalization.Course.Alert.confirmDownloadCellularTitle + } + } + + private var descriptionText: String { + switch actionType { + case .remove: + return CourseLocalization.Course.Alert.removeDescription(totalSize) + case .confirmDownload: + return CourseLocalization.Course.Alert.confirmDownloadDescription(totalSize) + case .confirmDownloadCellular: + return CourseLocalization.Course.Alert.confirmDownloadCellularDescription(totalSize) + } + } + + private var actionButtonText: String { + switch actionType { + case .remove: + return CourseLocalization.Course.Alert.remove + case .confirmDownload, .confirmDownloadCellular: + return CourseLocalization.Course.Alert.download + } + } + + private var actionButtonImage: Image { + switch actionType { + case .remove: + return CoreAssets.remove.swiftUIImage + case .confirmDownload, .confirmDownloadCellular: + return CoreAssets.startDownloading.swiftUIImage + } + } + + private var actionButtonColor: Color { + switch actionType { + case .remove: + Theme.Colors.snackbarErrorColor + case .confirmDownloadCellular, .confirmDownload: + Theme.Colors.accentColor + + } + } + + private var totalSize: String { + if sequentials.isEmpty { + courseBlocks.reduce(0) { $0 + ($1.fileSize ?? 0) }.formattedFileSize() + } else { + sequentials.reduce(0) { $0 + $1.totalSize }.formattedFileSize() + } + } +} + +#if DEBUG +struct ContentActionView_Previews: PreviewProvider { + static var previews: some View { + ScrollView { + DownloadActionView( + actionType: .remove, + sequentials: [ + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ), + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ), + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ) + ], + action: { + print("Action triggered") + }, + cancel: { print("Cancel triggered") } + ) + }.loadFonts() + } +} +#endif diff --git a/Course/Course/Presentation/Subviews/ActionViews/DownloadErrorAlertView.swift b/Course/Course/Presentation/Subviews/ActionViews/DownloadErrorAlertView.swift new file mode 100644 index 000000000..739d5267a --- /dev/null +++ b/Course/Course/Presentation/Subviews/ActionViews/DownloadErrorAlertView.swift @@ -0,0 +1,298 @@ +// +// DownloadErrorAlertView.swift +// Course +// +// Created by  Stepanok Ivan on 13.06.2024. +// + +import SwiftUI +import Core +import Theme + +public enum ContentErrorType { + case downloadFailed + case noInternetConnection + case wifiRequired +} + +public struct DownloadErrorAlertView: View { + + private let errorType: ContentErrorType + private let sequentials: [CourseSequential] + private let tryAgain: () -> Void + private let close: () -> Void + @State private var fadeEffect: Bool = false + + @Environment(\.isHorizontal) private var isHorizontal + + public init( + errorType: ContentErrorType, + sequentials: [CourseSequential], + tryAgain: @escaping () -> Void = {}, + close: @escaping () -> Void + ) { + self.errorType = errorType + self.sequentials = sequentials + self.tryAgain = tryAgain + self.close = close + } + + public var body: some View { + ZStack(alignment: .bottom) { + Color.black.opacity(fadeEffect ? 0.15 : 0) + .onTapGesture { + close() + fadeEffect = false + } + content + .padding(.bottom, 20) + } + .ignoresSafeArea() + .onAppear { + withAnimation(Animation.linear(duration: 0.3).delay(0.2)) { + fadeEffect = true + } + } + } + + private var content: some View { + VStack { + HStack { + CoreAssets.reportOctagon.swiftUIImage + .scaledToFit() + .foregroundStyle(Theme.Colors.alert) + Text(headerTitle) + .font(Theme.Fonts.titleLarge) + } + .padding(.top, 16) + .padding(.horizontal, 16) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + + if sequentials.count <= 3 { + list + } else { + ScrollView { + list + } + .frame(maxHeight: isHorizontal ? 80 : 200) + } + + Text(descriptionText) + .font(.subheadline) + .padding(.horizontal, 16) + .padding(.top, 8) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + + VStack(spacing: 16) { + + if errorType == .downloadFailed { + Button(action: { + fadeEffect = false + tryAgain() + }) { + Text(CourseLocalization.Course.Alert.tryAgain) + .font(Theme.Fonts.bodyMedium) + .foregroundStyle(Theme.Colors.white) + .frame(maxWidth: .infinity) + .frame(height: 42) + .background(Theme.Colors.accentColor) + .cornerRadius(8) + + } + } + + Button(action: { + fadeEffect = false + close() + }) { + Text(CourseLocalization.Course.Alert.close) + .font(Theme.Fonts.bodyMedium) + .foregroundStyle(Theme.Colors.accentColor) + .frame(maxWidth: .infinity) + .frame(height: 42) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.accentColor, lineWidth: 2) + ) + .background(Theme.Colors.background) + .cornerRadius(8) + + } + } + .padding([.leading, .trailing, .bottom], 16) + } + .background(Theme.Colors.background) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.datesSectionStroke, lineWidth: 2) + ) + .cornerRadius(8) + .padding(16) + .frame(maxWidth: 400) + } + + @ViewBuilder + var list: some View { + VStack(spacing: 8) { + ForEach(sequentials) { sequential in + HStack { + sequential.type.image + .renderingMode(.template) + .foregroundStyle(Theme.Colors.textPrimary) + Text(sequential.displayName) + .font(Theme.Fonts.bodyMedium) + .lineLimit(1) + if sequential.totalSize != 0 { + Text(sequential.totalSize.formattedFileSize()) + .foregroundColor(Theme.Colors.textSecondaryLight) + .font(Theme.Fonts.bodySmall) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + } + } + + private var headerTitle: String { + switch errorType { + case .downloadFailed: + return CourseLocalization.Course.Error.downloadFailedTitle + case .noInternetConnection: + return CourseLocalization.Course.Error.noInternetConnectionTitle + case .wifiRequired: + return CourseLocalization.Course.Error.wifiRequiredTitle + } + } + + private var descriptionText: String { + switch errorType { + case .downloadFailed: + return CourseLocalization.Course.Error.downloadFailedDescription + case .noInternetConnection: + return CourseLocalization.Course.Error.noInternetConnectionDescription + case .wifiRequired: + return CourseLocalization.Course.Error.wifiRequiredDescription + } + } +} + +#if DEBUG +struct DownloadErrorAlertView_Previews: PreviewProvider { + static var previews: some View { + ScrollView { + DownloadErrorAlertView( + errorType: .downloadFailed, + sequentials: [ + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ) + ], + close: { print("Cancel triggered") } + ) + + DownloadErrorAlertView( + errorType: .noInternetConnection, + sequentials: [ + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ), + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ), + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ), + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ), + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ), + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ), + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ) + ], + close: { print("Cancel triggered") } + ) + + DownloadErrorAlertView( + errorType: .wifiRequired, + sequentials: [ + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ) + ], + close: { print("Cancel triggered") } + ) + }.loadFonts() + } +} +#endif diff --git a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift index 841c49c1f..b4812db24 100644 --- a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift +++ b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift @@ -43,18 +43,17 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { return 0.0 } guard let index = courseViewModel.courseDownloadTasks.firstIndex( - where: { $0.id == currentDownloadTask.id } + where: { $0.id == currentDownloadTask.id && $0.type == .video } ) else { return 0.0 } courseViewModel.courseDownloadTasks[index].progress = currentDownloadTask.progress - return courseViewModel - .courseDownloadTasks - .reduce(0) { $0 + $1.progress } / Double(courseViewModel.courseDownloadTasks.count) + let videoTasks = courseViewModel.courseDownloadTasks.filter { $0.type == .video } + return videoTasks.reduce(0) { $0 + $1.progress } / Double(videoTasks.count) } var downloadableVerticals: Set { - courseViewModel.downloadableVerticals + courseViewModel.downloadableVerticals.filter { $0.downloadableBlocks.contains { $0.type == .video } } } var allVideosDownloaded: Bool { @@ -180,7 +179,7 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { let blocks = downloadableVerticals.filter { $0.state != .finished }.flatMap { $0.vertical.childs } await courseViewModel.download( state: .available, - blocks: blocks + blocks: blocks.filter { $0.type == .video }, sequentials: [] ) } else { do { @@ -188,7 +187,6 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { } catch { debugLog(error) } - } } diff --git a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift index 22bee864a..e783bdc07 100644 --- a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift +++ b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift @@ -12,12 +12,14 @@ import Theme struct CustomDisclosureGroup: View { @State private var expandedSections: [String: Bool] = [:] + private let isVideo: Bool private let proxy: GeometryProxy private let course: CourseStructure private let viewModel: CourseContainerViewModel private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } - init(course: CourseStructure, proxy: GeometryProxy, viewModel: CourseContainerViewModel) { + init(isVideo: Bool, course: CourseStructure, proxy: GeometryProxy, viewModel: CourseContainerViewModel) { + self.isVideo = isVideo self.course = course self.proxy = proxy self.viewModel = viewModel @@ -46,28 +48,11 @@ struct CustomDisclosureGroup: View { .foregroundColor(Theme.Colors.textPrimary) .lineLimit(1) Spacer() - if canDownloadAllSections(in: chapter), - let state = downloadAllButtonState(for: chapter) { + if canDownloadAllSections(in: chapter, videoOnly: isVideo), + let state = downloadAllButtonState(for: chapter, videoOnly: isVideo) { Button( action: { - switch state { - case .finished: - viewModel.router.presentAlert( - alertTitle: CourseLocalization.Alert.warning, - alertMessage: deleteMessage(for: chapter), - positiveAction: CoreLocalization.Alert.delete, - onCloseTapped: { - viewModel.router.dismiss(animated: true) - }, - okTapped: { - downloadAllSubsections(in: chapter, state: state) - viewModel.router.dismiss(animated: true) - }, - type: .deleteVideo - ) - default: downloadAllSubsections(in: chapter, state: state) - } }, label: { switch state { case .available: @@ -146,7 +131,14 @@ struct CustomDisclosureGroup: View { let numPointsPossible = sequentialProgress.numPointsPossible, let due = sequential.due { let daysRemaining = getAssignmentStatus(for: due) - Text("\(assignmentType) - \(daysRemaining) - \(numPointsEarned) / \(numPointsPossible)") + Text( + """ + \(assignmentType) - + \(daysRemaining) - + \(numPointsEarned) / + \(numPointsPossible) + """ + ) .font(Theme.Fonts.bodySmall) .multilineTextAlignment(.leading) .lineLimit(2) @@ -213,9 +205,15 @@ struct CustomDisclosureGroup: View { } } - private func canDownloadAllSections(in chapter: CourseChapter) -> Bool { + private func canDownloadAllSections(in chapter: CourseChapter, videoOnly: Bool) -> Bool { for sequential in chapter.childs { - if let state = viewModel.sequentialsDownloadState[sequential.id] { + if videoOnly { + let isDownloadable = sequential.childs.flatMap { + $0.childs.filter({ $0.type == .video }) + }.contains(where: { $0.isDownloadable }) + guard isDownloadable else { return false } + } + if viewModel.sequentialsDownloadState[sequential.id] != nil { return true } } @@ -224,18 +222,21 @@ struct CustomDisclosureGroup: View { private func downloadAllSubsections(in chapter: CourseChapter, state: DownloadViewState) { Task { + var allBlocks: [CourseBlock] = [] for sequential in chapter.childs { - await viewModel.onDownloadViewTap( - chapter: chapter, - blockId: sequential.id, - state: state - ) + let blocks = await viewModel.collectBlocks(chapter: chapter, blockId: sequential.id, state: state) + allBlocks.append(contentsOf: blocks) } + await viewModel.download( + state: state, + blocks: allBlocks, + sequentials: chapter.childs.filter({ $0.isDownloadable }) + ) } } - private func downloadAllButtonState(for chapter: CourseChapter) -> DownloadViewState? { - if canDownloadAllSections(in: chapter) { + 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 }) { @@ -397,6 +398,7 @@ struct CustomDisclosureGroup_Previews: PreviewProvider { return GeometryReader { proxy in ScrollView { CustomDisclosureGroup( + isVideo: false, course: CourseStructure( id: "Id", graded: false, diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index 0248e2393..f2a50efe1 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -34,6 +34,9 @@ public struct CourseUnitView: View { private let portraitTopSpacing: CGFloat = 60 private let landscapeTopSpacing: CGFloat = 75 + @State private var videoURL: URL? + @State private var webURL: URL? + let isDropdownActive: Bool var sequenceTitle: String { @@ -184,12 +187,14 @@ public struct CourseUnitView: View { isOnScreen: index == viewModel.index ) .frameLimit(width: reader.size.width) - + if !isHorizontal { Spacer(minLength: 150) } } else { - FullScreenErrorView(type: .noInternet) + OfflineContentView( + isDownloadable: false + ) } } else { @@ -214,30 +219,39 @@ public struct CourseUnitView: View { ) .padding(.top, 5) .frameLimit(width: reader.size.width) - + if !isHorizontal { Spacer(minLength: 150) } } else { - FullScreenErrorView(type: .noInternet) + OfflineContentView( + isDownloadable: true + ) } } + // MARK: Web - case let .web(url, injections): + case let .web(url, injections, blockId, isDownloadable): if index >= viewModel.index - 1 && index <= viewModel.index + 1 { - if viewModel.connectivity.isInternetAvaliable { + 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 ) - // not need to add frame limit there because we did that with injection } else { - FullScreenErrorView(type: .noInternet) + OfflineContentView( + isDownloadable: isDownloadable + ) } } else { EmptyView() } + // MARK: Unknown case .unknown(let url): if index >= viewModel.index - 1 && index <= viewModel.index + 1 { @@ -247,7 +261,9 @@ public struct CourseUnitView: View { Spacer() .frame(minHeight: 100) } else { - FullScreenErrorView(type: .noInternet) + OfflineContentView( + isDownloadable: false + ) } } else { EmptyView() @@ -449,7 +465,8 @@ struct CourseUnitView_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ), CourseBlock( blockId: "2", @@ -464,7 +481,8 @@ struct CourseUnitView_Previews: PreviewProvider { studentUrl: "2", webUrl: "2", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ), CourseBlock( blockId: "3", @@ -479,7 +497,8 @@ struct CourseUnitView_Previews: PreviewProvider { studentUrl: "3", webUrl: "3", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ), CourseBlock( blockId: "4", @@ -494,7 +513,8 @@ struct CourseUnitView_Previews: PreviewProvider { studentUrl: "4", webUrl: "4", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ), ] diff --git a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift index 8f4be45b8..8e3ef4497 100644 --- a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift +++ b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift @@ -9,9 +9,9 @@ import SwiftUI import Core public enum LessonType: Equatable { - case web(url: String, injections: [WebviewInjection]) - case youtube(youtubeVideoUrl: String, blockID: String) - case video(videoUrl: String, blockID: String) + case web(url: String, injections: [WebviewInjection], blockId: String, isDownloadable: Bool) + case youtube(youtubeVideoUrl: String, blockId: String) + case video(videoUrl: String, blockId: String) case unknown(String) case discussion(String, String, String) @@ -22,34 +22,66 @@ public enum LessonType: Equatable { return .unknown(block.studentUrl) case .unknown: if let multiDevice = block.multiDevice, multiDevice { - return .web(url: block.studentUrl, injections: mandatoryInjections) + return .web( + url: block.studentUrl, + injections: mandatoryInjections, + blockId: block.id, + isDownloadable: block.isDownloadable + ) } else { return .unknown(block.studentUrl) } case .html: - return .web(url: block.studentUrl, injections: mandatoryInjections) + return .web( + url: block.studentUrl, + injections: mandatoryInjections, + blockId: block.id, + isDownloadable: block.isDownloadable + ) case .discussion: return .discussion(block.topicId ?? "", block.id, block.displayName) case .video: if block.encodedVideo?.youtubeVideoUrl != nil, let encodedVideo = block.encodedVideo?.video(streamingQuality: streamingQuality)?.url { - return .video(videoUrl: encodedVideo, blockID: block.id) + return .video(videoUrl: encodedVideo, blockId: block.id) } else if let youtubeVideoUrl = block.encodedVideo?.youtubeVideoUrl { - return .youtube(youtubeVideoUrl: youtubeVideoUrl, blockID: block.id) + return .youtube(youtubeVideoUrl: youtubeVideoUrl, blockId: block.id) } else if let encodedVideo = block.encodedVideo?.video(streamingQuality: streamingQuality)?.url { - return .video(videoUrl: encodedVideo, blockID: block.id) + return .video(videoUrl: encodedVideo, blockId: block.id) + } else if let encodedVideo = block.encodedVideo?.video(downloadQuality: DownloadQuality.auto)?.url { + return .video(videoUrl: encodedVideo, blockId: block.id) } else { return .unknown(block.studentUrl) } case .problem: - return .web(url: block.studentUrl, injections: mandatoryInjections) + return .web( + url: block.studentUrl, + injections: mandatoryInjections, + blockId: block.id, + isDownloadable: block.isDownloadable + ) case .dragAndDropV2: - return .web(url: block.studentUrl, injections: mandatoryInjections + [.dragAndDropCss]) + return .web( + url: block.studentUrl, + injections: mandatoryInjections + [.dragAndDropCss], + blockId: block.id, + isDownloadable: block.isDownloadable + ) case .survey: - return .web(url: block.studentUrl, injections: mandatoryInjections + [.surveyCSS]) + return .web( + url: block.studentUrl, + injections: mandatoryInjections + [.surveyCSS], + blockId: block.id, + isDownloadable: block.isDownloadable + ) case .openassessment, .peerInstructionTool: - return .web(url: block.studentUrl, injections: mandatoryInjections) + return .web( + url: block.studentUrl, + injections: mandatoryInjections, + blockId: block.id, + isDownloadable: block.isDownloadable + ) } } } @@ -238,6 +270,10 @@ public class CourseUnitViewModel: ObservableObject { return URL(string: url) } } + + func urlForOfflineContent(blockId: String) -> URL? { + return manager.fileUrl(for: blockId) + } func trackFinishVerticalBackToOutlineClicked() { analytics.finishVerticalBackToOutlineClicked(courseId: courseID, courseName: courseName) diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift index 7b7310fb4..a00a2de0d 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift @@ -84,7 +84,8 @@ struct CourseUnitDropDownCell_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ) ] ) diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift index fea9801f3..86459a13a 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift @@ -58,7 +58,8 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ), CourseBlock( blockId: "2", @@ -73,7 +74,8 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { studentUrl: "2", webUrl: "2", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ), CourseBlock( blockId: "3", @@ -88,7 +90,8 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { studentUrl: "3", webUrl: "3", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ), CourseBlock( blockId: "4", @@ -103,7 +106,8 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { studentUrl: "4", webUrl: "4", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ) ] diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift index dd7ddbc75..e5f289ea4 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift @@ -71,7 +71,8 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ), CourseBlock( @@ -87,7 +88,8 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { studentUrl: "2", webUrl: "2", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ), CourseBlock( @@ -103,7 +105,8 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { studentUrl: "3", webUrl: "3", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ), CourseBlock( @@ -119,7 +122,8 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { studentUrl: "4", webUrl: "4", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ) ] diff --git a/Course/Course/Presentation/Unit/Subviews/OfflineContentView.swift b/Course/Course/Presentation/Unit/Subviews/OfflineContentView.swift new file mode 100644 index 000000000..cfe51c9f2 --- /dev/null +++ b/Course/Course/Presentation/Unit/Subviews/OfflineContentView.swift @@ -0,0 +1,65 @@ +// +// OfflineContentView.swift +// Course +// +// Created by  Stepanok Ivan on 22.06.2024. +// + +import SwiftUI +import Core +import Theme + +public struct OfflineContentView: View { + + enum OfflineContentState { + case notDownloaded + case notAvailableOffline + + var title: String { + switch self { + case .notDownloaded: + return CourseLocalization.Offline.NotDownloaded.title + case .notAvailableOffline: + return CourseLocalization.Offline.NotAvaliable.title + } + } + + var description: String { + switch self { + case .notDownloaded: + return CourseLocalization.Offline.NotDownloaded.description + case .notAvailableOffline: + return CourseLocalization.Offline.NotAvaliable.description + } + } + } + + @State private var contentState: OfflineContentState + + public init(isDownloadable: Bool) { + contentState = isDownloadable ? .notDownloaded : .notAvailableOffline + } + + public var body: some View { + VStack(spacing: 0) { + Spacer() + CoreAssets.notAvaliable.swiftUIImage + Text(contentState.title) + .font(Theme.Fonts.titleLarge) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 40) + Text(contentState.description) + .font(Theme.Fonts.bodyLarge) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 12) + Spacer() + } + .padding(24) + } +} + +#Preview { + OfflineContentView(isDownloadable: true) +} diff --git a/Course/Course/Presentation/Unit/Subviews/WebView.swift b/Course/Course/Presentation/Unit/Subviews/WebView.swift index 8d1b0c7ad..b8823bda6 100644 --- a/Course/Course/Presentation/Unit/Subviews/WebView.swift +++ b/Course/Course/Presentation/Unit/Subviews/WebView.swift @@ -12,15 +12,20 @@ import Theme struct WebView: View { let url: String + let localUrl: String? let injections: [WebviewInjection] + let blockID: String var roundedBackgroundEnabled: Bool = true - + var body: some View { VStack(spacing: 0) { WebUnitView( url: url, + dataUrl: localUrl, viewModel: Container.shared.resolve(WebUnitViewModel.self)!, - injections: injections + connectivity: Connectivity(), + injections: injections, + blockID: blockID ) if roundedBackgroundEnabled { Spacer(minLength: 5) diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index 8cf2f60a2..6b24786a9 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -57,6 +57,102 @@ public enum CourseLocalization { public static func progressCompleted(_ p1: Any, _ p2: Any) -> String { return CourseLocalization.tr("Localizable", "COURSE.PROGRESS_COMPLETED", String(describing: p1), String(describing: p2), fallback: "%@ of %@ assignments complete") } + public enum Alert { + /// Cancel + public static let cancel = CourseLocalization.tr("Localizable", "COURSE.ALERT.CANCEL", fallback: "Cancel") + /// Close + public static let close = CourseLocalization.tr("Localizable", "COURSE.ALERT.CLOSE", fallback: "Close") + /// Downloading this content will use %@ of cellular data. + public static func confirmDownloadCellularDescription(_ p1: Any) -> String { + return CourseLocalization.tr("Localizable", "COURSE.ALERT.CONFIRM_DOWNLOAD_CELLULAR_DESCRIPTION", String(describing: p1), fallback: "Downloading this content will use %@ of cellular data.") + } + /// Download on Cellular? + public static let confirmDownloadCellularTitle = CourseLocalization.tr("Localizable", "COURSE.ALERT.CONFIRM_DOWNLOAD_CELLULAR_TITLE", fallback: "Download on Cellular?") + /// Downloading this %@ of content will save available blocks offline. + public static func confirmDownloadDescription(_ p1: Any) -> String { + return CourseLocalization.tr("Localizable", "COURSE.ALERT.CONFIRM_DOWNLOAD_DESCRIPTION", String(describing: p1), fallback: "Downloading this %@ of content will save available blocks offline.") + } + /// Confirm Download + public static let confirmDownloadTitle = CourseLocalization.tr("Localizable", "COURSE.ALERT.CONFIRM_DOWNLOAD_TITLE", fallback: "Confirm Download") + /// Download + public static let download = CourseLocalization.tr("Localizable", "COURSE.ALERT.DOWNLOAD", fallback: "Download") + /// Remove + public static let remove = CourseLocalization.tr("Localizable", "COURSE.ALERT.REMOVE", fallback: "Remove") + /// Removing this content will free up %@. + public static func removeDescription(_ p1: Any) -> String { + return CourseLocalization.tr("Localizable", "COURSE.ALERT.REMOVE_DESCRIPTION", String(describing: p1), fallback: "Removing this content will free up %@.") + } + /// Remove Offline Content? + public static let removeTitle = CourseLocalization.tr("Localizable", "COURSE.ALERT.REMOVE_TITLE", fallback: "Remove Offline Content?") + /// Try again + public static let tryAgain = CourseLocalization.tr("Localizable", "COURSE.ALERT.TRY_AGAIN", fallback: "Try again") + } + public enum Error { + /// Unfortunately, this content failed to download. Please try again later or report this issue. + public static let downloadFailedDescription = CourseLocalization.tr("Localizable", "COURSE.ERROR.DOWNLOAD_FAILED_DESCRIPTION", fallback: "Unfortunately, this content failed to download. Please try again later or report this issue.") + /// Download Failed + public static let downloadFailedTitle = CourseLocalization.tr("Localizable", "COURSE.ERROR.DOWNLOAD_FAILED_TITLE", fallback: "Download Failed") + /// Downloading this content requires an active internet connection. Please connect to the internet and try again. + public static let noInternetConnectionDescription = CourseLocalization.tr("Localizable", "COURSE.ERROR.NO_INTERNET_CONNECTION_DESCRIPTION", fallback: "Downloading this content requires an active internet connection. Please connect to the internet and try again.") + /// No Internet Connection + public static let noInternetConnectionTitle = CourseLocalization.tr("Localizable", "COURSE.ERROR.NO_INTERNET_CONNECTION_TITLE", fallback: "No Internet Connection") + /// Downloading this content requires an active WiFi connection. Please connect to a WiFi network and try again. + public static let wifiRequiredDescription = CourseLocalization.tr("Localizable", "COURSE.ERROR.WIFI_REQUIRED_DESCRIPTION", fallback: "Downloading this content requires an active WiFi connection. Please connect to a WiFi network and try again.") + /// Wi-Fi Required + public static let wifiRequiredTitle = CourseLocalization.tr("Localizable", "COURSE.ERROR.WIFI_REQUIRED_TITLE", fallback: "Wi-Fi Required") + } + public enum LargestDownloads { + /// Done + public static let done = CourseLocalization.tr("Localizable", "COURSE.LARGEST_DOWNLOADS.DONE", fallback: "Done") + /// Edit + public static let edit = CourseLocalization.tr("Localizable", "COURSE.LARGEST_DOWNLOADS.EDIT", fallback: "Edit") + /// Remove all downloads + public static let removeDownloads = CourseLocalization.tr("Localizable", "COURSE.LARGEST_DOWNLOADS.REMOVE_DOWNLOADS", fallback: "Remove all downloads") + /// Largest Downloads + public static let title = CourseLocalization.tr("Localizable", "COURSE.LARGEST_DOWNLOADS.TITLE", fallback: "Largest Downloads") + } + public enum Offline { + /// %@%% of this course can be completed offline. + public static func canBeCompleted(_ p1: Any) -> String { + return CourseLocalization.tr("Localizable", "COURSE.OFFLINE.CAN_BE_COMPLETED", String(describing: p1), fallback: "%@%% of this course can be completed offline.") + } + /// Cancel Course Download + public static let cancelCourseDownload = CourseLocalization.tr("Localizable", "COURSE.OFFLINE.CANCEL_COURSE_DOWNLOAD", fallback: "Cancel Course Download") + /// Download all + public static let downloadAll = CourseLocalization.tr("Localizable", "COURSE.OFFLINE.DOWNLOAD_ALL", fallback: "Download all") + /// %@%% of this course is downloadable. + public static func downloadable(_ p1: Any) -> String { + return CourseLocalization.tr("Localizable", "COURSE.OFFLINE.DOWNLOADABLE", String(describing: p1), fallback: "%@%% of this course is downloadable.") + } + /// %@%% of this course is visible on mobile. + public static func visible(_ p1: Any) -> String { + return CourseLocalization.tr("Localizable", "COURSE.OFFLINE.VISIBLE", String(describing: p1), fallback: "%@%% of this course is visible on mobile.") + } + /// You can download course content offline to learn on the go, without requiring an active internet connection or using mobile data. + public static let youCanDownload = CourseLocalization.tr("Localizable", "COURSE.OFFLINE.YOU_CAN_DOWNLOAD", fallback: "You can download course content offline to learn on the go, without requiring an active internet connection or using mobile data.") + /// None of this course’s content is currently avaliable to download offline. + public static let youCantDownload = CourseLocalization.tr("Localizable", "COURSE.OFFLINE.YOU_CANT_DOWNLOAD", fallback: "None of this course’s content is currently avaliable to download offline.") + } + public enum StorageAlert { + /// Your device does not have enough free space to download this content. Please free up some space and try again. + public static let description = CourseLocalization.tr("Localizable", "COURSE.STORAGE_ALERT.DESCRIPTION", fallback: "Your device does not have enough free space to download this content. Please free up some space and try again.") + /// Device Storage Full + public static let title = CourseLocalization.tr("Localizable", "COURSE.STORAGE_ALERT.TITLE", fallback: "Device Storage Full") + /// %@ used, %@ free + public static func usedAndFree(_ p1: Any, _ p2: Any) -> String { + return CourseLocalization.tr("Localizable", "COURSE.STORAGE_ALERT.USED_AND_FREE", String(describing: p1), String(describing: p2), fallback: "%@ used, %@ free") + } + } + public enum TotalProgress { + /// Available to Download + public static let avaliableToDownload = CourseLocalization.tr("Localizable", "COURSE.TOTAL_PROGRESS.AVALIABLE_TO_DOWNLOAD", fallback: "Available to Download") + /// Downloaded + public static let downloaded = CourseLocalization.tr("Localizable", "COURSE.TOTAL_PROGRESS.DOWNLOADED", fallback: "Downloaded") + /// Downloading + public static let downloading = CourseLocalization.tr("Localizable", "COURSE.TOTAL_PROGRESS.DOWNLOADING", fallback: "Downloading") + /// Ready to Download + public static let readyToDownload = CourseLocalization.tr("Localizable", "COURSE.TOTAL_PROGRESS.READY_TO_DOWNLOAD", fallback: "Ready to Download") + } } public enum Courseware { /// Back to outline @@ -93,6 +189,8 @@ public enum CourseLocalization { public static let handoutsInDeveloping = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.HANDOUTS_IN_DEVELOPING", fallback: "Handouts In developing") /// Home public static let home = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.HOME", fallback: "Home") + /// Offline + public static let offline = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.OFFLINE", fallback: "Offline") /// Videos public static let videos = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.VIDEOS", fallback: "Videos") } @@ -232,6 +330,20 @@ public enum CourseLocalization { /// This interactive component isn't available on mobile public static let title = CourseLocalization.tr("Localizable", "NOT_AVALIABLE.TITLE", fallback: "This interactive component isn't available on mobile") } + public enum Offline { + public enum NotAvaliable { + /// Explore other parts of this course or view this when you reconnect. + public static let description = CourseLocalization.tr("Localizable", "OFFLINE.NOT_AVALIABLE.DESCRIPTION", fallback: "Explore other parts of this course or view this when you reconnect.") + /// This component is not yet available offline + public static let title = CourseLocalization.tr("Localizable", "OFFLINE.NOT_AVALIABLE.TITLE", fallback: "This component is not yet available offline") + } + public enum NotDownloaded { + /// Explore other parts of this course or download this when you reconnect. + public static let description = CourseLocalization.tr("Localizable", "OFFLINE.NOT_DOWNLOADED.DESCRIPTION", fallback: "Explore other parts of this course or download this when you reconnect.") + /// This component is not downloaded + public static let title = CourseLocalization.tr("Localizable", "OFFLINE.NOT_DOWNLOADED.TITLE", fallback: "This component is not downloaded") + } + } public enum Outline { /// Certificate public static let certificate = CourseLocalization.tr("Localizable", "OUTLINE.CERTIFICATE", fallback: "Certificate") diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index 424ecf737..2cd63e571 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -38,6 +38,7 @@ "COURSE_CONTAINER.HOME" = "Home"; "COURSE_CONTAINER.VIDEOS" = "Videos"; +"COURSE_CONTAINER.OFFLINE" = "Offline"; "COURSE_CONTAINER.DATES" = "Dates"; "COURSE_CONTAINER.DISCUSSIONS" = "Discussions"; "COURSE_CONTAINER.HANDOUTS" = "More"; @@ -111,6 +112,59 @@ "COURSE.DUE_TOMORROW" = "Due Tomorrow"; "COURSE.PROGRESS_COMPLETED" = "%@ of %@ assignments complete"; + +"COURSE.ALERT.CANCEL" = "Cancel"; +"COURSE.ALERT.CLOSE" = "Close"; +"COURSE.ALERT.REMOVE" = "Remove"; +"COURSE.ALERT.DOWNLOAD" = "Download"; +"COURSE.ALERT.TRY_AGAIN" = "Try again"; + +"COURSE.ALERT.REMOVE_TITLE" = "Remove Offline Content?"; +"COURSE.ALERT.CONFIRM_DOWNLOAD_TITLE" = "Confirm Download"; +"COURSE.ALERT.CONFIRM_DOWNLOAD_CELLULAR_TITLE" = "Download on Cellular?"; + +"COURSE.ALERT.REMOVE_DESCRIPTION" = "Removing this content will free up %@."; +"COURSE.ALERT.CONFIRM_DOWNLOAD_DESCRIPTION" = "Downloading this %@ of content will save available blocks offline."; +"COURSE.ALERT.CONFIRM_DOWNLOAD_CELLULAR_DESCRIPTION" = "Downloading this content will use %@ of cellular data."; + +"COURSE.ERROR.DOWNLOAD_FAILED_TITLE" = "Download Failed"; +"COURSE.ERROR.NO_INTERNET_CONNECTION_TITLE" = "No Internet Connection"; +"COURSE.ERROR.WIFI_REQUIRED_TITLE" = "Wi-Fi Required"; + +"COURSE.ERROR.DOWNLOAD_FAILED_DESCRIPTION" = "Unfortunately, this content failed to download. Please try again later or report this issue."; +"COURSE.ERROR.NO_INTERNET_CONNECTION_DESCRIPTION" = "Downloading this content requires an active internet connection. Please connect to the internet and try again."; +"COURSE.ERROR.WIFI_REQUIRED_DESCRIPTION" = "Downloading this content requires an active WiFi connection. Please connect to a WiFi network and try again."; + +"COURSE.STORAGE_ALERT.TITLE" = "Device Storage Full"; +"COURSE.STORAGE_ALERT.DESCRIPTION" = "Your device does not have enough free space to download this content. Please free up some space and try again."; +"COURSE.STORAGE_ALERT.USED_AND_FREE" = "%@ used, %@ free"; + +"COURSE.LARGEST_DOWNLOADS.TITLE" = "Largest Downloads"; +"COURSE.LARGEST_DOWNLOADS.DONE" = "Done"; +"COURSE.LARGEST_DOWNLOADS.EDIT" = "Edit"; +"COURSE.LARGEST_DOWNLOADS.REMOVE_DOWNLOADS" = "Remove all downloads"; + +"COURSE.OFFLINE.VISIBLE" = "%@%% of this course is visible on mobile."; +"COURSE.OFFLINE.DOWNLOADABLE" = "%@%% of this course is downloadable."; +"COURSE.OFFLINE.CAN_BE_COMPLETED" = "%@%% of this course can be completed offline."; + +"COURSE.TOTAL_PROGRESS.DOWNLOADED" = "Downloaded"; +"COURSE.TOTAL_PROGRESS.DOWNLOADING" = "Downloading"; +"COURSE.TOTAL_PROGRESS.AVALIABLE_TO_DOWNLOAD" = "Available to Download"; +"COURSE.TOTAL_PROGRESS.READY_TO_DOWNLOAD" = "Ready to Download"; + + +"COURSE.OFFLINE.DOWNLOAD_ALL" = "Download all"; +"COURSE.OFFLINE.CANCEL_COURSE_DOWNLOAD" = "Cancel Course Download"; + +"COURSE.OFFLINE.YOU_CAN_DOWNLOAD" = "You can download course content offline to learn on the go, without requiring an active internet connection or using mobile data."; +"COURSE.OFFLINE.YOU_CANT_DOWNLOAD" = "None of this course’s content is currently avaliable to download offline."; + +"OFFLINE.NOT_DOWNLOADED.TITLE" = "This component is not downloaded"; +"OFFLINE.NOT_DOWNLOADED.DESCRIPTION" = "Explore other parts of this course or download this when you reconnect."; +"OFFLINE.NOT_AVALIABLE.TITLE" = "This component is not yet available offline"; +"OFFLINE.NOT_AVALIABLE.DESCRIPTION" = "Explore other parts of this course or view this when you reconnect."; + "CALENDAR_SYNC_STATUS.SYNCED" = "Synced to Calendar"; "CALENDAR_SYNC_STATUS.FAILED" = "Calendar Sync Failed"; "CALENDAR_SYNC_STATUS.OFFLINE" = "Offline"; diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index 067556faf..f6bb64618 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -1466,6 +1466,611 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { } } +// MARK: - CorePersistenceProtocol + +open class CorePersistenceProtocolMock: CorePersistenceProtocol, 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 = [] } + } + + + + + + open func set(userId: Int) { + addInvocation(.m_set__userId_userId(Parameter.value(`userId`))) + let perform = methodPerformValue(.m_set__userId_userId(Parameter.value(`userId`))) as? (Int) -> Void + perform?(`userId`) + } + + open func getUserID() -> Int? { + addInvocation(.m_getUserID) + let perform = methodPerformValue(.m_getUserID) as? () -> Void + perform?() + var __value: Int? = nil + do { + __value = try methodReturnValue(.m_getUserID).casted() + } catch { + // do nothing + } + return __value + } + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func addToDownloadQueue(tasks: [DownloadDataTask]) { + addInvocation(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) as? ([DownloadDataTask]) -> Void + perform?(`tasks`) + } + + open func saveOfflineProgress(progress: OfflineProgress) { + addInvocation(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) + let perform = methodPerformValue(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) as? (OfflineProgress) -> Void + perform?(`progress`) + } + + open func loadProgress(for blockID: String) -> OfflineProgress? { + addInvocation(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + var __value: OfflineProgress? = nil + do { + __value = try methodReturnValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))).casted() + } catch { + // do nothing + } + return __value + } + + open func loadAllOfflineProgress() -> [OfflineProgress] { + addInvocation(.m_loadAllOfflineProgress) + let perform = methodPerformValue(.m_loadAllOfflineProgress) as? () -> Void + perform?() + var __value: [OfflineProgress] + do { + __value = try methodReturnValue(.m_loadAllOfflineProgress).casted() + } catch { + onFatalFailure("Stub return value not specified for loadAllOfflineProgress(). Use given") + Failure("Stub return value not specified for loadAllOfflineProgress(). Use given") + } + return __value + } + + open func deleteProgress(for blockID: String) { + addInvocation(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + } + + open func deleteAllProgress() { + addInvocation(.m_deleteAllProgress) + let perform = methodPerformValue(.m_deleteAllProgress) as? () -> Void + perform?() + } + + open func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) { + addInvocation(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) as? ([CourseBlock], DownloadQuality) -> Void + perform?(`blocks`, `downloadQuality`) + } + + open func nextBlockForDownloading() -> DownloadDataTask? { + addInvocation(.m_nextBlockForDownloading) + let perform = methodPerformValue(.m_nextBlockForDownloading) as? () -> Void + perform?() + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_nextBlockForDownloading).casted() + } catch { + // do nothing + } + return __value + } + + open func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) { + addInvocation(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) + let perform = methodPerformValue(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) as? (String, DownloadState, Data?) -> Void + perform?(`id`, `state`, `resumeData`) + } + + open func deleteDownloadDataTask(id: String) throws { + addInvocation(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) + let perform = methodPerformValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) as? (String) -> Void + perform?(`id`) + do { + _ = try methodReturnValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func saveDownloadDataTask(_ task: DownloadDataTask) { + addInvocation(.m_saveDownloadDataTask__task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_saveDownloadDataTask__task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) + } + + open func downloadDataTask(for blockId: String) -> DownloadDataTask? { + addInvocation(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + open func getDownloadDataTasks() -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasks) + let perform = methodPerformValue(.m_getDownloadDataTasks) as? () -> Void + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasks(). Use given") + Failure("Stub return value not specified for getDownloadDataTasks(). Use given") + } + return __value + } + + open func getDownloadDataTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + } + return __value + } + + + fileprivate enum MethodType { + case m_set__userId_userId(Parameter) + case m_getUserID + case m_publisher + case m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>) + case m_saveOfflineProgress__progress_progress(Parameter) + case m_loadProgress__for_blockID(Parameter) + case m_loadAllOfflineProgress + case m_deleteProgress__for_blockID(Parameter) + case m_deleteAllProgress + case m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>, Parameter) + case m_nextBlockForDownloading + case m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter, Parameter, Parameter) + case m_deleteDownloadDataTask__id_id(Parameter) + case m_saveDownloadDataTask__task(Parameter) + case m_downloadDataTask__for_blockId(Parameter) + case m_getDownloadDataTasks + case m_getDownloadDataTasksForCourse__courseId(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_set__userId_userId(let lhsUserid), .m_set__userId_userId(let rhsUserid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUserid, rhs: rhsUserid, with: matcher), lhsUserid, rhsUserid, "userId")) + return Matcher.ComparisonResult(results) + + case (.m_getUserID, .m_getUserID): return .match + + case (.m_publisher, .m_publisher): return .match + + case (.m_addToDownloadQueue__tasks_tasks(let lhsTasks), .m_addToDownloadQueue__tasks_tasks(let rhsTasks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTasks, rhs: rhsTasks, with: matcher), lhsTasks, rhsTasks, "tasks")) + return Matcher.ComparisonResult(results) + + case (.m_saveOfflineProgress__progress_progress(let lhsProgress), .m_saveOfflineProgress__progress_progress(let rhsProgress)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsProgress, rhs: rhsProgress, with: matcher), lhsProgress, rhsProgress, "progress")) + return Matcher.ComparisonResult(results) + + case (.m_loadProgress__for_blockID(let lhsBlockid), .m_loadProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_loadAllOfflineProgress, .m_loadAllOfflineProgress): return .match + + case (.m_deleteProgress__for_blockID(let lhsBlockid), .m_deleteProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_deleteAllProgress, .m_deleteAllProgress): return .match + + case (.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let lhsBlocks, let lhsDownloadquality), .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let rhsBlocks, let rhsDownloadquality)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDownloadquality, rhs: rhsDownloadquality, with: matcher), lhsDownloadquality, rhsDownloadquality, "downloadQuality")) + return Matcher.ComparisonResult(results) + + case (.m_nextBlockForDownloading, .m_nextBlockForDownloading): return .match + + case (.m_updateDownloadState__id_idstate_stateresumeData_resumeData(let lhsId, let lhsState, let lhsResumedata), .m_updateDownloadState__id_idstate_stateresumeData_resumeData(let rhsId, let rhsState, let rhsResumedata)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsState, rhs: rhsState, with: matcher), lhsState, rhsState, "state")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsResumedata, rhs: rhsResumedata, with: matcher), lhsResumedata, rhsResumedata, "resumeData")) + return Matcher.ComparisonResult(results) + + case (.m_deleteDownloadDataTask__id_id(let lhsId), .m_deleteDownloadDataTask__id_id(let rhsId)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + return Matcher.ComparisonResult(results) + + case (.m_saveDownloadDataTask__task(let lhsTask), .m_saveDownloadDataTask__task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "_ task")) + return Matcher.ComparisonResult(results) + + case (.m_downloadDataTask__for_blockId(let lhsBlockid), .m_downloadDataTask__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + + case (.m_getDownloadDataTasks, .m_getDownloadDataTasks): return .match + + case (.m_getDownloadDataTasksForCourse__courseId(let lhsCourseid), .m_getDownloadDataTasksForCourse__courseId(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "_ courseId")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_set__userId_userId(p0): return p0.intValue + case .m_getUserID: return 0 + case .m_publisher: return 0 + case let .m_addToDownloadQueue__tasks_tasks(p0): return p0.intValue + case let .m_saveOfflineProgress__progress_progress(p0): return p0.intValue + case let .m_loadProgress__for_blockID(p0): return p0.intValue + case .m_loadAllOfflineProgress: return 0 + case let .m_deleteProgress__for_blockID(p0): return p0.intValue + case .m_deleteAllProgress: return 0 + case let .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(p0, p1): return p0.intValue + p1.intValue + case .m_nextBlockForDownloading: return 0 + case let .m_updateDownloadState__id_idstate_stateresumeData_resumeData(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_deleteDownloadDataTask__id_id(p0): return p0.intValue + case let .m_saveDownloadDataTask__task(p0): return p0.intValue + case let .m_downloadDataTask__for_blockId(p0): return p0.intValue + case .m_getDownloadDataTasks: return 0 + case let .m_getDownloadDataTasksForCourse__courseId(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_set__userId_userId: return ".set(userId:)" + case .m_getUserID: return ".getUserID()" + case .m_publisher: return ".publisher()" + case .m_addToDownloadQueue__tasks_tasks: return ".addToDownloadQueue(tasks:)" + case .m_saveOfflineProgress__progress_progress: return ".saveOfflineProgress(progress:)" + case .m_loadProgress__for_blockID: return ".loadProgress(for:)" + case .m_loadAllOfflineProgress: return ".loadAllOfflineProgress()" + case .m_deleteProgress__for_blockID: return ".deleteProgress(for:)" + case .m_deleteAllProgress: return ".deleteAllProgress()" + case .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality: return ".addToDownloadQueue(blocks:downloadQuality:)" + case .m_nextBlockForDownloading: return ".nextBlockForDownloading()" + case .m_updateDownloadState__id_idstate_stateresumeData_resumeData: return ".updateDownloadState(id:state:resumeData:)" + case .m_deleteDownloadDataTask__id_id: return ".deleteDownloadDataTask(id:)" + case .m_saveDownloadDataTask__task: return ".saveDownloadDataTask(_:)" + case .m_downloadDataTask__for_blockId: return ".downloadDataTask(for:)" + case .m_getDownloadDataTasks: return ".getDownloadDataTasks()" + case .m_getDownloadDataTasksForCourse__courseId: return ".getDownloadDataTasksForCourse(_:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + 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 { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { + return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadAllOfflineProgress(willReturn: [OfflineProgress]...) -> MethodStub { + return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func nextBlockForDownloading(willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func downloadDataTask(for blockId: Parameter, willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getUserID(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Int?] = [] + let given: Given = { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Int?).self) + 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) })) }() + let stubber = given.stub(for: (OfflineProgress?).self) + willProduce(stubber) + return given + } + public static func loadAllOfflineProgress(willProduce: (Stubber<[OfflineProgress]>) -> Void) -> MethodStub { + let willReturn: [[OfflineProgress]] = [] + let given: Given = { return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([OfflineProgress]).self) + willProduce(stubber) + return given + } + public static func nextBlockForDownloading(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func downloadDataTask(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).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) })) + } + public static func deleteDownloadDataTask(id: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + 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)} + 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`))} + public static func loadAllOfflineProgress() -> Verify { return Verify(method: .m_loadAllOfflineProgress)} + public static func deleteProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_deleteProgress__for_blockID(`blockID`))} + public static func deleteAllProgress() -> Verify { return Verify(method: .m_deleteAllProgress)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`))} + public static func nextBlockForDownloading() -> Verify { return Verify(method: .m_nextBlockForDownloading)} + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter) -> Verify { return Verify(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`))} + public static func deleteDownloadDataTask(id: Parameter) -> Verify { return Verify(method: .m_deleteDownloadDataTask__id_id(`id`))} + public static func saveDownloadDataTask(_ task: Parameter) -> Verify { return Verify(method: .m_saveDownloadDataTask__task(`task`))} + public static func downloadDataTask(for blockId: Parameter) -> Verify { return Verify(method: .m_downloadDataTask__for_blockId(`blockId`))} + public static func getDownloadDataTasks() -> Verify { return Verify(method: .m_getDownloadDataTasks)} + public static func getDownloadDataTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func set(userId: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_set__userId_userId(`userId`), performs: perform) + } + public static func getUserID(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getUserID, performs: perform) + } + 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 { + return Perform(method: .m_addToDownloadQueue__tasks_tasks(`tasks`), performs: perform) + } + public static func saveOfflineProgress(progress: Parameter, perform: @escaping (OfflineProgress) -> Void) -> Perform { + return Perform(method: .m_saveOfflineProgress__progress_progress(`progress`), performs: perform) + } + public static func loadProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_loadProgress__for_blockID(`blockID`), performs: perform) + } + public static func loadAllOfflineProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_loadAllOfflineProgress, performs: perform) + } + public static func deleteProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteProgress__for_blockID(`blockID`), performs: perform) + } + public static func deleteAllProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllProgress, performs: perform) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter, perform: @escaping ([CourseBlock], DownloadQuality) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`), performs: perform) + } + public static func nextBlockForDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_nextBlockForDownloading, performs: perform) + } + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter, perform: @escaping (String, DownloadState, Data?) -> Void) -> Perform { + return Perform(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`), performs: perform) + } + public static func deleteDownloadDataTask(id: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteDownloadDataTask__id_id(`id`), performs: perform) + } + public static func saveDownloadDataTask(_ task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_saveDownloadDataTask__task(`task`), performs: perform) + } + public static func downloadDataTask(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_downloadDataTask__for_blockId(`blockId`), performs: perform) + } + public static func getDownloadDataTasks(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasks, performs: perform) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), 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) + } +} + // MARK: - CourseAnalytics open class CourseAnalyticsMock: CourseAnalytics, Mock { @@ -1570,6 +2175,12 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { perform?(`courseId`, `courseName`) } + open func courseOutlineOfflineTabClicked(courseId: String, courseName: String) { + addInvocation(.m_courseOutlineOfflineTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) + let perform = methodPerformValue(.m_courseOutlineOfflineTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) as? (String, String) -> Void + perform?(`courseId`, `courseName`) + } + open func courseOutlineDatesTabClicked(courseId: String, courseName: String) { addInvocation(.m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) let perform = methodPerformValue(.m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) as? (String, String) -> Void @@ -1666,6 +2277,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { case m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) + case m_courseOutlineOfflineTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) @@ -1756,6 +2368,12 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) return Matcher.ComparisonResult(results) + case (.m_courseOutlineOfflineTabClicked__courseId_courseIdcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_courseOutlineOfflineTabClicked__courseId_courseIdcourseName_courseName(let rhsCourseid, let rhsCoursename)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + return Matcher.ComparisonResult(results) + case (.m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(let rhsCourseid, let rhsCoursename)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) @@ -1876,6 +2494,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { case let .m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue + case let .m_courseOutlineOfflineTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue @@ -1904,6 +2523,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { case .m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName: return ".finishVerticalBackToOutlineClicked(courseId:courseName:)" case .m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineCourseTabClicked(courseId:courseName:)" case .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineVideosTabClicked(courseId:courseName:)" + case .m_courseOutlineOfflineTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineOfflineTabClicked(courseId:courseName:)" case .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineDatesTabClicked(courseId:courseName:)" case .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineDiscussionTabClicked(courseId:courseName:)" case .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineHandoutsTabClicked(courseId:courseName:)" @@ -1946,6 +2566,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { public static func finishVerticalBackToOutlineClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineCourseTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineVideosTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} + public static func courseOutlineOfflineTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineOfflineTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineDatesTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineDiscussionTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineHandoutsTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} @@ -1996,6 +2617,9 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { public static func courseOutlineVideosTabClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) } + public static func courseOutlineOfflineTabClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_courseOutlineOfflineTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) + } public static func courseOutlineDatesTabClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) } @@ -2203,6 +2827,22 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { return __value } + open func getSequentialsContainsBlocks(blockIds: [String], courseID: String) throws -> [CourseSequential] { + addInvocation(.m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(Parameter<[String]>.value(`blockIds`), Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(Parameter<[String]>.value(`blockIds`), Parameter.value(`courseID`))) as? ([String], String) -> Void + perform?(`blockIds`, `courseID`) + var __value: [CourseSequential] + do { + __value = try methodReturnValue(.m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(Parameter<[String]>.value(`blockIds`), Parameter.value(`courseID`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getSequentialsContainsBlocks(blockIds: [String], courseID: String). Use given") + Failure("Stub return value not specified for getSequentialsContainsBlocks(blockIds: [String], courseID: String). Use given") + } catch { + throw error + } + return __value + } + open func blockCompletionRequest(courseID: String, blockID: String) throws { addInvocation(.m_blockCompletionRequest__courseID_courseIDblockID_blockID(Parameter.value(`courseID`), Parameter.value(`blockID`))) let perform = methodPerformValue(.m_blockCompletionRequest__courseID_courseIDblockID_blockID(Parameter.value(`courseID`), Parameter.value(`blockID`))) as? (String, String) -> Void @@ -2329,6 +2969,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case m_getCourseBlocks__courseID_courseID(Parameter) case m_getCourseVideoBlocks__fullStructure_fullStructure(Parameter) case m_getLoadedCourseBlocks__courseID_courseID(Parameter) + case m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(Parameter<[String]>, Parameter) case m_blockCompletionRequest__courseID_courseIDblockID_blockID(Parameter, Parameter) case m_getHandouts__courseID_courseID(Parameter) case m_getUpdates__courseID_courseID(Parameter) @@ -2355,6 +2996,12 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) return Matcher.ComparisonResult(results) + case (.m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(let lhsBlockids, let lhsCourseid), .m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(let rhsBlockids, let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockids, rhs: rhsBlockids, with: matcher), lhsBlockids, rhsBlockids, "blockIds")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + return Matcher.ComparisonResult(results) + case (.m_blockCompletionRequest__courseID_courseIDblockID_blockID(let lhsCourseid, let lhsBlockid), .m_blockCompletionRequest__courseID_courseIDblockID_blockID(let rhsCourseid, let rhsBlockid)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) @@ -2405,6 +3052,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case let .m_getCourseBlocks__courseID_courseID(p0): return p0.intValue case let .m_getCourseVideoBlocks__fullStructure_fullStructure(p0): return p0.intValue case let .m_getLoadedCourseBlocks__courseID_courseID(p0): return p0.intValue + case let .m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(p0, p1): return p0.intValue + p1.intValue case let .m_blockCompletionRequest__courseID_courseIDblockID_blockID(p0, p1): return p0.intValue + p1.intValue case let .m_getHandouts__courseID_courseID(p0): return p0.intValue case let .m_getUpdates__courseID_courseID(p0): return p0.intValue @@ -2420,6 +3068,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case .m_getCourseBlocks__courseID_courseID: return ".getCourseBlocks(courseID:)" case .m_getCourseVideoBlocks__fullStructure_fullStructure: return ".getCourseVideoBlocks(fullStructure:)" case .m_getLoadedCourseBlocks__courseID_courseID: return ".getLoadedCourseBlocks(courseID:)" + case .m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID: return ".getSequentialsContainsBlocks(blockIds:courseID:)" case .m_blockCompletionRequest__courseID_courseIDblockID_blockID: return ".blockCompletionRequest(courseID:blockID:)" case .m_getHandouts__courseID_courseID: return ".getHandouts(courseID:)" case .m_getUpdates__courseID_courseID: return ".getUpdates(courseID:)" @@ -2450,6 +3099,9 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { public static func getLoadedCourseBlocks(courseID: Parameter, willReturn: CourseStructure...) -> MethodStub { return Given(method: .m_getLoadedCourseBlocks__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func getSequentialsContainsBlocks(blockIds: Parameter<[String]>, courseID: Parameter, willReturn: [CourseSequential]...) -> MethodStub { + return Given(method: .m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(`blockIds`, `courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func getHandouts(courseID: Parameter, willReturn: String?...) -> MethodStub { return Given(method: .m_getHandouts__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -2495,6 +3147,16 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { willProduce(stubber) return given } + public static func getSequentialsContainsBlocks(blockIds: Parameter<[String]>, courseID: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(`blockIds`, `courseID`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getSequentialsContainsBlocks(blockIds: Parameter<[String]>, courseID: Parameter, willProduce: (StubberThrows<[CourseSequential]>) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(`blockIds`, `courseID`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: ([CourseSequential]).self) + willProduce(stubber) + return given + } public static func blockCompletionRequest(courseID: Parameter, blockID: Parameter, willThrow: Error...) -> MethodStub { return Given(method: .m_blockCompletionRequest__courseID_courseIDblockID_blockID(`courseID`, `blockID`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -2583,6 +3245,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { public static func getCourseBlocks(courseID: Parameter) -> Verify { return Verify(method: .m_getCourseBlocks__courseID_courseID(`courseID`))} public static func getCourseVideoBlocks(fullStructure: Parameter) -> Verify { return Verify(method: .m_getCourseVideoBlocks__fullStructure_fullStructure(`fullStructure`))} public static func getLoadedCourseBlocks(courseID: Parameter) -> Verify { return Verify(method: .m_getLoadedCourseBlocks__courseID_courseID(`courseID`))} + public static func getSequentialsContainsBlocks(blockIds: Parameter<[String]>, courseID: Parameter) -> Verify { return Verify(method: .m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(`blockIds`, `courseID`))} public static func blockCompletionRequest(courseID: Parameter, blockID: Parameter) -> Verify { return Verify(method: .m_blockCompletionRequest__courseID_courseIDblockID_blockID(`courseID`, `blockID`))} public static func getHandouts(courseID: Parameter) -> Verify { return Verify(method: .m_getHandouts__courseID_courseID(`courseID`))} public static func getUpdates(courseID: Parameter) -> Verify { return Verify(method: .m_getUpdates__courseID_courseID(`courseID`))} @@ -2606,6 +3269,9 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { public static func getLoadedCourseBlocks(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_getLoadedCourseBlocks__courseID_courseID(`courseID`), performs: perform) } + public static func getSequentialsContainsBlocks(blockIds: Parameter<[String]>, courseID: Parameter, perform: @escaping ([String], String) -> Void) -> Perform { + return Perform(method: .m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(`blockIds`, `courseID`), performs: perform) + } public static func blockCompletionRequest(courseID: Parameter, blockID: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_blockCompletionRequest__courseID_courseIDblockID_blockID(`courseID`, `blockID`), performs: perform) } @@ -2900,6 +3566,20 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] { + addInvocation(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) + let perform = methodPerformValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) as? ([CourseSequential]) -> Void + perform?(`sequentials`) + var __value: [CourseSequential] + do { + __value = try methodReturnValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))).casted() + } catch { + onFatalFailure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + Failure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + } + return __value + } + open func resumeDownloading() throws { addInvocation(.m_resumeDownloading) let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void @@ -2947,6 +3627,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) + case m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case m_removeAppSupportDirectoryUnusedContent @@ -3000,6 +3681,11 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) return Matcher.ComparisonResult(results) + case (.m_updateUnzippedFileSize__for_sequentials(let lhsSequentials), .m_updateUnzippedFileSize__for_sequentials(let rhsSequentials)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSequentials, rhs: rhsSequentials, with: matcher), lhsSequentials, rhsSequentials, "for sequentials")) + return Matcher.ComparisonResult(results) + case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): @@ -3027,6 +3713,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case let .m_deleteFile__blocks_blocks(p0): return p0.intValue case .m_deleteAllFiles: return 0 case let .m_fileUrl__for_blockId(p0): return p0.intValue + case let .m_updateUnzippedFileSize__for_sequentials(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .m_removeAppSupportDirectoryUnusedContent: return 0 @@ -3047,6 +3734,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + case .m_updateUnzippedFileSize__for_sequentials: return ".updateUnzippedFileSize(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" @@ -3082,6 +3770,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willReturn: [CourseSequential]...) -> MethodStub { + return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } 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) })) } @@ -3120,6 +3811,13 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willProduce: (Stubber<[CourseSequential]>) -> Void) -> MethodStub { + let willReturn: [[CourseSequential]] = [] + let given: Given = { return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseSequential]).self) + willProduce(stubber) + return given + } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [Bool] = [] let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -3204,6 +3902,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} public static func deleteAllFiles() -> Verify { return Verify(method: .m_deleteAllFiles)} public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>) -> Verify { return Verify(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`))} public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} public static func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} @@ -3250,6 +3949,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, perform: @escaping ([CourseSequential]) -> Void) -> Perform { + return Perform(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), performs: perform) + } public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_resumeDownloading, performs: perform) } @@ -3334,6 +4036,205 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } +// MARK: - OfflineSyncInteractorProtocol + +open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, 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 = [] } + } + + + + + + open func submitOfflineProgress(courseID: String, blockID: String, data: String) throws -> Bool { + addInvocation(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) + let perform = methodPerformValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) as? (String, String, String) -> Void + perform?(`courseID`, `blockID`, `data`) + var __value: Bool + do { + __value = try methodReturnValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + Failure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + } catch { + throw error + } + return __value + } + + + fileprivate enum MethodType { + case m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter, Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let lhsCourseid, let lhsBlockid, let lhsData), .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let rhsCourseid, let rhsBlockid, let rhsData)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "blockID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsData, rhs: rhsData, with: matcher), lhsData, rhsData, "data")) + return Matcher.ComparisonResult(results) + } + } + + func intValue() -> Int { + switch self { + case let .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + } + } + func assertionName() -> String { + switch self { + case .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data: return ".submitOfflineProgress(courseID:blockID:data:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Bool).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter) -> Verify { return Verify(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { + return Perform(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), 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) + } +} + // MARK: - WebviewCookiesUpdateProtocol open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index 144614179..46511fb7f 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -57,7 +57,8 @@ final class CourseContainerViewModelTests: XCTestCase { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ) let vertical = CourseVertical( blockId: "", @@ -388,8 +389,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileLow: nil, hls: nil ), - multiDevice: true - + multiDevice: true, + offlineDownload: nil ) let vertical = CourseVertical( @@ -437,7 +438,7 @@ final class CourseContainerViewModelTests: XCTestCase { )), certificate: nil, org: "", - isSelfPaced: true, + isSelfPaced: true, courseProgress: nil ) @@ -453,15 +454,18 @@ final class CourseContainerViewModelTests: XCTestCase { resumeData: nil, state: .inProgress, type: .video, - fileSize: 1000 + fileSize: 1000, + lastModified: "" ) Given(connectivity, .isInternetAvaliable(getter: true)) Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + Given(connectivity, .isMobileData(getter: false)) Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [downloadData])) + Given(downloadManager, .updateUnzippedFileSize(for: .any, willReturn: [sequential])) let viewModel = CourseContainerViewModel( interactor: interactor, @@ -483,11 +487,11 @@ final class CourseContainerViewModelTests: XCTestCase { viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates() - await viewModel.onDownloadViewTap( - chapter: chapter, - blockId: blockId, - state: .available - ) + await viewModel.download( + state: .available, + blocks: [block], + sequentials: [sequential] + ) let exp = expectation(description: "Task Starting") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -498,6 +502,7 @@ final class CourseContainerViewModelTests: XCTestCase { XCTAssertEqual(viewModel.sequentialsDownloadState[blockId], .downloading) } + func testOnDownloadViewDownloadingTap() async { let interactor = CourseInteractorProtocolMock() @@ -530,7 +535,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileLow: nil, hls: nil ), - multiDevice: true + multiDevice: true, + offlineDownload: nil ) let vertical = CourseVertical( @@ -584,10 +590,12 @@ final class CourseContainerViewModelTests: XCTestCase { Given(connectivity, .isInternetAvaliable(getter: true)) Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + Given(connectivity, .isMobileData(getter: false)) Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [])) + Given(downloadManager, .updateUnzippedFileSize(for: .any, willReturn: [])) let viewModel = CourseContainerViewModel( interactor: interactor, @@ -609,11 +617,11 @@ final class CourseContainerViewModelTests: XCTestCase { viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates() - await viewModel.onDownloadViewTap( - chapter: chapter, - blockId: blockId, - state: .downloading - ) + await viewModel.download( + state: .available, + blocks: [block], + sequentials: [sequential] + ) let exp = expectation(description: "Task Starting") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -656,7 +664,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileLow: nil, hls: nil ), - multiDevice: true + multiDevice: true, + offlineDownload: nil ) let vertical = CourseVertical( @@ -710,10 +719,12 @@ final class CourseContainerViewModelTests: XCTestCase { Given(connectivity, .isInternetAvaliable(getter: true)) Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + Given(connectivity, .isMobileData(getter: false)) Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [])) + Given(downloadManager, .updateUnzippedFileSize(for: .any, willReturn: [])) let viewModel = CourseContainerViewModel( interactor: interactor, @@ -735,11 +746,11 @@ final class CourseContainerViewModelTests: XCTestCase { viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates() - await viewModel.onDownloadViewTap( - chapter: chapter, - blockId: blockId, - state: .finished - ) + await viewModel.download( + state: .available, + blocks: [block], + sequentials: [sequential] + ) let exp = expectation(description: "Task Starting") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -783,7 +794,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileLow: nil, hls: nil ), - multiDevice: true + multiDevice: true, + offlineDownload: nil ) let vertical = CourseVertical( @@ -837,10 +849,12 @@ final class CourseContainerViewModelTests: XCTestCase { Given(connectivity, .isInternetAvaliable(getter: true)) Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + Given(connectivity, .isMobileData(getter: false)) Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [])) + Given(downloadManager, .updateUnzippedFileSize(for: .any, willReturn: [])) let viewModel = CourseContainerViewModel( interactor: interactor, @@ -903,7 +917,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileLow: nil, hls: nil ), - multiDevice: true + multiDevice: true, + offlineDownload: nil ) let vertical = CourseVertical( @@ -967,15 +982,18 @@ final class CourseContainerViewModelTests: XCTestCase { resumeData: nil, state: .inProgress, type: .video, - fileSize: 1000 + fileSize: 1000, + lastModified: "" ) Given(connectivity, .isInternetAvaliable(getter: true)) Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + Given(connectivity, .isMobileData(getter: false)) Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [downloadData])) + Given(downloadManager, .updateUnzippedFileSize(for: .any, willReturn: [sequential])) let viewModel = CourseContainerViewModel( interactor: interactor, @@ -1038,7 +1056,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileLow: nil, hls: nil ), - multiDevice: true + multiDevice: true, + offlineDownload: nil ) let vertical = CourseVertical( @@ -1102,15 +1121,18 @@ final class CourseContainerViewModelTests: XCTestCase { resumeData: nil, state: .finished, type: .video, - fileSize: 1000 + fileSize: 1000, + lastModified: "" ) Given(connectivity, .isInternetAvaliable(getter: true)) Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + Given(connectivity, .isMobileData(getter: false)) Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [downloadData])) + Given(downloadManager, .updateUnzippedFileSize(for: .any, willReturn: [sequential])) let viewModel = CourseContainerViewModel( interactor: interactor, @@ -1172,7 +1194,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileLow: nil, hls: nil ), - multiDevice: true + multiDevice: true, + offlineDownload: nil ) let block2 = CourseBlock( blockId: "123", @@ -1194,7 +1217,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileLow: nil, hls: nil ), - multiDevice: true + multiDevice: true, + offlineDownload: nil ) let vertical = CourseVertical( @@ -1258,15 +1282,18 @@ final class CourseContainerViewModelTests: XCTestCase { resumeData: nil, state: .finished, type: .video, - fileSize: 1000 + fileSize: 1000, + lastModified: "" ) Given(connectivity, .isInternetAvaliable(getter: true)) Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + Given(connectivity, .isMobileData(getter: false)) Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [downloadData])) + Given(downloadManager, .updateUnzippedFileSize(for: .any, willReturn: [sequential])) let viewModel = CourseContainerViewModel( interactor: interactor, diff --git a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift index abf7d2000..dd24288b6 100644 --- a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift @@ -28,7 +28,8 @@ final class CourseUnitViewModelTests: XCTestCase { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ), CourseBlock(blockId: "2", id: "2", @@ -42,7 +43,8 @@ final class CourseUnitViewModelTests: XCTestCase { studentUrl: "2", webUrl: "2", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ), CourseBlock(blockId: "3", id: "3", @@ -56,7 +58,8 @@ final class CourseUnitViewModelTests: XCTestCase { studentUrl: "3", webUrl: "3", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ), CourseBlock(blockId: "4", id: "4", @@ -70,7 +73,8 @@ final class CourseUnitViewModelTests: XCTestCase { studentUrl: "4", webUrl: "4", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ), ] diff --git a/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents b/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents index 525156723..141c99dbd 100644 --- a/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents +++ b/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents @@ -1,6 +1,6 @@ - + @@ -13,7 +13,7 @@ - + @@ -36,13 +36,13 @@ - + - + @@ -62,4 +62,4 @@ - \ No newline at end of file + diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index 82ae9be00..642eb04fd 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -1466,6 +1466,611 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { } } +// MARK: - CorePersistenceProtocol + +open class CorePersistenceProtocolMock: CorePersistenceProtocol, 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 = [] } + } + + + + + + open func set(userId: Int) { + addInvocation(.m_set__userId_userId(Parameter.value(`userId`))) + let perform = methodPerformValue(.m_set__userId_userId(Parameter.value(`userId`))) as? (Int) -> Void + perform?(`userId`) + } + + open func getUserID() -> Int? { + addInvocation(.m_getUserID) + let perform = methodPerformValue(.m_getUserID) as? () -> Void + perform?() + var __value: Int? = nil + do { + __value = try methodReturnValue(.m_getUserID).casted() + } catch { + // do nothing + } + return __value + } + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func addToDownloadQueue(tasks: [DownloadDataTask]) { + addInvocation(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) as? ([DownloadDataTask]) -> Void + perform?(`tasks`) + } + + open func saveOfflineProgress(progress: OfflineProgress) { + addInvocation(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) + let perform = methodPerformValue(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) as? (OfflineProgress) -> Void + perform?(`progress`) + } + + open func loadProgress(for blockID: String) -> OfflineProgress? { + addInvocation(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + var __value: OfflineProgress? = nil + do { + __value = try methodReturnValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))).casted() + } catch { + // do nothing + } + return __value + } + + open func loadAllOfflineProgress() -> [OfflineProgress] { + addInvocation(.m_loadAllOfflineProgress) + let perform = methodPerformValue(.m_loadAllOfflineProgress) as? () -> Void + perform?() + var __value: [OfflineProgress] + do { + __value = try methodReturnValue(.m_loadAllOfflineProgress).casted() + } catch { + onFatalFailure("Stub return value not specified for loadAllOfflineProgress(). Use given") + Failure("Stub return value not specified for loadAllOfflineProgress(). Use given") + } + return __value + } + + open func deleteProgress(for blockID: String) { + addInvocation(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + } + + open func deleteAllProgress() { + addInvocation(.m_deleteAllProgress) + let perform = methodPerformValue(.m_deleteAllProgress) as? () -> Void + perform?() + } + + open func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) { + addInvocation(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) as? ([CourseBlock], DownloadQuality) -> Void + perform?(`blocks`, `downloadQuality`) + } + + open func nextBlockForDownloading() -> DownloadDataTask? { + addInvocation(.m_nextBlockForDownloading) + let perform = methodPerformValue(.m_nextBlockForDownloading) as? () -> Void + perform?() + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_nextBlockForDownloading).casted() + } catch { + // do nothing + } + return __value + } + + open func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) { + addInvocation(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) + let perform = methodPerformValue(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) as? (String, DownloadState, Data?) -> Void + perform?(`id`, `state`, `resumeData`) + } + + open func deleteDownloadDataTask(id: String) throws { + addInvocation(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) + let perform = methodPerformValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) as? (String) -> Void + perform?(`id`) + do { + _ = try methodReturnValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func saveDownloadDataTask(_ task: DownloadDataTask) { + addInvocation(.m_saveDownloadDataTask__task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_saveDownloadDataTask__task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) + } + + open func downloadDataTask(for blockId: String) -> DownloadDataTask? { + addInvocation(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + open func getDownloadDataTasks() -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasks) + let perform = methodPerformValue(.m_getDownloadDataTasks) as? () -> Void + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasks(). Use given") + Failure("Stub return value not specified for getDownloadDataTasks(). Use given") + } + return __value + } + + open func getDownloadDataTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + } + return __value + } + + + fileprivate enum MethodType { + case m_set__userId_userId(Parameter) + case m_getUserID + case m_publisher + case m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>) + case m_saveOfflineProgress__progress_progress(Parameter) + case m_loadProgress__for_blockID(Parameter) + case m_loadAllOfflineProgress + case m_deleteProgress__for_blockID(Parameter) + case m_deleteAllProgress + case m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>, Parameter) + case m_nextBlockForDownloading + case m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter, Parameter, Parameter) + case m_deleteDownloadDataTask__id_id(Parameter) + case m_saveDownloadDataTask__task(Parameter) + case m_downloadDataTask__for_blockId(Parameter) + case m_getDownloadDataTasks + case m_getDownloadDataTasksForCourse__courseId(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_set__userId_userId(let lhsUserid), .m_set__userId_userId(let rhsUserid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUserid, rhs: rhsUserid, with: matcher), lhsUserid, rhsUserid, "userId")) + return Matcher.ComparisonResult(results) + + case (.m_getUserID, .m_getUserID): return .match + + case (.m_publisher, .m_publisher): return .match + + case (.m_addToDownloadQueue__tasks_tasks(let lhsTasks), .m_addToDownloadQueue__tasks_tasks(let rhsTasks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTasks, rhs: rhsTasks, with: matcher), lhsTasks, rhsTasks, "tasks")) + return Matcher.ComparisonResult(results) + + case (.m_saveOfflineProgress__progress_progress(let lhsProgress), .m_saveOfflineProgress__progress_progress(let rhsProgress)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsProgress, rhs: rhsProgress, with: matcher), lhsProgress, rhsProgress, "progress")) + return Matcher.ComparisonResult(results) + + case (.m_loadProgress__for_blockID(let lhsBlockid), .m_loadProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_loadAllOfflineProgress, .m_loadAllOfflineProgress): return .match + + case (.m_deleteProgress__for_blockID(let lhsBlockid), .m_deleteProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_deleteAllProgress, .m_deleteAllProgress): return .match + + case (.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let lhsBlocks, let lhsDownloadquality), .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let rhsBlocks, let rhsDownloadquality)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDownloadquality, rhs: rhsDownloadquality, with: matcher), lhsDownloadquality, rhsDownloadquality, "downloadQuality")) + return Matcher.ComparisonResult(results) + + case (.m_nextBlockForDownloading, .m_nextBlockForDownloading): return .match + + case (.m_updateDownloadState__id_idstate_stateresumeData_resumeData(let lhsId, let lhsState, let lhsResumedata), .m_updateDownloadState__id_idstate_stateresumeData_resumeData(let rhsId, let rhsState, let rhsResumedata)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsState, rhs: rhsState, with: matcher), lhsState, rhsState, "state")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsResumedata, rhs: rhsResumedata, with: matcher), lhsResumedata, rhsResumedata, "resumeData")) + return Matcher.ComparisonResult(results) + + case (.m_deleteDownloadDataTask__id_id(let lhsId), .m_deleteDownloadDataTask__id_id(let rhsId)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + return Matcher.ComparisonResult(results) + + case (.m_saveDownloadDataTask__task(let lhsTask), .m_saveDownloadDataTask__task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "_ task")) + return Matcher.ComparisonResult(results) + + case (.m_downloadDataTask__for_blockId(let lhsBlockid), .m_downloadDataTask__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + + case (.m_getDownloadDataTasks, .m_getDownloadDataTasks): return .match + + case (.m_getDownloadDataTasksForCourse__courseId(let lhsCourseid), .m_getDownloadDataTasksForCourse__courseId(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "_ courseId")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_set__userId_userId(p0): return p0.intValue + case .m_getUserID: return 0 + case .m_publisher: return 0 + case let .m_addToDownloadQueue__tasks_tasks(p0): return p0.intValue + case let .m_saveOfflineProgress__progress_progress(p0): return p0.intValue + case let .m_loadProgress__for_blockID(p0): return p0.intValue + case .m_loadAllOfflineProgress: return 0 + case let .m_deleteProgress__for_blockID(p0): return p0.intValue + case .m_deleteAllProgress: return 0 + case let .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(p0, p1): return p0.intValue + p1.intValue + case .m_nextBlockForDownloading: return 0 + case let .m_updateDownloadState__id_idstate_stateresumeData_resumeData(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_deleteDownloadDataTask__id_id(p0): return p0.intValue + case let .m_saveDownloadDataTask__task(p0): return p0.intValue + case let .m_downloadDataTask__for_blockId(p0): return p0.intValue + case .m_getDownloadDataTasks: return 0 + case let .m_getDownloadDataTasksForCourse__courseId(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_set__userId_userId: return ".set(userId:)" + case .m_getUserID: return ".getUserID()" + case .m_publisher: return ".publisher()" + case .m_addToDownloadQueue__tasks_tasks: return ".addToDownloadQueue(tasks:)" + case .m_saveOfflineProgress__progress_progress: return ".saveOfflineProgress(progress:)" + case .m_loadProgress__for_blockID: return ".loadProgress(for:)" + case .m_loadAllOfflineProgress: return ".loadAllOfflineProgress()" + case .m_deleteProgress__for_blockID: return ".deleteProgress(for:)" + case .m_deleteAllProgress: return ".deleteAllProgress()" + case .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality: return ".addToDownloadQueue(blocks:downloadQuality:)" + case .m_nextBlockForDownloading: return ".nextBlockForDownloading()" + case .m_updateDownloadState__id_idstate_stateresumeData_resumeData: return ".updateDownloadState(id:state:resumeData:)" + case .m_deleteDownloadDataTask__id_id: return ".deleteDownloadDataTask(id:)" + case .m_saveDownloadDataTask__task: return ".saveDownloadDataTask(_:)" + case .m_downloadDataTask__for_blockId: return ".downloadDataTask(for:)" + case .m_getDownloadDataTasks: return ".getDownloadDataTasks()" + case .m_getDownloadDataTasksForCourse__courseId: return ".getDownloadDataTasksForCourse(_:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + 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 { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { + return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadAllOfflineProgress(willReturn: [OfflineProgress]...) -> MethodStub { + return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func nextBlockForDownloading(willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func downloadDataTask(for blockId: Parameter, willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getUserID(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Int?] = [] + let given: Given = { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Int?).self) + 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) })) }() + let stubber = given.stub(for: (OfflineProgress?).self) + willProduce(stubber) + return given + } + public static func loadAllOfflineProgress(willProduce: (Stubber<[OfflineProgress]>) -> Void) -> MethodStub { + let willReturn: [[OfflineProgress]] = [] + let given: Given = { return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([OfflineProgress]).self) + willProduce(stubber) + return given + } + public static func nextBlockForDownloading(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func downloadDataTask(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).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) })) + } + public static func deleteDownloadDataTask(id: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + 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)} + 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`))} + public static func loadAllOfflineProgress() -> Verify { return Verify(method: .m_loadAllOfflineProgress)} + public static func deleteProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_deleteProgress__for_blockID(`blockID`))} + public static func deleteAllProgress() -> Verify { return Verify(method: .m_deleteAllProgress)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`))} + public static func nextBlockForDownloading() -> Verify { return Verify(method: .m_nextBlockForDownloading)} + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter) -> Verify { return Verify(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`))} + public static func deleteDownloadDataTask(id: Parameter) -> Verify { return Verify(method: .m_deleteDownloadDataTask__id_id(`id`))} + public static func saveDownloadDataTask(_ task: Parameter) -> Verify { return Verify(method: .m_saveDownloadDataTask__task(`task`))} + public static func downloadDataTask(for blockId: Parameter) -> Verify { return Verify(method: .m_downloadDataTask__for_blockId(`blockId`))} + public static func getDownloadDataTasks() -> Verify { return Verify(method: .m_getDownloadDataTasks)} + public static func getDownloadDataTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func set(userId: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_set__userId_userId(`userId`), performs: perform) + } + public static func getUserID(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getUserID, performs: perform) + } + 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 { + return Perform(method: .m_addToDownloadQueue__tasks_tasks(`tasks`), performs: perform) + } + public static func saveOfflineProgress(progress: Parameter, perform: @escaping (OfflineProgress) -> Void) -> Perform { + return Perform(method: .m_saveOfflineProgress__progress_progress(`progress`), performs: perform) + } + public static func loadProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_loadProgress__for_blockID(`blockID`), performs: perform) + } + public static func loadAllOfflineProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_loadAllOfflineProgress, performs: perform) + } + public static func deleteProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteProgress__for_blockID(`blockID`), performs: perform) + } + public static func deleteAllProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllProgress, performs: perform) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter, perform: @escaping ([CourseBlock], DownloadQuality) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`), performs: perform) + } + public static func nextBlockForDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_nextBlockForDownloading, performs: perform) + } + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter, perform: @escaping (String, DownloadState, Data?) -> Void) -> Perform { + return Perform(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`), performs: perform) + } + public static func deleteDownloadDataTask(id: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteDownloadDataTask__id_id(`id`), performs: perform) + } + public static func saveDownloadDataTask(_ task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_saveDownloadDataTask__task(`task`), performs: perform) + } + public static func downloadDataTask(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_downloadDataTask__for_blockId(`blockId`), performs: perform) + } + public static func getDownloadDataTasks(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasks, performs: perform) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), 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) + } +} + // MARK: - DashboardAnalytics open class DashboardAnalyticsMock: DashboardAnalytics, Mock { @@ -2193,6 +2798,20 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] { + addInvocation(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) + let perform = methodPerformValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) as? ([CourseSequential]) -> Void + perform?(`sequentials`) + var __value: [CourseSequential] + do { + __value = try methodReturnValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))).casted() + } catch { + onFatalFailure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + Failure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + } + return __value + } + open func resumeDownloading() throws { addInvocation(.m_resumeDownloading) let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void @@ -2240,6 +2859,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) + case m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case m_removeAppSupportDirectoryUnusedContent @@ -2293,6 +2913,11 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) return Matcher.ComparisonResult(results) + case (.m_updateUnzippedFileSize__for_sequentials(let lhsSequentials), .m_updateUnzippedFileSize__for_sequentials(let rhsSequentials)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSequentials, rhs: rhsSequentials, with: matcher), lhsSequentials, rhsSequentials, "for sequentials")) + return Matcher.ComparisonResult(results) + case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): @@ -2320,6 +2945,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case let .m_deleteFile__blocks_blocks(p0): return p0.intValue case .m_deleteAllFiles: return 0 case let .m_fileUrl__for_blockId(p0): return p0.intValue + case let .m_updateUnzippedFileSize__for_sequentials(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .m_removeAppSupportDirectoryUnusedContent: return 0 @@ -2340,6 +2966,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + case .m_updateUnzippedFileSize__for_sequentials: return ".updateUnzippedFileSize(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" @@ -2375,6 +3002,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willReturn: [CourseSequential]...) -> MethodStub { + return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } 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) })) } @@ -2413,6 +3043,13 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willProduce: (Stubber<[CourseSequential]>) -> Void) -> MethodStub { + let willReturn: [[CourseSequential]] = [] + let given: Given = { return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseSequential]).self) + willProduce(stubber) + return given + } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [Bool] = [] let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -2497,6 +3134,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} public static func deleteAllFiles() -> Verify { return Verify(method: .m_deleteAllFiles)} public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>) -> Verify { return Verify(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`))} public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} public static func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} @@ -2543,6 +3181,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, perform: @escaping ([CourseSequential]) -> Void) -> Perform { + return Perform(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), performs: perform) + } public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_resumeDownloading, performs: perform) } @@ -2627,6 +3268,205 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } +// MARK: - OfflineSyncInteractorProtocol + +open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, 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 = [] } + } + + + + + + open func submitOfflineProgress(courseID: String, blockID: String, data: String) throws -> Bool { + addInvocation(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) + let perform = methodPerformValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) as? (String, String, String) -> Void + perform?(`courseID`, `blockID`, `data`) + var __value: Bool + do { + __value = try methodReturnValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + Failure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + } catch { + throw error + } + return __value + } + + + fileprivate enum MethodType { + case m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter, Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let lhsCourseid, let lhsBlockid, let lhsData), .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let rhsCourseid, let rhsBlockid, let rhsData)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "blockID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsData, rhs: rhsData, with: matcher), lhsData, rhsData, "data")) + return Matcher.ComparisonResult(results) + } + } + + func intValue() -> Int { + switch self { + case let .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + } + } + func assertionName() -> String { + switch self { + case .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data: return ".submitOfflineProgress(courseID:blockID:data:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Bool).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter) -> Verify { return Verify(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { + return Perform(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), 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) + } +} + // MARK: - WebviewCookiesUpdateProtocol open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift index b69bb3af9..e4c23ff15 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift @@ -95,11 +95,13 @@ public struct DiscoveryWebview: View { WebView( viewModel: .init( url: URLString, - baseURL: "" + baseURL: "", + openFile: {_ in} ), isLoading: $isLoading, refreshCookies: {}, navigationDelegate: viewModel, + connectivity: viewModel.connectivity, webViewType: discoveryType.rawValue ) .accessibilityIdentifier("discovery_webview") diff --git a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift index a646d5108..52a9ee7de 100644 --- a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift +++ b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift @@ -22,7 +22,7 @@ public struct ProgramWebviewView: View { private var router: DiscoveryRouter private var viewType: ProgramViewType public var pathID: String - + private var URLString: String { switch viewType { case .program: @@ -55,7 +55,8 @@ public struct ProgramWebviewView: View { WebView( viewModel: .init( url: URLString, - baseURL: "", + baseURL: "", + openFile: {_ in}, injections: [.colorInversionCss] ), isLoading: $isLoading, @@ -64,7 +65,8 @@ public struct ProgramWebviewView: View { force: true ) }, - navigationDelegate: viewModel, + navigationDelegate: viewModel, + connectivity: viewModel.connectivity, webViewType: viewType.rawValue ) .accessibilityIdentifier("program_webview") diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index 1bcdcff78..93a07e4e6 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -1466,6 +1466,611 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { } } +// MARK: - CorePersistenceProtocol + +open class CorePersistenceProtocolMock: CorePersistenceProtocol, 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 = [] } + } + + + + + + open func set(userId: Int) { + addInvocation(.m_set__userId_userId(Parameter.value(`userId`))) + let perform = methodPerformValue(.m_set__userId_userId(Parameter.value(`userId`))) as? (Int) -> Void + perform?(`userId`) + } + + open func getUserID() -> Int? { + addInvocation(.m_getUserID) + let perform = methodPerformValue(.m_getUserID) as? () -> Void + perform?() + var __value: Int? = nil + do { + __value = try methodReturnValue(.m_getUserID).casted() + } catch { + // do nothing + } + return __value + } + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func addToDownloadQueue(tasks: [DownloadDataTask]) { + addInvocation(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) as? ([DownloadDataTask]) -> Void + perform?(`tasks`) + } + + open func saveOfflineProgress(progress: OfflineProgress) { + addInvocation(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) + let perform = methodPerformValue(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) as? (OfflineProgress) -> Void + perform?(`progress`) + } + + open func loadProgress(for blockID: String) -> OfflineProgress? { + addInvocation(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + var __value: OfflineProgress? = nil + do { + __value = try methodReturnValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))).casted() + } catch { + // do nothing + } + return __value + } + + open func loadAllOfflineProgress() -> [OfflineProgress] { + addInvocation(.m_loadAllOfflineProgress) + let perform = methodPerformValue(.m_loadAllOfflineProgress) as? () -> Void + perform?() + var __value: [OfflineProgress] + do { + __value = try methodReturnValue(.m_loadAllOfflineProgress).casted() + } catch { + onFatalFailure("Stub return value not specified for loadAllOfflineProgress(). Use given") + Failure("Stub return value not specified for loadAllOfflineProgress(). Use given") + } + return __value + } + + open func deleteProgress(for blockID: String) { + addInvocation(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + } + + open func deleteAllProgress() { + addInvocation(.m_deleteAllProgress) + let perform = methodPerformValue(.m_deleteAllProgress) as? () -> Void + perform?() + } + + open func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) { + addInvocation(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) as? ([CourseBlock], DownloadQuality) -> Void + perform?(`blocks`, `downloadQuality`) + } + + open func nextBlockForDownloading() -> DownloadDataTask? { + addInvocation(.m_nextBlockForDownloading) + let perform = methodPerformValue(.m_nextBlockForDownloading) as? () -> Void + perform?() + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_nextBlockForDownloading).casted() + } catch { + // do nothing + } + return __value + } + + open func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) { + addInvocation(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) + let perform = methodPerformValue(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) as? (String, DownloadState, Data?) -> Void + perform?(`id`, `state`, `resumeData`) + } + + open func deleteDownloadDataTask(id: String) throws { + addInvocation(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) + let perform = methodPerformValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) as? (String) -> Void + perform?(`id`) + do { + _ = try methodReturnValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func saveDownloadDataTask(_ task: DownloadDataTask) { + addInvocation(.m_saveDownloadDataTask__task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_saveDownloadDataTask__task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) + } + + open func downloadDataTask(for blockId: String) -> DownloadDataTask? { + addInvocation(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + open func getDownloadDataTasks() -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasks) + let perform = methodPerformValue(.m_getDownloadDataTasks) as? () -> Void + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasks(). Use given") + Failure("Stub return value not specified for getDownloadDataTasks(). Use given") + } + return __value + } + + open func getDownloadDataTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + } + return __value + } + + + fileprivate enum MethodType { + case m_set__userId_userId(Parameter) + case m_getUserID + case m_publisher + case m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>) + case m_saveOfflineProgress__progress_progress(Parameter) + case m_loadProgress__for_blockID(Parameter) + case m_loadAllOfflineProgress + case m_deleteProgress__for_blockID(Parameter) + case m_deleteAllProgress + case m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>, Parameter) + case m_nextBlockForDownloading + case m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter, Parameter, Parameter) + case m_deleteDownloadDataTask__id_id(Parameter) + case m_saveDownloadDataTask__task(Parameter) + case m_downloadDataTask__for_blockId(Parameter) + case m_getDownloadDataTasks + case m_getDownloadDataTasksForCourse__courseId(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_set__userId_userId(let lhsUserid), .m_set__userId_userId(let rhsUserid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUserid, rhs: rhsUserid, with: matcher), lhsUserid, rhsUserid, "userId")) + return Matcher.ComparisonResult(results) + + case (.m_getUserID, .m_getUserID): return .match + + case (.m_publisher, .m_publisher): return .match + + case (.m_addToDownloadQueue__tasks_tasks(let lhsTasks), .m_addToDownloadQueue__tasks_tasks(let rhsTasks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTasks, rhs: rhsTasks, with: matcher), lhsTasks, rhsTasks, "tasks")) + return Matcher.ComparisonResult(results) + + case (.m_saveOfflineProgress__progress_progress(let lhsProgress), .m_saveOfflineProgress__progress_progress(let rhsProgress)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsProgress, rhs: rhsProgress, with: matcher), lhsProgress, rhsProgress, "progress")) + return Matcher.ComparisonResult(results) + + case (.m_loadProgress__for_blockID(let lhsBlockid), .m_loadProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_loadAllOfflineProgress, .m_loadAllOfflineProgress): return .match + + case (.m_deleteProgress__for_blockID(let lhsBlockid), .m_deleteProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_deleteAllProgress, .m_deleteAllProgress): return .match + + case (.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let lhsBlocks, let lhsDownloadquality), .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let rhsBlocks, let rhsDownloadquality)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDownloadquality, rhs: rhsDownloadquality, with: matcher), lhsDownloadquality, rhsDownloadquality, "downloadQuality")) + return Matcher.ComparisonResult(results) + + case (.m_nextBlockForDownloading, .m_nextBlockForDownloading): return .match + + case (.m_updateDownloadState__id_idstate_stateresumeData_resumeData(let lhsId, let lhsState, let lhsResumedata), .m_updateDownloadState__id_idstate_stateresumeData_resumeData(let rhsId, let rhsState, let rhsResumedata)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsState, rhs: rhsState, with: matcher), lhsState, rhsState, "state")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsResumedata, rhs: rhsResumedata, with: matcher), lhsResumedata, rhsResumedata, "resumeData")) + return Matcher.ComparisonResult(results) + + case (.m_deleteDownloadDataTask__id_id(let lhsId), .m_deleteDownloadDataTask__id_id(let rhsId)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + return Matcher.ComparisonResult(results) + + case (.m_saveDownloadDataTask__task(let lhsTask), .m_saveDownloadDataTask__task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "_ task")) + return Matcher.ComparisonResult(results) + + case (.m_downloadDataTask__for_blockId(let lhsBlockid), .m_downloadDataTask__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + + case (.m_getDownloadDataTasks, .m_getDownloadDataTasks): return .match + + case (.m_getDownloadDataTasksForCourse__courseId(let lhsCourseid), .m_getDownloadDataTasksForCourse__courseId(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "_ courseId")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_set__userId_userId(p0): return p0.intValue + case .m_getUserID: return 0 + case .m_publisher: return 0 + case let .m_addToDownloadQueue__tasks_tasks(p0): return p0.intValue + case let .m_saveOfflineProgress__progress_progress(p0): return p0.intValue + case let .m_loadProgress__for_blockID(p0): return p0.intValue + case .m_loadAllOfflineProgress: return 0 + case let .m_deleteProgress__for_blockID(p0): return p0.intValue + case .m_deleteAllProgress: return 0 + case let .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(p0, p1): return p0.intValue + p1.intValue + case .m_nextBlockForDownloading: return 0 + case let .m_updateDownloadState__id_idstate_stateresumeData_resumeData(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_deleteDownloadDataTask__id_id(p0): return p0.intValue + case let .m_saveDownloadDataTask__task(p0): return p0.intValue + case let .m_downloadDataTask__for_blockId(p0): return p0.intValue + case .m_getDownloadDataTasks: return 0 + case let .m_getDownloadDataTasksForCourse__courseId(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_set__userId_userId: return ".set(userId:)" + case .m_getUserID: return ".getUserID()" + case .m_publisher: return ".publisher()" + case .m_addToDownloadQueue__tasks_tasks: return ".addToDownloadQueue(tasks:)" + case .m_saveOfflineProgress__progress_progress: return ".saveOfflineProgress(progress:)" + case .m_loadProgress__for_blockID: return ".loadProgress(for:)" + case .m_loadAllOfflineProgress: return ".loadAllOfflineProgress()" + case .m_deleteProgress__for_blockID: return ".deleteProgress(for:)" + case .m_deleteAllProgress: return ".deleteAllProgress()" + case .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality: return ".addToDownloadQueue(blocks:downloadQuality:)" + case .m_nextBlockForDownloading: return ".nextBlockForDownloading()" + case .m_updateDownloadState__id_idstate_stateresumeData_resumeData: return ".updateDownloadState(id:state:resumeData:)" + case .m_deleteDownloadDataTask__id_id: return ".deleteDownloadDataTask(id:)" + case .m_saveDownloadDataTask__task: return ".saveDownloadDataTask(_:)" + case .m_downloadDataTask__for_blockId: return ".downloadDataTask(for:)" + case .m_getDownloadDataTasks: return ".getDownloadDataTasks()" + case .m_getDownloadDataTasksForCourse__courseId: return ".getDownloadDataTasksForCourse(_:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + 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 { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { + return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadAllOfflineProgress(willReturn: [OfflineProgress]...) -> MethodStub { + return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func nextBlockForDownloading(willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func downloadDataTask(for blockId: Parameter, willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getUserID(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Int?] = [] + let given: Given = { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Int?).self) + 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) })) }() + let stubber = given.stub(for: (OfflineProgress?).self) + willProduce(stubber) + return given + } + public static func loadAllOfflineProgress(willProduce: (Stubber<[OfflineProgress]>) -> Void) -> MethodStub { + let willReturn: [[OfflineProgress]] = [] + let given: Given = { return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([OfflineProgress]).self) + willProduce(stubber) + return given + } + public static func nextBlockForDownloading(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func downloadDataTask(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).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) })) + } + public static func deleteDownloadDataTask(id: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + 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)} + 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`))} + public static func loadAllOfflineProgress() -> Verify { return Verify(method: .m_loadAllOfflineProgress)} + public static func deleteProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_deleteProgress__for_blockID(`blockID`))} + public static func deleteAllProgress() -> Verify { return Verify(method: .m_deleteAllProgress)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`))} + public static func nextBlockForDownloading() -> Verify { return Verify(method: .m_nextBlockForDownloading)} + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter) -> Verify { return Verify(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`))} + public static func deleteDownloadDataTask(id: Parameter) -> Verify { return Verify(method: .m_deleteDownloadDataTask__id_id(`id`))} + public static func saveDownloadDataTask(_ task: Parameter) -> Verify { return Verify(method: .m_saveDownloadDataTask__task(`task`))} + public static func downloadDataTask(for blockId: Parameter) -> Verify { return Verify(method: .m_downloadDataTask__for_blockId(`blockId`))} + public static func getDownloadDataTasks() -> Verify { return Verify(method: .m_getDownloadDataTasks)} + public static func getDownloadDataTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func set(userId: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_set__userId_userId(`userId`), performs: perform) + } + public static func getUserID(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getUserID, performs: perform) + } + 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 { + return Perform(method: .m_addToDownloadQueue__tasks_tasks(`tasks`), performs: perform) + } + public static func saveOfflineProgress(progress: Parameter, perform: @escaping (OfflineProgress) -> Void) -> Perform { + return Perform(method: .m_saveOfflineProgress__progress_progress(`progress`), performs: perform) + } + public static func loadProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_loadProgress__for_blockID(`blockID`), performs: perform) + } + public static func loadAllOfflineProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_loadAllOfflineProgress, performs: perform) + } + public static func deleteProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteProgress__for_blockID(`blockID`), performs: perform) + } + public static func deleteAllProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllProgress, performs: perform) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter, perform: @escaping ([CourseBlock], DownloadQuality) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`), performs: perform) + } + public static func nextBlockForDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_nextBlockForDownloading, performs: perform) + } + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter, perform: @escaping (String, DownloadState, Data?) -> Void) -> Perform { + return Perform(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`), performs: perform) + } + public static func deleteDownloadDataTask(id: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteDownloadDataTask__id_id(`id`), performs: perform) + } + public static func saveDownloadDataTask(_ task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_saveDownloadDataTask__task(`task`), performs: perform) + } + public static func downloadDataTask(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_downloadDataTask__for_blockId(`blockId`), performs: perform) + } + public static func getDownloadDataTasks(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasks, performs: perform) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), 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) + } +} + // MARK: - DiscoveryAnalytics open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { @@ -2387,6 +2992,20 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] { + addInvocation(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) + let perform = methodPerformValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) as? ([CourseSequential]) -> Void + perform?(`sequentials`) + var __value: [CourseSequential] + do { + __value = try methodReturnValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))).casted() + } catch { + onFatalFailure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + Failure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + } + return __value + } + open func resumeDownloading() throws { addInvocation(.m_resumeDownloading) let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void @@ -2434,6 +3053,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) + case m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case m_removeAppSupportDirectoryUnusedContent @@ -2487,6 +3107,11 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) return Matcher.ComparisonResult(results) + case (.m_updateUnzippedFileSize__for_sequentials(let lhsSequentials), .m_updateUnzippedFileSize__for_sequentials(let rhsSequentials)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSequentials, rhs: rhsSequentials, with: matcher), lhsSequentials, rhsSequentials, "for sequentials")) + return Matcher.ComparisonResult(results) + case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): @@ -2514,6 +3139,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case let .m_deleteFile__blocks_blocks(p0): return p0.intValue case .m_deleteAllFiles: return 0 case let .m_fileUrl__for_blockId(p0): return p0.intValue + case let .m_updateUnzippedFileSize__for_sequentials(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .m_removeAppSupportDirectoryUnusedContent: return 0 @@ -2534,6 +3160,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + case .m_updateUnzippedFileSize__for_sequentials: return ".updateUnzippedFileSize(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" @@ -2569,6 +3196,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willReturn: [CourseSequential]...) -> MethodStub { + return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } 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) })) } @@ -2607,6 +3237,13 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willProduce: (Stubber<[CourseSequential]>) -> Void) -> MethodStub { + let willReturn: [[CourseSequential]] = [] + let given: Given = { return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseSequential]).self) + willProduce(stubber) + return given + } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [Bool] = [] let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -2691,6 +3328,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} public static func deleteAllFiles() -> Verify { return Verify(method: .m_deleteAllFiles)} public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>) -> Verify { return Verify(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`))} public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} public static func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} @@ -2737,6 +3375,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, perform: @escaping ([CourseSequential]) -> Void) -> Perform { + return Perform(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), performs: perform) + } public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_resumeDownloading, performs: perform) } @@ -2821,6 +3462,205 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } +// MARK: - OfflineSyncInteractorProtocol + +open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, 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 = [] } + } + + + + + + open func submitOfflineProgress(courseID: String, blockID: String, data: String) throws -> Bool { + addInvocation(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) + let perform = methodPerformValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) as? (String, String, String) -> Void + perform?(`courseID`, `blockID`, `data`) + var __value: Bool + do { + __value = try methodReturnValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + Failure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + } catch { + throw error + } + return __value + } + + + fileprivate enum MethodType { + case m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter, Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let lhsCourseid, let lhsBlockid, let lhsData), .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let rhsCourseid, let rhsBlockid, let rhsData)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "blockID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsData, rhs: rhsData, with: matcher), lhsData, rhsData, "data")) + return Matcher.ComparisonResult(results) + } + } + + func intValue() -> Int { + switch self { + case let .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + } + } + func assertionName() -> String { + switch self { + case .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data: return ".submitOfflineProgress(courseID:blockID:data:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Bool).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter) -> Verify { return Verify(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { + return Perform(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), 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) + } +} + // MARK: - WebviewCookiesUpdateProtocol open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index 82022c6aa..758b1cbdf 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -1466,6 +1466,611 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { } } +// MARK: - CorePersistenceProtocol + +open class CorePersistenceProtocolMock: CorePersistenceProtocol, 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 = [] } + } + + + + + + open func set(userId: Int) { + addInvocation(.m_set__userId_userId(Parameter.value(`userId`))) + let perform = methodPerformValue(.m_set__userId_userId(Parameter.value(`userId`))) as? (Int) -> Void + perform?(`userId`) + } + + open func getUserID() -> Int? { + addInvocation(.m_getUserID) + let perform = methodPerformValue(.m_getUserID) as? () -> Void + perform?() + var __value: Int? = nil + do { + __value = try methodReturnValue(.m_getUserID).casted() + } catch { + // do nothing + } + return __value + } + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func addToDownloadQueue(tasks: [DownloadDataTask]) { + addInvocation(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) as? ([DownloadDataTask]) -> Void + perform?(`tasks`) + } + + open func saveOfflineProgress(progress: OfflineProgress) { + addInvocation(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) + let perform = methodPerformValue(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) as? (OfflineProgress) -> Void + perform?(`progress`) + } + + open func loadProgress(for blockID: String) -> OfflineProgress? { + addInvocation(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + var __value: OfflineProgress? = nil + do { + __value = try methodReturnValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))).casted() + } catch { + // do nothing + } + return __value + } + + open func loadAllOfflineProgress() -> [OfflineProgress] { + addInvocation(.m_loadAllOfflineProgress) + let perform = methodPerformValue(.m_loadAllOfflineProgress) as? () -> Void + perform?() + var __value: [OfflineProgress] + do { + __value = try methodReturnValue(.m_loadAllOfflineProgress).casted() + } catch { + onFatalFailure("Stub return value not specified for loadAllOfflineProgress(). Use given") + Failure("Stub return value not specified for loadAllOfflineProgress(). Use given") + } + return __value + } + + open func deleteProgress(for blockID: String) { + addInvocation(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + } + + open func deleteAllProgress() { + addInvocation(.m_deleteAllProgress) + let perform = methodPerformValue(.m_deleteAllProgress) as? () -> Void + perform?() + } + + open func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) { + addInvocation(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) as? ([CourseBlock], DownloadQuality) -> Void + perform?(`blocks`, `downloadQuality`) + } + + open func nextBlockForDownloading() -> DownloadDataTask? { + addInvocation(.m_nextBlockForDownloading) + let perform = methodPerformValue(.m_nextBlockForDownloading) as? () -> Void + perform?() + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_nextBlockForDownloading).casted() + } catch { + // do nothing + } + return __value + } + + open func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) { + addInvocation(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) + let perform = methodPerformValue(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) as? (String, DownloadState, Data?) -> Void + perform?(`id`, `state`, `resumeData`) + } + + open func deleteDownloadDataTask(id: String) throws { + addInvocation(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) + let perform = methodPerformValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) as? (String) -> Void + perform?(`id`) + do { + _ = try methodReturnValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func saveDownloadDataTask(_ task: DownloadDataTask) { + addInvocation(.m_saveDownloadDataTask__task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_saveDownloadDataTask__task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) + } + + open func downloadDataTask(for blockId: String) -> DownloadDataTask? { + addInvocation(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + open func getDownloadDataTasks() -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasks) + let perform = methodPerformValue(.m_getDownloadDataTasks) as? () -> Void + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasks(). Use given") + Failure("Stub return value not specified for getDownloadDataTasks(). Use given") + } + return __value + } + + open func getDownloadDataTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + } + return __value + } + + + fileprivate enum MethodType { + case m_set__userId_userId(Parameter) + case m_getUserID + case m_publisher + case m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>) + case m_saveOfflineProgress__progress_progress(Parameter) + case m_loadProgress__for_blockID(Parameter) + case m_loadAllOfflineProgress + case m_deleteProgress__for_blockID(Parameter) + case m_deleteAllProgress + case m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>, Parameter) + case m_nextBlockForDownloading + case m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter, Parameter, Parameter) + case m_deleteDownloadDataTask__id_id(Parameter) + case m_saveDownloadDataTask__task(Parameter) + case m_downloadDataTask__for_blockId(Parameter) + case m_getDownloadDataTasks + case m_getDownloadDataTasksForCourse__courseId(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_set__userId_userId(let lhsUserid), .m_set__userId_userId(let rhsUserid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUserid, rhs: rhsUserid, with: matcher), lhsUserid, rhsUserid, "userId")) + return Matcher.ComparisonResult(results) + + case (.m_getUserID, .m_getUserID): return .match + + case (.m_publisher, .m_publisher): return .match + + case (.m_addToDownloadQueue__tasks_tasks(let lhsTasks), .m_addToDownloadQueue__tasks_tasks(let rhsTasks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTasks, rhs: rhsTasks, with: matcher), lhsTasks, rhsTasks, "tasks")) + return Matcher.ComparisonResult(results) + + case (.m_saveOfflineProgress__progress_progress(let lhsProgress), .m_saveOfflineProgress__progress_progress(let rhsProgress)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsProgress, rhs: rhsProgress, with: matcher), lhsProgress, rhsProgress, "progress")) + return Matcher.ComparisonResult(results) + + case (.m_loadProgress__for_blockID(let lhsBlockid), .m_loadProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_loadAllOfflineProgress, .m_loadAllOfflineProgress): return .match + + case (.m_deleteProgress__for_blockID(let lhsBlockid), .m_deleteProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_deleteAllProgress, .m_deleteAllProgress): return .match + + case (.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let lhsBlocks, let lhsDownloadquality), .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let rhsBlocks, let rhsDownloadquality)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDownloadquality, rhs: rhsDownloadquality, with: matcher), lhsDownloadquality, rhsDownloadquality, "downloadQuality")) + return Matcher.ComparisonResult(results) + + case (.m_nextBlockForDownloading, .m_nextBlockForDownloading): return .match + + case (.m_updateDownloadState__id_idstate_stateresumeData_resumeData(let lhsId, let lhsState, let lhsResumedata), .m_updateDownloadState__id_idstate_stateresumeData_resumeData(let rhsId, let rhsState, let rhsResumedata)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsState, rhs: rhsState, with: matcher), lhsState, rhsState, "state")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsResumedata, rhs: rhsResumedata, with: matcher), lhsResumedata, rhsResumedata, "resumeData")) + return Matcher.ComparisonResult(results) + + case (.m_deleteDownloadDataTask__id_id(let lhsId), .m_deleteDownloadDataTask__id_id(let rhsId)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + return Matcher.ComparisonResult(results) + + case (.m_saveDownloadDataTask__task(let lhsTask), .m_saveDownloadDataTask__task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "_ task")) + return Matcher.ComparisonResult(results) + + case (.m_downloadDataTask__for_blockId(let lhsBlockid), .m_downloadDataTask__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + + case (.m_getDownloadDataTasks, .m_getDownloadDataTasks): return .match + + case (.m_getDownloadDataTasksForCourse__courseId(let lhsCourseid), .m_getDownloadDataTasksForCourse__courseId(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "_ courseId")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_set__userId_userId(p0): return p0.intValue + case .m_getUserID: return 0 + case .m_publisher: return 0 + case let .m_addToDownloadQueue__tasks_tasks(p0): return p0.intValue + case let .m_saveOfflineProgress__progress_progress(p0): return p0.intValue + case let .m_loadProgress__for_blockID(p0): return p0.intValue + case .m_loadAllOfflineProgress: return 0 + case let .m_deleteProgress__for_blockID(p0): return p0.intValue + case .m_deleteAllProgress: return 0 + case let .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(p0, p1): return p0.intValue + p1.intValue + case .m_nextBlockForDownloading: return 0 + case let .m_updateDownloadState__id_idstate_stateresumeData_resumeData(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_deleteDownloadDataTask__id_id(p0): return p0.intValue + case let .m_saveDownloadDataTask__task(p0): return p0.intValue + case let .m_downloadDataTask__for_blockId(p0): return p0.intValue + case .m_getDownloadDataTasks: return 0 + case let .m_getDownloadDataTasksForCourse__courseId(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_set__userId_userId: return ".set(userId:)" + case .m_getUserID: return ".getUserID()" + case .m_publisher: return ".publisher()" + case .m_addToDownloadQueue__tasks_tasks: return ".addToDownloadQueue(tasks:)" + case .m_saveOfflineProgress__progress_progress: return ".saveOfflineProgress(progress:)" + case .m_loadProgress__for_blockID: return ".loadProgress(for:)" + case .m_loadAllOfflineProgress: return ".loadAllOfflineProgress()" + case .m_deleteProgress__for_blockID: return ".deleteProgress(for:)" + case .m_deleteAllProgress: return ".deleteAllProgress()" + case .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality: return ".addToDownloadQueue(blocks:downloadQuality:)" + case .m_nextBlockForDownloading: return ".nextBlockForDownloading()" + case .m_updateDownloadState__id_idstate_stateresumeData_resumeData: return ".updateDownloadState(id:state:resumeData:)" + case .m_deleteDownloadDataTask__id_id: return ".deleteDownloadDataTask(id:)" + case .m_saveDownloadDataTask__task: return ".saveDownloadDataTask(_:)" + case .m_downloadDataTask__for_blockId: return ".downloadDataTask(for:)" + case .m_getDownloadDataTasks: return ".getDownloadDataTasks()" + case .m_getDownloadDataTasksForCourse__courseId: return ".getDownloadDataTasksForCourse(_:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + 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 { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { + return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadAllOfflineProgress(willReturn: [OfflineProgress]...) -> MethodStub { + return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func nextBlockForDownloading(willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func downloadDataTask(for blockId: Parameter, willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getUserID(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Int?] = [] + let given: Given = { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Int?).self) + 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) })) }() + let stubber = given.stub(for: (OfflineProgress?).self) + willProduce(stubber) + return given + } + public static func loadAllOfflineProgress(willProduce: (Stubber<[OfflineProgress]>) -> Void) -> MethodStub { + let willReturn: [[OfflineProgress]] = [] + let given: Given = { return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([OfflineProgress]).self) + willProduce(stubber) + return given + } + public static func nextBlockForDownloading(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func downloadDataTask(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).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) })) + } + public static func deleteDownloadDataTask(id: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + 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)} + 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`))} + public static func loadAllOfflineProgress() -> Verify { return Verify(method: .m_loadAllOfflineProgress)} + public static func deleteProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_deleteProgress__for_blockID(`blockID`))} + public static func deleteAllProgress() -> Verify { return Verify(method: .m_deleteAllProgress)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`))} + public static func nextBlockForDownloading() -> Verify { return Verify(method: .m_nextBlockForDownloading)} + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter) -> Verify { return Verify(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`))} + public static func deleteDownloadDataTask(id: Parameter) -> Verify { return Verify(method: .m_deleteDownloadDataTask__id_id(`id`))} + public static func saveDownloadDataTask(_ task: Parameter) -> Verify { return Verify(method: .m_saveDownloadDataTask__task(`task`))} + public static func downloadDataTask(for blockId: Parameter) -> Verify { return Verify(method: .m_downloadDataTask__for_blockId(`blockId`))} + public static func getDownloadDataTasks() -> Verify { return Verify(method: .m_getDownloadDataTasks)} + public static func getDownloadDataTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func set(userId: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_set__userId_userId(`userId`), performs: perform) + } + public static func getUserID(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getUserID, performs: perform) + } + 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 { + return Perform(method: .m_addToDownloadQueue__tasks_tasks(`tasks`), performs: perform) + } + public static func saveOfflineProgress(progress: Parameter, perform: @escaping (OfflineProgress) -> Void) -> Perform { + return Perform(method: .m_saveOfflineProgress__progress_progress(`progress`), performs: perform) + } + public static func loadProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_loadProgress__for_blockID(`blockID`), performs: perform) + } + public static func loadAllOfflineProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_loadAllOfflineProgress, performs: perform) + } + public static func deleteProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteProgress__for_blockID(`blockID`), performs: perform) + } + public static func deleteAllProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllProgress, performs: perform) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter, perform: @escaping ([CourseBlock], DownloadQuality) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`), performs: perform) + } + public static func nextBlockForDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_nextBlockForDownloading, performs: perform) + } + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter, perform: @escaping (String, DownloadState, Data?) -> Void) -> Perform { + return Perform(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`), performs: perform) + } + public static func deleteDownloadDataTask(id: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteDownloadDataTask__id_id(`id`), performs: perform) + } + public static func saveDownloadDataTask(_ task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_saveDownloadDataTask__task(`task`), performs: perform) + } + public static func downloadDataTask(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_downloadDataTask__for_blockId(`blockId`), performs: perform) + } + public static func getDownloadDataTasks(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasks, performs: perform) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), 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) + } +} + // MARK: - DiscussionAnalytics open class DiscussionAnalyticsMock: DiscussionAnalytics, Mock { @@ -3324,6 +3929,20 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] { + addInvocation(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) + let perform = methodPerformValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) as? ([CourseSequential]) -> Void + perform?(`sequentials`) + var __value: [CourseSequential] + do { + __value = try methodReturnValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))).casted() + } catch { + onFatalFailure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + Failure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + } + return __value + } + open func resumeDownloading() throws { addInvocation(.m_resumeDownloading) let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void @@ -3371,6 +3990,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) + case m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case m_removeAppSupportDirectoryUnusedContent @@ -3424,6 +4044,11 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) return Matcher.ComparisonResult(results) + case (.m_updateUnzippedFileSize__for_sequentials(let lhsSequentials), .m_updateUnzippedFileSize__for_sequentials(let rhsSequentials)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSequentials, rhs: rhsSequentials, with: matcher), lhsSequentials, rhsSequentials, "for sequentials")) + return Matcher.ComparisonResult(results) + case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): @@ -3451,6 +4076,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case let .m_deleteFile__blocks_blocks(p0): return p0.intValue case .m_deleteAllFiles: return 0 case let .m_fileUrl__for_blockId(p0): return p0.intValue + case let .m_updateUnzippedFileSize__for_sequentials(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .m_removeAppSupportDirectoryUnusedContent: return 0 @@ -3471,6 +4097,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + case .m_updateUnzippedFileSize__for_sequentials: return ".updateUnzippedFileSize(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" @@ -3506,6 +4133,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willReturn: [CourseSequential]...) -> MethodStub { + return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } 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) })) } @@ -3544,6 +4174,13 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willProduce: (Stubber<[CourseSequential]>) -> Void) -> MethodStub { + let willReturn: [[CourseSequential]] = [] + let given: Given = { return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseSequential]).self) + willProduce(stubber) + return given + } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [Bool] = [] let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -3628,6 +4265,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} public static func deleteAllFiles() -> Verify { return Verify(method: .m_deleteAllFiles)} public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>) -> Verify { return Verify(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`))} public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} public static func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} @@ -3674,6 +4312,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, perform: @escaping ([CourseSequential]) -> Void) -> Perform { + return Perform(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), performs: perform) + } public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_resumeDownloading, performs: perform) } @@ -3758,6 +4399,205 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } +// MARK: - OfflineSyncInteractorProtocol + +open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, 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 = [] } + } + + + + + + open func submitOfflineProgress(courseID: String, blockID: String, data: String) throws -> Bool { + addInvocation(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) + let perform = methodPerformValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) as? (String, String, String) -> Void + perform?(`courseID`, `blockID`, `data`) + var __value: Bool + do { + __value = try methodReturnValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + Failure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + } catch { + throw error + } + return __value + } + + + fileprivate enum MethodType { + case m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter, Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let lhsCourseid, let lhsBlockid, let lhsData), .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let rhsCourseid, let rhsBlockid, let rhsData)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "blockID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsData, rhs: rhsData, with: matcher), lhsData, rhsData, "data")) + return Matcher.ComparisonResult(results) + } + } + + func intValue() -> Int { + switch self { + case let .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + } + } + func assertionName() -> String { + switch self { + case .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data: return ".submitOfflineProgress(courseID:blockID:data:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Bool).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter) -> Verify { return Verify(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { + return Perform(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), 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) + } +} + // MARK: - WebviewCookiesUpdateProtocol open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 653283d3c..dca913869 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -762,7 +762,9 @@ FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; @@ -851,7 +853,9 @@ FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; @@ -946,7 +950,9 @@ FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; @@ -1035,7 +1041,9 @@ FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; @@ -1184,7 +1192,9 @@ FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; @@ -1219,7 +1229,9 @@ FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 36cea0ba7..f21e60633 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -16,10 +16,13 @@ import UserNotifications import FirebaseCore import FirebaseMessaging import Theme +import BackgroundTasks @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { + static let bgAppTaskId = "openEdx.offlineProgressSync" + static var shared: AppDelegate { UIApplication.shared.delegate as! AppDelegate } @@ -157,6 +160,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { lastForceLogoutTime = Date().timeIntervalSince1970 Container.shared.resolve(CoreStorage.self)?.clear() + Container.shared.resolve(CorePersistenceProtocol.self)?.deleteAllProgress() Task { await Container.shared.resolve(DownloadManagerProtocol.self)?.deleteAllFiles() } @@ -195,4 +199,46 @@ class AppDelegate: UIResponder, UIApplicationDelegate { guard let deepLinkManager = Container.shared.resolve(DeepLinkManager.self) else { return } deepLinkManager.configureDeepLinkService(launchOptions: launchOptions) } + + // Background progress update + + func registerBackgroundTask() { + let isRegistered = BGTaskScheduler.shared.register( + forTaskWithIdentifier: Self.bgAppTaskId, + using: nil + ) { task in + debugLog("Background task is executing: \(task.identifier)") + guard let task = task as? BGAppRefreshTask else { return } + self.handleAppRefreshTask(task: task) + } + debugLog("Is the background task registered? \(isRegistered)") + } + + func handleAppRefreshTask(task: BGAppRefreshTask) { + //In real case scenario we should check internet here + reScheduleAppRefresh() + + task.expirationHandler = { + //This Block call by System + //Canel your all tak's & queues + task.setTaskCompleted(success: true) + } + + let offlineSyncManager = Container.shared.resolve(OfflineSyncManagerProtocol.self)! + Task { + await offlineSyncManager.syncOfflineProgress() + task.setTaskCompleted(success: true) + } + } + + func reScheduleAppRefresh() { + let request = BGAppRefreshTaskRequest(identifier: Self.bgAppTaskId) + request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60) // App Refresh after 60 minute. + //Note :: EarliestBeginDate should not be set to too far into the future. + do { + try BGTaskScheduler.shared.submit(request) + } catch { + debugLog("Could not schedule app refresh: \(error)") + } + } } diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 578e94df6..a65f25833 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -20,6 +20,26 @@ import Combine class ScreenAssembly: Assembly { func assemble(container: Container) { + // MARK: OfflineSync + container.register(OfflineSyncRepositoryProtocol.self) { r in + OfflineSyncRepository( + api: r.resolve(API.self)! + ) + } + container.register(OfflineSyncInteractorProtocol.self) { r in + OfflineSyncInteractor( + repository: r.resolve(OfflineSyncRepositoryProtocol.self)! + ) + } + + container.register(OfflineSyncManagerProtocol.self) { 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( @@ -39,8 +59,11 @@ class ScreenAssembly: Assembly { MainScreenViewModel( analytics: r.resolve(MainScreenAnalytics.self)!, config: r.resolve(ConfigProtocol.self)!, + router: r.resolve(Router.self)!, + syncManager: r.resolve(OfflineSyncManagerProtocol.self)!, profileInteractor: r.resolve(ProfileInteractorProtocol.self)!, - appStorage: r.resolve(AppStorage.self)!, + courseInteractor: r.resolve(CourseInteractorProtocol.self)!, + appStorage: r.resolve(AppStorage.self)!, calendarManager: r.resolve(CalendarManagerProtocol.self)!, sourceScreen: sourceScreen ) @@ -240,7 +263,9 @@ class ScreenAssembly: Assembly { router: r.resolve(ProfileRouter.self)!, analytics: r.resolve(ProfileAnalytics.self)!, coreAnalytics: r.resolve(CoreAnalytics.self)!, - config: r.resolve(ConfigProtocol.self)! + config: r.resolve(ConfigProtocol.self)!, + corePersistence: r.resolve(CorePersistenceProtocol.self)!, + connectivity: r.resolve(ConnectivityProtocol.self)! ) } @@ -363,8 +388,11 @@ class ScreenAssembly: Assembly { } container.register(WebUnitViewModel.self) { r in - WebUnitViewModel(authInteractor: r.resolve(AuthInteractorProtocol.self)!, - config: r.resolve(ConfigProtocol.self)!) + WebUnitViewModel( + authInteractor: r.resolve(AuthInteractorProtocol.self)!, + config: r.resolve(ConfigProtocol.self)!, + syncManager: r.resolve(OfflineSyncManagerProtocol.self)! + ) } container.register( diff --git a/OpenEdX/Data/CorePersistence.swift b/OpenEdX/Data/CorePersistence.swift index f9282bd27..4844aafdc 100644 --- a/OpenEdX/Data/CorePersistence.swift +++ b/OpenEdX/Data/CorePersistence.swift @@ -80,34 +80,86 @@ public class CorePersistence: CorePersistenceProtocol { let userId = getUserId32() ?? 0 for block in blocks { let downloadDataId = downloadDataId(from: block.id) - await context.perform {[context] in + + await context.perform { [weak self] in + guard let self else { return } let data = try? CorePersistenceHelper.fetchCDDownloadData( predicate: CDPredicate.id(downloadDataId), - context: context, + context: self.context, userId: userId ) guard data?.first == nil else { return } - guard let video = block.encodedVideo?.video(downloadQuality: downloadQuality), - let url = video.url, - let fileExtension = URL(string: url)?.pathExtension - else { return } + var fileExtension: String? + var url: String? + var fileSize: Int32? + var fileName: String? - let fileName = "\(block.id).\(fileExtension)" + if let html = block.offlineDownload { + let fileUrl = html.fileUrl + url = fileUrl + fileSize = Int32(html.fileSize) + fileExtension = URL(string: fileUrl)?.pathExtension + if let folderName = URL(string: fileUrl)?.lastPathComponent, + let folderUrl = URL(string: folderName)?.deletingPathExtension() { + fileName = folderUrl.absoluteString + } + saveDownloadData() + } else if let encodedVideo = block.encodedVideo, + let video = encodedVideo.video(downloadQuality: downloadQuality), + let videoUrl = video.url { + url = videoUrl + if let videoFileSize = video.fileSize { + fileSize = Int32(videoFileSize) + } + fileExtension = URL(string: videoUrl)?.pathExtension + fileName = "\(block.id).\(fileExtension ?? "")" + saveDownloadData() + } else { return } + + func saveDownloadData() { + let newDownloadData = CDDownloadData(context: context) + context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + newDownloadData.id = downloadDataId + newDownloadData.blockId = block.id + newDownloadData.userId = userId + newDownloadData.courseId = block.courseId + newDownloadData.url = url + newDownloadData.fileName = fileName + newDownloadData.displayName = block.displayName + if let lastModified = block.offlineDownload?.lastModified { + newDownloadData.lastModified = lastModified + } + newDownloadData.progress = .zero + newDownloadData.resumeData = nil + newDownloadData.state = DownloadState.waiting.rawValue + newDownloadData.type = block.offlineDownload != nil + ? DownloadType.html.rawValue + : DownloadType.video.rawValue + newDownloadData.fileSize = Int32(fileSize ?? 0) + } + } + } + } + + public func addToDownloadQueue(tasks: [DownloadDataTask]) { + for task in tasks { + context.performAndWait { let newDownloadData = CDDownloadData(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - newDownloadData.id = downloadDataId - newDownloadData.blockId = block.id - newDownloadData.userId = userId - newDownloadData.courseId = block.courseId - newDownloadData.url = url - newDownloadData.fileName = fileName - newDownloadData.displayName = block.displayName + newDownloadData.id = task.id + newDownloadData.blockId = task.blockId + newDownloadData.userId = Int32(task.userId) + newDownloadData.courseId = task.courseId + newDownloadData.url = task.url + newDownloadData.fileName = task.fileName + newDownloadData.displayName = task.displayName + newDownloadData.lastModified = task.lastModified newDownloadData.progress = .zero newDownloadData.resumeData = nil newDownloadData.state = DownloadState.waiting.rawValue - newDownloadData.type = DownloadType.video.rawValue - newDownloadData.fileSize = Int32(video.fileSize ?? 0) + newDownloadData.type = task.type.rawValue + newDownloadData.fileSize = Int32(task.fileSize) } } } @@ -290,9 +342,117 @@ public class CorePersistence: CorePersistenceProtocol { }) .eraseToAnyPublisher() } + + // MARK: - Offline Progress + public func saveOfflineProgress(progress: OfflineProgress) { + context.performAndWait { + let progressForSaving = CDOfflineProgress(context: context) + context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + progressForSaving.blockID = progress.blockID + progressForSaving.progressJson = progress.progressJson + + do { + try context.save() + } catch { + debugLog("⛔️⛔️⛔️⛔️⛔️", error) + } + } + } + + public func loadProgress(for blockID: String) -> OfflineProgress? { + context.performAndWait { + let request = CDOfflineProgress.fetchRequest() + request.predicate = NSPredicate(format: "blockID = %@", blockID) + guard let progress = try? context.fetch(request).first, + let savedBlockID = progress.blockID, + let progressJson = progress.progressJson, + blockID == savedBlockID else { return nil } + + return OfflineProgress( + progressJson: progressJson + ) + } + } + + public func loadAllOfflineProgress() -> [OfflineProgress] { + context.performAndWait { + let result = try? context.fetch(CDOfflineProgress.fetchRequest()) + .map { + OfflineProgress( + progressJson: $0.progressJson ?? "" + )} + if let result, !result.isEmpty { + return result + } else { + return [] + } + } + } + + public func deleteProgress(for blockID: String) { + context.performAndWait { + let request = CDOfflineProgress.fetchRequest() + request.predicate = NSPredicate(format: "blockID = %@", blockID) + guard let progress = try? context.fetch(request).first else { return } + + do { + context.delete(progress) + try context.save() + debugLog("File erased successfully") + } catch { + debugLog("Error deleteing progress: \(error.localizedDescription)") + } + } + } + + public func deleteAllProgress() { + context.performAndWait { + let request = CDOfflineProgress.fetchRequest() + guard let allProgress = try? context.fetch(request) else { return } + + do { + for progress in allProgress { + context.delete(progress) + try context.save() + debugLog("File erased successfully") + } + } catch { + debugLog("Error deleteing progress: \(error.localizedDescription)") + } + } + } // 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 8ba7bc45c..eaca47bb5 100644 --- a/OpenEdX/Data/CoursePersistence.swift +++ b/OpenEdX/Data/CoursePersistence.swift @@ -86,27 +86,27 @@ public class CoursePersistence: CoursePersistenceProtocol { encodedVideo: DataLayer.CourseDetailEncodedVideoData( youTube: DataLayer.EncodedVideoData( url: $0.youTube?.url, - fileSize: Int($0.youTube?.fileSize ?? 0) + fileSize: $0.youTube?.fileSize == nil ? nil : Int($0.youTube!.fileSize) ), fallback: DataLayer.EncodedVideoData( url: $0.fallback?.url, - fileSize: Int($0.fallback?.fileSize ?? 0) + fileSize: $0.fallback?.fileSize == nil ? nil : Int($0.fallback!.fileSize) ), desktopMP4: DataLayer.EncodedVideoData( url: $0.desktopMP4?.url, - fileSize: Int($0.desktopMP4?.fileSize ?? 0) + fileSize: $0.desktopMP4?.fileSize == nil ? nil : Int($0.desktopMP4!.fileSize) ), mobileHigh: DataLayer.EncodedVideoData( url: $0.mobileHigh?.url, - fileSize: Int($0.mobileHigh?.fileSize ?? 0) + fileSize: $0.mobileHigh?.fileSize == nil ? nil : Int($0.mobileHigh!.fileSize) ), mobileLow: DataLayer.EncodedVideoData( url: $0.mobileLow?.url, - fileSize: Int($0.mobileLow?.fileSize ?? 0) + fileSize: $0.mobileLow?.fileSize == nil ? nil : Int($0.mobileLow!.fileSize) ), hls: DataLayer.EncodedVideoData( url: $0.hls?.url, - fileSize: Int($0.hls?.fileSize ?? 0) + fileSize: $0.hls?.fileSize == nil ? nil : Int($0.hls!.fileSize) ) ), topicID: "" @@ -129,6 +129,11 @@ public class CoursePersistence: CoursePersistenceProtocol { assignmentType: $0.assignmentType, numPointsEarned: $0.numPointsEarned, numPointsPossible: $0.numPointsPossible + ), + offlineDownload: DataLayer.OfflineDownload( + fileUrl: $0.fileUrl, + lastModified: $0.lastModified, + fileSize: Int($0.fileSize) ) ) } @@ -199,6 +204,15 @@ 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 { + courseDetail.fileSize = Int64(fileSize) + courseDetail.fileUrl = fileUrl + courseDetail.lastModified = lastModified + } if block.userViewData?.encodedVideo?.youTube != nil { let youTube = CDCourseBlockVideo(context: self.context) diff --git a/OpenEdX/Info.plist b/OpenEdX/Info.plist index 2b4cb0751..e9bd32e58 100644 --- a/OpenEdX/Info.plist +++ b/OpenEdX/Info.plist @@ -2,6 +2,10 @@ + BGTaskSchedulerPermittedIdentifiers + + openEdx.offlineProgressSync + Configuration $(CONFIGURATION) FirebaseAppDelegateProxyEnabled @@ -26,18 +30,20 @@ NSAllowsArbitraryLoads + NSAllowsArbitraryLoadsInWebContent + + NSCalendarsFullAccessUsageDescription + We would like to utilize your calendar list to subscribe you to your personalized calendar for this course. UIAppFonts UIBackgroundModes audio + fetch + processing UIViewControllerBasedStatusBarAppearance - NSCalendarsUsageDescription - We would like to utilize your calendar list to subscribe you to your personalized calendar for this course. - NSCalendarsFullAccessUsageDescription - We would like to utilize your calendar list to subscribe you to your personalized calendar for this course. diff --git a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift index 404ffed0e..6949a193a 100644 --- a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift +++ b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift @@ -549,6 +549,15 @@ class AnalyticsManager: AuthorizationAnalytics, logScreenEvent(.courseOutlineVideosTabClicked, parameters: parameters) } + func courseOutlineOfflineTabClicked(courseId: String, courseName: String) { + let parameters = [ + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.courseOutlineOfflineTabClicked.rawValue + ] + logEvent(.courseOutlineOfflineTabClicked, parameters: parameters) + } + public func courseOutlineDatesTabClicked(courseId: String, courseName: String) { let parameters = [ EventParamKey.courseID: courseId, diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 186ff6329..d06b65e9a 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -840,7 +840,8 @@ public class Router: AuthorizationRouter, let webBrowser = WebBrowser( url: url.absoluteString, pageTitle: title, - showProgress: true + showProgress: true, + connectivity: Container.shared.resolve(ConnectivityProtocol.self)! ) let controller = UIHostingController(rootView: webBrowser) navigationController.pushViewController(controller, animated: true) diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index 72fa66b56..f430a4662 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -162,6 +162,13 @@ struct MainScreenView: View { .onReceive(NotificationCenter.default.publisher(for: .onNewVersionAvaliable)) { _ in updateAvailable = true } + .onReceive(NotificationCenter.default.publisher(for: .showDownloadFailed)) { downloads in + if let downloads = downloads.object as? [DownloadDataTask] { + Task { + await viewModel.showDownloadFailed(downloads: downloads) + } + } + } .onChange(of: viewModel.selection) { _ in if disableAllTabs { viewModel.selection = .profile diff --git a/OpenEdX/View/MainScreenViewModel.swift b/OpenEdX/View/MainScreenViewModel.swift index 740d0fd93..70b74ca6b 100644 --- a/OpenEdX/View/MainScreenViewModel.swift +++ b/OpenEdX/View/MainScreenViewModel.swift @@ -8,6 +8,7 @@ import Foundation import Core import Profile +import Course import Swinject import Combine @@ -22,7 +23,10 @@ final class MainScreenViewModel: ObservableObject { private let analytics: MainScreenAnalytics let config: ConfigProtocol - private let profileInteractor: ProfileInteractorProtocol + let router: BaseRouter + let syncManager: OfflineSyncManagerProtocol + let profileInteractor: ProfileInteractorProtocol + let courseInteractor: CourseInteractorProtocol var sourceScreen: LogistrationSourceScreen private var appStorage: CoreStorage & ProfileStorage private let calendarManager: CalendarManagerProtocol @@ -32,14 +36,20 @@ final class MainScreenViewModel: ObservableObject { init(analytics: MainScreenAnalytics, config: ConfigProtocol, + router: BaseRouter, + syncManager: OfflineSyncManagerProtocol, profileInteractor: ProfileInteractorProtocol, + courseInteractor: CourseInteractorProtocol, appStorage: CoreStorage & ProfileStorage, calendarManager: CalendarManagerProtocol, sourceScreen: LogistrationSourceScreen = .default ) { self.analytics = analytics self.config = config + self.router = router + self.syncManager = syncManager self.profileInteractor = profileInteractor + self.courseInteractor = courseInteractor self.appStorage = appStorage self.calendarManager = calendarManager self.sourceScreen = sourceScreen @@ -71,6 +81,37 @@ final class MainScreenViewModel: ObservableObject { analytics.mainProfileTabClicked() } + @MainActor + func showDownloadFailed(downloads: [DownloadDataTask]) async { + if let sequentials = try? await courseInteractor.getSequentialsContainsBlocks( + blockIds: downloads.map { + $0.blockId + }, + courseID: downloads.first?.courseId ?? "" + ) { + router.presentView( + transitionStyle: .coverVertical, + view: DownloadErrorAlertView( + errorType: .downloadFailed, + sequentials: sequentials, + tryAgain: { [weak self] in + guard let self else { return } + NotificationCenter.default.post( + name: .tryDownloadAgain, + object: downloads + ) + self.router.dismiss(animated: true) + }, + close: { [weak self] in + guard let self else { return } + self.router.dismiss(animated: true) + } + ), + completion: {} + ) + } + } + @MainActor func prefetchDataForOffline() async { if profileInteractor.getMyProfileOffline() == nil { diff --git a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift index 29ff3c17a..3683465ab 100644 --- a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift +++ b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift @@ -119,7 +119,8 @@ struct ProfileSupportInfoView: View { WebBrowser( url: viewModel.url.absoluteString, pageTitle: viewModel.title, - showProgress: true + showProgress: true, + connectivity: self.viewModel.connectivity ) } label: { diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index e827d005e..e11926591 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -252,7 +252,9 @@ struct SettingsView_Previews: PreviewProvider { router: router, analytics: ProfileAnalyticsMock(), coreAnalytics: CoreAnalyticsMock(), - config: ConfigMock() + config: ConfigMock(), + corePersistence: CorePersistenceMock(), + connectivity: Connectivity() ) SettingsView(viewModel: vm) diff --git a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift index 1fe8ada15..98885f15c 100644 --- a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift +++ b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift @@ -70,6 +70,8 @@ public class SettingsViewModel: ObservableObject { let analytics: ProfileAnalytics let coreAnalytics: CoreAnalytics let config: ConfigProtocol + let corePersistence: CorePersistenceProtocol + let connectivity: ConnectivityProtocol public init( interactor: ProfileInteractorProtocol, @@ -77,7 +79,9 @@ public class SettingsViewModel: ObservableObject { router: ProfileRouter, analytics: ProfileAnalytics, coreAnalytics: CoreAnalytics, - config: ConfigProtocol + config: ConfigProtocol, + corePersistence: CorePersistenceProtocol, + connectivity: ConnectivityProtocol ) { self.interactor = interactor self.downloadManager = downloadManager @@ -85,6 +89,8 @@ public class SettingsViewModel: ObservableObject { self.analytics = analytics self.coreAnalytics = coreAnalytics self.config = config + self.corePersistence = corePersistence + self.connectivity = connectivity let userSettings = interactor.getSettings() self.userSettings = userSettings @@ -137,6 +143,7 @@ public class SettingsViewModel: ObservableObject { func logOut() async { try? await interactor.logOut() try? await downloadManager.cancelAllDownloading() + 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 a52565c19..3ad89ad78 100644 --- a/Profile/Profile/Presentation/Settings/VideoQualityView.swift +++ b/Profile/Profile/Presentation/Settings/VideoQualityView.swift @@ -132,7 +132,9 @@ struct VideoQualityView_Previews: PreviewProvider { router: router, analytics: ProfileAnalyticsMock(), coreAnalytics: CoreAnalyticsMock(), - config: ConfigMock() + config: ConfigMock(), + corePersistence: CorePersistenceMock(), + connectivity: Connectivity() ) VideoQualityView(viewModel: vm) diff --git a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift index 98e14ebb2..f54446cec 100644 --- a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift +++ b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift @@ -136,7 +136,9 @@ struct VideoSettingsView_Previews: PreviewProvider { router: router, analytics: ProfileAnalyticsMock(), coreAnalytics: CoreAnalyticsMock(), - config: ConfigMock() + config: ConfigMock(), + corePersistence: CorePersistenceMock(), + connectivity: Connectivity() ) VideoSettingsView(viewModel: vm) diff --git a/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift b/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift index b9c77c6eb..de7d52a4c 100644 --- a/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift @@ -37,7 +37,9 @@ final class SettingsViewModelTests: XCTestCase { router: router, analytics: analytics, coreAnalytics: coreAnalytics, - config: ConfigMock() + config: ConfigMock(), + corePersistence: CorePersistenceMock(), + connectivity: Connectivity() ) await viewModel.logOut() @@ -69,7 +71,9 @@ final class SettingsViewModelTests: XCTestCase { router: router, analytics: analytics, coreAnalytics: coreAnalytics, - config: ConfigMock() + config: ConfigMock(), + corePersistence: CorePersistenceMock(), + connectivity: Connectivity() ) viewModel.trackProfileVideoSettingsClicked() @@ -100,7 +104,9 @@ final class SettingsViewModelTests: XCTestCase { router: router, analytics: analytics, coreAnalytics: coreAnalytics, - config: ConfigMock() + config: ConfigMock(), + corePersistence: CorePersistenceMock(), + connectivity: Connectivity() ) viewModel.trackEmailSupportClicked() @@ -131,7 +137,9 @@ final class SettingsViewModelTests: XCTestCase { router: router, analytics: analytics, coreAnalytics: coreAnalytics, - config: ConfigMock() + config: ConfigMock(), + corePersistence: CorePersistenceMock(), + connectivity: Connectivity() ) viewModel.trackCookiePolicyClicked() @@ -162,7 +170,9 @@ final class SettingsViewModelTests: XCTestCase { router: router, analytics: analytics, coreAnalytics: coreAnalytics, - config: ConfigMock() + config: ConfigMock(), + corePersistence: CorePersistenceMock(), + connectivity: Connectivity() ) viewModel.trackPrivacyPolicyClicked() @@ -193,7 +203,9 @@ final class SettingsViewModelTests: XCTestCase { router: router, analytics: analytics, coreAnalytics: coreAnalytics, - config: ConfigMock() + config: ConfigMock(), + corePersistence: CorePersistenceMock(), + connectivity: Connectivity() ) viewModel.trackProfileEditClicked() diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 843268a30..ec514f18d 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -1466,6 +1466,611 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { } } +// MARK: - CorePersistenceProtocol + +open class CorePersistenceProtocolMock: CorePersistenceProtocol, 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 = [] } + } + + + + + + open func set(userId: Int) { + addInvocation(.m_set__userId_userId(Parameter.value(`userId`))) + let perform = methodPerformValue(.m_set__userId_userId(Parameter.value(`userId`))) as? (Int) -> Void + perform?(`userId`) + } + + open func getUserID() -> Int? { + addInvocation(.m_getUserID) + let perform = methodPerformValue(.m_getUserID) as? () -> Void + perform?() + var __value: Int? = nil + do { + __value = try methodReturnValue(.m_getUserID).casted() + } catch { + // do nothing + } + return __value + } + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func addToDownloadQueue(tasks: [DownloadDataTask]) { + addInvocation(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) as? ([DownloadDataTask]) -> Void + perform?(`tasks`) + } + + open func saveOfflineProgress(progress: OfflineProgress) { + addInvocation(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) + let perform = methodPerformValue(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) as? (OfflineProgress) -> Void + perform?(`progress`) + } + + open func loadProgress(for blockID: String) -> OfflineProgress? { + addInvocation(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + var __value: OfflineProgress? = nil + do { + __value = try methodReturnValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))).casted() + } catch { + // do nothing + } + return __value + } + + open func loadAllOfflineProgress() -> [OfflineProgress] { + addInvocation(.m_loadAllOfflineProgress) + let perform = methodPerformValue(.m_loadAllOfflineProgress) as? () -> Void + perform?() + var __value: [OfflineProgress] + do { + __value = try methodReturnValue(.m_loadAllOfflineProgress).casted() + } catch { + onFatalFailure("Stub return value not specified for loadAllOfflineProgress(). Use given") + Failure("Stub return value not specified for loadAllOfflineProgress(). Use given") + } + return __value + } + + open func deleteProgress(for blockID: String) { + addInvocation(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + } + + open func deleteAllProgress() { + addInvocation(.m_deleteAllProgress) + let perform = methodPerformValue(.m_deleteAllProgress) as? () -> Void + perform?() + } + + open func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) { + addInvocation(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) as? ([CourseBlock], DownloadQuality) -> Void + perform?(`blocks`, `downloadQuality`) + } + + open func nextBlockForDownloading() -> DownloadDataTask? { + addInvocation(.m_nextBlockForDownloading) + let perform = methodPerformValue(.m_nextBlockForDownloading) as? () -> Void + perform?() + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_nextBlockForDownloading).casted() + } catch { + // do nothing + } + return __value + } + + open func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) { + addInvocation(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) + let perform = methodPerformValue(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) as? (String, DownloadState, Data?) -> Void + perform?(`id`, `state`, `resumeData`) + } + + open func deleteDownloadDataTask(id: String) throws { + addInvocation(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) + let perform = methodPerformValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) as? (String) -> Void + perform?(`id`) + do { + _ = try methodReturnValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func saveDownloadDataTask(_ task: DownloadDataTask) { + addInvocation(.m_saveDownloadDataTask__task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_saveDownloadDataTask__task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) + } + + open func downloadDataTask(for blockId: String) -> DownloadDataTask? { + addInvocation(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + open func getDownloadDataTasks() -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasks) + let perform = methodPerformValue(.m_getDownloadDataTasks) as? () -> Void + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasks(). Use given") + Failure("Stub return value not specified for getDownloadDataTasks(). Use given") + } + return __value + } + + open func getDownloadDataTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + } + return __value + } + + + fileprivate enum MethodType { + case m_set__userId_userId(Parameter) + case m_getUserID + case m_publisher + case m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>) + case m_saveOfflineProgress__progress_progress(Parameter) + case m_loadProgress__for_blockID(Parameter) + case m_loadAllOfflineProgress + case m_deleteProgress__for_blockID(Parameter) + case m_deleteAllProgress + case m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>, Parameter) + case m_nextBlockForDownloading + case m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter, Parameter, Parameter) + case m_deleteDownloadDataTask__id_id(Parameter) + case m_saveDownloadDataTask__task(Parameter) + case m_downloadDataTask__for_blockId(Parameter) + case m_getDownloadDataTasks + case m_getDownloadDataTasksForCourse__courseId(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_set__userId_userId(let lhsUserid), .m_set__userId_userId(let rhsUserid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUserid, rhs: rhsUserid, with: matcher), lhsUserid, rhsUserid, "userId")) + return Matcher.ComparisonResult(results) + + case (.m_getUserID, .m_getUserID): return .match + + case (.m_publisher, .m_publisher): return .match + + case (.m_addToDownloadQueue__tasks_tasks(let lhsTasks), .m_addToDownloadQueue__tasks_tasks(let rhsTasks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTasks, rhs: rhsTasks, with: matcher), lhsTasks, rhsTasks, "tasks")) + return Matcher.ComparisonResult(results) + + case (.m_saveOfflineProgress__progress_progress(let lhsProgress), .m_saveOfflineProgress__progress_progress(let rhsProgress)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsProgress, rhs: rhsProgress, with: matcher), lhsProgress, rhsProgress, "progress")) + return Matcher.ComparisonResult(results) + + case (.m_loadProgress__for_blockID(let lhsBlockid), .m_loadProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_loadAllOfflineProgress, .m_loadAllOfflineProgress): return .match + + case (.m_deleteProgress__for_blockID(let lhsBlockid), .m_deleteProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_deleteAllProgress, .m_deleteAllProgress): return .match + + case (.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let lhsBlocks, let lhsDownloadquality), .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let rhsBlocks, let rhsDownloadquality)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDownloadquality, rhs: rhsDownloadquality, with: matcher), lhsDownloadquality, rhsDownloadquality, "downloadQuality")) + return Matcher.ComparisonResult(results) + + case (.m_nextBlockForDownloading, .m_nextBlockForDownloading): return .match + + case (.m_updateDownloadState__id_idstate_stateresumeData_resumeData(let lhsId, let lhsState, let lhsResumedata), .m_updateDownloadState__id_idstate_stateresumeData_resumeData(let rhsId, let rhsState, let rhsResumedata)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsState, rhs: rhsState, with: matcher), lhsState, rhsState, "state")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsResumedata, rhs: rhsResumedata, with: matcher), lhsResumedata, rhsResumedata, "resumeData")) + return Matcher.ComparisonResult(results) + + case (.m_deleteDownloadDataTask__id_id(let lhsId), .m_deleteDownloadDataTask__id_id(let rhsId)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + return Matcher.ComparisonResult(results) + + case (.m_saveDownloadDataTask__task(let lhsTask), .m_saveDownloadDataTask__task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "_ task")) + return Matcher.ComparisonResult(results) + + case (.m_downloadDataTask__for_blockId(let lhsBlockid), .m_downloadDataTask__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + + case (.m_getDownloadDataTasks, .m_getDownloadDataTasks): return .match + + case (.m_getDownloadDataTasksForCourse__courseId(let lhsCourseid), .m_getDownloadDataTasksForCourse__courseId(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "_ courseId")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_set__userId_userId(p0): return p0.intValue + case .m_getUserID: return 0 + case .m_publisher: return 0 + case let .m_addToDownloadQueue__tasks_tasks(p0): return p0.intValue + case let .m_saveOfflineProgress__progress_progress(p0): return p0.intValue + case let .m_loadProgress__for_blockID(p0): return p0.intValue + case .m_loadAllOfflineProgress: return 0 + case let .m_deleteProgress__for_blockID(p0): return p0.intValue + case .m_deleteAllProgress: return 0 + case let .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(p0, p1): return p0.intValue + p1.intValue + case .m_nextBlockForDownloading: return 0 + case let .m_updateDownloadState__id_idstate_stateresumeData_resumeData(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_deleteDownloadDataTask__id_id(p0): return p0.intValue + case let .m_saveDownloadDataTask__task(p0): return p0.intValue + case let .m_downloadDataTask__for_blockId(p0): return p0.intValue + case .m_getDownloadDataTasks: return 0 + case let .m_getDownloadDataTasksForCourse__courseId(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_set__userId_userId: return ".set(userId:)" + case .m_getUserID: return ".getUserID()" + case .m_publisher: return ".publisher()" + case .m_addToDownloadQueue__tasks_tasks: return ".addToDownloadQueue(tasks:)" + case .m_saveOfflineProgress__progress_progress: return ".saveOfflineProgress(progress:)" + case .m_loadProgress__for_blockID: return ".loadProgress(for:)" + case .m_loadAllOfflineProgress: return ".loadAllOfflineProgress()" + case .m_deleteProgress__for_blockID: return ".deleteProgress(for:)" + case .m_deleteAllProgress: return ".deleteAllProgress()" + case .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality: return ".addToDownloadQueue(blocks:downloadQuality:)" + case .m_nextBlockForDownloading: return ".nextBlockForDownloading()" + case .m_updateDownloadState__id_idstate_stateresumeData_resumeData: return ".updateDownloadState(id:state:resumeData:)" + case .m_deleteDownloadDataTask__id_id: return ".deleteDownloadDataTask(id:)" + case .m_saveDownloadDataTask__task: return ".saveDownloadDataTask(_:)" + case .m_downloadDataTask__for_blockId: return ".downloadDataTask(for:)" + case .m_getDownloadDataTasks: return ".getDownloadDataTasks()" + case .m_getDownloadDataTasksForCourse__courseId: return ".getDownloadDataTasksForCourse(_:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + 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 { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { + return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadAllOfflineProgress(willReturn: [OfflineProgress]...) -> MethodStub { + return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func nextBlockForDownloading(willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func downloadDataTask(for blockId: Parameter, willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getUserID(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Int?] = [] + let given: Given = { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Int?).self) + 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) })) }() + let stubber = given.stub(for: (OfflineProgress?).self) + willProduce(stubber) + return given + } + public static func loadAllOfflineProgress(willProduce: (Stubber<[OfflineProgress]>) -> Void) -> MethodStub { + let willReturn: [[OfflineProgress]] = [] + let given: Given = { return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([OfflineProgress]).self) + willProduce(stubber) + return given + } + public static func nextBlockForDownloading(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func downloadDataTask(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).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) })) + } + public static func deleteDownloadDataTask(id: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + 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)} + 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`))} + public static func loadAllOfflineProgress() -> Verify { return Verify(method: .m_loadAllOfflineProgress)} + public static func deleteProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_deleteProgress__for_blockID(`blockID`))} + public static func deleteAllProgress() -> Verify { return Verify(method: .m_deleteAllProgress)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`))} + public static func nextBlockForDownloading() -> Verify { return Verify(method: .m_nextBlockForDownloading)} + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter) -> Verify { return Verify(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`))} + public static func deleteDownloadDataTask(id: Parameter) -> Verify { return Verify(method: .m_deleteDownloadDataTask__id_id(`id`))} + public static func saveDownloadDataTask(_ task: Parameter) -> Verify { return Verify(method: .m_saveDownloadDataTask__task(`task`))} + public static func downloadDataTask(for blockId: Parameter) -> Verify { return Verify(method: .m_downloadDataTask__for_blockId(`blockId`))} + public static func getDownloadDataTasks() -> Verify { return Verify(method: .m_getDownloadDataTasks)} + public static func getDownloadDataTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func set(userId: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_set__userId_userId(`userId`), performs: perform) + } + public static func getUserID(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getUserID, performs: perform) + } + 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 { + return Perform(method: .m_addToDownloadQueue__tasks_tasks(`tasks`), performs: perform) + } + public static func saveOfflineProgress(progress: Parameter, perform: @escaping (OfflineProgress) -> Void) -> Perform { + return Perform(method: .m_saveOfflineProgress__progress_progress(`progress`), performs: perform) + } + public static func loadProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_loadProgress__for_blockID(`blockID`), performs: perform) + } + public static func loadAllOfflineProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_loadAllOfflineProgress, performs: perform) + } + public static func deleteProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteProgress__for_blockID(`blockID`), performs: perform) + } + public static func deleteAllProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllProgress, performs: perform) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter, perform: @escaping ([CourseBlock], DownloadQuality) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`), performs: perform) + } + public static func nextBlockForDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_nextBlockForDownloading, performs: perform) + } + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter, perform: @escaping (String, DownloadState, Data?) -> Void) -> Perform { + return Perform(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`), performs: perform) + } + public static func deleteDownloadDataTask(id: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteDownloadDataTask__id_id(`id`), performs: perform) + } + public static func saveDownloadDataTask(_ task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_saveDownloadDataTask__task(`task`), performs: perform) + } + public static func downloadDataTask(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_downloadDataTask__for_blockId(`blockId`), performs: perform) + } + public static func getDownloadDataTasks(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasks, performs: perform) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), 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) + } +} + // MARK: - DownloadManagerProtocol open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { @@ -1661,6 +2266,20 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] { + addInvocation(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) + let perform = methodPerformValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) as? ([CourseSequential]) -> Void + perform?(`sequentials`) + var __value: [CourseSequential] + do { + __value = try methodReturnValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))).casted() + } catch { + onFatalFailure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + Failure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + } + return __value + } + open func resumeDownloading() throws { addInvocation(.m_resumeDownloading) let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void @@ -1708,6 +2327,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) + case m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case m_removeAppSupportDirectoryUnusedContent @@ -1761,6 +2381,11 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) return Matcher.ComparisonResult(results) + case (.m_updateUnzippedFileSize__for_sequentials(let lhsSequentials), .m_updateUnzippedFileSize__for_sequentials(let rhsSequentials)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSequentials, rhs: rhsSequentials, with: matcher), lhsSequentials, rhsSequentials, "for sequentials")) + return Matcher.ComparisonResult(results) + case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): @@ -1788,6 +2413,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case let .m_deleteFile__blocks_blocks(p0): return p0.intValue case .m_deleteAllFiles: return 0 case let .m_fileUrl__for_blockId(p0): return p0.intValue + case let .m_updateUnzippedFileSize__for_sequentials(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .m_removeAppSupportDirectoryUnusedContent: return 0 @@ -1808,6 +2434,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + case .m_updateUnzippedFileSize__for_sequentials: return ".updateUnzippedFileSize(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" @@ -1843,6 +2470,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willReturn: [CourseSequential]...) -> MethodStub { + return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } 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) })) } @@ -1881,6 +2511,13 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willProduce: (Stubber<[CourseSequential]>) -> Void) -> MethodStub { + let willReturn: [[CourseSequential]] = [] + let given: Given = { return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseSequential]).self) + willProduce(stubber) + return given + } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [Bool] = [] let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -1965,6 +2602,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} public static func deleteAllFiles() -> Verify { return Verify(method: .m_deleteAllFiles)} public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>) -> Verify { return Verify(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`))} public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} public static func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} @@ -2011,6 +2649,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, perform: @escaping ([CourseSequential]) -> Void) -> Perform { + return Perform(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), performs: perform) + } public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_resumeDownloading, performs: perform) } @@ -2095,6 +2736,205 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } +// MARK: - OfflineSyncInteractorProtocol + +open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, 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 = [] } + } + + + + + + open func submitOfflineProgress(courseID: String, blockID: String, data: String) throws -> Bool { + addInvocation(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) + let perform = methodPerformValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) as? (String, String, String) -> Void + perform?(`courseID`, `blockID`, `data`) + var __value: Bool + do { + __value = try methodReturnValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + Failure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + } catch { + throw error + } + return __value + } + + + fileprivate enum MethodType { + case m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter, Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let lhsCourseid, let lhsBlockid, let lhsData), .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let rhsCourseid, let rhsBlockid, let rhsData)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "blockID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsData, rhs: rhsData, with: matcher), lhsData, rhsData, "data")) + return Matcher.ComparisonResult(results) + } + } + + func intValue() -> Int { + switch self { + case let .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + } + } + func assertionName() -> String { + switch self { + case .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data: return ".submitOfflineProgress(courseID:blockID:data:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Bool).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter) -> Verify { return Verify(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { + return Perform(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), 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) + } +} + // MARK: - ProfileAnalytics open class ProfileAnalyticsMock: ProfileAnalytics, Mock { diff --git a/Theme/Theme/Assets.xcassets/Colors/Background.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/Background.colorset/Contents.json index 8fef18d07..df1a5f141 100644 --- a/Theme/Theme/Assets.xcassets/Colors/Background.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/Background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.184", - "green" : "0.129", - "red" : "0.098" + "blue" : "0x2E", + "green" : "0x20", + "red" : "0x18" } }, "idiom" : "universal" diff --git a/Theme/Theme/Assets.xcassets/Colors/CourseDates/NextWeekTimelineColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CourseDates/NextWeekTimelineColor.colorset/Contents.json index 5cd29db93..d7638b312 100644 --- a/Theme/Theme/Assets.xcassets/Colors/CourseDates/NextWeekTimelineColor.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/CourseDates/NextWeekTimelineColor.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.733", - "green" : "0.647", - "red" : "0.592" + "blue" : "0xBA", + "green" : "0xA4", + "red" : "0x96" } }, "idiom" : "universal" diff --git a/Theme/Theme/Assets.xcassets/Colors/CourseDates/UpcomingTimelineColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CourseDates/UpcomingTimelineColor.colorset/Contents.json index 2af3cc3c3..34275d32c 100644 --- a/Theme/Theme/Assets.xcassets/Colors/CourseDates/UpcomingTimelineColor.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/CourseDates/UpcomingTimelineColor.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.878", - "green" : "0.831", - "red" : "0.800" + "blue" : "0xDF", + "green" : "0xD3", + "red" : "0xCC" } }, "idiom" : "universal" diff --git a/Theme/Theme/Assets.xcassets/Colors/StyledButton/disabledButton.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/StyledButton/disabledButton.colorset/Contents.json new file mode 100644 index 000000000..1d2b5d427 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/StyledButton/disabledButton.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE0", + "green" : "0xD4", + "red" : "0xCC" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xAE", + "green" : "0x9B", + "red" : "0x8E" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/StyledButton/disabledButtonText.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/StyledButton/disabledButtonText.colorset/Contents.json new file mode 100644 index 000000000..3c2cd067c --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/StyledButton/disabledButtonText.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x64", + "green" : "0x49", + "red" : "0x3D" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2F", + "green" : "0x21", + "red" : "0x19" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/TextInput/TextInputUnfocusedStroke.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/TextInput/TextInputUnfocusedStroke.colorset/Contents.json index 432fab345..b0ae672b6 100644 --- a/Theme/Theme/Assets.xcassets/Colors/TextInput/TextInputUnfocusedStroke.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/TextInput/TextInputUnfocusedStroke.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.878", - "green" : "0.831", - "red" : "0.800" + "blue" : "0xDF", + "green" : "0xD3", + "red" : "0xCC" } }, "idiom" : "universal" diff --git a/Theme/Theme/Assets.xcassets/Colors/shade.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/shade.colorset/Contents.json new file mode 100644 index 000000000..0e22f05c2 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/shade.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFB", + "green" : "0xFA", + "red" : "0xF9" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2E", + "green" : "0x20", + "red" : "0x18" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/SwiftGen/ThemeAssets.swift b/Theme/Theme/SwiftGen/ThemeAssets.swift index 5a9ed5fe8..c7c49e9b2 100644 --- a/Theme/Theme/SwiftGen/ThemeAssets.swift +++ b/Theme/Theme/SwiftGen/ThemeAssets.swift @@ -67,6 +67,8 @@ public enum ThemeAssets { public static let snackbarTextColor = ColorAsset(name: "SnackbarTextColor") public static let snackbarWarningColor = ColorAsset(name: "SnackbarWarningColor") public static let styledButtonText = ColorAsset(name: "StyledButtonText") + public static let disabledButton = ColorAsset(name: "disabledButton") + public static let disabledButtonText = ColorAsset(name: "disabledButtonText") public static let success = ColorAsset(name: "Success") public static let tabbarColor = ColorAsset(name: "TabbarColor") public static let textPrimary = ColorAsset(name: "TextPrimary") @@ -80,6 +82,7 @@ public enum ThemeAssets { public static let textInputUnfocusedStroke = ColorAsset(name: "TextInputUnfocusedStroke") public static let toggleSwitchColor = ColorAsset(name: "ToggleSwitchColor") public static let navigationBarTintColor = ColorAsset(name: "navigationBarTintColor") + public static let shade = ColorAsset(name: "shade") public static let warning = ColorAsset(name: "warning") public static let warningText = ColorAsset(name: "warningText") public static let white = ColorAsset(name: "white") diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index 50fa75878..ddff37c66 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -37,6 +37,8 @@ public struct Theme { 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 @@ -72,6 +74,7 @@ public struct Theme { 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 static func update( From a183f183ef6923fd69a5b39fa7c3d5a89312c553 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 30 Sep 2024 10:19:26 +0200 Subject: [PATCH 2/6] fix: BugFix for PrimaryEnrollment data (#516) * Merge pull request #22 from edx/fix/PrimaryEnrollment_data fix: quick fix for primary enrolment data * chore: deleted unused parameter for init --------- Co-authored-by: Vadim Kuznetsov Co-authored-by: Anton Yarmolenko <37253+rnr@users.noreply.github.com> --- Core/Core/Data/Model/Data_PrimaryEnrollment.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Core/Core/Data/Model/Data_PrimaryEnrollment.swift b/Core/Core/Data/Model/Data_PrimaryEnrollment.swift index 60764c78a..cbf70fc81 100644 --- a/Core/Core/Data/Model/Data_PrimaryEnrollment.swift +++ b/Core/Core/Data/Model/Data_PrimaryEnrollment.swift @@ -28,7 +28,7 @@ public extension DataLayer { // MARK: - Primary struct ActiveEnrollment: Codable { - public let auditAccessExpires: Date? + public let auditAccessExpires: String? public let created: String? public let mode: String? public let isActive: Bool? @@ -53,7 +53,7 @@ public extension DataLayer { } public init( - auditAccessExpires: Date?, + auditAccessExpires: String?, created: String?, mode: String?, isActive: Bool?, From 61b3e37adad7965dbb0440c3930e262c92dbe9f3 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 30 Sep 2024 10:19:47 +0200 Subject: [PATCH 3/6] fix: picker crash when no elements (#63) (#517) Co-authored-by: Vadim Kuznetsov --- Core/Core/View/Base/PickerMenu.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Core/Core/View/Base/PickerMenu.swift b/Core/Core/View/Base/PickerMenu.swift index 0de023381..cd3d6c49e 100644 --- a/Core/Core/View/Base/PickerMenu.swift +++ b/Core/Core/View/Base/PickerMenu.swift @@ -33,6 +33,7 @@ public struct PickerMenu: View { private let router: BaseRouter private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } private var selected: ((PickerItem) -> Void) = { _ in } + private let emptyKey: String = "--empty--" public init( items: [PickerItem], @@ -50,18 +51,19 @@ public struct PickerMenu: View { private var filteredItems: [PickerItem] { if search.isEmpty { - return items + return items.isEmpty ? [PickerItem(key: emptyKey, value: "")] : items } else { - return items.filter { $0.value.localizedCaseInsensitiveContains(search) } + let filteredItems = items.filter { $0.value.localizedCaseInsensitiveContains(search) } + return filteredItems.isEmpty ? [PickerItem(key: emptyKey, value: "")] : filteredItems } } private var isSingleSelection: Bool { - return filteredItems.count == 1 + return filteredItems.count == 1 && filteredItems.first?.key != emptyKey } private var isItemSelected: Bool { - return filteredItems.contains(selectedItem) + return filteredItems.contains(selectedItem) && selectedItem.key != emptyKey } private var acceptButtonDisabled: Bool { From 8dbdcb209edce8d3a042c390f050f384449642d0 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 30 Sep 2024 10:20:23 +0200 Subject: [PATCH 4/6] fix: fix iPad crash of alert controller (#74) (#521) Co-authored-by: Saeed Bashir --- Core/Core/View/Base/Webview/WebView.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Core/Core/View/Base/Webview/WebView.swift b/Core/Core/View/Base/Webview/WebView.swift index 78d974b29..f23097445 100644 --- a/Core/Core/View/Base/Webview/WebView.swift +++ b/Core/Core/View/Base/Webview/WebView.swift @@ -136,6 +136,17 @@ public struct WebView: UIViewRepresentable { handler: { _ in completionHandler(false) })) + + if let presenter = alertController.popoverPresentationController { + let view = UIApplication.topViewController()?.view + presenter.sourceView = view + presenter.sourceRect = CGRect( + x: view?.bounds.midX ?? 0, + y: view?.bounds.midY ?? 0, + width: 0, + height: 0 + ) + } UIApplication.topViewController()?.present(alertController, animated: true, completion: nil) } From d5a55fac59d771eb6d07576eae5a995cf2b3aad3 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 30 Sep 2024 10:21:03 +0200 Subject: [PATCH 5/6] fix: video crashes (#70) (#520) Co-authored-by: Saeed Bashir --- Course/Course/Data/CourseRepository.swift | 2 +- Course/Course/Domain/CourseInteractor.swift | 10 ++++++++-- .../Presentation/Video/PlayerTrackerProtocol.swift | 8 +++++++- .../Presentation/Video/VideoPlayerViewModel.swift | 2 +- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index c068f4722..5da361928 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -256,7 +256,7 @@ public class CourseRepository: CourseRepositoryProtocol { } private func parseVideo(encodedVideo: DataLayer.EncodedVideoData?) -> CourseBlockVideo? { - guard let encodedVideo else { + guard let encodedVideo, encodedVideo.url?.isEmpty == false else { return nil } return .init( diff --git a/Course/Course/Domain/CourseInteractor.swift b/Course/Course/Domain/CourseInteractor.swift index e4498d024..42f6074f4 100644 --- a/Course/Course/Domain/CourseInteractor.swift +++ b/Course/Course/Domain/CourseInteractor.swift @@ -216,9 +216,15 @@ public class CourseInteractor: CourseInteractorProtocol { let endTime = startAndEndTimes.last ?? "00:00:00,000" let text = lines[2.. endTimeInverval { + endTimeInverval = startTimeInterval + } + let subtitle = Subtitle(id: id, - fromTo: DateInterval(start: Date(subtitleTime: startTime), - end: Date(subtitleTime: endTime)), + fromTo: DateInterval(start: startTimeInterval, + end: endTimeInverval), text: text.decodedHTMLEntities()) subtitles.append(subtitle) } diff --git a/Course/Course/Presentation/Video/PlayerTrackerProtocol.swift b/Course/Course/Presentation/Video/PlayerTrackerProtocol.swift index 4487bab29..775f8e0d6 100644 --- a/Course/Course/Presentation/Video/PlayerTrackerProtocol.swift +++ b/Course/Course/Presentation/Video/PlayerTrackerProtocol.swift @@ -111,7 +111,13 @@ public class PlayerTracker: PlayerTrackerProtocol { item = AVPlayerItem(url: url) } self.player = AVPlayer(playerItem: item) - timePublisher = CurrentValueSubject(player?.currentTime().seconds ?? 0) + + var playerTime = player?.currentTime().seconds ?? 0.0 + if playerTime.isNaN == true { + playerTime = 0.0 + } + + timePublisher = CurrentValueSubject(playerTime) ratePublisher = CurrentValueSubject(player?.rate ?? 0) finishPublisher = PassthroughSubject() readyPublisher = PassthroughSubject() diff --git a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift index 8e95a31f0..5014596da 100644 --- a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift @@ -103,7 +103,7 @@ public class VideoPlayerViewModel: ObservableObject { subtitles = result } catch { - print(">>>>> ⛔️⛔️⛔️⛔️⛔️⛔️⛔️⛔️", error) + debugLog(">>>>> ⛔️⛔️⛔️⛔️⛔️⛔️⛔️⛔️", error) } } From e85e9704fadfbb25d5ceea7d4797949e8cd0001d Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 30 Sep 2024 10:22:32 +0200 Subject: [PATCH 6/6] fix: fix coredata crash on primary course (#64) (#518) Co-authored-by: Saeed Bashir --- OpenEdX/Data/DashboardPersistence.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenEdX/Data/DashboardPersistence.swift b/OpenEdX/Data/DashboardPersistence.swift index ab0f14e52..0a55aeaf7 100644 --- a/OpenEdX/Data/DashboardPersistence.swift +++ b/OpenEdX/Data/DashboardPersistence.swift @@ -231,7 +231,7 @@ public class DashboardPersistence: DashboardPersistenceProtocol { // swiftlint:enable function_body_length func clearOldEnrollmentsData() { - context.perform {[context] in + context.performAndWait {[context] in let fetchRequest1: NSFetchRequest = CDDashboardCourse.fetchRequest() let batchDeleteRequest1 = NSBatchDeleteRequest(fetchRequest: fetchRequest1)