Skip to content

Commit f4c4cae

Browse files
committed
Supporting Readium LCP
1 parent 72db04f commit f4c4cae

File tree

2 files changed

+376
-0
lines changed

2 files changed

+376
-0
lines changed

Documentation/Guides/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* [Getting Started](Getting%20Started.md)
44
* [Extracting the content of a publication](Content.md)
55
* [Text-to-speech](TTS.md)
6+
* [Supporting Readium LCP](Readium%20LCP.md)
67
* Navigator
78
* [Configuring the Navigator](Navigator/Preferences.md)
89
* [Font families in the EPUB navigator](Navigator/EPUB%20Fonts.md)

Documentation/Guides/Readium LCP.md

+375
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
# Supporting Readium LCP
2+
3+
You can use the Readium Swift toolkit to download and read publications that are protected with the [Readium LCP](https://www.edrlab.org/readium-lcp/) DRM.
4+
5+
:point_up: To use LCP with the Readium toolkit, you must first obtain the `R2LCPClient` private library by contacting [EDRLab](https://www.edrlab.org/contact/).
6+
7+
## Overview
8+
9+
An LCP publication is distributed using an LCP License Document (`.lcpl`) protected with a *user passphrase*.
10+
11+
The user flow typically goes as follows:
12+
13+
1. The user imports a `.lcpl` file into your application.
14+
2. The application uses the Readium toolkit to download the protected publication from the `.lcpl` file to the user's bookshelf. The downloaded file can be a `.epub`, `.lcpdf` (PDF), or `.lcpa` (audiobook) package.
15+
3. The user opens the protected publication from the bookshelf.
16+
4. If the passphrase isn't saved, the user will be asked to enter it to unlock the contents.
17+
5. The publication is decrypted and rendered on the screen.
18+
19+
## Setup
20+
21+
To support LCP in your application, you require two components:
22+
23+
* The `ReadiumLCP` package from the toolkit provides APIs for downloading and decrypting protected publications. Import it as you would any other Readium package, such as `R2Navigator`.
24+
* The private `R2LCPClient` library customized for your application [is available from EDRLab](https://www.edrlab.org/contact/). They will provide instructions for integrating the `R2LCPClient` framework into your application.
25+
26+
### File formats
27+
28+
Readium LCP specifies new file formats.
29+
30+
| Name | File extension | Media type |
31+
|------|----------------|------------|
32+
| [License Document](https://readium.org/lcp-specs/releases/lcp/latest.html#32-content-conformance) | `.lcpl` | `application/vnd.readium.lcp.license.v1.0+json` |
33+
| [LCP for PDF package](https://readium.org/lcp-specs/notes/lcp-for-pdf.html) | `.lcpdf` | `application/pdf+lcp` |
34+
| [LCP for Audiobooks package](https://readium.org/lcp-specs/notes/lcp-for-audiobooks.html) | `.lcpa` | `application/audiobook+lcp` |
35+
36+
:point_up: EPUB files protected by LCP are supported without a special file extension or media type because EPUB accommodates any DRM scheme in its specification.
37+
38+
To support these formats in your application, you need to [register them in your `Info.plist`](https://developer.apple.com/documentation/uniformtypeidentifiers/defining_file_and_data_types_for_your_app) as imported types.
39+
40+
```xml
41+
<dict>
42+
<key>UTImportedTypeDeclarations</key>
43+
<array>
44+
<dict>
45+
<key>UTTypeIdentifier</key>
46+
<string>org.readium.lcpl</string>
47+
<key>UTTypeConformsTo</key>
48+
<array>
49+
<string>public.content</string>
50+
<string>public.data</string>
51+
<string>public.json</string>
52+
</array>
53+
<key>UTTypeDescription</key>
54+
<string>LCP License Document</string>
55+
<key>UTTypeTagSpecification</key>
56+
<dict>
57+
<key>public.filename-extension</key>
58+
<array>
59+
<string>lcpl</string>
60+
</array>
61+
<key>public.mime-type</key>
62+
<string>application/vnd.readium.lcp.license.v1.0+json</string>
63+
</dict>
64+
</dict>
65+
<dict>
66+
<key>UTTypeIdentifier</key>
67+
<string>org.readium.lcpdf</string>
68+
<key>UTTypeConformsTo</key>
69+
<array>
70+
<string>public.content</string>
71+
<string>public.data</string>
72+
<string>public.archive</string>
73+
<string>public.zip-archive</string>
74+
</array>
75+
<key>UTTypeDescription</key>
76+
<string>LCP for PDF package</string>
77+
<key>UTTypeTagSpecification</key>
78+
<dict>
79+
<key>public.filename-extension</key>
80+
<array>
81+
<string>lcpdf</string>
82+
</array>
83+
<key>public.mime-type</key>
84+
<string>application/pdf+lcp</string>
85+
</dict>
86+
</dict>
87+
<dict>
88+
<key>UTTypeIdentifier</key>
89+
<string>org.readium.lcpa</string>
90+
<key>UTTypeConformsTo</key>
91+
<array>
92+
<string>public.content</string>
93+
<string>public.data</string>
94+
<string>public.archive</string>
95+
<string>public.zip-archive</string>
96+
</array>
97+
<key>UTTypeDescription</key>
98+
<string>LCP for Audiobooks package</string>
99+
<key>UTTypeTagSpecification</key>
100+
<dict>
101+
<key>public.filename-extension</key>
102+
<array>
103+
<string>lcpa</string>
104+
</array>
105+
<key>public.mime-type</key>
106+
<string>application/audiobook+lcp</string>
107+
</dict>
108+
</dict>
109+
</array>
110+
</dict>
111+
```
112+
113+
Next, declare the imported types as [Document Types](https://help.apple.com/xcode/mac/current/#/devddd273fdd) in the `Info.plist` to have your application listed in the "Open with..." dialogs.
114+
115+
```xml
116+
<dict>
117+
<key>CFBundleDocumentTypes</key>
118+
<array>
119+
<dict>
120+
<key>CFBundleTypeName</key>
121+
<string>LCP License Document</string>
122+
<key>CFBundleTypeRole</key>
123+
<string>Viewer</string>
124+
<key>LSItemContentTypes</key>
125+
<array>
126+
<string>org.readium.lcpl</string>
127+
</array>
128+
</dict>
129+
<dict>
130+
<key>CFBundleTypeName</key>
131+
<string>LCP for PDF package</string>
132+
<key>CFBundleTypeRole</key>
133+
<string>Viewer</string>
134+
<key>LSItemContentTypes</key>
135+
<array>
136+
<string>org.readium.lcpdf</string>
137+
</array>
138+
</dict>
139+
<dict>
140+
<key>CFBundleTypeName</key>
141+
<string>LCP for Audiobooks package</string>
142+
<key>CFBundleTypeRole</key>
143+
<string>Viewer</string>
144+
<key>LSItemContentTypes</key>
145+
<array>
146+
<string>org.readium.lcpa</string>
147+
</array>
148+
</dict>
149+
</array>
150+
</dict>
151+
```
152+
153+
:point_up: If EPUB is not included in your document types, now is a good time to add it.
154+
155+
## Initializing the `LCPService`
156+
157+
`ReadiumLCP` offers an `LCPService` object that exposes its API. Since the `ReadiumLCP` package is not linked with `R2LCPClient`, you need to create your own adapter when setting up the `LCPService`.
158+
159+
```swift
160+
import R2LCPClient
161+
import ReadiumLCP
162+
163+
let lcpService = LCPService(client: LCPClientAdapter())
164+
165+
/// Facade to the private R2LCPClient.framework.
166+
class LCPClientAdapter: ReadiumLCP.LCPClient {
167+
func createContext(jsonLicense: String, hashedPassphrase: String, pemCrl: String) throws -> LCPClientContext {
168+
try R2LCPClient.createContext(jsonLicense: jsonLicense, hashedPassphrase: hashedPassphrase, pemCrl: pemCrl)
169+
}
170+
171+
func decrypt(data: Data, using context: LCPClientContext) -> Data? {
172+
R2LCPClient.decrypt(data: data, using: context as! DRMContext)
173+
}
174+
175+
func findOneValidPassphrase(jsonLicense: String, hashedPassphrases: [String]) -> String? {
176+
R2LCPClient.findOneValidPassphrase(jsonLicense: jsonLicense, hashedPassphrases: hashedPassphrases)
177+
}
178+
}
179+
```
180+
181+
## Acquiring a publication from a License Document (LCPL)
182+
183+
Users need to import a License Document into your application to download the protected publication (`.epub`, `.lcpdf`, or `.lcpa`).
184+
185+
The `LCPService` offers an API to retrieve the full publication from an LCPL on the filesystem.
186+
187+
```swift
188+
let acquisition = lcpService.acquirePublication(
189+
from: lcplURL,
190+
onProgress: { progress in
191+
switch progress {
192+
case .indefinite:
193+
// Display an activity indicator.
194+
case .percent(let percent):
195+
// Display a progress bar with percent from 0 to 1.
196+
}
197+
},
198+
completion: { result in
199+
switch result {
200+
case let .success(publication):
201+
// Import the `publication.localURL` file as any publication.
202+
case let .failure(error):
203+
// Display the error message
204+
case .cancelled:
205+
// The acquisition was cancelled before completion.
206+
}
207+
}
208+
)
209+
```
210+
211+
If the user wants to cancel the download, call `cancel()` on the object returned by `LCPService.acquirePublication()`.
212+
213+
After the download is completed, import the `publication.localURL` file into the bookshelf like any other publication file.
214+
215+
## Opening a publication protected with LCP
216+
217+
### Initializing the `Streamer`
218+
219+
A publication protected with LCP can be opened using the `Streamer` component, just like a non-protected publication. However, you must provide a [`ContentProtection`](https://readium.org/architecture/proposals/006-content-protection.html) implementation when initializing the `Streamer` to enable LCP. Luckily, `LCPService` has you covered.
220+
221+
```swift
222+
let authentication = LCPDialogAuthentication()
223+
224+
let streamer = Streamer(
225+
contentProtections: [
226+
lcpService.contentProtection(with: authentication)
227+
]
228+
)
229+
```
230+
231+
An LCP package is secured with a *user passphrase* for decrypting the content. The `LCPAuthenticating` protocol used by `LCPService.contentProtection(with:)` provides the passphrase when needed. You can use the default `LCPDialogAuthentication` which displays a pop-up to enter the passphrase, or implement your own method for passphrase retrieval.
232+
233+
:point_up: The user will be prompted once per passphrase since `ReadiumLCP` stores known passphrases on the device.
234+
235+
### Opening the publication
236+
237+
You are now ready to open the publication file with your `Streamer` instance.
238+
239+
```swift
240+
streamer.open(
241+
asset: FileAsset(url: publicationURL),
242+
allowUserInteraction: true,
243+
sender: hostViewController,
244+
completion: { result in
245+
switch result {
246+
case .success(let publication):
247+
// Import or present the publication.
248+
case .failure(let error):
249+
// Present the error.
250+
case .cancelled:
251+
// The operation was cancelled.
252+
}
253+
}
254+
)
255+
```
256+
257+
The `allowUserInteraction` and `sender` arguments are forwarded to the `LCPAuthenticating` implementation when the passphrase unknown. `LCPDialogAuthentication` shows a pop-up only if `allowUserInteraction` is `true`, using the `sender` as the pop-up's host `UIViewController`.
258+
259+
When importing the publication to the bookshelf, set `allowUserInteraction` to `false` as you don't need the passphrase for accessing the publication metadata and cover. If you intend to present the publication using a Navigator, set `allowUserInteraction` to `true` as decryption will be required.
260+
261+
:point_up: To check if a publication is protected with LCP before opening it, you can use `LCPService.isLCPProtected()`.
262+
263+
### Using the opened `Publication`
264+
265+
After obtaining a `Publication` instance, you can access the publication's metadata to import it into the user's bookshelf. The user passphrase is not needed for reading the metadata or cover.
266+
267+
However, if you want to display the publication with a Navigator, verify it is not restricted. It could be restricted if the user passphrase is unknown or if the license is no longer valid (e.g., expired loan, revoked purchase, etc.).
268+
269+
```swift
270+
if publication.isRestricted {
271+
if let error = publication.protectionError as? LCPError {
272+
// The user is not allowed to open the publication.
273+
// You should display the error.
274+
} else {
275+
// We don't have the user passphrase.
276+
// You may use `publication` to access its
277+
// metadata, but not to render its content.
278+
}
279+
} else {
280+
// The publication is not restricted, you may
281+
// render it with a Navigator component.
282+
}
283+
```
284+
285+
## Obtaining information on an LCP license
286+
287+
An LCP License Document contains metadata such as its expiration date, the remaining number of characters to copy and the user name. You can access this information using an `LCPLicense` object.
288+
289+
Use the `LCPService` to retrieve the `LCPLicense` instance for a publication.
290+
291+
```swift
292+
lcpService.retrieveLicense(
293+
from: publicationURL,
294+
authentication: LCPDialogAuthentication(),
295+
allowUserInteraction: true,
296+
sender: hostViewController
297+
) { result in
298+
switch result {
299+
case .success(let lcpLicense):
300+
if let lcpLicense = lcpLicense {
301+
if let user = lcpLicense.license.user.name {
302+
print("The publication was acquired by \(user)")
303+
}
304+
if let endDate = lcpLicense.license.rights.end {
305+
print("The loan expires on \(endDate)")
306+
}
307+
if let copyLeft = lcpLicense.charactersToCopyLeft {
308+
print("You can copy up to \(copyLeft) characters remaining.")
309+
}
310+
} else {
311+
// The file was not protected by LCP.
312+
}
313+
case .failure(let error):
314+
// Display the error.
315+
case .cancelled:
316+
// The operation was cancelled.
317+
}
318+
}
319+
```
320+
321+
If you have already opened a `Publication` with the `Streamer`, you can directly obtain the `LCPLicense` using `publication.lcpLicense`.
322+
323+
## Managing a loan
324+
325+
Readium LCP allows borrowing publications for a specific period. Use the `LCPLicense` object to manage a loan and retrieve its end date using `lcpLicense.license.rights.end`.
326+
327+
### Returning a loan
328+
329+
Some loans can be returned before the end date. You can confirm this by using `lcpLicense.canReturnPublication`. To return the publication, execute:
330+
331+
```swift
332+
lcpLicense.returnPublication { error in
333+
if let error = error {
334+
// Present the error.
335+
} else {
336+
// The publication was returned.
337+
}
338+
}
339+
```
340+
341+
### Renewing a loan
342+
343+
The loan end date may also be extended. You can confirm this by using `lcpLicense.canRenewLoan`.
344+
345+
Readium LCP supports [two types of renewal interactions](https://readium.org/lcp-specs/releases/lsd/latest#35-renewing-a-license):
346+
347+
* Programmatic: You show your own user interface.
348+
* Interactive: You display a web view, and the Readium LSD server manages the renewal for you.
349+
350+
You need to support both interactions by implementing the `LCPRenewDelegate` protocol. A default implementation is available with `LCPDefaultRenewDelegate`.
351+
352+
```swift
353+
lcpLicense.renewLoan(
354+
with: LCPDefaultRenewDelegate(
355+
presentingViewController: hostViewController
356+
)
357+
) { result in
358+
switch result {
359+
case .success, .cancelled:
360+
// The publication was renewed.
361+
case let .failure(error):
362+
// Display the error.
363+
}
364+
}
365+
```
366+
367+
## Handling `LCPError`
368+
369+
The APIs may fail with an `LCPError`. These errors **must** be displayed to the user with a suitable message.
370+
371+
LCPError implements LocalizedError, enabling you to retrieve a user-friendly message. It's advised to customize the LCP localized strings in your app for translation. These strings can be found at Sources/LCP/Resources/en.lproj/Localizable.strings.
372+
373+
`LCPError` implements `LocalizedError`, enabling you to retrieve a user-friendly message. It's recommended to override the LCP localized strings in your app to translate them. These strings can be found at [Sources/LCP/Resources/en.lproj/Localizable.strings](https://github.com/readium/swift-toolkit/blob/main/Sources/LCP/Resources/en.lproj/Localizable.strings).
374+
375+
:warning: In the next major update, `LCPError` will no longer be localized. Applications will need to provide their own localized error messages. If you are adding LCP to a new app, consider treating `LCPError` as non-localized from the start to ease future migration.

0 commit comments

Comments
 (0)