diff --git a/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents index cd49e4370..2fd252f0d 100644 --- a/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -1,6 +1,7 @@ + @@ -11,6 +12,7 @@ + diff --git a/Core/Core/Data/Persistence/CorePersistenceProtocol.swift b/Core/Core/Data/Persistence/CorePersistenceProtocol.swift index 3a577ec50..3a6ec2acf 100644 --- a/Core/Core/Data/Persistence/CorePersistenceProtocol.swift +++ b/Core/Core/Data/Persistence/CorePersistenceProtocol.swift @@ -9,12 +9,14 @@ import CoreData import Combine public protocol CorePersistenceProtocol { + func set(userId: Int) + func getUserID() -> Int? func publisher() -> AnyPublisher func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) - func getNextBlockForDownloading() -> DownloadDataTask? + func nextBlockForDownloading() -> DownloadDataTask? func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) func deleteDownloadDataTask(id: String) throws - func saveDownloadDataTask(data: DownloadDataTask) + func saveDownloadDataTask(_ task: DownloadDataTask) func downloadDataTask(for blockId: String) -> DownloadDataTask? func downloadDataTask(for blockId: String, completion: @escaping (DownloadDataTask?) -> Void) func getDownloadDataTasks(completion: @escaping ([DownloadDataTask]) -> Void) diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index 4c2e6879f..33a322168 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -33,6 +33,8 @@ public enum DownloadType: String { public struct DownloadDataTask: Identifiable, Hashable { public let id: String public let courseId: String + public let blockId: String + public let userId: Int public let url: String public let fileName: String public let displayName: String @@ -52,7 +54,9 @@ public struct DownloadDataTask: Identifiable, Hashable { public init( id: String, + blockId: String, courseId: String, + userId: Int, url: String, fileName: String, displayName: String, @@ -64,6 +68,8 @@ public struct DownloadDataTask: Identifiable, Hashable { ) { self.id = id self.courseId = courseId + self.blockId = blockId + self.userId = userId self.url = url self.fileName = fileName self.displayName = displayName @@ -73,6 +79,21 @@ public struct DownloadDataTask: Identifiable, Hashable { self.type = type self.fileSize = fileSize } + + public init(sourse: CDDownloadData) { + self.id = sourse.id ?? "" + self.blockId = sourse.blockId ?? "" + self.courseId = sourse.courseId ?? "" + self.userId = Int(sourse.userId) + self.url = sourse.url ?? "" + self.fileName = sourse.fileName ?? "" + self.displayName = sourse.displayName ?? "" + self.progress = sourse.progress + self.resumeData = sourse.resumeData + self.state = DownloadState(rawValue: sourse.state ?? "") ?? .waiting + self.type = DownloadType(rawValue: sourse.type ?? "") ?? .video + self.fileSize = Int(sourse.fileSize) + } } public class NoWiFiError: LocalizedError { @@ -138,6 +159,9 @@ public class DownloadManager: DownloadManagerProtocol { connectivity: ConnectivityProtocol ) { self.persistence = persistence + if let userId = appStorage.user?.id { + self.persistence.set(userId: userId) + } self.appStorage = appStorage self.connectivity = connectivity self.backgroundTask() @@ -202,7 +226,10 @@ public class DownloadManager: DownloadManagerProtocol { downloadRequest?.cancel() let downloaded = await getDownloadTasksForCourse(courseId).filter { $0.state == .finished } - let blocksForDelete = blocks.filter { block in downloaded.first(where: { $0.id == block.id }) == nil } + let blocksForDelete = blocks + .filter { + block in downloaded.first(where: { $0.blockId == block.id }) == nil + } await deleteFile(blocks: blocksForDelete) downloaded.forEach { @@ -226,11 +253,11 @@ public class DownloadManager: DownloadManagerProtocol { } public func cancelDownloading(courseId: String) async throws { - let downloads = await getDownloadTasksForCourse(courseId) - for downloadData in downloads { + let tasks = await getDownloadTasksForCourse(courseId) + for task in tasks { do { - try persistence.deleteDownloadDataTask(id: downloadData.id) - if let fileUrl = await fileUrl(for: downloadData.id) { + try persistence.deleteDownloadDataTask(id: task.id) + if let fileUrl = await fileUrl(for: task.id) { try FileManager.default.removeItem(at: fileUrl) } } catch { @@ -297,7 +324,7 @@ public class DownloadManager: DownloadManagerProtocol { guard userCanDownload() else { throw NoWiFiError() } - guard let downloadTask = persistence.getNextBlockForDownloading() else { + guard let downloadTask = persistence.nextBlockForDownloading() else { isDownloadingInProgress = false return } @@ -394,8 +421,8 @@ public class DownloadManager: DownloadManagerProtocol { lazy var videosFolderUrl: URL? = { let documentDirectoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - let directoryURL = documentDirectoryURL.appendingPathComponent("Files", isDirectory: true) - + let directoryURL = documentDirectoryURL.appendingPathComponent(folderPathComponent, isDirectory: true) + if FileManager.default.fileExists(atPath: directoryURL.path) { return URL(fileURLWithPath: directoryURL.path) } else { @@ -413,6 +440,13 @@ public class DownloadManager: DownloadManagerProtocol { } }() + private var folderPathComponent: String { + if let id = appStorage.user?.id { + return "\(id)_Files" + } + return "Files" + } + private func saveFile(fileName: String, data: Data, folderURL: URL) { let fileURL = folderURL.appendingPathComponent(fileName) do { @@ -522,7 +556,9 @@ public class DownloadManagerMock: DownloadManagerProtocol { .canceled( .init( id: "", + blockId: "", courseId: "", + userId: 0, url: "", fileName: "", displayName: "", diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index ce5463cd9..c26435d06 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -349,7 +349,7 @@ 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.id == block.id }) { + if let download = courseDownloadTasks.first(where: { $0.blockId == block.id }) { switch download.state { case .waiting, .inProgress: sequentialsChilds.append(.downloading) diff --git a/OpenEdX/Data/CorePersistence.swift b/OpenEdX/Data/CorePersistence.swift index 1070011dd..22df01f65 100644 --- a/OpenEdX/Data/CorePersistence.swift +++ b/OpenEdX/Data/CorePersistence.swift @@ -12,74 +12,68 @@ import Combine public class CorePersistence: CorePersistenceProtocol { - private var context: NSManagedObjectContext + // MARK: - Predicate - public init(context: NSManagedObjectContext) { - self.context = context - } + enum CDPredicate { + case id(String) + case courseId(String) + case state(String) - public func publisher() -> AnyPublisher { - let notification = NSManagedObjectContext.didChangeObjectsNotification - return NotificationCenter.default.publisher(for: notification, object: context) - .compactMap({ notification in - guard let userInfo = notification.userInfo else { return nil } + var predicate: NSPredicate { + switch self { + case let .id(args): + NSPredicate(format: "id = %@", args) + case .courseId(let args): + NSPredicate(format: "courseId = %@", args) + case .state(let args): + NSPredicate(format: "state != %@", args) + } + } + } - if let inserts = userInfo[NSInsertedObjectsKey] as? Set, inserts.count > 0 { - return inserts.count - } + // MARK: - Properties - if let updates = userInfo[NSUpdatedObjectsKey] as? Set, updates.count > 0 { - return updates.count - } + private var context: NSManagedObjectContext + private var userId: Int? - if let deletes = userInfo[NSDeletedObjectsKey] as? Set, deletes.count > 0 { - return deletes.count - } + public init(context: NSManagedObjectContext) { + self.context = context + } - return nil - }) - .eraseToAnyPublisher() + public func set(userId: Int) { + self.userId = userId } - public func getDownloadDataTasks(completion: @escaping ([DownloadDataTask]) -> Void) { - context.performAndWait { - let request = CDDownloadData.fetchRequest() - guard let downloadData = try? context.fetch(request) else { - completion([]) - return - } - let downloads = downloadData.map { - DownloadDataTask( - id: $0.id ?? "", - courseId: $0.courseId ?? "", - url: $0.url ?? "", - fileName: $0.fileName ?? "", - displayName: $0.displayName ?? "", - progress: $0.progress, - resumeData: $0.resumeData, - state: DownloadState(rawValue: $0.state ?? "") ?? .waiting, - type: DownloadType(rawValue: $0.type ?? "") ?? .video, - fileSize: Int($0.fileSize) - ) - } - completion(downloads) - } + public func getUserID() -> Int? { + userId } - public func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) { + // MARK: - Public Intents + + public func addToDownloadQueue( + blocks: [CourseBlock], + downloadQuality: DownloadQuality + ) { for block in blocks { - let request = CDDownloadData.fetchRequest() - request.predicate = NSPredicate(format: "id = %@", block.id) - guard (try? context.fetch(request).first) == nil else { continue } + let downloadDataId = downloadDataId(from: block.id) + + let data = try? fetchCDDownloadData( + predicate: CDPredicate.id(downloadDataId) + ) + guard data?.first == nil else { continue } + guard let video = block.encodedVideo?.video(downloadQuality: downloadQuality), let url = video.url, let fileExtension = URL(string: url)?.pathExtension else { continue } + let fileName = "\(block.id).\(fileExtension)" context.performAndWait { let newDownloadData = CDDownloadData(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - newDownloadData.id = block.id + newDownloadData.id = downloadDataId + newDownloadData.blockId = block.id + newDownloadData.userId = getUserId32() ?? 0 newDownloadData.courseId = block.courseId newDownloadData.url = url newDownloadData.fileName = fileName @@ -93,101 +87,105 @@ public class CorePersistence: CorePersistenceProtocol { } } - public func getNextBlockForDownloading() -> DownloadDataTask? { - let request = CDDownloadData.fetchRequest() - request.predicate = NSPredicate(format: "state != %@", DownloadState.finished.rawValue) - request.fetchLimit = 1 - guard let data = try? context.fetch(request).first else { return nil } - return DownloadDataTask( - id: data.id ?? "", - courseId: data.courseId ?? "", - url: data.url ?? "", - fileName: data.fileName ?? "", - displayName: data.displayName ?? "", - progress: data.progress, - resumeData: data.resumeData, - state: DownloadState(rawValue: data.state ?? "") ?? .waiting, - type: DownloadType(rawValue: data.type ?? "" ) ?? .video, - fileSize: Int(data.fileSize) - ) + public func getDownloadDataTasks(completion: @escaping ([DownloadDataTask]) -> Void) { + context.performAndWait { + guard let data = try? fetchCDDownloadData() else { + completion([]) + return + } + + let downloads = data.downloadDataTasks() + + completion(downloads) + } } - public func getDownloadDataTasksForCourse(_ courseId: String, completion: @escaping ([DownloadDataTask]) -> Void) { + public func getDownloadDataTasksForCourse( + _ courseId: String, + completion: @escaping ([DownloadDataTask]) -> Void + ) { context.performAndWait { - let request = CDDownloadData.fetchRequest() - request.predicate = NSPredicate(format: "courseId = %@", courseId) - guard let downloadData = try? context.fetch(request) else { + guard let data = try? fetchCDDownloadData( + predicate: .courseId(courseId) + ) else { completion([]) return } - let downloads = downloadData.map { - DownloadDataTask( - id: $0.id ?? "", - courseId: $0.courseId ?? "", - url: $0.url ?? "", - fileName: $0.fileName ?? "", - displayName: $0.displayName ?? "", - progress: $0.progress, - resumeData: $0.resumeData, - state: DownloadState(rawValue: $0.state ?? "") ?? .waiting, - type: DownloadType(rawValue: $0.type ?? "") ?? .video, - fileSize: Int($0.fileSize) - ) + + if data.isEmpty { + completion([]) + return } + + let downloads = data + .downloadDataTasks() + .filter(userId: userId) + completion(downloads) } } - public func downloadDataTask(for blockId: String, completion: @escaping (DownloadDataTask?) -> Void) { + public func downloadDataTask( + for blockId: String, + completion: @escaping (DownloadDataTask?) -> Void + ) { context.performAndWait { - let request = CDDownloadData.fetchRequest() - request.predicate = NSPredicate(format: "id = %@", blockId) - guard let downloadData = try? context.fetch(request).first else { + let data = try? fetchCDDownloadData( + predicate: .id(downloadDataId(from: blockId)) + ) + + guard let downloadData = data?.first else { completion(nil) return } - let data = DownloadDataTask( - id: downloadData.id ?? "", - courseId: downloadData.courseId ?? "", - url: downloadData.url ?? "", - fileName: downloadData.fileName ?? "", - displayName: downloadData.displayName ?? "", - progress: downloadData.progress, - resumeData: downloadData.resumeData, - state: DownloadState(rawValue: downloadData.state ?? "") ?? .waiting, - type: DownloadType(rawValue: downloadData.type ?? "" ) ?? .video, - fileSize: Int(downloadData.fileSize) - ) - completion(data) + + let downloadDataTask = DownloadDataTask(sourse: downloadData) + + completion(downloadDataTask) } } public func downloadDataTask(for blockId: String) -> DownloadDataTask? { - let request = CDDownloadData.fetchRequest() - request.predicate = NSPredicate(format: "id = %@", blockId) - guard let downloadData = try? context.fetch(request).first else { return nil } - return DownloadDataTask( - id: downloadData.id ?? "", - courseId: downloadData.courseId ?? "", - url: downloadData.url ?? "", - fileName: downloadData.fileName ?? "", - displayName: downloadData.displayName ?? "", - progress: downloadData.progress, - resumeData: downloadData.resumeData, - state: DownloadState(rawValue: downloadData.state ?? "") ?? .waiting, - type: DownloadType(rawValue: downloadData.type ?? "" ) ?? .video, - fileSize: Int(downloadData.fileSize) + let data = try? fetchCDDownloadData( + predicate: .id(downloadDataId(from: blockId)) + ) + + guard let downloadData = data?.first else { return nil } + + return DownloadDataTask(sourse: downloadData) + } + + public func nextBlockForDownloading() -> DownloadDataTask? { + let data = try? fetchCDDownloadData( + predicate: .state(DownloadState.finished.rawValue), + fetchLimit: 1 ) + + guard let downloadData = data?.first else { + return nil + } + + return DownloadDataTask(sourse: downloadData) } - public func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) { + public func updateDownloadState( + id: String, + state: DownloadState, + resumeData: Data? + ) { context.performAndWait { - let request = CDDownloadData.fetchRequest() - request.predicate = NSPredicate(format: "id = %@", id) - guard let downloadData = try? context.fetch(request).first else { return } - downloadData.state = state.rawValue - if state == .finished { downloadData.progress = 1 } - downloadData.resumeData = resumeData + guard let data = try? fetchCDDownloadData( + predicate: .id(downloadDataId(from: id)) + ) else { + return + } + + guard let task = data.first else { return } + + task.state = state.rawValue + if state == .finished { task.progress = 1 } + task.resumeData = resumeData + do { try context.save() } catch { @@ -198,33 +196,39 @@ public class CorePersistence: CorePersistenceProtocol { public func deleteDownloadDataTask(id: String) throws { context.performAndWait { - let request = CDDownloadData.fetchRequest() - request.predicate = NSPredicate(format: "id = %@", id) do { - let records = try context.fetch(request) + let records = try fetchCDDownloadData( + predicate: .id(downloadDataId(from: id)) + ) + for record in records { context.delete(record) try context.save() debugLog("File erased successfully") } + } catch { debugLog("Error fetching records: \(error.localizedDescription)") } } } - public func saveDownloadDataTask(data: DownloadDataTask) { + public func saveDownloadDataTask(_ task: DownloadDataTask) { context.performAndWait { let newDownloadData = CDDownloadData(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - newDownloadData.id = data.id - newDownloadData.courseId = data.courseId - newDownloadData.url = data.url - newDownloadData.progress = data.progress - newDownloadData.fileName = data.fileName - newDownloadData.resumeData = data.resumeData - newDownloadData.state = data.state.rawValue - newDownloadData.fileSize = Int32(data.fileSize) + newDownloadData.id = task.id + newDownloadData.blockId = task.blockId + newDownloadData.userId = Int32(task.userId) + newDownloadData.courseId = task.courseId + newDownloadData.url = task.url + newDownloadData.progress = task.progress + newDownloadData.fileName = task.fileName + newDownloadData.displayName = task.displayName + newDownloadData.resumeData = task.resumeData + newDownloadData.state = task.state.rawValue + newDownloadData.type = task.type.rawValue + newDownloadData.fileSize = Int32(task.fileSize) do { try context.save() @@ -233,4 +237,84 @@ public class CorePersistence: CorePersistenceProtocol { } } } + + public func publisher() -> AnyPublisher { + let notification = NSManagedObjectContext.didChangeObjectsNotification + return NotificationCenter.default.publisher(for: notification, object: context) + .compactMap({ notification in + guard let userInfo = notification.userInfo else { return nil } + + if let inserts = userInfo[NSInsertedObjectsKey] as? Set, inserts.count > 0 { + return inserts.count + } + + if let updates = userInfo[NSUpdatedObjectsKey] as? Set, updates.count > 0 { + return updates.count + } + + if let deletes = userInfo[NSDeletedObjectsKey] as? Set, deletes.count > 0 { + return deletes.count + } + + return nil + }) + .eraseToAnyPublisher() + } + + // MARK: - Private Intents + + private func fetchCDDownloadData( + predicate: CDPredicate? = nil, + fetchLimit: Int? = nil + ) throws -> [CDDownloadData] { + let request = CDDownloadData.fetchRequest() + if let predicate = predicate { + request.predicate = predicate.predicate + } + if let fetchLimit = fetchLimit { + request.fetchLimit = fetchLimit + } + let data = try context.fetch(request).filter { + guard let userId = getUserId32() else { + return true + } + debugLog(userId, "-userId-") + return $0.userId == userId + } + return data + } + + private func getUserId32() -> Int32? { + guard let userId else { + return nil + } + return Int32(userId) + } + + private func downloadDataId(from id: String) -> String { + guard let userId else { + return id + } + if id.contains(String(userId)) { + return id + } + return "\(userId)_\(id)" + } +} + +extension Array where Element == DownloadDataTask { + func filter(userId: Int?) -> [DownloadDataTask] { + filter { + guard let userId else { + return true + } + return $0.userId == userId + } + } +} + +extension Array where Element == CDDownloadData { + func downloadDataTasks() -> [DownloadDataTask] { + compactMap { DownloadDataTask(sourse: $0) } + } } diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index adc46d1f6..228a72e67 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -60,16 +60,22 @@ public class Router: AuthorizationRouter, public func showMainOrWhatsNewScreen(sourceScreen: LogistrationSourceScreen) { showToolBar() - var storage = Container.shared.resolve(WhatsNewStorage.self)! + var whatsNewStorage = Container.shared.resolve(WhatsNewStorage.self)! let config = Container.shared.resolve(ConfigProtocol.self)! + let persistence = Container.shared.resolve(CorePersistenceProtocol.self)! + let coreStorage = Container.shared.resolve(CoreStorage.self)! - let viewModel = WhatsNewViewModel(storage: storage, sourceScreen: sourceScreen) + if let userId = coreStorage.user?.id { + persistence.set(userId: userId) + } + + let viewModel = WhatsNewViewModel(storage: whatsNewStorage, sourceScreen: sourceScreen) let whatsNew = WhatsNewView(router: Container.shared.resolve(WhatsNewRouter.self)!, viewModel: viewModel) let shouldShowWhatsNew = viewModel.shouldShowWhatsNew() if shouldShowWhatsNew && config.features.whatNewEnabled { if let jsonVersion = viewModel.getVersion() { - storage.whatsNewVersion = jsonVersion + whatsNewStorage.whatsNewVersion = jsonVersion } let controller = UIHostingController(rootView: whatsNew) navigationController.viewControllers = [controller] diff --git a/Profile/Profile/Data/ProfileRepository.swift b/Profile/Profile/Data/ProfileRepository.swift index 83f37215a..43ca59d91 100644 --- a/Profile/Profile/Data/ProfileRepository.swift +++ b/Profile/Profile/Data/ProfileRepository.swift @@ -71,8 +71,8 @@ public class ProfileRepository: ProfileRepositoryProtocol { ProfileEndpoint.logOut(refreshToken: refreshToken, clientID: config.oAuthClientId) ) storage.clear() - await downloadManager.deleteAllFiles() - coreDataHandler.clear() + //await downloadManager.deleteAllFiles() + //coreDataHandler.clear() } public func getSpokenLanguages() -> [PickerFields.Option] {