Skip to content

Commit

Permalink
feat: [FC-0047] xBlock offline mode (#474)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
IvanStepanok authored Sep 30, 2024
1 parent a405dce commit 23c219c
Show file tree
Hide file tree
Showing 96 changed files with 9,193 additions and 400 deletions.
840 changes: 840 additions & 0 deletions Authorization/AuthorizationTests/AuthorizationMock.generated.swift

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions Core/Core.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Core/Core/Analytics/CoreAnalytics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions Core/Core/Assets.xcassets/check_circle.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "check_circle.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions Core/Core/Assets.xcassets/download.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "download.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}
12 changes: 12 additions & 0 deletions Core/Core/Assets.xcassets/download.imageset/download.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions Core/Core/Assets.xcassets/remove.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "remove.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}
3 changes: 3 additions & 0 deletions Core/Core/Assets.xcassets/remove.imageset/remove.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions Core/Core/Assets.xcassets/report_octagon.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "report.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}
3 changes: 3 additions & 0 deletions Core/Core/Assets.xcassets/report_octagon.imageset/report.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions Core/Core/Assets.xcassets/visibility.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "visibility.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}
3 changes: 3 additions & 0 deletions Core/Core/Assets.xcassets/visibility.imageset/visibility.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="22G91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="CDDownloadData" representedClassName="CDDownloadData" syncable="YES" codeGenerationType="class">
<attribute name="blockId" optional="YES" attributeType="String"/>
<attribute name="courseId" optional="YES" attributeType="String"/>
<attribute name="displayName" optional="YES" attributeType="String"/>
<attribute name="fileName" optional="YES" attributeType="String"/>
<attribute name="fileSize" optional="YES" attributeType="Integer 32" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="lastModified" optional="YES" attributeType="String"/>
<attribute name="progress" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="resumeData" optional="YES" attributeType="Binary"/>
<attribute name="state" optional="YES" attributeType="String"/>
Expand All @@ -19,4 +20,13 @@
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="CDOfflineProgress" representedClassName="CDOfflineProgress" syncable="YES" codeGenerationType="class">
<attribute name="blockID" optional="YES" attributeType="String"/>
<attribute name="progressJson" optional="YES" attributeType="String"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="blockID"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
</model>
33 changes: 33 additions & 0 deletions Core/Core/Data/Persistence/CorePersistenceProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,18 @@
import CoreData
import Combine

//sourcery: AutoMockable
public protocol CorePersistenceProtocol {
func set(userId: Int)
func getUserID() -> Int?
func publisher() -> AnyPublisher<Int, Never>
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?)
Expand All @@ -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<Int, Never> { 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() {}
}
42 changes: 42 additions & 0 deletions Core/Core/Data/Repository/OfflineSyncRepository.swift
Original file line number Diff line number Diff line change
@@ -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
48 changes: 46 additions & 2 deletions Core/Core/Domain/Model/CourseBlockModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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 })
}
}

Expand Down
50 changes: 50 additions & 0 deletions Core/Core/Domain/Model/OfflineProgress.swift
Original file line number Diff line number Diff line change
@@ -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..<url.endIndex)?.lowerBound {
return String(url[range..<endRange])
}
return ""
}

func extractCourseID(from url: String) -> String {
if let range = url.range(of: "courses/")?.upperBound,
let endRange = url.range(of: "/xblock", range: range..<url.endIndex)?.lowerBound {
return String(url[range..<endRange])
}
return ""
}
}
}
Loading

0 comments on commit 23c219c

Please sign in to comment.