Library pause / resume

This commit is contained in:
Chris Li 2016-08-29 15:37:18 -04:00
parent 1aeff3dbd0
commit 9879fae22a
9 changed files with 165 additions and 50 deletions

View File

@ -107,17 +107,32 @@ class DownloadTasksController: UITableViewController, NSFetchedResultsController
cell.titleLabel.text = book.title cell.titleLabel.text = book.title
cell.favIcon.image = UIImage(data: book.favIcon ?? NSData()) cell.favIcon.image = UIImage(data: book.favIcon ?? NSData())
guard let progress = Network.shared.operations[id]?.progress else {return} if let progress = Network.shared.operations[id]?.progress {
cell.progressLabel.text = progress.fractionCompletedDescription cell.progressLabel.text = progress.fractionCompletedDescription
cell.progressView.setProgress(Float(progress.fractionCompleted), animated: animated) cell.progressView.setProgress(Float(progress.fractionCompleted), animated: animated)
cell.detailLabel.text = { cell.detailLabel.text = {
let string = progress.progressAndSpeedDescription let string = progress.progressAndSpeedDescription
if downloadTask.state == .Downloading {
if string.containsString("") { if string.containsString("") {
return string.stringByReplacingOccurrencesOfString("", withString: "\n") return string.stringByReplacingOccurrencesOfString("", withString: "\n")
} else { } else {
return string + "\n" + NSLocalizedString("Estimating Speed and Remaining Time", comment: "") return string + "\n" + "Estimating"
}
} else {
return string + "\n" + String(downloadTask.state)
} }
}() }()
} else {
let progress = Double(downloadTask.totalBytesWritten) / Double(book.fileSize)
cell.progressLabel.text = DownloadTask.percentFormatter.stringFromNumber(NSNumber(double: progress))
cell.progressView.setProgress(Float(progress), animated: animated)
cell.detailLabel.text = {
let downloadedSize = NSByteCountFormatter.stringFromByteCount(downloadTask.totalBytesWritten, countStyle: .File)
let fileSize = book.fileSizeDescription
return String(format: "%@ of %@ completed", downloadedSize, fileSize) + "\n" + String(downloadTask.state)
}()
}
} }
// MARK: Other Data Source // MARK: Other Data Source
@ -159,27 +174,67 @@ class DownloadTasksController: UITableViewController, NSFetchedResultsController
override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {} override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {}
override func tableView(tableView: UITableView, editActionsForRowAtIndexPath indexPath: NSIndexPath) -> [UITableViewRowAction]? { override func tableView(tableView: UITableView, editActionsForRowAtIndexPath indexPath: NSIndexPath) -> [UITableViewRowAction]? {
guard let downloadTask = self.fetchedResultController.objectAtIndexPath(indexPath) as? DownloadTask else {return nil}
var actions = [UITableViewRowAction]()
switch downloadTask.state {
case .Downloading:
let pause = UITableViewRowAction(style: .Normal, title: "Pause") { (action, indexPath) in let pause = UITableViewRowAction(style: .Normal, title: "Pause") { (action, indexPath) in
guard let downloadTask = self.fetchedResultController.objectAtIndexPath(indexPath) as? DownloadTask else {return}
self.managedObjectContext.performBlock({
downloadTask.state = .Paused downloadTask.state = .Paused
})
guard let bookID = downloadTask.book?.id else {return} guard let bookID = downloadTask.book?.id else {return}
Network.shared.operations[bookID]?.cancel(produceResumeData: true) Network.shared.operations[bookID]?.cancel(produceResumeData: true)
tableView.setEditing(false, animated: true)
} }
actions.insert(pause, atIndex: 0)
case .Paused:
if let book = downloadTask.book,
let resumeData = Network.shared.data {
let resume = UITableViewRowAction(style: .Normal, title: "Resume") { (action, indexPath) in
let task = Network.shared.session.downloadTaskWithResumeData(resumeData)
let operation = DownloadBookOperation(downloadTask: task)
Network.shared.queue.addOperation(operation)
tableView.setEditing(false, animated: true)
}
actions.insert(resume, atIndex: 0)
} else {
let restart = UITableViewRowAction(style: .Normal, title: "Restart") { (action, indexPath) in
guard let bookID = downloadTask.book?.id,
let operation = DownloadBookOperation(bookID: bookID) else {return}
Network.shared.queue.addOperation(operation)
tableView.setEditing(false, animated: true)
}
actions.insert(restart, atIndex: 0)
}
default:
break
}
let cancel = UITableViewRowAction(style: .Destructive, title: LocalizedStrings.Common.cancel) { (action, indexPath) -> Void in let cancel = UITableViewRowAction(style: .Destructive, title: LocalizedStrings.Common.cancel) { (action, indexPath) -> Void in
guard let downloadTask = self.fetchedResultController.objectAtIndexPath(indexPath) as? DownloadTask else {return}
if let bookID = downloadTask.book?.id { if let bookID = downloadTask.book?.id {
// Cancel the download operation, did cancel observer will do the rest if let operation = Network.shared.operations[bookID] {
Network.shared.operations[bookID]?.cancel(produceResumeData: false) // When download is ongoing
// Cancel the download operation
// URLSessionTaskDelegate will update coredata and do clean up
operation.cancel(produceResumeData: false)
} else {
// When download is paused
// Remove resume data
// Delete downloadTask object and set book to not local
downloadTask.book?.removeResumeData()
downloadTask.book?.isLocal = NSNumber(bool: false)
self.managedObjectContext.deleteObject(downloadTask)
}
} else { } else {
// In case of something goes wrong, and cannot find the book related to a download task, allow user to delete the row // In case of something goes wrong, and cannot find the book related to a download task, allow user to delete the row
self.managedObjectContext.performBlock({
self.managedObjectContext.deleteObject(downloadTask) self.managedObjectContext.deleteObject(downloadTask)
})
} }
tableView.setEditing(false, animated: true)
} }
return [cancel, pause] actions.insert(cancel, atIndex: 0)
return actions
} }
// MARK: - Fetched Results Controller // MARK: - Fetched Results Controller

