diff --git a/Integrations/Carthage/Cartfile b/Integrations/Carthage/Cartfile index 65b46fb8..bb812ef9 100644 --- a/Integrations/Carthage/Cartfile +++ b/Integrations/Carthage/Cartfile @@ -1,9 +1,3 @@ -# Third-party dependencies used in the Test App itself. -github "onevcat/Kingfisher" ~> 5.15.8 -github "jdg/MBProgressHUD" ~> 1.2.0 -github "stephencelis/SQLite.swift" ~> 0.12.2 -github "scinfu/SwiftSoup" ~> 2.3.2 - # Readium 2 dependencies github "readium/r2-shared-swift" "develop" github "readium/r2-streamer-swift" "develop" diff --git a/Integrations/Carthage/Cartfile+lcp b/Integrations/Carthage/Cartfile+lcp index 7c3bc646..4b877fb6 100644 --- a/Integrations/Carthage/Cartfile+lcp +++ b/Integrations/Carthage/Cartfile+lcp @@ -1,9 +1,3 @@ -# Third-party dependencies used in the Test App itself. -github "onevcat/Kingfisher" ~> 5.15.8 -github "jdg/MBProgressHUD" ~> 1.2.0 -github "stephencelis/SQLite.swift" ~> 0.12.2 -github "scinfu/SwiftSoup" ~> 2.3.2 - # Readium 2 dependencies github "readium/r2-shared-swift" "develop" github "readium/r2-streamer-swift" "develop" diff --git a/Integrations/Carthage/project+lcp.yml b/Integrations/Carthage/project+lcp.yml index dc59d237..88a86a19 100644 --- a/Integrations/Carthage/project+lcp.yml +++ b/Integrations/Carthage/project+lcp.yml @@ -1,11 +1,21 @@ name: R2TestApp options: bundleIdPrefix: org.readium +packages: + GRDB: + url: https://github.com/groue/GRDB.swift.git + from: 5.8.0 + Kingfisher: + url: https://github.com/onevcat/Kingfisher.git + from: 5.15.8 + MBProgressHUD: + url: https://github.com/jdg/MBProgressHUD.git + from: 1.2.0 targets: R2TestApp: type: application platform: iOS - deploymentTarget: "10.0" + deploymentTarget: "13.6" sources: - path: Sources excludes: @@ -17,8 +27,6 @@ targets: - framework: Carthage/Build/CryptoSwift.xcframework - framework: Carthage/Build/Fuzi.xcframework - framework: Carthage/Build/GCDWebServer.xcframework - - framework: Carthage/Build/Kingfisher.xcframework - - framework: Carthage/Build/MBProgressHUD.xcframework - framework: Carthage/Build/Minizip.xcframework - framework: Carthage/Build/R2Navigator.xcframework - framework: Carthage/Build/R2Shared.xcframework @@ -28,6 +36,9 @@ targets: - framework: Carthage/Build/SQLite.xcframework - framework: Carthage/Build/SwiftSoup.xcframework - framework: Carthage/Build/ZIPFoundation.xcframework + - package: GRDB + - package: Kingfisher + - package: MBProgressHUD settings: LIBRARY_SEARCH_PATHS: $(PROJECT_DIR)/Carthage OTHER_SWIFT_FLAGS: -DLCP \ No newline at end of file diff --git a/Integrations/Carthage/project.yml b/Integrations/Carthage/project.yml index 77e6d7ad..38b12c1b 100644 --- a/Integrations/Carthage/project.yml +++ b/Integrations/Carthage/project.yml @@ -1,11 +1,21 @@ name: R2TestApp options: bundleIdPrefix: org.readium +packages: + GRDB: + url: https://github.com/groue/GRDB.swift.git + from: 5.8.0 + Kingfisher: + url: https://github.com/onevcat/Kingfisher.git + from: 5.15.8 + MBProgressHUD: + url: https://github.com/jdg/MBProgressHUD.git + from: 1.2.0 targets: R2TestApp: type: application platform: iOS - deploymentTarget: "10.0" + deploymentTarget: "13.6" sources: - path: Sources excludes: @@ -16,14 +26,14 @@ targets: - framework: Carthage/Build/CryptoSwift.xcframework - framework: Carthage/Build/Fuzi.xcframework - framework: Carthage/Build/GCDWebServer.xcframework - - framework: Carthage/Build/Kingfisher.xcframework - - framework: Carthage/Build/MBProgressHUD.xcframework - framework: Carthage/Build/Minizip.xcframework - framework: Carthage/Build/R2Navigator.xcframework - framework: Carthage/Build/R2Shared.xcframework - framework: Carthage/Build/R2Streamer.xcframework - framework: Carthage/Build/ReadiumOPDS.xcframework - - framework: Carthage/Build/SQLite.xcframework - framework: Carthage/Build/SwiftSoup.xcframework + - package: GRDB + - package: Kingfisher + - package: MBProgressHUD settings: LIBRARY_SEARCH_PATHS: $(PROJECT_DIR)/Carthage \ No newline at end of file diff --git a/Integrations/CocoaPods/Podfile b/Integrations/CocoaPods/Podfile index a0947082..5075cce1 100644 --- a/Integrations/CocoaPods/Podfile +++ b/Integrations/CocoaPods/Podfile @@ -11,9 +11,9 @@ target 'R2TestApp' do pod 'ReadiumLCP', podspec: 'https://raw.githubusercontent.com/readium/r2-lcp-swift/develop/ReadiumLCP.podspec' pod 'GCDWebServer', podspec: 'https://raw.githubusercontent.com/readium/GCDWebServer/3.6.3/GCDWebServer.podspec' + pod 'GRDB.swift' pod 'Kingfisher' pod 'MBProgressHUD' - pod 'SQLite.swift' pod 'SwiftSoup' end diff --git a/Integrations/CocoaPods/Podfile+lcp b/Integrations/CocoaPods/Podfile+lcp index 653291f1..38ac4327 100644 --- a/Integrations/CocoaPods/Podfile+lcp +++ b/Integrations/CocoaPods/Podfile+lcp @@ -12,9 +12,9 @@ target 'R2TestApp' do pod 'R2LCPClient', podspec: 'LCP_URL' pod 'GCDWebServer', podspec: 'https://raw.githubusercontent.com/readium/GCDWebServer/3.6.3/GCDWebServer.podspec' + pod 'GRDB.swift' pod 'Kingfisher' pod 'MBProgressHUD' - pod 'SQLite.swift' pod 'SwiftSoup' end diff --git a/Integrations/CocoaPods/project+lcp.yml b/Integrations/CocoaPods/project+lcp.yml index d94d70c8..09f38b5b 100644 --- a/Integrations/CocoaPods/project+lcp.yml +++ b/Integrations/CocoaPods/project+lcp.yml @@ -5,7 +5,7 @@ targets: R2TestApp: type: application platform: iOS - deploymentTarget: "10.0" + deploymentTarget: "13.6" sources: - path: Sources excludes: diff --git a/Integrations/CocoaPods/project.yml b/Integrations/CocoaPods/project.yml index aa2e78b7..b14ace38 100644 --- a/Integrations/CocoaPods/project.yml +++ b/Integrations/CocoaPods/project.yml @@ -5,7 +5,7 @@ targets: R2TestApp: type: application platform: iOS - deploymentTarget: "10.0" + deploymentTarget: "13.6" sources: - path: Sources excludes: diff --git a/Integrations/SPM/project+lcp.yml b/Integrations/SPM/project+lcp.yml index cba745b0..b291b802 100644 --- a/Integrations/SPM/project+lcp.yml +++ b/Integrations/SPM/project+lcp.yml @@ -17,6 +17,9 @@ packages: ReadiumLCP: url: https://github.com/readium/r2-lcp-swift.git branch: develop + GRDB: + url: https://github.com/groue/GRDB.swift.git + from: 5.8.0 Kingfisher: url: https://github.com/onevcat/Kingfisher.git from: 5.15.8 @@ -26,14 +29,11 @@ packages: SwiftSoup: url: https://github.com/scinfu/SwiftSoup.git from: 2.3.2 - SQLite: - url: https://github.com/stephencelis/SQLite.swift.git - from: 0.12.2 targets: R2TestApp: type: application platform: iOS - deploymentTarget: "10.0" + deploymentTarget: "13.6" sources: - path: Sources excludes: @@ -47,10 +47,10 @@ targets: - package: R2Navigator - package: ReadiumOPDS - package: ReadiumLCP + - package: GRDB - package: Kingfisher - package: MBProgressHUD - package: SwiftSoup - - package: SQLite settings: OTHER_SWIFT_FLAGS: -DLCP diff --git a/Integrations/SPM/project.yml b/Integrations/SPM/project.yml index 3415c753..f9c26190 100644 --- a/Integrations/SPM/project.yml +++ b/Integrations/SPM/project.yml @@ -14,6 +14,9 @@ packages: ReadiumOPDS: url: https://github.com/readium/r2-opds-swift.git branch: develop + GRDB: + url: https://github.com/groue/GRDB.swift.git + from: 5.8.0 Kingfisher: url: https://github.com/onevcat/Kingfisher.git from: 5.15.8 @@ -23,14 +26,11 @@ packages: SwiftSoup: url: https://github.com/scinfu/SwiftSoup.git from: 2.3.2 - SQLite: - url: https://github.com/stephencelis/SQLite.swift.git - from: 0.12.2 targets: R2TestApp: type: application platform: iOS - deploymentTarget: "10.0" + deploymentTarget: "13.6" sources: - path: Sources excludes: @@ -42,8 +42,7 @@ targets: - package: R2Streamer - package: R2Navigator - package: ReadiumOPDS + - package: GRDB - package: Kingfisher - package: MBProgressHUD - - package: SwiftSoup - - package: SQLite diff --git a/Integrations/Submodules/project+lcp.yml b/Integrations/Submodules/project+lcp.yml index 3d5da578..fd7972fc 100644 --- a/Integrations/Submodules/project+lcp.yml +++ b/Integrations/Submodules/project+lcp.yml @@ -12,6 +12,9 @@ packages: path: Integrations/Submodules/r2-opds-swift ReadiumLCP: path: Integrations/Submodules/r2-lcp-swift + GRDB: + url: https://github.com/groue/GRDB.swift.git + from: 5.8.0 Kingfisher: url: https://github.com/onevcat/Kingfisher.git from: 5.15.8 @@ -21,14 +24,11 @@ packages: SwiftSoup: url: https://github.com/scinfu/SwiftSoup.git from: 2.3.2 - SQLite: - url: https://github.com/stephencelis/SQLite.swift.git - from: 0.12.2 targets: R2TestApp: type: application platform: iOS - deploymentTarget: "10.0" + deploymentTarget: "13.6" sources: - path: Sources excludes: @@ -42,9 +42,9 @@ targets: - package: R2Navigator - package: ReadiumOPDS - package: ReadiumLCP + - package: GRDB - package: Kingfisher - package: MBProgressHUD - package: SwiftSoup - - package: SQLite settings: OTHER_SWIFT_FLAGS: -DLCP diff --git a/Integrations/Submodules/project.yml b/Integrations/Submodules/project.yml index 7ca7a7e5..be94374a 100644 --- a/Integrations/Submodules/project.yml +++ b/Integrations/Submodules/project.yml @@ -10,6 +10,9 @@ packages: path: Integrations/Submodules/r2-navigator-swift ReadiumOPDS: path: Integrations/Submodules/r2-opds-swift + GRDB: + url: https://github.com/groue/GRDB.swift.git + from: 5.8.0 Kingfisher: url: https://github.com/onevcat/Kingfisher.git from: 5.15.8 @@ -19,14 +22,11 @@ packages: SwiftSoup: url: https://github.com/scinfu/SwiftSoup.git from: 2.3.2 - SQLite: - url: https://github.com/stephencelis/SQLite.swift.git - from: 0.12.2 targets: R2TestApp: type: application platform: iOS - deploymentTarget: "10.0" + deploymentTarget: "13.6" sources: - path: Sources excludes: @@ -38,7 +38,7 @@ targets: - package: R2Streamer - package: R2Navigator - package: ReadiumOPDS + - package: GRDB - package: Kingfisher - package: MBProgressHUD - package: SwiftSoup - - package: SQLite diff --git a/Integrations/Submodules/r2-shared-swift b/Integrations/Submodules/r2-shared-swift index ce879202..70921c10 160000 --- a/Integrations/Submodules/r2-shared-swift +++ b/Integrations/Submodules/r2-shared-swift @@ -1 +1 @@ -Subproject commit ce8792022235cbc731e6d6731968f3ceb04b7674 +Subproject commit 70921c108c86e120ad04e4f1f9ba69c18ab0a6f6 diff --git a/Sources/App/AppModule.swift b/Sources/App/AppModule.swift index 6cdd7491..f991d96d 100644 --- a/Sources/App/AppModule.swift +++ b/Sources/App/AppModule.swift @@ -10,6 +10,7 @@ // LICENSE file present in the project repository where this source code is maintained. // +import Combine import Foundation import UIKit import R2Shared @@ -40,14 +41,19 @@ final class AppModule { fatalError("Can't start publication server") } - library = LibraryModule(delegate: self, server: server) - reader = ReaderModule(delegate: self, resourcesServer: server) + let httpClient = DefaultHTTPClient() + let db = try Database(file: Paths.library.appendingPathComponent("database.db")) + let books = BookRepository(db: db) + let bookmarks = BookmarkRepository(db: db) + + library = LibraryModule(delegate: self, books: books, server: server, httpClient: httpClient) + reader = ReaderModule(delegate: self, books: books, bookmarks: bookmarks, resourcesServer: server) opds = OPDSModule(delegate: self) // Set Readium 2's logging minimum level. R2EnableLog(withMinimumSeverityLevel: .debug) - try library.preloadSamples() + library.preloadSamples() } private(set) lazy var aboutViewController: UIViewController = { @@ -70,6 +76,7 @@ extension AppModule: ModuleDelegate { func presentError(_ error: Error?, from viewController: UIViewController) { guard let error = error else { return } + if case LibraryError.cancelled = error { return } presentAlert( NSLocalizedString("error_title", comment: "Alert title for errors"), message: error.localizedDescription, @@ -95,15 +102,12 @@ extension AppModule: ReaderModuleDelegate { extension AppModule: OPDSModuleDelegate { - func opdsDownloadPublication(_ publication: Publication?, at link: Link, sender: UIViewController, completion: @escaping (CancellableResult) -> ()) { + func opdsDownloadPublication(_ publication: Publication?, at link: Link, sender: UIViewController) -> AnyPublisher { guard let url = link.url(relativeTo: publication?.baseURL) else { - completion(.cancelled) - return + return .fail(.cancelled) } - library.importPublication(from: url, title: publication?.metadata.title, sender: sender) { - completion($0.eraseToAnyError()) - } + return library.importPublication(from: url, sender: sender) } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 22185c84..cfe5d34a 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -10,6 +10,7 @@ // LICENSE file present in the project repository where this source code is maintained. // +import Combine import UIKit @UIApplicationMain @@ -18,6 +19,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? private var app: AppModule! + private var subscriptions = Set() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { app = try! AppModule() @@ -60,6 +62,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { app.library.importPublication(from: url, sender: window!.rootViewController!) + .assertNoFailure() + .sink { _ in } + .store(in: &subscriptions) return true } diff --git a/Sources/Common/Connection.swift b/Sources/Common/Connection.swift deleted file mode 100644 index 1183f8a8..00000000 --- a/Sources/Common/Connection.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// Connection.swift -// r2-testapp-swift -// -// Created by Aferdita Muriqi on 4/7/19. -// -// Copyright 2018 European Digital Reading Lab. All rights reserved. -// Licensed to the Readium Foundation under one or more contributor license agreements. -// Use of this source code is governed by a BSD-style license which is detailed in the -// LICENSE file present in the project repository where this source code is maintained. -// - -import Foundation -import SQLite - -extension Connection { - - public var userVersion: Int32 { - get { return Int32(try! scalar("PRAGMA user_version") as! Int64)} - set { try! run("PRAGMA user_version = \(newValue)") } - } - - /// FIXME: Used to fix a crash with SQLite.swift pre Xcode 10.2.1. - /// We can't use the version 0.11.6 before Xcode 10.2, but the version 0.11.5 crashes on Xcode 10.2 (ie. https://github.com/stephencelis/SQLite.swift/issues/888) - func count(_ expressible: Expressible) throws -> Int64 { - let sql = "SELECT COUNT(*) FROM (\(expressible.asSQL())) AS countable;" - return (try scalar(sql) as? Int64) ?? 0 - } - -} - diff --git a/Sources/Common/Paths.swift b/Sources/Common/Paths.swift new file mode 100644 index 00000000..5c269a88 --- /dev/null +++ b/Sources/Common/Paths.swift @@ -0,0 +1,58 @@ +// +// Copyright 2021 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Combine +import Foundation +import R2Shared + +final class Paths { + private init() {} + + static let home: URL = + URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + + static let temporary: URL = + URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + + static let documents: URL = + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + + static let samples = Bundle.main.resourceURL!.appendingPathComponent("Samples") + + static let library: URL = + FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first! + + static let covers: URL = { + let url = library.appendingPathComponent("Covers") + try! FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + }() + + static func makeDocumentURL(for source: URL? = nil, title: String?, mediaType: MediaType) -> AnyPublisher { + Future(on: .global()) { promise in + // Is the file already in Documents/? + if let source = source, source.standardizedFileURL.deletingLastPathComponent() == documents.standardizedFileURL { + promise(.success(source)) + } else { + let title = title.takeIf { !$0.isEmpty } ?? UUID().uuidString + let ext = mediaType.fileExtension?.addingPrefix(".") ?? "" + let filename = "\(title)\(ext)".sanitizedPathComponent + promise(.success(documents.appendingUniquePathComponent(filename))) + } + }.eraseToAnyPublisher() + } + + static func makeTemporaryURL() -> AnyPublisher { + Future(on: .global()) { promise in + promise(.success(temporary.appendingUniquePathComponent())) + }.eraseToAnyPublisher() + } + + /// Returns whether the given `url` locates a file that is under the app's home directory. + static func isAppFile(at url: URL) -> Bool { + home.isParentOf(url) + } +} diff --git a/Sources/Common/Toolkit/Extensions/AnyPublisher.swift b/Sources/Common/Toolkit/Extensions/AnyPublisher.swift new file mode 100644 index 00000000..97b4634f --- /dev/null +++ b/Sources/Common/Toolkit/Extensions/AnyPublisher.swift @@ -0,0 +1,20 @@ +// +// Copyright 2021 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Combine + +extension AnyPublisher { + + public static func just(_ value: Output) -> Self { + Just(value) + .setFailureType(to: Failure.self) + .eraseToAnyPublisher() + } + + public static func fail(_ error: Failure) -> Self { + Fail(error: error).eraseToAnyPublisher() + } +} diff --git a/Sources/Common/Toolkit/Extensions/Future.swift b/Sources/Common/Toolkit/Extensions/Future.swift new file mode 100644 index 00000000..d68222d2 --- /dev/null +++ b/Sources/Common/Toolkit/Extensions/Future.swift @@ -0,0 +1,19 @@ +// +// Copyright 2021 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Combine +import Foundation + +extension Future { + /// Creates a `Future` which runs asynchronously on the given `queue`. + public convenience init(on queue: DispatchQueue, _ attemptToFulfill: @escaping (@escaping Future.Promise) -> Void) { + self.init { promise in + queue.async { + attemptToFulfill(promise) + } + } + } +} diff --git a/Sources/Common/Toolkit/Extensions/HTTPClient.swift b/Sources/Common/Toolkit/Extensions/HTTPClient.swift new file mode 100644 index 00000000..601a5460 --- /dev/null +++ b/Sources/Common/Toolkit/Extensions/HTTPClient.swift @@ -0,0 +1,71 @@ +// +// Copyright 2021 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Combine +import Foundation +import R2Shared + +struct HTTPDownload { + let file: URL + let response: HTTPResponse +} + +extension HTTPClient { + + func fetch(_ request: HTTPRequestConvertible) -> AnyPublisher { + var cancellable: R2Shared.Cancellable? = nil + return Future { promise in + cancellable = self.fetch(request, completion: promise) + } + .handleEvents(receiveCancel: { cancellable?.cancel() }) + .eraseToAnyPublisher() + } + + func download(_ request: HTTPRequestConvertible, progress: @escaping (Double) -> Void) -> AnyPublisher { + openTemporaryFileForWriting() + .flatMap { (destination, handle) -> AnyPublisher in + var cancellable: R2Shared.Cancellable? = nil + + return Future { promise in + cancellable = self.stream(request, + consume: { data, progression in + if let progression = progression { + progress(progression) + } + handle.write(data) + }, + completion: { result in + do { + try handle.close() + promise(.success(HTTPDownload(file: destination, response: try result.get()))) + } catch { + try? FileManager.default.removeItem(at: destination) + promise(.failure(HTTPError(error: error))) + } + }) + } + .handleEvents(receiveCancel: { + cancellable?.cancel() + try? handle.close() + try? FileManager.default.removeItem(at: destination) + }) + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + private func openTemporaryFileForWriting() -> AnyPublisher<(URL, FileHandle), HTTPError> { + Paths.makeTemporaryURL() + .tryMap { destination in + // Makes sure the file exists. + try "".write(to: destination, atomically: true, encoding: .utf8) + let handle = try FileHandle(forWritingTo: destination) + return (destination, handle) + } + .mapError { HTTPError(kind: .other, cause: $0) } + .eraseToAnyPublisher() + } +} diff --git a/Sources/Common/Toolkit/Extensions/Locator.swift b/Sources/Common/Toolkit/Extensions/Locator.swift new file mode 100644 index 00000000..fc8ad994 --- /dev/null +++ b/Sources/Common/Toolkit/Extensions/Locator.swift @@ -0,0 +1,20 @@ +// +// Copyright 2021 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import R2Shared + +extension Locator: Codable { + public init(from decoder: Decoder) throws { + let json = try decoder.singleValueContainer().decode(String.self) + try self.init(jsonString: json)! + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(jsonString) + } +} diff --git a/Sources/Common/Toolkit/Extensions/String.swift b/Sources/Common/Toolkit/Extensions/String.swift new file mode 100644 index 00000000..6febdc9b --- /dev/null +++ b/Sources/Common/Toolkit/Extensions/String.swift @@ -0,0 +1,22 @@ +// +// Copyright 2021 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation + +extension String { + + /// Returns this string after removing any character forbidden in a single path component. + var sanitizedPathComponent: String { + // See https://superuser.com/a/358861 + let invalidCharacters = CharacterSet(charactersIn: "\\/:*?\"<>|") + .union(.newlines) + .union(.illegalCharacters) + .union(.controlCharacters) + + return components(separatedBy: invalidCharacters) + .joined(separator: " ") + } +} diff --git a/Sources/Common/Toolkit/Extensions/URL.swift b/Sources/Common/Toolkit/Extensions/URL.swift deleted file mode 100644 index ba27c15d..00000000 --- a/Sources/Common/Toolkit/Extensions/URL.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// URL.swift -// r2-testapp-swift -// -// Created by Mickaël Menu on 26/07/2020. -// -// Copyright 2020 European Digital Reading Lab. All rights reserved. -// Licensed to the Readium Foundation under one or more contributor license agreements. -// Use of this source code is governed by a BSD-style license which is detailed in the -// LICENSE file present in the project repository where this source code is maintained. -// - -import Foundation -import UIKit -import R2Shared - -extension URL { - - func download(description: String? = nil) -> Deferred { - assert(scheme != nil && !isFileURL, "Only a remote URL can be downloaded") - - UIApplication.shared.isNetworkActivityIndicatorVisible = true - - return deferred { success, failure, cancel in - DownloadSession.shared.launch( - request: URLRequest(url: self), - description: description - ) { downloadURL, response, error, downloadTask in - UIApplication.shared.isNetworkActivityIndicatorVisible = false - - if let downloadURL = downloadURL { - // The downloaded file will be automatically deleted at the end of this - // completion block, so we need to copy it to a temporary location. - let files = FileManager.default - let destinationURL = files.temporaryDirectory.appendingUniquePathComponent(response?.suggestedFilename ?? downloadURL.lastPathComponent) - do { - try files.moveItem(at: downloadURL, to: destinationURL) - success(destinationURL) - } catch { - failure(error) - } - - } else if let error = error { - failure(error) - } else { - cancel() - } - - return true - } - } - - } - - /// Returns whether this URL locates a file that is under the app's home directory. - var isAppFile: Bool { - let homeDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) - return homeDirectory.isParentOf(self) - } - -} diff --git a/Sources/Data/Book.swift b/Sources/Data/Book.swift new file mode 100644 index 00000000..92bdd25f --- /dev/null +++ b/Sources/Data/Book.swift @@ -0,0 +1,98 @@ +// +// Copyright 2021 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Combine +import Foundation +import GRDB +import R2Shared + +struct Book: Codable { + struct Id: EntityId { let rawValue: Int64 } + + let id: Id? + /// Canonical identifier for the publication, extracted from its metadata. + var identifier: String? + /// Title of the publication, extracted from its metadata. + var title: String + /// Authors of the publication, separated by commas. + var authors: String? + /// Media type associated to the publication. + var type: String + /// Location of the packaged publication or a manifest. + var path: String + /// Location of the cover. + var coverPath: String? + /// Last read location in the publication. + var locator: Locator? { + didSet { progression = locator?.locations.totalProgression ?? 0 } + } + /// Current progression in the publication, extracted from the locator. + var progression: Double + /// Date of creation. + var created: Date + + init(id: Id? = nil, identifier: String? = nil, title: String, authors: String? = nil, type: String, path: String, coverPath: String? = nil, locator: Locator? = nil, created: Date = Date()) { + self.id = id + self.identifier = identifier + self.title = title + self.authors = authors + self.type = type + self.path = path + self.coverPath = coverPath + self.locator = locator + self.progression = locator?.locations.totalProgression ?? 0 + self.created = created + } + + var cover: URL? { + coverPath.map { Paths.covers.appendingPathComponent($0) } + } +} + +extension Book: TableRecord, FetchableRecord, PersistableRecord { + enum Columns: String, ColumnExpression { + case id, identifier, title, type, path, coverPath, locator, progression, created + } +} + +final class BookRepository { + private let db: Database + + init(db: Database) { + self.db = db + } + + func all() -> AnyPublisher<[Book], Error> { + db.observe { db in + try Book.order(Book.Columns.created).fetchAll(db) + } + } + + func add(_ book: Book) -> AnyPublisher { + return db.write { db in + try book.insert(db) + return Book.Id(rawValue: db.lastInsertedRowID) + }.eraseToAnyPublisher() + } + + func remove(_ id: Book.Id) -> AnyPublisher { + db.write { db in try Book.deleteOne(db, key: id) } + } + + func saveProgress(for id: Book.Id, locator: Locator) -> AnyPublisher { + guard let json = locator.jsonString else { + return .just(()) + } + + return db.write { db in + try db.execute(literal: """ + UPDATE book + SET locator = \(json), progression = \(locator.locations.totalProgression ?? 0) + WHERE id = \(id) + """) + } + } +} diff --git a/Sources/Data/Bookmark.swift b/Sources/Data/Bookmark.swift new file mode 100644 index 00000000..75d101ff --- /dev/null +++ b/Sources/Data/Bookmark.swift @@ -0,0 +1,66 @@ +// +// Copyright 2021 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Combine +import Foundation +import GRDB +import R2Shared + +struct Bookmark: Codable { + struct Id: EntityId { let rawValue: Int64 } + + let id: Id? + /// Foreign key to the publication. + var bookId: Book.Id + /// Location in the publication. + var locator: Locator + /// Progression in the publication, extracted from the locator. + var progression: Double? + /// Date of creation. + var created: Date = Date() + + init(id: Id? = nil, bookId: Book.Id, locator: Locator, created: Date = Date()) { + self.id = id + self.bookId = bookId + self.locator = locator + self.progression = locator.locations.totalProgression + self.created = created + } +} + +extension Bookmark: TableRecord, FetchableRecord, PersistableRecord { + enum Columns: String, ColumnExpression { + case id, bookId, locator, progression, created + } +} + +final class BookmarkRepository { + private let db: Database + + init(db: Database) { + self.db = db + } + + func all(for bookId: Book.Id) -> AnyPublisher<[Bookmark], Error> { + db.observe { db in + try Bookmark + .filter(Bookmark.Columns.bookId == bookId) + .order(Bookmark.Columns.progression) + .fetchAll(db) + } + } + + func add(_ bookmark: Bookmark) -> AnyPublisher { + return db.write { db in + try bookmark.insert(db) + return Bookmark.Id(rawValue: db.lastInsertedRowID) + }.eraseToAnyPublisher() + } + + func remove(_ id: Bookmark.Id) -> AnyPublisher { + db.write { db in try Bookmark.deleteOne(db, key: id) } + } +} diff --git a/Sources/Data/Database.swift b/Sources/Data/Database.swift new file mode 100644 index 00000000..caa8734c --- /dev/null +++ b/Sources/Data/Database.swift @@ -0,0 +1,101 @@ +// +// Copyright 2021 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Combine +import Foundation +import GRDB + +final class Database { + + convenience init(file: URL) throws { + try self.init(writer: try DatabaseQueue(path: file.path)) + } + + private let writer: DatabaseWriter + + private init(writer: DatabaseWriter = DatabaseQueue()) throws { + self.writer = writer + + try writer.write { db in + try db.create(table: "book", ifNotExists: true) { t in + t.autoIncrementedPrimaryKey("id") + t.column("identifier", .text) + t.column("title", .text).notNull() + t.column("authors", .text) + t.column("type", .text).notNull() + t.column("path", .text).notNull() + t.column("coverPath", .text) + t.column("locator", .text) + t.column("progression", .integer).notNull().defaults(to: 0) + t.column("created", .datetime).notNull() + } + + try db.create(table: "bookmark", ifNotExists: true) { t in + t.autoIncrementedPrimaryKey("id") + t.column("bookId", .integer).references("book", onDelete: .cascade).notNull() + t.column("locator", .text) + t.column("progression", .double).notNull() + t.column("created", .datetime).notNull() + } + } + } + + func read(_ query: @escaping (GRDB.Database) throws -> T) -> AnyPublisher { + writer.readPublisher(value: query) + .eraseToAnyPublisher() + } + + func write(_ updates: @escaping (GRDB.Database) throws -> T) -> AnyPublisher { + writer.writePublisher(updates: updates) + .eraseToAnyPublisher() + } + + func observe(_ query: @escaping (GRDB.Database) throws -> T) -> AnyPublisher { + ValueObservation.tracking(query) + .publisher(in: writer) + .eraseToAnyPublisher() + } +} + +/// Protocol for a database entity id. +/// +/// Using this instead of regular integers makes the code safer, because we can only give ids of the +/// right model in APIs. It also helps self-document APIs. +protocol EntityId: Codable, Hashable, RawRepresentable, ExpressibleByIntegerLiteral, CustomStringConvertible, DatabaseValueConvertible where RawValue == Int64 {} + +extension EntityId { + + // MARK: - ExpressibleByIntegerLiteral + + init(integerLiteral value: Int64) { + self.init(rawValue: value)! + } + + // MARK: - Codable + + init(from decoder: Decoder) throws { + self.init(rawValue: try decoder.singleValueContainer().decode(Int64.self))! + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + // MARK: - CustomStringConvertible + + var description: String { + "\(Self.self)(\(rawValue))" + } + + // MARK: - DatabaseValueConvertible + + var databaseValue: DatabaseValue { rawValue.databaseValue } + + static func fromDatabaseValue(_ dbValue: DatabaseValue) -> Self? { + Int64.fromDatabaseValue(dbValue).map(Self.init) + } +} diff --git a/Sources/Library/DB/BooksDatabase.swift b/Sources/Library/DB/BooksDatabase.swift deleted file mode 100644 index 3cd8e742..00000000 --- a/Sources/Library/DB/BooksDatabase.swift +++ /dev/null @@ -1,245 +0,0 @@ -// -// BooksDatabase.swift -// r2-testapp-swift -// -// Created by Aferdita Muriqi on 2018/9/05. -// -// Copyright 2018 European Digital Reading Lab. All rights reserved. -// Licensed to the Readium Foundation under one or more contributor license agreements. -// Use of this source code is governed by a BSD-style license which is detailed in the -// LICENSE file present in the project repository where this source code is maintained. -// - -import Foundation -import R2Shared -import SQLite - -final class BooksDatabase { - // Shared instance. - public static let shared = BooksDatabase() - - // Connection. - let connection: Connection - // The DB table for books. - let books: BooksTable! - - private init() { - do { - var url = try FileManager.default.url( - for: .libraryDirectory, - in: .userDomainMask, - appropriateFor: nil, create: true - ) - - url.appendPathComponent("books_database") - connection = try Connection(url.absoluteString) - books = BooksTable(connection) - - } catch { - fatalError("Error initializing db.") - } - } -} - -class Book: Loggable { - let id: Int64 - let creation:Date - let href: String - let title: String - let author: String? - let identifier: String? - let cover: Data? - var progression: String? - - enum Error: Swift.Error { - case notFound(Swift.Error?) - } - - func url() throws -> URL { - // Absolute URL. - if let url = URL(string: href), url.scheme != nil { - return url - } - - // Absolute file path. - if href.hasPrefix("/") { - return URL(fileURLWithPath: href) - } - - do { - // Path relative to Documents/. - let files = FileManager.default - let documents = try files.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - - let documentURL = documents.appendingPathComponent(href) - if (try? documentURL.checkResourceIsReachable()) == true { - return documentURL - } - - // Path relative to the Samples/ directory in the App bundle. - if - let sampleURL = Bundle.main.url(forResource: href, withExtension: nil, subdirectory: "Samples"), - (try? sampleURL.checkResourceIsReachable()) == true - { - return sampleURL - } - } catch { - throw Error.notFound(error) - } - - throw Error.notFound(nil) - } - - init( - id: Int64 = 0, - creation:Date = Date(), - href: String, - title: String, - author: String?, - identifier: String?, - cover: Data?, - progression: String? = nil - ) { - self.id = id - self.creation = creation - self.href = href - self.title = title - self.author = author - self.identifier = identifier - self.cover = cover - self.progression = progression - - } - - var progressionLocator: Locator? { - do { - return try progression.flatMap { try Locator(jsonString: $0) } - } catch { - log(.error, "Can't parse Book.progression: \(error.localizedDescription)") - return nil - } - } - -} - -class BooksTable { - - let books = Table("BOOKS") - - let ID = Expression("id") - let IDENTIFIER = Expression("identifier") - let HREF = Expression("href") - let TITLE = Expression("title") - let AUTHOR = Expression("author") - let COVER = Expression("cover") - let CREATION = Expression("creationDate") - let PROGRESSION = Expression("progression") - - init(_ connection: Connection) { - - connection.userVersion = 0 - _ = try? connection.run(books.create(temporary: false, ifNotExists: true) { t in - t.column(ID, primaryKey: PrimaryKey.autoincrement) - t.column(IDENTIFIER) - t.column(HREF) - t.column(TITLE) - t.column(AUTHOR) - t.column(COVER) - t.column(CREATION) - t.column(PROGRESSION) - }) - } - - func insert(book: Book, allowDuplicate:Bool = false) throws -> Int64? { - let db = BooksDatabase.shared.connection - - if !allowDuplicate { - guard !exists(book) else { - return nil - } - } - - let query = books.insert( - IDENTIFIER <- book.identifier, - HREF <- book.href, - TITLE <- book.title, - AUTHOR <- book.author, - COVER <- book.cover, - CREATION <- book.creation, - PROGRESSION <- book.progression - ) - - return try db.run(query) - } - - private func exists(_ book: Book) -> Bool { - guard let identifier = book.identifier else { - return false - } - let db = BooksDatabase.shared.connection - let filter = books.filter(self.IDENTIFIER == identifier) - return ((try? db.count(filter)) ?? 0) != 0 - } - - func delete(_ book: Book) throws -> Bool { - return try delete(book.id) - } - - private func delete(_ ID: Int64) throws -> Bool { - let db = BooksDatabase.shared.connection - let book = books.filter(self.ID == ID) - - // Check if empty. - guard try db.count(book) > 0 else { - return false - } - - try db.run(book.delete()) - return true - } - - @discardableResult - func saveProgression(_ locator: Locator?, of book: Book) throws -> Bool { - let db = BooksDatabase.shared.connection - let bookFilter = books.filter(ID == book.id) - - // Check if empty. - guard try db.count(bookFilter) > 0 else { - return false - } - - book.progression = locator?.jsonString - try db.run(bookFilter.update(PROGRESSION <- book.progression)) - return true - } - - func all() throws -> [Book] { - - let db = BooksDatabase.shared.connection - // Check if empty. - guard try db.count(books) > 0 else { - return [] - } - - let resultList = try { () -> AnySequence in - return try db.prepare(self.books.order(self.ID.desc)) - } () - - let bookList = resultList.map { (bookRow) -> Book in - - let _ID = bookRow[self.ID] - let _identifier = bookRow[self.IDENTIFIER] - let _href = bookRow[self.HREF] - let _title = bookRow[self.TITLE] - let _author = bookRow[self.AUTHOR] - let _cover = bookRow[self.COVER] - let _creation = bookRow[self.CREATION] - let _progression = bookRow[self.PROGRESSION] - - let book = Book(id: _ID, creation: _creation, href: _href, title: _title, author: _author, identifier: _identifier, cover: _cover, progression: _progression) - return book - } - - return bookList - } -} diff --git a/Sources/Library/DRM/DRMLibraryService.swift b/Sources/Library/DRM/DRMLibraryService.swift index 30c49938..71f38e81 100644 --- a/Sources/Library/DRM/DRMLibraryService.swift +++ b/Sources/Library/DRM/DRMLibraryService.swift @@ -9,6 +9,7 @@ // in the LICENSE file present in the project repository where this source code is maintained. // +import Combine import Foundation import R2Shared @@ -18,7 +19,6 @@ struct DRMFulfilledPublication { let suggestedFilename: String } - protocol DRMLibraryService { /// Returns the `ContentProtection` which will be provided to the `Streamer`, to unlock @@ -29,6 +29,6 @@ protocol DRMLibraryService { func canFulfill(_ file: URL) -> Bool /// Fulfills the given file to the fully protected publication. - func fulfill(_ file: URL) -> Deferred + func fulfill(_ file: URL) -> AnyPublisher } diff --git a/Sources/Library/DRM/LCPLibraryService.swift b/Sources/Library/DRM/LCPLibraryService.swift index 3e9eaddf..dafdcef9 100644 --- a/Sources/Library/DRM/LCPLibraryService.swift +++ b/Sources/Library/DRM/LCPLibraryService.swift @@ -11,6 +11,7 @@ #if LCP +import Combine import Foundation import UIKit import R2Shared @@ -28,22 +29,29 @@ class LCPLibraryService: DRMLibraryService { return file.pathExtension.lowercased() == "lcpl" } - func fulfill(_ file: URL) -> Deferred { - return deferred { completion in + func fulfill(_ file: URL) -> AnyPublisher { + Future { promise in self.lcpService.acquirePublication(from: file) { result in - completion(result - .map { - DRMFulfilledPublication( - localURL: $0.localURL, - suggestedFilename: $0.suggestedFilename - ) - } - .eraseToAnyError() - ) + // Removes the license file, but only if it's in the App directory (e.g. Inbox/). + // Otherwise we might delete something from a shared location (e.g. iCloud). + if Paths.isAppFile(at: file) { + try? FileManager.default.removeItem(at: file) + } + + switch result { + case .success(let pub): + promise(.success(DRMFulfilledPublication( + localURL: pub.localURL, + suggestedFilename: pub.suggestedFilename + ))) + case .failure(let error): + promise(.failure(error)) + case .cancelled: + promise(.success(nil)) + } } - } + }.eraseToAnyPublisher() } - } /// Facade to the private R2LCPClient.framework. diff --git a/Sources/Library/LibraryError.swift b/Sources/Library/LibraryError.swift index 223078a5..57e204a1 100644 --- a/Sources/Library/LibraryError.swift +++ b/Sources/Library/LibraryError.swift @@ -16,20 +16,27 @@ import R2Shared enum LibraryError: LocalizedError { case publicationIsNotValid + case bookNotFound + case bookDeletionFailed(Error?) case importFailed(Error) case openFailed(Error) - case downloadFailed(String) + case downloadFailed(Error) + case cancelled var errorDescription: String? { switch self { case .publicationIsNotValid: return NSLocalizedString("library_error_publicationIsNotValid", comment: "Error message used when trying to import a publication that is not valid") + case .bookNotFound: + return NSLocalizedString("library_error_bookNotFound", comment: "Error message used when trying to open a book whose file is not found") case .importFailed(let error): return String(format: NSLocalizedString("library_error_importFailed", comment: "Error message used when a low-level error occured while importing a publication"), error.localizedDescription) case .openFailed(let error): return String(format: NSLocalizedString("library_error_openFailed", comment: "Error message used when a low-level error occured while opening a publication"), error.localizedDescription) - case .downloadFailed(let description): - return String(format: NSLocalizedString("library_error_downloadFailed", comment: "Error message when the download of a publication failed"), description) + case .downloadFailed(let error): + return String(format: NSLocalizedString("library_error_downloadFailed", comment: "Error message when the download of a publication failed"), error.localizedDescription) + default: + return nil } } diff --git a/Sources/Library/LibraryModule.swift b/Sources/Library/LibraryModule.swift index 91ab06da..f2d93e25 100644 --- a/Sources/Library/LibraryModule.swift +++ b/Sources/Library/LibraryModule.swift @@ -10,6 +10,7 @@ // LICENSE file present in the project repository where this source code is maintained. // +import Combine import Foundation import R2Shared import R2Streamer @@ -26,27 +27,15 @@ protocol LibraryModuleAPI { var rootViewController: UINavigationController { get } /// Loads the sample publications if needed. - func preloadSamples() throws + func preloadSamples() /// Imports a new publication to the library, either from: /// - a local file URL /// - a remote URL which will be downloaded - /// - /// - Parameters: - /// - url: Source URL to import. - /// - title: Title of the publication when known, to provide context. - func importPublication(from url: URL, title: String?, sender: UIViewController, completion: @escaping (CancellableResult) -> Void) + func importPublication(from url: URL, sender: UIViewController) -> AnyPublisher } -extension LibraryModuleAPI { - - func importPublication(from url: URL, title: String? = nil, sender: UIViewController) { - importPublication(from: url, title: title, sender: sender, completion: { _ in }) - } - -} - protocol LibraryModuleDelegate: ModuleDelegate { /// Called when the user tap on a publication in the library. @@ -61,9 +50,10 @@ final class LibraryModule: LibraryModuleAPI { private let library: LibraryService private let factory: LibraryFactory + private var subscriptions = Set() - init(delegate: LibraryModuleDelegate?, server: PublicationServer) { - self.library = LibraryService(publicationServer: server) + init(delegate: LibraryModuleDelegate?, books: BookRepository, server: PublicationServer, httpClient: HTTPClient) { + self.library = LibraryService(books: books, publicationServer: server, httpClient: httpClient) self.factory = LibraryFactory(libraryService: library) self.delegate = delegate } @@ -78,12 +68,18 @@ final class LibraryModule: LibraryModuleAPI { return library }() - func preloadSamples() throws { - try library.preloadSamples() + func preloadSamples() { + library.preloadSamples() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { completion in + if case let .failure(error) = completion { + self.delegate?.presentError(error, from: self.libraryViewController) + } + }) {} + .store(in: &subscriptions) } - func importPublication(from url: URL, title: String?, sender: UIViewController, completion: @escaping (CancellableResult) -> ()) { - library.importPublication(from: url, title: title, sender: sender, completion: completion) + func importPublication(from url: URL, sender: UIViewController) -> AnyPublisher { + library.importPublication(from: url, sender: sender) } - } diff --git a/Sources/Library/LibraryService.swift b/Sources/Library/LibraryService.swift index ce495f18..2b4fc613 100644 --- a/Sources/Library/LibraryService.swift +++ b/Sources/Library/LibraryService.swift @@ -10,6 +10,7 @@ // LICENSE file present in the project repository where this source code is maintained. // +import Combine import Foundation import UIKit import R2Shared @@ -17,10 +18,7 @@ import R2Streamer protocol LibraryServiceDelegate: AnyObject { - - func reloadLibrary() - func confirmImportingDuplicatePublication(withTitle title: String) -> Deferred - + func confirmImportingDuplicatePublication(withTitle title: String) -> AnyPublisher } /// The Library service is used to: @@ -33,13 +31,15 @@ final class LibraryService: Loggable { weak var delegate: LibraryServiceDelegate? private let streamer: Streamer + private let books: BookRepository private let publicationServer: PublicationServer + private let httpClient: HTTPClient private var drmLibraryServices = [DRMLibraryService]() - private lazy var documentDirectory = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - - init(publicationServer: PublicationServer) { + init(books: BookRepository, publicationServer: PublicationServer, httpClient: HTTPClient) { + self.books = books self.publicationServer = publicationServer + self.httpClient = httpClient #if LCP drmLibraryServices.append(LCPLibraryService()) @@ -50,37 +50,56 @@ final class LibraryService: Loggable { ) } + func allBooks() -> AnyPublisher<[Book], Error> { + books.all() + } + // MARK: Opening /// Opens the Readium 2 Publication for the given `book`. /// /// If the `Publication` is intended to be presented in a navigator, set `forPresentation`. - func openBook(_ book: Book, forPresentation prepareForPresentation: Bool, sender: UIViewController, completion: @escaping (CancellableResult) -> Void) { - deferredCatching { .success(try book.url()) } + func openBook(_ book: Book, forPresentation prepareForPresentation: Bool, sender: UIViewController) -> AnyPublisher { + book.url() .flatMap { self.openPublication(at: $0, allowUserInteraction: true, sender: sender) } - .flatMap { publication in - guard !publication.isRestricted else { - if let error = publication.protectionError { - return .failure(error) - } else { - return .cancelled - } + .flatMap { (pub, _) in self.checkIsReadable(publication: pub) } + .handleEvents(receiveOutput: { self.preparePresentation(of: $0, book: book) }) + .eraseToAnyPublisher() + } + + /// Opens the Readium 2 Publication at the given `url`. + private func openPublication(at url: URL, allowUserInteraction: Bool, sender: UIViewController?) -> AnyPublisher<(Publication, MediaType), LibraryError> { + Future(on: .global()) { promise in + let asset = FileAsset(url: url) + guard let mediaType = asset.mediaType() else { + promise(.failure(.openFailed(Publication.OpeningError.unsupportedFormat))) + return + } + + self.streamer.open(asset: asset, allowUserInteraction: allowUserInteraction, sender: sender) { result in + switch result { + case .success(let publication): + promise(.success((publication, mediaType))) + case .failure(let error): + promise(.failure(.openFailed(error))) + case .cancelled: + promise(.failure(.cancelled)) } - - self.preparePresentation(of: publication, book: book) - return .success(publication) } - .mapError { LibraryError.openFailed($0) } - .resolve(completion) + }.eraseToAnyPublisher() } - /// Opens the Readium 2 Publication at the given `url`. - private func openPublication(at url: URL, allowUserInteraction: Bool, sender: UIViewController?) -> Deferred { - return deferred { - self.streamer.open(asset: FileAsset(url: url), allowUserInteraction: allowUserInteraction, sender: sender, completion: $0) + /// Checks if the publication is not still locked by a DRM. + private func checkIsReadable(publication: Publication) -> AnyPublisher { + guard !publication.isRestricted else { + if let error = publication.protectionError { + return .fail(.openFailed(error)) + } else { + return .fail(.cancelled) } - .eraseToAnyError() + } + return .just(publication) } private func preparePresentation(of publication: Publication, book: Book) { @@ -101,21 +120,14 @@ final class LibraryService: Loggable { // MARK: Importation /// Imports a bunch of publications. - func importPublications(from sourceURLs: [URL], sender: UIViewController, completion: @escaping (CancellableResult<(), LibraryError>) -> Void) { - var sourceURLs = sourceURLs - guard let url = sourceURLs.popFirst() else { - completion(.success(())) - return - } - - importPublication(from: url, sender: sender) { result in - switch result { - case .success, .cancelled: - self.importPublications(from: sourceURLs, sender: sender, completion: completion) - case .failure(let error): - completion(.failure(error)) + func importPublications(from sourceURLs: [URL], sender: UIViewController) -> AnyPublisher { + sourceURLs.publisher + .setFailureType(to: LibraryError.self) + .flatMap { + self.importPublication(from: $0, sender: sender) + .map { _ in } } - } + .eraseToAnyPublisher() } /// Imports the publication at the given `url` to the bookshelf. @@ -125,120 +137,167 @@ final class LibraryService: Loggable { /// /// DRM services are used to fulfill the publication, in case the URL locates a licensing /// document. - func importPublication(from sourceURL: URL, title: String? = nil, sender: UIViewController, completion: @escaping (CancellableResult) -> Void = { _ in }) { - downloadIfNeeded(sourceURL, title: title) - .flatMap { self.moveToDocuments($0) } + func importPublication(from sourceURL: URL, sender: UIViewController, progress: @escaping (Double) -> Void = { _ in }) -> AnyPublisher { + downloadIfNeeded(sourceURL, progress: progress) .flatMap { self.fulfillIfNeeded($0) } .flatMap { url in - self.openPublication(at: url, allowUserInteraction: false, sender: sender) - // Map on background because we will read the publication cover to create the - // `Book`, which might take some CPU time. - .map(on: .global(qos: .background)) { Book(publication: $0, url: url) } - } - .flatMap { self.insertBook($0) } - .mapError { LibraryError.importFailed($0) } - // FIXME: The Library should automatically observe the database instead. - .also { _ in - DispatchQueue.main.async { - self.delegate?.reloadLibrary() + self.openPublication(at: url, allowUserInteraction: false, sender: sender).flatMap { pub, mediaType in + self.moveToDocuments(from: url, title: pub.metadata.title, mediaType: mediaType).flatMap { url in + self.importCover(of: pub).flatMap { coverPath in + self.insertBook(at: url, publication: pub, mediaType: mediaType, coverPath: coverPath) + } + } } } - .resolve(completion) + .eraseToAnyPublisher() } /// Downloads `sourceURL` if it locates a remote file. - private func downloadIfNeeded(_ sourceURL: URL, title: String?) -> Deferred { - guard !sourceURL.isFileURL, sourceURL.scheme != nil else { - return .success(sourceURL) + private func downloadIfNeeded(_ url: URL, progress: @escaping (Double) -> Void) -> AnyPublisher { + guard !url.isFileURL, url.scheme != nil else { + return .just(url) } - return sourceURL.download(description: title) + return httpClient.download(url, progress: progress) + .map { $0.file } + .mapError { .downloadFailed($0) } + .eraseToAnyPublisher() } /// Fulfills the given `url` if it's a DRM license file. - private func fulfillIfNeeded(_ url: URL) -> Deferred { + private func fulfillIfNeeded(_ url: URL) -> AnyPublisher { guard let drmService = drmLibraryServices.first(where: { $0.canFulfill(url) }) else { - return .success(url) + return .just(url) } return drmService.fulfill(url) - .flatMap { download in - // Removes the license file if it's in the App directory (e.g. Inbox/) - if url.isAppFile { - try? FileManager.default.removeItem(at: url) + .mapError { LibraryError.downloadFailed($0) } + .flatMap { pub -> AnyPublisher in + guard let url = pub?.localURL else { + return .fail(.cancelled) } - - return self.moveToDocuments(download.localURL, suggestedFilename: download.suggestedFilename) + return .just(url) } + .eraseToAnyPublisher() } /// Moves the given `sourceURL` to the user Documents/ directory. - private func moveToDocuments(_ sourceURL: URL, suggestedFilename: String? = nil) -> Deferred { - return deferredCatching(on: .global(qos: .background)) { - // Necessary to read URL exported from the Files app, for example. - let shouldRelinquishAccess = sourceURL.startAccessingSecurityScopedResource() - defer { - if shouldRelinquishAccess { - sourceURL.stopAccessingSecurityScopedResource() + private func moveToDocuments(from source: URL, title: String, mediaType: MediaType) -> AnyPublisher { + Paths.makeDocumentURL(title: title, mediaType: mediaType) + .setFailureType(to: LibraryError.self) + .flatMap { destination in + Future(on: .global()) { promise in + // Necessary to read URL exported from the Files app, for example. + let shouldRelinquishAccess = source.startAccessingSecurityScopedResource() + defer { + if shouldRelinquishAccess { + source.stopAccessingSecurityScopedResource() + } + } + + do { + // If the source file is part of the app folder, we can move it. Otherwise we make a + // copy, to avoid deleting files from iCloud, for example. + if Paths.isAppFile(at: source) { + try FileManager.default.moveItem(at: source, to: destination) + } else { + try FileManager.default.copyItem(at: source, to: destination) + } + promise(.success(destination)) + } catch { + promise(.failure(LibraryError.importFailed(error))) + } } } + .eraseToAnyPublisher() + } + + /// Imports the publication cover and return its path relative to the Covers/ folder. + private func importCover(of publication: Publication) -> AnyPublisher { + Future(on: .global()) { promise in + guard let cover = publication.cover?.pngData() else { + promise(.success(nil)) + return + } + let coverURL = Paths.covers.appendingUniquePathComponent() - let destinationURL = self.documentDirectory.appendingUniquePathComponent(suggestedFilename ?? sourceURL.lastPathComponent) - - // If the source file is part of the app folder, we can move it. Otherwise we make a - // copy, to avoid deleting files from iCloud, for example. - if sourceURL.isAppFile { - try FileManager.default.moveItem(at: sourceURL, to: destinationURL) - } else { - try FileManager.default.copyItem(at: sourceURL, to: destinationURL) + do { + try cover.write(to: coverURL) + promise(.success(coverURL.lastPathComponent)) + } catch { + print(coverURL) + print(error) + promise(.failure(.importFailed(error))) } - return .success(destinationURL) - } + }.eraseToAnyPublisher() } /// Inserts the given `book` in the bookshelf. - /// - /// Use `allowDuplicate` to authorize or forbid duplicate books. When nil, the user will be - /// prompted to confirm the insertion. - private func insertBook(_ book: Book, allowDuplicate: Bool? = nil) -> Deferred { - return deferredCatching(on: .global(qos: .background)) { - guard try BooksDatabase.shared.books.insert(book: book, allowDuplicate: allowDuplicate ?? false) != nil else { - if allowDuplicate == false { - return .cancelled - } else { - // The book already exists, try again after confirming the import. - return self.confirmImportingDuplicate(book: book) - .flatMap { self.insertBook(book, allowDuplicate: true) } - } - } - - return .success(book) - } + private func insertBook(at url: URL, publication: Publication, mediaType: MediaType, coverPath: String?) -> AnyPublisher { + let book = Book( + identifier: publication.metadata.identifier, + title: publication.metadata.title, + authors: publication.metadata.authors + .map { $0.name } + .joined(separator: ", "), + type: mediaType.string, + path: (url.isFileURL || url.scheme == nil) ? url.lastPathComponent : url.absoluteString, + coverPath: coverPath + ) + + return books.add(book) + .map { _ in book } + .mapError { LibraryError.importFailed($0) } + .eraseToAnyPublisher() } - private func confirmImportingDuplicate(book: Book) -> Deferred { + private func confirmImportingDuplicate(book: Book) -> AnyPublisher { guard let delegate = delegate else { - return .success(()) + return .just(()) } return delegate.confirmImportingDuplicatePublication(withTitle: book.title) + .setFailureType(to: LibraryError.self) + .flatMap { confirmed -> AnyPublisher in + if confirmed { + return .just(()) + } else { + return .fail(.cancelled) + } + } + .eraseToAnyPublisher() } // MARK: Removing - func remove(_ book: Book) throws { - // Removes item from the database. - _ = try BooksDatabase.shared.books.delete(book) - - // Removes the file from Documents/ - if let url = try? book.url(), documentDirectory.isParentOf(url) { - try FileManager.default.removeItem(at: url) + func remove(_ book: Book) -> AnyPublisher { + guard let id = book.id else { + return .fail(.bookDeletionFailed(nil)) } // FIXME: ? - publicationServer.remove(at: book.href) + publicationServer.remove(at: book.path) + + return books.remove(id) + .mapError { LibraryError.bookDeletionFailed($0) } + .flatMap { book.url() } + .flatMap { self.removeBookFile(at: $0) } + .eraseToAnyPublisher() + } + + private func removeBookFile(at url: URL) -> AnyPublisher { + Future(on: .global()) { promise in + if Paths.documents.isParentOf(url) { + do { + try FileManager.default.removeItem(at: url) + } catch { + promise(.failure(.bookDeletionFailed(error))) + } + } + promise(.success(())) + }.eraseToAnyPublisher() } @@ -246,56 +305,77 @@ final class LibraryService: Loggable { /// Preloads the sample publications from the bundled Samples/ directory in the database, if /// needed. - func preloadSamples() throws { + func preloadSamples() -> AnyPublisher { let version = 1 let key = "LIBRARY_VERSION" let currentVersion = UserDefaults.standard.integer(forKey: key) guard currentVersion < version else { - return + return .just(()) } UserDefaults.standard.set(version, forKey: key) - let samplesPath = Bundle.main.resourceURL!.appendingPathComponent("Samples") - let sampleURLs = try FileManager.default.contentsOfDirectory(at: samplesPath, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) - loadSamples(from: sampleURLs) - } - - fileprivate func loadSamples(from urls: [URL]) { - var urls = urls - guard let url = urls.popFirst() else { - delegate?.reloadLibrary() - return - } - - openPublication(at: url, allowUserInteraction: false, sender: nil) - .map(on: .global(qos: .background)) { Book(publication: $0, url: url) } - .flatMap { self.insertBook($0, allowDuplicate: false) } - .resolve { result in - if case .failure(let error) = result { - self.log(.error, "Failed to import sample \(url.lastPathComponent): \(error)") + return samples().flatMap { url in + self.openPublication(at: url, allowUserInteraction: false, sender: nil).flatMap { pub, mediaType in + self.importCover(of: pub).flatMap { coverPath in + self.insertBook(at: url, publication: pub, mediaType: mediaType, coverPath: coverPath) } - - self.loadSamples(from: urls) } + .map { _ in } + }.eraseToAnyPublisher() } + private func samples() -> AnyPublisher { + do { + return try FileManager.default + .contentsOfDirectory(at: Paths.samples, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) + .publisher + .setFailureType(to: LibraryError.self) + .eraseToAnyPublisher() + } catch { + return .fail(.importFailed(error)) + } + } } private extension Book { - /// Creates a new `Book` from a Readium `Publication` and its URL. - convenience init(publication: Publication, url: URL) { - self.init( - href: (url.isFileURL || url.scheme == nil) ? url.lastPathComponent : url.absoluteString, - title: publication.metadata.title, - author: publication.metadata.authors - .map { $0.name } - .joined(separator: ", "), - identifier: publication.metadata.identifier ?? url.lastPathComponent, - cover: publication.cover?.pngData() - ) + func url() -> AnyPublisher { + // Absolute URL. + if let url = URL(string: path), url.scheme != nil { + return .just(url) + } + + // Absolute file path. + if path.hasPrefix("/") { + return .just(URL(fileURLWithPath: path)) + } + + return Future(on: .global()) { promise in + do { + // Path relative to Documents/. + let files = FileManager.default + let documents = try files.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + + let documentURL = documents.appendingPathComponent(path) + if (try? documentURL.checkResourceIsReachable()) == true { + return promise(.success(documentURL)) + } + + // Path relative to the Samples/ directory in the App bundle. + if + let sampleURL = Bundle.main.url(forResource: path, withExtension: nil, subdirectory: "Samples"), + (try? sampleURL.checkResourceIsReachable()) == true + { + return promise(.success(sampleURL)) + } + + promise(.failure(LibraryError.bookNotFound)) + + } catch { + promise(.failure(LibraryError.bookNotFound)) + } + }.eraseToAnyPublisher() } - } diff --git a/Sources/Library/LibraryViewController.swift b/Sources/Library/LibraryViewController.swift index c78c8d36..1514c910 100644 --- a/Sources/Library/LibraryViewController.swift +++ b/Sources/Library/LibraryViewController.swift @@ -10,6 +10,7 @@ // LICENSE file present in the project repository where this source code is maintained. // +import Combine import UIKit import MobileCoreServices import WebKit @@ -29,7 +30,7 @@ class LibraryViewController: UIViewController, Loggable { typealias Factory = DetailsTableViewControllerFactory var factory: Factory! - private var books: [Book]! + private var books: [Book] = [] weak var lastFlippedCell: PublicationCollectionViewCell? @@ -42,11 +43,9 @@ class LibraryViewController: UIViewController, Loggable { weak var libraryDelegate: LibraryModuleDelegate? - lazy var loadingIndicator = PublicationIndicator() + private var subscriptions = Set() - private var downloadSet = NSMutableOrderedSet() - private var downloadTaskToRatio = [URLSessionDownloadTask:Float]() - private var downloadTaskDescription = [URLSessionDownloadTask:String]() + lazy var loadingIndicator = PublicationIndicator() private lazy var addBookButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addBook)) @IBOutlet weak var collectionView: UICollectionView! { @@ -69,7 +68,17 @@ class LibraryViewController: UIViewController, Loggable { override func viewDidLoad() { super.viewDidLoad() - books = try! BooksDatabase.shared.books.all() + library.allBooks() + .receive(on: DispatchQueue.main) + .sink { completion in + if case .failure(let error) = completion { + self.libraryDelegate?.presentError(error, from: self) + } + } receiveValue: { newBooks in + self.books = newBooks + self.collectionView.reloadData() + } + .store(in: &subscriptions) // Add long press gesture recognizer. let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress)) @@ -79,8 +88,6 @@ class LibraryViewController: UIViewController, Loggable { collectionView.addGestureRecognizer(recognizer) collectionView.accessibilityLabel = NSLocalizedString("library_a11y_label", comment: "Accessibility label for the library collection view") - DownloadSession.shared.displayDelegate = self - self.navigationItem.rightBarButtonItem = addBookButton navigationController?.navigationBar.tintColor = #colorLiteral(red: 0, green: 0, blue: 0, alpha: 1) @@ -176,11 +183,14 @@ class LibraryViewController: UIViewController, Loggable { } func tryAdd(from url: URL) { - library.importPublication(from: url, sender: self) { result in - if case .failure(let error) = result { - retry(message: error.localizedDescription) - } - } + library.importPublication(from: url, sender: self) + .receive(on: DispatchQueue.main) + .sink { completion in + if case .failure(let error) = completion { + retry(message: error.localizedDescription) + } + } receiveValue: { _ in } + .store(in: &subscriptions) } let hideActivity = toastActivity(on: view) @@ -216,7 +226,6 @@ extension LibraryViewController { let location = gestureRecognizer.location(in: collectionView) if let indexPath = collectionView.indexPathForItem(at: location) { - if indexPath.item < downloadSet.count {return} let cell = collectionView.cellForItem(at: indexPath) as! PublicationCollectionViewCell cell.flipMenu() } @@ -230,19 +239,22 @@ extension LibraryViewController: UIDocumentPickerDelegate { guard controller.documentPickerMode == .import else { return } - library.importPublications(from: urls, sender: self) { result in - if case .failure(let error) = result { - self.libraryDelegate?.presentError(error, from: self) - } - } + importFiles(at: urls) } public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) { - library.importPublication(from: url, sender: self) { result in - if case .failure(let error) = result { - self.libraryDelegate?.presentError(error, from: self) - } - } + importFiles(at: [url]) + } + + private func importFiles(at urls: [URL]) { + library.importPublications(from: urls, sender: self) + .receive(on: DispatchQueue.main) + .sink { completion in + if case .failure(let error) = completion { + self.libraryDelegate?.presentError(error, from: self) + } + } receiveValue: { _ in } + .store(in: &subscriptions) } } @@ -250,20 +262,17 @@ extension LibraryViewController: UIDocumentPickerDelegate { // MARK: - CollectionView Datasource. extension LibraryViewController: UICollectionViewDelegateFlowLayout, UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - // No data to display. - if downloadSet.count == 0 && books.count == 0 { + if books.isEmpty { let noPublicationLabel = UILabel(frame: collectionView.frame) - noPublicationLabel.text = NSLocalizedString("library_empty_message", comment: "Hint message when the library is empty") noPublicationLabel.textColor = UIColor.gray noPublicationLabel.textAlignment = .center collectionView.backgroundView = noPublicationLabel - - return 0 } else { collectionView.backgroundView = nil - return downloadSet.count + books.count } + + return books.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { @@ -274,35 +283,20 @@ extension LibraryViewController: UICollectionViewDelegateFlowLayout, UICollectio cell.isAccessibilityElement = true cell.accessibilityHint = NSLocalizedString("library_publication_a11y_hint", comment: "Accessibility hint for the publication collection cell") - - if indexPath.item < downloadSet.count { - guard let task = downloadSet.object(at: indexPath.item) as? URLSessionDownloadTask else {return cell} - if let ratio = downloadTaskToRatio[task] { - cell.progress = ratio - } - - let downloadDescription = downloadTaskDescription[task] ?? "..." - let flowLayout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout - let textView = defaultCover(layout: flowLayout, description: downloadDescription) - cell.coverImageView.image = UIImage.imageWithTextView(textView: textView) - cell.accessibilityLabel = nil - cell.titleLabel.text = nil - cell.authorLabel.text = nil - - return cell - } - - let offset = indexPath.item-downloadSet.count - let book = books[offset] + let book = books[indexPath.item] cell.delegate = self cell.accessibilityLabel = book.title cell.titleLabel.text = book.title - cell.authorLabel.text = book.author + cell.authorLabel.text = book.authors // Load image and then apply the shadow. - if let cover = book.cover { - cell.coverImageView.image = UIImage(data: cover) + if + let coverURL = book.cover, + let data = try? Data(contentsOf: coverURL), + let cover = UIImage(data: data) + { + cell.coverImageView.image = cover } else { let flowLayout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout @@ -332,13 +326,6 @@ extension LibraryViewController: UICollectionViewDelegateFlowLayout, UICollectio guard let libraryDelegate = libraryDelegate else { return } - - let offset = downloadSet.count - let index = indexPath.item - offset - if (index < 0 || index >= books.count) {return} - - let book = books[index] - guard let cell = collectionView.cellForItem(at: indexPath) else {return} cell.contentView.addSubview(self.loadingIndicator) collectionView.isUserInteractionEnabled = false @@ -348,31 +335,25 @@ extension LibraryViewController: UICollectionViewDelegateFlowLayout, UICollectio collectionView.isUserInteractionEnabled = true } - library.openBook(book, forPresentation: true, sender: self) { result in - switch result { - case .success(let publication): - libraryDelegate.libraryDidSelectPublication(publication, book: book, completion: done) - - case .cancelled: - done() - - case .failure(let error): - self.libraryDelegate?.presentError(error, from: self) + let book = books[indexPath.item] + library.openBook(book, forPresentation: true, sender: self) + .receive(on: DispatchQueue.main) + .sink { completion in + if case .failure(let error) = completion { + self.libraryDelegate?.presentError(error, from: self) + } done() + } receiveValue: { pub in + libraryDelegate.libraryDidSelectPublication(pub, book: book, completion: done) } - } + .store(in: &subscriptions) } - } extension LibraryViewController: PublicationCollectionViewCellDelegate { func removePublicationFromLibrary(forCellAt indexPath: IndexPath) { - let offset = downloadSet.count - let index = indexPath.item-offset - if index >= self.books.count {return} - - let book = self.books[index] + let book = self.books[indexPath.item] let removePublicationAlert = UIAlertController( title: NSLocalizedString("library_delete_alert_title", comment: "Title of the publication remove confirmation alert"), @@ -380,28 +361,15 @@ extension LibraryViewController: PublicationCollectionViewCellDelegate { preferredStyle: .alert ) let removeAction = UIAlertAction(title: NSLocalizedString("remove_button", comment: "Button to confirm the deletion of a publication"), style: .destructive, handler: { alert in - // Remove the publication from publicationServer and Documents folder. - let newOffset = self.downloadSet.count - guard let newIndex = self.books.firstIndex(where: { (element) -> Bool in - book.id == element.id - }) else {return} - let newIndexPath = IndexPath(item: newOffset+newIndex, section: 0) - - do { - try self.library.remove(book) - self.books.remove(at: index) - - self.collectionView.performBatchUpdates({ - self.collectionView.deleteItems(at: [newIndexPath]) - }, completion: nil) - - } catch { - self.libraryDelegate?.presentError(error, from: self) - } - }) - let cancelAction = UIAlertAction(title: NSLocalizedString("cancel_button", comment: "Button to cancel the deletion of a publication"), style: .cancel, handler: { alert in - return + self.library.remove(book) + .sink { completion in + if case .failure(let error) = completion { + self.libraryDelegate?.presentError(error, from: self) + } + } receiveValue: {} + .store(in: &self.subscriptions) }) + let cancelAction = UIAlertAction(title: NSLocalizedString("cancel_button", comment: "Button to cancel the deletion of a publication"), style: .cancel) removePublicationAlert.addAction(removeAction) removePublicationAlert.addAction(cancelAction) @@ -411,20 +379,18 @@ extension LibraryViewController: PublicationCollectionViewCellDelegate { func displayInformation(forCellAt indexPath: IndexPath) { let book = books[indexPath.row] - library.openBook(book, forPresentation: false, sender: self) { result in - switch result { - case .success(let publication): - let detailsViewController = self.factory.make(publication: publication) + library.openBook(book, forPresentation: true, sender: self) + .receive(on: DispatchQueue.main) + .sink { completion in + if case .failure(let error) = completion { + self.libraryDelegate?.presentError(error, from: self) + } + } receiveValue: { pub in + let detailsViewController = self.factory.make(publication: pub) detailsViewController.modalPresentationStyle = .popover self.navigationController?.pushViewController(detailsViewController, animated: true) - - case .failure(let error): - self.libraryDelegate?.presentError(error, from: self) - - case .cancelled: - break } - } + .store(in: &subscriptions) } // Used to reset ui of the last flipped cell, we must not have two cells @@ -435,126 +401,16 @@ extension LibraryViewController: PublicationCollectionViewCellDelegate { } } -extension LibraryViewController: DownloadDisplayDelegate { - - func didStartDownload(task: URLSessionDownloadTask, description: String) { - let offset = downloadSet.count - downloadSet.add(task) - downloadTaskToRatio[task] = 0 - downloadTaskDescription[task] = description - let newIndexPath = IndexPath(item: offset, section: 0) - - self.collectionView.performBatchUpdates({ - self.collectionView.insertItems(at: [newIndexPath]) - }, completion: nil) - } - - func didFinishDownload(task:URLSessionDownloadTask) { - books = try! BooksDatabase.shared.books.all() - - let offset = downloadSet.index(of: task) - downloadSet.remove(task) - downloadTaskToRatio.removeValue(forKey: task) - downloadTaskDescription.removeValue(forKey: task) - - let theIndexPath = IndexPath(item: offset, section: 0) - let newIndexPath = IndexPath(item: downloadSet.count, section: 0) - - if newIndexPath == theIndexPath { - self.collectionView.reloadItems(at: [newIndexPath]) - return - } - - self.collectionView.performBatchUpdates({ - collectionView.moveItem(at: theIndexPath, to: newIndexPath) - }, completion: { (_) in - self.collectionView.reloadItems(at: [newIndexPath]) - }) - } - - func didFailWithError(task:URLSessionDownloadTask, error: Error?) { - let offset = downloadSet.index(of: task) - guard offset != NSNotFound else { - return - } - - downloadSet.remove(task) - downloadTaskToRatio.removeValue(forKey: task) - let description = downloadTaskDescription[task] ?? "" - downloadTaskDescription.removeValue(forKey: task) - - let indexPath = IndexPath(item: offset, section: 0) - self.collectionView.performBatchUpdates({ - collectionView.deleteItems(at: [indexPath]) - }) { [weak self] _ in - guard let self = self else { - return - } - self.libraryDelegate?.presentError(LibraryError.downloadFailed(description), from: self) - } - } - - func didUpdateDownloadPercentage(task:URLSessionDownloadTask, percentage: Float) { - - downloadTaskToRatio[task] = percentage - - let index = downloadSet.index(of: task) - let indexPath = IndexPath(item: index, section: 0) - - DispatchQueue.main.async { - guard let cell = self.collectionView.cellForItem(at: indexPath) as? PublicationCollectionViewCell else {return} - cell.progress = percentage - } - } - - func insertNewItemWithUpdatedDataSource() { - books = try! BooksDatabase.shared.books.all() - - let offset = downloadSet.count - let newIndexPath = IndexPath(item: offset, section: 0) - collectionView.performBatchUpdates({ - collectionView.insertItems(at: [newIndexPath]) - }, completion: { [weak self] _ in - guard let `self` = self else { return } - self.libraryDelegate?.presentAlert( - NSLocalizedString("success_title", comment: "Title of the alert when a publication is successfully imported"), - message: NSLocalizedString("library_import_success_message", comment: "Title of the alert when a publication is successfully imported"), - from: self - ) - }) - } - - func didCancel(task: URLSessionDownloadTask) { - let offset = downloadSet.index(of: task) - downloadSet.remove(task) - downloadTaskToRatio.removeValue(forKey: task) - downloadTaskDescription.removeValue(forKey: task) - - let theIndexPath = IndexPath(item: offset, section: 0) - - self.collectionView.performBatchUpdates({ - collectionView.deleteItems(at: [theIndexPath]) - }) - } - -} - extension LibraryViewController: LibraryServiceDelegate { - func reloadLibrary() { - // FIXME: More efficient reloading - books = try! BooksDatabase.shared.books.all() - collectionView.reloadData() - } - - func confirmImportingDuplicatePublication(withTitle title: String) -> Deferred { - return deferred(on: .main) { success, _, cancel in + func confirmImportingDuplicatePublication(withTitle title: String) -> AnyPublisher { + Future(on: .main) { promise in let confirmAction = UIAlertAction(title: NSLocalizedString("add_button", comment: "Confirmation button to import a duplicated publication"), style: .default) { _ in - success(()) + promise(.success(true)) } let cancelAction = UIAlertAction(title: NSLocalizedString("cancel_button", comment: "Cancel the confirmation alert"), style: .cancel) { _ in - cancel() + promise(.success(false)) } let alert = UIAlertController( @@ -566,7 +422,7 @@ extension LibraryViewController: LibraryServiceDelegate { alert.addAction(cancelAction) self.present(alert, animated: true) - } + }.eraseToAnyPublisher() } } @@ -575,7 +431,7 @@ class PublicationIndicator: UIView { lazy var indicator: UIActivityIndicatorView = { - let result = UIActivityIndicatorView(style: UIActivityIndicatorView.Style.whiteLarge) + let result = UIActivityIndicatorView(style: .large) result.translatesAutoresizingMaskIntoConstraints = false self.backgroundColor = UIColor(white: 0.3, alpha: 0.7) self.addSubview(result) diff --git a/Sources/OPDS/OPDSGroupTableViewCell.swift b/Sources/OPDS/OPDSGroupTableViewCell.swift index 1c876c9c..a721a2b8 100644 --- a/Sources/OPDS/OPDSGroupTableViewCell.swift +++ b/Sources/OPDS/OPDSGroupTableViewCell.swift @@ -104,16 +104,12 @@ extension OPDSGroupTableViewCell: UICollectionViewDataSource { ?? publication.images.first.flatMap { URL(string: $0.href) } if let coverURL = coverURL { - UIApplication.shared.isNetworkActivityIndicatorVisible = true cell.coverImageView.kf.setImage( with: coverURL, placeholder: titleTextView, options: [.transition(ImageTransition.fade(0.5))], - progressBlock: nil) { _ in - DispatchQueue.main.async { - UIApplication.shared.isNetworkActivityIndicatorVisible = false - } - } + progressBlock: nil + ) { _ in } } cell.titleLabel.text = publication.metadata.title diff --git a/Sources/OPDS/OPDSModule.swift b/Sources/OPDS/OPDSModule.swift index 35a1b372..1b050344 100644 --- a/Sources/OPDS/OPDSModule.swift +++ b/Sources/OPDS/OPDSModule.swift @@ -10,6 +10,7 @@ // LICENSE file present in the project repository where this source code is maintained. // +import Combine import Foundation import UIKit import R2Shared @@ -29,7 +30,7 @@ protocol OPDSModuleAPI { protocol OPDSModuleDelegate: ModuleDelegate { /// Called when an OPDS publication needs to be downloaded. - func opdsDownloadPublication(_ publication: Publication?, at link: Link, sender: UIViewController, completion: @escaping (CancellableResult) -> Void) + func opdsDownloadPublication(_ publication: Publication?, at link: Link, sender: UIViewController) -> AnyPublisher } diff --git a/Sources/OPDS/OPDSPublicationInfoViewController.swift b/Sources/OPDS/OPDSPublicationInfoViewController.swift index 3b4598f2..a44d4cec 100644 --- a/Sources/OPDS/OPDSPublicationInfoViewController.swift +++ b/Sources/OPDS/OPDSPublicationInfoViewController.swift @@ -10,6 +10,7 @@ // LICENSE file present in the project repository where this source code is maintained. // +import Combine import UIKit import R2Shared import Kingfisher @@ -33,6 +34,7 @@ class OPDSPublicationInfoViewController: UIViewController, Loggable { @IBOutlet weak var downloadActivityIndicator: UIActivityIndicatorView! private lazy var downloadLink: Link? = publication?.downloadLinks.first + private var subscriptions = Set() override func viewDidLoad() { fxImageView.clipsToBounds = true @@ -51,16 +53,12 @@ class OPDSPublicationInfoViewController: UIViewController, Loggable { if images.count > 0 { let coverURL = URL(string: images[0].href) if (coverURL != nil) { - UIApplication.shared.isNetworkActivityIndicatorVisible = true imageView.kf.setImage( with: coverURL, placeholder: titleTextView, options: [.transition(ImageTransition.fade(0.5))], progressBlock: nil ) { result in - DispatchQueue.main.async { - UIApplication.shared.isNetworkActivityIndicatorVisible = false - } switch result { case .success(let image): self.fxImageView?.image = image.image @@ -102,29 +100,23 @@ class OPDSPublicationInfoViewController: UIViewController, Loggable { downloadActivityIndicator.startAnimating() downloadButton.isEnabled = false - delegate.opdsDownloadPublication(publication, at: downloadLink, sender: self) { [weak self] result in - guard let self = self else { - return - } - - self.downloadActivityIndicator.stopAnimating() - self.downloadButton.isEnabled = true - - switch result { - case .success(let book): + delegate.opdsDownloadPublication(publication, at: downloadLink, sender: self) + .receive(on: DispatchQueue.main) + .sink { completion in + self.downloadActivityIndicator.stopAnimating() + self.downloadButton.isEnabled = true + + if case .failure(let error) = completion { + delegate.presentError(error, from: self) + } + } receiveValue: { book in delegate.presentAlert( NSLocalizedString("success_title", comment: "Title of the alert when a publication is successfully downloaded"), message: String(format: NSLocalizedString("library_download_success_message", comment: "Message of the alert when a publication is successfully downloaded"), book.title), from: self ) - - case .failure(let error): - delegate.presentError(error, from: self) - - case .cancelled: - break } - } + .store(in: &subscriptions) } } diff --git a/Sources/OPDS/OPDSPublicationTableViewCell.swift b/Sources/OPDS/OPDSPublicationTableViewCell.swift index cd91e0ff..61af5cb9 100644 --- a/Sources/OPDS/OPDSPublicationTableViewCell.swift +++ b/Sources/OPDS/OPDSPublicationTableViewCell.swift @@ -79,16 +79,11 @@ extension OPDSPublicationTableViewCell: UICollectionViewDataSource { ?? publication.images.first.flatMap { URL(string: $0.href) } if let coverURL = coverURL { - UIApplication.shared.isNetworkActivityIndicatorVisible = true cell.coverImageView.kf.setImage( with: coverURL, placeholder: titleTextView, options: [.transition(ImageTransition.fade(0.5))], - progressBlock: nil) { _ in - DispatchQueue.main.async { - UIApplication.shared.isNetworkActivityIndicatorVisible = false - } - } + progressBlock: nil) { _ in } } else { cell.coverImageView.addSubview(titleTextView) } @@ -99,11 +94,9 @@ extension OPDSPublicationTableViewCell: UICollectionViewDataSource { .joined(separator: ", ") if indexPath.row == publications.count - 3 { - UIApplication.shared.isNetworkActivityIndicatorVisible = true opdsRootTableViewController?.loadNextPage(completionHandler: { (feed) in self.feed = feed collectionView.reloadData() - UIApplication.shared.isNetworkActivityIndicatorVisible = false }) } diff --git a/Sources/Reader/CBZ/CBZModule.swift b/Sources/Reader/CBZ/CBZModule.swift index ec8ab477..85e6823b 100644 --- a/Sources/Reader/CBZ/CBZModule.swift +++ b/Sources/Reader/CBZ/CBZModule.swift @@ -27,8 +27,8 @@ final class CBZModule: ReaderFormatModule { return [.cbz] } - func makeReaderViewController(for publication: Publication, book: Book, resourcesServer: ResourcesServer) throws -> UIViewController { - let cbzVC = CBZViewController(publication: publication, book: book) + func makeReaderViewController(for publication: Publication, locator: Locator?, bookId: Book.Id, books: BookRepository, bookmarks: BookmarkRepository, resourcesServer: ResourcesServer) throws -> UIViewController { + let cbzVC = CBZViewController(publication: publication, locator: locator, bookId: bookId, books: books, bookmarks: bookmarks) cbzVC.moduleDelegate = self.delegate return cbzVC } diff --git a/Sources/Reader/CBZ/CBZViewController.swift b/Sources/Reader/CBZ/CBZViewController.swift index d260e713..1881d085 100644 --- a/Sources/Reader/CBZ/CBZViewController.swift +++ b/Sources/Reader/CBZ/CBZViewController.swift @@ -18,10 +18,10 @@ import R2Streamer class CBZViewController: ReaderViewController { - init(publication: Publication, book: Book) { - let navigator = CBZNavigatorViewController(publication: publication, initialLocation: book.progressionLocator) + init(publication: Publication, locator: Locator?, bookId: Book.Id, books: BookRepository, bookmarks: BookmarkRepository) { + let navigator = CBZNavigatorViewController(publication: publication, initialLocation: locator) - super.init(navigator: navigator, publication: publication, book: book) + super.init(navigator: navigator, publication: publication, bookId: bookId, books: books, bookmarks: bookmarks) navigator.delegate = self } @@ -33,18 +33,11 @@ class CBZViewController: ReaderViewController { } override var currentBookmark: Bookmark? { - guard - let locator = navigator.currentLocation, - let resourceIndex = publication.readingOrder.firstIndex(withHREF: locator.href) else - { + guard let locator = navigator.currentLocation else { return nil } - return Bookmark( - bookID: book.id, - resourceIndex: resourceIndex, - locator: locator - ) + return Bookmark(bookId: bookId, locator: locator) } } diff --git a/Sources/Reader/Common/Bookmark/Bookmark.swift b/Sources/Reader/Common/Bookmark/Bookmark.swift deleted file mode 100644 index 34e574c8..00000000 --- a/Sources/Reader/Common/Bookmark/Bookmark.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// Bookmark.swift -// r2-testapp-swift -// -// Created by Mickaël Menu on 04.04.19. -// -// Copyright 2019 Readium Foundation. All rights reserved. -// Use of this source code is governed by a BSD-style license which is detailed -// in the LICENSE file present in the project repository where this source code is maintained. -// - -import Foundation -import R2Shared - - -public class Bookmark { - - public var id: Int64? - public var bookID: Int64 - public var resourceIndex: Int - public var locator: Locator - public var creationDate: Date - - public init(id: Int64? = nil, bookID: Int64, resourceIndex: Int, locator: Locator, creationDate: Date = Date()) { - self.id = id - self.bookID = bookID - self.resourceIndex = resourceIndex - self.locator = locator - self.creationDate = creationDate - } - -} diff --git a/Sources/Reader/Common/Bookmark/BookmarkDataSource.swift b/Sources/Reader/Common/Bookmark/BookmarkDataSource.swift deleted file mode 100644 index 8ca9cb44..00000000 --- a/Sources/Reader/Common/Bookmark/BookmarkDataSource.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// BookmarkDataSource.swift -// r2-testapp-swift -// -// Created by Senda Li on 2018/7/19. -// -// Copyright 2018 European Digital Reading Lab. All rights reserved. -// Licensed to the Readium Foundation under one or more contributor license agreements. -// Use of this source code is governed by a BSD-style license which is detailed in the -// LICENSE file present in the project repository where this source code is maintained. -// - -import Foundation -import R2Shared - -class BookmarkDataSource: Loggable { - - let bookID: Int64? - private(set) var bookmarks = [Bookmark]() - - init() { - self.bookID = nil - self.reloadBookmarks() - } - - init(bookID: Int64) { - self.bookID = bookID - self.reloadBookmarks() - } - - func reloadBookmarks() { - if let list = try? BookmarkDatabase.shared.bookmarks.bookmarkList(for: self.bookID) { - self.bookmarks = list - self.bookmarks.sort { (b1, b2) -> Bool in - if b1.resourceIndex == b2.resourceIndex { - let locations1 = b1.locator.locations - let locations2 = b2.locator.locations - if let position1 = locations1.position, let position2 = locations2.position { - return position1 < position2 - } else if let progression1 = locations1.progression, let progression2 = locations2.progression { - return progression1 < progression2 - } - } - return b1.resourceIndex < b2.resourceIndex - } - } - } - - var count: Int { - return bookmarks.count - } - - func bookmark(at index: Int) -> Bookmark? { - guard bookmarks.indices.contains(index) else { - return nil - } - return bookmarks[index] - } - - func addBookmark(bookmark: Bookmark) -> Bool { - do { - if let bookmarkID = try BookmarkDatabase.shared.bookmarks.insert(newBookmark: bookmark) { - bookmark.id = bookmarkID - self.reloadBookmarks() - return true - } - return false - } catch { - log(.error, error) - return false - } - } - - func removeBookmark(index: Int) -> Bool { - if index < 0 || index >= bookmarks.count { - return false - } - let bookmark = bookmarks[index] - guard let deleted = try? BookmarkDatabase.shared.bookmarks.delete(bookmark:bookmark) else { - return false - } - - if deleted { - bookmarks.remove(at:index) - return true - } - return false - } - - func bookmarked(index: Int, progress: Double) -> Bool { - return false - } -} diff --git a/Sources/Reader/Common/Bookmark/BookmarkDatabase.swift b/Sources/Reader/Common/Bookmark/BookmarkDatabase.swift deleted file mode 100644 index 235847b7..00000000 --- a/Sources/Reader/Common/Bookmark/BookmarkDatabase.swift +++ /dev/null @@ -1,160 +0,0 @@ -// -// BookmarkDatabase.swift -// r2-testapp-swift -// -// Created by Senda Li on 2018/7/20. -// -// Copyright 2018 European Digital Reading Lab. All rights reserved. -// Licensed to the Readium Foundation under one or more contributor license agreements. -// Use of this source code is governed by a BSD-style license which is detailed in the -// LICENSE file present in the project repository where this source code is maintained. -// - -import Foundation -import SQLite -import R2Shared - -final class BookmarkDatabase { - // Shared instance. - public static let shared = BookmarkDatabase() - - // Connection. - let connection: Connection - // The DB table for bookmark. - let bookmarks: BookmarksTable! - - private init() { - do { - var url = try FileManager.default.url(for: .libraryDirectory, - in: .userDomainMask, - appropriateFor: nil, create: true) - - url.appendPathComponent("bookmarks_database") - connection = try Connection(url.absoluteString) - bookmarks = BookmarksTable(connection) - - - } catch { - fatalError("Error initializing db.") - } - } -} - -class BookmarksTable { - - let tableName = Table("BOOKMARKS") - - let bookmarkID = Expression("id") - let bookID = Expression("bookID") - - let resourceIndex = Expression("resourceIndex") - let resourceHref = Expression("resourceHref") - let resourceTitle = Expression("resourceTitle") - let resourceType = Expression("resourceType") - - let locations = Expression("locations") - let locatorText = Expression("locatorText") - let creationDate = Expression("creationDate") - - - init(_ connection: Connection) { - // connection.userVersion = 0 - if connection.userVersion == 0 { - // handle first migration - connection.userVersion = 1 - // upgrade database columns - // drop table and recreate, this will delete all prior bookmarks - _ = try? connection.run(tableName.drop()) - } - - _ = try? connection.run(tableName.create(temporary: false, ifNotExists: true) { t in - t.column(bookmarkID, primaryKey: PrimaryKey.autoincrement) - t.column(creationDate) - t.column(bookID) - t.column(resourceHref) - t.column(resourceIndex) - t.column(resourceType) - t.column(locations) - t.column(locatorText) - t.column(resourceTitle) - }) - } - - func insert(newBookmark: Bookmark) throws -> Int64? { - let db = BookmarkDatabase.shared.connection - - let bookmark = tableName.filter(self.bookID == newBookmark.bookID && self.resourceHref == newBookmark.locator.href && self.resourceIndex == newBookmark.resourceIndex && self.locations == (newBookmark.locator.locations.jsonString ?? "")) - - // Check if empty. - guard try db.count(bookmark) == 0 else { - return nil - } - - let insertQuery = tableName.insert( - creationDate <- newBookmark.creationDate, - bookID <- newBookmark.bookID, - resourceHref <- newBookmark.locator.href, - resourceIndex <- newBookmark.resourceIndex, - resourceType <- newBookmark.locator.type, - locations <- newBookmark.locator.locations.jsonString ?? "", - locatorText <- newBookmark.locator.text.jsonString ?? "", - resourceTitle <- newBookmark.locator.title ?? "" - ) - - return try db.run(insertQuery) - } - - func delete(bookmark: Bookmark) throws -> Bool { - return try delete(bookmarkID: bookmark.id!) - } - - func delete(bookmarkID: Int64) throws -> Bool { - let db = BookmarkDatabase.shared.connection - let bookmark = tableName.filter(self.bookmarkID == bookmarkID) - - // Check if empty. - guard try db.count(bookmark) > 0 else { - return false - } - - try db.run(bookmark.delete()) - return true - } - - func bookmarkList(for bookID: Int64? = nil, resourceIndex: Int? = nil) throws -> [Bookmark]? { - - let db = BookmarkDatabase.shared.connection - // Check if empty. - guard try db.count(tableName) > 0 else { - return nil - } - - let resultList = try { () -> AnySequence in - if let fetchingID = bookID { - if let fetchingIndex = resourceIndex { - let query = self.tableName.filter(self.bookID == fetchingID && self.resourceIndex == fetchingIndex) - return try db.prepare(query) - } - let query = self.tableName.filter(self.bookID == fetchingID) - return try db.prepare(query) - } - return try db.prepare(self.tableName) - } () - - return resultList.map { row in - Bookmark( - id: row[self.bookmarkID], - bookID: row[self.bookID], - resourceIndex: row[self.resourceIndex], - locator: Locator( - href: row[self.resourceHref], - type: row[self.resourceType], - title: row[self.resourceTitle], - locations: Locator.Locations(jsonString: row[self.locations]), - text: Locator.Text(jsonString: row[self.locatorText]) - ), - creationDate: row[self.creationDate] - ) - } - } -} diff --git a/Sources/Reader/Common/Outline/OutlineTableViewController.swift b/Sources/Reader/Common/Outline/OutlineTableViewController.swift index 9a6e267d..677b4159 100644 --- a/Sources/Reader/Common/Outline/OutlineTableViewController.swift +++ b/Sources/Reader/Common/Outline/OutlineTableViewController.swift @@ -10,17 +10,17 @@ // LICENSE file present in the project repository where this source code is maintained. // +import Combine import R2Shared import R2Navigator import UIKit protocol OutlineTableViewControllerFactory { - func make(publication: Publication) -> OutlineTableViewController + func make(publication: Publication, bookId: Book.Id, bookmarks: BookmarkRepository) -> OutlineTableViewController } protocol OutlineTableViewControllerDelegate: AnyObject { - var bookmarksDataSource: BookmarkDataSource? { get } func outline(_ outlineTableViewController: OutlineTableViewController, goTo location: Locator) } @@ -33,13 +33,14 @@ final class OutlineTableViewController: UITableViewController { let kContentCell = "kContentCell" var publication: Publication! + var bookId: Book.Id! + var bookmarkRepository: BookmarkRepository! // Outlines (list of links) to display for each section. private var outlines: [Section: [(level: Int, link: Link)]] = [:] - - var bookmarksDataSource: BookmarkDataSource? { - return delegate?.bookmarksDataSource - } + private var bookmarks: [Bookmark] = [] + + private var subscriptions = Set() @IBOutlet weak var segments: UISegmentedControl! @IBAction func segmentChanged(_ sender: Any) { @@ -75,15 +76,21 @@ final class OutlineTableViewController: UITableViewController { .landmarks: flatten(publication.landmarks), .pageList: flatten(publication.pageList) ] + + bookmarkRepository.all(for: bookId) + .assertNoFailure() + .sink { bookmarks in + self.bookmarks = bookmarks + self.tableView.reloadData() + } + .store(in: &subscriptions) + } func locator(at indexPath: IndexPath) -> Locator? { switch section { case .bookmarks: - guard let bookmark = bookmarksDataSource?.bookmark(at: indexPath.row) else { - return nil - } - return bookmark.locator + return bookmarks[indexPath.row].locator default: guard let outline = outlines[section], @@ -117,7 +124,6 @@ final class OutlineTableViewController: UITableViewController { override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { switch section { - case .bookmarks: let cell: BookmarkCell = { if let cell = tableView.dequeueReusableCell(withIdentifier: kBookmarkCell) as? BookmarkCell { @@ -126,19 +132,18 @@ final class OutlineTableViewController: UITableViewController { return BookmarkCell(style: UITableViewCell.CellStyle.subtitle, reuseIdentifier: kBookmarkCell) } () - if let bookmark = bookmarksDataSource?.bookmark(at: indexPath.item) { - cell.textLabel?.text = bookmark.locator.title - cell.formattedDate = bookmark.creationDate - cell.detailTextLabel?.text = { - if let position = bookmark.locator.locations.position { - return String(format: NSLocalizedString("reader_outline_position_label", comment: "Outline bookmark label when the position is available"), position) - } else if let progression = bookmark.locator.locations.progression { - return String(format: NSLocalizedString("reader_outline_progression_label", comment: "Outline bookmark label when the progression is available"), progression * 100) - } else { - return nil - } - }() - } + let bookmark = bookmarks[indexPath.row] + cell.textLabel?.text = bookmark.locator.title + cell.formattedDate = bookmark.created + cell.detailTextLabel?.text = { + if let position = bookmark.locator.locations.position { + return String(format: NSLocalizedString("reader_outline_position_label", comment: "Outline bookmark label when the position is available"), position) + } else if let progression = bookmark.locator.locations.progression { + return String(format: NSLocalizedString("reader_outline_progression_label", comment: "Outline bookmark label when the progression is available"), progression * 100) + } else { + return nil + } + }() return cell default: @@ -156,7 +161,7 @@ final class OutlineTableViewController: UITableViewController { override func tableView(_ tableView: UITableView, numberOfRowsInSection tableSection: Int) -> Int { switch section { case .bookmarks: - return bookmarksDataSource?.count ?? 0 + return bookmarks.count default: return outlines[section]?.count ?? 0 } @@ -181,9 +186,11 @@ final class OutlineTableViewController: UITableViewController { switch section { case .bookmarks: if editingStyle == .delete { - if (self.bookmarksDataSource?.removeBookmark(index: indexPath.item) ?? false) { - tableView.deleteRows(at: [indexPath], with: .fade) - } + let bookmark = bookmarks[indexPath.row] + bookmarkRepository.remove(bookmark.id!) + .assertNoFailure() + .sink {} + .store(in: &subscriptions) } default: diff --git a/Sources/Reader/Common/ReaderViewController.swift b/Sources/Reader/Common/ReaderViewController.swift index aeedcca3..489d33ab 100644 --- a/Sources/Reader/Common/ReaderViewController.swift +++ b/Sources/Reader/Common/ReaderViewController.swift @@ -10,6 +10,7 @@ // LICENSE file present in the project repository where this source code is maintained. // +import Combine import SafariServices import UIKit import R2Navigator @@ -25,12 +26,13 @@ class ReaderViewController: UIViewController, Loggable { let navigator: UIViewController & Navigator let publication: Publication - let book: Book + let bookId: Book.Id + private let books: BookRepository + private let bookmarks: BookmarkRepository - lazy var bookmarksDataSource: BookmarkDataSource? = BookmarkDataSource(bookID: book.id) - private(set) var stackView: UIStackView! private lazy var positionLabel = UILabel() + private var subscriptions = Set() /// This regex matches any string with at least 2 consecutive letters (not limited to ASCII). /// It's used when evaluating whether to display the body of a noteref referrer as the note's title. @@ -39,14 +41,16 @@ class ReaderViewController: UIViewController, Loggable { return try! NSRegularExpression(pattern: "[\\p{Ll}\\p{Lu}\\p{Lt}\\p{Lo}]{2}") }() - init(navigator: UIViewController & Navigator, publication: Publication, book: Book) { + init(navigator: UIViewController & Navigator, publication: Publication, bookId: Book.Id, books: BookRepository, bookmarks: BookmarkRepository) { self.navigator = navigator self.publication = publication - self.book = book + self.bookId = bookId + self.books = books + self.bookmarks = bookmarks super.init(nibName: nil, bundle: nil) - NotificationCenter.default.addObserver(self, selector: #selector(voiceOverStatusDidChange), name: Notification.Name(UIAccessibilityVoiceOverStatusChanged), object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(voiceOverStatusDidChange), name: UIAccessibility.voiceOverStatusDidChangeNotification, object: nil) } @available(*, unavailable) @@ -156,21 +160,28 @@ class ReaderViewController: UIViewController, Loggable { // MARK: - Outlines @objc func presentOutline() { - moduleDelegate?.presentOutline(of: publication, delegate: self, from: self) + moduleDelegate?.presentOutline(of: publication, bookId: bookId, delegate: self, from: self) } // MARK: - Bookmarks @objc func bookmarkCurrentPosition() { - guard let dataSource = bookmarksDataSource, - let bookmark = currentBookmark, - dataSource.addBookmark(bookmark: bookmark) else - { - toast(NSLocalizedString("reader_bookmark_failure_message", comment: "Error message when adding a new bookmark failed"), on: view, duration: 2) + guard let bookmark = currentBookmark else { return } - toast(NSLocalizedString("reader_bookmark_success_message", comment: "Success message when adding a bookmark"), on: view, duration: 1) + + bookmarks.add(bookmark) + .sink { completion in + switch completion { + case .finished: + toast(NSLocalizedString("reader_bookmark_success_message", comment: "Success message when adding a bookmark"), on: self.view, duration: 1) + case .failure(let error): + print(error) + toast(NSLocalizedString("reader_bookmark_failure_message", comment: "Error message when adding a new bookmark failed"), on: self.view, duration: 2) + } + } receiveValue: { _ in } + .store(in: &subscriptions) } @@ -245,11 +256,13 @@ class ReaderViewController: UIViewController, Loggable { extension ReaderViewController: NavigatorDelegate { func navigator(_ navigator: Navigator, locationDidChange locator: Locator) { - do { - try BooksDatabase.shared.books.saveProgression(locator, of: book) - } catch { - log(.error, error) - } + books.saveProgress(for: bookId, locator: locator) + .sink { completion in + if case .failure(let error) = completion { + self.moduleDelegate?.presentError(error, from: self) + } + } receiveValue: { _ in } + .store(in: &subscriptions) positionLabel.text = { if let position = locator.locations.position { diff --git a/Sources/Reader/EPUB/EPUBModule.swift b/Sources/Reader/EPUB/EPUBModule.swift index 62b13cdb..ee4673cd 100644 --- a/Sources/Reader/EPUB/EPUBModule.swift +++ b/Sources/Reader/EPUB/EPUBModule.swift @@ -27,12 +27,12 @@ final class EPUBModule: ReaderFormatModule { return [.epub, .webpub] } - func makeReaderViewController(for publication: Publication, book: Book, resourcesServer: ResourcesServer) throws -> UIViewController { + func makeReaderViewController(for publication: Publication, locator: Locator?, bookId: Book.Id, books: BookRepository, bookmarks: BookmarkRepository, resourcesServer: ResourcesServer) throws -> UIViewController { guard publication.metadata.identifier != nil else { throw ReaderError.epubNotValid } - let epubViewController = EPUBViewController(publication: publication, book: book, resourcesServer: resourcesServer) + let epubViewController = EPUBViewController(publication: publication, locator: locator, bookId: bookId, books: books, bookmarks: bookmarks, resourcesServer: resourcesServer) epubViewController.moduleDelegate = delegate return epubViewController } diff --git a/Sources/Reader/EPUB/EPUBViewController.swift b/Sources/Reader/EPUB/EPUBViewController.swift index 6a0a43c3..08b0eba1 100644 --- a/Sources/Reader/EPUB/EPUBViewController.swift +++ b/Sources/Reader/EPUB/EPUBViewController.swift @@ -19,8 +19,8 @@ class EPUBViewController: ReaderViewController { var popoverUserconfigurationAnchor: UIBarButtonItem? var userSettingNavigationController: UserSettingsNavigationController - init(publication: Publication, book: Book, resourcesServer: ResourcesServer) { - let navigator = EPUBNavigatorViewController(publication: publication, initialLocation: book.progressionLocator, resourcesServer: resourcesServer) + init(publication: Publication, locator: Locator?, bookId: Book.Id, books: BookRepository, bookmarks: BookmarkRepository, resourcesServer: ResourcesServer) { + let navigator = EPUBNavigatorViewController(publication: publication, initialLocation: locator, resourcesServer: resourcesServer) let settingsStoryboard = UIStoryboard(name: "UserSettings", bundle: nil) userSettingNavigationController = settingsStoryboard.instantiateViewController(withIdentifier: "UserSettingsNavigationController") as! UserSettingsNavigationController @@ -29,7 +29,7 @@ class EPUBViewController: ReaderViewController { userSettingNavigationController.advancedSettingsViewController = (settingsStoryboard.instantiateViewController(withIdentifier: "AdvancedSettingsViewController") as! AdvancedSettingsViewController) - super.init(navigator: navigator, publication: publication, book: book) + super.init(navigator: navigator, publication: publication, bookId: bookId, books: books, bookmarks: bookmarks) navigator.delegate = self } @@ -88,13 +88,11 @@ class EPUBViewController: ReaderViewController { } override var currentBookmark: Bookmark? { - guard - let locator = navigator.currentLocation, - let resourceIndex = publication.readingOrder.firstIndex(withHREF: locator.href) else - { + guard let locator = navigator.currentLocation else { return nil } - return Bookmark(bookID: book.id, resourceIndex: resourceIndex, locator: locator) + + return Bookmark(bookId: bookId, locator: locator) } @objc func presentUserSettings() { diff --git a/Sources/Reader/PDF/PDFModule.swift b/Sources/Reader/PDF/PDFModule.swift index ec7e6097..e3dda869 100644 --- a/Sources/Reader/PDF/PDFModule.swift +++ b/Sources/Reader/PDF/PDFModule.swift @@ -30,8 +30,8 @@ final class PDFModule: ReaderFormatModule { return [.pdf] } - func makeReaderViewController(for publication: Publication, book: Book, resourcesServer: ResourcesServer) throws -> UIViewController { - let viewController = PDFViewController(publication: publication, book: book) + func makeReaderViewController(for publication: Publication, locator: Locator?, bookId: Book.Id, books: BookRepository, bookmarks: BookmarkRepository, resourcesServer: ResourcesServer) throws -> UIViewController { + let viewController = PDFViewController(publication: publication, locator: locator, bookId: bookId, books: books, bookmarks: bookmarks) viewController.moduleDelegate = delegate return viewController } diff --git a/Sources/Reader/PDF/PDFViewController.swift b/Sources/Reader/PDF/PDFViewController.swift index a2748d55..fd38863e 100644 --- a/Sources/Reader/PDF/PDFViewController.swift +++ b/Sources/Reader/PDF/PDFViewController.swift @@ -18,10 +18,10 @@ import R2Shared @available(iOS 11.0, *) final class PDFViewController: ReaderViewController { - init(publication: Publication, book: Book) { - let navigator = PDFNavigatorViewController(publication: publication, initialLocation: book.progressionLocator) + init(publication: Publication, locator: Locator?, bookId: Book.Id, books: BookRepository, bookmarks: BookmarkRepository) { + let navigator = PDFNavigatorViewController(publication: publication, initialLocation: locator) - super.init(navigator: navigator, publication: publication, book: book) + super.init(navigator: navigator, publication: publication, bookId: bookId, books: books, bookmarks: bookmarks) navigator.delegate = self } @@ -31,18 +31,11 @@ final class PDFViewController: ReaderViewController { } override var currentBookmark: Bookmark? { - guard - let locator = navigator.currentLocation, - let resourceIndex = publication.readingOrder.firstIndex(withHREF: locator.href) else - { + guard let locator = navigator.currentLocation else { return nil } - return Bookmark( - bookID: book.id, - resourceIndex: resourceIndex, - locator: locator - ) + return Bookmark(bookId: bookId, locator: locator) } } diff --git a/Sources/Reader/ReaderFactory.swift b/Sources/Reader/ReaderFactory.swift index ba37f140..f0793297 100644 --- a/Sources/Reader/ReaderFactory.swift +++ b/Sources/Reader/ReaderFactory.swift @@ -26,9 +26,11 @@ final class ReaderFactory { } extension ReaderFactory: OutlineTableViewControllerFactory { - func make(publication: Publication) -> OutlineTableViewController { + func make(publication: Publication, bookId: Book.Id, bookmarks: BookmarkRepository) -> OutlineTableViewController { let controller = storyboards.outline.instantiateViewController(withIdentifier: "OutlineTableViewController") as! OutlineTableViewController controller.publication = publication + controller.bookId = bookId + controller.bookmarkRepository = bookmarks return controller } } diff --git a/Sources/Reader/ReaderFormatModule.swift b/Sources/Reader/ReaderFormatModule.swift index aeb32a87..4c6cfffe 100644 --- a/Sources/Reader/ReaderFormatModule.swift +++ b/Sources/Reader/ReaderFormatModule.swift @@ -24,14 +24,14 @@ protocol ReaderFormatModule { var publicationFormats: [Publication.Format] { get } /// Creates the view controller to present the publication. - func makeReaderViewController(for publication: Publication, book: Book, resourcesServer: ResourcesServer) throws -> UIViewController + func makeReaderViewController(for publication: Publication, locator: Locator?, bookId: Book.Id, books: BookRepository, bookmarks: BookmarkRepository, resourcesServer: ResourcesServer) throws -> UIViewController } protocol ReaderFormatModuleDelegate: AnyObject { /// Shows the reader's outline from the given links. - func presentOutline(of publication: Publication, delegate: OutlineTableViewControllerDelegate?, from viewController: UIViewController) + func presentOutline(of publication: Publication, bookId: Book.Id, delegate: OutlineTableViewControllerDelegate?, from viewController: UIViewController) /// Shows the DRM management screen for the given DRM. func presentDRM(for publication: Publication, from viewController: UIViewController) diff --git a/Sources/Reader/ReaderModule.swift b/Sources/Reader/ReaderModule.swift index 4383d6e6..953c0e3f 100644 --- a/Sources/Reader/ReaderModule.swift +++ b/Sources/Reader/ReaderModule.swift @@ -34,6 +34,8 @@ protocol ReaderModuleDelegate: ModuleDelegate { final class ReaderModule: ReaderModuleAPI { weak var delegate: ReaderModuleDelegate? + private let books: BookRepository + private let bookmarks: BookmarkRepository private let resourcesServer: ResourcesServer /// Sub-modules to handle different publication formats (eg. EPUB, CBZ) @@ -41,8 +43,10 @@ final class ReaderModule: ReaderModuleAPI { private let factory = ReaderFactory() - init(delegate: ReaderModuleDelegate?, resourcesServer: ResourcesServer) { + init(delegate: ReaderModuleDelegate?, books: BookRepository, bookmarks: BookmarkRepository, resourcesServer: ResourcesServer) { self.delegate = delegate + self.books = books + self.bookmarks = bookmarks self.resourcesServer = resourcesServer formatModules = [ @@ -56,7 +60,7 @@ final class ReaderModule: ReaderModuleAPI { } func presentPublication(publication: Publication, book: Book, in navigationController: UINavigationController, completion: @escaping () -> Void) { - guard let delegate = delegate else { + guard let delegate = delegate, let bookId = book.id else { fatalError("Reader delegate not set") } @@ -75,7 +79,7 @@ final class ReaderModule: ReaderModuleAPI { } do { - let readerViewController = try module.makeReaderViewController(for: publication, book: book, resourcesServer: resourcesServer) + let readerViewController = try module.makeReaderViewController(for: publication, locator: book.locator, bookId: bookId, books: books, bookmarks: bookmarks, resourcesServer: resourcesServer) present(readerViewController) } catch { delegate.presentError(error, from: navigationController) @@ -97,8 +101,8 @@ extension ReaderModule: ReaderFormatModuleDelegate { viewController.navigationController?.pushViewController(drmViewController, animated: true) } - func presentOutline(of publication: Publication, delegate: OutlineTableViewControllerDelegate?, from viewController: UIViewController) { - let outlineTableVC: OutlineTableViewController = factory.make(publication: publication) + func presentOutline(of publication: Publication, bookId: Book.Id, delegate: OutlineTableViewControllerDelegate?, from viewController: UIViewController) { + let outlineTableVC: OutlineTableViewController = factory.make(publication: publication, bookId: bookId, bookmarks: bookmarks) outlineTableVC.delegate = delegate viewController.present(UINavigationController(rootViewController: outlineTableVC), animated: true) } diff --git a/Sources/Resources/en.lproj/Localizable.strings b/Sources/Resources/en.lproj/Localizable.strings index e9f75ce9..9c731682 100644 --- a/Sources/Resources/en.lproj/Localizable.strings +++ b/Sources/Resources/en.lproj/Localizable.strings @@ -65,6 +65,8 @@ /* Error message used when trying to import a publication that is not valid */ "library_error_publicationIsNotValid" = "The publication isn't valid"; +/* Error message used when trying to open a book whose file is not found */ +"library_error_bookNotFound" = "The book file was not found"; /* Error message used when a low-level error occured while importing a publication */ "library_error_importFailed" = "Error while importing this publication: %@"; /* Error message used when a low-level error occured while opening a publication */