From 9879fae22a816d19d10f69debbe0f8e510736862 Mon Sep 17 00:00:00 2001 From: Chris Li Date: Mon, 29 Aug 2016 15:37:18 -0400 Subject: [PATCH] Library pause / resume --- .../Library/DownloadTasksController.swift | 103 ++++++++++++++---- Kiwix-iOS/Info.plist | 2 +- Kiwix-iOSWidgets/Bookmarks/Info.plist | 2 +- .../xcdebugger/Breakpoints_v2.xcbkptlist | 16 +++ Kiwix/CoreData/Classes/Book.swift | 21 +++- Kiwix/CoreData/Classes/DownloadTask.swift | 10 ++ Kiwix/Network/Network.swift | 41 +++++-- Kiwix/Tools/DownloadProgress.swift | 12 +- Kiwix/Tools/Preference.swift | 8 ++ 9 files changed, 165 insertions(+), 50 deletions(-) diff --git a/Kiwix-iOS/Controller/Library/DownloadTasksController.swift b/Kiwix-iOS/Controller/Library/DownloadTasksController.swift index 6aacaced..11f8abf8 100644 --- a/Kiwix-iOS/Controller/Library/DownloadTasksController.swift +++ b/Kiwix-iOS/Controller/Library/DownloadTasksController.swift @@ -107,17 +107,32 @@ class DownloadTasksController: UITableViewController, NSFetchedResultsController cell.titleLabel.text = book.title cell.favIcon.image = UIImage(data: book.favIcon ?? NSData()) - guard let progress = Network.shared.operations[id]?.progress else {return} - cell.progressLabel.text = progress.fractionCompletedDescription - cell.progressView.setProgress(Float(progress.fractionCompleted), animated: animated) - cell.detailLabel.text = { - let string = progress.progressAndSpeedDescription - if string.containsString(" — ") { - return string.stringByReplacingOccurrencesOfString(" — ", withString: "\n") - } else { - return string + "\n" + NSLocalizedString("Estimating Speed and Remaining Time", comment: "") - } - }() + if let progress = Network.shared.operations[id]?.progress { + cell.progressLabel.text = progress.fractionCompletedDescription + cell.progressView.setProgress(Float(progress.fractionCompleted), animated: animated) + cell.detailLabel.text = { + let string = progress.progressAndSpeedDescription + if downloadTask.state == .Downloading { + if string.containsString(" — ") { + return string.stringByReplacingOccurrencesOfString(" — ", withString: "\n") + } else { + 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 @@ -159,27 +174,67 @@ class DownloadTasksController: UITableViewController, NSFetchedResultsController override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {} override func tableView(tableView: UITableView, editActionsForRowAtIndexPath indexPath: NSIndexPath) -> [UITableViewRowAction]? { - let pause = UITableViewRowAction(style: .Normal, title: "Pause") { (action, indexPath) in - guard let downloadTask = self.fetchedResultController.objectAtIndexPath(indexPath) as? DownloadTask else {return} - self.managedObjectContext.performBlock({ + 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 downloadTask.state = .Paused - }) - guard let bookID = downloadTask.book?.id else {return} - Network.shared.operations[bookID]?.cancel(produceResumeData: true) + + guard let bookID = downloadTask.book?.id else {return} + 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 - guard let downloadTask = self.fetchedResultController.objectAtIndexPath(indexPath) as? DownloadTask else {return} if let bookID = downloadTask.book?.id { - // Cancel the download operation, did cancel observer will do the rest - Network.shared.operations[bookID]?.cancel(produceResumeData: false) + if let operation = Network.shared.operations[bookID] { + // 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 { // 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 diff --git a/Kiwix-iOS/Info.plist b/Kiwix-iOS/Info.plist index 609325d4..056d09e2 100644 --- a/Kiwix-iOS/Info.plist +++ b/Kiwix-iOS/Info.plist @@ -49,7 +49,7 @@ CFBundleVersion - 1.7.1525 + 1.7.1576 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/Kiwix-iOSWidgets/Bookmarks/Info.plist b/Kiwix-iOSWidgets/Bookmarks/Info.plist index 0b682b4e..445f4b63 100644 --- a/Kiwix-iOSWidgets/Bookmarks/Info.plist +++ b/Kiwix-iOSWidgets/Bookmarks/Info.plist @@ -21,7 +21,7 @@ CFBundleSignature ???? CFBundleVersion - 1.7.1892 + 1.7.1983 NSExtension NSExtensionMainStoryboard diff --git a/Kiwix.xcworkspace/xcuserdata/chrisli.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Kiwix.xcworkspace/xcuserdata/chrisli.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index dbfe494b..be55addd 100644 --- a/Kiwix.xcworkspace/xcuserdata/chrisli.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Kiwix.xcworkspace/xcuserdata/chrisli.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -19,5 +19,21 @@ landmarkType = "5"> + + + + diff --git a/Kiwix/CoreData/Classes/Book.swift b/Kiwix/CoreData/Classes/Book.swift index 729ed6bf..aa538081 100644 --- a/Kiwix/CoreData/Classes/Book.swift +++ b/Kiwix/CoreData/Classes/Book.swift @@ -74,6 +74,16 @@ class Book: NSManagedObject { 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 class func fetchAll(context: NSManagedObjectContext) -> [Book] { @@ -103,10 +113,9 @@ class Book: NSManagedObject { // MARK: - Manage - func removeCache() { - guard let bookID = id, - let cacheFileURL = NSFileManager.cacheDirURL.URLByAppendingPathComponent(bookID) else {return} - _ = try? NSFileManager.defaultManager().removeItemAtURL(cacheFileURL) + func removeResumeData() { + guard let id = id else {return} + Preference.resumeData[id] = nil } // MARK: - Properties Description @@ -120,7 +129,7 @@ class Book: NSManagedObject { return formatter.stringFromDate(date) } - var fileSizeDescription: String? { + var fileSizeDescription: String { return NSByteCountFormatter.stringFromByteCount(fileSize, countStyle: .File) } @@ -152,7 +161,7 @@ class Book: NSManagedObject { var detailedDescription: String? { var descriptions = [String]() if let dateDescription = dateDescription {descriptions.append(dateDescription)} - if let fileSizeDescription = fileSizeDescription {descriptions.append(fileSizeDescription)} + descriptions.append(fileSizeDescription) if let articleCountDescription = articleCountDescription {descriptions.append(articleCountDescription)} guard descriptions.count != 0 else {return nil} diff --git a/Kiwix/CoreData/Classes/DownloadTask.swift b/Kiwix/CoreData/Classes/DownloadTask.swift index c8c2bc58..889a9006 100644 --- a/Kiwix/CoreData/Classes/DownloadTask.swift +++ b/Kiwix/CoreData/Classes/DownloadTask.swift @@ -40,6 +40,16 @@ class DownloadTask: NSManagedObject { stateRaw = Int16(newValue.rawValue) } } + + static let percentFormatter: NSNumberFormatter = { + let formatter = NSNumberFormatter() + formatter.numberStyle = .PercentStyle + formatter.minimumFractionDigits = 1 + formatter.maximumIntegerDigits = 3 + formatter.minimumFractionDigits = 2 + formatter.maximumIntegerDigits = 2 + return formatter + }() } diff --git a/Kiwix/Network/Network.swift b/Kiwix/Network/Network.swift index 33b4c115..260b1161 100644 --- a/Kiwix/Network/Network.swift +++ b/Kiwix/Network/Network.swift @@ -13,7 +13,9 @@ import Operations class Network: NSObject, NSURLSessionDelegate, NSURLSessionTaskDelegate, NSURLSessionDownloadDelegate, OperationQueueDelegate { static let shared = Network() let queue = OperationQueue() + let context = NSManagedObjectContext.mainQueueContext private(set) var operations = [String: DownloadBookOperation]() + var data: NSData? private override init() { super.init() @@ -49,9 +51,30 @@ class Network: NSObject, NSURLSessionDelegate, NSURLSessionTaskDelegate, NSURLSe // MARK: - NSURLSessionTaskDelegate func URLSession(session: NSURLSession, task: NSURLSessionTask, didCompleteWithError error: NSError?) { - print(error?.code) - print(error?.localizedDescription) - print(error?.userInfo.keys) + guard let error = error, let bookID = task.taskDescription else {return} + self.context.performBlockAndWait { + 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 @@ -60,6 +83,11 @@ class Network: NSObject, NSURLSessionDelegate, NSURLSessionTaskDelegate, NSURLSe guard let bookID = downloadTask.taskDescription, let operation = operations[bookID] else {return} 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) { @@ -73,12 +101,11 @@ class Network: NSObject, NSURLSessionDelegate, NSURLSessionTaskDelegate, NSURLSe _ = try? NSFileManager.defaultManager().moveItemAtURL(location, toURL: destination) // Perform clean up (remove cache and delete download task) - let context = NSManagedObjectContext.mainQueueContext context.performBlock { - guard let book = Book.fetch(bookID, context: context) else {return} - book.removeCache() + guard let book = Book.fetch(bookID, context: self.context) else {return} + book.removeResumeData() guard let downloadTask = book.downloadTask else {return} - context.deleteObject(downloadTask) + self.context.deleteObject(downloadTask) } } } diff --git a/Kiwix/Tools/DownloadProgress.swift b/Kiwix/Tools/DownloadProgress.swift index f1c8562e..b2244dfd 100644 --- a/Kiwix/Tools/DownloadProgress.swift +++ b/Kiwix/Tools/DownloadProgress.swift @@ -14,16 +14,6 @@ class DownloadProgress: NSProgress { private let timePointMinCount: Int = 20 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) { super.init(parent: nil, userInfo: [NSProgressFileOperationKindKey: NSProgressFileOperationKindDownloading]) self.kind = NSProgressKindFile @@ -40,7 +30,7 @@ class DownloadProgress: NSProgress { // MARK: - Descriptions var fractionCompletedDescription: String? { - return percentFormatter.stringFromNumber(NSNumber(double: fractionCompleted)) + return DownloadTask.percentFormatter.stringFromNumber(NSNumber(double: fractionCompleted)) } var progressAndSpeedDescription: String! { diff --git a/Kiwix/Tools/Preference.swift b/Kiwix/Tools/Preference.swift index 5f346df0..f9f7a764 100644 --- a/Kiwix/Tools/Preference.swift +++ b/Kiwix/Tools/Preference.swift @@ -91,6 +91,12 @@ class Preference { 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 { @@ -108,4 +114,6 @@ extension DefaultsKeys { static let libraryHasShownPreferredLanguagePrompt = DefaultsKey("libraryHasShownPreferredLanguagePrompt") static let langFilterSortByAlphabeticalAsc = DefaultsKey("langFilterSortByAlphabeticalAsc") static let langFilterNameDisplayInOriginalLocale = DefaultsKey("langFilterNameDisplayInOriginalLocale") + + static let resumeData = DefaultsKey<[String: AnyObject]>("resumeData") }