|
| 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