View File

@ -49,7 +49,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.7.1525</string> <string>1.7.1576</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>

View File

@ -21,7 +21,7 @@
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.7.1892</string> <string>1.7.1983</string>
<key>NSExtension</key> <key>NSExtension</key>
<dict> <dict>
<key>NSExtensionMainStoryboard</key> <key>NSExtensionMainStoryboard</key>

View File

@ -19,5 +19,21 @@
landmarkType = "5"> landmarkType = "5">
</BreakpointContent> </BreakpointContent>
</BreakpointProxy> </BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "Kiwix-iOS/Controller/Library/DownloadTasksController.swift"
timestampString = "494191501.485551"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "195"
endingLineNumber = "195"
landmarkName = "tableView(_:editActionsForRowAtIndexPath:)"
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>
</Breakpoints> </Breakpoints>
</Bucket> </Bucket>

View File

@ -74,6 +74,16 @@ class Book: NSManagedObject {
return urlComponents?.URL return urlComponents?.URL
} }
var resumeDataURL: NSURL? {
guard let id = id,
let folderURL = NSURL(fileURLWithPath: NSFileManager.libDirURL.path!).URLByAppendingPathComponent("DownloadTemp", isDirectory: true),
let folderPath = folderURL.path else {return nil}
if !NSFileManager.defaultManager().fileExistsAtPath(folderPath) {
_ = try? NSFileManager.defaultManager().createDirectoryAtURL(folderURL, withIntermediateDirectories: true, attributes: [NSURLIsExcludedFromBackupKey: true])
}
return folderURL.URLByAppendingPathComponent(id)
}
// MARK: - Fetch // MARK: - Fetch
class func fetchAll(context: NSManagedObjectContext) -> [Book] { class func fetchAll(context: NSManagedObjectContext) -> [Book] {
@ -103,10 +113,9 @@ class Book: NSManagedObject {
// MARK: - Manage // MARK: - Manage
func removeCache() { func removeResumeData() {
guard let bookID = id, guard let id = id else {return}
let cacheFileURL = NSFileManager.cacheDirURL.URLByAppendingPathComponent(bookID) else {return} Preference.resumeData[id] = nil
_ = try? NSFileManager.defaultManager().removeItemAtURL(cacheFileURL)
} }
// MARK: - Properties Description // MARK: - Properties Description
@ -120,7 +129,7 @@ class Book: NSManagedObject {
return formatter.stringFromDate(date) return formatter.stringFromDate(date)
} }
var fileSizeDescription: String? { var fileSizeDescription: String {
return NSByteCountFormatter.stringFromByteCount(fileSize, countStyle: .File) return NSByteCountFormatter.stringFromByteCount(fileSize, countStyle: .File)
} }
@ -152,7 +161,7 @@ class Book: NSManagedObject {
var detailedDescription: String? { var detailedDescription: String? {
var descriptions = [String]() var descriptions = [String]()
if let dateDescription = dateDescription {descriptions.append(dateDescription)} if let dateDescription = dateDescription {descriptions.append(dateDescription)}
if let fileSizeDescription = fileSizeDescription {descriptions.append(fileSizeDescription)} descriptions.append(fileSizeDescription)
if let articleCountDescription = articleCountDescription {descriptions.append(articleCountDescription)} if let articleCountDescription = articleCountDescription {descriptions.append(articleCountDescription)}
guard descriptions.count != 0 else {return nil} guard descriptions.count != 0 else {return nil}

View File

@ -41,6 +41,16 @@ class DownloadTask: NSManagedObject {
} }
} }
static let percentFormatter: NSNumberFormatter = {
let formatter = NSNumberFormatter()
formatter.numberStyle = .PercentStyle
formatter.minimumFractionDigits = 1
formatter.maximumIntegerDigits = 3
formatter.minimumFractionDigits = 2
formatter.maximumIntegerDigits = 2
return formatter
}()
} }
enum DownloadTaskState: Int { enum DownloadTaskState: Int {

View File

@ -13,7 +13,9 @@ import Operations
class Network: NSObject, NSURLSessionDelegate, NSURLSessionTaskDelegate, NSURLSessionDownloadDelegate, OperationQueueDelegate { class Network: NSObject, NSURLSessionDelegate, NSURLSessionTaskDelegate, NSURLSessionDownloadDelegate, OperationQueueDelegate {
static let shared = Network() static let shared = Network()
let queue = OperationQueue() let queue = OperationQueue()
let context = NSManagedObjectContext.mainQueueContext
private(set) var operations = [String: DownloadBookOperation]() private(set) var operations = [String: DownloadBookOperation]()
var data: NSData?
private override init() { private override init() {
super.init() super.init()
@ -49,9 +51,30 @@ class Network: NSObject, NSURLSessionDelegate, NSURLSessionTaskDelegate, NSURLSe
// MARK: - NSURLSessionTaskDelegate // MARK: - NSURLSessionTaskDelegate
func URLSession(session: NSURLSession, task: NSURLSessionTask, didCompleteWithError error: NSError?) { func URLSession(session: NSURLSession, task: NSURLSessionTask, didCompleteWithError error: NSError?) {
print(error?.code) guard let error = error, let bookID = task.taskDescription else {return}
print(error?.localizedDescription) self.context.performBlockAndWait {
print(error?.userInfo.keys) guard let book = Book.fetch(bookID, context: self.context) else {return}
if error.code == NSURLErrorCancelled {
// If download task doesnt exist, it must mean download is cancelled by user
// DownloadTask object will have been deleted when user tap Cancel button / table row action
guard let downloadTask = book.downloadTask else {return}
downloadTask.totalBytesWritten = task.countOfBytesReceived
downloadTask.state = .Paused
// Save resue data to disk
guard let resumeData = error.userInfo[NSURLSessionDownloadTaskResumeData] as? NSData else {return}
let task = Network.shared.session.downloadTaskWithResumeData(resumeData)
task.resume()
guard let book = downloadTask.book else {return}
self.data = resumeData
// guard let resumeDataPath = book.resumeDataURL?.path,
// let resumeData = error.userInfo[NSURLSessionDownloadTaskResumeData] as? NSData else {return}
// resumeData.writeToFile(resumeDataPath, atomically: true)
} else {
// Handle other errors
}
}
} }
// MARK: - NSURLSessionDownloadDelegate // MARK: - NSURLSessionDownloadDelegate
@ -60,6 +83,11 @@ class Network: NSObject, NSURLSessionDelegate, NSURLSessionTaskDelegate, NSURLSe
guard let bookID = downloadTask.taskDescription, guard let bookID = downloadTask.taskDescription,
let operation = operations[bookID] else {return} let operation = operations[bookID] else {return}
operation.progress.completedUnitCount = totalBytesWritten operation.progress.completedUnitCount = totalBytesWritten
context.performBlock {
guard let downloadTask = Book.fetch(bookID, context: self.context)?.downloadTask where downloadTask.state == .Queued else {return}
downloadTask.state = .Downloading
}
} }
func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didFinishDownloadingToURL location: NSURL) { func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didFinishDownloadingToURL location: NSURL) {
@ -73,12 +101,11 @@ class Network: NSObject, NSURLSessionDelegate, NSURLSessionTaskDelegate, NSURLSe
_ = try? NSFileManager.defaultManager().moveItemAtURL(location, toURL: destination) _ = try? NSFileManager.defaultManager().moveItemAtURL(location, toURL: destination)
// Perform clean up (remove cache and delete download task) // Perform clean up (remove cache and delete download task)
let context = NSManagedObjectContext.mainQueueContext
context.performBlock { context.performBlock {
guard let book = Book.fetch(bookID, context: context) else {return} guard let book = Book.fetch(bookID, context: self.context) else {return}
book.removeCache() book.removeResumeData()
guard let downloadTask = book.downloadTask else {return} guard let downloadTask = book.downloadTask else {return}
context.deleteObject(downloadTask) self.context.deleteObject(downloadTask)
} }
} }
} }

