From ff983dcd56109508fe25a908c1ea940ea9f274ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 1 May 2024 08:05:41 +0200 Subject: [PATCH] Documentation (#486) Co-authored-by: qnga <32197639+qnga@users.noreply.github.com> --- CONTRIBUTING.md | 22 ++ MAINTAINING.md | 80 +++++ README.md | 13 +- docs/guides/README.md | 12 + docs/guides/getting-started.md | 153 ++++++++ docs/guides/index.md | 9 - docs/guides/lcp.md | 305 ++++++++++++++++ docs/guides/{ => navigator}/epub-fonts.md | 2 +- .../guides/{ => navigator}/media-navigator.md | 0 docs/guides/navigator/navigator.md | 329 ++++++++++++++++++ .../preferences.md} | 4 +- docs/guides/open-publication.md | 2 +- .../src/main/assets/_scripts/README.md | 9 - .../readium/r2/testapp/reader/OpeningError.kt | 4 +- 14 files changed, 909 insertions(+), 35 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 MAINTAINING.md create mode 100644 docs/guides/README.md create mode 100644 docs/guides/getting-started.md delete mode 100644 docs/guides/index.md create mode 100644 docs/guides/lcp.md rename docs/guides/{ => navigator}/epub-fonts.md (99%) rename docs/guides/{ => navigator}/media-navigator.md (100%) create mode 100644 docs/guides/navigator/navigator.md rename docs/guides/{navigator-preferences.md => navigator/preferences.md} (99%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..13464de0b9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,22 @@ +# Contributing to the Readium Kotlin Toolkit + +First and foremost, thanks for your interest! 🙏 We need contributors like you to help bring this project to fruition. + +We welcome many kind of contributions such as improving the documentation, submitting bug reports and feature requests, or writing code. + +## Writing code + +### Coding standard + +We use [`ktlint`](https://github.com/pinterest/ktlint) to ensure code formatting and avoid bikeshedding. + +Before submitting a PR, save yourself some trouble by automatically formatting the code with `make format` from the project's root directory. + +### Modifying the EPUB Navigator's JavaScript layer + +The EPUB navigator injects a set of JavaScript files into a publication's resources, exposing a JavaScript API to the `WebView` under the `readium` global namespace. The JavaScript source code is located under [`readium/navigator/src/main/assets/_scripts`](readium/navigator/src/main/assets/_scripts). + +`index-reflowable.js` is the root of the bundle injected in a reflowable EPUB's resources, while `index-fixed.js` is used for a fixed-layout EPUB's resources. + +If you make any changes to the JavaScript files, you must regenerate the bundles embedded in the application. First, make sure you have [`corepack` installed](https://pnpm.io/installation#using-corepack). Then, run `make scripts` from the project's root directory. + diff --git a/MAINTAINING.md b/MAINTAINING.md new file mode 100644 index 0000000000..5340869933 --- /dev/null +++ b/MAINTAINING.md @@ -0,0 +1,80 @@ +# Maintaining the Readium Kotlin toolkit + +## Releasing a new version + +You are ready to release a new version of the Kotlin toolkit? Great, follow these steps: + +1. Figure out the next version using the [semantic versioning scheme](https://semver.org). +2. Test a migration from the last released version. +3. Update the [migration guide](docs/migration-guide.md) in case of breaking changes. +4. Issue the new release. + 1. Create a branch with the same name as the future tag, from `develop`. + 2. Bump the version numbers in: + * `README` + * `gradle.properties` + * `test-app/build.gradle.kts` + 5. Close the version in the `CHANGELOG.md` and `docs/migration-guide.md`, [for example](https://github.com/readium/kotlin-toolkit/commit/011e0d74adc66ec2073f746d815310b838af4fbf). + 6. Create a PR to merge in `develop` and verify the CI workflows. + 7. Squash and merge the PR. + 8. Tag the new version from `develop`. + ```shell + git checkout develop + git pull + git tag -a 3.0.1 -m 3.0.1 + git push --tags + ``` +5. Create a new release on GitHub. + * Add an APK to the release page **with LCP enabled**. +6. Publish to Maven Central. + 1. Verify that the [`Publish` workflow](https://github.com/readium/kotlin-toolkit/actions/workflows/publish.yml) successfully pushed and closed the release to Maven Central. + 2. Sign in to https://s01.oss.sonatype.org/ + 3. Verify the content of the staging repository. + 4. Release the staging repository. +7. Check that the new modules can be imported in an Android project from Maven Central. +8. Merge `develop` into `main`. + +### Publishing to Maven Central manually + +If the `Publish` workflow fails, you may need to publish to Maven Central manually. + +[The Sonatype issue for Readium is located here](https://issues.sonatype.org/browse/OSSRH-85964). + +#### With the new vanniktech's Maven publish plugin + +1. Make sure you have the secrets in `.envrc` and [direnv](https://direnv.net) installed. +2. Run: + ``` + ./gradlew publishToMavenCentral --no-configuration-cache + ``` +3. Sign in to https://s01.oss.sonatype.org/ +4. Publish manually the previously closed Staging repository + +### (Deprecated) With the official Maven publish plugin + +1. Make sure you have the secrets in `local.properties`. +2. Run: + ``` + ./gradlew clean assembleRelease + ./gradlew androidSourcesJar javadocJar + ./gradlew publishReleasePublicationToSonatypeRepository closeSonatypeStagingRepository + ``` +3. Sign in to https://s01.oss.sonatype.org/ +4. Publish manually the previously closed Staging repository + +Note that [you can't run the gradlew commands separately](https://github.com/gradle-nexus/publish-plugin#publishing-and-closing-in-different-gradle-invocations), otherwise you get this error: + +> No staging repository with name sonatype created + +## Troubleshooting + +### GitHub CI workflow is stuck + +If a CI workflow is stuck with this message: + +``` +Requested labels: ubuntu-18.04 +Job defined at: readium/kotlin-toolkit/.github/workflows/docs.yml@refs/heads/main +Waiting for a runner to pick up this job... +``` + +Try to update the version of the OS image in the workflow. diff --git a/README.md b/README.md index 9430377292..283d7b5a3f 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,7 @@ [Readium Mobile](https://github.com/readium/mobile) is a toolkit for ebooks, audiobooks and comics written in Swift & Kotlin. -This toolkit is a modular project, which follows the [Readium Architecture](https://github.com/readium/architecture). The different modules are found under [`readium/`](readium). - -* [`shared`](readium/shared) – Shared `Publication` models and utilities -* [`streamer`](readium/streamer) – Publication parsers and local HTTP server -* [`navigator`](readium/navigator) – Plain `Fragment` and `Activity` classes rendering publications -* [`opds`](readium/opds) – Parsers for OPDS catalog feeds -* [`lcp`](readium/lcp) – Service and models for [Readium LCP](https://www.edrlab.org/readium-lcp/) -* [`adapters`](readium/adapters) – Adapters to use third-party libraries with Readium. - * [`adapters/pdfium`](readium/adapters/pdfium) – Parse and render PDFs using the open source library [PdfiumAndroid](https://github.com/barteksc/PdfiumAndroid). - * [`adapters/pspdfkit`](readium/adapters/pspdfkit) – Parse and render PDFs using the commercial library [PSPDFKit](https://pspdfkit.com/). - -A [Test App](test-app) demonstrates how to integrate the Readium Kotlin toolkit in your own reading app. +:point_up: **Take a look at the [guide to get started](docs/guides/getting-started.md).** A [Test App](test-app) demonstrates how to integrate the Readium Kotlin toolkit in your own reading app. :question: **Find documentation and API reference at [readium.org/kotlin-toolkit](https://readium.org/kotlin-toolkit)**. diff --git a/docs/guides/README.md b/docs/guides/README.md new file mode 100644 index 0000000000..bd7904cc00 --- /dev/null +++ b/docs/guides/README.md @@ -0,0 +1,12 @@ +# User guides + +* [Getting started](getting-started.md) +* [Opening a publication](open-publication.md) +* [Extracting the content of a publication](content.md) +* [Supporting PDF documents](pdf.md) +* [Text-to-speech](tts.md) +* [Supporting Readium LCP](lcp.md) +* [Navigator](navigator/navigator.md) + * [Configuring the Navigator](navigator/preferences.md) + * [Font families in the EPUB navigator](navigator/epub-fonts.md) + * [Media Navigator](navigator/media-navigator.md) diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md new file mode 100644 index 0000000000..eefb6ea547 --- /dev/null +++ b/docs/guides/getting-started.md @@ -0,0 +1,153 @@ +# Getting started + +The Readium Kotlin toolkit enables you to develop reading apps for Android and ChromeOS. It provides built-in support for multiple publication formats such as EPUB, PDF, audiobooks, and comics. + +:warning: Readium offers only low-level tools. You are responsible for creating a user interface for reading and managing books, as well as a data layer to store the user's publications. The Test App is an example of such integration. + +## Design principles + +The toolkit has been designed following these core tenets: + +* **Modular**: It is divided into separate modules that can be used independently. +* **Extensible**: Integrators should be able to support a custom DRM, publication format or inject their own stylesheets without modifying the toolkit itself. +* **Opiniated**: We adhere to open standards but sometimes interpret them for practicality. + +## Modules + +### Main modules + +* `readium-shared` contains shared `Publication` models and utilities. +* `readium-streamer` parses publication files (e.g. an EPUB) into a `Publication` object. +* [`readium-navigator` renders the content of a publication](navigator/navigator.md). + * [`readium-navigator-media-audio` renders audiobooks](navigator/media-navigator.md) + * [`readium-navigator-media-tts` renders publication with a text-to-speech engine](tts.md) + +### Specialized packages + +* `readium-opds` parses [OPDS catalog feeds](https://opds.io) (both OPDS 1 and 2). +* [`readium-lcp` downloads and decrypts LCP-protected publications](lcp.md). + +### Adapters to third-party dependencies + +* `readium-adapter-exoplayer` provides an [ExoPlayer](https://exoplayer.dev) adapter for the [`AudioNavigator`](navigator/media-navigator.md). +* [`readium-adapter-pdfium`](../../readium/adapters/pdfium/README.md) provides a [Pdfium](https://github.com/barteksc/AndroidPdfViewer) adapter for the [PDF Navigator](pdf.md). +* [`readium-adapter-pspdfkit`](../../readium/adapters/pspdfkit/README.md) provides a [PSPDFKit](https://pspdfkit.com) adapter for the [PDF Navigator](pdf.md). + +## Overview of the shared models (`readium-shared`) + +The Readium toolkit provides models used as exchange types between packages. + +### Publication models + +#### Publication + +`Publication` and its sub-components represent a single publication – ebook, audiobook or comic. It is loosely based on the [Readium Web Publication Manifest](https://readium.org/webpub-manifest/). + +A `Publication` instance: + +* holds the metadata of a publication, such as its author or table of contents, +* allows to read the contents of a publication, e.g. XHTML or audio resources, +* provides additional services, for example content extraction or text search. + +#### Link + +A [`Link` object](https://readium.org/webpub-manifest/#24-the-link-object) holds a pointer (URL) to a resource or service along with additional metadata, such as its media type or title. + +The `Publication` contains several `Link` collections, for example: + +* `readingOrder` lists the publication resources arranged in the order they should be read. +* `resources` contains secondary resources necessary for rendering the `readingOrder`, such as an image or a font file. +* `tableOfContents` represents the table of contents as a tree of `Link` objects. +* `links` exposes additional resources, such as a canonical link to the manifest or a search web service. + +#### Locator + +A [`Locator` object](https://readium.org/architecture/models/locators/) represents a precise location in a publication resource in a format that can be stored and shared across reading systems. It is more accurate than a `Link` and contains additional information about the location, e.g. progression percentage, position or textual context. + +`Locator` objects are used for various features, including: + +* reporting the current progression in the publication +* saving bookmarks, highlights and annotations +* navigating search results + +### Data models + +#### Asset + +An `Asset` represents a single file or package and provides access to its content. There are two types of `Asset`: + +* `ContainerAsset` for packages which contains several resources, such as a ZIP archive. +* `ResourceAsset` for accessing a single resource, such as a JSON or PDF file. + +`Asset` instances are obtained through an `AssetRetriever`. + +You can use the `asset.format` to identify the media type and capabilities of the asset. + +```kotlin +if (asset.format.conformsTo(Specification.Lcp)) { + // The asset is protected with LCP. +} +if (asset.format.conformsTo(Specification.Epub)) { + // The asset represent an EPUB publication. +} +``` + +#### Resource + +A `Resource` provides read access to a single resource, such as a file or an entry in an archive. + +`Resource` instances are usually created by a `ResourceFactory`. The toolkit ships with various implementations supporting different data access protocols such as local files, HTTP, Android Content Providers, etc. + +#### Container + +A `Container` provides read access to a collection of resources. `Container` instances representing an archive are usually created by an `ArchiveOpener`. The toolkit ships with a `ZipArchiveOpener` supporting local and remote ZIP files. + +`Publication` objects internally use a `Container` to expose its content. + +## Opening a publication (`readium-streamer`) + +To retrieve a `Publication` object from a publication file like an EPUB or audiobook, you can use an `AssetRetriever` and `PublicationOpener`. + +```kotlin +// Instantiate the required components. +val httpClient = DefaultHttpClient() +val assetRetriever = AssetRetriever( + contentResolver = context.contentResolver, + httpClient = httpClient +) +val publicationOpener = PublicationOpener( + publicationParser = DefaultPublicationParser( + context, + httpClient = httpClient, + assetRetriever = assetRetriever, + pdfFactory = PdfiumDocumentFactory(context) + ) +) + +// Retrieve an `Asset` to access the file content. +val url = File("/path/to/book.epub").toUrl() +val asset = assetRetriever.retrieve(url) + .getOrElse { /* Failed to retrieve the Asset */ } + +// Open a `Publication` from the `Asset`. +val publication = publicationOpener.open(asset, allowUserInteraction = true) + .getOrElse { /* Failed to access or parse the publication */ } + +print("Opened ${publication.metadata.title}") +``` + +The `allowUserInteraction` parameter is useful when supporting a DRM like Readium LCP. It indicates if the toolkit can prompt the user for credentials when the publication is protected. + +[See the dedicated user guide for more information](open-publication.md). + +## Accessing the metadata of a publication + +After opening a publication, you may want to read its metadata to insert a new entity into your bookshelf database, for instance. The `publication.metadata` object contains everything you need, including `title`, `authors` and the `published` date. + +You can retrieve the publication cover using `publication.cover()`. + +## Rendering the publication on the screen (`readium-navigator`) + +You can use a Readium navigator to present the publication to the user. The `Navigator` renders resources on the screen and offers APIs and user interactions for navigating the contents. + +Please refer to the [Navigator guide](navigator/navigator.md) for more information. diff --git a/docs/guides/index.md b/docs/guides/index.md deleted file mode 100644 index 01bee09211..0000000000 --- a/docs/guides/index.md +++ /dev/null @@ -1,9 +0,0 @@ -# User guides - -* [Opening a publication](open-publication.md) -* [Extracting the content of a publication](content.md) -* [Supporting PDF documents](pdf.md) -* [Configuring the Navigator](navigator-preferences.md) -* [Font families in the EPUB navigator](epub-fonts.md) -* [Media Navigator](media-navigator.md) -* [Text-to-speech](tts.md) \ No newline at end of file diff --git a/docs/guides/lcp.md b/docs/guides/lcp.md new file mode 100644 index 0000000000..ec1753a8f4 --- /dev/null +++ b/docs/guides/lcp.md @@ -0,0 +1,305 @@ +# Supporting Readium LCP + +You can use the Readium Kotlin toolkit to download and read publications that are protected with the [Readium LCP](https://www.edrlab.org/readium-lcp/) DRM. + +:point_up: To use LCP with the Readium toolkit, you must first obtain the `liblcp` private library by contacting [EDRLab](https://www.edrlab.org/contact/). + +## Overview + +An LCP publication is protected with a *user passphrase* and distributed using an LCP License Document (`.lcpl`) . + +The user flow typically goes as follows: + +1. The user imports a `.lcpl` file into your application. +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. +3. The user opens the protected publication from the bookshelf. +4. If the passphrase isn't already recorded in the `readium-lcp` internal database, the user will be asked to enter it to unlock the contents. +5. The publication is decrypted and rendered on the screen. + +## Setup + +To support LCP in your application, you require two components: + +* The `readium-lcp` module from the toolkit provides APIs for downloading and decrypting protected publications. Import it as you would any other Readium module, such as `readium-navigator`. +* The private `liblcp` library customized for your application [is available from EDRLab](https://www.edrlab.org/contact/). They will provide instructions for integrating the `liblcp` library into your application. + +### File formats + +Readium LCP specifies new file formats. + +| Name | File extension | Media type | +|---------------------------------------------------------------------------------------------------|----------------|-------------------------------------------------| +| [License Document](https://readium.org/lcp-specs/releases/lcp/latest.html#32-content-conformance) | `.lcpl` | `application/vnd.readium.lcp.license.v1.0+json` | +| [LCP for PDF package](https://readium.org/lcp-specs/notes/lcp-for-pdf.html) | `.lcpdf` | `application/pdf+lcp` | +| [LCP for Audiobooks package](https://readium.org/lcp-specs/notes/lcp-for-audiobooks.html) | `.lcpa` | `application/audiobook+lcp` | + +: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. + +You may want to register these new file extensions and media types in the [intent filters](https://developer.android.com/guide/components/intents-filters) of your `AndroidManifest.xml`. + +## Initializing the `LcpService` + +`readium-lcp` offers an `LcpService` object that exposes its API. If `liblcp` is not configured correctly in your application, the constructor will return `null`. This is helpful if your application has build variants without LCP. + +```kotlin +val lcpService = LcpService( + context = context, + assetRetriever = AssetRetriever( + contentResolver = context.contentResolver, + httpClient = DefaultHttpClient() + ) +) ?: error("liblcp is missing on the classpath"))) +``` + +## Acquiring a publication from a License Document (LCPL) + +Users need to import a License Document into your application to download the protected publication (`.epub`, `.lcpdf`, or `.lcpa`). + +The `LcpService` offers an API to retrieve the full publication from an LCPL on the filesystem. + +```kotlin +let acquisition = lcpService.acquirePublication( + lcpl = File("path/to/license.lcpl"), + onProgress = { progress -> + print(String.format("Downloaded %.1f%%", progress * 100) + } +).getOrElse { /* Failed to download the protected publication */ } + +print("The publication was downloaded at ${acquisition.localFile}, its type is ${acquisition.format.mediaType}.") +``` + +After the download is completed, import the `acquisition.localFile` file into the bookshelf like any other publication file. + +### Using a custom download manager + +If you want more control over the acquisition process, you can download the publication manually instead. + +The acquisition is done in three steps: + +1. Parse the License Document (LCPL) file. +2. Download the protected publication. +3. Inject the LCPL into the downloaded package. + +```kotlin +val lcplBytes: ByteArray = ... + +val licenseDocument = LicenseDocument.fromBytes(lcplBytes) + .getOrElse { /* The LCPL appears to be invalid */ } + + +val publicationLink = licenseDocument.publicationLink + +val downloadedFile = yourDownloadService.download(publicationLink.url()) + .getOrElse { /* Failed to download the protected publication */ } + +lcpService.injectLicenseDocument(licenseDocument, downloadedFile) + .getOrElse { /* Failed to inject the LCPL in the downloaded package */ } + +// The downloaded file is now ready to be imported in your bookshelf as usual. +``` + +## Opening a publication protected with LCP + +### Initializing the `PublicationOpener` + +A publication protected with LCP can be opened using the `PublicationOpener` 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 `PublicationOpener` to enable LCP. Luckily, `LcpService` has you covered. + +```kotlin +val authentication = LcpDialogAuthentication() + +val publicationOpener = PublicationOpener( + publicationParser = DefaultPublicationParser(), + contentProtections = listOf( + lcpService.contentProtection(authentication) + ) +) +``` + +An LCP package is secured with a *user passphrase* for decrypting the content. The `LcpAuthenticating` interface expected by `LcpService.contentProtection()` 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. If you already fetched the passphrase from a backend server, you can also use `LcpPassphraseAuthentication(passphrase)`. + +:point_up: The user will be prompted once per passphrase since `readium-lcp` stores known passphrases on the device. + +### Setting up the `LcpDialogAuthentication` + +For `LcpDialogAuthentication` to function correctly, it needs to identify the host view displaying the dialog. You must indicate the host view, for example using a `View.OnAttachStateChangeListener` in your bookshelf fragment. + +```kotlin +class MyFragment : Fragment { + + private inner class OnViewAttachedListener( + private val authentication: LcpDialogAuthentication + ) : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(view: View) { + authentication.onParentViewAttachedToWindow(view) + } + + override fun onViewDetachedFromWindow(view: View) { + authentication.onParentViewDetachedFromWindow() + } + } + + private val onViewAttachedListener: OnViewAttachedListener = OnViewAttachedListener( + // Use your shared instance here. + lcpDialogAuthentication + ) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + view.addOnAttachStateChangeListener(onViewAttachedListener) + } +} +``` + +### Opening the publication + +You are now ready to open the publication file with your `PublicationOpener` instance. + +```kotlin +// Retrieve an `Asset` to access the file content. +val url = File("/path/to/lcp-protected-book.epub").toUrl() +val asset = assetRetriever.retrieve(url) + .getOrElse { /* Failed to retrieve the Asset */ } + +// Open a `Publication` from the `Asset`. +val publication = publicationOpener.open(asset, allowUserInteraction = true) + .getOrElse { /* Failed to access or parse the publication */ } +``` + +The `allowUserInteraction` argument is forwarded to the `LcpAuthenticating` implementation when the passphrase is unknown. `LcpDialogAuthentication` shows a pop-up only if `allowUserInteraction` is `true`. + +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. + +:point_up: To check if a publication is protected with LCP before opening it, you can use `asset.format.conformsTo(Specification.Lcp)` on the `Asset` returned by the `AssetRetriever`. + +### Using the opened `Publication` + +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. + +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.). + +```kotlin +if (publication.isRestricted) { + val error = publication.protectionError + if (error != null) { + // The user is not allowed to open the publication. You should display the error. + // In the case of LCP, `error` will be an `LcpError`. + } else { + // We don't have the user passphrase. + // You may use `publication` to access its metadata, but not to render its content. + } +} else { + // The publication is not restricted, you may render it with a Navigator component. +} +``` + +## Streaming an LCP protected package + +If the server hosting the LCP protected package supports [HTTP Range requests](https://httpwg.org/specs/rfc7233.html), it is possible to stream directly an LCP protected publication from a License Document (`.lcpl`) file, without downloading the whole publication first. + +Simply open the License Document directly using the `PublicationOpener`. Make sure you provide an `HttpClient` (or an `HttpResourceFactory` for additional customization) to the `AssetRetriever`. + +```kotlin +// Instantiate the required components. +val httpClient = DefaultHttpClient() +val assetRetriever = AssetRetriever( + contentResolver = context.contentResolver, + httpClient = httpClient +) +val publicationOpener = PublicationOpener( + publicationParser = DefaultPublicationParser( + context, + httpClient = httpClient, + assetRetriever = assetRetriever + ) +) + +// Retrieve an `Asset` to access the LCPL content. +val url = File("/path/to/license.lcpl").toUrl() +val asset = assetRetriever.retrieve(url) + .getOrElse { /* Failed to retrieve the Asset */ } + +// Open a `Publication` from the LCPL `Asset`. +val publication = publicationOpener.open(asset, allowUserInteraction = true) + .getOrElse { /* Failed to access or parse the publication */ } + +print("Opened ${publication.metadata.title}") +``` + +## Obtaining information on an LCP license + +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. + +Use the `LcpService` to retrieve the `LcpLicense` instance for a publication. + +```kotlin +// Retrieve an `Asset` to access the file content. +val url = File("/path/to/lcp-protected-book.epub").toUrl() +val asset = assetRetriever.retrieve(url) + .getOrElse { /* Failed to retrieve the Asset */ } + +if (!asset.format.conformsTo(Specification.Lcp)) { + // Not protected with LCP. +} + +val lcpLicense = lcpService.retrieveLicense( + asset = asset, + authentication = authenticaton, + allowUserInteraction = true +).getOrElse { /* Failed to retrieve the LCP License from the publication */ } + +lcpLicense.license.user.name?.let { name -> + print("The publication was acquired by $user") +} + +lcpLicense.license.rights.end?.let { endDate -> + print("The loan expires on $endDate") +} + +lcpLicense.charactersToCopyLeft?.let { copyLeft -> + print("You can copy up to $copyLeft characters remaining.") +} +``` + +:point_up: If you have already opened a `Publication` with the `PublicationOpener`, you can directly obtain the `LcpLicense` using `publication.lcpLicense`. + +## Managing a loan + +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`. + +### Returning a loan + +Some loans can be returned before the end date. You can confirm this by using `lcpLicense.canReturnPublication`. To return the publication, execute: + +```kotlin +lcpLicense.returnPublication() + .onFailure { /* Failed to return the publication */ } +``` + +### Renewing a loan + +The loan end date may also be extended. You can confirm this by using `lcpLicense.canRenewLoan`. + +Readium LCP supports [two types of renewal interactions](https://readium.org/lcp-specs/releases/lsd/latest#35-renewing-a-license): + +* Programmatic: You show your own user interface. +* Interactive: You display a web view, and the Readium LSD server manages the renewal for you. + +You need to support both interactions by implementing the `LcpLicense.RenewListener` interface. A default Material Design implementation is available with `MaterialRenewListener`. + +```kotlin +val renewListener = MaterialRenewListener( + license = lcpLicense, + caller = hostFragment, + fragmentManager = hostFragment.childFragmentManager +) + +lcpLicense.renewLoan(renewListener) + .onFailure { /* Failed to extend the loan end date */ } +``` + +## Handling `LcpError` + +The APIs may fail with an `LcpError`. These errors **must** be displayed to the user with a suitable message. + +For an example, take a look at [`LcpUserError.kt`](../../test-app/src/main/java/org/readium/r2/testapp/domain/LcpUserError.kt) in the Test App. diff --git a/docs/guides/epub-fonts.md b/docs/guides/navigator/epub-fonts.md similarity index 99% rename from docs/guides/epub-fonts.md rename to docs/guides/navigator/epub-fonts.md index 9d82f13556..ef79e70594 100644 --- a/docs/guides/epub-fonts.md +++ b/docs/guides/navigator/epub-fonts.md @@ -1,6 +1,6 @@ # Font families in the EPUB navigator -Readium allows users to customize the font family used to render a reflowable EPUB, by changing the [EPUB navigator preferences](navigator-preferences.md). +Readium allows users to customize the font family used to render a reflowable EPUB, by changing the [EPUB navigator preferences](preferences.md). :warning: You cannot change the default font family of a fixed-layout EPUB (with zoomable pages), as it is similar to a PDF or a comic book. diff --git a/docs/guides/media-navigator.md b/docs/guides/navigator/media-navigator.md similarity index 100% rename from docs/guides/media-navigator.md rename to docs/guides/navigator/media-navigator.md diff --git a/docs/guides/navigator/navigator.md b/docs/guides/navigator/navigator.md new file mode 100644 index 0000000000..de8ee7cf97 --- /dev/null +++ b/docs/guides/navigator/navigator.md @@ -0,0 +1,329 @@ +# Navigator + +You can use a Readium Navigator to present the publication to the user. The `Navigator` renders resources on the screen and offers APIs and user interactions for navigating the contents. + +:warning: Navigators do not have user interfaces besides the view that displays the publication. Applications are responsible for providing a user interface with bookmark buttons, a progress bar, etc. + +## Default implementations + +The Readium toolkit comes with several `Navigator` implementations for different publication profiles. Some are Android `Fragment`s, designed to be added to your view hierarchy, while others are chromeless and can be used in the background. + +| Navigator | Supported publications | +|-------------------------------|----------------------------------------------------------------------------------------| +| `EpubNavigatorFragment` | `epub` profile (EPUB, Readium Web Publication) | +| `PdfNavigatorFragment` | `pdf` profile (PDF, LCP for PDF package) | +| `ImageNavigatorFragment` | `divina` profile (Zipped Comic Book, Readium Divina) | +| `AudioNavigator` | `audiobook` profile (Zipped Audio Book, Readium Audiobook, LCP for Audiobooks package) | +| `TtsNavigator` | Any publication with a [`ContentService`](../content.md) | + +To find out which Navigator is compatible with a publication, refer to its [profile](https://readium.org/webpub-manifest/profiles/). Use `publication.conformsTo()` to identify the publication's profile. + +```kotlin +if (publication.conformsTo(Publication.Profile.EPUB)) { + // Initialize an `EpubNavigatorFragment`. +} +``` + +### Navigator APIs + +Navigators implement a set of shared interfaces to help reuse the reading logic across publication profiles. For example, instead of using specific implementations like `EpubNavigatorFragment`, use the `Navigator` interface to create a location history manager compatible with all Navigator types. + +You can create custom Navigators and easily integrate them into your app with minimal modifications by implementing these interfaces. + +#### `Navigator` interface + +All Navigators implement the `Navigator` interface, which provides the foundation for navigating resources in a `Publication`. You can use it to move through the publication's content or to find the current position. + +Note that this interface does not specify how the content is presented to the user. + +#### `VisualNavigator` interface + +Navigators rendering the content visually on the screen implement the `VisualNavigator` interface. This interface allows monitoring input events such as taps or keyboard strokes. + +#### `OverflowableNavigator` interface + +An `OverflowableNavigator` is a Visual Navigator whose content can extend beyond the viewport. This interface offers details about the overflow style, e.g., scrolled, scroll axis or the reading progression. + +The user typically navigates through the publication by scrolling or tapping the viewport edges. + +#### `MediaNavigator` interface + +The `MediaNavigator` interface is implemented by Navigators rendering a publication as audio or video content. You can use it to control the playback or observe its status. + +[Refer to the `MediaNavigator` guide for additional details](media-navigator.md). + +##### `TimeBasedMediaNavigator` interface + +A time-based `MediaNavigator` renders an audio or video content with time locations. It is suitable for audiobook or media overlays Navigators. + +##### `TextAwareMediaNavigator` interface + +A text-aware `MediaNavigator` synchronizes utterances (e.g. sentences) with their corresponding audio or video clips. It can be used for text-to-speech, media overlays, and subtitled Navigators. + +#### `SelectableNavigator` interface + +Navigators enabling users to select parts of the content implement `SelectableNavigator`. You can use it to extract the `Locator` and content of the selected portion. + +#### `DecorableNavigator` interface + +A Decorable Navigator is able to render decorations over a publication, such as highlights or margin icons. + +[See the corresponding proposal for more information](https://readium.org/architecture/proposals/008-decorator-api.html). + +## Instantiating a navigator + +### Visual navigators + +The Visual Navigators are implemented as `Fragment` and must be added to your Android view hierarchy to render the publication contents. + +#### `EpubNavigatorFragment` + +Create an `EpubNavigatorFactory` using your `Publication` instance. Optionally, set custom defaults for user preferences. + +```kotlin +val navigatorFactory = EpubNavigatorFactory( + publication = publication, + configuration = EpubNavigatorFactory.Configuration( + defaults = EpubDefaults( + pageMargins = 1.4 + ) + ) +) +``` + +Then, you need to setup the `FragmentFactory` in your custom parent `Fragment`. Refer to `EpubReaderFragment` in the Test App for a complete example. + +:point_up: This is one method to set up the `EpubNavigatorFragment` in your view hierarchy. Select the approach that suits your application best. + +```kotlin +class EpubReaderFragment : Fragment(), EpubNavigatorFragment.Listener { + + lateinit var navigator: EpubNavigatorFragment + private var binding: FragmentReaderBinding by viewLifecycle() + + override fun onCreate(savedInstanceState: Bundle?) { + // You are responsible for creating/restoring the `NavigatorFactory`, + // for example from an in-memory repository. + // See `ReaderRepository` in the Test App for an example. + val navigatorFactory = ... + + // You should restore the initial location from your view model. + childFragmentManager.fragmentFactory = + navigatorFactory.createFragmentFactory( + initialLocator = viewModel.initialLocator, + listener = this + ) + + // IMPORTANT: Set the `fragmentFactory` before calling `super`. + super.onCreate(savedInstanceState) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentReaderBinding.inflate(inflater, container, false) + val view = binding.root + val tag = "EpubNavigatorFragment" + + if (savedInstanceState == null) { + childFragmentManager.commitNow { + add(R.id.navigator_container, EpubNavigatorFragment::class.java, Bundle(), tag) + } + } + + navigator = childFragmentManager.findFragmentByTag(tag) as EpubNavigatorFragment + + return view + } +} +``` + +```xml + + + + + +``` + +#### `PdfNavigatorFragment` + +Use the same approach as described with the `EpubNavigatorFragment`, using a `PdfNavigatorFactory` instead. + +#### `ImageNavigatorFragment` + +Use the same approach as described with the `EpubNavigatorFragment`, except that there is no `ImageNavigatorFactory`. Instead, you can build the `FragmentFactory` directly with: + +```kotlin +childFragmentManager.fragmentFactory = + ImageNavigatorFragment.createFactory( + publication = publication, + initialLocator = viewModel.initialLocator, + listener = this + ) +``` + +#### Limitations of the current `Fragment` APIs + +The current toolkit API has a limitation regarding the lifecycle of `Fragment`s. If your `Activity` is being recreated after Android terminates your application, you must still provide a `FragmentFactory` in `Activity.onCreate()`, even though you may no longer have access to a `Publication` or `NavigatorFactory` instance. + +To work around this issue, we provide "dummy" factories that you can use to recover during the restoration, before immediately removing the fragment or finishing the activity. Here's an example with the `EpubNavigatorFragment`: + +```kotlin +override fun onCreate(savedInstanceState: Bundle?) { + val navigatorFactory = viewModel.navigatorFactory + + if (navigatorFactory == null) { + // We provide a dummy fragment factory if the Activity is restored after the + // app process was killed because the view model is empty. In that case, finish + // the activity as soon as possible and go back to the previous one. + childFragmentManager.fragmentFactory = EpubNavigatorFragment.createDummyFactory() + + super.onCreate(savedInstanceState) + + requireActivity().finish() + + return + } + + childFragmentManager.fragmentFactory = + navigatorFactory.createFragmentFactory(...) + + super.onCreate(savedInstanceState) +} +``` + +### `AudioNavigator` + +The `AudioNavigator` is chromeless and does not provide any user interface, allowing you to create your own custom UI. + +First, create an instance of the `AudioNavigatorFactory`, with the audio engine provider you want to use. + +```kotlin +val navigatorFactory = AudioNavigatorFactory( + publication = publication, + audioEngineProvider = ExoPlayerEngineProvider( + application, + defaults = ExoPlayerDefaults( + pitch = 0.8 + ) + ) +) +``` + +Then, simply request an instance of the `AudioNavigator` at the given initial location. + +```kotlin +val navigator = navigatorFactory.createNavigator(initialLocator) +navigator.play() +``` + +### `TtsNavigator` + +The text-to-speech navigator is very similar to the `AudioNavigator`. + +```kotlin +val navigatorFactory = TtsNavigatorFactory( + application, + publication, + defaults = AndroidTtsDefaults( + pitch = 0.8 + ) +) +if (navigatorFactory == null) { + // This publication is not supported by the `TtsNavigator`. + return +} + +val navigator = navigatorFactory.createNavigator(initialLocator) +navigator.play() +``` + +## Navigating the contents of the publication + +The `Navigator` interface offers various `go` APIs for navigating the publication. For instance: + +* to a link from the `publication.tableOfContents` or `publication.readingOrder`: `navigator.go(Link)` +* to a locator from a search result: `navigator.go(Locator)` + +Specialized interfaces add more navigation APIs. For instance, the `OverflowableNavigator` enables moving to previous or next pages using `goForward()` and `goBackward()`. + +## Reading progression + +### Saving and restoring the reading progression + +Navigators don't store any data permanently. Therefore, it is your responsibility to save the last read location in your database and restore it when creating a new Navigator. + +You can observe the current position in the publication with `Navigator.currentLocator`. + +```kotlin +override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + navigator.currentLocator + .onEach { viewModel.saveReadingProgression(it) } + .launchIn(this) + } + } +} +``` + +The `Locator` object may be serialized to JSON in your database, and deserialized to set the initial location when creating the navigator, as explained in [the previous section](#instantiating-a-navigator). + +To restore the reading progression, pass the saved `Locator` to the `initialLocator` parameter when creating the navigator### Bookmarking the current location + +### Bookmarking the current location + +Use a Navigator's `currentLocator` property to persists the current position, for instance as a bookmark. + +After the user selects a bookmark from your user interface, navigate to it using `navigator.go(bookmark.locator)`. + +### Displaying a progression slider + +To display a percentage-based progression slider, use the `locations.totalProgression` property of the `currentLocator`. This property holds the total progression across an entire publication. + +Given a progression from 0 to 1, you can obtain a `Locator` object from the `Publication`. This can be used to navigate to a specific percentage within the publication. + +```kotlin +publication.locateProgression(progression)?.let { locator -> + navigator.go(locator) +} +``` + +### Displaying the number of positions + +:warning: Readium does not have the concept of pages, as they are not useful when dealing with reflowable publications across different screen sizes. Instead, we use [**positions**](https://readium.org/architecture/models/locators/positions/) which remain stable even when the user changes the font size or device. + +Not all Navigators provide positions, but most `VisualNavigator` implementations do. Verify if `publication.positions` is not empty to determine if it is supported. + +To find the total positions in the publication, use `publication.positions().size`. You can get the current position with `navigator.currentLocator.value.locations.position`. + +## Navigating with edge taps and keyboard arrows + +Readium provides a `DirectionalNavigationAdapter` helper to automatically turn pages when the user hit the arrow and space keys on their keyboard or tap the edge of the screen. + +It's easy to set it up with any implementation of `OverflowableNavigator`: + +```kotlin +(navigator as? OverflowableNavigator)?.apply { + addInputListener(DirectionalNavigationAdapter(this)) +} +``` + +`DirectionalNavigationAdapter` offers a lot of customization options. Take a look at its API. + +## User preferences + +Readium Navigators support user preferences, such as font size or background color. Take a look at [the Preferences API guide](preferences.md) for more information. diff --git a/docs/guides/navigator-preferences.md b/docs/guides/navigator/preferences.md similarity index 99% rename from docs/guides/navigator-preferences.md rename to docs/guides/navigator/preferences.md index ac8ba31b67..e806aa59e9 100644 --- a/docs/guides/navigator-preferences.md +++ b/docs/guides/navigator/preferences.md @@ -2,7 +2,7 @@ :warning: The Navigator Setting API is still experimental and currently only available with `EpubNavigatorFragment` and `PdfNavigatorFragment`. -Take a look at the [migration guide](../migration-guide.md) if you are already using the legacy EPUB settings. +Take a look at the [migration guide](../../migration-guide.md) if you are already using the legacy EPUB settings. ## Overview @@ -12,7 +12,7 @@ You cannot directly overwrite the Navigator settings. Instead, you submit a set For instance: "font size" is a **setting**, and the application can submit the font size value `150%` as a **preference**. - + ```kotlin // 1. Create a set of preferences. diff --git a/docs/guides/open-publication.md b/docs/guides/open-publication.md index 6a2a1daa9a..96a008ac09 100644 --- a/docs/guides/open-publication.md +++ b/docs/guides/open-publication.md @@ -33,7 +33,7 @@ val url = contentUri.toAbsoluteUrl() val url = AbsoluteUrl("https://domain/book.epub") val asset = assetRetriever.retrieve(url) - .getOrElse { /* Failed to retrieve the Asset */ } + .getOrElse { /* Failed to retrieve the Asset */ } ``` The `AssetRetriever` will sniff the media type of the asset, which you can store in your bookshelf database to speed up the process next time you retrieve the `Asset`. This will improve performance, especially with HTTP URL schemes. diff --git a/readium/navigator/src/main/assets/_scripts/README.md b/readium/navigator/src/main/assets/_scripts/README.md index f55a681cbb..8b722f603f 100644 --- a/readium/navigator/src/main/assets/_scripts/README.md +++ b/readium/navigator/src/main/assets/_scripts/README.md @@ -3,12 +3,3 @@ A set of JavaScript files used by the Kotlin EPUB navigator. This folder starts with an underscore to prevent Gradle from embedding it as an asset. - -## Scripts - -Run `npm install`, then use one of the following: - -* `yarn run bundle` Rebuild the assets after any changes in the `src/` folder. -* `yarn run lint` Check code quality. -* `yarn run checkformat` Check if there's any formatting issues. -* `yarn run format` Automatically format JavaScript sources. diff --git a/test-app/src/main/java/org/readium/r2/testapp/reader/OpeningError.kt b/test-app/src/main/java/org/readium/r2/testapp/reader/OpeningError.kt index 7620848ab2..8bea3071cf 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/reader/OpeningError.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/reader/OpeningError.kt @@ -6,6 +6,7 @@ package org.readium.r2.testapp.reader +import org.readium.r2.lcp.LcpError import org.readium.r2.shared.util.Error import org.readium.r2.testapp.R import org.readium.r2.testapp.domain.toUserError @@ -40,7 +41,8 @@ sealed class OpeningError( is PublicationError -> cause.toUserError() is RestrictedPublication -> - UserError(R.string.publication_error_restricted, cause = this) + (cause as? LcpError)?.toUserError() + ?: UserError(R.string.publication_error_restricted, cause = this) is CannotRender -> UserError(R.string.opening_publication_cannot_render, cause = this) }