Skip to content

Commit

Permalink
Notifications for new course content & Automated downloads (#895)
Browse files Browse the repository at this point in the history
- All features are enabled by feature flippers or launch arguments
- Get notified when a new course section starts
- Download the content of the new course section via the notification
- Download the most recent automatically (app launch, background)
  • Loading branch information
mathebox authored Jun 16, 2022
1 parent 13331f5 commit e9e8f04
Show file tree
Hide file tree
Showing 75 changed files with 3,724 additions and 292 deletions.
36 changes: 36 additions & 0 deletions Common/Base.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -1,4 +1,40 @@

/* Automated Downloads (Deletion options): Explanation for manual deletion */
"automated-downloads.deletion-options.explanation.manual" = "Downloaded content will not be removed automatically.";

/* Automated Downloads (Deletion options): Explanation for deletion with next section */
"automated-downloads.deletion-options.explanation.next-section" = "With the start of a new course section, the downloaded content of previous course sections will be removed from this device.";

/* Automated Downloads (Deletion options): Explanation for deletion with second next section */
"automated-downloads.deletion-options.explanation.second-next-section" = "With the start of a new course section, the downloaded content of the penultimate course section (and older) will be removed from this device.";

/* Automated Downloads (Deletion options): Title for manual deletion */
"automated-downloads.deletion-options.title.manually" = "Manually";

/* Automated Downloads (Deletion options): Title for deletion with next section */
"automated-downloads.deletion-options.title.next-section" = "With the start of the next course section";

/* Automated Downloads (Deletion options): Title for deletion with second next section */
"automated-downloads.deletion-options.title.second-next-section" = "With the start of the second next course section";

/* Automated Downloads (Notification only): Explanation of the feature */
"automated-downloads.feature.explanation.notification" = "When a new course section becomes available, you will receive a notification. If you tap this notification, you can open the course directly. If you long press on the notification, you can download the content of the new section for offline use.";

/* Automated Downloads (Notification + Background Downloads): Explanation of the feature (add.) */
"automated-downloads.feature.explanation.notification-background-download" = "In addition, the app will try to download the new content for you automatically. This is only triggered when your device is connected to a WiFi network and it has the least impact on battery life.";

/* Automated Downloads (Notification only): Title of the feature */
"automated-downloads.feature.title.notification" = "Notifications for New Content";

/* Automated Downloads (Notification + Background Downloads): Title of the feature */
"automated-downloads.feature.title.notification-background-download" = "Notifications for New Content & Automated Downloads";

/* Automated Downloads (File types): Explanation for considering only videos */
"automated-downloads.files-type.explanation.videos" = "Only videos will be downloaded.";

/* Automated Downloads (File types): Explanation for considering videos and slides */
"automated-downloads.files-type.explanation.videos-slides" = "Videos and slides will be downloaded.";

/* title for course start in a course date cell */
"course-date-cell.course-start.title" = "Course start";

Expand Down
6 changes: 5 additions & 1 deletion Common/Data/Helper/CoreDataHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ public class CoreDataHelper { // swiftlint:disable:this convenience_type
let coordinator = container.persistentStoreCoordinator
if let oldStore = coordinator.persistentStore(for: url) {
do {
try coordinator.migratePersistentStore(oldStore, to: sharedStoreURL, options: nil, withType: NSSQLiteStoreType)
let options = [
NSMigratePersistentStoresAutomaticallyOption: true,
NSInferMappingModelAutomaticallyOption: true,
]
try coordinator.migratePersistentStore(oldStore, to: sharedStoreURL, options: options, withType: NSSQLiteStoreType)
} catch {
print(error.localizedDescription)
}
Expand Down
36 changes: 36 additions & 0 deletions Common/Data/Helper/CourseHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//

import BrightFutures
import CoreData
import Foundation
import Stockpile

Expand All @@ -30,6 +31,41 @@ public enum CourseHelper {
return XikoloSyncEngine().synchronize(withFetchRequest: Self.FetchRequest.course(withSlugOrId: slugOrId), withQuery: query)
}

@discardableResult public static func setAutomatedDownloadSetting(
forCourse course: Course,
to settings: AutomatedDownloadSettings?
) -> Future<Void, XikoloError> {
let promise = Promise<Void, XikoloError>()

CoreDataHelper.persistentContainer.performBackgroundTask { context in
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump

guard let course = context.existingTypedObject(with: course.objectID) as? Course else {
promise.failure(.missingResource(ofType: Course.self))
return
}

if settings == nil { // delete settings
course.automatedDownloadSettings = settings
promise.complete(context.saveWithResult())
} else {
let center = UNUserNotificationCenter.current()
let options: UNAuthorizationOptions = [.alert]
center.requestAuthorization(options: options) { granted, error in
if granted {
course.automatedDownloadSettings = settings
course.automatedDownloadsHaveBeenNoticed = true
promise.complete(context.saveWithResult())
} else {
promise.failure(.permissionError(error))
}
}
}
}

return promise.future
}

public static func visit(_ course: Course) {
let courseObjectId = course.objectID
CoreDataHelper.persistentContainer.performBackgroundTask { context in
Expand Down
13 changes: 13 additions & 0 deletions Common/Data/Helper/CourseItemHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,17 @@ public enum CourseItemHelper {
return promise.future
}

@discardableResult public static func syncCourseItemsWithContent(
for course: Course,
withContentType type: String,
networker: SyncNetworker
) -> Future<SyncMultipleResult, XikoloError> {
let fetchRequest = Self.FetchRequest.courseItems(forCourse: course)
var query = MultipleResourcesQuery(type: CourseItem.self)
query.addFilter(forKey: "course", withValue: course.id)
query.addFilter(forKey: "content_type", withValue: type)
query.include("content")
return XikoloSyncEngine(networker: networker).synchronize(withFetchRequest: fetchRequest, withQuery: query, deleteNotExistingResources: false)
}

}
8 changes: 6 additions & 2 deletions Common/Data/Helper/ExperimentAssignmentHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ import Stockpile

public enum ExperimentAssignmentHelper {

public enum ExperimentIdentifier: String {
case newContentNotifications = "mobile.new_content_notification"
}

@discardableResult
public static func assign(to experimentIdentifier: String, inCourse course: Course? = nil) -> Future<Void, XikoloError> {
let experimentAssignment = ExperimentAssignment(experimentIdentifier: experimentIdentifier, course: course)
public static func assign(to experimentIdentifier: ExperimentIdentifier, inCourse course: Course? = nil) -> Future<Void, XikoloError> {
let experimentAssignment = ExperimentAssignment(experimentIdentifier: experimentIdentifier.rawValue, course: course)
return XikoloSyncEngine().createResource(experimentAssignment)
}

Expand Down
12 changes: 12 additions & 0 deletions Common/Data/Helper/FeatureHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ public enum FeatureHelper {
public enum FeatureIdentifier: String {
case quizRecap = "quiz_recap"
case courseReactivation = "course_reactivation"

// Identifiers for content notification and automated downloads user test
case newContentNotification = "mobile.new_content.notification"
case newContentBackgroundDownload = "mobile.new_content.background_download"
}

@discardableResult
Expand All @@ -26,6 +30,14 @@ public enum FeatureHelper {
}

public static func hasFeature(_ featureIdentifier: FeatureIdentifier, for course: Course? = nil) -> Bool {
#if DEBUG
if featureIdentifier == .newContentNotification && CommandLine.arguments.contains("-new-content-notification") {
return true
} else if featureIdentifier == .newContentBackgroundDownload && CommandLine.arguments.contains("-new-content-background-download") {
return true
}
#endif

let hasFeatureInGlobalScope: Bool = {
let fetchRequest = Self.FetchRequest.globalFeatures
guard let features = CoreDataHelper.viewContext.fetchSingle(fetchRequest).value else { return false }
Expand Down
28 changes: 28 additions & 0 deletions Common/Data/Helper/FetchRequests/CourseHelper+FetchRequests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,34 @@ extension CourseHelper {
return request
}

public static var coursesForAutomatedDownloads: NSFetchRequest<Course> {
let request = self.visibleCoursesRequest
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
visiblePredicate,
enrolledPredicate,
NSPredicate(format: "endsAt >= %@", Date() as NSDate),
NSPredicate(format: "automatedDownloadSettings == nil"),
])
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Course.endsAt, ascending: true),
]
return request
}

public static var coursesWithAutomatedDownloads: NSFetchRequest<Course> {
let request = self.visibleCoursesRequest
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
visiblePredicate,
enrolledPredicate,
NSPredicate(format: "endsAt >= %@", Date() as NSDate),
NSPredicate(format: "automatedDownloadSettings != nil"),
])
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Course.endsAt, ascending: true),
]
return request
}

public static func currentCourses(for channel: Channel) -> NSFetchRequest<Course> {
let request = self.visibleCoursesRequest
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ extension CourseSectionHelper {
return request
}

public static func courseSection(withId id: String) -> NSFetchRequest<CourseSection> {
let request: NSFetchRequest<CourseSection> = CourseSection.fetchRequest()
request.predicate = NSPredicate(format: "id = %@", id)
request.fetchLimit = 1
return request
}

}

}
53 changes: 51 additions & 2 deletions Common/Data/Helper/Sync/XikoloSyncEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,55 @@ public struct XikoloNetworker: SyncNetworker {

}