View File

@ -14,16 +14,6 @@ class DownloadProgress: NSProgress {
private let timePointMinCount: Int = 20 private let timePointMinCount: Int = 20
private let timePointMaxCount: Int = 200 private let timePointMaxCount: Int = 200
private lazy var percentFormatter: NSNumberFormatter = {
let formatter = NSNumberFormatter()
formatter.numberStyle = .PercentStyle
formatter.minimumFractionDigits = 1
formatter.maximumIntegerDigits = 3
formatter.minimumFractionDigits = 2
formatter.maximumIntegerDigits = 2
return formatter
}()
init(completedUnitCount: Int64 = 0, totalUnitCount: Int64) { init(completedUnitCount: Int64 = 0, totalUnitCount: Int64) {
super.init(parent: nil, userInfo: [NSProgressFileOperationKindKey: NSProgressFileOperationKindDownloading]) super.init(parent: nil, userInfo: [NSProgressFileOperationKindKey: NSProgressFileOperationKindDownloading])
self.kind = NSProgressKindFile self.kind = NSProgressKindFile
@ -40,7 +30,7 @@ class DownloadProgress: NSProgress {
// MARK: - Descriptions // MARK: - Descriptions
var fractionCompletedDescription: String? { var fractionCompletedDescription: String? {
return percentFormatter.stringFromNumber(NSNumber(double: fractionCompleted)) return DownloadTask.percentFormatter.stringFromNumber(NSNumber(double: fractionCompleted))
} }
var progressAndSpeedDescription: String! { var progressAndSpeedDescription: String! {

View File

@ -91,6 +91,12 @@ class Preference {
set{Defaults[.langFilterNameDisplayInOriginalLocale] = newValue} set{Defaults[.langFilterNameDisplayInOriginalLocale] = newValue}
} }
} }
// MARK: - Resume Data
class var resumeData: [String: NSData] {
get{return Defaults[.resumeData] as? [String: NSData] ?? [String: NSData]()}
set{Defaults[.resumeData] = newValue}}
} }
extension DefaultsKeys { extension DefaultsKeys {
@ -108,4 +114,6 @@ extension DefaultsKeys {
static let libraryHasShownPreferredLanguagePrompt = DefaultsKey<Bool>("libraryHasShownPreferredLanguagePrompt") static let libraryHasShownPreferredLanguagePrompt = DefaultsKey<Bool>("libraryHasShownPreferredLanguagePrompt")
static let langFilterSortByAlphabeticalAsc = DefaultsKey<Bool>("langFilterSortByAlphabeticalAsc") static let langFilterSortByAlphabeticalAsc = DefaultsKey<Bool>("langFilterSortByAlphabeticalAsc")
static let langFilterNameDisplayInOriginalLocale = DefaultsKey<Bool>("langFilterNameDisplayInOriginalLocale") static let langFilterNameDisplayInOriginalLocale = DefaultsKey<Bool>("langFilterNameDisplayInOriginalLocale")
static let resumeData = DefaultsKey<[String: AnyObject]>("resumeData")
} }