Skip to content

Commit 434e47b

Browse files
authored
Async HTTPClient and LCP APIs (#438)
1 parent 595037d commit 434e47b

File tree

71 files changed

+1672
-1808
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+1672
-1808
lines changed

CHANGELOG.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ All notable changes to this project will be documented in this file. Take a look
1414

1515
### Changed
1616

17-
The Readium Swift toolkit now requires a minimum of iOS 13.
17+
* The Readium Swift toolkit now requires a minimum of iOS 13.
18+
* Plenty of completion-based APIs were changed to use `async` functions instead.
1819

1920
#### Shared
2021

Documentation/Guides/Readium LCP.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -164,15 +164,15 @@ let lcpService = LCPService(client: LCPClientAdapter())
164164

165165
/// Facade to the private R2LCPClient.framework.
166166
class LCPClientAdapter: ReadiumLCP.LCPClient {
167-
func createContext(jsonLicense: String, hashedPassphrase: String, pemCrl: String) throws -> LCPClientContext {
167+
func createContext(jsonLicense: String, hashedPassphrase: LCPPassphraseHash, pemCrl: String) throws -> LCPClientContext {
168168
try R2LCPClient.createContext(jsonLicense: jsonLicense, hashedPassphrase: hashedPassphrase, pemCrl: pemCrl)
169169
}
170170

171171
func decrypt(data: Data, using context: LCPClientContext) -> Data? {
172172
R2LCPClient.decrypt(data: data, using: context as! DRMContext)
173173
}
174174

175-
func findOneValidPassphrase(jsonLicense: String, hashedPassphrases: [String]) -> String? {
175+
func findOneValidPassphrase(jsonLicense: String, hashedPassphrases: [LCPPassphraseHash]) -> LCPPassphraseHash? {
176176
R2LCPClient.findOneValidPassphrase(jsonLicense: jsonLicense, hashedPassphrases: hashedPassphrases)
177177
}
178178
}

Documentation/Migration Guide.md

+7
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
All migration steps necessary in reading apps to upgrade to major versions of the Swift Readium toolkit will be documented in this file.
44

5+
## Unreleased
6+
7+
### Async APIs
8+
9+
Plenty of completion-based APIs were changed to use `async` functions instead. Follow the deprecation warnings to update your codebase.
10+
11+
512
## 3.0.0-alpha.1
613

714
### R2 prefix dropped
+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//
2+
// Copyright 2024 Readium Foundation. All rights reserved.
3+
// Use of this source code is governed by the BSD-style license
4+
// available in the top-level LICENSE file of the project.
5+
//
6+
7+
import Foundation
8+
9+
public extension Result {
10+
/// Asynchronous variant of `map`.
11+
@inlinable func map<NewSuccess>(
12+
_ transform: (Success) async throws -> NewSuccess
13+
) async rethrows -> Result<NewSuccess, Failure> {
14+
switch self {
15+
case let .success(success):
16+
return try await .success(transform(success))
17+
case let .failure(error):
18+
return .failure(error)
19+
}
20+
}
21+
22+
/// Asynchronous variant of `flatMap`.
23+
@inlinable func flatMap<NewSuccess>(
24+
_ transform: (Success) async throws -> Result<NewSuccess, Failure>
25+
) async rethrows -> Result<NewSuccess, Failure> {
26+
switch self {
27+
case let .success(success):
28+
return try await transform(success)
29+
case let .failure(error):
30+
return .failure(error)
31+
}
32+
}
33+
34+
@inlinable func recover(
35+
_ catching: (Failure) async throws -> Self
36+
) async rethrows -> Self {
37+
switch self {
38+
case let .success(success):
39+
return .success(success)
40+
case let .failure(error):
41+
return try await catching(error)
42+
}
43+
}
44+
}

Sources/LCP/Authentications/LCPAuthenticating.swift

+7-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,13 @@ public protocol LCPAuthenticating {
2424
/// presenting dialogs. For example, the host `UIViewController`.
2525
/// - completion: Used to return the retrieved passphrase. If the user cancelled, send nil.
2626
/// The passphrase may be already hashed.
27-
func retrievePassphrase(for license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, allowUserInteraction: Bool, sender: Any?, completion: @escaping (String?) -> Void)
27+
@MainActor
28+
func retrievePassphrase(
29+
for license: LCPAuthenticatedLicense,
30+
reason: LCPAuthenticationReason,
31+
allowUserInteraction: Bool,
32+
sender: Any?
33+
) async -> String?
2834
}
2935

3036
public enum LCPAuthenticationReason {

Sources/LCP/Authentications/LCPDialogAuthentication.swift

+16-8
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,29 @@ public class LCPDialogAuthentication: LCPAuthenticating, Loggable {
2424
self.modalTransitionStyle = modalTransitionStyle
2525
}
2626

27-
public func retrievePassphrase(for license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, allowUserInteraction: Bool, sender: Any?, completion: @escaping (String?) -> Void) {
27+
public func retrievePassphrase(
28+
for license: LCPAuthenticatedLicense,
29+
reason: LCPAuthenticationReason,
30+
allowUserInteraction: Bool,
31+
sender: Any?
32+
) async -> String? {
2833
guard allowUserInteraction, let viewController = sender as? UIViewController else {
2934
if !(sender is UIViewController) {
3035
log(.error, "Tried to present the LCP dialog without providing a `UIViewController` as `sender`")
3136
}
32-
completion(nil)
33-
return
37+
return nil
3438
}
3539

36-
let dialogViewController = LCPDialogViewController(license: license, reason: reason, completion: completion)
40+
return await withCheckedContinuation { continuation in
41+
let dialogViewController = LCPDialogViewController(license: license, reason: reason) { passphrase in
42+
continuation.resume(returning: passphrase)
43+
}
3744

38-
let navController = UINavigationController(rootViewController: dialogViewController)
39-
navController.modalPresentationStyle = modalPresentationStyle
40-
navController.modalTransitionStyle = modalTransitionStyle
45+
let navController = UINavigationController(rootViewController: dialogViewController)
46+
navController.modalPresentationStyle = modalPresentationStyle
47+
navController.modalTransitionStyle = modalTransitionStyle
4148

42-
viewController.present(navController, animated: animated)
49+
viewController.present(navController, animated: animated)
50+
}
4351
}
4452
}

Sources/LCP/Authentications/LCPPassphraseAuthentication.swift

+4-5
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,15 @@ public class LCPPassphraseAuthentication: LCPAuthenticating {
1919
self.fallback = fallback
2020
}
2121

22-
public func retrievePassphrase(for license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, allowUserInteraction: Bool, sender: Any?, completion: @escaping (String?) -> Void) {
22+
public func retrievePassphrase(for license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, allowUserInteraction: Bool, sender: Any?) async -> String? {
2323
guard reason == .passphraseNotFound else {
2424
if let fallback = fallback {
25-
fallback.retrievePassphrase(for: license, reason: reason, allowUserInteraction: allowUserInteraction, sender: sender, completion: completion)
25+
return await fallback.retrievePassphrase(for: license, reason: reason, allowUserInteraction: allowUserInteraction, sender: sender)
2626
} else {
27-
completion(nil)
27+
return nil
2828
}
29-
return
3029
}
3130

32-
completion(passphrase)
31+
return passphrase
3332
}
3433
}

Sources/LCP/Content Protection/LCPContentProtection.swift

+8-9
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,13 @@ final class LCPContentProtection: ContentProtection, Loggable {
3333
let authentication = credentials.map { LCPPassphraseAuthentication($0, fallback: self.authentication) }
3434
?? self.authentication
3535

36-
service.retrieveLicense(
37-
from: file.file,
38-
authentication: authentication,
39-
allowUserInteraction: allowUserInteraction,
40-
sender: sender
41-
) { result in
36+
Task {
37+
let result = await service.retrieveLicense(
38+
from: file.file,
39+
authentication: authentication,
40+
allowUserInteraction: allowUserInteraction,
41+
sender: sender
42+
)
4243
if case let .success(license) = result, license == nil {
4344
// Not protected with LCP.
4445
completion(.success(nil))
@@ -86,14 +87,12 @@ private final class LCPContentProtectionService: ContentProtectionService {
8687
self.error = error
8788
}
8889

89-
convenience init(result: CancellableResult<LCPLicense?, LCPError>) {
90+
convenience init(result: Result<LCPLicense?, LCPError>) {
9091
switch result {
9192
case let .success(license):
9293
self.init(license: license)
9394
case let .failure(error):
9495
self.init(error: error)
95-
case .cancelled:
96-
self.init()
9796
}
9897
}
9998

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//
2+
// Copyright 2024 Readium Foundation. All rights reserved.
3+
// Use of this source code is governed by the BSD-style license
4+
// available in the top-level LICENSE file of the project.
5+
//
6+
7+
import Foundation
8+
import ReadiumShared
9+
10+
/// Holds information about an LCP protected publication which was acquired from an LCPL.
11+
public struct LCPAcquiredPublication {
12+
/// Path to the downloaded publication.
13+
/// You must move this file to the user library's folder.
14+
public let localURL: FileURL
15+
16+
/// Filename that should be used for the publication when importing it in the user library.
17+
public let suggestedFilename: String
18+
19+
/// LCP license document.
20+
public let licenseDocument: LicenseDocument
21+
}

Sources/LCP/LCPAcquisition.swift

+6-29
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import ReadiumShared
1010
/// Represents an on-going LCP acquisition task.
1111
///
1212
/// You can cancel the on-going download with `acquisition.cancel()`.
13-
public final class LCPAcquisition: Loggable, Cancellable {
13+
@available(*, deprecated)
14+
public final class LCPAcquisition: Loggable {
1415
/// Informations about an acquired publication protected with LCP.
16+
@available(*, unavailable, renamed: "LCPAcquiredPublication")
1517
public struct Publication {
1618
/// Path to the downloaded publication.
1719
/// You must move this file to the user library's folder.
@@ -21,6 +23,7 @@ public final class LCPAcquisition: Loggable, Cancellable {
2123
public let suggestedFilename: String
2224
}
2325

26+
@available(*, unavailable, renamed: "LCPProgress")
2427
/// Percent-based progress of the acquisition.
2528
public enum Progress {
2629
/// Undetermined progress, a spinner should be shown to the user.
@@ -30,32 +33,6 @@ public final class LCPAcquisition: Loggable, Cancellable {
3033
}
3134

3235
/// Cancels the acquisition.
33-
public func cancel() {
34-
cancellable.cancel()
35-
didComplete(with: .cancelled)
36-
}
37-
38-
let onProgress: (Progress) -> Void
39-
var cancellable = MediatorCancellable()
40-
41-
private var isCompleted = false
42-
private let completion: (CancellableResult<Publication, LCPError>) -> Void
43-
44-
init(onProgress: @escaping (Progress) -> Void, completion: @escaping (CancellableResult<Publication, LCPError>) -> Void) {
45-
self.onProgress = onProgress
46-
self.completion = completion
47-
}
48-
49-
func didComplete(with result: CancellableResult<Publication, LCPError>) {
50-
guard !isCompleted else {
51-
return
52-
}
53-
isCompleted = true
54-
55-
completion(result)
56-
57-
if case let .success(publication) = result, (try? publication.localURL.exists()) == true {
58-
log(.warning, "The acquired LCP publication file was not moved in the completion closure. It will be removed from the file system.")
59-
}
60-
}
36+
@available(*, unavailable, message: "This is not needed with the new async variants")
37+
public func cancel() {}
6138
}

Sources/LCP/LCPClient.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,13 @@ import Foundation
3131
/// }
3232
public protocol LCPClient {
3333
/// Create a context for a given license/passphrase tuple.
34-
func createContext(jsonLicense: String, hashedPassphrase: String, pemCrl: String) throws -> LCPClientContext
34+
func createContext(jsonLicense: String, hashedPassphrase: LCPPassphraseHash, pemCrl: String) throws -> LCPClientContext
3535

3636
/// Decrypt provided content, given a valid context is provided.
3737
func decrypt(data: Data, using context: LCPClientContext) -> Data?
3838

3939
/// Given an array of possible password hashes, return a valid password hash for the lcpl licence.
40-
func findOneValidPassphrase(jsonLicense: String, hashedPassphrases: [String]) -> String?
40+
func findOneValidPassphrase(jsonLicense: String, hashedPassphrases: [LCPPassphraseHash]) -> LCPPassphraseHash?
4141
}
4242

4343
public typealias LCPClientContext = Any

Sources/LCP/LCPError.swift

+32-13
Original file line numberDiff line numberDiff line change
@@ -5,39 +5,58 @@
55
//
66

77
import Foundation
8+
import ReadiumShared
89

910
public enum LCPError: LocalizedError {
10-
// The operation can't be done right now because another License operation is running.
11+
/// The license could not be retrieved because the passphrase is unknown.
12+
case missingPassphrase
13+
14+
/// The given file is not an LCP License Document (LCPL).
15+
case notALicenseDocument(FileURL)
16+
17+
/// The operation can't be done right now because another License operation is running.
1118
case licenseIsBusy
12-
// An error occured while checking the integrity of the License, it can't be retrieved.
19+
20+
/// An error occured while checking the integrity of the License, it can't be retrieved.
1321
case licenseIntegrity(LCPClientError)
14-
// The status of the License is not valid, it can't be used to decrypt the publication.
22+
23+
/// The status of the License is not valid, it can't be used to decrypt the publication.
1524
case licenseStatus(StatusError)
16-
// Can't read or write the License Document from its container.
25+
26+
/// Can't read or write the License Document from its container.
1727
case licenseContainer(ContainerError)
18-
// The interaction is not available with this License.
28+
29+
/// The interaction is not available with this License.
1930
case licenseInteractionNotAvailable
20-
// This License's profile is not supported by liblcp.
31+
32+
/// This License's profile is not supported by liblcp.
2133
case licenseProfileNotSupported
22-
// Failed to renew the loan.
34+
35+
/// Failed to renew the loan.
2336
case licenseRenew(RenewError)
24-
// Failed to return the loan.
37+
38+
/// Failed to return the loan.
2539
case licenseReturn(ReturnError)
2640

27-
// Failed to retrieve the Certificate Revocation List.
41+
/// Failed to retrieve the Certificate Revocation List.
2842
case crlFetching
2943

30-
// Failed to parse information from the License or Status Documents.
44+
/// Failed to parse information from the License or Status Documents.
3145
case parsing(ParsingError)
32-
// A network request failed with the given error.
46+
47+
/// A network request failed with the given error.
3348
case network(Error?)
34-
// An unexpected LCP error occured. Please post an issue on r2-lcp-swift with the error message and how to reproduce it.
49+
50+
/// An unexpected LCP error occured. Please post an issue on r2-lcp-swift with the error message and how to reproduce it.
3551
case runtime(String)
36-
// An unknown low-level error was reported.
52+
53+
/// An unknown low-level error was reported.
3754
case unknown(Error?)
3855

3956
public var errorDescription: String? {
4057
switch self {
58+
case .missingPassphrase: return nil
59+
case .notALicenseDocument: return nil
4160
case .licenseIsBusy:
4261
return ReadiumLCPLocalizedString("LCPError.licenseIsBusy")
4362
case let .licenseIntegrity(error):

0 commit comments

Comments
 (0)