public class XikoloBackgroundNetworker: NSObject, SyncNetworker, URLSessionDownloadDelegate {

public typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void

let sessionConfiguration: URLSessionConfiguration
var backgroundCompletionHandler: (() -> Void)
var completionHandlers: [URLSessionTask: CompletionHandler] = [:]

var session: URLSession!

public init(withIdentifier identifier: String, saveBattery: Bool = false, backgroundCompletionHandler: @escaping (() -> Void)) {
self.sessionConfiguration = URLSessionConfiguration.background(withIdentifier: identifier)

if saveBattery {
self.sessionConfiguration.waitsForConnectivity = true
if #available(iOS 13, *) {
self.sessionConfiguration.allowsConstrainedNetworkAccess = false
self.sessionConfiguration.allowsExpensiveNetworkAccess = false
}
}

self.backgroundCompletionHandler = backgroundCompletionHandler
super.init()
self.session = URLSession(configuration: sessionConfiguration, delegate: self, delegateQueue: nil)
}

public func perform(request: URLRequest, completionHandler: @escaping CompletionHandler) {
let task = self.session.downloadTask(with: request)
self.completionHandlers[task] = completionHandler
task.resume()
}

public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
let completionHandler = self.completionHandlers.removeValue(forKey: task)
completionHandler?(nil, task.response, error)
}

public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
let completionHandler = self.completionHandlers.removeValue(forKey: downloadTask)
let data = try? Data(contentsOf: location)
completionHandler?(data, downloadTask.response, downloadTask.error)
}

public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
self.backgroundCompletionHandler()
}

}

public struct XikoloSyncEngine: SyncEngine {

public static let persistentContainerQueue: OperationQueue = {
Expand All @@ -54,7 +103,7 @@ public struct XikoloSyncEngine: SyncEngine {
return persistentContainerQueue
}()

public let networker: XikoloNetworker
public let networker: SyncNetworker

public let baseURL: URL = Routes.api

Expand All @@ -72,7 +121,7 @@ public struct XikoloSyncEngine: SyncEngine {
return Self.persistentContainerQueue
}()

public init(networker: XikoloNetworker = XikoloNetworker()) {
public init(networker: SyncNetworker = XikoloNetworker()) {
self.networker = networker
}

Expand Down
4 changes: 4 additions & 0 deletions Common/Data/Helper/TrackingHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ public enum TrackingHelper {

// social
case shareCourse = "SHARE_COURSE"

// content notification
case contentNotificationsEnabled = "CONTENT_NOTIFICATIONS_ENABLED"
case contentNotificationsDisabled = "CONTENT_NOTIFICATIONS_DISABLED"
}

// swiftlint:disable redundant_string_enum_value
Expand Down
Loading

0 comments on commit e9e8f04

Please sign in to comment.