diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index 2b10348505..e0be883ca7 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -13,11 +13,11 @@ jobs:
if: ${{ !github.event.pull_request.draft }}
steps:
- name: Checkout
- uses: actions/checkout@v2
- - name: Set up JDK 11
- uses: actions/setup-java@v2
+ uses: actions/checkout@v3
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
with:
- java-version: '11'
+ java-version: '17'
distribution: 'adopt'
- name: Build
run: ./gradlew clean build -x test -x ktlintMainSourceSetCheck
@@ -30,11 +30,11 @@ jobs:
if: ${{ !github.event.pull_request.draft }}
steps:
- name: Checkout
- uses: actions/checkout@v2
- - name: Set up JDK 11
- uses: actions/setup-java@v2
+ uses: actions/checkout@v3
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
with:
- java-version: '11'
+ java-version: '17'
distribution: 'adopt'
- name: Lint
run: ./gradlew ktlintCheck
@@ -43,22 +43,29 @@ jobs:
name: Lint JavaScript
runs-on: macos-latest
if: ${{ !github.event.pull_request.draft }}
- defaults:
- run:
- working-directory: readium/navigator
env:
- scripts: ${{ 'src/main/assets/_scripts' }}
+ scripts: ${{ 'readium/navigator/src/main/assets/_scripts' }}
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
+ - name: Install pnpm
+ uses: pnpm/action-setup@v2
+ with:
+ package_json_file: readium/navigator/src/main/assets/_scripts/package.json
+ run_install: false
+ - name: Setup cache
+ uses: actions/setup-node@v3
+ with:
+ node-version: 20
+ cache: 'pnpm'
+ cache-dependency-path: readium/navigator/src/main/assets/_scripts/pnpm-lock.yaml
- name: Install dependencies
- run: yarn --cwd "$scripts" install --frozen-lockfile
+ run: pnpm --dir "$scripts" install --frozen-lockfile
- name: Lint
- run: yarn --cwd "$scripts" run lint
+ run: pnpm --dir "$scripts" run lint
- name: Check formatting
- run: yarn --cwd "$scripts" run checkformat
- # FIXME: This suddenly stopped working even though the toolchain versions seem identical.
- # - name: Check if bundled scripts are up-to-date
- # run: |
- # make scripts
- # git diff --exit-code --name-only src/main/assets/readium/scripts/*.js
+ run: pnpm --dir "$scripts" run checkformat
+ - name: Check if bundled scripts are up-to-date
+ run: |
+ make scripts
+ git diff --exit-code --name-only src/main/assets/readium/scripts/*.js
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 54cc479ab6..4abba3507f 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -30,7 +30,7 @@ jobs:
- uses: actions/setup-java@v3
with:
- java-version: 11
+ java-version: 17
distribution: 'adopt'
- name: Get current Readium version
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 32bd05c089..1b8a9556a8 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -12,14 +12,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
with:
ref: develop
- - name: Set up JDK 11
- uses: actions/setup-java@v2
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
with:
distribution: adopt
- java-version: 11
+ java-version: 17
# Builds the release artifacts of the library
- name: Release build
diff --git a/.gitignore b/.gitignore
index 3070fa2ac9..d46e9173c1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,6 +48,7 @@ captures/
.idea/libraries
.idea/jarRepositories.xml
.idea/misc.xml
+.idea/migrations.xml
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
@@ -82,4 +83,5 @@ lint/reports/
docs/readium
docs/index.md
docs/package-list
-site/
\ No newline at end of file
+site/
+androidTestResultsUserPreferences.xml
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index e1eea1d6b9..8d81632f83 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1b8faa114d..9a35bded3f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,68 @@ All notable changes to this project will be documented in this file. Take a look
**Warning:** Features marked as *experimental* may change or be removed in a future release without notice. Use with caution.
-
+## [Unreleased]
+
+:warning: Please consult [the migration guide](docs/migration-guide.md#300-alpha1) to assist you in handling the breaking changes in this latest major release.
+
+### Added
+
+#### Shared
+
+* A new `Format` type was introduced to augment `MediaType` with more precise information about the format specifications of an `Asset`.
+* The `DownloadManager` interface handles HTTP downloads. Components like the `LcpService` rely on it for downloading publications. Readium v3 ships with two implementations:
+ * `ForegroundDownloadManager` uses an `HttpClient` to download files while the app is running.
+ * `AndroidDownloadManager` is built upon [Android's `DownloadManager`](https://developer.android.com/reference/android/app/DownloadManager) to manage HTTP downloads, even when the application is closed. It allows for resuming downloads after losing connection.
+* The default `ZipArchiveOpener` now supports streaming ZIP archives, which enables opening a packaged publication (e.g. EPUB or LCP protected audiobook):
+ * served by a remote HTTP server,
+ * accessed through an Android `ContentProvider`, such as the shared storage.
+
+#### Navigator
+
+* Support for keyboard events in the EPUB, PDF and image navigators. See `VisualNavigator.addInputListener()`.
+
+#### LCP
+
+* You can now stream an LCP protected publication using its LCP License Document. This is useful for example to read a large audiobook without downloading it on the device first.
+* The hash of protected publications is now verified upon download.
+
+### Changed
+
+* :warning: To avoid conflicts when merging your app resources, all resources declared in the Readium toolkit now have the prefix `readium_`. This means that you must rename any layouts or strings you have overridden. Some resources were removed from the toolkit. Please consult [the migration guide](docs/migration-guide.md#300-alpha1).
+* Most APIs now return an `Error` instance instead of an `Exception` in case of failure, as these objects are not thrown by the toolkit but returned as values
+
+#### Shared
+
+* :warning: To improve the interoperability with other Readium toolkits (in particular the Readium Web Toolkits, which only work in a streaming context) **Readium v3 now generates and expects valid URLs** for `Locator` and `Link`'s `href`. **You must migrate the HREFs or Locators stored in your database**, please consult [the migration guide](docs/migration-guide.md#300-alpha1).
+* `Link.href` and `Locator.href` are now respectively `Href` and `Url` objects. If you still need the string value, you can call `toString()`
+* `MediaType` no longer has static helpers for sniffing it from a file or URL. Instead, you can use an `AssetRetriever` to retrieve the format of a file.
+
+#### Navigator
+
+* Version 3 includes a new component called `DirectionalNavigationAdapter` that replaces `EdgeTapNavigation`. This helper enables users to navigate between pages using arrow and space keys on their keyboard or by tapping the edge of the screen.
+* The `onTap` and `onDrag` events of `VisualNavigator.Listener` have been deprecated. You can now use multiple implementations of `InputListener` with `VisualNavigator.addInputListener()`.
+
+#### Streamer
+
+* The `Streamer` object has been deprecated in favor of components with smaller responsibilities: `AssetRetriever` and `PublicationOpener`.
+
+#### LCP
+
+* `LcpService.acquirePublication()` is deprecated in favor of `LcpService.publicationRetriever()`, which provides greater flexibility thanks to the `DownloadManager`.
+* The way the host view of a `LcpDialogAuthentication` is retrieved was changed to support Android configuration changes.
+
+### Deprecated
+
+* Both the Fuel and Kovenant libraries have been completely removed from the toolkit. With that, several deprecated functions have also been removed.
+
+#### Shared
+
+* The `putPublication` and `getPublication` helpers in `Intent` are deprecated. Now, it is the application's responsibility to pass `Publication` objects between activities and reopen them when necessary.
+
+#### Navigator
+
+* EPUB external links are no longer handled by the navigator. You need to open the link in your own Web View or Chrome Custom Tab.
+
## [2.4.0]
@@ -48,10 +109,35 @@ All notable changes to this project will be documented in this file. Take a look
### Changed
+* Readium resources are now prefixed with `readium_`. Take care of updating any overridden resource by following [the migration guide](docs/migration-guide.md#300).
+* `Link` and `Locator`'s `href` are normalized as valid URLs to improve interoperability with the Readium Web toolkits.
+ * **You MUST migrate your database if you were persisting HREFs and Locators**. Take a look at [the migration guide](docs/migration-guide.md) for guidance.
+
+#### Shared
+
+* `Publication.localizedTitle` is nullable, as we cannot guarantee that all publication sources offer a title.
+* The `MediaType` sniffing helpers are deprecated in favor of `MediaTypeRetriever` (for media type and file extension hints and raw content) and `AssetRetriever` (for URLs).
+
#### Navigator
* `EpubNavigatorFragment.firstVisibleElementLocator()` now returns the first *block* element that is visible on the screen, even if it starts on previous pages.
* This is used to make sure the user will not miss any context when restoring a TTS session in the middle of a resource.
+* The `VisualNavigator`'s drag and tap listener events are moved to a new `addInputListener()` API.
+* The new `DirectionalNavigationAdapter` component replaces `EdgeTapNavigation`, helping you turn pages with the arrow and space keyboard keys, or taps on the edge of the screen.
+
+### Deprecated
+
+#### Shared
+
+* `DefaultHttClient.additionalHeaders` is deprecated. Set all the headers when creating a new `HttpRequest`, or modify outgoing requests in `DefaultHttpClient.Callback.onStartRequest()`.
+
+#### Navigator
+
+* All the navigator `Activity` are deprecated in favor of the `Fragment` variants.
+
+#### Streamer
+
+* The `Fetcher` interface was deprecated in favor of the `Container` one in `readium-shared`.
### Fixed
@@ -63,7 +149,6 @@ All notable changes to this project will be documented in this file. Take a look
* Fixed issue with the TTS starting from the beginning of the chapter instead of the current position.
-
## [2.3.0]
### Added
diff --git a/Makefile b/Makefile
index 8e42a5b89f..b03c46f2b2 100644
--- a/Makefile
+++ b/Makefile
@@ -17,7 +17,11 @@ format:
.PHONY: scripts
scripts:
- yarn --cwd "$(SCRIPTS_PATH)" install --frozen-lockfile
- yarn --cwd "$(SCRIPTS_PATH)" run format
- yarn --cwd "$(SCRIPTS_PATH)" run lint
- yarn --cwd "$(SCRIPTS_PATH)" run bundle
+ @which corepack >/dev/null 2>&1 || (echo "ERROR: corepack is required, please install it first\nhttps://pnpm.io/installation#using-corepack"; exit 1)
+
+ cd $(SCRIPTS_PATH); \
+ corepack install; \
+ pnpm install --frozen-lockfile; \
+ pnpm run format; \
+ pnpm run lint; \
+ pnpm run bundle
diff --git a/README.md b/README.md
index 4a8acdfea4..558dbc5681 100644
--- a/README.md
+++ b/README.md
@@ -19,9 +19,12 @@ A [Test App](test-app) demonstrates how to integrate the Readium Kotlin toolkit
## Minimum Requirements
-| Readium | Android min SDK | Android compile SDK | Kotlin compiler | Gradle |
-|---------|-----------------|---------------------|-----------------|--------|
-| latest | 21 | 33 | 1.7.10 | 6.9.3 |
+| Readium | Android min SDK | Android compile SDK | Kotlin compiler (✻) | Gradle (✻) |
+|---------|-----------------|---------------------|---------------------|------------|
+| 3.0.0 | 21 | 34 | 1.9.22 | 8.2.0 |
+| 2.3.0 | 21 | 33 | 1.7.10 | 6.9.3 |
+
+✻ Only required if you integrate Readium as a submodule instead of using Maven Central.
## Setting Up Readium
diff --git a/build.gradle.kts b/build.gradle.kts
index f9774a5d8a..ef236c3079 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -12,6 +12,7 @@ plugins {
id("io.github.gradle-nexus.publish-plugin") apply true
id("org.jetbrains.dokka") apply true
id("org.jetbrains.kotlin.android") apply false
+ id("com.google.devtools.ksp") apply false
id("org.jlleitschuh.gradle.ktlint") apply true
}
diff --git a/docs/guides/index.md b/docs/guides/index.md
new file mode 100644
index 0000000000..01bee09211
--- /dev/null
+++ b/docs/guides/index.md
@@ -0,0 +1,9 @@
+# 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/media-navigator.md b/docs/guides/media-navigator.md
new file mode 100644
index 0000000000..4ee2f3fe19
--- /dev/null
+++ b/docs/guides/media-navigator.md
@@ -0,0 +1,214 @@
+# Media Navigator
+
+A `MediaNavigator` implementation can play media-based reading orders, such as audiobooks, text-to-speech rendition, and Media overlays. It enables you to reuse your UI, media controls, and logic related to media playback.
+
+## Controlling the playback
+
+A media navigator provides the API you need to pause or resume playback.
+
+```kotlin
+navigator.pause()
+check(!navigator.playback.value.playWhenReady)
+
+navigator.play()
+check(navigator.playback.value.playWhenReady)
+```
+
+## Observing the playback changes
+
+You can observe the changes in the playback with the `navigator.playback` flow property.
+
+`playWhenReady` indicates whether the media is playing or will start playing once the required conditions are met (e.g. buffering). You will typically use this to change the icon of a play/pause button.
+
+The `state` property gives more information about the status of the playback:
+
+* `Ready` when the media is ready to be played if `playWhenReady` is true.
+* `Ended` after reaching the end of the reading order items.
+* `Buffering` if the navigator cannot play because the buffer is starved.
+* `Error` occurs when an error preventing the playback happened.
+
+By combining the two, you can determine if the media is really playing: `playWhenReady && state == Ready`.
+
+Finally, you can use the `index` property to know which `navigator.readingOrder` item is set to be played.
+
+```kotlin
+navigator.playback
+ .onEach { playback ->
+ playPauseButton.toggle(playback.playWhenReady)
+
+ val playingItem = navigator.readingOrder.items[playback.index]
+
+ if (playback.state is MediaNavigator.State.Failure) {
+ // Alert
+ }
+ }
+ .launchIn(scope)
+```
+
+`MediaNavigator` implementations may provide additional playback properties.
+
+## Specializations of `MediaNavigator`
+
+### Audio Navigator
+
+The `AudioNavigator` interface is a specialized version of `MediaNavigator` for publications based on pre-recorded audio resources, such as audiobooks. It provides additional time-based APIs and properties.
+
+```kotlin
+audioNavigator.playback
+ .onEach { playback ->
+ print("At duration ${playback.offset} in the resource, buffered ${playback.buffered}")
+ }
+ .launchIn(scope)
+
+// Jump to a particular duration offset in the resource item at index 4.
+audioNavigator.seek(index = 4, offset = 5.seconds)
+```
+
+### Text-aware Media Navigator
+
+`TextAwareMediaNavigator` specializes `MediaNavigator` for media-based resources that are synchronized with text utterances, such as sentences. It offers additional APIs and properties to determine which utterances are playing. This interface is helpful for a text-to-speech or a Media overlays navigator.
+
+```kotlin
+textAwareNavigator.playback
+ .onEach { playback ->
+ print("Playing the range ${playback.range} in text ${playback.utterance}")
+ }
+ .launchIn(scope)
+
+// Get additional context by observing the location instead of the playback.
+textAwareNavigator.location
+ .onEach { location ->
+ // Highlight the portion of text being played.
+ visualNavigator.applyDecorations(
+ listOf(Decoration(
+ locator = location.utteranceLocator,
+ style = Decoration.Style.Highlight(tint = Color.RED)
+ )),
+ "highlight"
+ )
+ }
+ .launchIn(scope)
+
+// Skip the current utterance.
+if (textAwareNavigator.hasNextUtterance()) {
+ textAwareNavigator.goToNextUtterance()
+}
+```
+
+## Background playback and media notification
+
+The Readium Kotlin toolkit provides implementations of `MediaNavigator` powered by Jetpack media3. This allows for continuous playback in the background and displaying Media-style notifications with playback controls.
+
+To accomplish this, you must create your own `MediaSessionService`. Get acquainted with [the concept behind media3](https://developer.android.com/guide/topics/media/media3) first.
+
+### Configuration
+
+Add the following [Jetpack media3](https://developer.android.com/jetpack/androidx/releases/media3) dependencies to your `build.gradle`, after checking for the latest version.
+
+```groovy
+dependencies {
+ implementation "androidx.media3:media3-common:1.0.2"
+ implementation "androidx.media3:media3-session:1.0.2"
+ implementation "androidx.media3:media3-exoplayer:1.0.2"
+}
+```
+
+### Add the `MediaSessionService`
+
+Create a new implementation of `MediaSessionService` in your application. For an example, take a look at `MediaService` in the Test App. You can access the media3 `Player` from the navigator with `navigator.asMedia3Player()`.
+
+Don't forget to declare this new service in your `AndroidManifest.xml`.
+
+```xml
+
+
+
+
+
+
+
+ ...
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Customizing the notification metadata
+
+By default, the navigators will use the publication's metadata to display playback information in the Media-style notification. If you want to customize this, for example by retrieving metadata from your database, you can provide a custom `MediaMetadataFactory` implementation when creating the navigator.
+
+Here's an example for the `AndroidTtsNavigator`.
+
+```kotlin
+val navigatorFactory = AndroidTtsNavigatorFactory(
+ application, publication,
+ metadataProvider = { pub ->
+ DatabaseMediaMetadataFactory(
+ context = application,
+ scope = application,
+ bookId = bookId,
+ trackCount = pub.readingOrder.size
+ )
+ }
+)
+
+/**
+ * Factory of media3 metadata for the local publication with given [bookId].
+ */
+class DatabaseMediaMetadataFactory(
+ private val context: Context,
+ scope: CoroutineScope,
+ private val bookId: Int,
+ private val trackCount: Int
+) : MediaMetadataFactory {
+
+ private class Metadata(
+ val title: String,
+ val author: String,
+ val cover: ByteArray
+ )
+
+ private val metadata: Deferred = scope.async {
+ Database.getInstance(context).bookDao().get(bookId)?.let { book ->
+ Metadata(
+ title = book.title,
+ author = book.author,
+ // Byte arrays will go cross processes and should be kept small
+ cover = book.cover.scaleToFit(400, 400).toPng()
+ )
+ }
+ }
+
+ override suspend fun publicationMetadata(): MediaMetadata =
+ builder()?.build() ?: MediaMetadata.EMPTY
+
+ override suspend fun resourceMetadata(index: Int): MediaMetadata =
+ builder()?.setTrackNumber(index)?.build() ?: MediaMetadata.EMPTY
+
+ private suspend fun builder(): MediaMetadata.Builder? {
+ val metadata = metadata.await() ?: return null
+
+ return MediaMetadata.Builder()
+ .setTitle(metadata.title)
+ .setTotalTrackCount(trackCount)
+ .setArtist(metadata.artist)
+ // We can't yet directly use a `content://` or `file://` URI with `setArtworkUri`.
+ // See https://github.com/androidx/media/issues/271
+ .setArtworkData(metadata.cover, PICTURE_TYPE_FRONT_COVER) }
+ }
+}
+```
diff --git a/docs/guides/open-publication.md b/docs/guides/open-publication.md
new file mode 100644
index 0000000000..6a2a1daa9a
--- /dev/null
+++ b/docs/guides/open-publication.md
@@ -0,0 +1,86 @@
+# Opening a publication
+
+:warning: The APIs described here may still undergo changes before the stable 3.0 release.
+
+To open a publication with Readium, you need to instantiate a couple of components: an `AssetRetriever` and a `PublicationOpener`.
+
+## `AssetRetriever`
+
+The `AssetRetriever` grants access to the content of an asset located at a given URL, such as a publication package, manifest, or LCP license.
+
+### Constructing an `AssetRetriever`
+
+You can create an instance of `AssetRetriever` with:
+
+* A `ContentResolver` to support data access through the `content` URL scheme.
+* An `HttpClient` to enable the toolkit to perform HTTP requests and support the `http` and `https` URL schemes. You can use `DefaultHttpClient` which provides callbacks for handling authentication when needed.
+
+```kotlin
+val httpClient = DefaultHttpClient()
+val assetRetriever = AssetRetriever(context.contentResolver, httpClient)
+```
+
+### Retrieving an `Asset`
+
+With your fresh instance of `AssetRetriever`, you can open an `Asset` from an `AbsoluteUrl`.
+
+```kotlin
+// From a `File`
+val url = File("...").toUrl()
+// or from a content:// `Uri`
+val url = contentUri.toAbsoluteUrl()
+// or from a raw URL string
+val url = AbsoluteUrl("https://domain/book.epub")
+
+val asset = assetRetriever.retrieve(url)
+ .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.
+
+```kotlin
+val mediaType = asset.format.mediaType
+
+// Speed up the retrieval with a known media type.
+val asset = assetRetriever.retrieve(url, mediaType)
+```
+
+## `PublicationOpener`
+
+`PublicationOpener` builds a `Publication` object from an `Asset` using:
+
+* A `PublicationParser` to parse the asset structure and publication metadata.
+ * The `DefaultPublicationParser` handles all the formats supported by Readium out of the box.
+* An optional list of `ContentProtection` to decrypt DRM-protected publications.
+ * If you support Readium LCP, you can get one from the `LcpService`.
+
+```kotlin
+val contentProtections = listOf(lcpService.contentProtection(authentication))
+
+val publicationParser = DefaultPublicationParser(context, httpClient, assetRetriever, pdfFactory)
+
+val publicationOpener = PublicationOpener(publicationParser, contentProtections)
+```
+
+### Opening a `Publication`
+
+Now that you have a `PublicationOpener` ready, you can use it to create a `Publication` from an `Asset` that was previously obtained using the `AssetRetriever`.
+
+The `allowUserInteraction` parameter is useful when supporting Readium LCP. When enabled and using a `LcpDialogAuthentication`, the toolkit will prompt the user if the passphrase is missing.
+
+```kotlin
+val publication = publicationOpener.open(asset, allowUserInteraction = true)
+ .getOrElse { /* Failed to access or parse the publication */ }
+```
+
+## Supporting additional formats or URL schemes
+
+`DefaultPublicationParser` accepts additional parsers. You also have the option to use your own parser list by using `CompositePublicationParser` or create your own `PublicationParser` for a fully customized parsing resolution strategy.
+
+The `AssetRetriever` offers an additional constructor that provides greater extensibility options, using:
+
+* `ResourceFactory` which handles the URL schemes through which you can access content.
+* `ArchiveOpener` which determines the types of archives (ZIP, RAR, etc.) that can be opened by the `AssetRetriever`.
+* `FormatSniffer` which identifies the file formats that `AssetRetriever` can recognize.
+
+You can use either the default implementations or implement your own for each of these components using the composite pattern. The toolkit's `CompositeResourceFactory`, `CompositeArchiveOpener`, and `CompositeFormatSniffer` provide a simple resolution strategy.
diff --git a/docs/guides/tts.md b/docs/guides/tts.md
index 5a15ead559..1271332f22 100644
--- a/docs/guides/tts.md
+++ b/docs/guides/tts.md
@@ -1,180 +1,161 @@
# Text-to-speech
-:warning: The API described in this guide will be changed in the next version of the Kotlin toolkit to support background TTS playback and media notifications. It is recommended that you wait before integrating it in your app.
-
-Text-to-speech can be used to read aloud a publication using a synthetic voice. The Readium toolkit ships with a TTS implementation based on the native [Android TTS engine](https://developer.android.com/reference/android/speech/tts/TextToSpeech), but it is opened for extension if you want to use a different TTS engine.
+Text-to-speech can read aloud a publication using a synthetic voice. The Readium toolkit includes an implementation based on the [Android TTS engine](https://developer.android.com/reference/android/speech/tts/TextToSpeech), but it can be extended to use a different TTS engine.
## Glossary
-* **engine** – a TTS engine takes an utterance and transforms it into audio using a synthetic voice
-* **rate** - speech speed of a synthetic voice
-* **tokenizer** - algorithm splitting the publication text content into individual utterances, usually by sentences
* **utterance** - a single piece of text played by a TTS engine, such as a sentence
-* **voice** – a synthetic voice is used by a TTS engine to speak a text using rules pertaining to the voice's language and region
-
-## Reading a publication aloud
-
-To read a publication, you need to create an instance of `PublicationSpeechSynthesizer`. It orchestrates the rendition of a publication by iterating through its content, splitting it into individual utterances using a `ContentTokenizer`, then using a `TtsEngine` to read them aloud. Not all publications can be read using TTS, therefore the constructor returns a nullable object. You can also check whether a publication can be played beforehand using `PublicationSpeechSynthesizer.canSpeak(publication)`.
+* **tokenizer** - algorithm splitting the publication text content into individual utterances, usually by sentences
+* **engine** – a TTS engine takes an utterance and transforms it into audio using a synthetic voice
+* **voice** – a synthetic voice is used by a TTS engine to speak a text in a way suitable for the language and region
-```kotlin
-val synthesizer = PublicationSpeechSynthesizer(
- publication = publication,
- config = PublicationSpeechSynthesizer.Configuration(
- rateMultiplier = 1.25
- ),
- listener = object : PublicationSpeechSynthesizer.Listener { ... }
-)
-```
+## Getting started
-Then, begin the playback from a given starting `Locator`. When missing, the playback will start from the beginning of the publication.
+:warning: Apps targeting Android 11 that use the native text-to-speech must declare `INTENT_ACTION_TTS_SERVICE` in the queries elements of their manifest.
-```kotlin
-synthesizer.start()
+```xml
+
+
+
+
+
```
-You should now hear the TTS engine speak the utterances from the beginning. `PublicationSpeechSynthesizer` provides the APIs necessary to control the playback from the app:
-
-* `stop()` - stops the playback ; requires start to be called again
-* `pause()` - interrupts the playback temporarily
-* `resume()` - resumes the playback where it was paused
-* `pauseOrResume()` - toggles the pause
-* `previous()` - skips to the previous utterance
-* `next()` - skips to the next utterance
-
-Look at `TtsControls` in the Test App for an example of a view calling these APIs.
-
-:warning: Once you are done with the synthesizer, you should call `close()` to release held resources.
-
-## Observing the playback state
+The text-to-speech feature is implemented as a standalone `Navigator`, which can render any publication with a [Content Service](content.md), such as an EPUB. This means you don't need an `EpubNavigatorFragment` open to read the publication; you can use the TTS navigator in the background.
-The `PublicationSpeechSynthesizer` should be the single source of truth to represent the playback state in your user interface. You can observe the `synthesizer.state` property to keep your user interface synchronized with the playback. The possible states are:
+To get a new instance of `TtsNavigator`, first create an `AndroidTtsNavigatorFactory` to use the default Android TTS engine.
-* `Stopped` when idle and waiting for a call to `start()`.
-* `Paused(utterance: Utterance)` when interrupted while playing `utterance`.
-* `Playing(utterance: Utterance, range: Locator?)` when speaking `utterance`. This state is updated repeatedly while the utterance is spoken, updating the `range` property with the portion of utterance being played (usually the current word).
+```kotlin
+val factory = AndroidTtsNavigatorFactory(application, publication)
+ ?: throw Exception("This publication cannot be played with the TTS navigator")
-When pairing the `PublicationSpeechSynthesizer` with a `Navigator`, you can use the `utterance.locator` and `range` properties to highlight spoken utterances and turn pages automatically.
+val navigator = factory.createNavigator()
+navigator.play()
+```
-## Configuring the TTS
+`TtsNavigator` implements `MediaNavigator`, so you can use all the APIs available for media-based playback. Check out the [dedicated user guide](media-navigator.md) to learn how to control `TtsNavigator` and observe playback notifications.
-The `PublicationSpeechSynthesizer` offers some options to configure the TTS engine. Note that the support of each configuration option depends on the TTS engine used.
+## Configuring the Android TTS navigator
-Update the configuration by setting it directly. The configuration is not applied right away but for the next utterance.
+The `AndroidTtsNavigator` implements [`Configurable`](navigator-preferences.md) and provides various settings to customize the text-to-speech experience.
```kotlin
-synthesizer.setConfig(synthesizer.config.copy(
- defaultLanguage = Language(Locale.FRENCH)
+navigator.submitPreferences(AndroidTtsPreferences(
+ language = Language("fr"),
+ pitch = 0.8f,
+ speed = 1.5f
))
```
-To keep your settings user interface up to date when the configuration changes, observe the `PublicationSpeechSynthesizer.config` property. Look at `TtsControls` in the Test App for an example of a TTS settings screen.
+A `PreferencesEditor` is available to help you construct your user interface and modify the preferences.
-### Default language
+```kotlin
+val factory = AndroidTtsNavigatorFactory(application, publication)
+ ?: throw Exception("This publication cannot be played with the TTS navigator")
-The language used by the synthesizer is important, as it determines which TTS voices are used and the rules to tokenize the publication text content.
+val navigator = factory.createNavigator()
-By default, `PublicationSpeechSynthesizer` will use any language explicitly set on a text element (e.g. with `lang="fr"` in HTML) and fall back on the global language declared in the publication manifest. You can override the fallback language with `Configuration.defaultLanguage` which is useful when the publication language is incorrect or missing.
+val editor = factory.createPreferencesEditor(preferences)
+editor.pitch.increment()
+navigator.submitPreferences(editor.preferences)
+```
-### Speech rate
+### Language preference
-The `rateMultiplier` configuration sets the speech speed as a multiplier, 1.0 being the normal speed. The available range depends on the TTS engine and can be queried with `synthesizer.rateMultiplierRange`.
+The language set in the preferences determines the default voice used and how the publication text content is tokenized – i.e. split in utterances.
-```kotlin
-PublicationSpeechSynthesizer.Configuration(
- rateMultiplier = multiplier.coerceIn(synthesizer.rateMultiplierRange)
-)
-```
+By default, the TTS navigator uses any language explicitly set on a text element (e.g. `lang="fr"` in HTML) and, if none is set, it falls back on the language declared in the publication manifest. Providing an explicit language preference is useful when the publication language is incorrect or missing.
-### Voice
+### Voices preference
-The `voice` setting can be used to change the synthetic voice used by the engine. To get the available list, use `synthesizer.availableVoices`. Note that the available voices can change during runtime, observe `availableVoices` to keep your interface up to date.
+The Android TTS engine supports multiple voices. To allow users to choose their preferred voice for each language, they are stored as a dictionary `Map` in `AndroidTtsPreferences`.
-To restore a user-selected voice, persist the unique voice identifier returned by `voice.id`.
+Use the `voices` property of the `AndroidTtsNavigator` instance to get the full list of available voices.
-Users do not expect to see all available voices at all time, as they depend on the selected language. You can group the voices by their language and filter them by the selected language using the following snippet.
+Users don't expect to see all available voices at once, as they depend on the selected language. To get an `EnumPreference` based on the current `language` preference, you can use the following snippet.
```kotlin
-// Supported voices grouped by their language.
-val voicesByLanguage: Flow>> =
- synthesizer.availableVoices
- .map { voices -> voices.groupBy { it.language } }
-
-// Supported voices for the language selected in the configuration.
-val voicesForSelectedLanguage: Flow> =
- combine(
- synthesizer.config.map { it.defaultLanguage },
- voicesByLanguage,
- ) { language, voices ->
- language
- ?.let { voices[it] }
- ?.sortedBy { it.name ?: it.id }
- ?: emptyList()
+// We remove the region to show all the voices for a given language, no matter the region (e.g. Canada, France).
+val currentLanguage = editor.language.effectiveValue?.removeRegion()
+
+val voice: EnumPreference = editor.voices
+ .map(
+ from = { voices ->
+ currentLanguage?.let { voices[it] }
+ },
+ to = { voice ->
+ currentLanguage
+ ?.let { editor.voices.value.orEmpty().update(it, voice) }
+ ?: editor.voices.value.orEmpty()
+ }
+ )
+ .withSupportedValues(
+ navigator.voices
+ .filter { it.language.removeRegion() == currentLanguage }
+ .map { it.id }
+ )
+
+fun Map.update(key: K, value: V?): Map =
+ buildMap {
+ putAll(this@update)
+ if (value == null) {
+ remove(key)
+ } else {
+ put(key, value)
+ }
}
```
-## Installing missing voice data
+#### Installing missing voice data
:point_up: This only applies if you use the default `AndroidTtsEngine`.
-Sometimes the device does not have access to all the data required by a selected voice, in which case the user needs to download it manually. You can catch the `TtsEngine.Exception.LanguageSupportIncomplete` error and call `synthesizer.engine.requestInstallMissingVoice()` to start the system voice download activity.
+If the device lacks the data necessary for the chosen voice, the user needs to manually download it. To do so, call the `AndroidTtsEngine.requestInstallVoice()` helper when the `AndroidTtsEngine.Error.LanguageMissingData` error occurs. This will launch the system voice download activity.
```kotlin
-val synthesizer = PublicationSpeechSynthesizer(context, publication)
-
-synthesizer.listener = object : PublicationSpeechSynthesizer.Listener {
- override fun onUtteranceError( utterance: PublicationSpeechSynthesizer.Utterance, error: PublicationSpeechSynthesizer.Exception) {
- handle(error)
- }
-
- override fun onError(error: PublicationSpeechSynthesizer.Exception) {
- handle(error)
- }
-
- private fun handle(error: PublicationSpeechSynthesizer.Exception) {
- when (error) {
- is PublicationSpeechSynthesizer.Exception.Engine ->
- when (val err = error.error) {
- is TtsEngine.Exception.LanguageSupportIncomplete -> {
- synthesizer.engine.requestInstallMissingVoice(context)
- }
-
- else -> {
- ...
- }
- }
+navigator.playback
+ .onEach { playback ->
+ (playback?.state as? TtsNavigator.State.Failure.EngineError<*>)
+ ?.let { it.error as? AndroidTtsEngine.Error.LanguageMissingData }
+ ?.let { error ->
+ Timber.e("Missing data for language ${error.language}")
+ AndroidTtsEngine.requestInstallVoice(context)
}
- }
}
+ .launchIn(viewModelScope)
```
-## Synchronizing the TTS with a Navigator
+## Synchronizing the TTS navigator with a visual navigator
-While `PublicationSpeechSynthesizer` is completely independent from `Navigator` and can be used to play a publication in the background, most apps prefer to render the publication while it is being read aloud. The `Locator` core model is used as a means to synchronize the synthesizer with the navigator.
+`TtsNavigator` is a standalone navigator that can be used to play a publication in the background. However, most apps prefer to display the publication while it is being read aloud. To do this, you can open the publication with a visual navigator (e.g. `EpubNavigatorFragment`) alongside the `TtsNavigator`. Then, synchronize the progression between the two navigators and use the Decorator API to highlight the spoken utterances.
+
+For concrete examples, take a look at `TtsViewModel` in the Test App.
### Starting the TTS from the visible page
-`PublicationSpeechSynthesizer.start()` takes a starting `Locator` for parameter. You can use it to begin the playback from the currently visible page in a `VisualNavigator` using `firstVisibleElementLocator()`.
+To start the TTS from the currently visible page, you can use the `VisualNavigator.firstVisibleElementLocator()` API to feed the initial locator of the `TtsNavigator`.
```kotlin
-val start = (navigator as? VisualNavigator)?.firstVisibleElementLocator()
-synthesizer.start(fromLocator = start)
+val ttsNavigator = ttsNavigatorFactory.createNavigator(
+ initialLocator = (navigator as? VisualNavigator)?.firstVisibleElementLocator()
+)
```
### Highlighting the currently spoken utterance
-If you want to highlight or underline the current utterance on the page, you can apply a `Decoration` on the utterance locator with a `DecorableNavigator`.
+To highlight the current utterance on the page, you can apply a `Decoration` on the utterance locator if the visual navigator implements `DecorableNavigator`.
```kotlin
-val navigator: DecorableNavigator
+val visualNavigator: DecorableNavigator
-synthesizer.state
- .map { (it as? State.Playing)?.utterance }
+ttsNavigator.location
+ .map { it.utteranceLocator }
.distinctUntilChanged()
- .onEach { utterance ->
+ .onEach { locator ->
navigator.applyDecorations(listOf(
Decoration(
id = "tts-utterance",
- locator = utterance.locator,
+ locator = locator,
style = Decoration.Style.Highlight(tint = Color.RED)
)
), group = "tts")
@@ -184,47 +165,48 @@ synthesizer.state
### Turning pages automatically
-You can use the same technique as described above to automatically synchronize the `Navigator` with the played utterance, using `navigator.go(utterance.locator)`.
+To keep the visual navigator in sync with the utterance being played, observe the navigator's current `location` as described above and use `navigator.go(location.utteranceLocator)`.
+
+However, this won't turn pages in the middle of an utterance, which can be irritating when speaking a lengthy sentence that spans two pages. To tackle this issue, you can use `location.tokenLocator` when available. It is updated constantly while you speak each word of an utterance.
+
+Jumping to the token locator for every word can significantly reduce performance. To address this, it is recommended to use [`throttleLatest`](https://github.com/Kotlin/kotlinx.coroutines/issues/1107#issuecomment-1083076517).
-However, this will not turn pages mid-utterance, which can be annoying when speaking a long sentence spanning two pages. To address this, you can go to the `State.Playing.range` locator instead, which is updated regularly while speaking each word of an utterance. Note that jumping to the `range` locator for every word can severely impact performances. To alleviate this, you can throttle the flow using [`throttleLatest`](https://github.com/Kotlin/kotlinx.coroutines/issues/1107#issuecomment-1083076517).
```kotlin
-synthesizer.state
- .filterIsInstance()
- .map { it.range ?: it.utterance.locator }
+ttsNavigator.location
.throttleLatest(1.seconds)
+ .map { it.tokenLocator ?: it.utteranceLocator }
+ .distinctUntilChanged()
.onEach { locator ->
navigator.go(locator, animated = false)
}
.launchIn(scope)
```
-## Using a custom utterance tokenizer
+## Advanced customizations
+
+### Utterance tokenizer
-By default, the `PublicationSpeechSynthesizer` will split the publication text into sentences to create the utterances. You can customize this for finer or coarser utterances using a different tokenizer.
+By default, the `TtsNavigator` splits the publication text into sentences, but you can supply your own tokenizer to customize how the text is divided.
-For example, this will speak the content word-by-word:
+For example, this will speak the content word by word:
```kotlin
-val synthesizer = PublicationSpeechSynthesizer(context, publication,
+val navigatorFactory = TtsNavigatorFactory(
+ application, publication,
tokenizerFactory = { language ->
- TextContentTokenizer(
- defaultLanguage = language,
- unit = TextUnit.Word
- )
+ DefaultTextContentTokenizer(unit = TextUnit.Word, language = language)
}
)
```
-For completely custom tokenizing or to improve the existing tokenizers, you can implement your own `ContentTokenizer`.
+### Custom TTS engine
-## Using a custom TTS engine
-
-`PublicationSpeechSynthesizer` can be used with any TTS engine, provided they implement the `TtsEngine` interface. Take a look at `AndroidTtsEngine` for an example implementation.
+`TtsNavigator` is compatible with any TTS engine if you provide an adapter implementing the `TtsEngine` interface. For an example, take a look at `AndroidTtsEngine`.
```kotlin
-val synthesizer = PublicationSpeechSynthesizer(publication,
- engineFactory = { listener -> MyCustomEngine(listener) }
+val navigatorFactory = TtsNavigatorFactory(
+ application, publication,
+ engineProvider = MyEngineProvider()
)
```
-
diff --git a/docs/migration-guide.md b/docs/migration-guide.md
index 5522689c2b..7347af208d 100644
--- a/docs/migration-guide.md
+++ b/docs/migration-guide.md
@@ -2,7 +2,379 @@
All migration steps necessary in reading apps to upgrade to major versions of the Kotlin Readium toolkit will be documented in this file.
-
+## 3.0.0-alpha.1
+
+First of all, upgrade to version 2.4.0 and resolve any deprecation notices. This will help you avoid troubles, as the APIs that were deprecated in version 2.x have been removed in version 3.0.
+
+### Minimum requirements
+
+If you integrate Readium 3.0 as a submodule, it requires Kotlin 1.9.22 and Gradle 8.2.0. You should start by updating these dependencies in your application.
+
+#### Targeting Android SDK 34
+
+The modules now target Android SDK 34. If your app also targets it, you will need the `FOREGROUND_SERVICE_MEDIA_PLAYBACK` permission in your `AndroidManifest.xml` file to use TTS and audiobook playback.
+
+### `Publication`
+
+#### Opening a `Publication`
+
+The `Streamer` object has been deprecated in favor of components with smaller responsibilities:
+
+* `AssetRetriever` grants access to the content of an asset located at a given URL, such as a publication package, manifest, or LCP license
+* `PublicationOpener` uses a publication parser and a set of content protections to create a `Publication` object from an `Asset`.
+
+[See the user guide for a detailed explanation on how to use these new APIs](guides/open-publication.md).
+
+#### Sharing `Publication` across Android activities
+
+The `putPublication` and `getPublication` helpers in `Intent` are deprecated. Now, it is the application's responsibility to pass `Publication` objects between activities and reopen them when necessary.
+
+You can take a look at the [`ReaderRepository` in the Test App](https://github.com/readium/kotlin-toolkit/blob/09e338b0f3acc8d59282280bded6c4bf93de6281/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt#L46) for inspiration.
+
+Alternatively, you can copy [the deprecated helpers](https://github.com/readium/kotlin-toolkit/blob/7649378a0a6b924abedf8b72372c3808fe9b992f/readium/shared/src/main/java/org/readium/r2/shared/extensions/Intent.kt#L26) and add them to your codebase. However, please note that this approach is discouraged because it will not handle configuration changes smoothly.
+
+### `MediaType`
+
+#### Sniffing a `MediaType`
+
+`MediaType` no longer has static helpers for sniffing it from a file or URL. Instead, you can use an `AssetRetriever` to retrieve the format of a file.
+
+```kotlin
+val httpClient = DefaultHttpClient()
+val assetRetriever = AssetRetriever(context.contentResolver, httpClient)
+
+val mediaType = assetRetriever.sniffFormat(File(...))
+ .getOrElse { /* Failed to access the asset or recognize its format */ }
+ .mediaType
+```
+
+### HREFs
+
+#### `Link.href` and `Locator.href` are not strings anymore
+
+`Link.href` and `Locator.href` are now respectively `Href` and `Url` objects. If you still need the string value, you can call `toString()`, but you may find the `Url` objects more useful in practice.
+
+Use `link.url()` to get a `Url` from a `Link` object.
+
+#### Migration of HREFs and Locators (bookmarks, annotations, etc.)
+
+:warning: This requires a database migration in your application, if you were persisting `Locator` objects.
+
+In Readium v2.x, a `Link` or `Locator`'s `href` could be either:
+
+* a valid absolute URL for a streamed publication, e.g. `https://domain.com/isbn/dir/my%20chapter.html`,
+* a percent-decoded path for a local archive such as an EPUB, e.g. `/dir/my chapter.html`.
+ * Note that it was relative to the root of the archive (`/`).
+
+To improve the interoperability with other Readium toolkits (in particular the Readium Web Toolkits, which only work in a streaming context) **Readium v3 now generates and expects valid URLs** for `Locator` and `Link`'s `href`.
+
+* `https://domain.com/isbn/dir/my%20chapter.html` is left unchanged, as it was already a valid URL.
+* `/dir/my chapter.html` becomes the relative URL path `dir/my%20chapter.html`
+ * We dropped the `/` prefix to avoid issues when resolving to a base URL.
+ * Special characters are percent-encoded.
+
+**You must migrate the HREFs or Locators stored in your database** when upgrading to Readium 3. To assist you, two helpers are provided: `Url.fromLegacyHref()` and `Locator.fromLegacyJSON()`.
+
+Here's an example of a Jetpack Room migration that can serve as inspiration:
+
+```kotlin
+val MIGRATION_HREF = object : Migration(1, 2) {
+
+ override fun migrate(db: SupportSQLiteDatabase) {
+ val normalizedHrefs: Map = buildMap {
+ db.query("SELECT id, href FROM bookmarks").use { cursor ->
+ while (cursor.moveToNext()) {
+ val id = cursor.getLong(0)
+ val href = cursor.getString(1)
+
+ val normalizedHref = Url.fromLegacyHref(href)?.toString()
+ if (normalizedHref != null) {
+ put(id, normalizedHref)
+ }
+ }
+ }
+ }
+
+ val stmt = db.compileStatement("UPDATE bookmarks SET href = ? WHERE id = ?")
+ for ((id, href) in normalizedHrefs) {
+ stmt.bindString(1, href)
+ stmt.bindLong(2, id)
+ stmt.executeUpdateDelete()
+ }
+ }
+}
+```
+
+### Error management
+
+Most APIs now return an `Error` instance instead of an `Exception` in case of failure, as these objects are not thrown by the toolkit but returned as values.
+
+It is recommended to handle `Error` objects using a `when` statement. However, if you still need an `Exception`, you may wrap an `Error` with `ErrorException`, for example:
+
+```kotlin
+assetRetriever.sniffFormat(...)
+ .getOrElse { throw ErrorException(it) }
+```
+`UserException` is also deprecated. The application now needs to provide localized error messages for toolkit errors.
+
+### Navigator
+
+#### Click on external links in the EPUB navigator
+
+Clicking on external links is no longer managed by the EPUB navigator. To open the link yourself, override `HyperlinkNavigator.Listener.onExternalLinkActivated`, for example:
+
+```kotlin
+override fun onExternalLinkActivated(url: AbsoluteUrl) {
+ if (!url.isHttp) return
+ val context = requireActivity()
+ val uri = url.toUri()
+ try {
+ CustomTabsIntent.Builder()
+ .build()
+ .launchUrl(context, uri)
+ } catch (e: ActivityNotFoundException) {
+ context.startActivity(Intent(Intent.ACTION_VIEW, uri))
+ }
+}
+```
+
+##### Edge tap and keyboard navigation
+
+Version 3 includes a new component called `DirectionalNavigationAdapter` that replaces `EdgeTapNavigation`. This helper enables users to navigate between pages using arrow and space keys on their keyboard or by tapping the edge of the screen.
+
+As it implements `InputListener`, you can attach it to any `OverflowableNavigator`.
+
+```kotlin
+navigator.addInputListener(
+ DirectionalNavigationAdapter(
+ navigator,
+ animatedTransition = true
+ )
+)
+```
+
+The `DirectionalNavigationAdapter` provides plenty of customization options. Please refer to its API for more details.
+
+#### Tap and drag events
+
+The `onTap` and `onDrag` events of `VisualNavigator.Listener` have been deprecated. You can now use multiple implementations of `InputListener`. The order is important when events are consumed.
+
+```kotlin
+navigator.addInputListener(DirectionalNavigationAdapter(navigator))
+
+navigator.addInputListener(object : InputListener {
+ override fun onTap(event: TapEvent): Boolean {
+ toggleUi()
+ return true
+ }
+})
+```
+
+### LCP
+
+#### Creating an `LcpService`
+
+The `LcpService` now requires an instance of `AssetRetriever` and `DownloadManager` during construction. To get the same behavior as before, you can use a `ForegroundDownloadManager`. If you want to support downloads in the background instead, take a look at `AndroidDownloadManager`.
+
+```kotlin
+val lcpService = LcpService(
+ context,
+ assetRetriever = assetRetriever,
+ downloadManager = ForegroundDownloadManager(
+ httpClient = httpClient,
+ downloadsDirectory = File(context.cacheDir, "lcp")
+ )
+)
+```
+
+#### Downloading an LCP protected publication from a license
+
+`LcpService.acquirePublication()` is deprecated in favor of `LcpService.publicationRetriever()`, which provides greater flexibility thanks to the `DownloadManager`.
+
+```kotlin
+// 1. Open an `Asset` from a `File`.
+val asset = assetRetriever.retrieve(file)
+ .getOrElse { /* Failed to open the file or sniff its format */ }
+
+// 2. Verify that it is an LCP License Document.
+if (asset is ResourceAsset && asset.format.conformsTo(LcpLicenseSpecification)) {
+ // 3. Parse the LCP License Document from its JSON representation.
+ val license = lcplAsset.resource.read()
+ .getOrElse { /* Failed to read the content of the LCPL asset */ }
+ .let { LicenseDocument.fromBytes(it) }
+ .getOrElse { /* Failed to parse a valid LCP License Document from the the raw bytes */ }
+
+ // 4. Download the publication using the `LcpPublicationRetriever`.
+ // The returned `requestId` can be used to cancel an on-going download, or to resume a download
+ // with `LcpPublicationRetriever.register()`, if it was downloaded in the background.
+ val requestId = lcpService.publicationRetriever()
+ .retrieve(license, listener = object : LcpPublicationRetriever.Listener {
+ override fun onAcquisitionCompleted(
+ requestId: LcpPublicationRetriever.RequestId,
+ acquiredPublication: LcpService.AcquiredPublication
+ ) {
+ }
+
+ override fun onAcquisitionProgressed(
+ requestId: LcpPublicationRetriever.RequestId,
+ downloaded: Long,
+ expected: Long?
+ ) {
+ // Report progress.
+ }
+
+ override fun onAcquisitionFailed(
+ requestId: LcpPublicationRetriever.RequestId,
+ error: LcpError
+ ) {
+ // Report error.
+ }
+
+ override fun onAcquisitionCancelled(requestId: LcpPublicationRetriever.RequestId) {
+ // Handle cancellation.
+ }
+ })
+}
+```
+
+If you are using a `ForegroundDownloadManager` and **not supporting background downloads**, you can use this helper to have a similar API as Readium 2.x with coroutines.
+
+```kotlin
+suspend fun LcpService.acquirePublication(
+ lcplAsset: ResourceAsset,
+ onProgress: (Double) -> Unit
+): Try {
+ require(lcplAsset.format.conformsTo(LcpLicenseSpecification))
+
+ val license = lcplAsset.resource.read()
+ .flatMap { LicenseDocument.fromBytes(it) }
+ .getOrElse { return Try.failure(it) }
+
+ return suspendCancellableCoroutine { cont ->
+ publicationRetriever().retrieve(license, object : LcpPublicationRetriever.Listener {
+ override fun onAcquisitionCompleted(
+ requestId: LcpPublicationRetriever.RequestId,
+ acquiredPublication: LcpService.AcquiredPublication
+ ) {
+ cont.resume(Try.success(acquiredPublication))
+ }
+
+ override fun onAcquisitionProgressed(
+ requestId: LcpPublicationRetriever.RequestId,
+ downloaded: Long,
+ expected: Long?
+ ) {
+ expected ?: return
+ onProgress(downloaded.toDouble() / expected.toDouble())
+ }
+
+ override fun onAcquisitionFailed(
+ requestId: LcpPublicationRetriever.RequestId,
+ error: LcpError
+ ) {
+ cont.resume(Try.failure(error))
+ }
+
+ override fun onAcquisitionCancelled(requestId: LcpPublicationRetriever.RequestId) {
+ cont.cancel()
+ }
+ })
+ }
+}
+```
+
+#### `LcpDialogAuthentication` updated to support configuration changes
+
+The way the host view of a `LcpDialogAuthentication` is retrieved was changed to support Android configuration changes. You no longer need to pass an activity, fragment or view as `sender` parameter.
+
+Instead, call on your instance of `LcpDialogAuthentication`:
+* `onParentViewAttachedToWindow` every time you have a view attached to a window available as anchor
+* `onParentViewDetachedFromWindow` every time it gets detached
+
+You can monitor these events by setting a `View.OnAttachStateChangeListener` on your view. [See the Test App for an example](https://github.com/readium/kotlin-toolkit/blob/01d6c7936accea2d6b953d435e669260676e8c99/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfFragment.kt#L68).
+
+### Removal of Fuel and Kovenant
+
+Both the Fuel and Kovenant libraries have been completely removed from the toolkit. With that, several deprecated functions have also been removed.
+
+### Resources
+
+To avoid conflicts when merging your app resources, all resources declared in the Readium toolkit now have the prefix `readium_`. This means that you must rename any layouts or strings you have overridden. Some resources were removed from the toolkit.
+
+#### Deleted resources
+
+If you referenced these resources, you need to remove them from your application or copy them to your own resources.
+
+##### Deleted colors
+
+| Name |
+|-----------------------------|
+| `colorPrimary` |
+| `colorPrimaryDark` |
+| `colorAccent` |
+| `colorAccentPrefs` |
+| `snackbar_background_color` |
+| `snackbar_text_color` |
+
+##### Deleted strings
+
+| Name |
+|----------------------------|
+| `end_of_chapter` |
+| `end_of_chapter_indicator` |
+| `zero` |
+| `epub_navigator_tag` |
+| `image_navigator_tag` |
+| `snackbar_text_color` |
+
+All the localized error messages are also removed.
+
+#### Renamed resources
+
+If you used the resources listed below, you must rename the references to reflect the new names. You can use a global search to help you find the references in your project.
+
+##### Renamed layouts
+
+| Deprecated | New |
+|-----------------------------|-----------------------------------------------|
+| `activity_r2_viewpager` | `readium_navigator_viewpager` |
+| `fragment_fxllayout_double` | `readium_navigator_fragment_fxllayout_double` |
+| `fragment_fxllayout_single` | `readium_navigator_fragment_fxllayout_single` |
+| `popup_footnote` | `readium_navigator_popup_footnote` |
+| `r2_lcp_auth_dialog` | `readium_lcp_auth_dialog` |
+| `viewpager_fragment_cbz` | `readium_navigator_viewpager_fragment_cbz` |
+| `viewpager_fragment_epub` | `readium_navigator_viewpager_fragment_epub` |
+
+##### Renamed dimensions
+
+| Deprecated | New |
+|--------------------------------------|-------------------------------------------|
+| `r2_navigator_epub_vertical_padding` | `readium_navigator_epub_vertical_padding` |
+
+##### Renamed strings
+
+| Deprecated | New |
+|---------------------------------------------|--------------------------------------------------|
+| `r2_lcp_dialog_cancel` | `readium_lcp_dialog_cancel` |
+| `r2_lcp_dialog_continue` | `readium_lcp_dialog_continue` |
+| `r2_lcp_dialog_forgotPassphrase` | `readium_lcp_dialog_forgotPassphrase` |
+| `r2_lcp_dialog_help` | `readium_lcp_dialog_help` |
+| `r2_lcp_dialog_prompt` | `readium_lcp_dialog_prompt` |
+| `r2_lcp_dialog_reason_invalidPassphrase` | `readium_lcp_dialog_reason_invalidPassphrase` |
+| `r2_lcp_dialog_reason_passphraseNotFound` | `readium_lcp_dialog_reason_passphraseNotFound` |
+| `r2_lcp_dialog_support_mail` | `readium_lcp_dialog_support_mail` |
+| `r2_lcp_dialog_support_phone` | `readium_lcp_dialog_support_phone` |
+| `r2_lcp_dialog_support_web` | `readium_lcp_dialog_support_web` |
+| `r2_media_notification_channel_description` | `readium_media_notification_channel_description` |
+| `r2_media_notification_channel_name` | `readium_media_notification_channel_name` |
+
+##### Renamed drawables
+
+| Deprecated | New |
+|-----------------------------------------|----------------------------------------------|
+| `r2_media_notification_fastforward.xml` | `readium_media_notification_fastforward.xml` |
+| `r2_media_notification_rewind.xml` | `readium_media_notification_rewind.xml` |
+
## 2.4.0
diff --git a/gradle.properties b/gradle.properties
index c064741c76..cac7c68c14 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -19,5 +19,3 @@ android.useAndroidX=true
android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
-
-android.disableAutomaticComponentCreation=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a32be7e185..ecc21d3bb4 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,62 +1,68 @@
[versions]
-androidx-activity = "1.6.1"
-androidx-appcompat = "1.5.1"
-androidx-browser = "1.4.0"
+accompanist = "0.32.0"
+
+androidx-activity = "1.8.2"
+androidx-appcompat = "1.6.1"
+androidx-browser = "1.7.0"
androidx-cardview = "1.0.0"
-androidx-compose-compiler = "1.3.2"
-androidx-compose-animation = "1.3.0-beta03"
-androidx-compose-foundation = "1.3.0-beta03"
-androidx-compose-material = "1.3.0-beta03"
-androidx-compose-material3 = "1.0.0-beta03"
-androidx-compose-runtime = "1.3.0-beta03"
-androidx-compose-theme-adapter = "1.1.19"
-androidx-compose-ui = "1.3.0-beta03"
+# Make sure to align with the Kotlin version
+# https://developer.android.com/jetpack/androidx/releases/compose-kotlin
+androidx-compose-compiler = "1.5.8"
+androidx-compose-animation = "1.5.4"
+androidx-compose-foundation = "1.5.4"
+androidx-compose-material = "1.5.4"
+androidx-compose-material3 = "1.1.2"
+androidx-compose-runtime = "1.5.4"
+androidx-compose-ui = "1.5.4"
androidx-constraintlayout = "2.1.4"
-androidx-core = "1.9.0"
+androidx-core = "1.12.0"
androidx-datastore = "1.0.0"
-androidx-expresso-core = "3.4.0"
-androidx-ext-junit = "1.1.3"
-androidx-fragment-ktx = "1.5.4"
+androidx-expresso-core = "3.5.1"
+androidx-ext-junit = "1.1.5"
+androidx-fragment-ktx = "1.6.2"
androidx-legacy = "1.0.0"
-androidx-lifecycle = "2.5.1"
+androidx-lifecycle = "2.7.0"
androidx-lifecycle-extensions = "2.2.0"
-androidx-media = "1.6.0"
-androidx-media2 = "1.2.1"
-androidx-media3 = "1.0.0-rc01"
-androidx-navigation = "2.5.2"
-androidx-paging = "3.1.1"
-androidx-recyclerview = "1.2.1"
-androidx-room = "2.4.3"
+androidx-media = "1.7.0"
+androidx-media2 = "1.3.0"
+androidx-media3 = "1.2.0"
+androidx-navigation = "2.7.6"
+androidx-paging = "3.2.1"
+androidx-recyclerview = "1.3.2"
+androidx-room = "2.6.1"
androidx-viewpager2 = "1.0.0"
-androidx-webkit = "1.5.0"
+androidx-webkit = "1.9.0"
-assertj = "3.23.1"
+assertj = "3.25.1"
-dokka = "1.7.20"
+dokka = "1.9.10"
-google-exoplayer = "2.18.1"
-google-material = "1.7.0"
+google-exoplayer = "2.19.1"
+google-material = "1.11.0"
-joda-time = "2.12.1"
-jsoup = "1.15.3"
+joda-time = "2.12.6"
+jsoup = "1.17.2"
junit = "4.13.2"
-kotlin = "1.7.20"
-kotlinx-coroutines = "1.6.4"
-kotlinx-coroutines-test = "1.6.4"
-kotlinx-serialization-json = "1.4.1"
+kotlin = "1.9.22"
+kotlinx-coroutines = "1.7.3"
+kotlinx-coroutines-test = "1.7.3"
+kotlinx-serialization-json = "1.6.2"
pdfium = "1.8.2"
pdf-viewer = "2.8.2"
-picasso = "2.71828"
+#noinspection GradleDependency
+picasso = "2.8"
pspdfkit = "8.4.1"
-robolectric = "4.9"
+robolectric = "4.11.1"
timber = "5.0.1"
[libraries]
+accompanist-themeadapter-material = { group = "com.google.accompanist", name = "accompanist-themeadapter-material", version.ref = "accompanist" }
+
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "androidx-activity" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" }
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidx-browser" }
@@ -67,7 +73,6 @@ androidx-compose-foundation = { group = "androidx.compose.foundation", name = "f
androidx-compose-material = { group = "androidx.compose.material", name = "material", version.ref = "androidx-compose-material" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "androidx-compose-material3" }
androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "androidx-compose-material" }
-androidx-compose-theme-adapter = { group ="com.google.android.material", name = "compose-theme-adapter", version.ref = "androidx-compose-theme-adapter" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "androidx-compose-ui" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "androidx-compose-ui" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" }
@@ -119,7 +124,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
kotlin-gradle = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
kotlin-junit = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit", version.ref = "kotlin" }
kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" }
-kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib-jdk8", version.ref = "kotlin" }
+kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
@@ -137,7 +142,7 @@ timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "tim
[bundles]
-compose = ["androidx-compose-activity", "androidx-compose-animation", "androidx-compose-foundation", "androidx-compose-material", "androidx-compose-material3", "androidx-compose-material-icons", "androidx-compose-theme-adapter", "androidx-compose-ui", "androidx-compose-ui-tooling"]
+compose = ["androidx-compose-activity", "androidx-compose-animation", "androidx-compose-foundation", "androidx-compose-material", "androidx-compose-material3", "androidx-compose-material-icons", "androidx-compose-ui", "androidx-compose-ui-tooling"]
coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]
exoplayer = ["google-exoplayer-core", "google-exoplayer-ui", "google-exoplayer-mediasession", "google-exoplayer-workmanager", "google-exoplayer-extension-media2"]
lifecycle = ["androidx-lifecycle-common", "androidx-lifecycle-extensions", "androidx-lifecycle-livedata", "androidx-lifecycle-runtime", "androidx-lifecycle-viewmodel", "androidx-lifecycle-vmsavedstate", "androidx-lifecycle-viewmodel-compose"]
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index aa991fceae..15de90249f 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/mkdocs.yml b/mkdocs.yml
index aa372bf708..5756d0a2fa 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -10,7 +10,7 @@ repo_name: kotlin-toolkit
repo_url: https://github.com/readium/kotlin-toolkit
# Copyright (shown at the footer)
-copyright: 'Copyright © 2022 Readium Foundation'
+copyright: 'Copyright © 2023 Readium Foundation'
# Material theme
theme:
diff --git a/readium/adapters/exoplayer/audio/build.gradle.kts b/readium/adapters/exoplayer/audio/build.gradle.kts
new file mode 100644
index 0000000000..93fd839bb3
--- /dev/null
+++ b/readium/adapters/exoplayer/audio/build.gradle.kts
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2022 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+plugins {
+ id("com.android.library")
+ kotlin("android")
+ kotlin("plugin.parcelize")
+ kotlin("plugin.serialization")
+}
+
+android {
+ resourcePrefix = "readium_"
+
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 21
+ targetSdk = 34
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ freeCompilerArgs = freeCompilerArgs + listOf(
+ "-opt-in=kotlin.RequiresOptIn",
+ "-opt-in=org.readium.r2.shared.InternalReadiumApi"
+ )
+ }
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = false
+ proguardFiles(getDefaultProguardFile("proguard-android.txt"))
+ }
+ }
+ buildFeatures {
+ viewBinding = true
+ }
+ namespace = "org.readium.adapter.exoplayer.audio"
+}
+
+kotlin {
+ explicitApi()
+}
+
+rootProject.ext["publish.artifactId"] = "readium-navigator-exoplayer-audio"
+apply(from = "$rootDir/scripts/publish-module.gradle")
+
+dependencies {
+ api(project(":readium:readium-shared"))
+ api(project(":readium:navigators:media:readium-navigator-media-audio"))
+
+ implementation(libs.androidx.media3.common)
+ implementation(libs.androidx.media3.exoplayer)
+ implementation(libs.timber)
+ implementation(libs.bundles.coroutines)
+ implementation(libs.kotlinx.serialization.json)
+
+ // Tests
+ testImplementation(libs.junit)
+}
diff --git a/readium/adapters/pdfium/pdfium-document/src/main/AndroidManifest.xml b/readium/adapters/exoplayer/audio/src/main/AndroidManifest.xml
similarity index 100%
rename from readium/adapters/pdfium/pdfium-document/src/main/AndroidManifest.xml
rename to readium/adapters/exoplayer/audio/src/main/AndroidManifest.xml
diff --git a/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoAudiobookPlayer.kt b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoAudiobookPlayer.kt
new file mode 100644
index 0000000000..e76564f9f1
--- /dev/null
+++ b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoAudiobookPlayer.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2023 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.adapter.exoplayer.audio
+
+import androidx.media3.common.ForwardingPlayer
+import androidx.media3.exoplayer.ExoPlaybackException
+import androidx.media3.exoplayer.ExoPlayer
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.ExperimentalTime
+import timber.log.Timber
+
+/**
+ * A wrapper around ExoPlayer to customize some behaviours.
+ */
+@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
+internal class ExoAudiobookPlayer(
+ private val player: ExoPlayer,
+ private val itemDurations: List?,
+ private val seekForwardIncrement: Duration,
+ private val seekBackwardIncrement: Duration
+) : ForwardingPlayer(player) {
+
+ fun seekBy(offset: Duration) {
+ itemDurations
+ ?.let { smartSeekBy(offset, it) }
+ ?: dumbSeekBy(offset)
+ }
+
+ override fun seekForward() {
+ seekBy(seekForwardIncrement)
+ }
+
+ override fun seekBack() {
+ seekBy(-seekBackwardIncrement)
+ }
+
+ override fun getPlayerError(): ExoPlaybackException? {
+ return player.playerError
+ }
+
+ @OptIn(ExperimentalTime::class)
+ private fun smartSeekBy(
+ offset: Duration,
+ durations: List
+ ) {
+ val (newIndex, newPosition) =
+ SmartSeeker.dispatchSeek(
+ offset,
+ player.currentPosition.milliseconds,
+ player.currentMediaItemIndex,
+ durations
+ )
+ Timber.v("Smart seeking by $offset resolved to item $newIndex position $newPosition")
+ player.seekTo(newIndex, newPosition.inWholeMilliseconds)
+ }
+
+ private fun dumbSeekBy(offset: Duration) {
+ val newIndex = player.currentMediaItemIndex
+ val newPosition = player.currentPosition + offset.inWholeMilliseconds
+ player.seekTo(newIndex, newPosition)
+ }
+}
diff --git a/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerAliases.kt b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerAliases.kt
new file mode 100644
index 0000000000..d095ad6729
--- /dev/null
+++ b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerAliases.kt
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2022 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.adapter.exoplayer.audio
+
+import org.readium.navigator.media.audio.AudioNavigator
+import org.readium.navigator.media.audio.AudioNavigatorFactory
+import org.readium.r2.shared.ExperimentalReadiumApi
+
+@OptIn(ExperimentalReadiumApi::class)
+public typealias ExoPlayerNavigatorFactory = AudioNavigatorFactory
+
+@OptIn(ExperimentalReadiumApi::class)
+public typealias ExoPlayerNavigator = AudioNavigator
diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/media3/exoplayer/ExoPlayerDataSource.kt b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerDataSource.kt
similarity index 74%
rename from readium/navigator/src/main/java/org/readium/r2/navigator/media3/exoplayer/ExoPlayerDataSource.kt
rename to readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerDataSource.kt
index c3411199fd..401395caf7 100644
--- a/readium/navigator/src/main/java/org/readium/r2/navigator/media3/exoplayer/ExoPlayerDataSource.kt
+++ b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerDataSource.kt
@@ -4,7 +4,7 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.r2.navigator.media3.exoplayer
+package org.readium.adapter.exoplayer.audio
import android.net.Uri
import androidx.media3.common.C.LENGTH_UNSET
@@ -15,21 +15,32 @@ import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.TransferListener
import java.io.IOException
import kotlinx.coroutines.runBlocking
-import org.readium.r2.shared.fetcher.Resource
-import org.readium.r2.shared.fetcher.buffered
import org.readium.r2.shared.publication.Publication
-
-sealed class ExoPlayerDataSourceException(message: String, cause: Throwable?) : IOException(message, cause) {
+import org.readium.r2.shared.util.data.ReadException
+import org.readium.r2.shared.util.getOrThrow
+import org.readium.r2.shared.util.resource.Resource
+import org.readium.r2.shared.util.resource.buffered
+import org.readium.r2.shared.util.toUrl
+
+internal sealed class ExoPlayerDataSourceException(message: String, cause: Throwable?) : IOException(
+ message,
+ cause
+) {
class NotOpened(message: String) : ExoPlayerDataSourceException(message, null)
class NotFound(message: String) : ExoPlayerDataSourceException(message, null)
- class ReadFailed(uri: Uri, offset: Int, readLength: Int, cause: Throwable) : ExoPlayerDataSourceException("Failed to read $readLength bytes of URI $uri at offset $offset.", cause)
+ class ReadFailed(uri: Uri, offset: Int, readLength: Int, cause: Throwable) : ExoPlayerDataSourceException(
+ "Failed to read $readLength bytes of URI $uri at offset $offset.",
+ cause
+ )
}
/**
* An ExoPlayer's [DataSource] which retrieves resources from a [Publication].
*/
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
-internal class ExoPlayerDataSource internal constructor(private val publication: Publication) : BaseDataSource(/* isNetwork = */ true) {
+internal class ExoPlayerDataSource internal constructor(
+ private val publication: Publication
+) : BaseDataSource(/* isNetwork = */ true) {
class Factory(
private val publication: Publication,
@@ -47,23 +58,25 @@ internal class ExoPlayerDataSource internal constructor(private val publication:
private data class OpenedResource(
val resource: Resource,
val uri: Uri,
- var position: Long,
+ var position: Long
)
private var openedResource: OpenedResource? = null
override fun open(dataSpec: DataSpec): Long {
- val link = publication.linkWithHref(dataSpec.uri.toString())
- ?: throw ExoPlayerDataSourceException.NotFound("Can't find a [Link] for URI: ${dataSpec.uri}. Make sure you only request resources declared in the manifest.")
-
- val resource = publication.get(link)
+ val resource = dataSpec.uri.toUrl()
+ ?.let { publication.linkWithHref(it) }
+ ?.let { publication.get(it) }
// Significantly improves performances, in particular with deflated ZIP entries.
- .buffered(resourceLength = cachedLengths[dataSpec.uri.toString()])
+ ?.buffered(resourceLength = cachedLengths[dataSpec.uri.toString()])
+ ?: throw ExoPlayerDataSourceException.NotFound(
+ "Can't find a [Link] for URI: ${dataSpec.uri}. Make sure you only request resources declared in the manifest."
+ )
openedResource = OpenedResource(
resource = resource,
uri = dataSpec.uri,
- position = dataSpec.position,
+ position = dataSpec.position
)
val bytesToRead =
@@ -96,12 +109,15 @@ internal class ExoPlayerDataSource internal constructor(private val publication:
return 0
}
- val openedResource = openedResource ?: throw ExoPlayerDataSourceException.NotOpened("No opened resource to read from. Did you call open()?")
+ val openedResource = openedResource ?: throw ExoPlayerDataSourceException.NotOpened(
+ "No opened resource to read from. Did you call open()?"
+ )
try {
val data = runBlocking {
openedResource.resource
.read(range = openedResource.position until (openedResource.position + length))
+ .mapFailure { ReadException(it) }
.getOrThrow()
}
@@ -141,8 +157,9 @@ internal class ExoPlayerDataSource internal constructor(private val publication:
if (e !is InterruptedException) {
throw e
}
+ } finally {
+ openedResource = null
}
}
- openedResource = null
}
}
diff --git a/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerDefaults.kt b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerDefaults.kt
new file mode 100644
index 0000000000..81bba2f128
--- /dev/null
+++ b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerDefaults.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2022 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.adapter.exoplayer.audio
+
+import org.readium.r2.shared.ExperimentalReadiumApi
+
+/**
+ * Default values for the ExoPlayer engine.
+ *
+ * These values will be used as a last resort by [ExoPlayerSettingsResolver]
+ * when no user preference takes precedence.
+ *
+ * @see ExoPlayerPreferences
+ */
+@ExperimentalReadiumApi
+public data class ExoPlayerDefaults(
+ val pitch: Double? = null,
+ val speed: Double? = null
+) {
+ init {
+ require(pitch == null || pitch > 0)
+ require(speed == null || speed > 0)
+ }
+}
diff --git a/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerEngine.kt b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerEngine.kt
new file mode 100644
index 0000000000..d7dfcda84b
--- /dev/null
+++ b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerEngine.kt
@@ -0,0 +1,257 @@
+/*
+ * Copyright 2022 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.adapter.exoplayer.audio
+
+import android.app.Application
+import androidx.media3.common.*
+import androidx.media3.datasource.DataSource
+import androidx.media3.exoplayer.ExoPlaybackException
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import org.readium.navigator.media.audio.AudioEngine
+import org.readium.r2.shared.ExperimentalReadiumApi
+import org.readium.r2.shared.util.Url
+import org.readium.r2.shared.util.toUri
+import org.readium.r2.shared.util.units.Hz
+import org.readium.r2.shared.util.units.hz
+
+/**
+ * An [AudioEngine] based on Media3 ExoPlayer.
+ */
+@ExperimentalReadiumApi
+@OptIn(ExperimentalCoroutinesApi::class)
+@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
+public class ExoPlayerEngine private constructor(
+ private val exoPlayer: ExoAudiobookPlayer,
+ private val settingsResolver: SettingsResolver,
+ private val configuration: Configuration,
+ initialPreferences: ExoPlayerPreferences
+) : AudioEngine {
+
+ public companion object {
+
+ public suspend operator fun invoke(
+ application: Application,
+ settingsResolver: SettingsResolver,
+ dataSourceFactory: DataSource.Factory,
+ playlist: Playlist,
+ configuration: Configuration,
+ initialIndex: Int,
+ initialPosition: Duration,
+ initialPreferences: ExoPlayerPreferences
+ ): ExoPlayerEngine {
+ val exoPlayer = ExoPlayer.Builder(application)
+ .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
+ .setAudioAttributes(
+ AudioAttributes.Builder()
+ .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
+ .setUsage(C.USAGE_MEDIA)
+ .build(),
+ true
+ )
+ .setHandleAudioBecomingNoisy(true)
+ .setSeekBackIncrementMs(configuration.seekBackwardIncrement.inWholeMilliseconds)
+ .setSeekForwardIncrementMs(configuration.seekForwardIncrement.inWholeMilliseconds)
+ .build()
+
+ exoPlayer.setMediaItems(
+ playlist.items.map { item ->
+ MediaItem.Builder()
+ .setUri(item.url.toUri())
+ .setMediaMetadata(item.mediaMetadata)
+ .build()
+ }
+ )
+
+ val durations: List? =
+ playlist.items.mapNotNull { it.duration }
+ .takeIf { it.size == playlist.items.size }
+
+ exoPlayer.playlistMetadata = playlist.mediaMetadata
+
+ exoPlayer.seekTo(initialIndex, initialPosition.inWholeMilliseconds)
+
+ prepareExoPlayer(exoPlayer)
+
+ val customizedPlayer =
+ ExoAudiobookPlayer(
+ exoPlayer,
+ durations,
+ configuration.seekForwardIncrement,
+ configuration.seekBackwardIncrement
+ )
+
+ return ExoPlayerEngine(
+ customizedPlayer,
+ settingsResolver,
+ configuration,
+ initialPreferences
+ )
+ }
+
+ private suspend fun prepareExoPlayer(player: ExoPlayer) {
+ lateinit var listener: Player.Listener
+ suspendCancellableCoroutine { continuation ->
+ listener = object : Player.Listener {
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ when (playbackState) {
+ Player.STATE_READY -> continuation.resume(Unit) {}
+ Player.STATE_IDLE -> if (player.playerError != null) {
+ continuation.resume(Unit) {}
+ }
+ else -> {}
+ }
+ }
+ }
+ continuation.invokeOnCancellation { player.removeListener(listener) }
+ player.addListener(listener)
+ player.prepare()
+ }
+ player.removeListener(listener)
+ }
+ }
+
+ public data class Configuration(
+ val positionRefreshRate: Hz = 2.0.hz,
+ val seekBackwardIncrement: Duration = 15.seconds,
+ val seekForwardIncrement: Duration = 30.seconds
+ )
+
+ public data class Playlist(
+ val mediaMetadata: MediaMetadata,
+ val duration: Duration?,
+ val items: List-
+ ) {
+ public data class Item(
+ val url: Url,
+ val mediaMetadata: MediaMetadata,
+ val duration: Duration?
+ )
+ }
+
+ public fun interface SettingsResolver {
+
+ /**
+ * Computes a set of engine settings from the engine preferences.
+ */
+ public fun settings(preferences: ExoPlayerPreferences): ExoPlayerSettings
+ }
+
+ private inner class Listener : Player.Listener {
+
+ override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) {
+ submitPreferences(
+ ExoPlayerPreferences(
+ pitch = playbackParameters.pitch.toDouble(),
+ speed = playbackParameters.speed.toDouble()
+ )
+ )
+ }
+
+ override fun onEvents(player: Player, events: Player.Events) {
+ _playback.value = exoPlayer.playback
+ }
+ }
+
+ public data class Error(val error: ExoPlaybackException) : AudioEngine.Error
+
+ private val coroutineScope: CoroutineScope =
+ MainScope()
+
+ init {
+ exoPlayer.addListener(Listener())
+ }
+
+ private val _settings: MutableStateFlow
=
+ MutableStateFlow(settingsResolver.settings(initialPreferences))
+
+ private val _playback: MutableStateFlow =
+ MutableStateFlow(exoPlayer.playback)
+
+ init {
+ coroutineScope.launch {
+ val positionRefreshDelay = (1.0 / configuration.positionRefreshRate.value).seconds
+ while (isActive) {
+ delay(positionRefreshDelay)
+ _playback.value = exoPlayer.playback
+ }
+ }
+
+ submitPreferences(initialPreferences)
+ }
+
+ override val playback: StateFlow
+ get() = _playback.asStateFlow()
+
+ override val settings: StateFlow
+ get() = _settings.asStateFlow()
+
+ override fun play() {
+ exoPlayer.play()
+ }
+
+ override fun pause() {
+ exoPlayer.pause()
+ }
+
+ override fun skipTo(index: Int, offset: Duration) {
+ exoPlayer.seekTo(index, offset.inWholeMilliseconds)
+ }
+
+ override fun skip(duration: Duration) {
+ exoPlayer.seekBy(duration)
+ }
+
+ override fun skipForward() {
+ exoPlayer.seekForward()
+ }
+
+ override fun skipBackward() {
+ exoPlayer.seekBack()
+ }
+
+ override fun close() {
+ coroutineScope.cancel()
+ exoPlayer.release()
+ }
+
+ override fun asPlayer(): Player {
+ return exoPlayer
+ }
+
+ override fun submitPreferences(preferences: ExoPlayerPreferences) {
+ val newSettings = settingsResolver.settings(preferences)
+ exoPlayer.playbackParameters = PlaybackParameters(
+ newSettings.speed.toFloat(),
+ newSettings.pitch.toFloat()
+ )
+ }
+
+ private val ExoAudiobookPlayer.playback: AudioEngine.Playback get() =
+ AudioEngine.Playback(
+ state = engineState,
+ playWhenReady = playWhenReady,
+ index = currentMediaItemIndex,
+ offset = currentPosition.milliseconds,
+ buffered = bufferedPosition.milliseconds
+ )
+
+ private val ExoAudiobookPlayer.engineState: AudioEngine.State get() =
+ when (this.playbackState) {
+ Player.STATE_READY -> AudioEngine.State.Ready
+ Player.STATE_BUFFERING -> AudioEngine.State.Buffering
+ Player.STATE_ENDED -> AudioEngine.State.Ended
+ else -> AudioEngine.State.Failure(Error(playerError!!))
+ }
+}
diff --git a/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerEngineProvider.kt b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerEngineProvider.kt
new file mode 100644
index 0000000000..63355d0aa1
--- /dev/null
+++ b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerEngineProvider.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2022 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.adapter.exoplayer.audio
+
+import android.app.Application
+import androidx.media3.datasource.DataSource
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import org.readium.navigator.media.audio.AudioEngineProvider
+import org.readium.navigator.media.common.DefaultMediaMetadataProvider
+import org.readium.navigator.media.common.MediaMetadataProvider
+import org.readium.r2.navigator.extensions.time
+import org.readium.r2.shared.ExperimentalReadiumApi
+import org.readium.r2.shared.publication.Locator
+import org.readium.r2.shared.publication.Publication
+import org.readium.r2.shared.publication.indexOfFirstWithHref
+import org.readium.r2.shared.util.Try
+
+/**
+ * Main component to use the audio navigator with the ExoPlayer adapter.
+ *
+ * Provide [ExoPlayerDefaults] to customize the default values that will be used by
+ * the navigator for some preferences.
+ */
+@ExperimentalReadiumApi
+@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
+public class ExoPlayerEngineProvider(
+ private val application: Application,
+ private val metadataProvider: MediaMetadataProvider = DefaultMediaMetadataProvider(),
+ private val defaults: ExoPlayerDefaults = ExoPlayerDefaults(),
+ private val configuration: ExoPlayerEngine.Configuration = ExoPlayerEngine.Configuration()
+) : AudioEngineProvider {
+
+ override suspend fun createEngine(
+ publication: Publication,
+ initialLocator: Locator,
+ initialPreferences: ExoPlayerPreferences
+ ): Try {
+ val metadataFactory = metadataProvider.createMetadataFactory(publication)
+ val settingsResolver = ExoPlayerSettingsResolver(defaults)
+ val dataSourceFactory: DataSource.Factory = ExoPlayerDataSource.Factory(publication)
+ val initialIndex = publication.readingOrder.indexOfFirstWithHref(initialLocator.href) ?: 0
+ val initialPosition = initialLocator.locations.time ?: Duration.ZERO
+ val playlist = ExoPlayerEngine.Playlist(
+ mediaMetadata = metadataFactory.publicationMetadata(),
+ duration = publication.metadata.duration?.seconds,
+ items = publication.readingOrder.mapIndexed { index, link ->
+ ExoPlayerEngine.Playlist.Item(
+ url = link.url(),
+ mediaMetadata = metadataFactory.resourceMetadata(index),
+ duration = link.duration?.seconds
+ )
+ }
+ )
+
+ val engine = ExoPlayerEngine(
+ application = application,
+ settingsResolver = settingsResolver,
+ playlist = playlist,
+ dataSourceFactory = dataSourceFactory,
+ configuration = configuration,
+ initialIndex = initialIndex,
+ initialPosition = initialPosition,
+ initialPreferences = initialPreferences
+ )
+
+ return Try.success(engine)
+ }
+
+ override fun createPreferenceEditor(
+ publication: Publication,
+ initialPreferences: ExoPlayerPreferences
+ ): ExoPlayerPreferencesEditor =
+ ExoPlayerPreferencesEditor(
+ initialPreferences,
+ publication.metadata,
+ defaults
+ )
+
+ override fun createEmptyPreferences(): ExoPlayerPreferences =
+ ExoPlayerPreferences()
+}
diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/media3/exoplayer/ExoPlayerPreferences.kt b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerPreferences.kt
similarity index 59%
rename from readium/navigator/src/main/java/org/readium/r2/navigator/media3/exoplayer/ExoPlayerPreferences.kt
rename to readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerPreferences.kt
index c77c551475..cac4a12758 100644
--- a/readium/navigator/src/main/java/org/readium/r2/navigator/media3/exoplayer/ExoPlayerPreferences.kt
+++ b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerPreferences.kt
@@ -4,19 +4,27 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.r2.navigator.media3.exoplayer
+package org.readium.adapter.exoplayer.audio
import org.readium.r2.navigator.preferences.Configurable
import org.readium.r2.shared.ExperimentalReadiumApi
+/**
+ * Preferences for the the ExoPlayer engine.
+ *
+ * @param pitch Playback pitch rate.
+ * @param speed Playback speed rate.
+ */
@ExperimentalReadiumApi
@kotlinx.serialization.Serializable
-data class ExoPlayerPreferences(
- val rateMultiplier: Double? = null,
+public data class ExoPlayerPreferences(
+ val pitch: Double? = null,
+ val speed: Double? = null
) : Configurable.Preferences {
override fun plus(other: ExoPlayerPreferences): ExoPlayerPreferences =
ExoPlayerPreferences(
- rateMultiplier = other.rateMultiplier ?: rateMultiplier,
+ pitch = other.pitch ?: pitch,
+ speed = other.speed ?: speed
)
}
diff --git a/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerPreferencesEditor.kt b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerPreferencesEditor.kt
new file mode 100644
index 0000000000..c17da9f058
--- /dev/null
+++ b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerPreferencesEditor.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2022 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.adapter.exoplayer.audio
+
+import org.readium.r2.navigator.extensions.format
+import org.readium.r2.navigator.preferences.DoubleIncrement
+import org.readium.r2.navigator.preferences.PreferencesEditor
+import org.readium.r2.navigator.preferences.RangePreference
+import org.readium.r2.navigator.preferences.RangePreferenceDelegate
+import org.readium.r2.shared.ExperimentalReadiumApi
+import org.readium.r2.shared.publication.Metadata
+
+/**
+ * Editor for a set of [ExoPlayerPreferences].
+ *
+ * Use [ExoPlayerPreferencesEditor] to assist you in building a preferences user interface or modifying
+ * existing preferences. It includes rules for adjusting preferences, such as the supported values
+ * or ranges.
+ */
+@ExperimentalReadiumApi
+public class ExoPlayerPreferencesEditor(
+ initialPreferences: ExoPlayerPreferences,
+ @Suppress("UNUSED_PARAMETER") publicationMetadata: Metadata,
+ defaults: ExoPlayerDefaults
+) : PreferencesEditor {
+
+ private data class State(
+ val preferences: ExoPlayerPreferences,
+ val settings: ExoPlayerSettings
+ )
+
+ private val settingsResolver: ExoPlayerSettingsResolver =
+ ExoPlayerSettingsResolver(defaults)
+
+ private var state: State =
+ initialPreferences.toState()
+
+ override val preferences: ExoPlayerPreferences
+ get() = state.preferences
+
+ override fun clear() {
+ updateValues { ExoPlayerPreferences() }
+ }
+
+ public val pitch: RangePreference =
+ RangePreferenceDelegate(
+ getValue = { preferences.pitch },
+ getEffectiveValue = { state.settings.pitch },
+ getIsEffective = { true },
+ updateValue = { value -> updateValues { it.copy(pitch = value) } },
+ supportedRange = 0.1..Double.MAX_VALUE,
+ progressionStrategy = DoubleIncrement(0.1),
+ valueFormatter = { "${it.format(2)}x" }
+ )
+
+ public val speed: RangePreference =
+ RangePreferenceDelegate(
+ getValue = { preferences.speed },
+ getEffectiveValue = { state.settings.speed },
+ getIsEffective = { true },
+ updateValue = { value -> updateValues { it.copy(speed = value) } },
+ supportedRange = 0.1..Double.MAX_VALUE,
+ progressionStrategy = DoubleIncrement(0.1),
+ valueFormatter = { "${it.format(2)}x" }
+ )
+
+ private fun updateValues(updater: (ExoPlayerPreferences) -> ExoPlayerPreferences) {
+ val newPreferences = updater(preferences)
+ state = newPreferences.toState()
+ }
+
+ private fun ExoPlayerPreferences.toState() =
+ State(
+ preferences = this,
+ settings = settingsResolver.settings(this)
+ )
+}
diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/media3/exoplayer/ExoPlayerPreferencesSerializer.kt b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerPreferencesSerializer.kt
similarity index 84%
rename from readium/navigator/src/main/java/org/readium/r2/navigator/media3/exoplayer/ExoPlayerPreferencesSerializer.kt
rename to readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerPreferencesSerializer.kt
index aada34c11e..4dae0d4aed 100644
--- a/readium/navigator/src/main/java/org/readium/r2/navigator/media3/exoplayer/ExoPlayerPreferencesSerializer.kt
+++ b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerPreferencesSerializer.kt
@@ -4,7 +4,7 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.r2.navigator.media3.exoplayer
+package org.readium.adapter.exoplayer.audio
import kotlinx.serialization.json.Json
import org.readium.r2.navigator.preferences.PreferencesSerializer
@@ -14,7 +14,7 @@ import org.readium.r2.shared.ExperimentalReadiumApi
* JSON serializer of [ExoPlayerPreferences].
*/
@ExperimentalReadiumApi
-class ExoPlayerPreferencesSerializer : PreferencesSerializer {
+public class ExoPlayerPreferencesSerializer : PreferencesSerializer {
override fun serialize(preferences: ExoPlayerPreferences): String =
Json.encodeToString(ExoPlayerPreferences.serializer(), preferences)
diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/media3/exoplayer/ExoPlayerSettings.kt b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerSettings.kt
similarity index 62%
rename from readium/navigator/src/main/java/org/readium/r2/navigator/media3/exoplayer/ExoPlayerSettings.kt
rename to readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerSettings.kt
index 58bc54b1e5..67b2e013f3 100644
--- a/readium/navigator/src/main/java/org/readium/r2/navigator/media3/exoplayer/ExoPlayerSettings.kt
+++ b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerSettings.kt
@@ -4,12 +4,18 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.r2.navigator.media3.exoplayer
+package org.readium.adapter.exoplayer.audio
import org.readium.r2.navigator.preferences.Configurable
import org.readium.r2.shared.ExperimentalReadiumApi
+/**
+ * Settings values of the ExoPlayer engine.
+ *
+ * @see ExoPlayerPreferences
+ */
@ExperimentalReadiumApi
-data class ExoPlayerSettings(
- val rateMultiplier: Double
+public data class ExoPlayerSettings(
+ val pitch: Double,
+ val speed: Double
) : Configurable.Settings
diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/media3/exoplayer/ExoPlayerSettingsResolver.kt b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerSettingsResolver.kt
similarity index 51%
rename from readium/navigator/src/main/java/org/readium/r2/navigator/media3/exoplayer/ExoPlayerSettingsResolver.kt
rename to readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerSettingsResolver.kt
index b940630a3e..8a7719394a 100644
--- a/readium/navigator/src/main/java/org/readium/r2/navigator/media3/exoplayer/ExoPlayerSettingsResolver.kt
+++ b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/ExoPlayerSettingsResolver.kt
@@ -4,20 +4,19 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.r2.navigator.media3.exoplayer
+package org.readium.adapter.exoplayer.audio
import org.readium.r2.shared.ExperimentalReadiumApi
-import org.readium.r2.shared.publication.Metadata
@ExperimentalReadiumApi
internal class ExoPlayerSettingsResolver(
- private val metadata: Metadata,
-) {
-
- fun settings(preferences: ExoPlayerPreferences): ExoPlayerSettings {
+ private val defaults: ExoPlayerDefaults
+) : ExoPlayerEngine.SettingsResolver {
+ override fun settings(preferences: ExoPlayerPreferences): ExoPlayerSettings {
return ExoPlayerSettings(
- rateMultiplier = preferences.rateMultiplier ?: 1.0,
+ pitch = preferences.pitch ?: defaults.pitch ?: 1.0,
+ speed = preferences.speed ?: defaults.speed ?: 1.0
)
}
}
diff --git a/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/SmartSeeker.kt b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/SmartSeeker.kt
new file mode 100644
index 0000000000..68e42c62a2
--- /dev/null
+++ b/readium/adapters/exoplayer/audio/src/main/java/org/readium/adapter/exoplayer/audio/SmartSeeker.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2022 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.adapter.exoplayer.audio
+
+import kotlin.time.Duration
+import kotlin.time.ExperimentalTime
+
+/**
+ * Computes relative seeks across playlist items.
+ */
+@ExperimentalTime
+internal object SmartSeeker {
+
+ data class Result(val index: Int, val position: Duration)
+
+ fun dispatchSeek(
+ offset: Duration,
+ currentPosition: Duration,
+ currentIndex: Int,
+ playlist: List
+ ): Result {
+ val currentDuration = playlist[currentIndex]
+ val dummyNewPosition = currentPosition + offset
+
+ return when {
+ offset == Duration.ZERO -> {
+ Result(currentIndex, currentPosition)
+ }
+ currentDuration > dummyNewPosition && dummyNewPosition > Duration.ZERO -> {
+ Result(currentIndex, dummyNewPosition)
+ }
+ offset.isPositive() && currentIndex == playlist.size - 1 -> {
+ Result(currentIndex, playlist[currentIndex])
+ }
+ offset.isNegative() && currentIndex == 0 -> {
+ Result(0, Duration.ZERO)
+ }
+ offset.isPositive() -> {
+ var toDispatch = offset - (currentDuration - currentPosition)
+ var index = currentIndex + 1
+ while (toDispatch > playlist[index] && index + 1 < playlist.size) {
+ toDispatch -= playlist[index]
+ index += 1
+ }
+ Result(index, toDispatch.coerceAtMost(playlist[index]))
+ }
+ else -> {
+ var toDispatch = offset + currentPosition
+ var index = currentIndex - 1
+ while (-toDispatch > playlist[index] && index > 0) {
+ toDispatch += playlist[index]
+ index -= 1
+ }
+ Result(index, (playlist[index] + toDispatch).coerceAtLeast(Duration.ZERO))
+ }
+ }
+ }
+}
diff --git a/readium/adapters/exoplayer/audio/src/test/java/org/readium/adapter/exoplayer/audio/SmartSeekerTest.kt b/readium/adapters/exoplayer/audio/src/test/java/org/readium/adapter/exoplayer/audio/SmartSeekerTest.kt
new file mode 100644
index 0000000000..f410850119
--- /dev/null
+++ b/readium/adapters/exoplayer/audio/src/test/java/org/readium/adapter/exoplayer/audio/SmartSeekerTest.kt
@@ -0,0 +1,114 @@
+package org.readium.adapter.exoplayer.audio
+
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.ExperimentalTime
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+@OptIn(ExperimentalTime::class)
+class SmartSeekerTest {
+
+ private val playlist: List = listOf(
+ 10,
+ 20,
+ 15,
+ 800,
+ 10,
+ 230,
+ 20,
+ 10
+ ).map { it.seconds }
+
+ private val forwardOffset = 50.seconds
+
+ private val backwardOffset = (-50).seconds
+
+ @Test
+ fun `seek forward within current item`() {
+ val result = SmartSeeker.dispatchSeek(
+ offset = forwardOffset,
+ currentPosition = 200.seconds,
+ currentIndex = 3,
+ playlist
+ )
+ assertEquals(SmartSeeker.Result(3, 250.seconds), result)
+ }
+
+ @Test
+ fun `seek backward within current item`() {
+ val result = SmartSeeker.dispatchSeek(
+ offset = backwardOffset,
+ currentPosition = 200.seconds,
+ currentIndex = 3,
+ playlist
+ )
+ assertEquals(SmartSeeker.Result(3, 150.seconds), result)
+ }
+
+ @Test
+ fun `seek forward across items`() {
+ val result = SmartSeeker.dispatchSeek(
+ offset = forwardOffset,
+ currentPosition = 780.seconds,
+ currentIndex = 3,
+ playlist
+ )
+ assertEquals(SmartSeeker.Result(5, 20.seconds), result)
+ }
+
+ @Test
+ fun `seek backward across items`() {
+ val result = SmartSeeker.dispatchSeek(
+ offset = backwardOffset,
+ currentPosition = 10.seconds,
+ currentIndex = 3,
+ playlist
+ )
+ assertEquals(SmartSeeker.Result(0, 5.seconds), result)
+ }
+
+ @Test
+ fun `positive offset too big within last item`() {
+ val result = SmartSeeker.dispatchSeek(
+ offset = forwardOffset,
+ currentPosition = 5.seconds,
+ currentIndex = 7,
+ playlist
+ )
+ assertEquals(SmartSeeker.Result(7, 10.seconds), result)
+ }
+
+ @Test
+ fun `positive offset too big across items`() {
+ val result = SmartSeeker.dispatchSeek(
+ offset = forwardOffset,
+ currentPosition = 220.seconds,
+ currentIndex = 6,
+ playlist
+ )
+ assertEquals(SmartSeeker.Result(7, 10.seconds), result)
+ }
+
+ @Test
+ fun `negative offset too small within first item`() {
+ val result = SmartSeeker.dispatchSeek(
+ offset = backwardOffset,
+ currentPosition = 5.seconds,
+ currentIndex = 0,
+ playlist
+ )
+ assertEquals(SmartSeeker.Result(0, 0.seconds), result)
+ }
+
+ @Test
+ fun `negative offset too small across items`() {
+ val result = SmartSeeker.dispatchSeek(
+ offset = backwardOffset,
+ currentPosition = 10.seconds,
+ currentIndex = 2,
+ playlist
+ )
+ assertEquals(SmartSeeker.Result(0, 0.seconds), result)
+ }
+}
diff --git a/readium/adapters/exoplayer/build.gradle.kts b/readium/adapters/exoplayer/build.gradle.kts
new file mode 100644
index 0000000000..c36ab5a491
--- /dev/null
+++ b/readium/adapters/exoplayer/build.gradle.kts
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2022 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+plugins {
+ id("com.android.library")
+ kotlin("android")
+ kotlin("plugin.parcelize")
+}
+
+android {
+ resourcePrefix = "readium_"
+
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 21
+ targetSdk = 34
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ freeCompilerArgs = freeCompilerArgs + listOf(
+ "-opt-in=kotlin.RequiresOptIn",
+ "-opt-in=org.readium.r2.shared.InternalReadiumApi"
+ )
+ }
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = false
+ proguardFiles(getDefaultProguardFile("proguard-android.txt"))
+ }
+ }
+ namespace = "org.readium.adapter.exoplayer"
+}
+
+kotlin {
+ explicitApi()
+}
+
+rootProject.ext["publish.artifactId"] = "readium-adapter-exoplayer"
+apply(from = "$rootDir/scripts/publish-module.gradle")
+
+dependencies {
+ api(project(":readium:adapters:exoplayer:readium-adapter-exoplayer-audio"))
+}
diff --git a/readium/adapters/pspdfkit/pspdfkit-document/src/main/AndroidManifest.xml b/readium/adapters/exoplayer/src/main/AndroidManifest.xml
similarity index 100%
rename from readium/adapters/pspdfkit/pspdfkit-document/src/main/AndroidManifest.xml
rename to readium/adapters/exoplayer/src/main/AndroidManifest.xml
diff --git a/readium/adapters/pdfium/build.gradle.kts b/readium/adapters/pdfium/build.gradle.kts
index 8021c8dee7..de6e0c5a4e 100644
--- a/readium/adapters/pdfium/build.gradle.kts
+++ b/readium/adapters/pdfium/build.gradle.kts
@@ -13,19 +13,19 @@ plugins {
android {
resourcePrefix = "readium_"
- compileSdk = 33
+ compileSdk = 34
defaultConfig {
minSdk = 21
- targetSdk = 33
+ targetSdk = 34
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
- jvmTarget = "1.8"
+ jvmTarget = JavaVersion.VERSION_17.toString()
freeCompilerArgs = freeCompilerArgs + listOf(
"-opt-in=kotlin.RequiresOptIn",
"-opt-in=org.readium.r2.shared.InternalReadiumApi"
@@ -37,7 +37,11 @@ android {
proguardFiles(getDefaultProguardFile("proguard-android.txt"))
}
}
- namespace = "org.readium.adapters.pdfium"
+ namespace = "org.readium.adapter.pdfium"
+}
+
+kotlin {
+ explicitApi()
}
rootProject.ext["publish.artifactId"] = "readium-adapter-pdfium"
diff --git a/readium/adapters/pdfium/pdfium-document/build.gradle.kts b/readium/adapters/pdfium/document/build.gradle.kts
similarity index 82%
rename from readium/adapters/pdfium/pdfium-document/build.gradle.kts
rename to readium/adapters/pdfium/document/build.gradle.kts
index 6113dec3c1..c18cdbde30 100644
--- a/readium/adapters/pdfium/pdfium-document/build.gradle.kts
+++ b/readium/adapters/pdfium/document/build.gradle.kts
@@ -13,19 +13,19 @@ plugins {
android {
resourcePrefix = "readium_"
- compileSdk = 33
+ compileSdk = 34
defaultConfig {
minSdk = 21
- targetSdk = 33
+ targetSdk = 34
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
- jvmTarget = "1.8"
+ jvmTarget = JavaVersion.VERSION_17.toString()
freeCompilerArgs = freeCompilerArgs + listOf(
"-opt-in=kotlin.RequiresOptIn",
"-opt-in=org.readium.r2.shared.InternalReadiumApi"
@@ -40,7 +40,11 @@ android {
buildFeatures {
viewBinding = true
}
- namespace = "org.readium.adapters.pdfium.document"
+ namespace = "org.readium.adapter.pdfium.document"
+}
+
+kotlin {
+ explicitApi()
}
rootProject.ext["publish.artifactId"] = "readium-adapter-pdfium-document"
diff --git a/readium/adapters/pdfium/document/src/main/AndroidManifest.xml b/readium/adapters/pdfium/document/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..2d10029868
--- /dev/null
+++ b/readium/adapters/pdfium/document/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/readium/adapters/pdfium/pdfium-document/src/main/java/org/readium/adapters/pdfium/document/PdfiumDocument.kt b/readium/adapters/pdfium/document/src/main/java/org/readium/adapter/pdfium/document/PdfiumDocument.kt
similarity index 72%
rename from readium/adapters/pdfium/pdfium-document/src/main/java/org/readium/adapters/pdfium/document/PdfiumDocument.kt
rename to readium/adapters/pdfium/document/src/main/java/org/readium/adapter/pdfium/document/PdfiumDocument.kt
index d654bf1024..6a388c6454 100644
--- a/readium/adapters/pdfium/pdfium-document/src/main/java/org/readium/adapters/pdfium/document/PdfiumDocument.kt
+++ b/readium/adapters/pdfium/document/src/main/java/org/readium/adapter/pdfium/document/PdfiumDocument.kt
@@ -4,7 +4,7 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.adapters.pdfium.document
+package org.readium.adapter.pdfium.document
import android.content.Context
import android.graphics.Bitmap
@@ -15,19 +15,22 @@ import java.io.File
import kotlin.reflect.KClass
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
-import org.readium.r2.shared.PdfSupport
+import org.readium.r2.shared.InternalReadiumApi
import org.readium.r2.shared.extensions.md5
import org.readium.r2.shared.extensions.tryOrNull
-import org.readium.r2.shared.fetcher.Resource
+import org.readium.r2.shared.util.Try
+import org.readium.r2.shared.util.data.ReadError
+import org.readium.r2.shared.util.data.ReadTry
+import org.readium.r2.shared.util.flatMap
import org.readium.r2.shared.util.pdf.PdfDocument
import org.readium.r2.shared.util.pdf.PdfDocumentFactory
+import org.readium.r2.shared.util.resource.Resource
import org.readium.r2.shared.util.use
import timber.log.Timber
-@OptIn(PdfSupport::class)
-class PdfiumDocument(
- val core: PdfiumCore,
- val document: _PdfiumDocument,
+public class PdfiumDocument(
+ @InternalReadiumApi public val core: PdfiumCore,
+ @InternalReadiumApi public val document: _PdfiumDocument,
override val identifier: String?,
override val pageCount: Int
) : PdfDocument {
@@ -68,10 +71,9 @@ class PdfiumDocument(
override suspend fun close() {}
- companion object
+ public companion object
}
-@OptIn(PdfSupport::class)
private fun _PdfiumDocument.Bookmark.toOutlineNode(): PdfDocument.OutlineNode =
PdfDocument.OutlineNode(
title = title,
@@ -79,36 +81,48 @@ private fun _PdfiumDocument.Bookmark.toOutlineNode(): PdfDocument.OutlineNode =
children = children.map { it.toOutlineNode() }
)
-@OptIn(PdfSupport::class)
-class PdfiumDocumentFactory(context: Context) : PdfDocumentFactory {
+public class PdfiumDocumentFactory(context: Context) : PdfDocumentFactory {
override val documentType: KClass = PdfiumDocument::class
private val core by lazy { PdfiumCore(context.applicationContext) }
- override suspend fun open(file: File, password: String?): PdfiumDocument =
- core.fromFile(file, password)
-
- override suspend fun open(resource: Resource, password: String?): PdfiumDocument {
+ override suspend fun open(resource: Resource, password: String?): ReadTry {
// First try to open the resource as a file on the FS for performance improvement, as
// PDFium requires the whole PDF document to be loaded in memory when using raw bytes.
return resource.openAsFile(password)
?: resource.openBytes(password)
}
- private suspend fun Resource.openAsFile(password: String?): PdfiumDocument? =
- file?.let {
- tryOrNull { open(it, password) }
+ private suspend fun Resource.openAsFile(password: String?): ReadTry? =
+ tryOrNull {
+ sourceUrl?.toFile()?.let { file ->
+ withContext(Dispatchers.IO) {
+ Try.success(core.fromFile(file, password))
+ }
+ }
}
- private suspend fun Resource.openBytes(password: String?): PdfiumDocument =
+ private suspend fun Resource.openBytes(password: String?): ReadTry =
use {
- core.fromBytes(read().getOrThrow(), password)
+ it.read()
+ .flatMap { bytes ->
+ try {
+ Try.success(
+ core.fromBytes(bytes, password)
+ )
+ } catch (e: Exception) {
+ Try.failure(ReadError.Decoding(e))
+ }
+ }
}
private fun PdfiumCore.fromFile(file: File, password: String?): PdfiumDocument =
fromDocument(
- newDocument(ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY), password),
+ newDocument(
+ ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY),
+ password
+ ),
identifier = file.md5()
)
diff --git a/readium/adapters/pdfium/document/src/main/java/org/readium/adapters/pdfium/document/Deprecated.kt b/readium/adapters/pdfium/document/src/main/java/org/readium/adapters/pdfium/document/Deprecated.kt
new file mode 100644
index 0000000000..fbbe24936d
--- /dev/null
+++ b/readium/adapters/pdfium/document/src/main/java/org/readium/adapters/pdfium/document/Deprecated.kt
@@ -0,0 +1,15 @@
+package org.readium.adapters.pdfium.document
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pdfium.document.PdfiumDocument"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PdfiumDocument = org.readium.adapter.pdfium.document.PdfiumDocument
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pdfium.document.PdfiumDocumentFactory"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PdfiumDocumentFactory = org.readium.adapter.pdfium.document.PdfiumDocumentFactory
diff --git a/readium/adapters/pdfium/pdfium-navigator/build.gradle.kts b/readium/adapters/pdfium/navigator/build.gradle.kts
similarity index 84%
rename from readium/adapters/pdfium/pdfium-navigator/build.gradle.kts
rename to readium/adapters/pdfium/navigator/build.gradle.kts
index 31ce3a0f4d..7afc1d3620 100644
--- a/readium/adapters/pdfium/pdfium-navigator/build.gradle.kts
+++ b/readium/adapters/pdfium/navigator/build.gradle.kts
@@ -14,19 +14,19 @@ plugins {
android {
resourcePrefix = "readium_"
- compileSdk = 33
+ compileSdk = 34
defaultConfig {
minSdk = 21
- targetSdk = 33
+ targetSdk = 34
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
- jvmTarget = "1.8"
+ jvmTarget = JavaVersion.VERSION_17.toString()
freeCompilerArgs = freeCompilerArgs + listOf(
"-opt-in=kotlin.RequiresOptIn",
"-opt-in=org.readium.r2.shared.InternalReadiumApi"
@@ -41,7 +41,11 @@ android {
buildFeatures {
viewBinding = true
}
- namespace = "org.readium.adapters.pdfium.navigator"
+ namespace = "org.readium.adapter.pdfium.navigator"
+}
+
+kotlin {
+ explicitApi()
}
rootProject.ext["publish.artifactId"] = "readium-adapter-pdfium-navigator"
diff --git a/readium/adapters/pdfium/pdfium-navigator/src/main/AndroidManifest.xml b/readium/adapters/pdfium/navigator/src/main/AndroidManifest.xml
similarity index 100%
rename from readium/adapters/pdfium/pdfium-navigator/src/main/AndroidManifest.xml
rename to readium/adapters/pdfium/navigator/src/main/AndroidManifest.xml
diff --git a/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumDefaults.kt b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumDefaults.kt
similarity index 80%
rename from readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumDefaults.kt
rename to readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumDefaults.kt
index 0515064caa..024509cf3b 100644
--- a/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumDefaults.kt
+++ b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumDefaults.kt
@@ -4,7 +4,7 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.adapters.pdfium.navigator
+package org.readium.adapter.pdfium.navigator
import org.readium.r2.navigator.preferences.ReadingProgression
import org.readium.r2.shared.ExperimentalReadiumApi
@@ -17,7 +17,7 @@ import org.readium.r2.shared.ExperimentalReadiumApi
* @see PdfiumPreferences
*/
@ExperimentalReadiumApi
-data class PdfiumDefaults(
+public data class PdfiumDefaults(
val pageSpacing: Double? = null,
- val readingProgression: ReadingProgression? = null,
+ val readingProgression: ReadingProgression? = null
)
diff --git a/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumDocumentFragment.kt b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumDocumentFragment.kt
new file mode 100644
index 0000000000..21911e3ad9
--- /dev/null
+++ b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumDocumentFragment.kt
@@ -0,0 +1,197 @@
+/*
+ * Copyright 2022 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.adapter.pdfium.navigator
+
+import android.graphics.PointF
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.lifecycle.lifecycleScope
+import com.github.barteksc.pdfviewer.PDFView
+import kotlin.math.roundToInt
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import org.readium.adapter.pdfium.document.PdfiumDocumentFactory
+import org.readium.r2.navigator.pdf.PdfDocumentFragment
+import org.readium.r2.navigator.preferences.Axis
+import org.readium.r2.navigator.preferences.Fit
+import org.readium.r2.navigator.preferences.ReadingProgression
+import org.readium.r2.shared.ExperimentalReadiumApi
+import org.readium.r2.shared.publication.LocalizedString
+import org.readium.r2.shared.publication.Manifest
+import org.readium.r2.shared.publication.Metadata
+import org.readium.r2.shared.publication.Publication
+import org.readium.r2.shared.util.SingleJob
+import org.readium.r2.shared.util.Url
+import org.readium.r2.shared.util.data.ReadError
+import org.readium.r2.shared.util.getOrElse
+import org.readium.r2.shared.util.toDebugDescription
+import timber.log.Timber
+
+@ExperimentalReadiumApi
+public class PdfiumDocumentFragment internal constructor(
+ private val publication: Publication,
+ private val href: Url,
+ private val initialPageIndex: Int,
+ initialSettings: PdfiumSettings,
+ private val listener: Listener?
+) : PdfDocumentFragment() {
+
+ // Dummy constructor to address https://github.com/readium/kotlin-toolkit/issues/395
+ public constructor() : this(
+ publication = Publication(
+ manifest = Manifest(
+ metadata = Metadata(
+ identifier = "readium:dummy",
+ localizedTitle = LocalizedString("")
+ )
+ )
+ ),
+ href = Url("publication.pdf")!!,
+ initialPageIndex = 0,
+ initialSettings = PdfiumSettings(
+ fit = Fit.WIDTH,
+ pageSpacing = 0.0,
+ readingProgression = ReadingProgression.LTR,
+ scrollAxis = Axis.VERTICAL
+ ),
+ listener = null
+ )
+
+ internal interface Listener {
+ fun onResourceLoadFailed(href: Url, error: ReadError)
+ fun onConfigurePdfView(configurator: PDFView.Configurator)
+ fun onTap(point: PointF): Boolean
+ }
+
+ private lateinit var pdfView: PDFView
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View =
+ PDFView(inflater.context, null)
+ .also { pdfView = it }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ resetJob = SingleJob(viewLifecycleOwner.lifecycleScope)
+ reset(pageIndex = initialPageIndex)
+ }
+
+ private lateinit var resetJob: SingleJob
+
+ private fun reset(pageIndex: Int = _pageIndex.value) {
+ if (view == null) return
+ val context = context?.applicationContext ?: return
+
+ resetJob.launch {
+ val resource = requireNotNull(publication.get(href))
+ val document = PdfiumDocumentFactory(context)
+ // PDFium crashes when reusing the same PdfDocument, so we must not cache it.
+// .cachedIn(publication)
+ .open(resource, null)
+ .getOrElse { error ->
+ Timber.e(error.toDebugDescription())
+ listener?.onResourceLoadFailed(href, error)
+ return@launch
+ }
+
+ pageCount = document.pageCount
+ val page = convertPageIndexToView(pageIndex)
+
+ pdfView.recycle()
+ pdfView
+ .fromSource { _, _, _ -> document.document }
+ .apply {
+ if (isPagesOrderReversed) {
+ // AndroidPdfViewer doesn't support RTL. A workaround is to provide
+ // the explicit page list in the right order.
+ pages(*((pageCount - 1) downTo 0).toList().toIntArray())
+ }
+ }
+ .swipeHorizontal(settings.scrollAxis == Axis.HORIZONTAL)
+ .spacing(settings.pageSpacing.roundToInt())
+ // Customization of [PDFView] is done before setting the listeners,
+ // to avoid overriding them in reading apps, which would break the
+ // navigator.
+ .apply { listener?.onConfigurePdfView(this) }
+ .defaultPage(page)
+ .onRender { _, _, _ ->
+ if (settings.fit == Fit.WIDTH) {
+ pdfView.fitToWidth()
+ // Using `fitToWidth` often breaks the use of `defaultPage`, so we
+ // need to jump manually to the target page.
+ pdfView.jumpTo(page, false)
+ }
+ }
+ .onPageChange { index, _ ->
+ _pageIndex.value = convertPageIndexFromView(index)
+ }
+ .onTap { event ->
+ listener?.onTap(PointF(event.x, event.y)) ?: false
+ }
+ .load()
+ }
+ }
+
+ private var pageCount = 0
+
+ private val _pageIndex = MutableStateFlow(initialPageIndex)
+ override val pageIndex: StateFlow = _pageIndex.asStateFlow()
+
+ override fun goToPageIndex(index: Int, animated: Boolean): Boolean {
+ if (!isValidPageIndex(index)) {
+ return false
+ }
+ pdfView.jumpTo(convertPageIndexToView(index), animated)
+ return true
+ }
+
+ private fun isValidPageIndex(pageIndex: Int): Boolean {
+ val validRange = 0 until pageCount
+ return validRange.contains(pageIndex)
+ }
+
+ private fun convertPageIndexToView(page: Int): Int {
+ var index = (page - 1).coerceAtLeast(0)
+ if (isPagesOrderReversed) {
+ index = (pageCount - 1) - index
+ }
+ return index
+ }
+
+ private fun convertPageIndexFromView(index: Int): Int {
+ var page = index + 1
+ if (isPagesOrderReversed) {
+ page = (pageCount + 1) - page
+ }
+ return page
+ }
+
+ /**
+ * Indicates whether the order of the [PDFView] pages is reversed to take into account
+ * right-to-left reading progressions.
+ */
+ private val isPagesOrderReversed: Boolean get() =
+ settings.scrollAxis == Axis.HORIZONTAL && settings.readingProgression == ReadingProgression.RTL
+
+ private var settings: PdfiumSettings = initialSettings
+
+ override fun applySettings(settings: PdfiumSettings) {
+ if (this.settings == settings) {
+ return
+ }
+
+ this.settings = settings
+ reset()
+ }
+}
diff --git a/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumEngineProvider.kt b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumEngineProvider.kt
new file mode 100644
index 0000000000..5d8fe18863
--- /dev/null
+++ b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumEngineProvider.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2022 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.adapter.pdfium.navigator
+
+import android.graphics.PointF
+import com.github.barteksc.pdfviewer.PDFView
+import org.readium.r2.navigator.OverflowableNavigator
+import org.readium.r2.navigator.SimpleOverflow
+import org.readium.r2.navigator.input.TapEvent
+import org.readium.r2.navigator.pdf.PdfDocumentFragmentInput
+import org.readium.r2.navigator.pdf.PdfEngineProvider
+import org.readium.r2.navigator.util.SingleFragmentFactory
+import org.readium.r2.navigator.util.createFragmentFactory
+import org.readium.r2.shared.ExperimentalReadiumApi
+import org.readium.r2.shared.publication.Metadata
+import org.readium.r2.shared.publication.Publication
+import org.readium.r2.shared.util.Url
+import org.readium.r2.shared.util.data.ReadError
+
+/**
+ * Main component to use the PDF navigator with the PDFium adapter.
+ *
+ * Provide [PdfiumDefaults] to customize the default values that will be used by
+ * the navigator for some preferences.
+ */
+@ExperimentalReadiumApi
+public class PdfiumEngineProvider(
+ private val defaults: PdfiumDefaults = PdfiumDefaults(),
+ private val listener: Listener? = null
+) : PdfEngineProvider {
+
+ public interface Listener : PdfEngineProvider.Listener {
+
+ /** Called when configuring [PDFView]. */
+ public fun onConfigurePdfView(configurator: PDFView.Configurator) {}
+ }
+
+ override fun createDocumentFragmentFactory(
+ input: PdfDocumentFragmentInput
+ ): SingleFragmentFactory =
+ createFragmentFactory {
+ PdfiumDocumentFragment(
+ publication = input.publication,
+ href = input.href,
+ initialPageIndex = input.pageIndex,
+ initialSettings = input.settings,
+ listener = object : PdfiumDocumentFragment.Listener {
+ override fun onResourceLoadFailed(href: Url, error: ReadError) {
+ input.navigatorListener?.onResourceLoadFailed(href, error)
+ }
+
+ override fun onConfigurePdfView(configurator: PDFView.Configurator) {
+ listener?.onConfigurePdfView(configurator)
+ }
+
+ override fun onTap(point: PointF): Boolean =
+ input.inputListener?.onTap(TapEvent(point)) ?: false
+ }
+ )
+ }
+
+ override fun computeSettings(metadata: Metadata, preferences: PdfiumPreferences): PdfiumSettings {
+ val settingsPolicy = PdfiumSettingsResolver(metadata, defaults)
+ return settingsPolicy.settings(preferences)
+ }
+
+ override fun computeOverflow(settings: PdfiumSettings): OverflowableNavigator.Overflow =
+ SimpleOverflow(
+ readingProgression = settings.readingProgression,
+ scroll = true,
+ axis = settings.scrollAxis
+ )
+
+ override fun createPreferenceEditor(
+ publication: Publication,
+ initialPreferences: PdfiumPreferences
+ ): PdfiumPreferencesEditor =
+ PdfiumPreferencesEditor(
+ initialPreferences,
+ publication.metadata,
+ defaults
+ )
+
+ override fun createEmptyPreferences(): PdfiumPreferences =
+ PdfiumPreferences()
+}
diff --git a/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumNavigator.kt b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumNavigator.kt
new file mode 100644
index 0000000000..2086be435c
--- /dev/null
+++ b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumNavigator.kt
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2023 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.adapter.pdfium.navigator
+
+import org.readium.r2.navigator.pdf.PdfNavigatorFactory
+import org.readium.r2.navigator.pdf.PdfNavigatorFragment
+import org.readium.r2.shared.ExperimentalReadiumApi
+
+@ExperimentalReadiumApi
+public typealias PdfiumNavigatorFragment = PdfNavigatorFragment
+
+@ExperimentalReadiumApi
+public typealias PdfiumNavigatorFactory = PdfNavigatorFactory
diff --git a/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumPreferences.kt b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumPreferences.kt
similarity index 85%
rename from readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumPreferences.kt
rename to readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumPreferences.kt
index 951d56c58f..076ab3c43d 100644
--- a/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumPreferences.kt
+++ b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumPreferences.kt
@@ -4,7 +4,7 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.adapters.pdfium.navigator
+package org.readium.adapter.pdfium.navigator
import kotlinx.serialization.Serializable
import org.readium.r2.navigator.preferences.Axis
@@ -23,11 +23,11 @@ import org.readium.r2.shared.ExperimentalReadiumApi
*/
@ExperimentalReadiumApi
@Serializable
-data class PdfiumPreferences(
+public data class PdfiumPreferences(
val fit: Fit? = null,
val pageSpacing: Double? = null,
val readingProgression: ReadingProgression? = null,
- val scrollAxis: Axis? = null,
+ val scrollAxis: Axis? = null
) : Configurable.Preferences {
init {
@@ -35,11 +35,11 @@ data class PdfiumPreferences(
require(pageSpacing == null || pageSpacing >= 0)
}
- override operator fun plus(other: PdfiumPreferences) =
+ override operator fun plus(other: PdfiumPreferences): PdfiumPreferences =
PdfiumPreferences(
fit = other.fit ?: fit,
pageSpacing = other.pageSpacing ?: pageSpacing,
readingProgression = other.readingProgression ?: readingProgression,
- scrollAxis = other.scrollAxis ?: scrollAxis,
+ scrollAxis = other.scrollAxis ?: scrollAxis
)
}
diff --git a/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumPreferencesEditor.kt b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumPreferencesEditor.kt
similarity index 86%
rename from readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumPreferencesEditor.kt
rename to readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumPreferencesEditor.kt
index c4d0b2b768..fefd7eeedd 100644
--- a/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumPreferencesEditor.kt
+++ b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumPreferencesEditor.kt
@@ -4,7 +4,7 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.adapters.pdfium.navigator
+package org.readium.adapter.pdfium.navigator
import org.readium.r2.navigator.extensions.format
import org.readium.r2.navigator.preferences.*
@@ -19,10 +19,10 @@ import org.readium.r2.shared.publication.Metadata
* or ranges.
*/
@ExperimentalReadiumApi
-class PdfiumPreferencesEditor internal constructor(
+public class PdfiumPreferencesEditor internal constructor(
initialPreferences: PdfiumPreferences,
publicationMetadata: Metadata,
- defaults: PdfiumDefaults,
+ defaults: PdfiumDefaults
) : PreferencesEditor {
private data class State(
@@ -49,19 +49,19 @@ class PdfiumPreferencesEditor internal constructor(
/**
* Indicates how pages should be laid out within the viewport.
*/
- val fit: EnumPreference =
+ public val fit: EnumPreference =
EnumPreferenceDelegate(
getValue = { preferences.fit },
getEffectiveValue = { state.settings.fit },
getIsEffective = { true },
updateValue = { value -> updateValues { it.copy(fit = value) } },
- supportedValues = listOf(Fit.CONTAIN, Fit.WIDTH),
+ supportedValues = listOf(Fit.CONTAIN, Fit.WIDTH)
)
/**
* Space between pages in dp.
*/
- val pageSpacing: RangePreference =
+ public val pageSpacing: RangePreference =
RangePreferenceDelegate(
getValue = { preferences.pageSpacing },
getEffectiveValue = { state.settings.pageSpacing },
@@ -69,31 +69,31 @@ class PdfiumPreferencesEditor internal constructor(
updateValue = { value -> updateValues { it.copy(pageSpacing = value) } },
supportedRange = 0.0..50.0,
progressionStrategy = DoubleIncrement(5.0),
- valueFormatter = { "${it.format(1)} dp" },
+ valueFormatter = { "${it.format(1)} dp" }
)
/**
* Direction of the horizontal progression across pages.
*/
- val readingProgression: EnumPreference =
+ public val readingProgression: EnumPreference =
EnumPreferenceDelegate(
getValue = { preferences.readingProgression },
getEffectiveValue = { state.settings.readingProgression },
getIsEffective = { true },
updateValue = { value -> updateValues { it.copy(readingProgression = value) } },
- supportedValues = listOf(ReadingProgression.LTR, ReadingProgression.RTL),
+ supportedValues = listOf(ReadingProgression.LTR, ReadingProgression.RTL)
)
/**
* Indicates the axis along which pages should be laid out in scroll mode.
*/
- val scrollAxis: EnumPreference =
+ public val scrollAxis: EnumPreference =
EnumPreferenceDelegate(
getValue = { preferences.scrollAxis },
getEffectiveValue = { state.settings.scrollAxis },
getIsEffective = { true },
updateValue = { value -> updateValues { it.copy(scrollAxis = value) } },
- supportedValues = listOf(Axis.VERTICAL, Axis.HORIZONTAL),
+ supportedValues = listOf(Axis.VERTICAL, Axis.HORIZONTAL)
)
private fun updateValues(updater: (PdfiumPreferences) -> PdfiumPreferences) {
diff --git a/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumPreferencesFilters.kt b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumPreferencesFilters.kt
similarity index 69%
rename from readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumPreferencesFilters.kt
rename to readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumPreferencesFilters.kt
index 75a1dc5f93..e1898d4967 100644
--- a/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumPreferencesFilters.kt
+++ b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumPreferencesFilters.kt
@@ -4,7 +4,7 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.adapters.pdfium.navigator
+package org.readium.adapter.pdfium.navigator
import org.readium.r2.navigator.preferences.PreferencesFilter
import org.readium.r2.shared.ExperimentalReadiumApi
@@ -13,11 +13,11 @@ import org.readium.r2.shared.ExperimentalReadiumApi
* Suggested filter to keep only shared [PdfiumPreferences].
*/
@ExperimentalReadiumApi
-object PdfiumSharedPreferencesFilter : PreferencesFilter {
+public object PdfiumSharedPreferencesFilter : PreferencesFilter {
override fun filter(preferences: PdfiumPreferences): PdfiumPreferences =
preferences.copy(
- readingProgression = null,
+ readingProgression = null
)
}
@@ -25,10 +25,10 @@ object PdfiumSharedPreferencesFilter : PreferencesFilter {
* Suggested filter to keep only publication-specific [PdfiumPreferences].
*/
@ExperimentalReadiumApi
-object PdfiumPublicationPreferencesFilter : PreferencesFilter {
+public object PdfiumPublicationPreferencesFilter : PreferencesFilter {
override fun filter(preferences: PdfiumPreferences): PdfiumPreferences =
PdfiumPreferences(
- readingProgression = preferences.readingProgression,
+ readingProgression = preferences.readingProgression
)
}
diff --git a/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumPreferencesSerializer.kt b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumPreferencesSerializer.kt
similarity index 84%
rename from readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumPreferencesSerializer.kt
rename to readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumPreferencesSerializer.kt
index 8353a636d4..f944732e17 100644
--- a/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumPreferencesSerializer.kt
+++ b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumPreferencesSerializer.kt
@@ -4,7 +4,7 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.adapters.pdfium.navigator
+package org.readium.adapter.pdfium.navigator
import kotlinx.serialization.json.Json
import org.readium.r2.navigator.preferences.PreferencesSerializer
@@ -14,7 +14,7 @@ import org.readium.r2.shared.ExperimentalReadiumApi
* JSON serializer of [PdfiumPreferences].
*/
@ExperimentalReadiumApi
-class PdfiumPreferencesSerializer : PreferencesSerializer {
+public class PdfiumPreferencesSerializer : PreferencesSerializer {
override fun serialize(preferences: PdfiumPreferences): String =
Json.encodeToString(PdfiumPreferences.serializer(), preferences)
diff --git a/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumSettings.kt b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumSettings.kt
similarity index 83%
rename from readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumSettings.kt
rename to readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumSettings.kt
index dc83987411..0c8a56af5d 100644
--- a/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumSettings.kt
+++ b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumSettings.kt
@@ -4,7 +4,7 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.adapters.pdfium.navigator
+package org.readium.adapter.pdfium.navigator
import org.readium.r2.navigator.preferences.*
import org.readium.r2.shared.ExperimentalReadiumApi
@@ -15,9 +15,9 @@ import org.readium.r2.shared.ExperimentalReadiumApi
* @see PdfiumPreferences
*/
@ExperimentalReadiumApi
-data class PdfiumSettings(
+public data class PdfiumSettings(
val fit: Fit,
val pageSpacing: Double,
val readingProgression: ReadingProgression,
- val scrollAxis: Axis,
+ val scrollAxis: Axis
) : Configurable.Settings
diff --git a/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumSettingsResolver.kt b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumSettingsResolver.kt
similarity index 95%
rename from readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumSettingsResolver.kt
rename to readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumSettingsResolver.kt
index cc821d6a0c..366f21289f 100644
--- a/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumSettingsResolver.kt
+++ b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapter/pdfium/navigator/PdfiumSettingsResolver.kt
@@ -4,7 +4,7 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.adapters.pdfium.navigator
+package org.readium.adapter.pdfium.navigator
import org.readium.r2.navigator.preferences.Axis
import org.readium.r2.navigator.preferences.Fit
@@ -48,7 +48,7 @@ internal class PdfiumSettingsResolver(
fit = fit,
pageSpacing = pageSpacing,
readingProgression = readingProgression,
- scrollAxis = scrollAxis,
+ scrollAxis = scrollAxis
)
}
}
diff --git a/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapters/pdfium/navigator/Deprecated.kt b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapters/pdfium/navigator/Deprecated.kt
new file mode 100644
index 0000000000..4c2109f269
--- /dev/null
+++ b/readium/adapters/pdfium/navigator/src/main/java/org/readium/adapters/pdfium/navigator/Deprecated.kt
@@ -0,0 +1,82 @@
+@file:OptIn(ExperimentalReadiumApi::class)
+
+package org.readium.adapters.pdfium.navigator
+
+import org.readium.r2.shared.ExperimentalReadiumApi
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pdfium.navigator.PdfiumEngineProvider"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PdfiumEngineProvider = org.readium.adapter.pdfium.navigator.PdfiumEngineProvider
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pdfium.navigator.PdfiumDefaults"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PdfiumDefaults = org.readium.adapter.pdfium.navigator.PdfiumDefaults
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pdfium.navigator.PdfiumPreferences"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PdfiumPreferences = org.readium.adapter.pdfium.navigator.PdfiumPreferences
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pdfium.navigator.PdfiumPreferencesEditor"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PdfiumPreferencesEditor = org.readium.adapter.pdfium.navigator.PdfiumPreferencesEditor
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pdfium.navigator.PdfiumSettings"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PdfiumSettings = org.readium.adapter.pdfium.navigator.PdfiumSettings
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pdfium.navigator.PdfiumDocumentFragment"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PdfiumDocumentFragment = org.readium.adapter.pdfium.navigator.PdfiumDocumentFragment
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pdfium.navigator.PdfiumNavigatorFactory"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PdfiumNavigatorFactory = org.readium.adapter.pdfium.navigator.PdfiumNavigatorFactory
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pdfium.navigator.PdfiumNavigatorFragment"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PdfiumNavigatorFragment = org.readium.adapter.pdfium.navigator.PdfiumNavigatorFragment
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pdfium.navigator.PdfiumPreferencesSerializer"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PdfiumPreferencesSerializer = org.readium.adapter.pdfium.navigator.PdfiumPreferencesSerializer
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pdfium.navigator.PdfiumPublicationPreferencesFilter"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PdfiumPublicationPreferencesFilter = org.readium.adapter.pdfium.navigator.PdfiumPublicationPreferencesFilter
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pdfium.navigator.PdfiumSharedPreferencesFilter"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PdfiumSharedPreferencesFilter = org.readium.adapter.pdfium.navigator.PdfiumSharedPreferencesFilter
diff --git a/readium/adapters/pdfium/pdfium-navigator/src/main/res/layout/readium_pspdfkit_fragment.xml b/readium/adapters/pdfium/navigator/src/main/res/layout/readium_pspdfkit_fragment.xml
similarity index 100%
rename from readium/adapters/pdfium/pdfium-navigator/src/main/res/layout/readium_pspdfkit_fragment.xml
rename to readium/adapters/pdfium/navigator/src/main/res/layout/readium_pspdfkit_fragment.xml
diff --git a/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumDocumentFragment.kt b/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumDocumentFragment.kt
deleted file mode 100644
index d8248eccfc..0000000000
--- a/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumDocumentFragment.kt
+++ /dev/null
@@ -1,210 +0,0 @@
-/*
- * Copyright 2022 Readium Foundation. All rights reserved.
- * Use of this source code is governed by the BSD-style license
- * available in the top-level LICENSE file of the project.
- */
-
-package org.readium.adapters.pdfium.navigator
-
-import android.graphics.PointF
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.lifecycle.lifecycleScope
-import com.github.barteksc.pdfviewer.PDFView
-import kotlin.math.roundToInt
-import kotlinx.coroutines.launch
-import org.readium.adapters.pdfium.document.PdfiumDocumentFactory
-import org.readium.r2.navigator.pdf.PdfDocumentFragment
-import org.readium.r2.navigator.preferences.Axis
-import org.readium.r2.navigator.preferences.Fit
-import org.readium.r2.navigator.preferences.ReadingProgression
-import org.readium.r2.shared.ExperimentalReadiumApi
-import org.readium.r2.shared.fetcher.Resource
-import org.readium.r2.shared.publication.Link
-import org.readium.r2.shared.publication.LocalizedString
-import org.readium.r2.shared.publication.Manifest
-import org.readium.r2.shared.publication.Metadata
-import org.readium.r2.shared.publication.Publication
-import timber.log.Timber
-
-@ExperimentalReadiumApi
-class PdfiumDocumentFragment internal constructor(
- private val publication: Publication,
- private val link: Link,
- private val initialPageIndex: Int,
- settings: PdfiumSettings,
- private val appListener: Listener?,
- private val navigatorListener: PdfDocumentFragment.Listener?
-) : PdfDocumentFragment() {
-
- // Dummy constructor to address https://github.com/readium/kotlin-toolkit/issues/395
- constructor() : this(
- publication = Publication(
- manifest = Manifest(
- metadata = Metadata(
- identifier = "readium:dummy",
- localizedTitle = LocalizedString("")
- )
- )
- ),
- link = Link(href = "publication.pdf", type = "application/pdf"),
- initialPageIndex = 0,
- settings = PdfiumSettings(
- fit = Fit.WIDTH,
- pageSpacing = 0.0,
- readingProgression = ReadingProgression.LTR,
- scrollAxis = Axis.VERTICAL
- ),
- appListener = null,
- navigatorListener = null
- )
-
- interface Listener {
- /** Called when configuring [PDFView]. */
- fun onConfigurePdfView(configurator: PDFView.Configurator) {}
- }
-
- override var settings: PdfiumSettings = settings
- set(value) {
- if (field == value) return
-
- val page = pageIndex
- field = value
- reloadDocumentAtPage(page)
- }
-
- private lateinit var pdfView: PDFView
-
- private var isReloading: Boolean = false
- private var hasToReload: Int? = null
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View =
- PDFView(inflater.context, null)
- .also { pdfView = it }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- reloadDocumentAtPage(pageIndex)
- }
-
- private fun reloadDocumentAtPage(pageIndex: Int) {
- if (isReloading) {
- hasToReload = pageIndex
- return
- }
-
- isReloading = true
-
- val context = context?.applicationContext ?: return
-
- viewLifecycleOwner.lifecycleScope.launch {
-
- try {
- val document = PdfiumDocumentFactory(context)
- // PDFium crashes when reusing the same PdfDocument, so we must not cache it.
-// .cachedIn(publication)
- .open(publication.get(link), null)
-
- pageCount = document.pageCount
- val page = convertPageIndexToView(pageIndex)
-
- pdfView.recycle()
- pdfView
- .fromSource { _, _, _ -> document.document }
- .apply {
- if (isPagesOrderReversed) {
- // AndroidPdfViewer doesn't support RTL. A workaround is to provide
- // the explicit page list in the right order.
- pages(*((pageCount - 1) downTo 0).toList().toIntArray())
- }
- }
- .swipeHorizontal(settings.scrollAxis == Axis.HORIZONTAL)
- .spacing(settings.pageSpacing.roundToInt())
- // Customization of [PDFView] is done before setting the listeners,
- // to avoid overriding them in reading apps, which would break the
- // navigator.
- .apply { appListener?.onConfigurePdfView(this) }
- .defaultPage(page)
- .onRender { _, _, _ ->
- if (settings.fit == Fit.WIDTH) {
- pdfView.fitToWidth()
- // Using `fitToWidth` often breaks the use of `defaultPage`, so we
- // need to jump manually to the target page.
- pdfView.jumpTo(page, false)
- }
- }
- .onLoad {
- val hasToReloadNow = hasToReload
- if (hasToReloadNow != null) {
- reloadDocumentAtPage(pageIndex)
- } else {
- isReloading = false
- }
- }
- .onPageChange { index, _ ->
- navigatorListener?.onPageChanged(convertPageIndexFromView(index))
- }
- .onTap { event ->
- navigatorListener?.onTap(PointF(event.x, event.y))
- ?: false
- }
- .load()
- } catch (e: Exception) {
- val error = Resource.Exception.wrap(e)
- Timber.e(error)
- navigatorListener?.onResourceLoadFailed(link, error)
- }
- }
- }
-
- override val pageIndex: Int get() = viewPageIndex ?: initialPageIndex
-
- private val viewPageIndex: Int? get() =
- if (pdfView.isRecycled) null
- else convertPageIndexFromView(pdfView.currentPage)
-
- override fun goToPageIndex(index: Int, animated: Boolean): Boolean {
- if (!isValidPageIndex(index)) {
- return false
- }
- pdfView.jumpTo(convertPageIndexToView(index), animated)
- return true
- }
-
- private var pageCount = 0
-
- private fun isValidPageIndex(pageIndex: Int): Boolean {
- val validRange = 0 until pageCount
- return validRange.contains(pageIndex)
- }
-
- private fun convertPageIndexToView(page: Int): Int {
- var index = (page - 1).coerceAtLeast(0)
- if (isPagesOrderReversed) {
- index = (pageCount - 1) - index
- }
- return index
- }
-
- private fun convertPageIndexFromView(index: Int): Int {
- var page = index + 1
- if (isPagesOrderReversed) {
- page = (pageCount + 1) - page
- }
- return page
- }
-
- /**
- * Indicates whether the order of the [PDFView] pages is reversed to take into account
- * right-to-left reading progressions.
- */
- private val isPagesOrderReversed: Boolean get() =
- settings.scrollAxis == Axis.HORIZONTAL &&
- settings.readingProgression == ReadingProgression.RTL
-}
diff --git a/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumEngineProvider.kt b/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumEngineProvider.kt
deleted file mode 100644
index 9f2d1c5e1d..0000000000
--- a/readium/adapters/pdfium/pdfium-navigator/src/main/java/org/readium/adapters/pdfium/navigator/PdfiumEngineProvider.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * Copyright 2022 Readium Foundation. All rights reserved.
- * Use of this source code is governed by the BSD-style license
- * available in the top-level LICENSE file of the project.
- */
-
-package org.readium.adapters.pdfium.navigator
-
-import org.readium.r2.navigator.SimplePresentation
-import org.readium.r2.navigator.VisualNavigator
-import org.readium.r2.navigator.pdf.PdfDocumentFragmentInput
-import org.readium.r2.navigator.pdf.PdfEngineProvider
-import org.readium.r2.shared.ExperimentalReadiumApi
-import org.readium.r2.shared.publication.Metadata
-import org.readium.r2.shared.publication.Publication
-
-/**
- * Main component to use the PDF navigator with the PDFium adapter.
- *
- * Provide [PdfiumDefaults] to customize the default values that will be used by
- * the navigator for some preferences.
- */
-@ExperimentalReadiumApi
-class PdfiumEngineProvider(
- private val listener: PdfiumDocumentFragment.Listener? = null,
- private val defaults: PdfiumDefaults = PdfiumDefaults()
-) : PdfEngineProvider {
-
- override suspend fun createDocumentFragment(input: PdfDocumentFragmentInput) =
- PdfiumDocumentFragment(
- publication = input.publication,
- link = input.link,
- initialPageIndex = input.initialPageIndex,
- settings = input.settings,
- appListener = listener,
- navigatorListener = input.listener
- )
-
- override fun computeSettings(metadata: Metadata, preferences: PdfiumPreferences): PdfiumSettings {
- val settingsPolicy = PdfiumSettingsResolver(metadata, defaults)
- return settingsPolicy.settings(preferences)
- }
-
- override fun computePresentation(settings: PdfiumSettings): VisualNavigator.Presentation =
- SimplePresentation(
- readingProgression = settings.readingProgression,
- scroll = true,
- axis = settings.scrollAxis
- )
-
- override fun createPreferenceEditor(
- publication: Publication,
- initialPreferences: PdfiumPreferences
- ): PdfiumPreferencesEditor =
- PdfiumPreferencesEditor(
- initialPreferences,
- publication.metadata,
- defaults
- )
-
- override fun createEmptyPreferences(): PdfiumPreferences =
- PdfiumPreferences()
-}
diff --git a/readium/adapters/pspdfkit/build.gradle.kts b/readium/adapters/pspdfkit/build.gradle.kts
index e09623883c..30ba3d3e8b 100644
--- a/readium/adapters/pspdfkit/build.gradle.kts
+++ b/readium/adapters/pspdfkit/build.gradle.kts
@@ -13,19 +13,19 @@ plugins {
android {
resourcePrefix = "readium_"
- compileSdk = 33
+ compileSdk = 34
defaultConfig {
minSdk = 21
- targetSdk = 33
+ targetSdk = 34
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
- jvmTarget = "1.8"
+ jvmTarget = JavaVersion.VERSION_17.toString()
freeCompilerArgs = freeCompilerArgs + listOf(
"-opt-in=kotlin.RequiresOptIn",
"-opt-in=org.readium.r2.shared.InternalReadiumApi"
@@ -37,7 +37,11 @@ android {
proguardFiles(getDefaultProguardFile("proguard-android.txt"))
}
}
- namespace = "org.readium.adapters.pspdfkit"
+ namespace = "org.readium.adapter.pspdfkit"
+}
+
+kotlin {
+ explicitApi()
}
rootProject.ext["publish.artifactId"] = "readium-adapter-pspdfkit"
diff --git a/readium/adapters/pspdfkit/pspdfkit-document/build.gradle.kts b/readium/adapters/pspdfkit/document/build.gradle.kts
similarity index 82%
rename from readium/adapters/pspdfkit/pspdfkit-document/build.gradle.kts
rename to readium/adapters/pspdfkit/document/build.gradle.kts
index a8ab144c29..84322e6426 100644
--- a/readium/adapters/pspdfkit/pspdfkit-document/build.gradle.kts
+++ b/readium/adapters/pspdfkit/document/build.gradle.kts
@@ -13,19 +13,19 @@ plugins {
android {
resourcePrefix = "readium_"
- compileSdk = 33
+ compileSdk = 34
defaultConfig {
minSdk = 21
- targetSdk = 33
+ targetSdk = 34
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
- jvmTarget = "1.8"
+ jvmTarget = JavaVersion.VERSION_17.toString()
freeCompilerArgs = freeCompilerArgs + listOf(
"-opt-in=kotlin.RequiresOptIn",
"-opt-in=org.readium.r2.shared.InternalReadiumApi"
@@ -40,7 +40,11 @@ android {
buildFeatures {
viewBinding = true
}
- namespace = "org.readium.adapters.pspdfkit.document"
+ namespace = "org.readium.adapter.pspdfkit.document"
+}
+
+kotlin {
+ explicitApi()
}
rootProject.ext["publish.artifactId"] = "readium-adapter-pspdfkit-document"
diff --git a/readium/adapters/pspdfkit/document/src/main/AndroidManifest.xml b/readium/adapters/pspdfkit/document/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..2d10029868
--- /dev/null
+++ b/readium/adapters/pspdfkit/document/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/readium/adapters/pspdfkit/pspdfkit-document/src/main/java/org/readium/adapters/pspdfkit/document/PsPdfKitDocument.kt b/readium/adapters/pspdfkit/document/src/main/java/org/readium/adapter/pspdfkit/document/PsPdfKitDocument.kt
similarity index 69%
rename from readium/adapters/pspdfkit/pspdfkit-document/src/main/java/org/readium/adapters/pspdfkit/document/PsPdfKitDocument.kt
rename to readium/adapters/pspdfkit/document/src/main/java/org/readium/adapter/pspdfkit/document/PsPdfKitDocument.kt
index 2e231dcf9e..102a79b539 100644
--- a/readium/adapters/pspdfkit/pspdfkit-document/src/main/java/org/readium/adapters/pspdfkit/document/PsPdfKitDocument.kt
+++ b/readium/adapters/pspdfkit/document/src/main/java/org/readium/adapter/pspdfkit/document/PsPdfKitDocument.kt
@@ -4,46 +4,56 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.adapters.pspdfkit.document
+package org.readium.adapter.pspdfkit.document
import android.content.Context
import android.graphics.Bitmap
-import androidx.core.net.toUri
import com.pspdfkit.annotations.actions.GoToAction
import com.pspdfkit.document.DocumentSource
import com.pspdfkit.document.OutlineElement
import com.pspdfkit.document.PageBinding
import com.pspdfkit.document.PdfDocument as _PsPdfKitDocument
import com.pspdfkit.document.PdfDocumentLoader
-import java.io.File
+import com.pspdfkit.exceptions.InvalidPasswordException
+import com.pspdfkit.exceptions.InvalidSignatureException
+import java.io.IOException
import kotlin.reflect.KClass
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
-import org.readium.r2.shared.fetcher.Resource
import org.readium.r2.shared.publication.ReadingProgression
+import org.readium.r2.shared.util.ThrowableError
+import org.readium.r2.shared.util.Try
+import org.readium.r2.shared.util.data.ReadError
+import org.readium.r2.shared.util.data.ReadTry
import org.readium.r2.shared.util.pdf.PdfDocument
import org.readium.r2.shared.util.pdf.PdfDocumentFactory
+import org.readium.r2.shared.util.resource.Resource
import timber.log.Timber
-class PsPdfKitDocumentFactory(context: Context) : PdfDocumentFactory {
+public class PsPdfKitDocumentFactory(context: Context) : PdfDocumentFactory {
private val context = context.applicationContext
override val documentType: KClass = PsPdfKitDocument::class
- override suspend fun open(file: File, password: String?): PsPdfKitDocument =
- open(context, DocumentSource(file.toUri(), password))
-
- override suspend fun open(resource: Resource, password: String?): PsPdfKitDocument =
- open(context, DocumentSource(ResourceDataProvider(resource), password))
-
- private suspend fun open(context: Context, documentSource: DocumentSource): PsPdfKitDocument =
+ override suspend fun open(resource: Resource, password: String?): ReadTry =
withContext(Dispatchers.IO) {
- PsPdfKitDocument(PdfDocumentLoader.openDocument(context, documentSource))
+ val dataProvider = ResourceDataProvider(resource)
+ val documentSource = DocumentSource(dataProvider, password)
+ try {
+ val innerDocument = PdfDocumentLoader.openDocument(context, documentSource)
+ Try.success(PsPdfKitDocument(innerDocument))
+ } catch (e: InvalidPasswordException) {
+ Try.failure(ReadError.Decoding(ThrowableError(e)))
+ } catch (e: InvalidSignatureException) {
+ Try.failure(ReadError.Decoding(ThrowableError(e)))
+ } catch (e: IOException) {
+ Try.failure(dataProvider.error!!)
+ }
}
}
-class PsPdfKitDocument(
- val document: _PsPdfKitDocument
+public class PsPdfKitDocument(
+ public val document: _PsPdfKitDocument
) : PdfDocument {
// FIXME: Doesn't seem to be exposed by PSPDFKit.
diff --git a/readium/adapters/pspdfkit/pspdfkit-document/src/main/java/org/readium/adapters/pspdfkit/document/ResourceDataProvider.kt b/readium/adapters/pspdfkit/document/src/main/java/org/readium/adapter/pspdfkit/document/ResourceDataProvider.kt
similarity index 64%
rename from readium/adapters/pspdfkit/pspdfkit-document/src/main/java/org/readium/adapters/pspdfkit/document/ResourceDataProvider.kt
rename to readium/adapters/pspdfkit/document/src/main/java/org/readium/adapter/pspdfkit/document/ResourceDataProvider.kt
index fcef729023..65e881f429 100644
--- a/readium/adapters/pspdfkit/pspdfkit-document/src/main/java/org/readium/adapters/pspdfkit/document/ResourceDataProvider.kt
+++ b/readium/adapters/pspdfkit/document/src/main/java/org/readium/adapter/pspdfkit/document/ResourceDataProvider.kt
@@ -4,32 +4,39 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.adapters.pspdfkit.document
+package org.readium.adapter.pspdfkit.document
import com.pspdfkit.document.providers.DataProvider
-import java.util.*
+import java.util.UUID
import kotlinx.coroutines.runBlocking
-import org.readium.r2.shared.fetcher.Resource
-import org.readium.r2.shared.fetcher.synchronized
+import org.readium.r2.shared.util.data.ReadError
import org.readium.r2.shared.util.getOrElse
import org.readium.r2.shared.util.isLazyInitialized
+import org.readium.r2.shared.util.resource.Resource
+import org.readium.r2.shared.util.resource.synchronized
+import org.readium.r2.shared.util.toDebugDescription
import timber.log.Timber
-class ResourceDataProvider(
+internal class ResourceDataProvider(
resource: Resource,
- private val onResourceError: (Resource.Exception) -> Unit = { Timber.e(it) }
+ private val onResourceError: (ReadError) -> Unit = { Timber.e(it.toDebugDescription()) }
) : DataProvider {
+ var error: ReadError? = null
+
private val resource =
// PSPDFKit accesses the resource from multiple threads.
resource.synchronized()
- private val length: Long = runBlocking {
- resource.length()
- .getOrElse {
- onResourceError(it)
- DataProvider.FILE_SIZE_UNKNOWN.toLong()
- }
+ private val length by lazy {
+ runBlocking {
+ resource.length()
+ .getOrElse {
+ error = it
+ onResourceError(it)
+ DataProvider.FILE_SIZE_UNKNOWN.toLong()
+ }
+ }
}
override fun getSize(): Long = length
@@ -47,6 +54,7 @@ class ResourceDataProvider(
val range = offset until (offset + size)
resource.read(range)
.getOrElse {
+ error = it
onResourceError(it)
DataProvider.NO_DATA_AVAILABLE
}
@@ -54,6 +62,7 @@ class ResourceDataProvider(
override fun release() {
if (::resource.isLazyInitialized) {
+ error = null
runBlocking { resource.close() }
}
}
diff --git a/readium/adapters/pspdfkit/document/src/main/java/org/readium/adapters/pspdfkit/document/Deprecated.kt b/readium/adapters/pspdfkit/document/src/main/java/org/readium/adapters/pspdfkit/document/Deprecated.kt
new file mode 100644
index 0000000000..d66a1b220b
--- /dev/null
+++ b/readium/adapters/pspdfkit/document/src/main/java/org/readium/adapters/pspdfkit/document/Deprecated.kt
@@ -0,0 +1,15 @@
+package org.readium.adapters.pspdfkit.document
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pspdfkit.document.PsPdfKitDocument"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PsPdfKitDocument = org.readium.adapter.pspdfkit.document.PsPdfKitDocument
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pspdfkit.document.PsPdfKitDocumentFactory"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PsPdfKitDocumentFactory = org.readium.adapter.pspdfkit.document.PsPdfKitDocumentFactory
diff --git a/readium/adapters/pspdfkit/pspdfkit-navigator/build.gradle.kts b/readium/adapters/pspdfkit/navigator/build.gradle.kts
similarity index 84%
rename from readium/adapters/pspdfkit/pspdfkit-navigator/build.gradle.kts
rename to readium/adapters/pspdfkit/navigator/build.gradle.kts
index 0b9a53a6e3..98e163027d 100644
--- a/readium/adapters/pspdfkit/pspdfkit-navigator/build.gradle.kts
+++ b/readium/adapters/pspdfkit/navigator/build.gradle.kts
@@ -14,19 +14,19 @@ plugins {
android {
resourcePrefix = "readium_"
- compileSdk = 33
+ compileSdk = 34
defaultConfig {
minSdk = 21
- targetSdk = 33
+ targetSdk = 34
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
- jvmTarget = "1.8"
+ jvmTarget = JavaVersion.VERSION_17.toString()
freeCompilerArgs = freeCompilerArgs + listOf(
"-opt-in=kotlin.RequiresOptIn",
"-opt-in=org.readium.r2.shared.InternalReadiumApi"
@@ -41,7 +41,11 @@ android {
buildFeatures {
viewBinding = true
}
- namespace = "org.readium.adapters.pspdfkit.navigator"
+ namespace = "org.readium.adapter.pspdfkit.navigator"
+}
+
+kotlin {
+ explicitApi()
}
rootProject.ext["publish.artifactId"] = "readium-adapter-pspdfkit-navigator"
diff --git a/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/AndroidManifest.xml b/readium/adapters/pspdfkit/navigator/src/main/AndroidManifest.xml
similarity index 100%
rename from readium/adapters/pspdfkit/pspdfkit-navigator/src/main/AndroidManifest.xml
rename to readium/adapters/pspdfkit/navigator/src/main/AndroidManifest.xml
diff --git a/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitDefaults.kt b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitDefaults.kt
similarity index 86%
rename from readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitDefaults.kt
rename to readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitDefaults.kt
index e29906b6b7..2f1cc0730c 100644
--- a/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitDefaults.kt
+++ b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitDefaults.kt
@@ -4,7 +4,7 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.adapters.pspdfkit.navigator
+package org.readium.adapter.pspdfkit.navigator
import org.readium.r2.navigator.preferences.ReadingProgression
import org.readium.r2.navigator.preferences.Spread
@@ -18,10 +18,10 @@ import org.readium.r2.shared.ExperimentalReadiumApi
* @see PsPdfKitPreferences
*/
@ExperimentalReadiumApi
-data class PsPdfKitDefaults(
+public data class PsPdfKitDefaults(
val offsetFirstPage: Boolean? = null,
val pageSpacing: Double? = null,
val readingProgression: ReadingProgression? = null,
val scroll: Boolean? = null,
- val spread: Spread? = null,
+ val spread: Spread? = null
)
diff --git a/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitDocumentFragment.kt b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitDocumentFragment.kt
similarity index 51%
rename from readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitDocumentFragment.kt
rename to readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitDocumentFragment.kt
index f302faec1d..a2e0532f94 100644
--- a/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitDocumentFragment.kt
+++ b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitDocumentFragment.kt
@@ -4,7 +4,7 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.adapters.pspdfkit.navigator
+package org.readium.adapter.pspdfkit.navigator
import android.graphics.PointF
import android.os.Bundle
@@ -14,6 +14,10 @@ import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentContainerView
import androidx.fragment.app.commitNow
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.viewModelScope
import com.pspdfkit.annotations.Annotation
import com.pspdfkit.annotations.LinkAnnotation
import com.pspdfkit.annotations.SoundAnnotation
@@ -31,36 +35,107 @@ import com.pspdfkit.listeners.OnPreparePopupToolbarListener
import com.pspdfkit.ui.PdfFragment
import com.pspdfkit.ui.toolbar.popup.PdfTextSelectionPopupToolbar
import kotlin.math.roundToInt
-import org.readium.adapters.pspdfkit.document.PsPdfKitDocument
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import org.readium.adapter.pspdfkit.document.PsPdfKitDocument
+import org.readium.adapter.pspdfkit.document.PsPdfKitDocumentFactory
import org.readium.r2.navigator.pdf.PdfDocumentFragment
import org.readium.r2.navigator.preferences.Axis
import org.readium.r2.navigator.preferences.Fit
import org.readium.r2.navigator.preferences.ReadingProgression
import org.readium.r2.navigator.preferences.Spread
+import org.readium.r2.navigator.util.createViewModelFactory
import org.readium.r2.shared.ExperimentalReadiumApi
import org.readium.r2.shared.publication.Publication
import org.readium.r2.shared.publication.services.isProtected
+import org.readium.r2.shared.util.Url
+import org.readium.r2.shared.util.data.ReadError
+import org.readium.r2.shared.util.data.ReadTry
+import org.readium.r2.shared.util.pdf.cachedIn
+import timber.log.Timber
@ExperimentalReadiumApi
-internal class PsPdfKitDocumentFragment(
+public class PsPdfKitDocumentFragment internal constructor(
private val publication: Publication,
- private val document: PsPdfKitDocument,
- private val initialPageIndex: Int,
- settings: PsPdfKitSettings,
+ private val href: Url,
+ initialPageIndex: Int,
+ initialSettings: PsPdfKitSettings,
private val listener: Listener?
) : PdfDocumentFragment() {
- override var settings: PsPdfKitSettings = settings
- set(value) {
- if (field == value) return
+ internal interface Listener {
+ fun onResourceLoadFailed(href: Url, error: ReadError)
+ fun onConfigurePdfView(builder: PdfConfiguration.Builder): PdfConfiguration.Builder
+ fun onTap(point: PointF): Boolean
+ }
+
+ private companion object {
+ private const val pdfFragmentTag = "com.pspdfkit.ui.PdfFragment"
+ }
+ private var pdfFragment: PdfFragment? = null
+ set(value) {
field = value
- reloadDocumentAtPage(pageIndex)
+ value?.apply {
+ setOnPreparePopupToolbarListener(psPdfKitListener)
+ addDocumentListener(psPdfKitListener)
+ }
}
- private lateinit var pdfFragment: PdfFragment
private val psPdfKitListener = PsPdfKitListener()
+ private class DocumentViewModel(
+ document: suspend () -> ReadTry
+ ) : ViewModel() {
+
+ private val _document: Deferred> =
+ viewModelScope.async { document() }
+
+ suspend fun loadDocument(): ReadTry =
+ _document.await()
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val document: PsPdfKitDocument? get() =
+ _document.run {
+ if (isCompleted) {
+ getCompleted().getOrNull()
+ } else {
+ null
+ }
+ }
+ }
+
+ private val viewModel: DocumentViewModel by viewModels {
+ createViewModelFactory {
+ DocumentViewModel(
+ document = {
+ val resource = requireNotNull(publication.get(href))
+ PsPdfKitDocumentFactory(requireContext())
+ .cachedIn(publication)
+ .open(resource, null)
+ }
+ )
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Restores the PdfFragment after a configuration change.
+ pdfFragment = (childFragmentManager.findFragmentByTag(pdfFragmentTag) as? PdfFragment)
+ ?.apply {
+ val document = checkNotNull(viewModel.document) {
+ "Should have a document when restoring the PdfFragment."
+ }
+ setCustomPdfSources(document.document.documentSources)
+ }
+ }
+
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -73,60 +148,47 @@ internal class PsPdfKitDocumentFragment(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- reloadDocumentAtPage(initialPageIndex)
- }
-
- private fun reloadDocumentAtPage(pageIndex: Int) {
- pdfFragment = createPdfFragment()
- childFragmentManager.commitNow {
- replace(R.id.readium_pspdfkit_fragment, pdfFragment, "com.pspdfkit.ui.PdfFragment")
+ if (pdfFragment == null) {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewModel.loadDocument()
+ .onFailure { error ->
+ listener?.onResourceLoadFailed(href, error)
+ }
+ .onSuccess { resetPdfFragment() }
+ }
}
}
- private fun createPdfFragment(): PdfFragment {
- document.document.pageBinding = settings.readingProgression.pageBinding
- val config = configForSettings(settings)
+ /**
+ * Recreates the [PdfFragment] with the current settings.
+ */
+ private fun resetPdfFragment() {
+ if (isStateSaved || view == null) return
+ val doc = viewModel.document ?: return
- val newFragment = if (::pdfFragment.isInitialized) {
- PdfFragment.newInstance(pdfFragment, config)
- } else {
- PdfFragment.newInstance(document.document, config)
- }
+ doc.document.pageBinding = settings.readingProgression.pageBinding
- newFragment.apply {
- setOnPreparePopupToolbarListener(psPdfKitListener)
- addDocumentListener(psPdfKitListener)
- }
+ val fragment = PdfFragment.newInstance(doc.document, configForSettings(settings))
+ .also { pdfFragment = it }
- return newFragment
+ childFragmentManager.commitNow {
+ replace(R.id.readium_pspdfkit_fragment, fragment, pdfFragmentTag)
+ }
}
private fun configForSettings(settings: PsPdfKitSettings): PdfConfiguration {
- val config = PdfConfiguration.Builder()
+ var config = PdfConfiguration.Builder()
.animateScrollOnEdgeTaps(false)
.annotationReplyFeatures(AnnotationReplyFeatures.READ_ONLY)
.automaticallyGenerateLinks(true)
.autosaveEnabled(false)
-// .backgroundColor(Color.TRANSPARENT)
.disableAnnotationEditing()
.disableAnnotationRotation()
.disableAutoSelectNextFormElement()
.disableFormEditing()
.enableMagnifier(true)
.excludedAnnotationTypes(emptyList())
- .fitMode(settings.fit.fitMode)
- .layoutMode(settings.spread.pageLayout)
-// .loadingProgressDrawable(null)
-// .maxZoomScale()
- .firstPageAlwaysSingle(settings.offsetFirstPage)
- .pagePadding(settings.pageSpacing.roundToInt())
- .restoreLastViewedPage(false)
- .scrollDirection(
- if (!settings.scroll) PageScrollDirection.HORIZONTAL
- else settings.scrollAxis.scrollDirection
- )
- .scrollMode(settings.scroll.scrollMode)
.scrollOnEdgeTapEnabled(false)
.scrollOnEdgeTapMargin(50)
.scrollbarsEnabled(true)
@@ -138,41 +200,70 @@ internal class PsPdfKitDocumentFragment(
.videoPlaybackEnabled(true)
.zoomOutBounce(true)
+ // Customization point for integrators.
+ listener?.let {
+ config = it.onConfigurePdfView(config)
+ }
+
+ // Settings-specific configuration
+ config = config
+ .fitMode(settings.fit.fitMode)
+ .layoutMode(settings.spread.pageLayout)
+ .firstPageAlwaysSingle(settings.offsetFirstPage)
+ .pagePadding(settings.pageSpacing.roundToInt())
+ .restoreLastViewedPage(false)
+ .scrollDirection(
+ if (!settings.scroll) {
+ PageScrollDirection.HORIZONTAL
+ } else {
+ settings.scrollAxis.scrollDirection
+ }
+ )
+ .scrollMode(settings.scroll.scrollMode)
+
if (publication.isProtected) {
- config.disableCopyPaste()
+ config = config.disableCopyPaste()
}
return config.build()
}
- override var pageIndex: Int = initialPageIndex
- private set
+ private val _pageIndex = MutableStateFlow(initialPageIndex)
+ override val pageIndex: StateFlow = _pageIndex.asStateFlow()
override fun goToPageIndex(index: Int, animated: Boolean): Boolean {
+ val fragment = pdfFragment ?: return false
if (!isValidPageIndex(index)) {
return false
}
- pageIndex = index
- pdfFragment.setPageIndex(index, animated)
+ fragment.setPageIndex(index, animated)
return true
}
private fun isValidPageIndex(pageIndex: Int): Boolean {
- val validRange = 0 until pdfFragment.pageCount
+ val validRange = 0 until (pdfFragment?.pageCount ?: 0)
return validRange.contains(pageIndex)
}
+ private var settings: PsPdfKitSettings = initialSettings
+
+ override fun applySettings(settings: PsPdfKitSettings) {
+ if (this.settings == settings) {
+ return
+ }
+
+ this.settings = settings
+ resetPdfFragment()
+ }
+
private inner class PsPdfKitListener : DocumentListener, OnPreparePopupToolbarListener {
override fun onPageChanged(document: PdfDocument, pageIndex: Int) {
- this@PsPdfKitDocumentFragment.pageIndex = pageIndex
- listener?.onPageChanged(pageIndex)
+ _pageIndex.value = pageIndex
}
override fun onDocumentClick(): Boolean {
- val listener = listener ?: return false
-
val center = view?.run { PointF(width.toFloat() / 2, height.toFloat() / 2) }
- return center?.let { listener.onTap(it) } ?: false
+ return center?.let { listener?.onTap(it) } ?: false
}
override fun onPageClick(
@@ -185,17 +276,24 @@ internal class PsPdfKitDocumentFragment(
if (
pagePosition == null || clickedAnnotation is LinkAnnotation ||
clickedAnnotation is SoundAnnotation
- ) return false
+ ) {
+ return false
+ }
- pdfFragment.viewProjection.toViewPoint(pagePosition, pageIndex)
+ checkNotNull(pdfFragment).viewProjection.toViewPoint(pagePosition, pageIndex)
return listener?.onTap(pagePosition) ?: false
}
- private val allowedTextSelectionItems = listOf(
- R.id.pspdf__text_selection_toolbar_item_share,
- R.id.pspdf__text_selection_toolbar_item_copy,
- R.id.pspdf__text_selection_toolbar_item_speak
- )
+ private val allowedTextSelectionItems: List by lazy {
+ buildList {
+ add(com.pspdfkit.R.id.pspdf__text_selection_toolbar_item_speak)
+
+ if (!publication.isProtected) {
+ add(com.pspdfkit.R.id.pspdf__text_selection_toolbar_item_share)
+ add(com.pspdfkit.R.id.pspdf__text_selection_toolbar_item_copy)
+ }
+ }
+ }
override fun onPrepareTextSelectionPopupToolbar(toolbar: PdfTextSelectionPopupToolbar) {
// Makes sure only the menu items in `allowedTextSelectionItems` will be visible.
@@ -204,9 +302,15 @@ internal class PsPdfKitDocumentFragment(
}
override fun onDocumentLoaded(document: PdfDocument) {
- super.onDocumentLoaded(document)
+ val index = pageIndex.value
+ if (index < 0 || index >= document.pageCount) {
+ Timber.w(
+ "Tried to restore page index $index, but the document has ${document.pageCount} pages"
+ )
+ return
+ }
- pdfFragment.setPageIndex(pageIndex, false)
+ checkNotNull(pdfFragment).setPageIndex(index, false)
}
}
diff --git a/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitEngineProvider.kt b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitEngineProvider.kt
new file mode 100644
index 0000000000..580748f6f3
--- /dev/null
+++ b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitEngineProvider.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2022 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.adapter.pspdfkit.navigator
+
+import android.graphics.PointF
+import com.pspdfkit.configuration.PdfConfiguration
+import org.readium.r2.navigator.OverflowableNavigator
+import org.readium.r2.navigator.SimpleOverflow
+import org.readium.r2.navigator.input.TapEvent
+import org.readium.r2.navigator.pdf.PdfDocumentFragmentInput
+import org.readium.r2.navigator.pdf.PdfEngineProvider
+import org.readium.r2.navigator.preferences.Axis
+import org.readium.r2.navigator.util.SingleFragmentFactory
+import org.readium.r2.navigator.util.createFragmentFactory
+import org.readium.r2.shared.ExperimentalReadiumApi
+import org.readium.r2.shared.publication.Metadata
+import org.readium.r2.shared.publication.Publication
+import org.readium.r2.shared.util.Url
+import org.readium.r2.shared.util.data.ReadError
+
+/**
+ * Main component to use the PDF navigator with PSPDFKit.
+ *
+ * Provide [PsPdfKitDefaults] to customize the default values that will be used by
+ * the navigator for some preferences.
+ */
+@ExperimentalReadiumApi
+public class PsPdfKitEngineProvider(
+ private val defaults: PsPdfKitDefaults = PsPdfKitDefaults(),
+ private val listener: Listener? = null
+) : PdfEngineProvider {
+
+ public interface Listener : PdfEngineProvider.Listener {
+
+ /** Called when configuring a new PDF fragment. */
+ public fun onConfigurePdfView(builder: PdfConfiguration.Builder): PdfConfiguration.Builder = builder
+ }
+
+ override fun createDocumentFragmentFactory(
+ input: PdfDocumentFragmentInput
+ ): SingleFragmentFactory =
+ createFragmentFactory {
+ PsPdfKitDocumentFragment(
+ publication = input.publication,
+ href = input.href,
+ initialPageIndex = input.pageIndex,
+ initialSettings = input.settings,
+ listener = object : PsPdfKitDocumentFragment.Listener {
+ override fun onResourceLoadFailed(href: Url, error: ReadError) {
+ input.navigatorListener?.onResourceLoadFailed(href, error)
+ }
+
+ override fun onConfigurePdfView(builder: PdfConfiguration.Builder): PdfConfiguration.Builder =
+ listener?.onConfigurePdfView(builder) ?: builder
+
+ override fun onTap(point: PointF): Boolean =
+ input.inputListener?.onTap(TapEvent(point)) ?: false
+ }
+ )
+ }
+
+ override fun computeSettings(metadata: Metadata, preferences: PsPdfKitPreferences): PsPdfKitSettings {
+ val settingsPolicy = PsPdfKitSettingsResolver(metadata, defaults)
+ return settingsPolicy.settings(preferences)
+ }
+
+ override fun computeOverflow(settings: PsPdfKitSettings): OverflowableNavigator.Overflow =
+ SimpleOverflow(
+ readingProgression = settings.readingProgression,
+ scroll = settings.scroll,
+ axis = if (settings.scroll) settings.scrollAxis else Axis.HORIZONTAL
+ )
+
+ override fun createPreferenceEditor(
+ publication: Publication,
+ initialPreferences: PsPdfKitPreferences
+ ): PsPdfKitPreferencesEditor =
+ PsPdfKitPreferencesEditor(
+ initialPreferences = initialPreferences,
+ publicationMetadata = publication.metadata,
+ defaults = defaults
+ )
+
+ override fun createEmptyPreferences(): PsPdfKitPreferences =
+ PsPdfKitPreferences()
+}
diff --git a/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitNavigator.kt b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitNavigator.kt
new file mode 100644
index 0000000000..fe5b6760ba
--- /dev/null
+++ b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitNavigator.kt
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2023 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.adapter.pspdfkit.navigator
+
+import org.readium.r2.navigator.pdf.PdfNavigatorFactory
+import org.readium.r2.navigator.pdf.PdfNavigatorFragment
+import org.readium.r2.shared.ExperimentalReadiumApi
+
+@ExperimentalReadiumApi
+public typealias PsPdfKitNavigatorFragment = PdfNavigatorFragment
+
+@ExperimentalReadiumApi
+public typealias PsPdfKitNavigatorFactory = PdfNavigatorFactory
diff --git a/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitPreferences.kt b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitPreferences.kt
similarity index 88%
rename from readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitPreferences.kt
rename to readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitPreferences.kt
index 329adfe66b..bf7b19440a 100644
--- a/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitPreferences.kt
+++ b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitPreferences.kt
@@ -4,7 +4,7 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.adapters.pspdfkit.navigator
+package org.readium.adapter.pspdfkit.navigator
import kotlinx.serialization.Serializable
import org.readium.r2.navigator.preferences.*
@@ -23,14 +23,14 @@ import org.readium.r2.shared.ExperimentalReadiumApi
*/
@ExperimentalReadiumApi
@Serializable
-data class PsPdfKitPreferences(
+public data class PsPdfKitPreferences(
val fit: Fit? = null,
val offsetFirstPage: Boolean? = null,
val pageSpacing: Double? = null,
val readingProgression: ReadingProgression? = null,
val scroll: Boolean? = null,
val scrollAxis: Axis? = null,
- val spread: Spread? = null,
+ val spread: Spread? = null
) : Configurable.Preferences {
init {
@@ -38,7 +38,7 @@ data class PsPdfKitPreferences(
require(pageSpacing == null || pageSpacing >= 0)
}
- override operator fun plus(other: PsPdfKitPreferences) =
+ override operator fun plus(other: PsPdfKitPreferences): PsPdfKitPreferences =
PsPdfKitPreferences(
fit = other.fit ?: fit,
offsetFirstPage = other.offsetFirstPage ?: offsetFirstPage,
@@ -46,6 +46,6 @@ data class PsPdfKitPreferences(
readingProgression = other.readingProgression ?: readingProgression,
scroll = other.scroll ?: scroll,
scrollAxis = other.scrollAxis ?: scrollAxis,
- spread = other.spread ?: spread,
+ spread = other.spread ?: spread
)
}
diff --git a/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitPreferencesEditor.kt b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitPreferencesEditor.kt
similarity index 88%
rename from readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitPreferencesEditor.kt
rename to readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitPreferencesEditor.kt
index 5b01eca390..3b1e5842a5 100644
--- a/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitPreferencesEditor.kt
+++ b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitPreferencesEditor.kt
@@ -4,7 +4,7 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.adapters.pspdfkit.navigator
+package org.readium.adapter.pspdfkit.navigator
import org.readium.r2.navigator.extensions.format
import org.readium.r2.navigator.preferences.Axis
@@ -30,10 +30,10 @@ import org.readium.r2.shared.publication.Metadata
* or ranges.
*/
@ExperimentalReadiumApi
-class PsPdfKitPreferencesEditor internal constructor(
+public class PsPdfKitPreferencesEditor internal constructor(
initialPreferences: PsPdfKitPreferences,
publicationMetadata: Metadata,
- defaults: PsPdfKitDefaults,
+ defaults: PsPdfKitDefaults
) : PreferencesEditor {
private data class State(
@@ -60,13 +60,13 @@ class PsPdfKitPreferencesEditor internal constructor(
/**
* Indicates how pages should be laid out within the viewport.
*/
- val fit: EnumPreference =
+ public val fit: EnumPreference =
EnumPreferenceDelegate(
getValue = { preferences.fit },
getEffectiveValue = { state.settings.fit },
getIsEffective = { true },
updateValue = { value -> updateValues { it.copy(fit = value) } },
- supportedValues = listOf(Fit.CONTAIN, Fit.WIDTH),
+ supportedValues = listOf(Fit.CONTAIN, Fit.WIDTH)
)
/**
@@ -76,18 +76,18 @@ class PsPdfKitPreferencesEditor internal constructor(
* - [scroll] is off
* - [spread] are not disabled
*/
- val offsetFirstPage: Preference =
+ public val offsetFirstPage: Preference =
PreferenceDelegate(
getValue = { preferences.offsetFirstPage },
getEffectiveValue = { state.settings.offsetFirstPage },
getIsEffective = { !state.settings.scroll && state.settings.spread != Spread.NEVER },
- updateValue = { value -> updateValues { it.copy(offsetFirstPage = value) } },
+ updateValue = { value -> updateValues { it.copy(offsetFirstPage = value) } }
)
/**
* Space between pages in dp.
*/
- val pageSpacing: RangePreference =
+ public val pageSpacing: RangePreference =
RangePreferenceDelegate(
getValue = { preferences.pageSpacing },
getEffectiveValue = { state.settings.pageSpacing },
@@ -95,30 +95,30 @@ class PsPdfKitPreferencesEditor internal constructor(
updateValue = { value -> updateValues { it.copy(pageSpacing = value) } },
supportedRange = 0.0..50.0,
progressionStrategy = DoubleIncrement(5.0),
- valueFormatter = { "${it.format(1)} dp" },
+ valueFormatter = { "${it.format(1)} dp" }
)
/**
* Direction of the horizontal progression across pages.
*/
- val readingProgression: EnumPreference =
+ public val readingProgression: EnumPreference =
EnumPreferenceDelegate(
getValue = { preferences.readingProgression },
getEffectiveValue = { state.settings.readingProgression },
getIsEffective = { true },
updateValue = { value -> updateValues { it.copy(readingProgression = value) } },
- supportedValues = listOf(ReadingProgression.LTR, ReadingProgression.RTL),
+ supportedValues = listOf(ReadingProgression.LTR, ReadingProgression.RTL)
)
/**
* Indicates if pages should be handled using scrolling instead of pagination.
*/
- val scroll: Preference =
+ public val scroll: Preference =
PreferenceDelegate(
getValue = { preferences.scroll },
getEffectiveValue = { state.settings.scroll },
getIsEffective = { true },
- updateValue = { value -> updateValues { it.copy(scroll = value) } },
+ updateValue = { value -> updateValues { it.copy(scroll = value) } }
)
/**
@@ -126,13 +126,13 @@ class PsPdfKitPreferencesEditor internal constructor(
*
* Only effective when [scroll] is on.
*/
- val scrollAxis: EnumPreference =
+ public val scrollAxis: EnumPreference =
EnumPreferenceDelegate(
getValue = { preferences.scrollAxis },
getEffectiveValue = { state.settings.scrollAxis },
getIsEffective = { state.settings.scroll },
updateValue = { value -> updateValues { it.copy(scrollAxis = value) } },
- supportedValues = listOf(Axis.VERTICAL, Axis.HORIZONTAL),
+ supportedValues = listOf(Axis.VERTICAL, Axis.HORIZONTAL)
)
/**
@@ -140,13 +140,13 @@ class PsPdfKitPreferencesEditor internal constructor(
*
* Only effective when [scroll] is off.
*/
- val spread: EnumPreference =
+ public val spread: EnumPreference =
EnumPreferenceDelegate(
getValue = { preferences.spread },
getEffectiveValue = { state.settings.spread },
getIsEffective = { !state.settings.scroll },
updateValue = { value -> updateValues { it.copy(spread = value) } },
- supportedValues = listOf(Spread.AUTO, Spread.NEVER, Spread.ALWAYS),
+ supportedValues = listOf(Spread.AUTO, Spread.NEVER, Spread.ALWAYS)
)
private fun updateValues(updater: (PsPdfKitPreferences) -> PsPdfKitPreferences) {
diff --git a/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitPreferencesFilters.kt b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitPreferencesFilters.kt
similarity index 76%
rename from readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitPreferencesFilters.kt
rename to readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitPreferencesFilters.kt
index 28d01056e4..368b3e23b9 100644
--- a/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitPreferencesFilters.kt
+++ b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitPreferencesFilters.kt
@@ -4,7 +4,7 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.adapters.pspdfkit.navigator
+package org.readium.adapter.pspdfkit.navigator
import org.readium.r2.navigator.preferences.PreferencesFilter
import org.readium.r2.shared.ExperimentalReadiumApi
@@ -13,13 +13,13 @@ import org.readium.r2.shared.ExperimentalReadiumApi
* Suggested filter to keep only shared [PsPdfKitPreferences].
*/
@ExperimentalReadiumApi
-object PsPdfKitSharedPreferencesFilter : PreferencesFilter {
+public object PsPdfKitSharedPreferencesFilter : PreferencesFilter {
override fun filter(preferences: PsPdfKitPreferences): PsPdfKitPreferences =
preferences.copy(
readingProgression = null,
offsetFirstPage = null,
- spread = null,
+ spread = null
)
}
@@ -27,12 +27,12 @@ object PsPdfKitSharedPreferencesFilter : PreferencesFilter
* Suggested filter to keep only publication-specific [PsPdfKitPreferences].
*/
@ExperimentalReadiumApi
-object PsPdfKitPublicationPreferencesFilter : PreferencesFilter {
+public object PsPdfKitPublicationPreferencesFilter : PreferencesFilter {
override fun filter(preferences: PsPdfKitPreferences): PsPdfKitPreferences =
PsPdfKitPreferences(
readingProgression = preferences.readingProgression,
offsetFirstPage = preferences.offsetFirstPage,
- spread = preferences.spread,
+ spread = preferences.spread
)
}
diff --git a/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitPreferencesSerializer.kt b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitPreferencesSerializer.kt
similarity index 84%
rename from readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitPreferencesSerializer.kt
rename to readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitPreferencesSerializer.kt
index b461c1a9d9..7348fc655f 100644
--- a/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitPreferencesSerializer.kt
+++ b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitPreferencesSerializer.kt
@@ -4,7 +4,7 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.adapters.pspdfkit.navigator
+package org.readium.adapter.pspdfkit.navigator
import kotlinx.serialization.json.Json
import org.readium.r2.navigator.preferences.PreferencesSerializer
@@ -14,7 +14,7 @@ import org.readium.r2.shared.ExperimentalReadiumApi
* JSON serializer of [PsPdfKitPreferences].
*/
@ExperimentalReadiumApi
-class PsPdfKitPreferencesSerializer : PreferencesSerializer {
+public class PsPdfKitPreferencesSerializer : PreferencesSerializer {
override fun serialize(preferences: PsPdfKitPreferences): String =
Json.encodeToString(PsPdfKitPreferences.serializer(), preferences)
diff --git a/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitSettings.kt b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitSettings.kt
similarity index 85%
rename from readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitSettings.kt
rename to readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitSettings.kt
index 7f90046e12..e43867feab 100644
--- a/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitSettings.kt
+++ b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitSettings.kt
@@ -4,7 +4,7 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.adapters.pspdfkit.navigator
+package org.readium.adapter.pspdfkit.navigator
import org.readium.r2.navigator.preferences.*
import org.readium.r2.shared.ExperimentalReadiumApi
@@ -15,12 +15,12 @@ import org.readium.r2.shared.ExperimentalReadiumApi
* @see PsPdfKitPreferences
*/
@ExperimentalReadiumApi
-data class PsPdfKitSettings(
+public data class PsPdfKitSettings(
val fit: Fit,
val offsetFirstPage: Boolean,
val pageSpacing: Double,
val readingProgression: ReadingProgression,
val scroll: Boolean,
val scrollAxis: Axis,
- val spread: Spread,
+ val spread: Spread
) : Configurable.Settings
diff --git a/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitSettingsResolver.kt b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitSettingsResolver.kt
similarity index 95%
rename from readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitSettingsResolver.kt
rename to readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitSettingsResolver.kt
index a56213138b..cfa7e01102 100644
--- a/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitSettingsResolver.kt
+++ b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapter/pspdfkit/navigator/PsPdfKitSettingsResolver.kt
@@ -4,7 +4,7 @@
* available in the top-level LICENSE file of the project.
*/
-package org.readium.adapters.pspdfkit.navigator
+package org.readium.adapter.pspdfkit.navigator
import org.readium.r2.navigator.preferences.Axis
import org.readium.r2.navigator.preferences.Fit
@@ -17,7 +17,7 @@ import org.readium.r2.shared.publication.ReadingProgression as PublicationReadin
@ExperimentalReadiumApi
internal class PsPdfKitSettingsResolver(
private val metadata: Metadata,
- private val defaults: PsPdfKitDefaults,
+ private val defaults: PsPdfKitDefaults
) {
fun settings(preferences: PsPdfKitPreferences): PsPdfKitSettings {
val readingProgression: ReadingProgression =
@@ -66,7 +66,7 @@ internal class PsPdfKitSettingsResolver(
readingProgression = readingProgression,
scroll = scroll,
scrollAxis = scrollAxis,
- spread = spread,
+ spread = spread
)
}
}
diff --git a/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/Deprecated.kt b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/Deprecated.kt
new file mode 100644
index 0000000000..bbdd9efb49
--- /dev/null
+++ b/readium/adapters/pspdfkit/navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/Deprecated.kt
@@ -0,0 +1,82 @@
+@file:OptIn(ExperimentalReadiumApi::class)
+
+package org.readium.adapters.pspdfkit.navigator
+
+import org.readium.r2.shared.ExperimentalReadiumApi
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pspdfkit.navigator.PsPdfKitEngineProvider"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PsPdfKitEngineProvider = org.readium.adapter.pspdfkit.navigator.PsPdfKitEngineProvider
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pspdfkit.navigator.PsPdfKitDefaults"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PsPdfKitDefaults = org.readium.adapter.pspdfkit.navigator.PsPdfKitDefaults
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pspdfkit.navigator.PsPdfKitPreferences"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PsPdfKitPreferences = org.readium.adapter.pspdfkit.navigator.PsPdfKitPreferences
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pspdfkit.navigator.PsPdfKitPreferencesEditor"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PsPdfKitPreferencesEditor = org.readium.adapter.pspdfkit.navigator.PsPdfKitPreferencesEditor
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pspdfkit.navigator.PsPdfKitSettings"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PsPdfKitSettings = org.readium.adapter.pspdfkit.navigator.PsPdfKitSettings
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pspdfkit.navigator.PsPdfKitDocumentFragment"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PsPdfKitDocumentFragment = org.readium.adapter.pspdfkit.navigator.PsPdfKitDocumentFragment
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pspdfkit.navigator.PsPdfKitNavigatorFactory"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PsPdfKitNavigatorFactory = org.readium.adapter.pspdfkit.navigator.PsPdfKitNavigatorFactory
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pspdfkit.navigator.PsPdfKitNavigatorFragment"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PsPdfKitNavigatorFragment = org.readium.adapter.pspdfkit.navigator.PsPdfKitNavigatorFragment
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pspdfkit.navigator.PsPdfKitPreferencesSerializer"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PsPdfKitPreferencesSerializer = org.readium.adapter.pspdfkit.navigator.PsPdfKitPreferencesSerializer
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pspdfkit.navigator.PsPdfKitPublicationPreferencesFilter"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PsPdfKitPublicationPreferencesFilter = org.readium.adapter.pspdfkit.navigator.PsPdfKitPublicationPreferencesFilter
+
+@Deprecated(
+ "Moved to another package",
+ ReplaceWith("org.readium.adapter.pspdfkit.navigator.PsPdfKitSharedPreferencesFilter"),
+ level = DeprecationLevel.ERROR
+)
+public typealias PsPdfKitSharedPreferencesFilter = org.readium.adapter.pspdfkit.navigator.PsPdfKitSharedPreferencesFilter
diff --git a/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/res/values/ids.xml b/readium/adapters/pspdfkit/navigator/src/main/res/values/ids.xml
similarity index 100%
rename from readium/adapters/pspdfkit/pspdfkit-navigator/src/main/res/values/ids.xml
rename to readium/adapters/pspdfkit/navigator/src/main/res/values/ids.xml
diff --git a/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitEngineProvider.kt b/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitEngineProvider.kt
deleted file mode 100644
index 5fbc8d8f5c..0000000000
--- a/readium/adapters/pspdfkit/pspdfkit-navigator/src/main/java/org/readium/adapters/pspdfkit/navigator/PsPdfKitEngineProvider.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * Copyright 2022 Readium Foundation. All rights reserved.
- * Use of this source code is governed by the BSD-style license
- * available in the top-level LICENSE file of the project.
- */
-
-package org.readium.adapters.pspdfkit.navigator
-
-import android.content.Context
-import org.readium.adapters.pspdfkit.document.PsPdfKitDocumentFactory
-import org.readium.r2.navigator.SimplePresentation
-import org.readium.r2.navigator.VisualNavigator
-import org.readium.r2.navigator.pdf.PdfDocumentFragment
-import org.readium.r2.navigator.pdf.PdfDocumentFragmentInput
-import org.readium.r2.navigator.pdf.PdfEngineProvider
-import org.readium.r2.navigator.preferences.Axis
-import org.readium.r2.shared.ExperimentalReadiumApi
-import org.readium.r2.shared.publication.Metadata
-import org.readium.r2.shared.publication.Publication
-import org.readium.r2.shared.util.pdf.cachedIn
-
-/**
- * Main component to use the PDF navigator with PSPDFKit.
- *
- * Provide [PsPdfKitDefaults] to customize the default values that will be used by
- * the navigator for some preferences.
- */
-@ExperimentalReadiumApi
-class PsPdfKitEngineProvider(
- private val context: Context,
- private val defaults: PsPdfKitDefaults = PsPdfKitDefaults()
-) : PdfEngineProvider {
-
- override suspend fun createDocumentFragment(
- input: PdfDocumentFragmentInput
- ): PdfDocumentFragment {
-
- val publication = input.publication
- val document = PsPdfKitDocumentFactory(context)
- .cachedIn(publication)
- .open(publication.get(input.link), null)
-
- return PsPdfKitDocumentFragment(
- publication = publication,
- document = document,
- initialPageIndex = input.initialPageIndex,
- settings = input.settings,
- listener = input.listener
- )
- }
-
- override fun computeSettings(metadata: Metadata, preferences: PsPdfKitPreferences): PsPdfKitSettings {
- val settingsPolicy = PsPdfKitSettingsResolver(metadata, defaults)
- return settingsPolicy.settings(preferences)
- }
-
- override fun computePresentation(settings: PsPdfKitSettings): VisualNavigator.Presentation =
- SimplePresentation(
- readingProgression = settings.readingProgression,
- scroll = settings.scroll,
- axis = if (settings.scroll) settings.scrollAxis else Axis.HORIZONTAL
- )
-
- override fun createPreferenceEditor(
- publication: Publication,
- initialPreferences: PsPdfKitPreferences
- ): PsPdfKitPreferencesEditor =
- PsPdfKitPreferencesEditor(
- initialPreferences = initialPreferences,
- publicationMetadata = publication.metadata,
- defaults = defaults
- )
-
- override fun createEmptyPreferences(): PsPdfKitPreferences =
- PsPdfKitPreferences()
-}
diff --git a/readium/lcp/build.gradle.kts b/readium/lcp/build.gradle.kts
index 2d4fd94f66..57da92b017 100644
--- a/readium/lcp/build.gradle.kts
+++ b/readium/lcp/build.gradle.kts
@@ -8,23 +8,24 @@ plugins {
id("com.android.library")
kotlin("android")
kotlin("plugin.parcelize")
- kotlin("kapt")
+ id("com.google.devtools.ksp")
}
android {
+ resourcePrefix = "readium_"
- compileSdk = 33
+ compileSdk = 34
defaultConfig {
minSdk = 21
- targetSdk = 33
+ targetSdk = 34
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
- jvmTarget = "1.8"
+ jvmTarget = JavaVersion.VERSION_17.toString()
allWarningsAsErrors = true
freeCompilerArgs = freeCompilerArgs + listOf(
"-opt-in=kotlin.RequiresOptIn",
@@ -37,9 +38,16 @@ android {
proguardFiles(getDefaultProguardFile("proguard-android.txt"))
}
}
+ buildFeatures {
+ buildConfig = true
+ }
namespace = "org.readium.r2.lcp"
}
+kotlin {
+ explicitApi()
+}
+
rootProject.ext["publish.artifactId"] = "readium-lcp"
apply(from = "$rootDir/scripts/publish-module.gradle")
@@ -59,15 +67,14 @@ dependencies {
exclude(module = "support-v4")
}
implementation(libs.joda.time)
- implementation("org.zeroturnaround:zt-zip:1.15")
implementation(libs.androidx.browser)
implementation(libs.bundles.room)
- kapt(libs.androidx.room.compiler)
- kapt("org.jetbrains.kotlinx:kotlinx-metadata-jvm:0.5.0")
+ ksp(libs.androidx.room.compiler)
// Tests
testImplementation(libs.junit)
+ testImplementation(libs.kotlin.junit)
androidTestImplementation(libs.androidx.ext.junit)
androidTestImplementation(libs.androidx.expresso.core)
diff --git a/readium/lcp/src/main/AndroidManifest.xml b/readium/lcp/src/main/AndroidManifest.xml
index d0c18d3e0e..05d00db7b9 100644
--- a/readium/lcp/src/main/AndroidManifest.xml
+++ b/readium/lcp/src/main/AndroidManifest.xml
@@ -1,11 +1,8 @@
+ Copyright 2023 Readium Foundation. All rights reserved.
+ Use of this source code is governed by the BSD-style license
+ available in the top-level LICENSE file of the project.
+-->
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpAuthenticating.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpAuthenticating.kt
index 96a9ebc4a7..97e1507da3 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpAuthenticating.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpAuthenticating.kt
@@ -13,7 +13,7 @@ import org.readium.r2.lcp.license.model.LicenseDocument
import org.readium.r2.lcp.license.model.components.Link
import org.readium.r2.lcp.license.model.components.lcp.User
-interface LcpAuthenticating {
+public interface LcpAuthenticating {
/**
* Retrieves the passphrase used to decrypt the given license.
@@ -41,17 +41,14 @@ interface LcpAuthenticating {
* @param license Information to show to the user about the license being opened.
* @param reason Reason why the passphrase is requested. It should be used to prompt the user.
* @param allowUserInteraction Indicates whether the user can be prompted for their passphrase.
- * @param sender Free object that can be used by reading apps to give some UX context when
- * presenting dialogs.
*/
- suspend fun retrievePassphrase(
+ public suspend fun retrievePassphrase(
license: AuthenticatedLicense,
reason: AuthenticationReason,
- allowUserInteraction: Boolean,
- sender: Any? = null
+ allowUserInteraction: Boolean
): String?
- enum class AuthenticationReason {
+ public enum class AuthenticationReason {
/** No matching passphrase was found. */
PassphraseNotFound,
@@ -59,13 +56,13 @@ interface LcpAuthenticating {
/** The provided passphrase was invalid. */
InvalidPassphrase;
- companion object
+ public companion object
}
/**
* @param document License Document being opened.
*/
- data class AuthenticatedLicense(val document: LicenseDocument) {
+ public data class AuthenticatedLicense(val document: LicenseDocument) {
/**
* A hint to be displayed to the User to help them remember the User Passphrase.
@@ -78,13 +75,13 @@ interface LcpAuthenticating {
* about the User Passphrase.
*/
val hintLink: Link?
- get() = document.link(LicenseDocument.Rel.hint)
+ get() = document.link(LicenseDocument.Rel.Hint)
/**
* Support resources for the user (either a website, an email or a telephone number).
*/
val supportLinks: List
- get() = document.links(LicenseDocument.Rel.support)
+ get() = document.links(LicenseDocument.Rel.Support)
/**
* URI of the license provider.
@@ -95,27 +92,47 @@ interface LcpAuthenticating {
/**
* Informations about the user owning the license.
*/
- val user: User?
+ val user: User
get() = document.user
}
}
-@Deprecated("Renamed to `LcpAuthenticating`", replaceWith = ReplaceWith("LcpAuthenticating"))
-typealias LCPAuthenticating = LcpAuthenticating
+@Deprecated(
+ "Renamed to `LcpAuthenticating`",
+ replaceWith = ReplaceWith("LcpAuthenticating"),
+ level = DeprecationLevel.ERROR
+)
+public typealias LCPAuthenticating = LcpAuthenticating
@Deprecated("Not used anymore", level = DeprecationLevel.ERROR)
-interface LCPAuthenticationDelegate
-
-@Deprecated("Renamed to `LcpAuthenticating.AuthenticationReason`", replaceWith = ReplaceWith("LcpAuthenticating.AuthenticationReason"))
-typealias LCPAuthenticationReason = LcpAuthenticating.AuthenticationReason
-
-@Deprecated("Renamed to `LcpAuthenticating.AuthenticatedLicense`", replaceWith = ReplaceWith("LcpAuthenticating.AuthenticatedLicense"))
-typealias LCPAuthenticatedLicense = LcpAuthenticating.AuthenticatedLicense
-
-@Deprecated("Renamed to `PassphraseNotFound`", replaceWith = ReplaceWith("PassphraseNotFound"))
-val LcpAuthenticating.AuthenticationReason.Companion.passphraseNotFound get() =
- LcpAuthenticating.AuthenticationReason.PassphraseNotFound
-
-@Deprecated("Renamed to `InvalidPassphrase`", replaceWith = ReplaceWith("InvalidPassphrase"))
-val LcpAuthenticating.AuthenticationReason.Companion.invalidPassphrase get() =
- LcpAuthenticating.AuthenticationReason.InvalidPassphrase
+public interface LCPAuthenticationDelegate
+
+@Deprecated(
+ "Renamed to `LcpAuthenticating.AuthenticationReason`",
+ replaceWith = ReplaceWith("LcpAuthenticating.AuthenticationReason"),
+ level = DeprecationLevel.ERROR
+)
+public typealias LCPAuthenticationReason = LcpAuthenticating.AuthenticationReason
+
+@Deprecated(
+ "Renamed to `LcpAuthenticating.AuthenticatedLicense`",
+ replaceWith = ReplaceWith("LcpAuthenticating.AuthenticatedLicense"),
+ level = DeprecationLevel.ERROR
+)
+public typealias LCPAuthenticatedLicense = LcpAuthenticating.AuthenticatedLicense
+
+@Deprecated(
+ "Renamed to `PassphraseNotFound`",
+ replaceWith = ReplaceWith("PassphraseNotFound"),
+ level = DeprecationLevel.ERROR
+)
+public val LcpAuthenticating.AuthenticationReason.Companion.passphraseNotFound: LcpAuthenticating.AuthenticationReason
+ get() = LcpAuthenticating.AuthenticationReason.PassphraseNotFound
+
+@Deprecated(
+ "Renamed to `InvalidPassphrase`",
+ replaceWith = ReplaceWith("InvalidPassphrase"),
+ level = DeprecationLevel.ERROR
+)
+public val LcpAuthenticating.AuthenticationReason.Companion.invalidPassphrase: LcpAuthenticating.AuthenticationReason
+ get() = LcpAuthenticating.AuthenticationReason.InvalidPassphrase
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpContentProtection.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpContentProtection.kt
index 5bf5ff7595..6d038707d8 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpContentProtection.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpContentProtection.kt
@@ -7,47 +7,102 @@
package org.readium.r2.lcp
import org.readium.r2.lcp.auth.LcpPassphraseAuthentication
-import org.readium.r2.shared.fetcher.Fetcher
-import org.readium.r2.shared.fetcher.TransformingFetcher
-import org.readium.r2.shared.publication.ContentProtection
-import org.readium.r2.shared.publication.Publication
-import org.readium.r2.shared.publication.asset.FileAsset
-import org.readium.r2.shared.publication.asset.PublicationAsset
+import org.readium.r2.lcp.license.model.LicenseDocument
+import org.readium.r2.shared.publication.encryption.Encryption
+import org.readium.r2.shared.publication.encryption.encryption
+import org.readium.r2.shared.publication.epub.EpubEncryptionParser
+import org.readium.r2.shared.publication.protection.ContentProtection
import org.readium.r2.shared.publication.services.contentProtectionServiceFactory
+import org.readium.r2.shared.util.AbsoluteUrl
+import org.readium.r2.shared.util.DebugError
+import org.readium.r2.shared.util.ThrowableError
import org.readium.r2.shared.util.Try
+import org.readium.r2.shared.util.Url
+import org.readium.r2.shared.util.asset.Asset
+import org.readium.r2.shared.util.asset.AssetRetriever
+import org.readium.r2.shared.util.asset.ContainerAsset
+import org.readium.r2.shared.util.asset.ResourceAsset
+import org.readium.r2.shared.util.data.Container
+import org.readium.r2.shared.util.data.ReadError
+import org.readium.r2.shared.util.data.decodeRwpm
+import org.readium.r2.shared.util.data.decodeXml
+import org.readium.r2.shared.util.data.readDecodeOrElse
+import org.readium.r2.shared.util.flatMap
+import org.readium.r2.shared.util.format.EpubSpecification
+import org.readium.r2.shared.util.format.LcpLicenseSpecification
+import org.readium.r2.shared.util.format.LcpSpecification
+import org.readium.r2.shared.util.getOrElse
+import org.readium.r2.shared.util.resource.Resource
+import org.readium.r2.shared.util.resource.TransformingContainer
internal class LcpContentProtection(
private val lcpService: LcpService,
- private val authentication: LcpAuthenticating
+ private val authentication: LcpAuthenticating,
+ private val assetRetriever: AssetRetriever
) : ContentProtection {
override suspend fun open(
- asset: PublicationAsset,
- fetcher: Fetcher,
+ asset: Asset,
credentials: String?,
- allowUserInteraction: Boolean,
- sender: Any?
- ): Try? {
- if (asset !is FileAsset) {
- return null
+ allowUserInteraction: Boolean
+ ): Try =
+ when (asset) {
+ is ContainerAsset -> openPublication(asset, credentials, allowUserInteraction)
+ is ResourceAsset -> openLicense(asset, credentials, allowUserInteraction)
}
- if (!lcpService.isLcpProtected(asset.file)) {
- return null
+ private suspend fun openPublication(
+ asset: ContainerAsset,
+ credentials: String?,
+ allowUserInteraction: Boolean
+ ): Try {
+ if (
+ !asset.format.conformsTo(LcpSpecification)
+ ) {
+ return Try.failure(ContentProtection.OpenError.AssetNotSupported())
}
- val authentication = credentials?.let { LcpPassphraseAuthentication(it, fallback = this.authentication) }
+ val license = retrieveLicense(asset, credentials, allowUserInteraction)
+ return createResultAsset(asset, license)
+ }
+
+ private suspend fun retrieveLicense(
+ asset: Asset,
+ credentials: String?,
+ allowUserInteraction: Boolean
+ ): Try {
+ val authentication = credentials
+ ?.let { LcpPassphraseAuthentication(it, fallback = this.authentication) }
?: this.authentication
- val license = lcpService
- .retrieveLicense(asset.file, authentication, allowUserInteraction, sender)
+ return lcpService.retrieveLicense(asset, authentication, allowUserInteraction)
+ }
+ private suspend fun createResultAsset(
+ asset: ContainerAsset,
+ license: Try
+ ): Try {
val serviceFactory = LcpContentProtectionService
- .createFactory(license?.getOrNull(), license?.exceptionOrNull())
+ .createFactory(license.getOrNull(), license.failureOrNull())
+
+ val encryptionData =
+ when {
+ asset.format.conformsTo(EpubSpecification) -> parseEncryptionDataEpub(
+ asset.container
+ )
+ else -> parseEncryptionDataRpf(asset.container)
+ }
+ .getOrElse { return Try.failure(ContentProtection.OpenError.Reading(it)) }
+
+ val decryptor = LcpDecryptor(license.getOrNull(), encryptionData)
- val protectedFile = ContentProtection.ProtectedAsset(
- asset = asset,
- fetcher = TransformingFetcher(fetcher, LcpDecryptor(license?.getOrNull())::transform),
+ val container = TransformingContainer(asset.container, decryptor::transform)
+
+ val protectedFile = ContentProtection.OpenResult(
+ asset = ContainerAsset(
+ format = asset.format,
+ container = container
+ ),
onCreatePublication = {
servicesBuilder.contentProtectionServiceFactory = serviceFactory
}
@@ -55,4 +110,121 @@ internal class LcpContentProtection(
return Try.success(protectedFile)
}
+
+ private suspend fun parseEncryptionDataEpub(container: Container): Try, ReadError> {
+ val encryptionResource = container[Url("META-INF/encryption.xml")!!]
+ ?: return Try.failure(ReadError.Decoding("Missing encryption.xml"))
+
+ val encryptionDocument = encryptionResource
+ .readDecodeOrElse(
+ decode = { it.decodeXml() },
+ recover = { return Try.failure(it) }
+ )
+
+ return Try.success(EpubEncryptionParser.parse(encryptionDocument))
+ }
+
+ private suspend fun parseEncryptionDataRpf(container: Container): Try, ReadError> {
+ val manifestResource = container[Url("manifest.json")!!]
+ ?: return Try.failure(ReadError.Decoding("Missing manifest"))
+
+ val manifest = manifestResource
+ .readDecodeOrElse(
+ decode = { it.decodeRwpm() },
+ recover = { return Try.failure(it) }
+ )
+
+ val encryptionData = manifest
+ .let { (it.readingOrder + it.resources) }
+ .mapNotNull { link -> link.properties.encryption?.let { link.url() to it } }
+ .toMap()
+
+ return Try.success(encryptionData)
+ }
+
+ private suspend fun openLicense(
+ licenseAsset: ResourceAsset,
+ credentials: String?,
+ allowUserInteraction: Boolean
+ ): Try {
+ if (!licenseAsset.format.conformsTo(LcpLicenseSpecification)) {
+ return Try.failure(ContentProtection.OpenError.AssetNotSupported())
+ }
+
+ val license = retrieveLicense(licenseAsset, credentials, allowUserInteraction)
+
+ val licenseDoc = license.getOrNull()?.license
+ ?: licenseAsset.resource.read()
+ .map {
+ try {
+ LicenseDocument(it)
+ } catch (e: Exception) {
+ return Try.failure(
+ ContentProtection.OpenError.Reading(
+ ReadError.Decoding(
+ DebugError(
+ "Failed to read the LCP license document",
+ cause = ThrowableError(e)
+ )
+ )
+ )
+ )
+ }
+ }
+ .getOrElse {
+ return Try.failure(
+ ContentProtection.OpenError.Reading(it)
+ )
+ }
+
+ val link = licenseDoc.publicationLink
+ val url = (link.url() as? AbsoluteUrl)
+ ?: return Try.failure(
+ ContentProtection.OpenError.Reading(
+ ReadError.Decoding(
+ DebugError(
+ "The LCP license document does not contain a valid link to the publication"
+ )
+ )
+ )
+ )
+
+ val asset =
+ if (link.mediaType != null) {
+ assetRetriever.retrieve(
+ url,
+ mediaType = link.mediaType
+ )
+ .map { it as ContainerAsset }
+ .mapFailure { it.wrap() }
+ } else {
+ assetRetriever.retrieve(url)
+ .mapFailure { it.wrap() }
+ .flatMap {
+ if (it is ContainerAsset) {
+ Try.success((it))
+ } else {
+ Try.failure(
+ ContentProtection.OpenError.AssetNotSupported(
+ DebugError(
+ "LCP license points to an unsupported publication."
+ )
+ )
+ )
+ }
+ }
+ }
+
+ return asset.flatMap { createResultAsset(it, license) }
+ }
+
+ private fun AssetRetriever.RetrieveUrlError.wrap(): ContentProtection.OpenError =
+ when (this) {
+ is AssetRetriever.RetrieveUrlError.FormatNotSupported ->
+ ContentProtection.OpenError.AssetNotSupported(this)
+ is AssetRetriever.RetrieveUrlError.Reading ->
+ ContentProtection.OpenError.Reading(cause)
+ is AssetRetriever.RetrieveUrlError.SchemeNotSupported ->
+ ContentProtection.OpenError.AssetNotSupported(this)
+ }
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpContentProtectionService.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpContentProtectionService.kt
index 71eedd8890..91891d0850 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpContentProtectionService.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpContentProtectionService.kt
@@ -9,11 +9,14 @@
package org.readium.r2.lcp
-import org.readium.r2.shared.publication.ContentProtection
import org.readium.r2.shared.publication.Publication
+import org.readium.r2.shared.publication.protection.ContentProtection
import org.readium.r2.shared.publication.services.ContentProtectionService
-class LcpContentProtectionService(val license: LcpLicense?, override val error: LcpException?) : ContentProtectionService {
+public class LcpContentProtectionService(
+ public val license: LcpLicense?,
+ override val error: LcpError?
+) : ContentProtectionService {
override val isRestricted: Boolean = license == null
@@ -22,11 +25,17 @@ class LcpContentProtectionService(val license: LcpLicense?, override val error:
override val rights: ContentProtectionService.UserRights = license
?: ContentProtectionService.UserRights.AllRestricted
- override val scheme = ContentProtection.Scheme.Lcp
+ override val scheme: ContentProtection.Scheme = ContentProtection.Scheme.Lcp
- companion object {
+ override fun close() {
+ license?.close()
+ }
+
+ public companion object {
- fun createFactory(license: LcpLicense?, error: LcpException?): (Publication.Service.Context) -> LcpContentProtectionService =
+ public fun createFactory(license: LcpLicense?, error: LcpError?): (
+ Publication.Service.Context
+ ) -> LcpContentProtectionService =
{ LcpContentProtectionService(license, error) }
}
}
@@ -34,5 +43,5 @@ class LcpContentProtectionService(val license: LcpLicense?, override val error:
/**
* Returns the [LcpLicense] if the [Publication] is protected by LCP and the license is opened.
*/
-val Publication.lcpLicense: LcpLicense?
+public val Publication.lcpLicense: LcpLicense?
get() = findService(LcpContentProtectionService::class)?.license
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDecryptor.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDecryptor.kt
index 6aff0c21cd..5f15df16fc 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDecryptor.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDecryptor.kt
@@ -9,33 +9,53 @@
package org.readium.r2.lcp
-import java.io.IOException
import org.readium.r2.shared.extensions.coerceFirstNonNegative
import org.readium.r2.shared.extensions.inflate
import org.readium.r2.shared.extensions.requireLengthFitInt
-import org.readium.r2.shared.fetcher.*
-import org.readium.r2.shared.publication.Link
-import org.readium.r2.shared.publication.encryption.encryption
+import org.readium.r2.shared.publication.encryption.Encryption
+import org.readium.r2.shared.util.DebugError
import org.readium.r2.shared.util.Try
+import org.readium.r2.shared.util.Url
+import org.readium.r2.shared.util.data.ReadError
+import org.readium.r2.shared.util.flatMap
import org.readium.r2.shared.util.getOrElse
+import org.readium.r2.shared.util.resource.FailureResource
+import org.readium.r2.shared.util.resource.Resource
+import org.readium.r2.shared.util.resource.TransformingResource
+import org.readium.r2.shared.util.resource.flatMap
/**
* Decrypts a resource protected with LCP.
*/
-internal class LcpDecryptor(val license: LcpLicense?) {
-
- fun transform(resource: Resource): Resource = LazyResource {
- // Checks if the resource is encrypted and whether the encryption schemes of the resource
- // and the DRM license are the same.
- val link = resource.link()
- val encryption = link.properties.encryption
- if (encryption == null || encryption.scheme != "http://readium.org/2014/01/lcp")
- return@LazyResource resource
-
- when {
- license == null -> FailureResource(link, Resource.Exception.Forbidden())
- link.isDeflated || !link.isCbcEncrypted -> FullLcpResource(resource, license)
- else -> CbcLcpResource(resource, license)
+internal class LcpDecryptor(
+ val license: LcpLicense?,
+ val encryptionData: Map
+) {
+
+ fun transform(url: Url, resource: Resource): Resource {
+ return resource.flatMap {
+ val encryption = encryptionData[url]
+
+ // Checks if the resource is encrypted and whether the encryption schemes of the resource
+ // and the DRM license are the same.
+ if (encryption == null || encryption.scheme != "http://readium.org/2014/01/lcp") {
+ return@flatMap resource
+ }
+
+ when {
+ license == null ->
+ FailureResource(
+ ReadError.Decoding(
+ DebugError(
+ "Cannot decipher content because the publication is locked."
+ )
+ )
+ )
+ encryption.isDeflated || !encryption.isCbcEncrypted ->
+ FullLcpResource(resource, encryption, license)
+ else ->
+ CbcLcpResource(resource, encryption, license)
+ }
}
}
@@ -47,15 +67,15 @@ internal class LcpDecryptor(val license: LcpLicense?) {
*/
private class FullLcpResource(
resource: Resource,
+ private val encryption: Encryption,
private val license: LcpLicense
) : TransformingResource(resource) {
- override suspend fun transform(data: ResourceTry): ResourceTry =
- license.decryptFully(data, resource.link().isDeflated)
+ override suspend fun transform(data: Try): Try =
+ license.decryptFully(data, encryption.isDeflated)
- override suspend fun length(): ResourceTry =
- resource.link().properties.encryption?.originalLength
- ?.let { Try.success(it) }
+ override suspend fun length(): Try =
+ encryption.originalLength?.let { Try.success(it) }
?: super.length()
}
@@ -66,85 +86,158 @@ internal class LcpDecryptor(val license: LcpLicense?) {
*/
private class CbcLcpResource(
private val resource: Resource,
+ private val encryption: Encryption,
private val license: LcpLicense
- ) : Resource {
-
- lateinit var _length: ResourceTry
-
- override suspend fun link(): Link = resource.link()
+ ) : Resource by resource {
+
+ private class Cache(
+ var startIndex: Int? = null,
+ val data: ByteArray = ByteArray(3 * AES_BLOCK_SIZE)
+ )
+
+ private lateinit var _length: Try
+
+ /*
+ * Decryption needs to look around the data strictly matching the content to decipher.
+ * That means that in case of contiguous read requests, data fetched from the underlying
+ * resource are not contiguous. Every request to the underlying resource starts slightly
+ * before the end of the previous one. This is an issue with remote publications because
+ * you have to make a new HTTP request every time instead of reusing the previous one.
+ * To alleviate this, we cache the three last bytes read in each call and reuse them
+ * in the next call if possible.
+ */
+ private val _cache: Cache = Cache()
/** Plain text size. */
- override suspend fun length(): ResourceTry {
- if (::_length.isInitialized)
+ override suspend fun length(): Try {
+ if (::_length.isInitialized) {
return _length
+ }
- _length = resource.length().flatMapCatching { length ->
- if (length < 2 * AES_BLOCK_SIZE) {
- throw Exception("Invalid CBC-encrypted stream")
- }
+ _length = encryption.originalLength?.let { Try.success(it) }
+ ?: lengthFromPadding()
- val readOffset = length - (2 * AES_BLOCK_SIZE)
- resource.read(readOffset..length)
- .mapCatching { bytes ->
- val decryptedBytes = license.decrypt(bytes)
- .getOrElse { throw Exception("Can't decrypt trailing size of CBC-encrypted stream", it) }
- check(decryptedBytes.size == AES_BLOCK_SIZE)
-
- return@mapCatching length -
- AES_BLOCK_SIZE - // Minus IV
- decryptedBytes.last().toInt() // Minus padding size
- }
+ return _length
+ }
+
+ private suspend fun lengthFromPadding(): Try {
+ val length = resource.length()
+ .getOrElse { return Try.failure(it) }
+
+ if (length < 2 * AES_BLOCK_SIZE) {
+ return Try.failure(
+ ReadError.Decoding(
+ DebugError("Invalid CBC-encrypted stream.")
+ )
+ )
}
- return _length
+ val readOffset = length - (2 * AES_BLOCK_SIZE)
+ val bytes = resource.read(readOffset..length)
+ .getOrElse { return Try.failure(it) }
+
+ val decryptedBytes = license.decrypt(bytes)
+ .getOrElse {
+ return Try.failure(
+ ReadError.Decoding(
+ DebugError("Can't decrypt trailing size of CBC-encrypted stream")
+ )
+ )
+ }
+
+ check(decryptedBytes.size == AES_BLOCK_SIZE)
+
+ val adjustedLength = length -
+ AES_BLOCK_SIZE - // Minus IV
+ decryptedBytes.last().toInt() // Minus padding size
+
+ return Try.success(adjustedLength)
}
- override suspend fun read(range: LongRange?): ResourceTry {
- if (range == null)
+ override suspend fun read(range: LongRange?): Try {
+ if (range == null) {
return license.decryptFully(resource.read(), isDeflated = false)
+ }
@Suppress("NAME_SHADOWING")
val range = range
.coerceFirstNonNegative()
.requireLengthFitInt()
- if (range.isEmpty())
+ if (range.isEmpty()) {
return Try.success(ByteArray(0))
+ }
- return resource.length().flatMapCatching { encryptedLength ->
+ val encryptedLength = resource.length()
+ .getOrElse { return Try.failure(it) }
- // encrypted data is shifted by AES_BLOCK_SIZE because of IV and
- // the previous block must be provided to perform XOR on intermediate blocks
- val encryptedStart = range.first.floorMultipleOf(AES_BLOCK_SIZE.toLong())
- val encryptedEndExclusive = (range.last + 1).ceilMultipleOf(AES_BLOCK_SIZE.toLong()) + AES_BLOCK_SIZE
+ // encrypted data is shifted by AES_BLOCK_SIZE because of IV and
+ // the previous block must be provided to perform XOR on intermediate blocks
+ val encryptedStart = range.first.floorMultipleOf(AES_BLOCK_SIZE.toLong())
+ val encryptedEndExclusive = (range.last + 1).ceilMultipleOf(AES_BLOCK_SIZE.toLong()) + AES_BLOCK_SIZE
- resource.read(encryptedStart until encryptedEndExclusive).mapCatching { encryptedData ->
- val bytes = license.decrypt(encryptedData)
- .getOrElse { throw IOException("Can't decrypt the content at: ${link().href}", it) }
+ val encryptedData = getEncryptedData(encryptedStart until encryptedEndExclusive)
+ .getOrElse { return Try.failure(it) }
- // exclude the bytes added to match a multiple of AES_BLOCK_SIZE
- val sliceStart = (range.first - encryptedStart).toInt()
+ if (encryptedData.size >= _cache.data.size) {
+ // cache the three last encrypted blocks that have been read for future use
+ val cacheStart = encryptedData.size - _cache.data.size
+ _cache.startIndex = (encryptedEndExclusive - _cache.data.size).toInt()
+ encryptedData.copyInto(_cache.data, 0, cacheStart)
+ }
- // was the last block read to provide the desired range
- val lastBlockRead = encryptedLength - encryptedEndExclusive <= AES_BLOCK_SIZE
+ val bytes = license.decrypt(encryptedData)
+ .getOrElse {
+ return Try.failure(
+ ReadError.Decoding(
+ DebugError(
+ "Can't decrypt the content for resource with key: ${resource.sourceUrl}",
+ it
+ )
+ )
+ )
+ }
- val rangeLength =
- if (lastBlockRead)
- // use decrypted length to ensure range.last doesn't exceed decrypted length - 1
- range.last.coerceAtMost(length().getOrThrow() - 1) - range.first + 1
- else
- // the last block won't be read, so there's no need to compute length
- range.last - range.first + 1
+ // exclude the bytes added to match a multiple of AES_BLOCK_SIZE
+ val sliceStart = (range.first - encryptedStart).toInt()
+
+ // was the last block read to provide the desired range
+ val lastBlockRead = encryptedLength - encryptedEndExclusive <= AES_BLOCK_SIZE
+
+ val rangeLength =
+ if (lastBlockRead) {
+ // use decrypted length to ensure range.last doesn't exceed decrypted length - 1
+ val decryptedLength = length()
+ .getOrElse { return Try.failure(it) }
+ range.last.coerceAtMost(decryptedLength - 1) - range.first + 1
+ } else {
+ // the last block won't be read, so there's no need to compute length
+ range.last - range.first + 1
+ }
- // keep only enough bytes to fit the length corrected request in order to never include padding
- val sliceEnd = sliceStart + rangeLength.toInt()
+ // keep only enough bytes to fit the length corrected request in order to never include padding
+ val sliceEnd = sliceStart + rangeLength.toInt()
- bytes.sliceArray(sliceStart until sliceEnd)
- }
- }
+ return Try.success(bytes.sliceArray(sliceStart until sliceEnd))
}
- override suspend fun close() = resource.close()
+ private suspend fun getEncryptedData(range: LongRange): Try {
+ val cacheStartIndex = _cache.startIndex
+ ?.takeIf { cacheStart ->
+ val cacheEnd = cacheStart + _cache.data.size
+ range.first in cacheStart until cacheEnd && cacheEnd <= range.last + 1
+ } ?: return resource.read(range)
+
+ val bytes = ByteArray(range.last.toInt() - range.first.toInt() + 1)
+ val offsetInCache = (range.first - cacheStartIndex).toInt()
+ val fromCacheLength = _cache.data.size - offsetInCache
+
+ return resource.read(range.first + fromCacheLength..range.last).map {
+ _cache.data.copyInto(bytes, 0, offsetInCache)
+ it.copyInto(bytes, fromCacheLength)
+ bytes
+ }
+ }
companion object {
private const val AES_BLOCK_SIZE = 16 // bytes
@@ -152,14 +245,24 @@ internal class LcpDecryptor(val license: LcpLicense?) {
}
}
-private suspend fun LcpLicense.decryptFully(data: ResourceTry, isDeflated: Boolean): ResourceTry =
- data.mapCatching { encryptedData ->
+private suspend fun LcpLicense.decryptFully(
+ data: Try,
+ isDeflated: Boolean
+): Try =
+ data.flatMap { encryptedData ->
// Decrypts the resource.
var bytes = decrypt(encryptedData)
- .getOrElse { throw Exception("Failed to decrypt the resource", it) }
+ .getOrElse {
+ return Try.failure(
+ ReadError.Decoding(
+ DebugError("Failed to decrypt the resource", it)
+ )
+ )
+ }
- if (bytes.isEmpty())
+ if (bytes.isEmpty()) {
throw IllegalStateException("Lcp.nativeDecrypt returned an empty ByteArray")
+ }
// Removes the padding.
val padding = bytes.last().toInt()
@@ -170,14 +273,14 @@ private suspend fun LcpLicense.decryptFully(data: ResourceTry, isDefl
bytes = bytes.inflate(nowrap = true)
}
- bytes
+ Try.success(bytes)
}
-private val Link.isDeflated: Boolean get() =
- properties.encryption?.compression?.lowercase(java.util.Locale.ROOT) == "deflate"
+private val Encryption.isDeflated: Boolean get() =
+ compression?.lowercase(java.util.Locale.ROOT) == "deflate"
-private val Link.isCbcEncrypted: Boolean get() =
- properties.encryption?.algorithm == "http://www.w3.org/2001/04/xmlenc#aes256-cbc"
+private val Encryption.isCbcEncrypted: Boolean get() =
+ algorithm == "http://www.w3.org/2001/04/xmlenc#aes256-cbc"
private fun Long.ceilMultipleOf(divisor: Long) =
divisor * (this / divisor + if (this % divisor == 0L) 0 else 1)
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDecryptorTest.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDecryptorTest.kt
index 299d4681ae..bc38a882df 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDecryptorTest.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDecryptorTest.kt
@@ -7,19 +7,23 @@
* LICENSE file present in the project repository where this source code is maintained.
*/
+@file:Suppress("unused")
+
package org.readium.r2.lcp
import kotlin.math.ceil
import org.readium.r2.shared.extensions.coerceIn
-import org.readium.r2.shared.fetcher.Resource
-import org.readium.r2.shared.fetcher.mapCatching
import org.readium.r2.shared.publication.Publication
+import org.readium.r2.shared.util.ErrorException
+import org.readium.r2.shared.util.Try
+import org.readium.r2.shared.util.checkSuccess
import org.readium.r2.shared.util.getOrElse
+import org.readium.r2.shared.util.getOrThrow
+import org.readium.r2.shared.util.resource.Resource
import org.readium.r2.shared.util.use
import timber.log.Timber
-suspend fun Publication.checkDecryption() {
-
+internal suspend fun Publication.checkDecryption() {
checkResourcesAreReadableInOneBlock(this)
checkLengthComputationIsCorrect(this)
@@ -29,29 +33,32 @@ suspend fun Publication.checkDecryption() {
checkExceedingRangesAreAllowed(this)
}
-private suspend fun checkResourcesAreReadableInOneBlock(publication: Publication) {
+internal suspend fun checkResourcesAreReadableInOneBlock(publication: Publication) {
Timber.d("checking resources are readable in one block")
(publication.readingOrder + publication.resources)
.forEach { link ->
Timber.d("attempting to read ${link.href} in one block")
- publication.get(link).use { resource ->
+ publication.get(link)!!.use { resource ->
val bytes = resource.read()
check(bytes.isSuccess) { "failed to read ${link.href} in one block" }
}
}
}
-private suspend fun checkLengthComputationIsCorrect(publication: Publication) {
+internal suspend fun checkLengthComputationIsCorrect(publication: Publication) {
Timber.d("checking length computation is correct")
(publication.readingOrder + publication.resources)
.forEach { link ->
- val trueLength = publication.get(link).use { it.read().getOrThrow().size.toLong() }
- publication.get(link).use { resource ->
+ val trueLength = publication.get(link)!!.use { it.read().checkSuccess().size.toLong() }
+ publication.get(link)!!.use { resource ->
resource.length()
.onFailure {
- throw IllegalStateException("failed to compute length of ${link.href}", it)
+ throw IllegalStateException(
+ "failed to compute length of ${link.href}",
+ ErrorException(it)
+ )
}.onSuccess {
check(it == trueLength) { "computed length of ${link.href} seems to be wrong" }
}
@@ -59,46 +66,53 @@ private suspend fun checkLengthComputationIsCorrect(publication: Publication) {
}
}
-private suspend fun checkAllResourcesAreReadableByChunks(publication: Publication) {
+internal suspend fun checkAllResourcesAreReadableByChunks(publication: Publication) {
Timber.d("checking all resources are readable by chunks")
(publication.readingOrder + publication.resources)
.forEach { link ->
Timber.d("attempting to read ${link.href} by chunks ")
- val groundTruth = publication.get(link).use { it.read() }.getOrThrow()
+ val groundTruth = publication.get(link)!!.use { it.read() }.checkSuccess()
for (chunkSize in listOf(4096L, 2050L)) {
publication.get(link).use { resource ->
- resource.readByChunks(chunkSize, groundTruth).onFailure {
- throw IllegalStateException("failed to read ${link.href} by chunks of size $chunkSize", it)
+ resource!!.readByChunks(chunkSize, groundTruth).onFailure {
+ throw IllegalStateException(
+ "failed to read ${link.href} by chunks of size $chunkSize",
+ it
+ )
}
}
}
}
}
-private suspend fun checkExceedingRangesAreAllowed(publication: Publication) {
+internal suspend fun checkExceedingRangesAreAllowed(publication: Publication) {
Timber.d("checking exceeding ranges are allowed")
(publication.readingOrder + publication.resources)
.forEach { link ->
publication.get(link).use { resource ->
- val length = resource.length().getOrThrow()
- val fullTruth = resource.read().getOrThrow()
+ val length = resource!!.length().checkSuccess()
+ val fullTruth = resource.read().checkSuccess()
for (
- range in listOf(
- 0 until length + 100,
- 0 until length + 2048,
- length - 500 until length + 200,
- length until length + 5028,
- length + 200 until length + 500
- )
+ range in listOf(
+ 0 until length + 100,
+ 0 until length + 2048,
+ length - 500 until length + 200,
+ length until length + 5028,
+ length + 200 until length + 500
+ )
) {
resource.read(range)
.onFailure {
- throw IllegalStateException("unable to decrypt range $range from ${link.href}")
+ throw IllegalStateException(
+ "unable to decrypt range $range from ${link.href}"
+ )
}.onSuccess {
val coercedRange = range.coerceIn(0L until fullTruth.size)
- val truth = fullTruth.sliceArray(coercedRange.first.toInt()..coercedRange.last.toInt())
+ val truth = fullTruth.sliceArray(
+ coercedRange.first.toInt()..coercedRange.last.toInt()
+ )
check(it.contentEquals(truth)) {
Timber.d("decrypted length: ${it.size}")
Timber.d("expected length: ${truth.size}")
@@ -110,12 +124,16 @@ private suspend fun checkExceedingRangesAreAllowed(publication: Publication) {
}
}
-private suspend fun Resource.readByChunks(
+internal suspend fun Resource.readByChunks(
chunkSize: Long,
groundTruth: ByteArray,
shuffle: Boolean = true
) =
- length().mapCatching { length ->
+ try {
+ val length = length()
+ .mapFailure { ErrorException(it) }
+ .getOrThrow()
+
val blockNb = ceil(length / chunkSize.toDouble()).toInt()
val blocks = (0 until blockNb)
.map { Pair(it, it * chunkSize until kotlin.math.min(length, (it + 1) * chunkSize)) }
@@ -131,14 +149,22 @@ private suspend fun Resource.readByChunks(
blocks.forEach {
Timber.d("block index ${it.first}: ${it.second}")
val decryptedBytes = read(it.second).getOrElse { error ->
- throw IllegalStateException("unable to decrypt chunk ${it.second} from ${link().href}", error)
+ throw IllegalStateException(
+ "unable to decrypt chunk ${it.second} from $sourceUrl",
+ ErrorException(error)
+ )
}
check(decryptedBytes.isNotEmpty()) { "empty decrypted bytearray" }
check(decryptedBytes.contentEquals(groundTruth.sliceArray(it.second.map(Long::toInt)))) {
Timber.d("decrypted length: ${decryptedBytes.size}")
- Timber.d("expected length: ${groundTruth.sliceArray(it.second.map(Long::toInt)).size}")
- "decrypted chunk ${it.first}: ${it.second} seems to be wrong in ${link().href}"
+ Timber.d(
+ "expected length: ${groundTruth.sliceArray(it.second.map(Long::toInt)).size}"
+ )
+ "decrypted chunk ${it.first}: ${it.second} seems to be wrong in $sourceUrl"
}
Pair(it.first, decryptedBytes)
}
+ Try.success(Unit)
+ } catch (e: Exception) {
+ Try.failure(e)
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt
new file mode 100644
index 0000000000..f9c05ae2e3
--- /dev/null
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2023 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.r2.lcp
+
+import android.content.Context
+import java.io.File
+import java.util.LinkedList
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.async
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import org.readium.r2.shared.util.CoroutineQueue
+
+internal class LcpDownloadsRepository(
+ context: Context
+) {
+ private val coroutineScope: CoroutineScope =
+ MainScope()
+
+ private val queue: CoroutineQueue =
+ CoroutineQueue()
+
+ private val storageDir: Deferred =
+ coroutineScope.async {
+ withContext(Dispatchers.IO) {
+ File(context.noBackupFilesDir, LcpDownloadsRepository::class.qualifiedName!!)
+ .also { if (!it.exists()) it.mkdirs() }
+ }
+ }
+
+ private val storageFile: Deferred =
+ coroutineScope.async {
+ withContext(Dispatchers.IO) {
+ File(storageDir.await(), "licenses.json")
+ .also { if (!it.exists()) { it.writeText("{}", Charsets.UTF_8) } }
+ }
+ }
+
+ private val snapshot: Deferred> =
+ coroutineScope.async {
+ readSnapshot().toMutableMap()
+ }
+
+ fun addDownload(id: String, license: JSONObject) {
+ coroutineScope.launch {
+ val snapshotCompleted = snapshot.await()
+ snapshotCompleted[id] = license
+ writeSnapshot(snapshotCompleted)
+ }
+ }
+
+ fun removeDownload(id: String) {
+ queue.launch {
+ val snapshotCompleted = snapshot.await()
+ snapshotCompleted.remove(id)
+ writeSnapshot(snapshotCompleted)
+ }
+ }
+
+ suspend fun retrieveLicense(id: String): JSONObject? =
+ queue.await {
+ snapshot.await()[id]
+ }
+
+ private suspend fun readSnapshot(): Map {
+ return withContext(Dispatchers.IO) {
+ storageFile.await().readText(Charsets.UTF_8).toData().toMutableMap()
+ }
+ }
+
+ private suspend fun writeSnapshot(snapshot: Map) {
+ val storageFileCompleted = storageFile.await()
+ withContext(Dispatchers.IO) {
+ storageFileCompleted.writeText(snapshot.toJson(), Charsets.UTF_8)
+ }
+ }
+
+ private fun Map.toJson(): String {
+ val jsonObject = JSONObject()
+ for ((id, license) in this.entries) {
+ jsonObject.put(id, license)
+ }
+ return jsonObject.toString()
+ }
+
+ private fun String.toData(): Map {
+ val jsonObject = JSONObject(this)
+ val names = jsonObject.keys().iterator().toList()
+ return names.associateWith { jsonObject.getJSONObject(it) }
+ }
+
+ private fun Iterator.toList(): List =
+ LinkedList().apply {
+ while (hasNext())
+ this += next()
+ }.toMutableList()
+}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpError.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpError.kt
new file mode 100644
index 0000000000..85d2ee611d
--- /dev/null
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpError.kt
@@ -0,0 +1,262 @@
+/*
+ * Copyright 2020 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.r2.lcp
+
+import java.net.SocketTimeoutException
+import java.util.*
+import org.readium.r2.lcp.service.NetworkException
+import org.readium.r2.shared.util.DebugError
+import org.readium.r2.shared.util.Error
+import org.readium.r2.shared.util.ErrorException
+import org.readium.r2.shared.util.ThrowableError
+import org.readium.r2.shared.util.Url
+
+public sealed class LcpError(
+ override val message: String,
+ override val cause: Error? = null
+) : Error {
+
+ /** The interaction is not available with this License. */
+ public object LicenseInteractionNotAvailable :
+ LcpError("This interaction is not available.")
+
+ /** This License's profile is not supported by liblcp. */
+ public object LicenseProfileNotSupported :
+ LcpError(
+ "This License has a profile identifier that this app cannot handle, the publication cannot be processed"
+ )
+
+ /** Failed to retrieve the Certificate Revocation List. */
+ public object CrlFetching :
+ LcpError("Can't retrieve the Certificate Revocation List")
+
+ /** A network request failed with the given exception. */
+ public class Network(override val cause: Error?) :
+ LcpError("NetworkError", cause = cause) {
+
+ internal constructor(throwable: Throwable) : this(ThrowableError(throwable))
+ }
+
+ /**
+ * An unexpected LCP exception occurred. Please post an issue on r2-lcp-kotlin with the error
+ * message and how to reproduce it.
+ */
+ public class Runtime(message: String) :
+ LcpError("Unexpected LCP error", DebugError(message))
+
+ /** An unknown low-level exception was reported. */
+ public class Unknown(override val cause: Error?) :
+ LcpError("Unknown LCP error") {
+
+ internal constructor(throwable: Throwable) : this(ThrowableError(throwable))
+ }
+
+ /**
+ * Errors while checking the status of the License, using the Status Document.
+ *
+ * The app should notify the user and stop there. The message to the user must be clear about
+ * the status of the license: don't display "expired" if the status is "revoked". The date and
+ * time corresponding to the new status should be displayed (e.g. "The license expired on 01
+ * January 2018").
+ */
+ public sealed class LicenseStatus(
+ message: String,
+ cause: Error? = null
+ ) : LcpError(message, cause) {
+
+ public class Cancelled(public val date: Date) :
+ LicenseStatus("This license was cancelled on $date")
+
+ public class Returned(public val date: Date) :
+ LicenseStatus("This license has been returned on $date")
+
+ public class NotStarted(public val start: Date) :
+ LicenseStatus("This license starts on $start")
+
+ public class Expired(public val end: Date) :
+ LicenseStatus("This license expired on $end")
+
+ /**
+ * If the license has been revoked, the user message should display the number of devices which
+ * registered to the server. This count can be calculated from the number of "register" events
+ * in the status document. If no event is logged in the status document, no such message should
+ * appear (certainly not "The license was registered by 0 devices").
+ */
+ public class Revoked(public val date: Date, public val devicesCount: Int) :
+ LicenseStatus(
+ "This license was revoked by its provider on $date. It was registered by $devicesCount device(s)."
+ )
+ }
+
+ /**
+ * Errors while renewing a loan.
+ */
+ public sealed class Renew(
+ message: String,
+ cause: Error? = null
+ ) : LcpError(message, cause) {
+
+ /** Your publication could not be renewed properly. */
+ public object RenewFailed :
+ Renew("Publication could not be renewed properly")
+
+ /** Incorrect renewal period, your publication could not be renewed. */
+ public class InvalidRenewalPeriod(public val maxRenewDate: Date?) :
+ Renew("Incorrect renewal period, your publication could not be renewed")
+
+ /** An unexpected error has occurred on the licensing server. */
+ public object UnexpectedServerError :
+ Renew("An unexpected error has occurred on the server")
+ }
+
+ /**
+ * Errors while returning a loan.
+ */
+ public sealed class Return(
+ message: String,
+ cause: Error? = null
+ ) : LcpError(message, cause) {
+
+ /** Your publication could not be returned properly. */
+ public object ReturnFailed :
+ Return("Publication could not be returned properly")
+
+ /** Your publication has already been returned before or is expired. */
+
+ public object AlreadyReturnedOrExpired :
+ Return("Publication has already been returned before or is expired")
+
+ /** An unexpected error has occurred on the licensing server. */
+ public object UnexpectedServerError :
+ Return("An unexpected error has occurred on the server")
+ }
+
+ /**
+ * Errors while parsing the License or Status JSON Documents.
+ */
+ public sealed class Parsing(
+ message: String,
+ cause: Error? = null
+ ) : LcpError(message, cause) {
+
+ /** The JSON is malformed and can't be parsed. */
+ public object MalformedJSON :
+ Parsing("The JSON is malformed and can't be parsed")
+
+ /** The JSON is not representing a valid License Document. */
+ public object LicenseDocument :
+ Parsing("The JSON is not representing a valid License Document")
+
+ /** The JSON is not representing a valid Status Document. */
+ public object StatusDocument :
+ Parsing("The JSON is not representing a valid Status Document")
+
+ /** Invalid Link. */
+ public object Link :
+ Parsing("The JSON is not representing a valid document")
+
+ /** Invalid Encryption. */
+ public object Encryption :
+ Parsing("The JSON is not representing a valid document")
+
+ /** Invalid License Document Signature. */
+ public object Signature :
+ Parsing("The JSON is not representing a valid document")
+
+ /** Invalid URL for link with [rel]. */
+ public class Url(public val rel: String) :
+ Parsing("The JSON is not representing a valid document")
+ }
+
+ /**
+ * Errors while reading or writing a LCP container (LCPL, EPUB, LCPDF, etc.)
+ */
+ public sealed class Container(
+ message: String,
+ cause: Error? = null
+ ) : LcpError(message, cause) {
+
+ /** Can't access the container, it's format is wrong. */
+ public object OpenFailed :
+ Container("Can't open the license container")
+
+ /** The file at given relative path is not found in the Container. */
+ public class FileNotFound(public val url: Url) :
+ Container("License not found in container")
+
+ /** Can't read the file at given relative path in the Container. */
+ public class ReadFailed(public val url: Url?) :
+ Container("Can't read license from container")
+
+ /** Can't write the file at given relative path in the Container. */
+ public class WriteFailed(public val url: Url?) :
+ Container("Can't write license in container")
+ }
+
+ /**
+ * An error occurred while checking the integrity of the License, it can't be retrieved.
+ */
+ public sealed class LicenseIntegrity(
+ message: String,
+ cause: Error? = null
+ ) : LcpError(message, cause) {
+
+ public object CertificateRevoked :
+ LicenseIntegrity("Certificate has been revoked in the CRL")
+
+ public object InvalidCertificateSignature :
+ LicenseIntegrity("Certificate has not been signed by CA")
+
+ public object InvalidLicenseSignatureDate :
+ LicenseIntegrity("License has been issued by an expired certificate")
+
+ public object InvalidLicenseSignature :
+ LicenseIntegrity("License signature does not match")
+
+ public object InvalidUserKeyCheck :
+ LicenseIntegrity("User key check invalid")
+ }
+
+ public sealed class Decryption(
+ message: String,
+ cause: Error? = null
+ ) : LcpError(message, cause) {
+
+ public object ContentKeyDecryptError :
+ Decryption("Unable to decrypt encrypted content key from user key")
+
+ public object ContentDecryptError : Decryption(
+ "Unable to decrypt encrypted content from content key"
+ )
+ }
+
+ public companion object {
+
+ internal fun wrap(e: Exception): LcpError = when (e) {
+ is LcpException -> e.error
+ is NetworkException -> Network(e)
+ is SocketTimeoutException -> Network(e)
+ else -> Unknown(e)
+ }
+ }
+}
+
+internal class LcpException(val error: LcpError) : Exception(error.message, ErrorException(error))
+
+@Deprecated(
+ "Renamed to `LcpException`",
+ replaceWith = ReplaceWith("LcpException"),
+ level = DeprecationLevel.ERROR
+)
+public typealias LCPError = LcpError
+
+@Deprecated(
+ "Use `getUserMessage()` instead",
+ replaceWith = ReplaceWith("getUserMessage(context)"),
+ level = DeprecationLevel.ERROR
+)
+public val LcpError.errorDescription: String get() = message
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpException.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpException.kt
deleted file mode 100644
index 0f35992c47..0000000000
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpException.kt
+++ /dev/null
@@ -1,193 +0,0 @@
-/*
- * Copyright 2020 Readium Foundation. All rights reserved.
- * Use of this source code is governed by the BSD-style license
- * available in the top-level LICENSE file of the project.
- */
-
-package org.readium.r2.lcp
-
-import androidx.annotation.PluralsRes
-import androidx.annotation.StringRes
-import java.net.SocketTimeoutException
-import java.util.*
-import org.readium.r2.shared.UserException
-
-sealed class LcpException(
- userMessageId: Int,
- vararg args: Any,
- quantity: Int? = null,
- cause: Throwable? = null
-) : UserException(userMessageId, quantity, *args, cause = cause) {
- constructor(@StringRes userMessageId: Int, vararg args: Any, cause: Throwable? = null) : this(userMessageId, *args, quantity = null, cause = cause)
- constructor(
- @PluralsRes userMessageId: Int,
- quantity: Int,
- vararg args: Any,
- cause: Throwable? = null
- ) : this(userMessageId, *args, quantity = quantity, cause = cause)
-
- /** The interaction is not available with this License. */
- object LicenseInteractionNotAvailable : LcpException(R.string.r2_lcp_exception_license_interaction_not_available)
-
- /** This License's profile is not supported by liblcp. */
- object LicenseProfileNotSupported : LcpException(R.string.r2_lcp_exception_license_profile_not_supported)
-
- /** Failed to retrieve the Certificate Revocation List. */
- object CrlFetching : LcpException(R.string.r2_lcp_exception_crl_fetching)
-
- /** A network request failed with the given exception. */
- class Network(override val cause: Throwable?) : LcpException(R.string.r2_lcp_exception_network, cause = cause)
-
- /**
- * An unexpected LCP exception occurred. Please post an issue on r2-lcp-kotlin with the error
- * message and how to reproduce it.
- */
- class Runtime(override val message: String) : LcpException(R.string.r2_lcp_exception_runtime)
-
- /** An unknown low-level exception was reported. */
- class Unknown(override val cause: Throwable?) : LcpException(R.string.r2_lcp_exception_unknown)
-
- /**
- * Errors while checking the status of the License, using the Status Document.
- *
- * The app should notify the user and stop there. The message to the user must be clear about
- * the status of the license: don't display "expired" if the status is "revoked". The date and
- * time corresponding to the new status should be displayed (e.g. "The license expired on 01
- * January 2018").
- */
- sealed class LicenseStatus(userMessageId: Int, vararg args: Any, quantity: Int? = null) : LcpException(userMessageId, *args, quantity = quantity) {
- constructor(@StringRes userMessageId: Int, vararg args: Any) : this(userMessageId, *args, quantity = null)
- constructor(@PluralsRes userMessageId: Int, quantity: Int, vararg args: Any) : this(userMessageId, *args, quantity = quantity)
-
- class Cancelled(val date: Date) : LicenseStatus(R.string.r2_lcp_exception_license_status_cancelled, date)
-
- class Returned(val date: Date) : LicenseStatus(R.string.r2_lcp_exception_license_status_returned, date)
-
- class NotStarted(val start: Date) : LicenseStatus(R.string.r2_lcp_exception_license_status_not_started, start)
-
- class Expired(val end: Date) : LicenseStatus(R.string.r2_lcp_exception_license_status_expired, end)
-
- /**
- * If the license has been revoked, the user message should display the number of devices which
- * registered to the server. This count can be calculated from the number of "register" events
- * in the status document. If no event is logged in the status document, no such message should
- * appear (certainly not "The license was registered by 0 devices").
- */
- class Revoked(val date: Date, val devicesCount: Int) :
- LicenseStatus(R.plurals.r2_lcp_exception_license_status_revoked, devicesCount, date, devicesCount)
- }
-
- /**
- * Errors while renewing a loan.
- */
- sealed class Renew(@StringRes userMessageId: Int) : LcpException(userMessageId) {
-
- /** Your publication could not be renewed properly. */
- object RenewFailed : Renew(R.string.r2_lcp_exception_renew_renew_failed)
-
- /** Incorrect renewal period, your publication could not be renewed. */
- class InvalidRenewalPeriod(val maxRenewDate: Date?) : Renew(R.string.r2_lcp_exception_renew_invalid_renewal_period)
-
- /** An unexpected error has occurred on the licensing server. */
- object UnexpectedServerError : Renew(R.string.r2_lcp_exception_renew_unexpected_server_error)
- }
-
- /**
- * Errors while returning a loan.
- */
- sealed class Return(@StringRes userMessageId: Int) : LcpException(userMessageId) {
-
- /** Your publication could not be returned properly. */
- object ReturnFailed : Return(R.string.r2_lcp_exception_return_return_failed)
-
- /** Your publication has already been returned before or is expired. */
-
- object AlreadyReturnedOrExpired : Return(R.string.r2_lcp_exception_return_already_returned_or_expired)
-
- /** An unexpected error has occurred on the licensing server. */
- object UnexpectedServerError : Return(R.string.r2_lcp_exception_return_unexpected_server_error)
- }
-
- /**
- * Errors while parsing the License or Status JSON Documents.
- */
- sealed class Parsing(@StringRes userMessageId: Int = R.string.r2_lcp_exception_parsing) : LcpException(userMessageId) {
-
- /** The JSON is malformed and can't be parsed. */
- object MalformedJSON : Parsing(R.string.r2_lcp_exception_parsing_malformed_json)
-
- /** The JSON is not representing a valid License Document. */
- object LicenseDocument : Parsing(R.string.r2_lcp_exception_parsing_license_document)
-
- /** The JSON is not representing a valid Status Document. */
- object StatusDocument : Parsing(R.string.r2_lcp_exception_parsing_status_document)
-
- /** Invalid Link. */
- object Link : Parsing()
-
- /** Invalid Encryption. */
- object Encryption : Parsing()
-
- /** Invalid License Document Signature. */
- object Signature : Parsing()
-
- /** Invalid URL for link with [rel]. */
- class Url(val rel: String) : Parsing()
- }
-
- /**
- * Errors while reading or writing a LCP container (LCPL, EPUB, LCPDF, etc.)
- */
- sealed class Container(@StringRes userMessageId: Int) : LcpException(userMessageId) {
-
- /** Can't access the container, it's format is wrong. */
- object OpenFailed : Container(R.string.r2_lcp_exception_container_open_failed)
-
- /** The file at given relative path is not found in the Container. */
- class FileNotFound(val path: String) : Container(R.string.r2_lcp_exception_container_file_not_found)
-
- /** Can't read the file at given relative path in the Container. */
- class ReadFailed(val path: String) : Container(R.string.r2_lcp_exception_container_read_failed)
-
- /** Can't write the file at given relative path in the Container. */
- class WriteFailed(val path: String) : Container(R.string.r2_lcp_exception_container_write_failed)
- }
-
- /**
- * An error occurred while checking the integrity of the License, it can't be retrieved.
- */
- sealed class LicenseIntegrity(@StringRes userMessageId: Int) : LcpException(userMessageId) {
-
- object CertificateRevoked : LicenseIntegrity(R.string.r2_lcp_exception_license_integrity_certificate_revoked)
-
- object InvalidCertificateSignature : LicenseIntegrity(R.string.r2_lcp_exception_license_integrity_invalid_certificate_signature)
-
- object InvalidLicenseSignatureDate : LicenseIntegrity(R.string.r2_lcp_exception_license_integrity_invalid_license_signature_date)
-
- object InvalidLicenseSignature : LicenseIntegrity(R.string.r2_lcp_exception_license_integrity_invalid_license_signature)
-
- object InvalidUserKeyCheck : LicenseIntegrity(R.string.r2_lcp_exception_license_integrity_invalid_user_key_check)
- }
-
- sealed class Decryption(@StringRes userMessageId: Int) : LcpException(userMessageId) {
-
- object ContentKeyDecryptError : Decryption(R.string.r2_lcp_exception_decryption_content_key_decrypt_error)
-
- object ContentDecryptError : Decryption(R.string.r2_lcp_exception_decryption_content_decrypt_error)
- }
-
- companion object {
-
- internal fun wrap(e: Exception?): LcpException = when (e) {
- is LcpException -> e
- is SocketTimeoutException -> Network(e)
- else -> Unknown(e)
- }
- }
-}
-
-@Deprecated("Renamed to `LcpException`", replaceWith = ReplaceWith("LcpException"))
-typealias LCPError = LcpException
-
-@Deprecated("Use `getUserMessage()` instead", replaceWith = ReplaceWith("getUserMessage(context)"))
-val LcpException.errorDescription: String? get() = message
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpLicense.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpLicense.kt
index e8ca3acc08..1ac13e10a1 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpLicense.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpLicense.kt
@@ -10,52 +10,56 @@ import java.net.URL
import java.util.*
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.joda.time.DateTime
import org.readium.r2.lcp.license.model.LicenseDocument
import org.readium.r2.lcp.license.model.StatusDocument
import org.readium.r2.shared.publication.services.ContentProtectionService
+import org.readium.r2.shared.util.Closeable
import org.readium.r2.shared.util.Try
+import org.readium.r2.shared.util.Url
+import org.readium.r2.shared.util.toDebugDescription
import timber.log.Timber
/**
* Opened license, used to decipher a protected publication and manage its license.
*/
-interface LcpLicense : ContentProtectionService.UserRights {
+public interface LcpLicense : ContentProtectionService.UserRights, Closeable {
/**
* License Document information.
* https://readium.org/lcp-specs/releases/lcp/latest.html
*/
- val license: LicenseDocument
+ public val license: LicenseDocument
/**
* License Status Document information.
* https://readium.org/lcp-specs/releases/lsd/latest.html
*/
- val status: StatusDocument?
+ public val status: StatusDocument?
/**
* Number of remaining characters allowed to be copied by the user. If null, there's no limit.
*/
- val charactersToCopyLeft: Int?
+ public val charactersToCopyLeft: StateFlow
/**
* Number of pages allowed to be printed by the user. If null, there's no limit.
*/
- val pagesToPrintLeft: Int?
+ public val pagesToPrintLeft: StateFlow
/**
* Can the user renew the loaned publication?
*/
- val canRenewLoan: Boolean
+ public val canRenewLoan: Boolean
/**
* The maximum potential date to renew to.
* If null, then the renew date might not be customizable.
*/
- val maxRenewDate: Date?
+ public val maxRenewDate: Date?
/**
* Renews the loan by starting a renew LSD interaction.
@@ -63,22 +67,22 @@ interface LcpLicense : ContentProtectionService.UserRights {
* @param prefersWebPage Indicates whether the loan should be renewed through a web page if
* available, instead of programmatically.
*/
- suspend fun renewLoan(listener: RenewListener, prefersWebPage: Boolean = false): Try
+ public suspend fun renewLoan(listener: RenewListener, prefersWebPage: Boolean = false): Try
/**
* Can the user return the loaned publication?
*/
- val canReturnPublication: Boolean
+ public val canReturnPublication: Boolean
/**
* Returns the publication to its provider.
*/
- suspend fun returnPublication(): Try
+ public suspend fun returnPublication(): Try
/**
* Decrypts the given [data] encrypted with the license's content key.
*/
- suspend fun decrypt(data: ByteArray): Try
+ public suspend fun decrypt(data: ByteArray): Try
/**
* UX delegate for the loan renew LSD interaction.
@@ -86,7 +90,7 @@ interface LcpLicense : ContentProtectionService.UserRights {
* If your application fits Material Design guidelines, take a look at [MaterialRenewListener]
* for a default implementation.
*/
- interface RenewListener {
+ public interface RenewListener {
/**
* Called when the renew interaction allows to customize the end date programmatically.
@@ -94,7 +98,7 @@ interface LcpLicense : ContentProtectionService.UserRights {
*
* The returned date can't exceed [maximumDate].
*/
- suspend fun preferredEndDate(maximumDate: Date?): Date?
+ public suspend fun preferredEndDate(maximumDate: Date?): Date?
/**
* Called when the renew interaction uses an HTML web page.
@@ -102,40 +106,70 @@ interface LcpLicense : ContentProtectionService.UserRights {
* You should present the URL in a Chrome Custom Tab and terminate the function when the
* web page is dismissed by the user.
*/
- suspend fun openWebPage(url: URL)
+ public suspend fun openWebPage(url: Url)
}
- @Deprecated("Use `license.encryption.profile` instead", ReplaceWith("license.encryption.profile"))
- val encryptionProfile: String? get() =
+ @Deprecated(
+ "Use `license.encryption.profile` instead",
+ ReplaceWith("license.encryption.profile"),
+ level = DeprecationLevel.ERROR
+ )
+ public val encryptionProfile: String? get() =
license.encryption.profile
- @Deprecated("Use `decrypt()` with coroutines instead", ReplaceWith("decrypt(data)"))
- fun decipher(data: ByteArray): ByteArray? =
+ @Deprecated(
+ "Use `decrypt()` with coroutines instead",
+ ReplaceWith("decrypt(data)"),
+ level = DeprecationLevel.ERROR
+ )
+ public fun decipher(data: ByteArray): ByteArray? =
runBlocking { decrypt(data) }
- .onFailure { Timber.e(it) }
+ .onFailure { Timber.e(it.toDebugDescription()) }
.getOrNull()
- @Deprecated("Use `renewLoan` with `RenewListener` instead", ReplaceWith("renewLoan(LcpLicense.RenewListener)"), level = DeprecationLevel.ERROR)
- suspend fun renewLoan(end: DateTime?, urlPresenter: suspend (URL) -> Unit): Try = Try.success(Unit)
-
- @Deprecated("Use `renewLoan` with `RenewListener` instead", ReplaceWith("renewLoan(LcpLicense.RenewListener)"), level = DeprecationLevel.ERROR)
- fun renewLoan(
+ @Deprecated(
+ "Use `renewLoan` with `RenewListener` instead",
+ ReplaceWith("renewLoan(LcpLicense.RenewListener)"),
+ level = DeprecationLevel.ERROR
+ )
+ public suspend fun renewLoan(end: DateTime?, urlPresenter: suspend (URL) -> Unit): Try = Try.success(
+ Unit
+ )
+
+ @Deprecated(
+ "Use `renewLoan` with `RenewListener` instead",
+ ReplaceWith("renewLoan(LcpLicense.RenewListener)"),
+ level = DeprecationLevel.ERROR
+ )
+ public fun renewLoan(
end: DateTime?,
present: (URL, dismissed: () -> Unit) -> Unit,
- completion: (LcpException?) -> Unit
+ completion: (LcpError?) -> Unit
) {}
- @Deprecated("Use `returnPublication()` with coroutines instead", ReplaceWith("returnPublication"))
+ @Deprecated(
+ "Use `returnPublication()` with coroutines instead",
+ ReplaceWith("returnPublication"),
+ level = DeprecationLevel.ERROR
+ )
@DelicateCoroutinesApi
- fun returnPublication(completion: (LcpException?) -> Unit) {
+ public fun returnPublication(completion: (LcpError?) -> Unit) {
GlobalScope.launch {
- completion(returnPublication().exceptionOrNull())
+ completion(returnPublication().failureOrNull())
}
}
}
-@Deprecated("Renamed to `LcpService`", replaceWith = ReplaceWith("LcpService"))
-typealias LCPService = LcpService
-
-@Deprecated("Renamed to `LcpLicense`", replaceWith = ReplaceWith("LcpLicense"))
-typealias LCPLicense = LcpLicense
+@Deprecated(
+ "Renamed to `LcpService`",
+ replaceWith = ReplaceWith("LcpService"),
+ level = DeprecationLevel.ERROR
+)
+public typealias LCPService = LcpService
+
+@Deprecated(
+ "Renamed to `LcpLicense`",
+ replaceWith = ReplaceWith("LcpLicense"),
+ level = DeprecationLevel.ERROR
+)
+public typealias LCPLicense = LcpLicense
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt
new file mode 100644
index 0000000000..b16f261e85
--- /dev/null
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt
@@ -0,0 +1,332 @@
+/*
+ * Copyright 2023 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.r2.lcp
+
+import android.content.Context
+import java.io.File
+import kotlin.io.encoding.Base64
+import kotlin.io.encoding.ExperimentalEncodingApi
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.launch
+import org.readium.r2.lcp.license.container.createLicenseContainer
+import org.readium.r2.lcp.license.model.LicenseDocument
+import org.readium.r2.lcp.util.sha256
+import org.readium.r2.shared.extensions.tryOrLog
+import org.readium.r2.shared.util.AbsoluteUrl
+import org.readium.r2.shared.util.ErrorException
+import org.readium.r2.shared.util.FileExtension
+import org.readium.r2.shared.util.asset.AssetRetriever
+import org.readium.r2.shared.util.downloads.DownloadManager
+import org.readium.r2.shared.util.format.EpubSpecification
+import org.readium.r2.shared.util.format.Format
+import org.readium.r2.shared.util.format.FormatHints
+import org.readium.r2.shared.util.format.FormatSpecification
+import org.readium.r2.shared.util.format.LcpSpecification
+import org.readium.r2.shared.util.format.ZipSpecification
+import org.readium.r2.shared.util.getOrElse
+import org.readium.r2.shared.util.mediatype.MediaType
+
+/**
+ * Utility to acquire a protected publication from an LCP License Document.
+ */
+public class LcpPublicationRetriever(
+ context: Context,
+ private val downloadManager: DownloadManager,
+ private val assetRetriever: AssetRetriever
+) {
+
+ @JvmInline
+ public value class RequestId(public val value: String)
+
+ public interface Listener {
+
+ /**
+ * Called when the publication has been successfully acquired.
+ */
+ public fun onAcquisitionCompleted(
+ requestId: RequestId,
+ acquiredPublication: LcpService.AcquiredPublication
+ )
+
+ /**
+ * The acquisition with ID [requestId] has downloaded [downloaded] out of [expected] bytes.
+ */
+ public fun onAcquisitionProgressed(
+ requestId: RequestId,
+ downloaded: Long,
+ expected: Long?
+ )
+
+ /**
+ * The acquisition with ID [requestId] has failed with the given [error].
+ */
+ public fun onAcquisitionFailed(
+ requestId: RequestId,
+ error: LcpError
+ )
+
+ /**
+ * The acquisition with ID [requestId] has been cancelled.
+ */
+ public fun onAcquisitionCancelled(
+ requestId: RequestId
+ )
+ }
+
+ /**
+ * Submits a new request to acquire the publication protected with the given [license].
+ *
+ * The given [listener] will automatically be registered.
+ *
+ * Returns the ID of the acquisition request, which can be used to cancel it.
+ */
+ public fun retrieve(
+ license: LicenseDocument,
+ listener: Listener
+ ): RequestId {
+ val requestId = fetchPublication(license)
+ addListener(requestId, listener)
+ return requestId
+ }
+
+ /**
+ * Registers a listener for the acquisition with the given [requestId].
+ *
+ * If the [downloadManager] provided during construction supports background downloading, this
+ * should typically be used when you create a new instance after the app restarted.
+ */
+ public fun register(
+ requestId: RequestId,
+ listener: Listener
+ ) {
+ addListener(
+ requestId,
+ listener,
+ onFirstListenerAdded = {
+ downloadManager.register(
+ DownloadManager.RequestId(requestId.value),
+ downloadListener
+ )
+ }
+ )
+ }
+
+ /**
+ * Cancels the acquisition with the given [requestId].
+ */
+ public fun cancel(requestId: RequestId) {
+ downloadManager.cancel(DownloadManager.RequestId(requestId.value))
+ downloadsRepository.removeDownload(requestId.value)
+ }
+
+ /**
+ * Releases any in-memory resource associated with this [LcpPublicationRetriever].
+ *
+ * If the pending acquisitions cannot continue in the background, they will be cancelled.
+ */
+ public fun close() {
+ downloadManager.close()
+ }
+
+ private val coroutineScope: CoroutineScope =
+ MainScope()
+
+ private val downloadsRepository: LcpDownloadsRepository =
+ LcpDownloadsRepository(context)
+
+ private val downloadListener: DownloadManager.Listener =
+ DownloadListener()
+
+ private val listeners: MutableMap> =
+ mutableMapOf()
+
+ private fun addListener(
+ requestId: RequestId,
+ listener: Listener,
+ onFirstListenerAdded: () -> Unit = {}
+ ) {
+ listeners
+ .getOrPut(requestId) {
+ onFirstListenerAdded()
+ mutableListOf()
+ }
+ .add(listener)
+ }
+
+ private fun fetchPublication(
+ license: LicenseDocument
+ ): RequestId {
+ val url = license.publicationLink.url() as AbsoluteUrl
+
+ val requestId = downloadManager.submit(
+ request = DownloadManager.Request(
+ url = url,
+ headers = emptyMap()
+ ),
+ listener = downloadListener
+ )
+
+ downloadsRepository.addDownload(requestId.value, license.json)
+ return RequestId(requestId.value)
+ }
+
+ private inner class DownloadListener : DownloadManager.Listener {
+
+ @OptIn(ExperimentalEncodingApi::class, ExperimentalStdlibApi::class)
+ override fun onDownloadCompleted(
+ requestId: DownloadManager.RequestId,
+ download: DownloadManager.Download
+ ) {
+ coroutineScope.launch {
+ val lcpRequestId = RequestId(requestId.value)
+ val listenersForId = checkNotNull(listeners.remove(lcpRequestId))
+
+ fun failWithError(error: LcpError) {
+ listenersForId.forEach {
+ it.onAcquisitionFailed(lcpRequestId, error)
+ }
+ tryOrLog { download.file.delete() }
+ }
+
+ val license = downloadsRepository.retrieveLicense(requestId.value)
+ ?.let { LicenseDocument(it) }
+ .also { downloadsRepository.removeDownload(requestId.value) }
+ ?: run {
+ failWithError(
+ LcpError.wrap(
+ Exception("Couldn't retrieve license from local storage.")
+ )
+ )
+ return@launch
+ }
+
+ license.publicationLink.hash
+ ?.takeIf { download.file.checkSha256(it) == false }
+ ?.run {
+ failWithError(
+ LcpError.Network(
+ Exception("Digest mismatch: download looks corrupted.")
+ )
+ )
+ return@launch
+ }
+
+ val format =
+ assetRetriever.sniffFormat(
+ download.file,
+ FormatHints(
+ mediaTypes = listOfNotNull(
+ license.publicationLink.mediaType,
+ download.mediaType
+ )
+ )
+ ).getOrElse {
+ when (it) {
+ is AssetRetriever.RetrieveError.Reading -> {
+ failWithError(LcpError.wrap(ErrorException(it)))
+ return@launch
+ }
+ is AssetRetriever.RetrieveError.FormatNotSupported -> {
+ Format(
+ specification = FormatSpecification(
+ ZipSpecification,
+ EpubSpecification,
+ LcpSpecification
+ ),
+ mediaType = MediaType.EPUB,
+ fileExtension = FileExtension("epub")
+ )
+ }
+ }
+ }
+
+ try {
+ // Saves the License Document into the downloaded publication
+ val container = createLicenseContainer(download.file, format.specification)
+ container.write(license)
+ } catch (e: Exception) {
+ failWithError(LcpError.wrap(e))
+ return@launch
+ }
+
+ val acquiredPublication = LcpService.AcquiredPublication(
+ localFile = download.file,
+ suggestedFilename = "${license.id}.${format.fileExtension}",
+ format,
+ licenseDocument = license
+ )
+
+ listenersForId.forEach {
+ it.onAcquisitionCompleted(lcpRequestId, acquiredPublication)
+ }
+ }
+ }
+
+ override fun onDownloadProgressed(
+ requestId: DownloadManager.RequestId,
+ downloaded: Long,
+ expected: Long?
+ ) {
+ val lcpRequestId = RequestId(requestId.value)
+ val listenersForId = checkNotNull(listeners[lcpRequestId])
+
+ listenersForId.forEach {
+ it.onAcquisitionProgressed(
+ lcpRequestId,
+ downloaded,
+ expected
+ )
+ }
+ }
+
+ override fun onDownloadFailed(
+ requestId: DownloadManager.RequestId,
+ error: DownloadManager.DownloadError
+ ) {
+ val lcpRequestId = RequestId(requestId.value)
+ val listenersForId = checkNotNull(listeners[lcpRequestId])
+
+ downloadsRepository.removeDownload(requestId.value)
+
+ listenersForId.forEach {
+ it.onAcquisitionFailed(
+ lcpRequestId,
+ LcpError.Network(ErrorException(error))
+ )
+ }
+
+ listeners.remove(lcpRequestId)
+ }
+
+ override fun onDownloadCancelled(requestId: DownloadManager.RequestId) {
+ val lcpRequestId = RequestId(requestId.value)
+ val listenersForId = checkNotNull(listeners[lcpRequestId])
+ listenersForId.forEach {
+ it.onAcquisitionCancelled(lcpRequestId)
+ }
+ listeners.remove(lcpRequestId)
+ }
+ }
+
+ /**
+ * Checks that the sha256 sum of file content matches the expected one.
+ * Returns null if we can't decide.
+ */
+ @OptIn(ExperimentalEncodingApi::class, ExperimentalStdlibApi::class)
+ private fun File.checkSha256(expected: String): Boolean? {
+ val actual = sha256() ?: return null
+
+ // Supports hexadecimal encoding for compatibility.
+ // See https://github.com/readium/lcp-specs/issues/52
+ return when (expected.length) {
+ 44 -> Base64.encode(actual) == expected
+ 64 -> actual.toHexString() == expected
+ else -> null
+ }
+ }
+}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt
index 1c099f16b7..23222a2d27 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt
@@ -11,8 +11,11 @@ package org.readium.r2.lcp
import android.content.Context
import java.io.File
-import kotlinx.coroutines.*
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import org.readium.r2.lcp.auth.LcpDialogAuthentication
+import org.readium.r2.lcp.license.model.LicenseDocument
import org.readium.r2.lcp.persistence.LcpDatabase
import org.readium.r2.lcp.service.CRLService
import org.readium.r2.lcp.service.DeviceRepository
@@ -23,18 +26,28 @@ import org.readium.r2.lcp.service.LicensesService
import org.readium.r2.lcp.service.NetworkService
import org.readium.r2.lcp.service.PassphrasesRepository
import org.readium.r2.lcp.service.PassphrasesService
-import org.readium.r2.shared.publication.ContentProtection
+import org.readium.r2.shared.publication.protection.ContentProtection
import org.readium.r2.shared.util.Try
+import org.readium.r2.shared.util.asset.Asset
+import org.readium.r2.shared.util.asset.AssetRetriever
+import org.readium.r2.shared.util.downloads.DownloadManager
+import org.readium.r2.shared.util.format.Format
/**
* Service used to acquire and open publications protected with LCP.
*/
-interface LcpService {
+public interface LcpService {
/**
- * Returns if the publication is protected by LCP.
+ * Returns if the file is a LCP license document or a publication protected by LCP.
*/
- suspend fun isLcpProtected(file: File): Boolean
+ @Deprecated(
+ "Use an AssetSniffer and check the conformance of the returned format to LcpSpecification",
+ level = DeprecationLevel.ERROR
+ )
+ public suspend fun isLcpProtected(file: File): Boolean {
+ throw NotImplementedError()
+ }
/**
* Acquires a protected publication from a standalone LCPL's bytes.
@@ -43,7 +56,14 @@ interface LcpService {
*
* @param onProgress Callback to follow the acquisition progress from 0.0 to 1.0.
*/
- suspend fun acquirePublication(lcpl: ByteArray, onProgress: (Double) -> Unit = {}): Try
+ @Deprecated(
+ "Use a LcpPublicationRetriever instead.",
+ ReplaceWith("publicationRetriever()"),
+ level = DeprecationLevel.ERROR
+ )
+ public suspend fun acquirePublication(lcpl: ByteArray, onProgress: (Double) -> Unit = {}): Try {
+ throw NotImplementedError()
+ }
/**
* Acquires a protected publication from a standalone LCPL file.
@@ -52,12 +72,15 @@ interface LcpService {
*
* @param onProgress Callback to follow the acquisition progress from 0.0 to 1.0.
*/
- suspend fun acquirePublication(lcpl: File, onProgress: (Double) -> Unit = {}): Try = withContext(Dispatchers.IO) {
- try {
- acquirePublication(lcpl.readBytes(), onProgress)
- } catch (e: Exception) {
- Try.failure(LcpException.wrap(e))
- }
+ @Deprecated(
+ "Use a LcpPublicationRetriever instead.",
+ ReplaceWith("publicationRetriever()"),
+ level = DeprecationLevel.ERROR
+ )
+ public suspend fun acquirePublication(lcpl: File, onProgress: (Double) -> Unit = {}): Try = withContext(
+ Dispatchers.IO
+ ) {
+ throw NotImplementedError()
}
/**
@@ -71,12 +94,44 @@ interface LcpService {
* @param sender Free object that can be used by reading apps to give some UX context when
* presenting dialogs with [LcpAuthenticating].
*/
- suspend fun retrieveLicense(
+ @Deprecated(
+ "Use the overload taking an asset instead.",
+ level = DeprecationLevel.ERROR
+ )
+ public suspend fun retrieveLicense(
file: File,
authentication: LcpAuthenticating = LcpDialogAuthentication(),
allowUserInteraction: Boolean,
sender: Any? = null
- ): Try?
+ ): Try? {
+ throw NotImplementedError()
+ }
+
+ /**
+ * Opens the LCP license of a protected publication, to access its DRM metadata and decipher
+ * its content. If the updated license cannot be stored into the [Asset], you'll get
+ * an exception if the license points to a LSD server that cannot be reached,
+ * for instance because no Internet gateway is available.
+ *
+ * Updated licenses can currently be stored only into [Asset]s whose source property points to
+ * a URL with scheme _file_ or _content_.
+ *
+ * @param authentication Used to retrieve the user passphrase if it is not already known.
+ * The request will be cancelled if no passphrase is found in the LCP passphrase storage
+ * and the provided [authentication].
+ * @param allowUserInteraction Indicates whether the user can be prompted for their passphrase.
+ */
+ public suspend fun retrieveLicense(
+ asset: Asset,
+ authentication: LcpAuthenticating,
+ allowUserInteraction: Boolean
+ ): Try
+
+ /**
+ * Creates an [LcpPublicationRetriever] instance which can be used to acquire a protected
+ * publication from an LCP License Document.
+ */
+ public fun publicationRetriever(): LcpPublicationRetriever
/**
* Creates a [ContentProtection] instance which can be used with a Streamer to unlock
@@ -86,8 +141,9 @@ interface LcpService {
* LCP license. The default implementation [LcpDialogAuthentication] presents a dialog to the
* user to enter their passphrase.
*/
- fun contentProtection(authentication: LcpAuthenticating = LcpDialogAuthentication()): ContentProtection =
- LcpContentProtection(this, authentication)
+ public fun contentProtection(
+ authentication: LcpAuthenticating
+ ): ContentProtection
/**
* Information about an acquired publication protected with LCP.
@@ -97,75 +153,110 @@ interface LcpService {
* @param suggestedFilename Filename that should be used for the publication when importing it in
* the user library.
*/
- data class AcquiredPublication(
+ public data class AcquiredPublication(
val localFile: File,
- val suggestedFilename: String
+ val suggestedFilename: String,
+ val format: Format,
+ val licenseDocument: LicenseDocument
) {
- @Deprecated("Use `localFile` instead", replaceWith = ReplaceWith("localFile"))
+ @Deprecated(
+ "Use `localFile` instead",
+ replaceWith = ReplaceWith("localFile"),
+ level = DeprecationLevel.ERROR
+ )
val localURL: String get() = localFile.path
}
- companion object {
+ public companion object {
/**
* LCP service factory.
*/
- operator fun invoke(context: Context): LcpService? {
- if (!LcpClient.isAvailable())
+ public operator fun invoke(
+ context: Context,
+ assetRetriever: AssetRetriever,
+ downloadManager: DownloadManager
+ ): LcpService? {
+ if (!LcpClient.isAvailable()) {
return null
+ }
val db = LcpDatabase.getDatabase(context).lcpDao()
val deviceRepository = DeviceRepository(db)
val passphraseRepository = PassphrasesRepository(db)
val licenseRepository = LicensesRepository(db)
val network = NetworkService()
- val device = DeviceService(repository = deviceRepository, network = network, context = context)
+ val device = DeviceService(
+ repository = deviceRepository,
+ network = network,
+ context = context
+ )
val crl = CRLService(network = network, context = context)
val passphrases = PassphrasesService(repository = passphraseRepository)
- return LicensesService(licenses = licenseRepository, crl = crl, device = device, network = network, passphrases = passphrases, context = context)
+ return LicensesService(
+ licenses = licenseRepository,
+ crl = crl,
+ device = device,
+ network = network,
+ passphrases = passphrases,
+ context = context,
+ assetRetriever = assetRetriever,
+ downloadManager = downloadManager
+ )
}
- @Deprecated("Use `LcpService()` instead", ReplaceWith("LcpService(context)"), level = DeprecationLevel.ERROR)
- fun create(context: Context): LcpService? = invoke(context)
+ @Suppress("UNUSED_PARAMETER")
+ @Deprecated(
+ "Use `LcpService()` instead",
+ ReplaceWith("LcpService(context, AssetRetriever(), MediaTypeRetriever())"),
+ level = DeprecationLevel.ERROR
+ )
+ public fun create(context: Context): LcpService = throw NotImplementedError()
}
- @Deprecated("Use `acquirePublication()` with coroutines instead", ReplaceWith("acquirePublication(lcpl)"))
+ @Deprecated(
+ "Use a LcpPublicationRetriever instead.",
+ ReplaceWith("publicationRetriever()"),
+ level = DeprecationLevel.ERROR
+ )
@DelicateCoroutinesApi
- fun importPublication(
+ public fun importPublication(
lcpl: ByteArray,
authentication: LcpAuthenticating?,
- completion: (AcquiredPublication?, LcpException?) -> Unit
+ completion: (AcquiredPublication?, LcpError?) -> Unit
) {
- GlobalScope.launch {
- acquirePublication(lcpl)
- .onSuccess { completion(it, null) }
- .onFailure { completion(null, it) }
- }
+ throw NotImplementedError()
}
- @Deprecated("Use `retrieveLicense()` with coroutines instead", ReplaceWith("retrieveLicense(File(publication), authentication, allowUserInteraction = true)"))
+ @Deprecated(
+ "Use `retrieveLicense()` with coroutines instead",
+ ReplaceWith(
+ "retrieveLicense(File(publication), authentication, allowUserInteraction = true)"
+ ),
+ level = DeprecationLevel.ERROR
+ )
@DelicateCoroutinesApi
- fun retrieveLicense(
+ public fun retrieveLicense(
publication: String,
authentication: LcpAuthenticating?,
- completion: (LcpLicense?, LcpException?) -> Unit
+ completion: (LcpLicense?, LcpError?) -> Unit
) {
- GlobalScope.launch {
- val result = retrieveLicense(File(publication), authentication ?: LcpDialogAuthentication(), allowUserInteraction = true)
- if (result == null) {
- completion(null, null)
- } else {
- result
- .onSuccess { completion(it, null) }
- .onFailure { completion(null, it) }
- }
- }
+ throw NotImplementedError()
}
}
-@Deprecated("Renamed to `LcpService()`", replaceWith = ReplaceWith("LcpService(context)"))
-fun R2MakeLCPService(context: Context): LcpService =
- LcpService(context) ?: throw Exception("liblcp is missing on the classpath")
+@Suppress("UNUSED_PARAMETER")
+@Deprecated(
+ "Renamed to `LcpService()`",
+ replaceWith = ReplaceWith("LcpService(context)"),
+ level = DeprecationLevel.ERROR
+)
+public fun R2MakeLCPService(context: Context): LcpService =
+ throw NotImplementedError()
-@Deprecated("Renamed to `LcpService.AcquiredPublication`", replaceWith = ReplaceWith("LcpService.AcquiredPublication"))
-typealias LCPImportedPublication = LcpService.AcquiredPublication
+@Deprecated(
+ "Renamed to `LcpService.AcquiredPublication`",
+ replaceWith = ReplaceWith("LcpService.AcquiredPublication"),
+ level = DeprecationLevel.ERROR
+)
+public typealias LCPImportedPublication = LcpService.AcquiredPublication
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/MaterialRenewListener.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/MaterialRenewListener.kt
index 5661a01aaa..6fe2f85d77 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/MaterialRenewListener.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/MaterialRenewListener.kt
@@ -12,12 +12,12 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.browser.customtabs.CustomTabsIntent
import androidx.fragment.app.FragmentManager
import com.google.android.material.datepicker.*
-import java.net.URL
import java.util.*
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.suspendCancellableCoroutine
+import org.readium.r2.shared.util.Url
/**
* A default implementation of the [LcpLicense.RenewListener] using Chrome Custom Tabs for
@@ -31,7 +31,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
* @param caller Activity or Fragment used to register the ActivityResultLauncher.
* @param fragmentManager FragmentManager used to present the date picker.
*/
-class MaterialRenewListener(
+public class MaterialRenewListener(
private val license: LcpLicense,
private val caller: ActivityResultCaller,
private val fragmentManager: FragmentManager
@@ -73,19 +73,23 @@ class MaterialRenewListener(
.show(fragmentManager, "MaterialRenewListener.DatePicker")
}
- override suspend fun openWebPage(url: URL) = suspendCoroutine { cont ->
- webPageContinuation = cont
+ override suspend fun openWebPage(url: Url) {
+ suspendCoroutine { cont ->
+ webPageContinuation = cont
- webPageLauncher.launch(
- CustomTabsIntent.Builder().build().intent.apply {
- data = Uri.parse(url.toString())
- }
- )
+ webPageLauncher.launch(
+ CustomTabsIntent.Builder().build().intent.apply {
+ data = Uri.parse(url.toString())
+ }
+ )
+ }
}
private var webPageContinuation: Continuation? = null
- private val webPageLauncher = caller.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ private val webPageLauncher = caller.registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) {
webPageContinuation?.resume(Unit)
}
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/auth/LcpDialogAuthentication.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/auth/LcpDialogAuthentication.kt
index 83b81ab99f..02e513bce3 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/auth/LcpDialogAuthentication.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/auth/LcpDialogAuthentication.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2020 Readium Foundation. All rights reserved.
+ * Copyright 2023 Readium Foundation. All rights reserved.
* Use of this source code is governed by the BSD-style license
* available in the top-level LICENSE file of the project.
*/
@@ -7,68 +7,118 @@
package org.readium.r2.lcp.auth
import android.annotation.SuppressLint
-import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
+import android.text.Editable
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
-import android.view.ViewGroup
import android.widget.Button
import android.widget.ListPopupWindow
import android.widget.PopupWindow
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
-import androidx.fragment.app.Fragment
+import androidx.core.widget.addTextChangedListener
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import java.util.*
+import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
import org.readium.r2.lcp.LcpAuthenticating
import org.readium.r2.lcp.R
import org.readium.r2.lcp.license.model.components.Link
import org.readium.r2.shared.extensions.tryOr
import org.readium.r2.shared.extensions.tryOrNull
-import timber.log.Timber
+import org.readium.r2.shared.util.AbsoluteUrl
+import org.readium.r2.shared.util.toUri
/**
* An [LcpAuthenticating] implementation presenting a dialog to the user.
*
- * For this authentication to trigger, you must provide a [sender] parameter of type [Activity],
- * [Fragment] or [View] to `Streamer::open()` or `LcpService::retrieveLicense()`. It will be used as
- * the host view for the dialog.
+ * This authentication requires a view to anchor on. To use it, you'll need to call
+ * [onParentViewAttachedToWindow] every time it gets attached to a window and
+ * [onParentViewDetachedFromWindow] when it gets detached. You can typically achieve this with
+ * a [View.OnAttachStateChangeListener]. Without view to anchor on, [retrievePassphrase] will
+ * suspend until one is available.
*/
-class LcpDialogAuthentication : LcpAuthenticating {
+public class LcpDialogAuthentication : LcpAuthenticating {
+
+ private class SuspendedCall(
+ val continuation: Continuation,
+ val license: LcpAuthenticating.AuthenticatedLicense,
+ val reason: LcpAuthenticating.AuthenticationReason,
+ var currentInput: Editable? = null
+ )
+
+ private val mutex: Mutex = Mutex()
+ private var suspendedCall: SuspendedCall? = null
+ private var parentView: View? = null
+
+ /**
+ * Call this method every time the anchor view gets attached to the window.
+ */
+ public fun onParentViewAttachedToWindow(parentView: View) {
+ this@LcpDialogAuthentication.parentView = parentView
+ suspendedCall?.let { showPopupWindow(it, parentView) }
+ }
+
+ /**
+ * Call this method every time the anchor view gets detached from the window.
+ */
+ public fun onParentViewDetachedFromWindow() {
+ this.parentView = null
+ }
override suspend fun retrievePassphrase(
license: LcpAuthenticating.AuthenticatedLicense,
reason: LcpAuthenticating.AuthenticationReason,
- allowUserInteraction: Boolean,
- sender: Any?
+ allowUserInteraction: Boolean
): String? =
- if (allowUserInteraction) withContext(Dispatchers.Main) { askPassphrase(license, reason, sender) }
- else null
+ if (allowUserInteraction) {
+ withContext(Dispatchers.Main) {
+ askPassphrase(
+ license,
+ reason
+ )
+ }
+ } else {
+ null
+ }
private suspend fun askPassphrase(
license: LcpAuthenticating.AuthenticatedLicense,
- reason: LcpAuthenticating.AuthenticationReason,
- sender: Any?
+ reason: LcpAuthenticating.AuthenticationReason
): String? {
- val hostView = (sender as? View) ?: (sender as? Activity)?.findViewById(android.R.id.content)?.getChildAt(0) ?: (sender as? Fragment)?.view
- ?: run {
- Timber.e("No valid [sender] was passed to `LcpDialogAuthentication::retrievePassphrase()`. Make sure it is an Activity, a Fragment or a View.")
- return null
- }
+ mutex.lock()
+
+ return suspendCoroutine { cont ->
+ val suspendedCall = SuspendedCall(cont, license, reason)
+ this.suspendedCall = suspendedCall
+ parentView?.let { showPopupWindow(suspendedCall, it) }
+ }
+ }
+
+ private fun terminateCall() {
+ suspendedCall = null
+ mutex.unlock()
+ }
+
+ private fun showPopupWindow(
+ suspendedCall: SuspendedCall,
+ hostView: View
+ ) {
val context = hostView.context
val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
+
@SuppressLint("InflateParams") // https://stackoverflow.com/q/26404951/1474476
- val dialogView = inflater.inflate(R.layout.r2_lcp_auth_dialog, null)
+ val dialogView = inflater.inflate(R.layout.readium_lcp_auth_dialog, null)
val title = dialogView.findViewById(R.id.r2_title) as TextView
val description = dialogView.findViewById(R.id.r2_description) as TextView
@@ -80,61 +130,75 @@ class LcpDialogAuthentication : LcpAuthenticating {
val forgotButton = dialogView.findViewById(R.id.r2_forgotButton) as Button
val helpButton = dialogView.findViewById(R.id.r2_helpButton) as Button
- forgotButton.isVisible = license.hintLink != null
- helpButton.isVisible = license.supportLinks.isNotEmpty()
+ password.text = suspendedCall.currentInput
+ password.addTextChangedListener { suspendedCall.currentInput = it }
+
+ forgotButton.isVisible = suspendedCall.license.hintLink != null
+ helpButton.isVisible = suspendedCall.license.supportLinks.isNotEmpty()
- when (reason) {
+ when (suspendedCall.reason) {
LcpAuthenticating.AuthenticationReason.PassphraseNotFound -> {
- title.text = context.getString(R.string.r2_lcp_dialog_reason_passphraseNotFound)
+ title.text = context.getString(
+ R.string.readium_lcp_dialog_reason_passphraseNotFound
+ )
}
+
LcpAuthenticating.AuthenticationReason.InvalidPassphrase -> {
- title.text = context.getString(R.string.r2_lcp_dialog_reason_invalidPassphrase)
- passwordLayout.error = context.getString(R.string.r2_lcp_dialog_reason_invalidPassphrase)
+ title.text = context.getString(R.string.readium_lcp_dialog_reason_invalidPassphrase)
+ passwordLayout.error = context.getString(
+ R.string.readium_lcp_dialog_reason_invalidPassphrase
+ )
}
}
- val provider = tryOr(license.provider) { Uri.parse(license.provider).host }
- description.text = context.getString(R.string.r2_lcp_dialog_prompt, provider)
-
- hint.text = license.hint
-
- return suspendCoroutine { cont ->
- val popupWindow = PopupWindow(dialogView, ListPopupWindow.MATCH_PARENT, ListPopupWindow.MATCH_PARENT).apply {
- isOutsideTouchable = false
- isFocusable = true
- elevation = 5.0f
- }
-
- cancelButton.setOnClickListener {
- popupWindow.dismiss()
- cont.resume(null)
- }
+ val provider = tryOr(suspendedCall.license.provider) {
+ Uri.parse(suspendedCall.license.provider).host
+ }
+ description.text = context.getString(R.string.readium_lcp_dialog_prompt, provider)
+
+ hint.text = suspendedCall.license.hint
+
+ val popupWindow = PopupWindow(
+ dialogView,
+ ListPopupWindow.MATCH_PARENT,
+ ListPopupWindow.MATCH_PARENT
+ ).apply {
+ isOutsideTouchable = false
+ isFocusable = true
+ elevation = 5.0f
+ }
- confirmButton.setOnClickListener {
- popupWindow.dismiss()
- cont.resume(password.text.toString())
- }
+ cancelButton.setOnClickListener {
+ popupWindow.dismiss()
+ terminateCall()
+ suspendedCall.continuation.resume(null)
+ }
- forgotButton.setOnClickListener {
- license.hintLink?.let { context.startActivityForLink(it) }
- }
+ confirmButton.setOnClickListener {
+ popupWindow.dismiss()
+ terminateCall()
+ suspendedCall.continuation.resume(password.text.toString())
+ }
- helpButton.setOnClickListener {
- showHelpDialog(context, license.supportLinks)
- }
+ forgotButton.setOnClickListener {
+ suspendedCall.license.hintLink?.let { context.startActivityForLink(it) }
+ }
- popupWindow.showAtLocation(hostView, Gravity.CENTER, 0, 0)
+ helpButton.setOnClickListener {
+ showHelpDialog(context, suspendedCall.license.supportLinks)
}
+
+ popupWindow.showAtLocation(hostView, Gravity.CENTER, 0, 0)
}
private fun showHelpDialog(context: Context, links: List ) {
val titles = links.map {
- it.title ?: tryOr(context.getString(R.string.r2_lcp_dialog_support)) {
- when (Uri.parse(it.href).scheme) {
- "http", "https" -> context.getString(R.string.r2_lcp_dialog_support_web)
- "tel" -> context.getString(R.string.r2_lcp_dialog_support_phone)
- "mailto" -> context.getString(R.string.r2_lcp_dialog_support_mail)
- else -> context.getString(R.string.r2_lcp_dialog_support)
+ it.title ?: tryOr(context.getString(R.string.readium_lcp_dialog_support)) {
+ when ((it.url() as? AbsoluteUrl)?.scheme?.value) {
+ "http", "https" -> context.getString(R.string.readium_lcp_dialog_support_web)
+ "tel" -> context.getString(R.string.readium_lcp_dialog_support_phone)
+ "mailto" -> context.getString(R.string.readium_lcp_dialog_support_mail)
+ else -> context.getString(R.string.readium_lcp_dialog_support)
}
}
}.toTypedArray()
@@ -147,9 +211,9 @@ class LcpDialogAuthentication : LcpAuthenticating {
}
private fun Context.startActivityForLink(link: Link) {
- val url = tryOrNull { Uri.parse(link.href) } ?: return
+ val url = tryOrNull { (link.url() as? AbsoluteUrl) } ?: return
- val action = when (url.scheme?.lowercase(Locale.ROOT)) {
+ val action = when (url.scheme.value) {
"http", "https" -> Intent(Intent.ACTION_VIEW)
"tel" -> Intent(Intent.ACTION_CALL)
"mailto" -> Intent(Intent.ACTION_SEND)
@@ -158,7 +222,7 @@ class LcpDialogAuthentication : LcpAuthenticating {
startActivity(
Intent(action).apply {
- data = url
+ data = url.toUri()
}
)
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/auth/LcpPassphraseAuthentication.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/auth/LcpPassphraseAuthentication.kt
index 84f87b0462..fb7aca869d 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/auth/LcpPassphraseAuthentication.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/auth/LcpPassphraseAuthentication.kt
@@ -14,7 +14,7 @@ import org.readium.r2.lcp.LcpAuthenticating
*
* If the provided [passphrase] is incorrect, the given [fallback] authentication is used.
*/
-class LcpPassphraseAuthentication(
+public class LcpPassphraseAuthentication(
private val passphrase: String,
private val fallback: LcpAuthenticating? = null
) : LcpAuthenticating {
@@ -22,11 +22,14 @@ class LcpPassphraseAuthentication(
override suspend fun retrievePassphrase(
license: LcpAuthenticating.AuthenticatedLicense,
reason: LcpAuthenticating.AuthenticationReason,
- allowUserInteraction: Boolean,
- sender: Any?
+ allowUserInteraction: Boolean
): String? {
if (reason != LcpAuthenticating.AuthenticationReason.PassphraseNotFound) {
- return fallback?.retrievePassphrase(license, reason, allowUserInteraction = allowUserInteraction, sender = sender)
+ return fallback?.retrievePassphrase(
+ license,
+ reason,
+ allowUserInteraction = allowUserInteraction
+ )
}
return passphrase
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/License.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/License.kt
index faef7f453a..efcc9a363c 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/License.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/License.kt
@@ -10,12 +10,17 @@
package org.readium.r2.lcp.license
import java.net.HttpURLConnection
-import java.util.*
-import kotlin.time.ExperimentalTime
+import java.util.Date
import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import org.readium.r2.lcp.BuildConfig.DEBUG
+import org.readium.r2.lcp.LcpError
import org.readium.r2.lcp.LcpException
import org.readium.r2.lcp.LcpLicense
import org.readium.r2.lcp.license.model.LicenseDocument
@@ -29,24 +34,61 @@ import org.readium.r2.shared.extensions.toIso8601String
import org.readium.r2.shared.extensions.tryOrNull
import org.readium.r2.shared.util.Try
import org.readium.r2.shared.util.getOrElse
+import org.readium.r2.shared.util.getOrThrow
import org.readium.r2.shared.util.mediatype.MediaType
import timber.log.Timber
-@OptIn(ExperimentalTime::class)
-internal class License(
+internal class License private constructor(
+ private val coroutineScope: CoroutineScope,
private var documents: ValidatedDocuments,
private val validation: LicenseValidation,
private val licenses: LicensesRepository,
private val device: DeviceService,
- private val network: NetworkService
+ private val network: NetworkService,
+ private val printsLeft: StateFlow,
+ private val copiesLeft: StateFlow
) : LcpLicense {
+ companion object {
+
+ suspend operator fun invoke(
+ documents: ValidatedDocuments,
+ validation: LicenseValidation,
+ licenses: LicensesRepository,
+ device: DeviceService,
+ network: NetworkService
+ ): License {
+ val coroutineScope = MainScope()
+
+ val printsLeft = licenses
+ .printsLeft(documents.license.id)
+ .stateIn(coroutineScope)
+
+ val copiesLeft = licenses
+ .copiesLeft(documents.license.id)
+ .stateIn(coroutineScope)
+
+ return License(
+ coroutineScope = coroutineScope,
+ documents = documents,
+ validation = validation,
+ licenses = licenses,
+ device = device,
+ network = network,
+ printsLeft = printsLeft,
+ copiesLeft = copiesLeft
+ )
+ }
+ }
+
override val license: LicenseDocument
get() = documents.license
override val status: StatusDocument?
get() = documents.status
- override suspend fun decrypt(data: ByteArray): Try = withContext(Dispatchers.Default) {
+ override suspend fun decrypt(data: ByteArray): Try = withContext(
+ Dispatchers.Default
+ ) {
try {
// LCP lib crashes if we call decrypt on an empty ByteArray
if (data.isEmpty()) {
@@ -57,87 +99,55 @@ internal class License(
Try.success(decryptedData)
}
} catch (e: Exception) {
- Try.failure(LcpException.wrap(e))
+ Try.failure(LcpError.wrap(e))
}
}
- override val charactersToCopyLeft: Int?
- get() {
- try {
- val charactersLeft = licenses.copiesLeft(license.id)
- if (charactersLeft != null) {
- return charactersLeft
- }
- } catch (error: Error) {
- if (DEBUG) Timber.e(error)
- }
- return null
- }
+ override val charactersToCopyLeft: StateFlow
+ get() = copiesLeft
override val canCopy: Boolean
- get() = (charactersToCopyLeft ?: 1) > 0
+ get() = (charactersToCopyLeft.value ?: 1) > 0
override fun canCopy(text: String): Boolean =
- charactersToCopyLeft?.let { it <= text.length }
+ charactersToCopyLeft.value?.let { it <= text.length }
?: true
- override fun copy(text: String): Boolean {
- var charactersLeft = charactersToCopyLeft ?: return true
- if (text.length > charactersLeft) {
- return false
- }
-
- try {
- charactersLeft = maxOf(0, charactersLeft - text.length)
- licenses.setCopiesLeft(charactersLeft, license.id)
- } catch (error: Error) {
- if (DEBUG) Timber.e(error)
+ override suspend fun copy(text: String): Boolean {
+ return try {
+ licenses.tryCopy(text.length, license.id)
+ } catch (e: Exception) {
+ if (DEBUG) Timber.e(e)
+ false
}
- return true
}
- override val pagesToPrintLeft: Int?
- get() {
- try {
- val pagesLeft = licenses.printsLeft(license.id)
- if (pagesLeft != null) {
- return pagesLeft
- }
- } catch (error: Error) {
- if (DEBUG) Timber.e(error)
- }
- return null
- }
+ override val pagesToPrintLeft: StateFlow =
+ printsLeft
override val canPrint: Boolean
- get() = (pagesToPrintLeft ?: 1) > 0
+ get() = (pagesToPrintLeft.value ?: 1) > 0
override fun canPrint(pageCount: Int): Boolean =
- pagesToPrintLeft?.let { it <= pageCount }
+ pagesToPrintLeft.value?.let { it <= pageCount }
?: true
- override fun print(pageCount: Int): Boolean {
- var pagesLeft = pagesToPrintLeft ?: return true
- if (pagesLeft < pageCount) {
- return false
- }
- try {
- pagesLeft = maxOf(0, pagesLeft - pageCount)
- licenses.setPrintsLeft(pagesLeft, license.id)
- } catch (error: Error) {
- if (DEBUG) Timber.e(error)
+ override suspend fun print(pageCount: Int): Boolean {
+ return try {
+ licenses.tryPrint(pageCount, license.id)
+ } catch (e: Exception) {
+ if (DEBUG) Timber.e(e)
+ false
}
- return true
}
override val canRenewLoan: Boolean
- get() = status?.link(StatusDocument.Rel.renew) != null
+ get() = status?.link(StatusDocument.Rel.Renew) != null
override val maxRenewDate: Date?
get() = status?.potentialRights?.end
- override suspend fun renewLoan(listener: LcpLicense.RenewListener, prefersWebPage: Boolean): Try {
-
+ override suspend fun renewLoan(listener: LcpLicense.RenewListener, prefersWebPage: Boolean): Try {
// Finds the renew link according to `prefersWebPage`.
fun findRenewLink(): Link? {
val status = documents.status ?: return null
@@ -150,34 +160,43 @@ internal class License(
}
for (type in types) {
- return status.link(StatusDocument.Rel.renew, type = type)
+ return status.link(StatusDocument.Rel.Renew, type = type)
?: continue
}
// Fallback on the first renew link with no media type set and assume it's a PUT action.
- return status.linkWithNoType(StatusDocument.Rel.renew)
+ return status.linkWithNoType(StatusDocument.Rel.Renew)
}
// Programmatically renew the loan with a PUT request.
suspend fun renewProgrammatically(link: Link): ByteArray {
val endDate =
- if (link.templateParameters.contains("end"))
+ if (link.href.parameters?.contains("end") == true) {
listener.preferredEndDate(maxRenewDate)
- else null
+ } else {
+ null
+ }
val parameters = this.device.asQueryParameters.toMutableMap()
if (endDate != null) {
parameters["end"] = endDate.toIso8601String()
}
- val url = link.url(parameters)
+ val url = link.url(parameters = parameters)
return network.fetch(url.toString(), NetworkService.Method.PUT)
.getOrElse { error ->
when (error.status) {
- HttpURLConnection.HTTP_BAD_REQUEST -> throw LcpException.Renew.RenewFailed
- HttpURLConnection.HTTP_FORBIDDEN -> throw LcpException.Renew.InvalidRenewalPeriod(maxRenewDate = this.maxRenewDate)
- else -> throw LcpException.Renew.UnexpectedServerError
+ HttpURLConnection.HTTP_BAD_REQUEST ->
+ throw LcpException(LcpError.Renew.RenewFailed)
+ HttpURLConnection.HTTP_FORBIDDEN ->
+ throw LcpException(
+ LcpError.Renew.InvalidRenewalPeriod(
+ maxRenewDate = this.maxRenewDate
+ )
+ )
+ else ->
+ throw LcpException(LcpError.Renew.UnexpectedServerError)
}
}
}
@@ -185,21 +204,27 @@ internal class License(
// Renew the loan by presenting a web page to the user.
suspend fun renewWithWebPage(link: Link): ByteArray {
// The reading app will open the URL in a web view and return when it is dismissed.
- listener.openWebPage(link.url)
+ listener.openWebPage(link.url())
val statusURL = tryOrNull {
- license.url(LicenseDocument.Rel.status, preferredType = MediaType.LCP_STATUS_DOCUMENT)
- } ?: throw LcpException.LicenseInteractionNotAvailable
-
- return network.fetch(statusURL.toString(), headers = mapOf("Accept" to MediaType.LCP_STATUS_DOCUMENT.toString())).getOrThrow()
+ license.url(
+ LicenseDocument.Rel.Status,
+ preferredType = MediaType.LCP_STATUS_DOCUMENT
+ )
+ } ?: throw LcpException(LcpError.LicenseInteractionNotAvailable)
+
+ return network.fetch(
+ statusURL.toString(),
+ headers = mapOf("Accept" to MediaType.LCP_STATUS_DOCUMENT.toString())
+ ).getOrThrow()
}
try {
val link = findRenewLink()
- ?: throw LcpException.LicenseInteractionNotAvailable
+ ?: throw LcpException(LcpError.LicenseInteractionNotAvailable)
val data =
- if (link.mediaType.isHtml) {
+ if (link.mediaType?.isHtml == true) {
renewWithWebPage(link)
} else {
renewProgrammatically(link)
@@ -212,41 +237,52 @@ internal class License(
// Passthrough for cancelled coroutines
throw e
} catch (e: Exception) {
- return Try.failure(LcpException.wrap(e))
+ return Try.failure(LcpError.wrap(e))
}
}
override val canReturnPublication: Boolean
- get() = status?.link(StatusDocument.Rel.`return`) != null
+ get() = status?.link(StatusDocument.Rel.Return) != null
- override suspend fun returnPublication(): Try {
+ override suspend fun returnPublication(): Try {
try {
val status = this.documents.status
val url = try {
- status?.url(StatusDocument.Rel.`return`, preferredType = null, parameters = device.asQueryParameters)
+ status?.url(
+ StatusDocument.Rel.Return,
+ preferredType = null,
+ parameters = device.asQueryParameters
+ )
} catch (e: Throwable) {
null
}
if (status == null || url == null) {
- throw LcpException.LicenseInteractionNotAvailable
+ throw LcpException(LcpError.LicenseInteractionNotAvailable)
}
network.fetch(url.toString(), method = NetworkService.Method.PUT)
.onSuccess { validateStatusDocument(it) }
.onFailure { error ->
when (error.status) {
- HttpURLConnection.HTTP_BAD_REQUEST -> throw LcpException.Return.ReturnFailed
- HttpURLConnection.HTTP_FORBIDDEN -> throw LcpException.Return.AlreadyReturnedOrExpired
- else -> throw LcpException.Return.UnexpectedServerError
+ HttpURLConnection.HTTP_BAD_REQUEST -> throw LcpException(
+ LcpError.Return.ReturnFailed
+ )
+ HttpURLConnection.HTTP_FORBIDDEN -> throw LcpException(
+ LcpError.Return.AlreadyReturnedOrExpired
+ )
+ else -> throw LcpException(LcpError.Return.UnexpectedServerError)
}
}
return Try.success(Unit)
} catch (e: Exception) {
- return Try.failure(LcpException.wrap(e))
+ return Try.failure(LcpError.wrap(e))
}
}
+ private fun validateStatusDocument(data: ByteArray): Unit =
+ validation.validate(LicenseValidation.Document.status(data)) { _, _ -> }
+
init {
LicenseValidation.observe(validation) { documents, _ ->
documents?.let {
@@ -255,6 +291,7 @@ internal class License(
}
}
- private fun validateStatusDocument(data: ByteArray): Unit =
- validation.validate(LicenseValidation.Document.status(data)) { _, _ -> }
+ override fun close() {
+ coroutineScope.cancel()
+ }
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/LicenseValidation.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/LicenseValidation.kt
index a918497a76..8c9d60435a 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/LicenseValidation.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/LicenseValidation.kt
@@ -11,15 +11,19 @@ package org.readium.r2.lcp.license
import java.util.*
import kotlin.time.Duration.Companion.seconds
-import kotlin.time.ExperimentalTime
import kotlinx.coroutines.runBlocking
import org.readium.r2.lcp.BuildConfig.DEBUG
import org.readium.r2.lcp.LcpAuthenticating
+import org.readium.r2.lcp.LcpError
import org.readium.r2.lcp.LcpException
import org.readium.r2.lcp.license.model.LicenseDocument
import org.readium.r2.lcp.license.model.StatusDocument
import org.readium.r2.lcp.license.model.components.Link
-import org.readium.r2.lcp.service.*
+import org.readium.r2.lcp.service.CRLService
+import org.readium.r2.lcp.service.DeviceService
+import org.readium.r2.lcp.service.LcpClient
+import org.readium.r2.lcp.service.NetworkService
+import org.readium.r2.lcp.service.PassphrasesService
import org.readium.r2.shared.util.getOrElse
import org.readium.r2.shared.util.mediatype.MediaType
import timber.log.Timber
@@ -29,17 +33,20 @@ internal sealed class Either {
class Right (val right: B) : Either ()
}
-private val supportedProfiles = listOf("http://readium.org/lcp/basic-profile", "http://readium.org/lcp/profile-1.0")
+private val supportedProfiles = listOf(
+ "http://readium.org/lcp/basic-profile",
+ "http://readium.org/lcp/profile-1.0"
+)
-internal typealias Context = Either
+internal typealias Context = Either
internal typealias Observer = (ValidatedDocuments?, Exception?) -> Unit
private var observers: MutableList> = mutableListOf()
internal enum class ObserverPolicy {
- once,
- always
+ Once,
+ Always
}
internal data class ValidatedDocuments constructor(
@@ -50,7 +57,7 @@ internal data class ValidatedDocuments constructor(
fun getContext(): LcpClient.Context {
when (context) {
is Either.Left -> return context.left
- is Either.Right -> throw context.right
+ is Either.Right -> throw LcpException(context.right)
}
}
}
@@ -61,7 +68,11 @@ internal sealed class State {
data class fetchStatus(val license: LicenseDocument) : State()
data class validateStatus(val license: LicenseDocument, val data: ByteArray) : State()
data class fetchLicense(val license: LicenseDocument, val status: StatusDocument) : State()
- data class checkLicenseStatus(val license: LicenseDocument, val status: StatusDocument?) : State()
+ data class checkLicenseStatus(
+ val license: LicenseDocument,
+ val status: StatusDocument?,
+ val statusDocumentTakesPrecedence: Boolean
+ ) : State()
data class retrievePassphrase(val license: LicenseDocument, val status: StatusDocument?) : State()
data class validateIntegrity(
val license: LicenseDocument,
@@ -79,7 +90,7 @@ internal sealed class Event {
data class validatedLicense(val license: LicenseDocument) : Event()
data class retrievedStatusData(val data: ByteArray) : Event()
data class validatedStatus(val status: StatusDocument) : Event()
- data class checkedLicenseStatus(val error: LcpException.LicenseStatus?) : Event()
+ data class checkedLicenseStatus(val error: LcpError.LicenseStatus?) : Event()
data class retrievedPassphrase(val passphrase: String) : Event()
data class validatedIntegrity(val context: LcpClient.Context) : Event()
data class registeredDevice(val statusData: ByteArray?) : Event()
@@ -87,11 +98,14 @@ internal sealed class Event {
object cancelled : Event()
}
-@OptIn(ExperimentalTime::class)
+/**
+ * If [ignoreInternetErrors] is true, then the validation won't fail on [LcpError.Network] errors.
+ * This should be the case with writable licenses (such as local ones) but not with read-only licences.
+ */
internal class LicenseValidation(
var authentication: LcpAuthenticating?,
val allowUserInteraction: Boolean,
- val sender: Any?,
+ val ignoreInternetErrors: Boolean,
val crl: CRLService,
val device: DeviceService,
val network: NetworkService,
@@ -142,7 +156,7 @@ internal class LicenseValidation(
on {
status?.let { status ->
if (DEBUG) Timber.d("State.checkLicenseStatus(it.license, status)")
- transitionTo(State.checkLicenseStatus(it.license, status))
+ transitionTo(State.checkLicenseStatus(it.license, status, false))
} ?: run {
if (DEBUG) Timber.d("State.fetchStatus(it.license)")
transitionTo(State.fetchStatus(it.license))
@@ -159,8 +173,13 @@ internal class LicenseValidation(
transitionTo(State.validateStatus(license, it.data))
}
on {
- if (DEBUG) Timber.d("State.checkLicenseStatus(license, null)")
- transitionTo(State.checkLicenseStatus(license, null))
+ if (!ignoreInternetErrors && it.error is LcpException && it.error.error is LcpError.Network) {
+ if (DEBUG) Timber.d("State.failure(it.error)")
+ transitionTo(State.failure(it.error))
+ } else {
+ if (DEBUG) Timber.d("State.checkLicenseStatus(license, null)")
+ transitionTo(State.checkLicenseStatus(license, null, false))
+ }
}
}
state {
@@ -170,12 +189,12 @@ internal class LicenseValidation(
transitionTo(State.fetchLicense(license, it.status))
} else {
if (DEBUG) Timber.d("State.checkLicenseStatus(license, it.status)")
- transitionTo(State.checkLicenseStatus(license, it.status))
+ transitionTo(State.checkLicenseStatus(license, it.status, false))
}
}
on {
if (DEBUG) Timber.d("State.checkLicenseStatus(license, null)")
- transitionTo(State.checkLicenseStatus(license, null))
+ transitionTo(State.checkLicenseStatus(license, null, false))
}
}
state {
@@ -185,14 +204,20 @@ internal class LicenseValidation(
}
on {
if (DEBUG) Timber.d("State.checkLicenseStatus(license, status)")
- transitionTo(State.checkLicenseStatus(license, status))
+ transitionTo(State.checkLicenseStatus(license, status, true))
}
}
state {
on {
it.error?.let { error ->
- if (DEBUG) Timber.d("State.valid(ValidatedDocuments(license, Either.Right(error), status))")
- transitionTo(State.valid(ValidatedDocuments(license, Either.Right(error), status)))
+ if (DEBUG) {
+ Timber.d(
+ "State.valid(ValidatedDocuments(license, Either.Right(error), status))"
+ )
+ }
+ transitionTo(
+ State.valid(ValidatedDocuments(license, Either.Right(error), status))
+ )
} ?: run {
if (DEBUG) Timber.d("State.requestPassphrase(license, status)")
transitionTo(State.retrievePassphrase(license, status))
@@ -216,7 +241,7 @@ internal class LicenseValidation(
state {
on {
val documents = ValidatedDocuments(license, Either.Left(it.context), status)
- val link = status?.link(StatusDocument.Rel.register)
+ val link = status?.link(StatusDocument.Rel.Register)
link?.let {
if (DEBUG) Timber.d("State.registerDevice(documents, link)")
transitionTo(State.registerDevice(documents, link))
@@ -280,7 +305,11 @@ internal class LicenseValidation(
is State.fetchStatus -> fetchStatus(state.license)
is State.validateStatus -> validateStatus(state.data)
is State.fetchLicense -> fetchLicense(state.status)
- is State.checkLicenseStatus -> checkLicenseStatus(state.license, state.status)
+ is State.checkLicenseStatus -> checkLicenseStatus(
+ state.license,
+ state.status,
+ state.statusDocumentTakesPrecedence
+ )
is State.retrievePassphrase -> requestPassphrase(state.license)
is State.validateIntegrity -> validateIntegrity(state.license, state.passphrase)
is State.registerDevice -> registerDevice(state.documents.license, state.link)
@@ -297,7 +326,7 @@ internal class LicenseValidation(
private fun observe(event: Event, observer: Observer) {
raise(event)
- Companion.observe(this, ObserverPolicy.once, observer)
+ Companion.observe(this, ObserverPolicy.Once, observer)
}
private fun notifyObservers(documents: ValidatedDocuments?, error: Exception?) {
@@ -306,24 +335,32 @@ internal class LicenseValidation(
observer.first(documents, error)
}
// Timber.d("observers $observers")
- observers = (observers.filter { it.second != ObserverPolicy.once }).toMutableList()
+ observers = (observers.filter { it.second != ObserverPolicy.Once }).toMutableList()
// Timber.d("observers $observers")
}
private fun validateLicense(data: ByteArray) {
val license = LicenseDocument(data = data)
if (!isProduction && license.encryption.profile != "http://readium.org/lcp/basic-profile") {
- throw LcpException.LicenseProfileNotSupported
+ throw LcpException(LcpError.LicenseProfileNotSupported)
}
onLicenseValidated(license)
raise(Event.validatedLicense(license))
}
private suspend fun fetchStatus(license: LicenseDocument) {
- val url = license.url(LicenseDocument.Rel.status, preferredType = MediaType.LCP_STATUS_DOCUMENT).toString()
- // Short timeout to avoid blocking the License, since the LSD is optional.
- val data = network.fetch(url, timeout = 5.seconds, headers = mapOf("Accept" to MediaType.LCP_STATUS_DOCUMENT.toString()))
- .getOrElse { throw LcpException.Network(it) }
+ val url = license.url(
+ LicenseDocument.Rel.Status,
+ preferredType = MediaType.LCP_STATUS_DOCUMENT
+ ).toString()
+ // Short timeout to avoid blocking the License, when the LSD is optional.
+ val timeout = 5.seconds.takeIf { ignoreInternetErrors }
+ val data = network.fetch(
+ url,
+ timeout = timeout,
+ headers = mapOf("Accept" to MediaType.LCP_STATUS_DOCUMENT.toString())
+ )
+ .getOrElse { throw LcpException(LcpError.Network(it)) }
raise(Event.retrievedStatusData(data))
}
@@ -334,41 +371,60 @@ internal class LicenseValidation(
}
private suspend fun fetchLicense(status: StatusDocument) {
- val url = status.url(StatusDocument.Rel.license, preferredType = MediaType.LCP_LICENSE_DOCUMENT).toString()
+ val url = status.url(
+ StatusDocument.Rel.License,
+ preferredType = MediaType.LCP_LICENSE_DOCUMENT
+ ).toString()
// Short timeout to avoid blocking the License, since it can be updated next time.
val data = network.fetch(url, timeout = 5.seconds)
- .getOrElse { throw LcpException.Network(it) }
+ .getOrElse { throw LcpException(LcpError.Network(it)) }
raise(Event.retrievedLicenseData(data))
}
- private fun checkLicenseStatus(license: LicenseDocument, status: StatusDocument?) {
- var error: LcpException.LicenseStatus? = null
+ private fun checkLicenseStatus(
+ license: LicenseDocument,
+ status: StatusDocument?,
+ statusDocumentTakesPrecedence: Boolean
+ ) {
+ var error: LcpError.LicenseStatus? = null
val now = Date()
val start = license.rights.start ?: now
val end = license.rights.end ?: now
- if (start > now || now > end) {
+ val isLicenseExpired = (start > now || now > end)
+ val isStatusValid = status?.status in listOf(
+ null,
+ StatusDocument.Status.Active,
+ StatusDocument.Status.Ready
+ )
+
+ // We only check the Status Document's status if the License itself is expired, to get a proper status error message.
+ // But in the case where the Status Document takes precedence (eg. after a failed License update),
+ // then we also check the status validity.
+ if (isLicenseExpired || statusDocumentTakesPrecedence && !isStatusValid) {
error = if (status != null) {
val date = status.statusUpdated
when (status.status) {
- StatusDocument.Status.ready, StatusDocument.Status.active, StatusDocument.Status.expired ->
+ StatusDocument.Status.Ready, StatusDocument.Status.Active, StatusDocument.Status.Expired ->
if (start > now) {
- LcpException.LicenseStatus.NotStarted(start)
+ LcpError.LicenseStatus.NotStarted(start)
} else {
- LcpException.LicenseStatus.Expired(end)
+ LcpError.LicenseStatus.Expired(end)
}
- StatusDocument.Status.returned -> LcpException.LicenseStatus.Returned(date)
- StatusDocument.Status.revoked -> {
- val devicesCount = status.events(org.readium.r2.lcp.license.model.components.lsd.Event.EventType.register).size
- LcpException.LicenseStatus.Revoked(date, devicesCount = devicesCount)
+ StatusDocument.Status.Returned -> LcpError.LicenseStatus.Returned(date)
+ StatusDocument.Status.Revoked -> {
+ val devicesCount = status.events(
+ org.readium.r2.lcp.license.model.components.lsd.Event.EventType.Register
+ ).size
+ LcpError.LicenseStatus.Revoked(date, devicesCount = devicesCount)
}
- StatusDocument.Status.cancelled -> LcpException.LicenseStatus.Cancelled(date)
+ StatusDocument.Status.Cancelled -> LcpError.LicenseStatus.Cancelled(date)
}
} else {
if (start > now) {
- LcpException.LicenseStatus.NotStarted(start)
+ LcpError.LicenseStatus.NotStarted(start)
} else {
- LcpException.LicenseStatus.Expired(end)
+ LcpError.LicenseStatus.Expired(end)
}
}
}
@@ -377,18 +433,19 @@ internal class LicenseValidation(
private suspend fun requestPassphrase(license: LicenseDocument) {
if (DEBUG) Timber.d("requestPassphrase")
- val passphrase = passphrases.request(license, authentication, allowUserInteraction, sender)
- if (passphrase == null)
+ val passphrase = passphrases.request(license, authentication, allowUserInteraction)
+ if (passphrase == null) {
raise(Event.cancelled)
- else
+ } else {
raise(Event.retrievedPassphrase(passphrase))
+ }
}
private suspend fun validateIntegrity(license: LicenseDocument, passphrase: String) {
if (DEBUG) Timber.d("validateIntegrity")
val profile = license.encryption.profile
if (!supportedProfiles.contains(profile)) {
- throw LcpException.LicenseProfileNotSupported
+ throw LcpException(LcpError.LicenseProfileNotSupported)
}
val context = LcpClient.createContext(license.json.toString(), passphrase, crl.retrieve())
raise(Event.validatedIntegrity(context))
@@ -403,17 +460,23 @@ internal class LicenseValidation(
companion object {
fun observe(
licenseValidation: LicenseValidation,
- policy: ObserverPolicy = ObserverPolicy.always,
+ policy: ObserverPolicy = ObserverPolicy.Always,
observer: Observer
) {
var notified = true
when (licenseValidation.stateMachine.state) {
- is State.valid -> observer((licenseValidation.stateMachine.state as State.valid).documents, null)
- is State.failure -> observer(null, (licenseValidation.stateMachine.state as State.failure).error)
+ is State.valid -> observer(
+ (licenseValidation.stateMachine.state as State.valid).documents,
+ null
+ )
+ is State.failure -> observer(
+ null,
+ (licenseValidation.stateMachine.state as State.failure).error
+ )
is State.cancelled -> observer(null, null)
else -> notified = false
}
- if (notified && policy != ObserverPolicy.always) {
+ if (notified && policy != ObserverPolicy.Always) {
return
}
observers.add(Pair(observer, policy))
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/StateMachine.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/StateMachine.kt
index 6a2b7df110..569084c3ff 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/StateMachine.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/StateMachine.kt
@@ -165,7 +165,11 @@ internal class StateMachine private constructor(
}
fun build(): Graph {
- return Graph(requireNotNull(initialState), stateDefinitions.toMap(), onTransitionListeners.toList())
+ return Graph(
+ requireNotNull(initialState),
+ stateDefinitions.toMap(),
+ onTransitionListeners.toList()
+ )
}
inner class StateDefinitionBuilder {
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/BytesLicenseContainer.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/BytesLicenseContainer.kt
index 0692e18c46..a512348179 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/BytesLicenseContainer.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/BytesLicenseContainer.kt
@@ -14,11 +14,11 @@ import org.readium.r2.lcp.license.model.LicenseDocument
/**
* Access a License Document from its raw bytes.
*/
-internal class BytesLicenseContainer(private var bytes: ByteArray) : LicenseContainer {
+internal class BytesLicenseContainer(private var bytes: ByteArray) : WritableLicenseContainer {
override fun read(): ByteArray = bytes
override fun write(license: LicenseDocument) {
- bytes = license.data
+ bytes = license.toByteArray()
}
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/ContainerLicenseContainer.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/ContainerLicenseContainer.kt
new file mode 100644
index 0000000000..21889e7fe8
--- /dev/null
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/ContainerLicenseContainer.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2023 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.r2.lcp.license.container
+
+import kotlinx.coroutines.runBlocking
+import org.readium.r2.lcp.LcpError
+import org.readium.r2.lcp.LcpException
+import org.readium.r2.shared.util.Url
+import org.readium.r2.shared.util.data.Container
+import org.readium.r2.shared.util.getOrThrow
+import org.readium.r2.shared.util.resource.Resource
+
+/**
+ * Access to a License Document stored in a read-only container.
+ */
+internal class ContainerLicenseContainer(
+ private val container: Container,
+ private val entryUrl: Url
+) : LicenseContainer {
+
+ override fun read(): ByteArray {
+ return runBlocking {
+ val resource = container.get(entryUrl)
+ ?: throw LcpException(LcpError.Container.FileNotFound(entryUrl))
+
+ resource.read()
+ .mapFailure {
+ LcpException(LcpError.Container.ReadFailed(entryUrl))
+ }
+ .getOrThrow()
+ }
+ }
+}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/ContentZipLicenseContainer.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/ContentZipLicenseContainer.kt
new file mode 100644
index 0000000000..a09ab849af
--- /dev/null
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/ContentZipLicenseContainer.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2023 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.r2.lcp.license.container
+
+import android.content.ContentResolver
+import android.content.Context
+import android.net.Uri
+import java.io.ByteArrayInputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.util.UUID
+import java.util.zip.ZipFile
+import org.readium.r2.lcp.LcpError
+import org.readium.r2.lcp.LcpException
+import org.readium.r2.lcp.license.model.LicenseDocument
+import org.readium.r2.shared.util.Url
+import org.readium.r2.shared.util.data.Container
+import org.readium.r2.shared.util.resource.Resource
+import org.readium.r2.shared.util.toUri
+
+internal class ContentZipLicenseContainer(
+ context: Context,
+ private val container: Container,
+ private val pathInZip: Url
+) : LicenseContainer by ContainerLicenseContainer(container, pathInZip), WritableLicenseContainer {
+
+ private val zipUri: Uri =
+ requireNotNull(container.sourceUrl).toUri()
+
+ private val contentResolver: ContentResolver =
+ context.contentResolver
+
+ private val cache: File =
+ context.externalCacheDir ?: context.cacheDir
+
+ override fun write(license: LicenseDocument) {
+ try {
+ val tmpZip = File(cache, UUID.randomUUID().toString())
+ contentResolver.openInputStream(zipUri)
+ ?.use { it.copyTo(FileOutputStream(tmpZip)) }
+ ?: throw LcpException(LcpError.Container.WriteFailed(pathInZip))
+ val tmpZipFile = ZipFile(tmpZip)
+
+ val outStream = contentResolver.openOutputStream(zipUri, "wt")
+ ?: throw LcpException(LcpError.Container.WriteFailed(pathInZip))
+ tmpZipFile.addOrReplaceEntry(
+ pathInZip.toString(),
+ ByteArrayInputStream(license.toByteArray()),
+ outStream
+ )
+
+ outStream.close()
+ tmpZipFile.close()
+ tmpZip.delete()
+ } catch (e: Exception) {
+ throw LcpException(LcpError.Container.WriteFailed(pathInZip))
+ }
+ }
+}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/EPUBLicenseContainer.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/EPUBLicenseContainer.kt
deleted file mode 100644
index c44bc7a0f2..0000000000
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/EPUBLicenseContainer.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * Module: r2-lcp-kotlin
- * Developers: Aferdita Muriqi, Mickaël Menu
- *
- * Copyright (c) 2019. Readium Foundation. All rights reserved.
- * Use of this source code is governed by a BSD-style license which is detailed in the
- * LICENSE file present in the project repository where this source code is maintained.
- */
-
-package org.readium.r2.lcp.license.container
-
-/**
- * Access a License Document stored in an EPUB archive, under META-INF/license.lcpl.
- */
-internal class EPUBLicenseContainer(epub: String) :
- ZIPLicenseContainer(zip = epub, pathInZIP = "META-INF/license.lcpl")
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/FileUtil.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/FileUtil.kt
new file mode 100644
index 0000000000..31b389fed0
--- /dev/null
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/FileUtil.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright 2023 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.r2.lcp.license.container
+
+import java.io.File
+internal fun File.moveTo(target: File) {
+ if (!this.renameTo(target)) {
+ this.copyTo(target, overwrite = true)
+ this.delete()
+ }
+}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/ZIPLicenseContainer.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/FileZipLicenseContainer.kt
similarity index 56%
rename from readium/lcp/src/main/java/org/readium/r2/lcp/license/container/ZIPLicenseContainer.kt
rename to readium/lcp/src/main/java/org/readium/r2/lcp/license/container/FileZipLicenseContainer.kt
index a3115aa5f8..c30897b07c 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/ZIPLicenseContainer.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/FileZipLicenseContainer.kt
@@ -9,35 +9,38 @@
package org.readium.r2.lcp.license.container
+import java.io.ByteArrayInputStream
import java.io.File
import java.util.zip.ZipFile
+import org.readium.r2.lcp.LcpError
import org.readium.r2.lcp.LcpException
import org.readium.r2.lcp.license.model.LicenseDocument
-import org.zeroturnaround.zip.ZipUtil
+import org.readium.r2.shared.util.Url
/**
* Access to a License Document stored in a ZIP archive.
- * Meant to be subclassed to customize the pathInZIP property, eg. [EPUBLicenseContainer].
*/
-internal open class ZIPLicenseContainer(private val zip: String, private val pathInZIP: String) : LicenseContainer {
+internal class FileZipLicenseContainer(
+ private val zip: String,
+ private val pathInZIP: Url
+) : WritableLicenseContainer {
override fun read(): ByteArray {
-
val archive = try {
ZipFile(zip)
} catch (e: Exception) {
- throw LcpException.Container.OpenFailed
+ throw LcpException(LcpError.Container.OpenFailed)
}
val entry = try {
- archive.getEntry(pathInZIP)
+ archive.getEntry(pathInZIP.toString())!!
} catch (e: Exception) {
- throw LcpException.Container.FileNotFound(pathInZIP)
+ throw LcpException(LcpError.Container.FileNotFound(pathInZIP))
}
return try {
archive.getInputStream(entry).readBytes()
} catch (e: Exception) {
- throw LcpException.Container.ReadFailed(pathInZIP)
+ throw LcpException(LcpError.Container.ReadFailed(pathInZIP))
}
}
@@ -45,16 +48,16 @@ internal open class ZIPLicenseContainer(private val zip: String, private val pat
try {
val source = File(zip)
val tmpZip = File("$zip.tmp")
- tmpZip.delete()
- source.copyTo(tmpZip)
- source.delete()
- if (ZipUtil.containsEntry(tmpZip, pathInZIP)) {
- ZipUtil.removeEntry(tmpZip, pathInZIP)
- }
- ZipUtil.addEntry(tmpZip, pathInZIP, license.data, source)
- tmpZip.delete()
+ val zipFile = ZipFile(source)
+ zipFile.addOrReplaceEntry(
+ pathInZIP.toString(),
+ ByteArrayInputStream(license.toByteArray()),
+ tmpZip
+ )
+ zipFile.close()
+ tmpZip.moveTo(source)
} catch (e: Exception) {
- throw LcpException.Container.WriteFailed(pathInZIP)
+ throw LcpException(LcpError.Container.WriteFailed(pathInZIP))
}
}
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/LCPLLicenseContainer.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/LcplLicenseContainer.kt
similarity index 65%
rename from readium/lcp/src/main/java/org/readium/r2/lcp/license/container/LCPLLicenseContainer.kt
rename to readium/lcp/src/main/java/org/readium/r2/lcp/license/container/LcplLicenseContainer.kt
index b5dd5590aa..8ba1fcd33f 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/LCPLLicenseContainer.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/LcplLicenseContainer.kt
@@ -6,29 +6,32 @@
* Use of this source code is governed by a BSD-style license which is detailed in the
* LICENSE file present in the project repository where this source code is maintained.
*/
+
package org.readium.r2.lcp.license.container
import java.io.File
+import org.readium.r2.lcp.LcpError
import org.readium.r2.lcp.LcpException
import org.readium.r2.lcp.license.model.LicenseDocument
+import org.readium.r2.shared.util.toUrl
/**
* Access a License Document stored in an LCP License Document file (LCPL).
*/
-internal class LCPLLicenseContainer(private val lcpl: String) : LicenseContainer {
+internal class LcplLicenseContainer(private val licenseFile: File) : WritableLicenseContainer {
override fun read(): ByteArray =
try {
- File(lcpl).readBytes()
+ licenseFile.readBytes()
} catch (e: Exception) {
- throw LcpException.Container.OpenFailed
+ throw LcpException(LcpError.Container.OpenFailed)
}
override fun write(license: LicenseDocument) {
try {
- File(lcpl).writeBytes(license.data)
+ licenseFile.writeBytes(license.toByteArray())
} catch (e: Exception) {
- throw LcpException.Container.WriteFailed(lcpl)
+ throw LcpException(LcpError.Container.WriteFailed(licenseFile.toUrl()))
}
}
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/LcplResourceLicenseContainer.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/LcplResourceLicenseContainer.kt
new file mode 100644
index 0000000000..8d5fa01c50
--- /dev/null
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/LcplResourceLicenseContainer.kt
@@ -0,0 +1,29 @@
+/*
+ * Module: r2-lcp-kotlin
+ * Developers: Aferdita Muriqi, Mickaël Menu
+ *
+ * Copyright (c) 2019. Readium Foundation. All rights reserved.
+ * Use of this source code is governed by a BSD-style license which is detailed in the
+ * LICENSE file present in the project repository where this source code is maintained.
+ */
+
+package org.readium.r2.lcp.license.container
+
+import kotlinx.coroutines.runBlocking
+import org.readium.r2.lcp.LcpError
+import org.readium.r2.lcp.LcpException
+import org.readium.r2.shared.util.getOrElse
+import org.readium.r2.shared.util.resource.Resource
+
+/**
+ * Access a License Document stored in an LCP License Document file (LCPL) readable through a
+ * [Resource].
+ */
+internal class LcplResourceLicenseContainer(private val resource: Resource) : LicenseContainer {
+
+ override fun read(): ByteArray =
+ runBlocking {
+ resource.read()
+ .getOrElse { throw LcpException(LcpError.Container.OpenFailed) }
+ }
+}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/LicenseContainer.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/LicenseContainer.kt
index c65cc3c04d..b966d1b387 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/LicenseContainer.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/LicenseContainer.kt
@@ -9,9 +9,23 @@
package org.readium.r2.lcp.license.container
+import android.content.Context
+import java.io.File
+import org.readium.r2.lcp.LcpError
import org.readium.r2.lcp.LcpException
import org.readium.r2.lcp.license.model.LicenseDocument
-import org.readium.r2.shared.util.mediatype.MediaType
+import org.readium.r2.shared.util.Url
+import org.readium.r2.shared.util.asset.Asset
+import org.readium.r2.shared.util.asset.ContainerAsset
+import org.readium.r2.shared.util.asset.ResourceAsset
+import org.readium.r2.shared.util.data.Container
+import org.readium.r2.shared.util.format.EpubSpecification
+import org.readium.r2.shared.util.format.FormatSpecification
+import org.readium.r2.shared.util.format.LcpLicenseSpecification
+import org.readium.r2.shared.util.resource.Resource
+
+private val LICENSE_IN_EPUB = Url("META-INF/license.lcpl")!!
+private val LICENSE_IN_RPF = Url("license.lcpl")!!
/**
* Encapsulates the read/write access to the packaged License Document (eg. in an EPUB container,
@@ -19,22 +33,72 @@ import org.readium.r2.shared.util.mediatype.MediaType
*/
internal interface LicenseContainer {
fun read(): ByteArray
+}
+
+internal interface WritableLicenseContainer : LicenseContainer {
fun write(license: LicenseDocument)
}
-internal suspend fun createLicenseContainer(
- filepath: String,
- mediaTypes: List = emptyList()
+internal fun createLicenseContainer(
+ file: File,
+ formatSpecification: FormatSpecification
+): WritableLicenseContainer =
+ when {
+ formatSpecification.conformsTo(EpubSpecification) -> FileZipLicenseContainer(
+ file.path,
+ LICENSE_IN_EPUB
+ )
+ formatSpecification.conformsTo(LcpLicenseSpecification) -> LcplLicenseContainer(file)
+ // Assuming it's a Readium WebPub package (e.g. audiobook, LCPDF, etc.) as a fallback
+ else -> FileZipLicenseContainer(file.path, LICENSE_IN_RPF)
+ }
+
+internal fun createLicenseContainer(
+ context: Context,
+ asset: Asset
+): LicenseContainer =
+ when (asset) {
+ is ResourceAsset -> createLicenseContainer(asset.resource, asset.format.specification)
+ is ContainerAsset -> createLicenseContainer(
+ context,
+ asset.container,
+ asset.format.specification
+ )
+ }
+
+internal fun createLicenseContainer(
+ resource: Resource,
+ formatSpecification: FormatSpecification
): LicenseContainer {
- val mediaType = MediaType.ofFile(filepath, mediaTypes = mediaTypes, fileExtensions = emptyList())
- ?: throw LcpException.Container.OpenFailed
- return createLicenseContainer(filepath, mediaType)
+ if (!formatSpecification.conformsTo(LcpLicenseSpecification)) {
+ throw LcpException(LcpError.Container.OpenFailed)
+ }
+
+ return when {
+ resource.sourceUrl?.isFile == true ->
+ LcplLicenseContainer(resource.sourceUrl!!.toFile()!!)
+ else ->
+ LcplResourceLicenseContainer(resource)
+ }
}
-internal fun createLicenseContainer(filepath: String, mediaType: MediaType): LicenseContainer =
- when (mediaType) {
- MediaType.EPUB -> EPUBLicenseContainer(filepath)
- MediaType.LCP_LICENSE_DOCUMENT -> LCPLLicenseContainer(filepath)
+internal fun createLicenseContainer(
+ context: Context,
+ container: Container,
+ formatSpecification: FormatSpecification
+): LicenseContainer {
+ val licensePath = when {
+ formatSpecification.conformsTo(EpubSpecification) -> LICENSE_IN_EPUB
// Assuming it's a Readium WebPub package (e.g. audiobook, LCPDF, etc.) as a fallback
- else -> WebPubLicenseContainer(filepath)
+ else -> LICENSE_IN_RPF
}
+
+ return when {
+ container.sourceUrl?.isFile == true ->
+ FileZipLicenseContainer(container.sourceUrl!!.path!!, licensePath)
+ container.sourceUrl?.isContent == true ->
+ ContentZipLicenseContainer(context, container, licensePath)
+ else ->
+ ContainerLicenseContainer(container, licensePath)
+ }
+}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/WebPubLicenseContainer.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/WebPubLicenseContainer.kt
deleted file mode 100644
index c7033c224c..0000000000
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/WebPubLicenseContainer.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * Module: r2-lcp-kotlin
- * Developers: Mickaël Menu
- *
- * Copyright (c) 2020. Readium Foundation. All rights reserved.
- * Use of this source code is governed by a BSD-style license which is detailed in the
- * LICENSE file present in the project repository where this source code is maintained.
- */
-
-package org.readium.r2.lcp.license.container
-
-/**
- * Access a License Document stored in a Readium WebPub package (e.g. WebPub, Audiobook, LCPDF or DiViNa).
- */
-internal class WebPubLicenseContainer(path: String) :
- ZIPLicenseContainer(zip = path, pathInZIP = "license.lcpl")
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/ZipUtil.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/ZipUtil.kt
new file mode 100644
index 0000000000..fdcfc35c57
--- /dev/null
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/ZipUtil.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2023 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.r2.lcp.license.container
+
+import java.io.File
+import java.io.FileOutputStream
+import java.io.InputStream
+import java.io.OutputStream
+import java.util.zip.ZipEntry
+import java.util.zip.ZipFile
+import java.util.zip.ZipOutputStream
+
+internal fun ZipFile.addOrReplaceEntry(
+ name: String,
+ inputStream: InputStream,
+ dest: File
+) {
+ addOrReplaceEntry(name, inputStream, FileOutputStream(dest))
+}
+
+internal fun ZipFile.addOrReplaceEntry(
+ name: String,
+ inputStream: InputStream,
+ dest: OutputStream
+) {
+ val outZip = ZipOutputStream(dest)
+ var entryAdded = false
+
+ val newEntry = ZipEntry(name)
+ newEntry.method = ZipEntry.DEFLATED
+ getEntry(name)?.let { originalEntry ->
+ newEntry.extra = originalEntry.extra
+ newEntry.comment = originalEntry.comment
+ }
+
+ for (entry in entries()) {
+ if (entry.name == name) {
+ addEntry(newEntry, inputStream, outZip)
+ entryAdded = true
+ } else {
+ copyEntry(entry.copy(), this, outZip)
+ }
+ }
+
+ if (!entryAdded) {
+ addEntry(newEntry, inputStream, outZip)
+ }
+
+ outZip.finish()
+ outZip.close()
+}
+
+private fun ZipEntry.copy(): ZipEntry {
+ val copy = ZipEntry(name)
+ if (crc != -1L) {
+ copy.crc = crc
+ }
+ if (method != -1) {
+ copy.method = method
+ }
+ if (size >= 0) {
+ copy.size = size
+ }
+ if (extra != null) {
+ copy.extra = extra
+ }
+ copy.comment = comment
+ copy.time = time
+ return copy
+}
+
+/**
+ * If STORED method is used, entry must contain CRC and size.
+ */
+private fun addEntry(
+ entry: ZipEntry,
+ source: InputStream,
+ outStream: ZipOutputStream
+) {
+ outStream.putNextEntry(entry)
+ source.copyTo(outStream)
+ outStream.closeEntry()
+}
+
+private fun copyEntry(
+ entry: ZipEntry,
+ srcZip: ZipFile,
+ outStream: ZipOutputStream
+) {
+ addEntry(entry, srcZip.getInputStream(entry), outStream)
+}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/LicenseDocument.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/LicenseDocument.kt
index 0843124d27..2e584b2c13 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/LicenseDocument.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/LicenseDocument.kt
@@ -9,10 +9,10 @@
package org.readium.r2.lcp.license.model
-import java.net.URL
import java.nio.charset.Charset
import java.util.*
import org.json.JSONObject
+import org.readium.r2.lcp.LcpError
import org.readium.r2.lcp.LcpException
import org.readium.r2.lcp.license.model.components.Link
import org.readium.r2.lcp.license.model.components.Links
@@ -23,68 +23,137 @@ import org.readium.r2.lcp.license.model.components.lcp.User
import org.readium.r2.lcp.service.URLParameters
import org.readium.r2.shared.extensions.iso8601ToDate
import org.readium.r2.shared.extensions.optNullableString
+import org.readium.r2.shared.util.AbsoluteUrl
+import org.readium.r2.shared.util.Try
+import org.readium.r2.shared.util.Url
import org.readium.r2.shared.util.mediatype.MediaType
-class LicenseDocument(val data: ByteArray) {
- val provider: String
- val id: String
- val issued: Date
- val updated: Date
- val encryption: Encryption
- val links: Links
- val user: User
- val rights: Rights
- val signature: Signature
- val json: JSONObject
-
- enum class Rel(val rawValue: String) {
- hint("hint"),
- publication("publication"),
- self("self"),
- support("support"),
- status("status");
-
- companion object {
- operator fun invoke(rawValue: String) = values().firstOrNull { it.rawValue == rawValue }
+public class LicenseDocument internal constructor(public val json: JSONObject) {
+
+ public companion object {
+
+ public fun fromJSON(json: JSONObject): Try {
+ val document = try {
+ LicenseDocument(json)
+ } catch (e: Exception) {
+ check(e is LcpException)
+ check(e.error is LcpError.Parsing)
+ return Try.failure(e.error)
+ }
+
+ return Try.success(document)
+ }
+
+ public fun fromBytes(data: ByteArray): Try {
+ val json = try {
+ JSONObject(data.decodeToString())
+ } catch (e: Exception) {
+ return Try.failure(LcpError.Parsing.MalformedJSON)
+ }
+
+ return fromJSON(json)
}
}
+ public val provider: String =
+ json.optNullableString("provider")
+ ?: throw LcpException(LcpError.Parsing.LicenseDocument)
+
+ public val id: String =
+ json.optNullableString("id")
+ ?: throw LcpException(LcpError.Parsing.LicenseDocument)
+
+ public val issued: Date =
+ json.optNullableString("issued")
+ ?.iso8601ToDate()
+ ?: throw LcpException(LcpError.Parsing.LicenseDocument)
+
+ public val updated: Date =
+ json.optNullableString("updated")
+ ?.iso8601ToDate()
+ ?: issued
+
+ public val encryption: Encryption =
+ json.optJSONObject("encryption")
+ ?.let { Encryption(it) }
+ ?: throw LcpException(LcpError.Parsing.LicenseDocument)
+
+ public val links: Links =
+ json.optJSONArray("links")
+ ?.let { Links(it) }
+ ?: throw LcpException(LcpError.Parsing.LicenseDocument)
+
+ public val user: User =
+ User(json.optJSONObject("user") ?: JSONObject())
+
+ public val rights: Rights =
+ Rights(json.optJSONObject("rights") ?: JSONObject())
+
+ public val signature: Signature =
+ json.optJSONObject("signature")
+ ?.let { Signature(it) }
+ ?: throw LcpException(LcpError.Parsing.LicenseDocument)
+
init {
+ if (link(Rel.Hint) == null || link(Rel.Publication) == null) {
+ throw LcpException(LcpError.Parsing.LicenseDocument)
+ }
+
+ // Check that the acquisition link has a valid URL.
try {
- json = JSONObject(data.toString(Charset.defaultCharset()))
+ link(Rel.Publication)!!.url() as AbsoluteUrl
} catch (e: Exception) {
- throw LcpException.Parsing.MalformedJSON
+ throw LcpException(LcpError.Parsing.Url(rel = LicenseDocument.Rel.Publication.value))
}
+ }
- provider = json.optNullableString("provider") ?: throw LcpException.Parsing.LicenseDocument
- id = json.optNullableString("id") ?: throw LcpException.Parsing.LicenseDocument
- issued = json.optNullableString("issued")?.iso8601ToDate() ?: throw LcpException.Parsing.LicenseDocument
- encryption = json.optJSONObject("encryption")?.let { Encryption(it) } ?: throw LcpException.Parsing.LicenseDocument
- signature = json.optJSONObject("signature")?.let { Signature(it) } ?: throw LcpException.Parsing.LicenseDocument
- links = json.optJSONArray("links")?.let { Links(it) } ?: throw LcpException.Parsing.LicenseDocument
- updated = json.optNullableString("updated")?.iso8601ToDate() ?: issued
- user = User(json.optJSONObject("user") ?: JSONObject())
- rights = Rights(json.optJSONObject("rights") ?: JSONObject())
-
- if (link(Rel.hint) == null || link(Rel.publication) == null) {
- throw LcpException.Parsing.LicenseDocument
+ internal constructor(data: ByteArray) : this(
+ try {
+ JSONObject(data.decodeToString())
+ } catch (e: Exception) {
+ throw LcpException(LcpError.Parsing.MalformedJSON)
+ }
+ )
+
+ public enum class Rel(public val value: String) {
+ Hint("hint"),
+ Publication("publication"),
+ Self("self"),
+ Support("support"),
+ Status("status");
+
+ @Deprecated("Use [value] instead", ReplaceWith("value"), level = DeprecationLevel.ERROR)
+ public val rawValue: String get() = value
+
+ public companion object {
+ public operator fun invoke(value: String): Rel? = values().firstOrNull { it.value == value }
}
}
- fun link(rel: Rel, type: MediaType? = null): Link? =
- links.firstWithRel(rel.rawValue, type)
+ public val publicationLink: Link
+ get() = link(Rel.Publication)!!
+
+ public fun link(rel: Rel, type: MediaType? = null): Link? =
+ links.firstWithRel(rel.value, type)
- fun links(rel: Rel, type: MediaType? = null): List =
- links.allWithRel(rel.rawValue, type)
+ public fun links(rel: Rel, type: MediaType? = null): List =
+ links.allWithRel(rel.value, type)
- fun url(rel: Rel, preferredType: MediaType? = null, parameters: URLParameters = emptyMap()): URL {
+ public fun url(
+ rel: Rel,
+ preferredType: MediaType? = null,
+ parameters: URLParameters = emptyMap()
+ ): Url {
val link = link(rel, preferredType)
- ?: links.firstWithRelAndNoType(rel.rawValue)
- ?: throw LcpException.Parsing.Url(rel = rel.rawValue)
+ ?: links.firstWithRelAndNoType(rel.value)
+ ?: throw LcpException(LcpError.Parsing.Url(rel = rel.value))
- return link.url(parameters)
+ return link.url(parameters = parameters)
}
- val description: String
+ public val description: String
get() = "License($id)"
+
+ public fun toByteArray(): ByteArray =
+ json.toString().toByteArray(Charset.defaultCharset())
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/StatusDocument.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/StatusDocument.kt
index b15c8671f0..aea56dd363 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/StatusDocument.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/StatusDocument.kt
@@ -9,10 +9,10 @@
package org.readium.r2.lcp.license.model
-import java.net.URL
import java.nio.charset.Charset
import java.util.*
import org.json.JSONObject
+import org.readium.r2.lcp.LcpError
import org.readium.r2.lcp.LcpException
import org.readium.r2.lcp.license.model.components.Link
import org.readium.r2.lcp.license.model.components.Links
@@ -22,41 +22,48 @@ import org.readium.r2.lcp.service.URLParameters
import org.readium.r2.shared.extensions.iso8601ToDate
import org.readium.r2.shared.extensions.mapNotNull
import org.readium.r2.shared.extensions.optNullableString
+import org.readium.r2.shared.util.Url
import org.readium.r2.shared.util.mediatype.MediaType
-class StatusDocument(val data: ByteArray) {
- val id: String
- val status: Status
- val message: String
- val licenseUpdated: Date
- val statusUpdated: Date
- val links: Links
- val potentialRights: PotentialRights?
- val events: List
-
- val json: JSONObject
-
- enum class Status(val rawValue: String) {
- ready("ready"),
- active("active"),
- revoked("revoked"),
- returned("returned"),
- cancelled("cancelled"),
- expired("expired");
-
- companion object {
- operator fun invoke(rawValue: String) = values().firstOrNull { it.rawValue == rawValue }
+public class StatusDocument(public val data: ByteArray) {
+ public val id: String
+ public val status: Status
+ public val message: String
+ public val licenseUpdated: Date
+ public val statusUpdated: Date
+ public val links: Links
+ public val potentialRights: PotentialRights?
+ public val events: List
+
+ public val json: JSONObject
+
+ public enum class Status(public val value: String) {
+ Ready("ready"),
+ Active("active"),
+ Revoked("revoked"),
+ Returned("returned"),
+ Cancelled("cancelled"),
+ Expired("expired");
+
+ @Deprecated("Use [value] instead", ReplaceWith("value"), level = DeprecationLevel.ERROR)
+ public val rawValue: String get() = value
+
+ public companion object {
+ public operator fun invoke(value: String): Status? = values().firstOrNull { it.value == value }
}
}
- enum class Rel(val rawValue: String) {
- register("register"),
- license("license"),
- `return`("return"),
- renew("renew");
+ public enum class Rel(public val value: String) {
+ Register("register"),
+ License("license"),
+ Return("return"),
+ Renew("renew");
- companion object {
- operator fun invoke(rawValue: String) = values().firstOrNull { it.rawValue == rawValue }
+ @Deprecated("Use [value] instead", ReplaceWith("value"), level = DeprecationLevel.ERROR)
+ public val rawValue: String get() = value
+
+ public companion object {
+ public operator fun invoke(value: String): Rel? = values().firstOrNull { it.value == value }
}
}
@@ -64,18 +71,28 @@ class StatusDocument(val data: ByteArray) {
try {
json = JSONObject(data.toString(Charset.defaultCharset()))
} catch (e: Exception) {
- throw LcpException.Parsing.MalformedJSON
+ throw LcpException(LcpError.Parsing.MalformedJSON)
}
- id = json.optNullableString("id") ?: throw LcpException.Parsing.StatusDocument
- status = json.optNullableString("status")?.let { Status(it) } ?: throw LcpException.Parsing.StatusDocument
- message = json.optNullableString("message") ?: throw LcpException.Parsing.StatusDocument
+ id = json.optNullableString("id") ?: throw LcpException(LcpError.Parsing.StatusDocument)
+ status = json.optNullableString("status")?.let { Status(it) } ?: throw LcpException(
+ LcpError.Parsing.StatusDocument
+ )
+ message = json.optNullableString("message") ?: throw LcpException(
+ LcpError.Parsing.StatusDocument
+ )
val updated = json.optJSONObject("updated") ?: JSONObject()
- licenseUpdated = updated.optNullableString("license")?.iso8601ToDate() ?: throw LcpException.Parsing.StatusDocument
- statusUpdated = updated.optNullableString("status")?.iso8601ToDate() ?: throw LcpException.Parsing.StatusDocument
+ licenseUpdated = updated.optNullableString("license")?.iso8601ToDate() ?: throw LcpException(
+ LcpError.Parsing.StatusDocument
+ )
+ statusUpdated = updated.optNullableString("status")?.iso8601ToDate() ?: throw LcpException(
+ LcpError.Parsing.StatusDocument
+ )
- links = json.optJSONArray("links")?.let { Links(it) } ?: throw LcpException.Parsing.StatusDocument
+ links = json.optJSONArray("links")?.let { Links(it) } ?: throw LcpException(
+ LcpError.Parsing.StatusDocument
+ )
potentialRights = json.optJSONObject("potential_rights")?.let { PotentialRights(it) }
@@ -86,29 +103,33 @@ class StatusDocument(val data: ByteArray) {
?: emptyList()
}
- fun link(rel: Rel, type: MediaType? = null): Link? =
- links.firstWithRel(rel.rawValue, type)
+ public fun link(rel: Rel, type: MediaType? = null): Link? =
+ links.firstWithRel(rel.value, type)
- fun links(rel: Rel, type: MediaType? = null): List =
- links.allWithRel(rel.rawValue, type)
+ public fun links(rel: Rel, type: MediaType? = null): List =
+ links.allWithRel(rel.value, type)
internal fun linkWithNoType(rel: Rel): Link? =
- links.firstWithRelAndNoType(rel.rawValue)
+ links.firstWithRelAndNoType(rel.value)
- fun url(rel: Rel, preferredType: MediaType? = null, parameters: URLParameters = emptyMap()): URL {
+ public fun url(
+ rel: Rel,
+ preferredType: MediaType? = null,
+ parameters: URLParameters = emptyMap()
+ ): Url {
val link = link(rel, preferredType)
?: linkWithNoType(rel)
- ?: throw LcpException.Parsing.Url(rel = rel.rawValue)
+ ?: throw LcpException(LcpError.Parsing.Url(rel = rel.value))
- return link.url(parameters)
+ return link.url(parameters = parameters)
}
- fun events(type: Event.EventType): List =
- events(type.rawValue)
+ public fun events(type: Event.EventType): List =
+ events(type.value)
- fun events(type: String): List =
+ public fun events(type: String): List =
events.filter { it.type == type }
- val description: String
- get() = "Status(${status.rawValue})"
+ public val description: String
+ get() = "Status(${status.value})"
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/Link.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/Link.kt
index b001bf4315..c322e8d0f7 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/Link.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/Link.kt
@@ -10,74 +10,74 @@
package org.readium.r2.lcp.license.model.components
-import java.net.URL
-import org.json.JSONArray
import org.json.JSONObject
+import org.readium.r2.lcp.LcpError
import org.readium.r2.lcp.LcpException
-import org.readium.r2.lcp.service.URLParameters
-import org.readium.r2.shared.publication.Link
-import org.readium.r2.shared.util.URITemplate
+import org.readium.r2.shared.extensions.optNullableInt
+import org.readium.r2.shared.extensions.optNullableString
+import org.readium.r2.shared.extensions.optStringsFromArrayOrSingle
+import org.readium.r2.shared.publication.Href
+import org.readium.r2.shared.util.Url
import org.readium.r2.shared.util.mediatype.MediaType
-data class Link(val json: JSONObject) {
- val href: String
- var rel = mutableListOf()
- val title: String?
- val type: String?
- val templated: Boolean
- val profile: String?
- val length: Int?
- val hash: String?
+public data class Link(
+ val href: Href,
+ val mediaType: MediaType? = null,
+ val title: String? = null,
+ val rels: Set = setOf(),
+ val profile: String? = null,
+ val length: Int? = null,
+ val hash: String? = null
+) {
- init {
-
- href = if (json.has("href")) json.getString("href") else throw LcpException.Parsing.Link
-
- if (json.has("rel")) {
- val rel = json["rel"]
- if (rel is String) {
- this.rel.add(rel)
- } else if (rel is JSONArray) {
- for (i in 0 until rel.length()) {
- this.rel.add(rel[i].toString())
+ public companion object {
+ public operator fun invoke(
+ json: JSONObject
+ ): Link {
+ val href = json.optNullableString("href")
+ ?.let {
+ Href(
+ href = it,
+ templated = json.optBoolean("templated", false)
+ )
}
- }
- }
+ ?: throw LcpException(LcpError.Parsing.Link)
- if (rel.isEmpty()) {
- throw LcpException.Parsing.Link
+ return Link(
+ href = href,
+ mediaType = json.optNullableString("type")
+ ?.let { MediaType(it) },
+ title = json.optNullableString("title"),
+ rels = json.optStringsFromArrayOrSingle("rel").toSet()
+ .takeIf { it.isNotEmpty() }
+ ?: throw LcpException(LcpError.Parsing.Link),
+ profile = json.optNullableString("profile"),
+ length = json.optNullableInt("length"),
+ hash = json.optNullableString("hash")
+ )
}
-
- title = if (json.has("title")) json.getString("title") else null
- type = if (json.has("type")) json.getString("type") else null
- templated = if (json.has("templated")) json.getBoolean("templated") else false
- profile = if (json.has("profile")) json.getString("profile") else null
- length = if (json.has("length")) json.getInt("length") else null
- hash = if (json.has("hash")) json.getString("hash") else null
}
- fun url(parameters: URLParameters): URL {
- if (!templated) {
- return URL(href)
- }
-
- val expandedHref = URITemplate(href).expand(parameters.mapValues { it.value ?: "" })
- return URL(expandedHref)
- }
-
- val url: URL
- get() = url(parameters = emptyMap())
-
- val mediaType: MediaType
- get() = type?.let { MediaType.parse(it) } ?: MediaType.BINARY
-
/**
- * List of URI template parameter keys, if the [Link] is templated.
+ * Returns the URL represented by this link's HREF.
+ *
+ * If the HREF is a template, the [parameters] are used to expand it according to RFC 6570.
*/
- internal val templateParameters: List by lazy {
- if (!templated)
- emptyList()
- else
- URITemplate(href).parameters
- }
+ public fun url(
+ parameters: Map = emptyMap()
+ ): Url = href.resolve(parameters = parameters)
+
+ @Deprecated(
+ "Use [mediaType.toString()] instead",
+ ReplaceWith("mediaType.toString()"),
+ level = DeprecationLevel.ERROR
+ )
+ public val type: String? get() = throw NotImplementedError()
+
+ @Deprecated(
+ "Renamed `rels`",
+ ReplaceWith("rels"),
+ level = DeprecationLevel.ERROR
+ )
+ public val rel: List get() = throw NotImplementedError()
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/Links.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/Links.kt
index 02fda8880b..749783bcdb 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/Links.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/Links.kt
@@ -15,7 +15,7 @@ import org.readium.r2.shared.extensions.mapNotNull
import org.readium.r2.shared.extensions.tryOrNull
import org.readium.r2.shared.util.mediatype.MediaType
-data class Links(val json: JSONArray) {
+public data class Links(val json: JSONArray) {
val links: List = json
.mapNotNull { item ->
@@ -24,17 +24,17 @@ data class Links(val json: JSONArray) {
}
}
- fun firstWithRel(rel: String, type: MediaType? = null): Link? =
+ public fun firstWithRel(rel: String, type: MediaType? = null): Link? =
links.firstOrNull { it.matches(rel, type) }
internal fun firstWithRelAndNoType(rel: String): Link? =
- links.firstOrNull { it.rel.contains(rel) && it.type == null }
+ links.firstOrNull { it.rels.contains(rel) && it.mediaType == null }
- fun allWithRel(rel: String, type: MediaType? = null): List =
+ public fun allWithRel(rel: String, type: MediaType? = null): List =
links.filter { it.matches(rel, type) }
- private fun Link.matches(rel: String, type: MediaType?): Boolean =
- this.rel.contains(rel) && (type?.matches(this.type) ?: true)
+ private fun Link.matches(rel: String, mediaType: MediaType?): Boolean =
+ this.rels.contains(rel) && (mediaType?.matches(this.mediaType) ?: true)
- operator fun get(rel: String): List = allWithRel(rel)
+ public operator fun get(rel: String): List = allWithRel(rel)
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/ContentKey.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/ContentKey.kt
index c28dbe6dfb..56e5e6a50b 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/ContentKey.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/ContentKey.kt
@@ -10,14 +10,27 @@
package org.readium.r2.lcp.license.model.components.lcp
import org.json.JSONObject
+import org.readium.r2.lcp.LcpError
import org.readium.r2.lcp.LcpException
-data class ContentKey(val json: JSONObject) {
+public data class ContentKey(val json: JSONObject) {
val algorithm: String
val encryptedValue: String
init {
- algorithm = if (json.has("algorithm")) json.getString("algorithm") else throw LcpException.Parsing.Encryption
- encryptedValue = if (json.has("encrypted_value")) json.getString("encrypted_value") else throw LcpException.Parsing.Encryption
+ algorithm = if (json.has("algorithm")) {
+ json.getString("algorithm")
+ } else {
+ throw LcpException(
+ LcpError.Parsing.Encryption
+ )
+ }
+ encryptedValue = if (json.has("encrypted_value")) {
+ json.getString("encrypted_value")
+ } else {
+ throw LcpException(
+ LcpError.Parsing.Encryption
+ )
+ }
}
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/Encryption.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/Encryption.kt
index d25f1366bd..0899a3f8fa 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/Encryption.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/Encryption.kt
@@ -10,16 +10,35 @@
package org.readium.r2.lcp.license.model.components.lcp
import org.json.JSONObject
+import org.readium.r2.lcp.LcpError
import org.readium.r2.lcp.LcpException
-data class Encryption(val json: JSONObject) {
+public data class Encryption(val json: JSONObject) {
val profile: String
val contentKey: ContentKey
val userKey: UserKey
init {
- profile = if (json.has("profile")) json.getString("profile") else throw LcpException.Parsing.Encryption
- contentKey = if (json.has("content_key")) ContentKey(json.getJSONObject("content_key")) else throw LcpException.Parsing.Encryption
- userKey = if (json.has("user_key")) UserKey(json.getJSONObject("user_key")) else throw LcpException.Parsing.Encryption
+ profile = if (json.has("profile")) {
+ json.getString("profile")
+ } else {
+ throw LcpException(
+ LcpError.Parsing.Encryption
+ )
+ }
+ contentKey = if (json.has("content_key")) {
+ ContentKey(json.getJSONObject("content_key"))
+ } else {
+ throw LcpException(
+ LcpError.Parsing.Encryption
+ )
+ }
+ userKey = if (json.has("user_key")) {
+ UserKey(json.getJSONObject("user_key"))
+ } else {
+ throw LcpException(
+ LcpError.Parsing.Encryption
+ )
+ }
}
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/Rights.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/Rights.kt
index 104cdde54e..a3ca648390 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/Rights.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/Rights.kt
@@ -15,7 +15,7 @@ import org.readium.r2.shared.extensions.iso8601ToDate
import org.readium.r2.shared.extensions.optNullableInt
import org.readium.r2.shared.extensions.optNullableString
-data class Rights(val json: JSONObject) {
+public data class Rights(val json: JSONObject) {
val print: Int?
val copy: Int?
val start: Date?
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/Signature.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/Signature.kt
index a8dd863f80..e17429c077 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/Signature.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/Signature.kt
@@ -10,11 +10,18 @@
package org.readium.r2.lcp.license.model.components.lcp
import org.json.JSONObject
+import org.readium.r2.lcp.LcpError
import org.readium.r2.lcp.LcpException
import org.readium.r2.shared.extensions.optNullableString
-data class Signature(val json: JSONObject) {
- val algorithm: String = json.optNullableString("algorithm") ?: throw LcpException.Parsing.Signature
- val certificate: String = json.optNullableString("certificate") ?: throw LcpException.Parsing.Signature
- val value: String = json.optNullableString("value") ?: throw LcpException.Parsing.Signature
+public data class Signature(val json: JSONObject) {
+ val algorithm: String = json.optNullableString("algorithm") ?: throw LcpException(
+ LcpError.Parsing.Signature
+ )
+ val certificate: String = json.optNullableString("certificate") ?: throw LcpException(
+ LcpError.Parsing.Signature
+ )
+ val value: String = json.optNullableString("value") ?: throw LcpException(
+ LcpError.Parsing.Signature
+ )
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/User.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/User.kt
index ad5a5081fc..9411d85575 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/User.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/User.kt
@@ -12,12 +12,12 @@ package org.readium.r2.lcp.license.model.components.lcp
import org.json.JSONObject
-data class User(val json: JSONObject) {
+public data class User(val json: JSONObject) {
val id: String?
val email: String?
val name: String?
var extensions: JSONObject
- var encrypted = mutableListOf()
+ var encrypted: MutableList = mutableListOf()
init {
id = if (json.has("id")) json.getString("id") else null
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/UserKey.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/UserKey.kt
index 8e55c83548..9b624d86b1 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/UserKey.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lcp/UserKey.kt
@@ -10,16 +10,35 @@
package org.readium.r2.lcp.license.model.components.lcp
import org.json.JSONObject
+import org.readium.r2.lcp.LcpError
import org.readium.r2.lcp.LcpException
-data class UserKey(val json: JSONObject) {
+public data class UserKey(val json: JSONObject) {
val textHint: String
val algorithm: String
val keyCheck: String
init {
- textHint = if (json.has("text_hint")) json.getString("text_hint") else throw LcpException.Parsing.Encryption
- algorithm = if (json.has("algorithm")) json.getString("algorithm") else throw LcpException.Parsing.Encryption
- keyCheck = if (json.has("key_check")) json.getString("key_check") else throw LcpException.Parsing.Encryption
+ textHint = if (json.has("text_hint")) {
+ json.getString("text_hint")
+ } else {
+ throw LcpException(
+ LcpError.Parsing.Encryption
+ )
+ }
+ algorithm = if (json.has("algorithm")) {
+ json.getString("algorithm")
+ } else {
+ throw LcpException(
+ LcpError.Parsing.Encryption
+ )
+ }
+ keyCheck = if (json.has("key_check")) {
+ json.getString("key_check")
+ } else {
+ throw LcpException(
+ LcpError.Parsing.Encryption
+ )
+ }
}
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lsd/Event.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lsd/Event.kt
index 291c175d21..8664ca9836 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lsd/Event.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lsd/Event.kt
@@ -14,21 +14,24 @@ import org.json.JSONObject
import org.readium.r2.shared.extensions.iso8601ToDate
import org.readium.r2.shared.extensions.optNullableString
-data class Event(val json: JSONObject) {
+public data class Event(val json: JSONObject) {
val type: String = json.optNullableString("type") ?: ""
val name: String = json.optNullableString("name") ?: ""
val id: String = json.optNullableString("id") ?: ""
val date: Date? = json.optNullableString("timestamp")?.iso8601ToDate()
- enum class EventType(val rawValue: String) {
- register("register"),
- renew("renew"),
- `return`("return"),
- revoke("revoke"),
- cancel("cancel");
+ public enum class EventType(public val value: String) {
+ Register("register"),
+ Renew("renew"),
+ Return("return"),
+ Revoke("revoke"),
+ Cancel("cancel");
- companion object {
- operator fun invoke(rawValue: String) = values().firstOrNull { it.rawValue == rawValue }
+ @Deprecated("Use [value] instead", ReplaceWith("value"), level = DeprecationLevel.ERROR)
+ public val rawValue: String get() = value
+
+ public companion object {
+ public operator fun invoke(value: String): EventType? = values().firstOrNull { it.value == value }
}
}
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lsd/PotentialRights.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lsd/PotentialRights.kt
index 519740b860..389fc23fa3 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lsd/PotentialRights.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lsd/PotentialRights.kt
@@ -14,6 +14,6 @@ import org.json.JSONObject
import org.readium.r2.shared.extensions.iso8601ToDate
import org.readium.r2.shared.extensions.optNullableString
-data class PotentialRights(val json: JSONObject) {
+public data class PotentialRights(val json: JSONObject) {
val end: Date? = json.optNullableString("end")?.iso8601ToDate()
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/persistence/LcpDao.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/persistence/LcpDao.kt
index 80f6b22975..c4c0b37c46 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/persistence/LcpDao.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/persistence/LcpDao.kt
@@ -4,18 +4,24 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
+import androidx.room.Transaction
+import kotlinx.coroutines.flow.Flow
@Dao
-interface LcpDao {
+internal interface LcpDao {
/**
* Retrieve passphrase
* @return Passphrase
*/
- @Query("SELECT ${Passphrase.PASSPHRASE} FROM ${Passphrase.TABLE_NAME} WHERE ${Passphrase.PROVIDER} = :licenseId")
+ @Query(
+ "SELECT ${Passphrase.PASSPHRASE} FROM ${Passphrase.TABLE_NAME} WHERE ${Passphrase.PROVIDER} = :licenseId"
+ )
suspend fun passphrase(licenseId: String): String?
- @Query("SELECT ${Passphrase.PASSPHRASE} FROM ${Passphrase.TABLE_NAME} WHERE ${Passphrase.USERID} = :userId")
+ @Query(
+ "SELECT ${Passphrase.PASSPHRASE} FROM ${Passphrase.TABLE_NAME} WHERE ${Passphrase.USERID} = :userId"
+ )
suspend fun passphrases(userId: String): List
@Query("SELECT ${Passphrase.PASSPHRASE} FROM ${Passphrase.TABLE_NAME}")
@@ -24,27 +30,83 @@ interface LcpDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun addPassphrase(passphrase: Passphrase)
- @Query("SELECT ${License.LICENSE_ID} FROM ${License.TABLE_NAME} WHERE ${License.LICENSE_ID} = :licenseId")
+ @Query(
+ "SELECT ${License.LICENSE_ID} FROM ${License.TABLE_NAME} WHERE ${License.LICENSE_ID} = :licenseId"
+ )
suspend fun exists(licenseId: String): String?
- @Query("SELECT ${License.REGISTERED} FROM ${License.TABLE_NAME} WHERE ${License.LICENSE_ID} = :licenseId")
+ @Query(
+ "SELECT ${License.REGISTERED} FROM ${License.TABLE_NAME} WHERE ${License.LICENSE_ID} = :licenseId"
+ )
suspend fun isDeviceRegistered(licenseId: String): Boolean
- @Query("UPDATE ${License.TABLE_NAME} SET ${License.REGISTERED} = 1 WHERE ${License.LICENSE_ID} = :licenseId")
+ @Query(
+ "UPDATE ${License.TABLE_NAME} SET ${License.REGISTERED} = 1 WHERE ${License.LICENSE_ID} = :licenseId"
+ )
suspend fun registerDevice(licenseId: String)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun addLicense(license: License)
- @Query("SELECT ${License.RIGHTCOPY} FROM ${License.TABLE_NAME} WHERE ${License.LICENSE_ID} = :licenseId")
- fun getCopiesLeft(licenseId: String): Int?
+ @Query(
+ "SELECT ${License.RIGHTCOPY} FROM ${License.TABLE_NAME} WHERE ${License.LICENSE_ID} = :licenseId"
+ )
+ suspend fun getCopiesLeft(licenseId: String): Int?
- @Query("UPDATE ${License.TABLE_NAME} SET ${License.RIGHTCOPY} = :quantity WHERE ${License.LICENSE_ID} = :licenseId")
- fun setCopiesLeft(quantity: Int, licenseId: String)
+ @Query(
+ "SELECT ${License.RIGHTCOPY} FROM ${License.TABLE_NAME} WHERE ${License.LICENSE_ID} = :licenseId"
+ )
+ fun copiesLeftFlow(licenseId: String): Flow
- @Query("SELECT ${License.RIGHTPRINT} FROM ${License.TABLE_NAME} WHERE ${License.LICENSE_ID} = :licenseId")
- fun getPrintsLeft(licenseId: String): Int?
+ @Query(
+ "UPDATE ${License.TABLE_NAME} SET ${License.RIGHTCOPY} = :quantity WHERE ${License.LICENSE_ID} = :licenseId"
+ )
+ suspend fun setCopiesLeft(quantity: Int, licenseId: String)
- @Query("UPDATE ${License.TABLE_NAME} SET ${License.RIGHTPRINT} = :quantity WHERE ${License.LICENSE_ID} = :licenseId")
- fun setPrintsLeft(quantity: Int, licenseId: String)
+ @Transaction
+ suspend fun tryCopy(quantity: Int, licenseId: String): Boolean {
+ require(quantity >= 0)
+ val copiesLeft = getCopiesLeft(licenseId)
+ return when {
+ copiesLeft == null ->
+ true
+ copiesLeft < quantity ->
+ false
+ else -> {
+ setCopiesLeft(copiesLeft - quantity, licenseId)
+ return true
+ }
+ }
+ }
+
+ @Query(
+ "SELECT ${License.RIGHTPRINT} FROM ${License.TABLE_NAME} WHERE ${License.LICENSE_ID} = :licenseId"
+ )
+ suspend fun getPrintsLeft(licenseId: String): Int?
+
+ @Query(
+ "SELECT ${License.RIGHTPRINT} FROM ${License.TABLE_NAME} WHERE ${License.LICENSE_ID} = :licenseId"
+ )
+ fun printsLeftFlow(licenseId: String): Flow
+
+ @Query(
+ "UPDATE ${License.TABLE_NAME} SET ${License.RIGHTPRINT} = :quantity WHERE ${License.LICENSE_ID} = :licenseId"
+ )
+ suspend fun setPrintsLeft(quantity: Int, licenseId: String)
+
+ @Transaction
+ suspend fun tryPrint(quantity: Int, licenseId: String): Boolean {
+ require(quantity >= 0)
+ val printLeft = getPrintsLeft(licenseId)
+ return when {
+ printLeft == null ->
+ true
+ printLeft < quantity ->
+ false
+ else -> {
+ setPrintsLeft(printLeft - quantity, licenseId)
+ return true
+ }
+ }
+ }
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/persistence/LcpDatabase.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/persistence/LcpDatabase.kt
index d877001a6c..d7744f9bc1 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/persistence/LcpDatabase.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/persistence/LcpDatabase.kt
@@ -35,8 +35,8 @@ internal abstract class LcpDatabase : RoomDatabase() {
return tempInstance
}
val MIGRATION_1_2 = object : Migration(1, 2) {
- override fun migrate(database: SupportSQLiteDatabase) {
- database.execSQL(
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
"""
CREATE TABLE passphrases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -47,15 +47,15 @@ internal abstract class LcpDatabase : RoomDatabase() {
)
""".trimIndent()
)
- database.execSQL(
+ db.execSQL(
"""
INSERT INTO passphrases (license_id, provider, user_id, passphrase)
SELECT id, origin, userId, passphrase FROM Transactions
""".trimIndent()
)
- database.execSQL("DROP TABLE Transactions")
+ db.execSQL("DROP TABLE Transactions")
- database.execSQL(
+ db.execSQL(
"""
CREATE TABLE new_Licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -66,14 +66,14 @@ internal abstract class LcpDatabase : RoomDatabase() {
)
""".trimIndent()
)
- database.execSQL(
+ db.execSQL(
"""
INSERT INTO new_Licenses (license_id, right_print, right_copy, registered)
SELECT id, printsLeft, copiesLeft, registered FROM Licenses
""".trimIndent()
)
- database.execSQL("DROP TABLE Licenses")
- database.execSQL("ALTER TABLE new_Licenses RENAME TO licenses")
+ db.execSQL("DROP TABLE Licenses")
+ db.execSQL("ALTER TABLE new_Licenses RENAME TO licenses")
}
}
synchronized(this) {
@@ -81,7 +81,7 @@ internal abstract class LcpDatabase : RoomDatabase() {
context.applicationContext,
LcpDatabase::class.java,
"lcpdatabase"
- ).allowMainThreadQueries().addMigrations(MIGRATION_1_2).build()
+ ).addMigrations(MIGRATION_1_2).build()
INSTANCE = instance
return instance
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/persistence/License.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/persistence/License.kt
index 7f0f31be04..de02041a4c 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/persistence/License.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/persistence/License.kt
@@ -14,7 +14,7 @@ import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = License.TABLE_NAME)
-data class License(
+internal data class License(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = ID)
var id: Long? = null,
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/persistence/Passphrase.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/persistence/Passphrase.kt
index 4b7fbfbc15..8e64c3ceae 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/persistence/Passphrase.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/persistence/Passphrase.kt
@@ -14,7 +14,7 @@ import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = Passphrase.TABLE_NAME)
-data class Passphrase(
+internal data class Passphrase(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = ID)
var id: Long? = null,
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/public/Deprecated.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/public/Deprecated.kt
index f6b44cfa8f..ac5585d8c9 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/public/Deprecated.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/public/Deprecated.kt
@@ -11,31 +11,70 @@ package org.readium.r2.lcp.public
import android.content.Context
import org.readium.r2.lcp.LcpAuthenticating
-import org.readium.r2.lcp.LcpException
+import org.readium.r2.lcp.LcpError
import org.readium.r2.lcp.LcpLicense
import org.readium.r2.lcp.LcpService
-@Deprecated("Renamed to `LcpService`", ReplaceWith("org.readium.r2.lcp.LcpService"), level = DeprecationLevel.ERROR)
-typealias LCPService = LcpService
-@Deprecated("Renamed to `LcpService.AcquiredPublication`", ReplaceWith("org.readium.r2.lcp.LcpService.AcquiredPublication"), level = DeprecationLevel.ERROR)
-typealias LCPImportedPublication = LcpService.AcquiredPublication
+@Deprecated(
+ "Renamed to `LcpService`",
+ ReplaceWith("org.readium.r2.lcp.LcpService"),
+ level = DeprecationLevel.ERROR
+)
+public typealias LCPService = LcpService
+
+@Deprecated(
+ "Renamed to `LcpService.AcquiredPublication`",
+ ReplaceWith("org.readium.r2.lcp.LcpService.AcquiredPublication"),
+ level = DeprecationLevel.ERROR
+)
+public typealias LCPImportedPublication = LcpService.AcquiredPublication
+
@Deprecated("Not used anymore", level = DeprecationLevel.ERROR)
-typealias URLPresenter = () -> Unit
-@Deprecated("Renamed to `LcpLicense`", ReplaceWith("org.readium.r2.lcp.LcpLicense"), level = DeprecationLevel.ERROR)
-typealias LCPLicense = LcpLicense
+public typealias URLPresenter = () -> Unit
+
+@Deprecated(
+ "Renamed to `LcpLicense`",
+ ReplaceWith("org.readium.r2.lcp.LcpLicense"),
+ level = DeprecationLevel.ERROR
+)
+public typealias LCPLicense = LcpLicense
+
+@Deprecated(
+ "Renamed to `LcpAuthenticating`",
+ ReplaceWith("org.readium.r2.lcp.LcpAuthenticating"),
+ level = DeprecationLevel.ERROR
+)
+public typealias LCPAuthenticating = LcpAuthenticating
-@Deprecated("Renamed to `LcpAuthenticating`", ReplaceWith("org.readium.r2.lcp.LcpAuthenticating"), level = DeprecationLevel.ERROR)
-typealias LCPAuthenticating = LcpAuthenticating
@Deprecated("Not used anymore", level = DeprecationLevel.ERROR)
-interface LCPAuthenticationDelegate
-@Deprecated("Renamed to `LcpAuthenticating.AuthenticationReason`", ReplaceWith("org.readium.r2.lcp.LcpAuthenticating.AuthenticationReason"), level = DeprecationLevel.ERROR)
-typealias LCPAuthenticationReason = LcpAuthenticating.AuthenticationReason
-@Deprecated("Renamed to `LcpAuthenticating.AuthenticatedLicense`", ReplaceWith("org.readium.r2.lcp.LcpAuthenticating.AuthenticatedLicense"), level = DeprecationLevel.ERROR)
-typealias LCPAuthenticatedLicense = LcpAuthenticating.AuthenticatedLicense
-
-@Deprecated("Renamed to `LcpException", ReplaceWith("org.readium.r2.lcp.LcpException"), level = DeprecationLevel.ERROR)
-typealias LCPError = LcpException
-
-@Deprecated("Renamed to `LcpService()`", ReplaceWith("LcpService()"), level = DeprecationLevel.ERROR)
-fun R2MakeLCPService(context: Context) =
- LcpService(context)
+public interface LCPAuthenticationDelegate
+
+@Deprecated(
+ "Renamed to `LcpAuthenticating.AuthenticationReason`",
+ ReplaceWith("org.readium.r2.lcp.LcpAuthenticating.AuthenticationReason"),
+ level = DeprecationLevel.ERROR
+)
+public typealias LCPAuthenticationReason = LcpAuthenticating.AuthenticationReason
+
+@Deprecated(
+ "Renamed to `LcpAuthenticating.AuthenticatedLicense`",
+ ReplaceWith("org.readium.r2.lcp.LcpAuthenticating.AuthenticatedLicense"),
+ level = DeprecationLevel.ERROR
+)
+public typealias LCPAuthenticatedLicense = LcpAuthenticating.AuthenticatedLicense
+
+@Deprecated(
+ "Renamed to `LcpException",
+ ReplaceWith("org.readium.r2.lcp.LcpException"),
+ level = DeprecationLevel.ERROR
+)
+public typealias LCPError = LcpError
+
+@Deprecated(
+ "Renamed to `LcpService()`",
+ ReplaceWith("LcpService()"),
+ level = DeprecationLevel.ERROR
+)
+@Suppress("UNUSED_PARAMETER")
+public fun R2MakeLCPService(context: Context): LcpService? =
+ throw NotImplementedError()
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/service/CRLService.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/service/CRLService.kt
index 992ca8e40b..c896fbc666 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/service/CRLService.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/service/CRLService.kt
@@ -17,6 +17,7 @@ import kotlin.time.ExperimentalTime
import org.joda.time.DateTime
import org.joda.time.Days
import org.readium.r2.lcp.BuildConfig.DEBUG
+import org.readium.r2.lcp.LcpError
import org.readium.r2.lcp.LcpException
import org.readium.r2.shared.util.getOrElse
import timber.log.Timber
@@ -24,7 +25,10 @@ import timber.log.Timber
@OptIn(ExperimentalTime::class)
internal class CRLService(val network: NetworkService, val context: Context) {
- private val preferences: SharedPreferences = context.getSharedPreferences("org.readium.r2.lcp", Context.MODE_PRIVATE)
+ private val preferences: SharedPreferences = context.getSharedPreferences(
+ "org.readium.r2.lcp",
+ Context.MODE_PRIVATE
+ )
companion object {
const val expiration = 7
@@ -50,12 +54,15 @@ internal class CRLService(val network: NetworkService, val context: Context) {
private suspend fun fetch(): String {
val url = "http://crl.edrlab.telesec.de/rl/EDRLab_CA.crl"
val data = network.fetch(url, NetworkService.Method.GET)
- .getOrElse { throw LcpException.CrlFetching }
+ .getOrElse { throw LcpException(LcpError.CrlFetching) }
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
"-----BEGIN X509 CRL-----${Base64.getEncoder().encodeToString(data)}-----END X509 CRL-----"
} else {
- "-----BEGIN X509 CRL-----${android.util.Base64.encodeToString(data, android.util.Base64.DEFAULT)}-----END X509 CRL-----"
+ "-----BEGIN X509 CRL-----${android.util.Base64.encodeToString(
+ data,
+ android.util.Base64.DEFAULT
+ )}-----END X509 CRL-----"
}
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/service/DeviceRepository.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/service/DeviceRepository.kt
index 9ded1bcfa5..dfd7f64209 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/service/DeviceRepository.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/service/DeviceRepository.kt
@@ -9,6 +9,7 @@
package org.readium.r2.lcp.service
+import org.readium.r2.lcp.LcpError
import org.readium.r2.lcp.LcpException
import org.readium.r2.lcp.license.model.LicenseDocument
import org.readium.r2.lcp.persistence.LcpDao
@@ -17,14 +18,14 @@ internal class DeviceRepository(private val lcpDao: LcpDao) {
suspend fun isDeviceRegistered(license: LicenseDocument): Boolean {
if (lcpDao.exists(license.id) == null) {
- throw LcpException.Runtime("The LCP License doesn't exist in the database")
+ throw LcpException(LcpError.Runtime("The LCP License doesn't exist in the database"))
}
return lcpDao.isDeviceRegistered(license.id)
}
suspend fun registerDevice(license: LicenseDocument) {
if (lcpDao.exists(license.id) == null) {
- throw LcpException.Runtime("The LCP License doesn't exist in the database")
+ throw LcpException(LcpError.Runtime("The LCP License doesn't exist in the database"))
}
lcpDao.registerDevice(license.id)
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/service/DeviceService.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/service/DeviceService.kt
index cde7889faf..07182b2f29 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/service/DeviceService.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/service/DeviceService.kt
@@ -29,7 +29,10 @@ internal class DeviceService(
val context: Context
) : Serializable {
- private val preferences: SharedPreferences = context.getSharedPreferences("org.readium.r2.settings", Context.MODE_PRIVATE)
+ private val preferences: SharedPreferences = context.getSharedPreferences(
+ "org.readium.r2.settings",
+ Context.MODE_PRIVATE
+ )
val id: String
get() {
@@ -67,7 +70,7 @@ internal class DeviceService(
return null
}
- val url = link.url(asQueryParameters).toString()
+ val url = link.url(parameters = asQueryParameters).toString()
val data = network.fetch(url, NetworkService.Method.POST, asQueryParameters)
.getOrNull() ?: return null
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/service/LcpClient.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/service/LcpClient.kt
index f361d9a522..5be0dd1dd7 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/service/LcpClient.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/service/LcpClient.kt
@@ -1,6 +1,7 @@
package org.readium.r2.lcp.service
import java.lang.reflect.InvocationTargetException
+import org.readium.r2.lcp.LcpError
import org.readium.r2.lcp.LcpException
import org.readium.r2.shared.extensions.tryOr
@@ -26,12 +27,17 @@ internal object LcpClient {
fun toDRMContext(): Any =
Class.forName("org.readium.lcp.sdk.DRMContext")
- .getConstructor(String::class.java, String::class.java, String::class.java, String::class.java)
+ .getConstructor(
+ String::class.java,
+ String::class.java,
+ String::class.java,
+ String::class.java
+ )
.newInstance(hashedPassphrase, encryptedContentKey, token, profile)
}
private val instance: Any by lazy {
- klass.newInstance()
+ klass.getDeclaredConstructor().newInstance()
}
private val klass: Class<*> by lazy {
@@ -46,7 +52,12 @@ internal object LcpClient {
fun createContext(jsonLicense: String, hashedPassphrases: String, pemCrl: String): Context =
try {
val drmContext = klass
- .getMethod("createContext", String::class.java, String::class.java, String::class.java)
+ .getMethod(
+ "createContext",
+ String::class.java,
+ String::class.java,
+ String::class.java
+ )
.invoke(instance, jsonLicense, hashedPassphrases, pemCrl)!!
Context.fromDRMContext(drmContext)
@@ -57,7 +68,11 @@ internal object LcpClient {
fun decrypt(context: Context, encryptedData: ByteArray): ByteArray =
try {
klass
- .getMethod("decrypt", Class.forName("org.readium.lcp.sdk.DRMContext"), ByteArray::class.java)
+ .getMethod(
+ "decrypt",
+ Class.forName("org.readium.lcp.sdk.DRMContext"),
+ ByteArray::class.java
+ )
.invoke(instance, context.toDRMContext(), encryptedData)
as ByteArray
} catch (e: InvocationTargetException) {
@@ -74,11 +89,11 @@ internal object LcpClient {
}
private fun mapException(e: Throwable): LcpException {
-
val drmExceptionClass = Class.forName("org.readium.lcp.sdk.DRMException")
- if (!drmExceptionClass.isInstance(e))
- return LcpException.Runtime("the Lcp client threw an unhandled exception")
+ if (!drmExceptionClass.isInstance(e)) {
+ return LcpException(LcpError.Runtime("the Lcp client threw an unhandled exception"))
+ }
val drmError = drmExceptionClass
.getMethod("getDrmError")
@@ -89,19 +104,21 @@ internal object LcpClient {
.getMethod("getCode")
.invoke(drmError) as Int
- return when (errorCode) {
+ val error = when (errorCode) {
// Error code 11 should never occur since we check the start/end date before calling createContext
- 11 -> LcpException.Runtime("License is out of date (check start and end date).")
- 101 -> LcpException.LicenseIntegrity.CertificateRevoked
- 102 -> LcpException.LicenseIntegrity.InvalidCertificateSignature
- 111 -> LcpException.LicenseIntegrity.InvalidLicenseSignatureDate
- 112 -> LcpException.LicenseIntegrity.InvalidLicenseSignature
+ 11 -> LcpError.Runtime("License is out of date (check start and end date).")
+ 101 -> LcpError.LicenseIntegrity.CertificateRevoked
+ 102 -> LcpError.LicenseIntegrity.InvalidCertificateSignature
+ 111 -> LcpError.LicenseIntegrity.InvalidLicenseSignatureDate
+ 112 -> LcpError.LicenseIntegrity.InvalidLicenseSignature
// Error code 121 seems to be unused in the C++ lib.
- 121 -> LcpException.Runtime("The drm context is invalid.")
- 131 -> LcpException.Decryption.ContentKeyDecryptError
- 141 -> LcpException.LicenseIntegrity.InvalidUserKeyCheck
- 151 -> LcpException.Decryption.ContentDecryptError
- else -> LcpException.Unknown(e)
+ 121 -> LcpError.Runtime("The drm context is invalid.")
+ 131 -> LcpError.Decryption.ContentKeyDecryptError
+ 141 -> LcpError.LicenseIntegrity.InvalidUserKeyCheck
+ 151 -> LcpError.Decryption.ContentDecryptError
+ else -> LcpError.Unknown(e)
}
+
+ return LcpException(error)
}
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/service/LicensesRepository.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/service/LicensesRepository.kt
index 7dd818c036..93ef9e716f 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/service/LicensesRepository.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/service/LicensesRepository.kt
@@ -9,6 +9,7 @@
package org.readium.r2.lcp.service
+import kotlinx.coroutines.flow.Flow
import org.readium.r2.lcp.license.model.LicenseDocument
import org.readium.r2.lcp.persistence.LcpDao
import org.readium.r2.lcp.persistence.License
@@ -27,19 +28,17 @@ internal class LicensesRepository(private val lcpDao: LcpDao) {
lcpDao.addLicense(license)
}
- fun copiesLeft(licenseId: String): Int? {
- return lcpDao.getCopiesLeft(licenseId)
+ fun copiesLeft(licenseId: String): Flow {
+ return lcpDao.copiesLeftFlow(licenseId)
}
- fun setCopiesLeft(quantity: Int, licenseId: String) {
- lcpDao.setCopiesLeft(quantity, licenseId)
- }
+ suspend fun tryCopy(quantity: Int, licenseId: String): Boolean =
+ lcpDao.tryCopy(quantity, licenseId)
- fun printsLeft(licenseId: String): Int? {
- return lcpDao.getPrintsLeft(licenseId)
+ fun printsLeft(licenseId: String): Flow {
+ return lcpDao.printsLeftFlow(licenseId)
}
- fun setPrintsLeft(quantity: Int, licenseId: String) {
- lcpDao.setPrintsLeft(quantity, licenseId)
- }
+ suspend fun tryPrint(quantity: Int, licenseId: String): Boolean =
+ lcpDao.tryPrint(quantity, licenseId)
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/service/LicensesService.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/service/LicensesService.kt
index 5bf9cd5dce..aabdbf8b5d 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/service/LicensesService.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/service/LicensesService.kt
@@ -10,21 +10,31 @@
package org.readium.r2.lcp.service
import android.content.Context
-import java.io.File
import kotlin.coroutines.resume
-import kotlinx.coroutines.*
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
import org.readium.r2.lcp.LcpAuthenticating
-import org.readium.r2.lcp.LcpException
+import org.readium.r2.lcp.LcpContentProtection
+import org.readium.r2.lcp.LcpError
import org.readium.r2.lcp.LcpLicense
+import org.readium.r2.lcp.LcpPublicationRetriever
import org.readium.r2.lcp.LcpService
import org.readium.r2.lcp.license.License
import org.readium.r2.lcp.license.LicenseValidation
import org.readium.r2.lcp.license.container.LicenseContainer
+import org.readium.r2.lcp.license.container.WritableLicenseContainer
import org.readium.r2.lcp.license.container.createLicenseContainer
import org.readium.r2.lcp.license.model.LicenseDocument
-import org.readium.r2.shared.extensions.tryOr
+import org.readium.r2.shared.publication.protection.ContentProtection
import org.readium.r2.shared.util.Try
-import org.readium.r2.shared.util.mediatype.MediaType
+import org.readium.r2.shared.util.asset.Asset
+import org.readium.r2.shared.util.asset.AssetRetriever
+import org.readium.r2.shared.util.downloads.DownloadManager
import timber.log.Timber
internal class LicensesService(
@@ -33,52 +43,73 @@ internal class LicensesService(
private val device: DeviceService,
private val network: NetworkService,
private val passphrases: PassphrasesService,
- private val context: Context
+ private val context: Context,
+ private val assetRetriever: AssetRetriever,
+ private val downloadManager: DownloadManager
) : LcpService, CoroutineScope by MainScope() {
- override suspend fun isLcpProtected(file: File): Boolean =
- tryOr(false) {
- createLicenseContainer(file.path).read()
- true
- }
+ override fun contentProtection(
+ authentication: LcpAuthenticating
+ ): ContentProtection =
+ LcpContentProtection(this, authentication, assetRetriever)
- override suspend fun acquirePublication(lcpl: ByteArray, onProgress: (Double) -> Unit): Try =
- try {
- val licenseDocument = LicenseDocument(lcpl)
- Timber.d("license ${licenseDocument.json}")
- fetchPublication(licenseDocument, onProgress).let { Try.success(it) }
- } catch (e: Exception) {
- Try.failure(LcpException.wrap(e))
- }
+ override fun publicationRetriever(): LcpPublicationRetriever {
+ return LcpPublicationRetriever(
+ context,
+ downloadManager,
+ assetRetriever
+ )
+ }
override suspend fun retrieveLicense(
- file: File,
+ asset: Asset,
authentication: LcpAuthenticating,
- allowUserInteraction: Boolean,
- sender: Any?
- ): Try? =
+ allowUserInteraction: Boolean
+ ): Try =
try {
- val container = createLicenseContainer(file.path)
- // WARNING: Using the Default dispatcher in the state machine code is critical. If we were using the Main Dispatcher,
- // calling runBlocking in LicenseValidation.handle would block the main thread and cause a severe issue
- // with LcpAuthenticating.retrievePassphrase. Specifically, the interaction of runBlocking and suspendCoroutine
- // blocks the current thread before the passphrase popup has been showed until some button not yet showed is clicked.
- val license = withContext(Dispatchers.Default) { retrieveLicense(container, authentication, allowUserInteraction, sender) }
- Timber.d("license retrieved ${license?.license}")
-
- license?.let { Try.success(it) }
+ val licenseContainer = createLicenseContainer(context, asset)
+ val license = retrieveLicense(
+ licenseContainer,
+ authentication,
+ allowUserInteraction
+ )
+ Try.success(license)
} catch (e: Exception) {
- Try.failure(LcpException.wrap(e))
+ Try.failure(LcpError.wrap(e))
}
private suspend fun retrieveLicense(
+ container: LicenseContainer,
+ authentication: LcpAuthenticating,
+ allowUserInteraction: Boolean
+ ): LcpLicense {
+ // WARNING: Using the Default dispatcher in the state machine code is critical. If we were using the Main Dispatcher,
+ // calling runBlocking in LicenseValidation.handle would block the main thread and cause a severe issue
+ // with LcpAuthenticating.retrievePassphrase. Specifically, the interaction of runBlocking and suspendCoroutine
+ // blocks the current thread before the passphrase popup has been showed until some button not yet showed is clicked.
+ val license = withContext(Dispatchers.Default) {
+ retrieveLicenseUnsafe(
+ container,
+ authentication,
+ allowUserInteraction
+ )
+ }
+ Timber.d("license retrieved ${license.license}")
+
+ return license
+ }
+
+ private suspend fun retrieveLicenseUnsafe(
container: LicenseContainer,
authentication: LcpAuthenticating?,
- allowUserInteraction: Boolean,
- sender: Any?
- ): License? =
+ allowUserInteraction: Boolean
+ ): License =
suspendCancellableCoroutine { cont ->
- retrieveLicense(container, authentication, allowUserInteraction, sender) { license ->
+ retrieveLicense(
+ container,
+ authentication,
+ allowUserInteraction
+ ) { license ->
if (cont.isActive) {
cont.resume(license)
}
@@ -89,17 +120,20 @@ internal class LicensesService(
container: LicenseContainer,
authentication: LcpAuthenticating?,
allowUserInteraction: Boolean,
- sender: Any?,
- completion: (License?) -> Unit
+ completion: (License) -> Unit
) {
-
var initialData = container.read()
Timber.d("license ${LicenseDocument(data = initialData).json}")
val validation = LicenseValidation(
- authentication = authentication, crl = this.crl,
- device = this.device, network = this.network, passphrases = this.passphrases, context = this.context,
- allowUserInteraction = allowUserInteraction, sender = sender
+ authentication = authentication,
+ crl = this.crl,
+ device = this.device,
+ network = this.network,
+ passphrases = this.passphrases,
+ context = this.context,
+ allowUserInteraction = allowUserInteraction,
+ ignoreInternetErrors = container is WritableLicenseContainer
) { licenseDocument ->
try {
launch {
@@ -108,9 +142,11 @@ internal class LicensesService(
} catch (error: Error) {
Timber.d("Failed to add the LCP License to the local database: $error")
}
- if (!licenseDocument.data.contentEquals(initialData)) {
+ if (!licenseDocument.toByteArray().contentEquals(initialData)) {
try {
- container.write(licenseDocument)
+ (container as? WritableLicenseContainer)
+ ?.let { container.write(licenseDocument) }
+
Timber.d("licenseDocument ${licenseDocument.json}")
initialData = container.read()
@@ -127,38 +163,27 @@ internal class LicensesService(
Timber.d("validated documents $it")
try {
documents.getContext()
- completion(License(documents = it, validation = validation, licenses = this.licenses, device = this.device, network = this.network))
+ launch {
+ completion(
+ License(
+ documents = it,
+ validation = validation,
+ licenses = this@LicensesService.licenses,
+ device = this@LicensesService.device,
+ network = this@LicensesService.network
+ )
+ )
+ }
} catch (e: Exception) {
throw e
}
}
error?.let { throw error }
- if (documents == null && error == null) {
- completion(null)
+ // Both error and documents can be null if the user cancelled the passphrase prompt.
+ if (documents == null) {
+ throw CancellationException("License validation was interrupted.")
}
}
}
-
- private suspend fun fetchPublication(license: LicenseDocument, onProgress: (Double) -> Unit): LcpService.AcquiredPublication {
- val link = license.link(LicenseDocument.Rel.publication)
- val url = link?.url
- ?: throw LcpException.Parsing.Url(rel = LicenseDocument.Rel.publication.rawValue)
-
- val destination = withContext(Dispatchers.IO) {
- File.createTempFile("lcp-${System.currentTimeMillis()}", ".tmp")
- }
- Timber.i("LCP destination $destination")
-
- val mediaType = network.download(url, destination, mediaType = link.type, onProgress = onProgress) ?: MediaType.of(mediaType = link.type) ?: MediaType.EPUB
-
- // Saves the License Document into the downloaded publication
- val container = createLicenseContainer(destination.path, mediaType)
- container.write(license)
-
- return LcpService.AcquiredPublication(
- localFile = destination,
- suggestedFilename = "${license.id}.${mediaType.fileExtension}"
- )
- }
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/service/NetworkService.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/service/NetworkService.kt
index 182592ecaf..a41116f05c 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/service/NetworkService.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/service/NetworkService.kt
@@ -17,26 +17,28 @@ import java.net.HttpURLConnection
import java.net.URL
import kotlin.math.round
import kotlin.time.Duration
-import kotlin.time.ExperimentalTime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import org.readium.r2.lcp.LcpError
import org.readium.r2.lcp.LcpException
import org.readium.r2.shared.util.Try
+import org.readium.r2.shared.util.Url
import org.readium.r2.shared.util.mediatype.MediaType
-import org.readium.r2.shared.util.mediatype.sniffMediaType
import timber.log.Timber
-internal typealias URLParameters = Map
+internal typealias URLParameters = Map
-internal class NetworkException(val status: Int?, cause: Throwable? = null) : Exception("Network failure with status $status", cause)
+internal class NetworkException(val status: Int?, cause: Throwable? = null) : Exception(
+ "Network failure with status $status",
+ cause
+)
-@OptIn(ExperimentalTime::class)
internal class NetworkService {
- enum class Method(val rawValue: String) {
+ enum class Method(val value: String) {
GET("GET"), POST("POST"), PUT("PUT");
companion object {
- operator fun invoke(rawValue: String) = values().firstOrNull { it.rawValue == rawValue }
+ operator fun invoke(value: String) = values().firstOrNull { it.value == value }
}
}
@@ -50,10 +52,12 @@ internal class NetworkService {
withContext(Dispatchers.IO) {
try {
@Suppress("NAME_SHADOWING")
- val url = URL(Uri.parse(url).buildUpon().appendQueryParameters(parameters).build().toString())
+ val url = URL(
+ Uri.parse(url).buildUpon().appendQueryParameters(parameters).build().toString()
+ )
val connection = url.openConnection() as HttpURLConnection
- connection.requestMethod = method.rawValue
+ connection.requestMethod = method.value
if (timeout != null) {
connection.connectTimeout = timeout.inWholeMilliseconds.toInt()
}
@@ -81,22 +85,20 @@ internal class NetworkService {
private fun Uri.Builder.appendQueryParameters(parameters: URLParameters): Uri.Builder =
apply {
for ((key, value) in parameters) {
- if (value != null) {
- appendQueryParameter(key, value)
- }
+ appendQueryParameter(key, value)
}
}
suspend fun download(
- url: URL,
+ url: Url,
destination: File,
- mediaType: String? = null,
+ mediaType: MediaType? = null,
onProgress: (Double) -> Unit
): MediaType? = withContext(Dispatchers.IO) {
try {
- val connection = url.openConnection() as HttpURLConnection
+ val connection = URL(url.toString()).openConnection() as HttpURLConnection
if (connection.responseCode >= 400) {
- throw LcpException.Network(NetworkException(connection.responseCode))
+ throw LcpException(LcpError.Network(NetworkException(connection.responseCode)))
}
var readLength = 0L
@@ -132,10 +134,12 @@ internal class NetworkService {
}
}
- connection.sniffMediaType(mediaTypes = listOfNotNull(mediaType))
+ connection.contentType
+ ?.let { MediaType(it) }
+ ?: mediaType
} catch (e: Exception) {
Timber.e(e)
- throw LcpException.Network(e)
+ throw LcpException(LcpError.Network(e))
}
}
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/service/PassphrasesService.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/service/PassphrasesService.kt
index bc9be10499..a356be015f 100644
--- a/readium/lcp/src/main/java/org/readium/r2/lcp/service/PassphrasesService.kt
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/service/PassphrasesService.kt
@@ -18,8 +18,7 @@ internal class PassphrasesService(private val repository: PassphrasesRepository)
suspend fun request(
license: LicenseDocument,
authentication: LcpAuthenticating?,
- allowUserInteraction: Boolean,
- sender: Any?
+ allowUserInteraction: Boolean
): String? {
val candidates = this@PassphrasesService.possiblePassphrasesFromRepository(license)
val passphrase = try {
@@ -29,7 +28,12 @@ internal class PassphrasesService(private val repository: PassphrasesRepository)
}
return when {
passphrase != null -> passphrase
- authentication != null -> this@PassphrasesService.authenticate(license, LcpAuthenticating.AuthenticationReason.PassphraseNotFound, authentication, allowUserInteraction, sender)
+ authentication != null -> this@PassphrasesService.authenticate(
+ license,
+ LcpAuthenticating.AuthenticationReason.PassphraseNotFound,
+ authentication,
+ allowUserInteraction
+ )
else -> null
}
}
@@ -38,11 +42,14 @@ internal class PassphrasesService(private val repository: PassphrasesRepository)
license: LicenseDocument,
reason: LcpAuthenticating.AuthenticationReason,
authentication: LcpAuthenticating,
- allowUserInteraction: Boolean,
- sender: Any?
+ allowUserInteraction: Boolean
): String? {
val authenticatedLicense = LcpAuthenticating.AuthenticatedLicense(document = license)
- val clearPassphrase = authentication.retrievePassphrase(authenticatedLicense, reason, allowUserInteraction, sender)
+ val clearPassphrase = authentication.retrievePassphrase(
+ authenticatedLicense,
+ reason,
+ allowUserInteraction
+ )
?: return null
val hashedPassphrase = HASH.sha256(clearPassphrase)
val passphrases = mutableListOf(hashedPassphrase)
@@ -57,7 +64,12 @@ internal class PassphrasesService(private val repository: PassphrasesRepository)
addPassphrase(passphrase, true, license.id, license.provider, license.user.id)
passphrase
} catch (e: Exception) {
- authenticate(license, LcpAuthenticating.AuthenticationReason.InvalidPassphrase, authentication, allowUserInteraction, sender)
+ authenticate(
+ license,
+ LcpAuthenticating.AuthenticationReason.InvalidPassphrase,
+ authentication,
+ allowUserInteraction
+ )
}
}
diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/util/Digest.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/util/Digest.kt
new file mode 100644
index 0000000000..5365e51b68
--- /dev/null
+++ b/readium/lcp/src/main/java/org/readium/r2/lcp/util/Digest.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2023 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.r2.lcp.util
+
+import java.io.File
+import java.security.MessageDigest
+import org.readium.r2.shared.extensions.tryOrNull
+
+/**
+ * Returns the SHA-256 sum of file content or null if computation failed.
+ */
+internal fun File.sha256(): ByteArray? =
+ tryOrNull {
+ val md = MessageDigest.getInstance("SHA-256")
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ inputStream().use {
+ var bytes = it.read(buffer)
+ while (bytes >= 0) {
+ md.update(buffer, 0, bytes)
+ bytes = it.read(buffer)
+ }
+ }
+ return md.digest()
+ }
diff --git a/readium/lcp/src/main/res/layout/r2_lcp_auth_dialog.xml b/readium/lcp/src/main/res/layout/readium_lcp_auth_dialog.xml
similarity index 95%
rename from readium/lcp/src/main/res/layout/r2_lcp_auth_dialog.xml
rename to readium/lcp/src/main/res/layout/readium_lcp_auth_dialog.xml
index 67921e8ace..a226959842 100644
--- a/readium/lcp/src/main/res/layout/r2_lcp_auth_dialog.xml
+++ b/readium/lcp/src/main/res/layout/readium_lcp_auth_dialog.xml
@@ -16,7 +16,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
- android:text="@string/r2_lcp_dialog_cancel"
+ android:text="@string/readium_lcp_dialog_cancel"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
@@ -85,7 +85,7 @@
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
- android:text="@string/r2_lcp_dialog_continue"
+ android:text="@string/readium_lcp_dialog_continue"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/r2_passwordLayout" />
@@ -98,7 +98,7 @@
android:layout_marginTop="16dp"
android:layout_marginEnd="8dp"
android:background="@android:color/transparent"
- android:text="@string/r2_lcp_dialog_forgotPassphrase"
+ android:text="@string/readium_lcp_dialog_forgotPassphrase"
android:textAlignment="viewStart"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
@@ -112,7 +112,7 @@
android:layout_marginTop="16dp"
android:layout_marginEnd="8dp"
android:background="@android:color/transparent"
- android:text="@string/r2_lcp_dialog_help"
+ android:text="@string/readium_lcp_dialog_help"
android:textAlignment="viewStart"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
diff --git a/readium/lcp/src/main/res/values/strings.xml b/readium/lcp/src/main/res/values/strings.xml
index e744f7e0c8..7a2bab272c 100644
--- a/readium/lcp/src/main/res/values/strings.xml
+++ b/readium/lcp/src/main/res/values/strings.xml
@@ -9,61 +9,16 @@
- Continue
- Cancel
- Passphrase Required
- Incorrect Passphrase
- This publication is protected by Readium LCP.\n\nIn order to open it, we need to know the passphrase required by: \n\n%1$s.\n\nTo help you remember it, the following hint is available:
- Forgot your passphrase?
- Need more help?
- Support
- Website
- Phone
- Mail
-
-
-
- This interaction is not available
- This License has a profile identifier that this app cannot handle, the publication cannot be processed
- Can\'t retrieve the Certificate Revocation List
- Network error
- Unexpected LCP error
- Unknown LCP error
-
- This license was cancelled on %1$s
- This license has been returned on %1$s
- This license starts on %1$s
- This license expired on %1$s
-
- - This license was revoked by its provider on %1$s. It was registered by %2$d device.
- - This license was revoked by its provider on %1$s. It was registered by %2$d devices.
-
-
- Your publication could not be renewed properly
- Incorrect renewal period, your publication could not be renewed
- An unexpected error has occurred on the server
-
- Your publication could not be returned properly
- Your publication has already been returned before or is expired
- An unexpected error has occurred on the server
-
- The JSON is not representing a valid document
- The JSON is malformed and can\'t be parsed
- The JSON is not representing a valid License Document
- The JSON is not representing a valid Status Document
-
- Can\'t open the license container
- License not found in container
- Can\'t read license from container
- Can\'t write license in container
-
- Certificate has been revoked in the CRL
- Certificate has not been signed by CA
- License has been issued by an expired certificate
- License signature does not match
- User key check invalid
-
- Unable to decrypt encrypted content key from user key
- Unable to decrypt encrypted content from content key
+ Continue
+ Cancel
+ Passphrase Required
+ Incorrect Passphrase
+ This publication is protected by Readium LCP.\n\nIn order to open it, we need to know the passphrase required by: \n\n%1$s.\n\nTo help you remember it, the following hint is available:
+ Forgot your passphrase?
+ Need more help?
+ Support
+ Website
+ Phone
+ Mail
\ No newline at end of file
diff --git a/readium/lcp/src/test/java/org/readium/r2/lcp/license/container/ZipUtilTest.kt b/readium/lcp/src/test/java/org/readium/r2/lcp/license/container/ZipUtilTest.kt
new file mode 100644
index 0000000000..8b461df4b0
--- /dev/null
+++ b/readium/lcp/src/test/java/org/readium/r2/lcp/license/container/ZipUtilTest.kt
@@ -0,0 +1,113 @@
+/*
+ * Copyright 2023 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.r2.lcp.license.container
+
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.FileInputStream
+import java.util.zip.ZipFile
+import java.util.zip.ZipInputStream
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertNotNull
+
+class ZipUtilTest {
+
+ private val zipPath: String =
+ ZipUtilTest::class.java.getResource("futuristic_tales.cbz")!!.path
+
+ private val zipFile: ZipFile =
+ ZipFile(zipPath)
+
+ private val entryNames: List = listOf(
+ "Cory Doctorow's Futuristic Tales of the Here and Now/a-fc.jpg",
+ "Cory Doctorow's Futuristic Tales of the Here and Now/x-002.jpg",
+ "Cory Doctorow's Futuristic Tales of the Here and Now/x-003.jpg",
+ "Cory Doctorow's Futuristic Tales of the Here and Now/x-004.jpg"
+ )
+
+ private val aFcPath: String =
+ ZipUtilTest::class.java.getResource("a-fc.jpg")!!.path
+
+ private fun ZipFile.readEntry(name: String): ByteArray? {
+ val entry = getEntry(name) ?: return null
+ val stream = getInputStream(entry)
+ return stream.readBytes()
+ }
+
+ private fun ZipInputStream.readEntries(): Map {
+ val modifiedEntries = mutableMapOf()
+
+ do {
+ val entry = nextEntry
+ if (entry != null) {
+ modifiedEntries[entry.name] = readBytes()
+ }
+ } while (entry != null)
+
+ return modifiedEntries
+ }
+
+ @Test
+ fun addEntryWorks() {
+ val entryToAdd = "Cory Doctorow's Futuristic Tales of the Here and Now/x-005.jpg"
+
+ val modifiedZip = run {
+ val outStream = ByteArrayOutputStream()
+ zipFile.addOrReplaceEntry(
+ entryToAdd,
+ FileInputStream(aFcPath),
+ outStream
+ )
+ outStream.toByteArray()
+ }
+
+ val modifiedZipStream = ZipInputStream(ByteArrayInputStream(modifiedZip))
+ val modifiedEntries = modifiedZipStream.readEntries()
+
+ for (name in entryNames) {
+ val modifiedEntry = assertNotNull(modifiedEntries[name])
+ val expected = zipFile.readEntry(name)
+ assertContentEquals(expected, modifiedEntry)
+ }
+
+ assert(entryToAdd in modifiedEntries.keys)
+ assertContentEquals(
+ zipFile.readEntry(entryNames[0]),
+ modifiedEntries[entryToAdd]
+ )
+ }
+
+ @Test
+ fun replaceEntryWorks() {
+ val entryToReplace = "Cory Doctorow's Futuristic Tales of the Here and Now/x-004.jpg"
+
+ val modifiedZip = run {
+ val outStream = ByteArrayOutputStream()
+ zipFile.addOrReplaceEntry(
+ entryToReplace,
+ FileInputStream(aFcPath),
+ outStream
+ )
+ outStream.toByteArray()
+ }
+
+ val modifiedZipStream = ZipInputStream(ByteArrayInputStream(modifiedZip))
+ val modifiedEntries = modifiedZipStream.readEntries()
+
+ for (name in entryNames) {
+ val expected = if (name == entryToReplace) {
+ zipFile.readEntry(entryNames[0])
+ } else {
+ zipFile.readEntry(name)
+ }
+
+ val modifiedEntry = assertNotNull(modifiedEntries[name])
+ assertContentEquals(expected, modifiedEntry)
+ }
+ }
+}
diff --git a/readium/lcp/src/test/java/org/readium/r2/lcp/util/DigestTest.kt b/readium/lcp/src/test/java/org/readium/r2/lcp/util/DigestTest.kt
new file mode 100644
index 0000000000..30ff7f7c2d
--- /dev/null
+++ b/readium/lcp/src/test/java/org/readium/r2/lcp/util/DigestTest.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2023 Readium Foundation. All rights reserved.
+ * Use of this source code is governed by the BSD-style license
+ * available in the top-level LICENSE file of the project.
+ */
+
+package org.readium.r2.lcp.util
+
+import java.io.File
+import kotlin.io.encoding.Base64
+import kotlin.io.encoding.ExperimentalEncodingApi
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import org.junit.Test
+
+class DigestTest {
+
+ private val file: File =
+ File(DigestTest::class.java.getResource("a-fc.jpg")!!.path)
+
+ @OptIn(ExperimentalEncodingApi::class, ExperimentalStdlibApi::class)
+ @Test
+ fun `sha256 is correct`() {
+ val digest = assertNotNull(file.sha256())
+ assertEquals("GI42TOamBYJ4q4KKBcmMzlkfvld8bTVRcbjjQ20OvLI=", Base64.encode(digest))
+ assertEquals(
+ "188e364ce6a6058278ab828a05c98cce591fbe577c6d355171b8e3436d0ebcb2",
+ digest.toHexString()
+ )
+ }
+}
diff --git a/readium/lcp/src/test/resources/org/readium/r2/lcp/license/container/a-fc.jpg b/readium/lcp/src/test/resources/org/readium/r2/lcp/license/container/a-fc.jpg
new file mode 100644
index 0000000000..8455b3d4ad
Binary files /dev/null and b/readium/lcp/src/test/resources/org/readium/r2/lcp/license/container/a-fc.jpg differ
diff --git a/readium/lcp/src/test/resources/org/readium/r2/lcp/license/container/futuristic_tales.cbz b/readium/lcp/src/test/resources/org/readium/r2/lcp/license/container/futuristic_tales.cbz
new file mode 100644
index 0000000000..48da598b30
Binary files /dev/null and b/readium/lcp/src/test/resources/org/readium/r2/lcp/license/container/futuristic_tales.cbz differ
diff --git a/readium/lcp/src/test/resources/org/readium/r2/lcp/util/a-fc.jpg b/readium/lcp/src/test/resources/org/readium/r2/lcp/util/a-fc.jpg
new file mode 100644
index 0000000000..8455b3d4ad
Binary files /dev/null and b/readium/lcp/src/test/resources/org/readium/r2/lcp/util/a-fc.jpg differ
diff --git a/readium/navigator-media2/build.gradle.kts b/readium/navigator-media2/build.gradle.kts
index 2512435bf7..1bed109721 100644
--- a/readium/navigator-media2/build.gradle.kts
+++ b/readium/navigator-media2/build.gradle.kts
@@ -13,19 +13,19 @@ plugins {
android {
resourcePrefix = "readium_"
- compileSdk = 33
+ compileSdk = 34
defaultConfig {
minSdk = 21
- targetSdk = 33
+ targetSdk = 34
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
- jvmTarget = "1.8"
+ jvmTarget = JavaVersion.VERSION_17.toString()
freeCompilerArgs = freeCompilerArgs + listOf(
"-opt-in=kotlin.RequiresOptIn",
"-opt-in=org.readium.r2.shared.InternalReadiumApi"
@@ -43,6 +43,10 @@ android {
namespace = "org.readium.navigator.media2"
}
+kotlin {
+ explicitApi()
+}
+
rootProject.ext["publish.artifactId"] = "readium-navigator-media2"
apply(from = "$rootDir/scripts/publish-module.gradle")
diff --git a/readium/navigator-media2/src/main/java/org/readium/navigator/media2/DefaultMetadataFactory.kt b/readium/navigator-media2/src/main/java/org/readium/navigator/media2/DefaultMetadataFactory.kt
index 036693ecce..1e9311ce04 100644
--- a/readium/navigator-media2/src/main/java/org/readium/navigator/media2/DefaultMetadataFactory.kt
+++ b/readium/navigator-media2/src/main/java/org/readium/navigator/media2/DefaultMetadataFactory.kt
@@ -51,7 +51,7 @@ internal class DefaultMetadataFactory(private val publication: Publication) : Me
val builder = MediaMetadata.Builder()
val link = publication.readingOrder[index]
builder.putLong(MediaMetadata.METADATA_KEY_TRACK_NUMBER, index.toLong())
- builder.putString(MediaMetadata.METADATA_KEY_MEDIA_URI, link.href)
+ builder.putString(MediaMetadata.METADATA_KEY_MEDIA_URI, link.href.toString())
builder.putString(MediaMetadata.METADATA_KEY_TITLE, link.title)
builder.putString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, publication.metadata.title)
builder.putString(MediaMetadata.METADATA_KEY_ALBUM, publication.metadata.title)
diff --git a/readium/navigator-media2/src/main/java/org/readium/navigator/media2/ExoPlayerDataSource.kt b/readium/navigator-media2/src/main/java/org/readium/navigator/media2/ExoPlayerDataSource.kt
index 16836b801b..12807146f0 100644
--- a/readium/navigator-media2/src/main/java/org/readium/navigator/media2/ExoPlayerDataSource.kt
+++ b/readium/navigator-media2/src/main/java/org/readium/navigator/media2/ExoPlayerDataSource.kt
@@ -4,6 +4,9 @@
* available in the top-level LICENSE file of the project.
*/
+// Everything in this file will be deprecated
+@file:Suppress("DEPRECATION")
+
package org.readium.navigator.media2
import android.net.Uri
@@ -15,22 +18,33 @@ import com.google.android.exoplayer2.upstream.DataSpec
import com.google.android.exoplayer2.upstream.TransferListener
import java.io.IOException
import kotlinx.coroutines.runBlocking
-import org.readium.r2.shared.fetcher.Resource
-import org.readium.r2.shared.fetcher.buffered
import org.readium.r2.shared.publication.Publication
-
-sealed class ExoPlayerDataSourceException(message: String, cause: Throwable?) : IOException(message, cause) {
- class NotOpened(message: String) : ExoPlayerDataSourceException(message, null)
- class NotFound(message: String) : ExoPlayerDataSourceException(message, null)
- class ReadFailed(uri: Uri, offset: Int, readLength: Int, cause: Throwable) : ExoPlayerDataSourceException("Failed to read $readLength bytes of URI $uri at offset $offset.", cause)
+import org.readium.r2.shared.util.data.ReadException
+import org.readium.r2.shared.util.getOrThrow
+import org.readium.r2.shared.util.resource.Resource
+import org.readium.r2.shared.util.resource.buffered
+import org.readium.r2.shared.util.toUrl
+
+public sealed class ExoPlayerDataSourceException(message: String, cause: Throwable?) : IOException(
+ message,
+ cause
+) {
+ public class NotOpened(message: String) : ExoPlayerDataSourceException(message, null)
+ public class NotFound(message: String) : ExoPlayerDataSourceException(message, null)
+ public class ReadFailed(uri: Uri, offset: Int, readLength: Int, cause: Throwable) : ExoPlayerDataSourceException(
+ "Failed to read $readLength bytes of URI $uri at offset $offset.",
+ cause
+ )
}
/**
* An ExoPlayer's [DataSource] which retrieves resources from a [Publication].
*/
-class ExoPlayerDataSource internal constructor(private val publication: Publication) : BaseDataSource(/* isNetwork = */ true) {
+public class ExoPlayerDataSource internal constructor(private val publication: Publication) : BaseDataSource(/* isNetwork = */
+ true
+) {
- class Factory(
+ public class Factory(
private val publication: Publication,
private val transferListener: TransferListener? = null
) : DataSource.Factory {
@@ -46,23 +60,25 @@ class ExoPlayerDataSource internal constructor(private val publication: Publicat
private data class OpenedResource(
val resource: Resource,
val uri: Uri,
- var position: Long,
+ var position: Long
)
private var openedResource: OpenedResource? = null
override fun open(dataSpec: DataSpec): Long {
- val link = publication.linkWithHref(dataSpec.uri.toString())
- ?: throw ExoPlayerDataSourceException.NotFound("Can't find a [Link] for URI: ${dataSpec.uri}. Make sure you only request resources declared in the manifest.")
-
- val resource = publication.get(link)
+ val resource = dataSpec.uri.toUrl()
+ ?.let { publication.linkWithHref(it) }
+ ?.let { publication.get(it) }
// Significantly improves performances, in particular with deflated ZIP entries.
- .buffered(resourceLength = cachedLengths[dataSpec.uri.toString()])
+ ?.buffered(resourceLength = cachedLengths[dataSpec.uri.toString()])
+ ?: throw ExoPlayerDataSourceException.NotFound(
+ "Can't find a [Link] for URI: ${dataSpec.uri}. Make sure you only request resources declared in the manifest."
+ )
openedResource = OpenedResource(
resource = resource,
uri = dataSpec.uri,
- position = dataSpec.position,
+ position = dataSpec.position
)
val bytesToRead =
@@ -95,12 +111,15 @@ class ExoPlayerDataSource internal constructor(private val publication: Publicat
return 0
}
- val openedResource = openedResource ?: throw ExoPlayerDataSourceException.NotOpened("No opened resource to read from. Did you call open()?")
+ val openedResource = openedResource ?: throw ExoPlayerDataSourceException.NotOpened(
+ "No opened resource to read from. Did you call open()?"
+ )
try {
val data = runBlocking {
openedResource.resource
.read(range = openedResource.position until (openedResource.position + length))
+ .mapFailure { ReadException(it) }
.getOrThrow()
}
diff --git a/readium/navigator-media2/src/main/java/org/readium/navigator/media2/MediaMetadataFactory.kt b/readium/navigator-media2/src/main/java/org/readium/navigator/media2/MediaMetadataFactory.kt
index 02ba2b3ce3..d378433196 100644
--- a/readium/navigator-media2/src/main/java/org/readium/navigator/media2/MediaMetadataFactory.kt
+++ b/readium/navigator-media2/src/main/java/org/readium/navigator/media2/MediaMetadataFactory.kt
@@ -7,16 +7,16 @@ import androidx.media2.common.MediaMetadata
*
* The metadata are used for example in the media-style Android notification.
*/
-@ExperimentalMedia2
-interface MediaMetadataFactory {
+@Deprecated("Use the new MediaMetadataFactory from the readium-navigator-media-common module.")
+public interface MediaMetadataFactory {
/**
* Creates the [MediaMetadata] for the whole publication.
*/
- suspend fun publicationMetadata(): MediaMetadata
+ public suspend fun publicationMetadata(): MediaMetadata
/**
* Creates the [MediaMetadata] for the reading order resource at the given [index].
*/
- suspend fun resourceMetadata(index: Int): MediaMetadata
+ public suspend fun resourceMetadata(index: Int): MediaMetadata
}
diff --git a/readium/navigator-media2/src/main/java/org/readium/navigator/media2/MediaNavigator.kt b/readium/navigator-media2/src/main/java/org/readium/navigator/media2/MediaNavigator.kt
index 3332e877fa..9534a6eaf5 100644
--- a/readium/navigator-media2/src/main/java/org/readium/navigator/media2/MediaNavigator.kt
+++ b/readium/navigator-media2/src/main/java/org/readium/navigator/media2/MediaNavigator.kt
@@ -4,6 +4,9 @@
* available in the top-level LICENSE file of the project.
*/
+// Everything in this file will be deprecated
+@file:Suppress("DEPRECATION")
+
package org.readium.navigator.media2
import android.app.PendingIntent
@@ -31,6 +34,8 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.readium.navigator.media2.MediaNavigator.Companion.create
import org.readium.r2.navigator.Navigator
+import org.readium.r2.navigator.extensions.normalizeLocator
+import org.readium.r2.shared.DelicateReadiumApi
import org.readium.r2.shared.publication.Link
import org.readium.r2.shared.publication.Locator
import org.readium.r2.shared.publication.Publication
@@ -50,10 +55,10 @@ import timber.log.Timber
* providing [create] with it. If you don't, ExoPlayer will be used, without cache.
* You can build your own [SessionPlayer] based on [ExoPlayer] using [ExoPlayerDataSource].
*/
-@ExperimentalMedia2
+@Deprecated("Use the new AudioNavigator from the readium-navigator-media-audio module.")
@OptIn(ExperimentalTime::class)
-class MediaNavigator private constructor(
- override val publication: Publication,
+public class MediaNavigator private constructor(
+ public val publication: Publication,
private val playerFacade: SessionPlayerFacade,
private val playerCallback: SessionPlayerCallback,
private val configuration: Configuration
@@ -124,15 +129,18 @@ class MediaNavigator private constructor(
this.playerFacade.playlist!!.metadata.durations?.sum()
private fun computeLocator(
- item: ItemState,
+ item: ItemState
): Locator {
val playlist = this.playerFacade.playlist!!.map { it.metadata!! }
val position = item.position
val link = publication.readingOrder[item.index]
val itemStartPosition = playlist.slice(0 until item.index).durations?.sum()
val totalProgression =
- if (itemStartPosition == null) null
- else totalDuration?.let { (itemStartPosition + position) / it }
+ if (itemStartPosition == null) {
+ null
+ } else {
+ totalDuration?.let { (itemStartPosition + position) / it }
+ }
val locator = requireNotNull(publication.locatorFromLink(link))
return locator.copyWithLocations(
@@ -151,8 +159,8 @@ class MediaNavigator private constructor(
val state = when (currentState) {
SessionPlayerState.Playing ->
Playback.State.Playing
- SessionPlayerState.Idle, SessionPlayerState.Error ->
- Playback.State.Error
+ SessionPlayerState.Idle, SessionPlayerState.Failure ->
+ Playback.State.Failure
SessionPlayerState.Paused ->
if (playerCallback.playbackCompleted) {
Playback.State.Finished
@@ -200,7 +208,7 @@ class MediaNavigator private constructor(
/**
* Indicates the navigator current state.
*/
- val playback: StateFlow =
+ public val playback: StateFlow =
playbackMutable
/**
@@ -208,35 +216,39 @@ class MediaNavigator private constructor(
*
* Normal speed is 1.0 and 0.0 is incorrect.
*/
- suspend fun setPlaybackRate(rate: Double): Try = executeCommand {
+ public suspend fun setPlaybackRate(rate: Double): Try = executeCommand {
playerFacade.setPlaybackSpeed(rate).toNavigatorResult()
}
/**
* Resumes or start the playback at the current location.
*/
- suspend fun play(): Try = executeCommand {
+ public suspend fun play(): Try = executeCommand {
playerFacade.play().toNavigatorResult()
}
/**
* Pauses the playback.
*/
- suspend fun pause(): Try = executeCommand {
+ public suspend fun pause(): Try = executeCommand {
playerFacade.pause().toNavigatorResult()
}
/**
* Seeks to the given time at the given resource.
*/
- suspend fun seek(index: Int, position: Duration): Try = executeCommand {
+ public suspend fun seek(index: Int, position: Duration): Try = executeCommand {
playerFacade.seekTo(index, position).toNavigatorResult()
}
/**
* Seeks to the given locator.
*/
- suspend fun go(locator: Locator): Try {
+ @OptIn(DelicateReadiumApi::class)
+ public suspend fun go(locator: Locator): Try {
+ @Suppress("NAME_SHADOWING")
+ val locator = publication.normalizeLocator(locator)
+
val itemIndex = publication.readingOrder.indexOfFirstWithHref(locator.href)
?: return Try.failure(Exception.InvalidArgument("Invalid href ${locator.href}."))
val position = locator.locations.time ?: Duration.ZERO
@@ -247,7 +259,7 @@ class MediaNavigator private constructor(
/**
* Seeks to the beginning of the given link.
*/
- suspend fun go(link: Link): Try {
+ public suspend fun go(link: Link): Try {
val locator = publication.locatorFromLink(link)
?: return Try.failure(Exception.InvalidArgument("Resource not found at ${link.href}"))
return go(locator)
@@ -256,13 +268,13 @@ class MediaNavigator private constructor(
/**
* Skips to a little amount of time later.
*/
- suspend fun goForward(): Try =
+ public suspend fun goForward(): Try =
seekBy(configuration.skipForwardInterval)
/**
* Skips to a little amount of time before.
*/
- suspend fun goBackward(): Try =
+ public suspend fun goBackward(): Try =
seekBy(-configuration.skipBackwardInterval)
private suspend fun seekBy(offset: Duration): Try = executeCommand {
@@ -298,7 +310,7 @@ class MediaNavigator private constructor(
* Compared to [pause], the navigator may clear its state in whatever way is appropriate. For
* example, recovering a player's resources.
*/
- fun close() {
+ public fun close() {
playerFacade.unregisterPlayerCallback(playerCallback)
playerCallback.close()
playerFacade.close()
@@ -308,50 +320,50 @@ class MediaNavigator private constructor(
/**
* Builds a [MediaSession] for this navigator.
*/
- fun session(context: Context, activityIntent: PendingIntent, id: String? = null): MediaSession =
+ public fun session(context: Context, activityIntent: PendingIntent, id: String? = null): MediaSession =
playerFacade.session(context, id, activityIntent)
- data class Configuration(
+ public data class Configuration(
val positionRefreshRate: Double = 2.0, // Hz
val skipForwardInterval: Duration = 30.seconds,
- val skipBackwardInterval: Duration = 30.seconds,
+ val skipBackwardInterval: Duration = 30.seconds
)
@ExperimentalTime
- data class Playback(
+ public data class Playback(
val state: State,
val rate: Double,
val resource: Resource,
val buffer: Buffer
) {
- enum class State {
+ public enum class State {
Playing,
Paused,
Finished,
- Error
+ Failure
}
- data class Resource(
+ public data class Resource(
val index: Int,
val link: Link,
val position: Duration,
val duration: Duration?
)
- data class Buffer(
+ public data class Buffer(
val isPlayable: Boolean,
val position: Duration
)
}
- sealed class Exception(override val message: String) : kotlin.Exception(message) {
+ public sealed class Exception(override val message: String) : kotlin.Exception(message) {
- class SessionPlayer internal constructor(
+ public class SessionPlayer internal constructor(
internal val error: SessionPlayerError
) : Exception("${error.name} error occurred in SessionPlayer.")
- class InvalidArgument(message: String) : Exception(message)
+ public class InvalidArgument(message: String) : Exception(message)
}
/*
@@ -371,19 +383,19 @@ class MediaNavigator private constructor(
return true
}
- override fun goForward(animated: Boolean, completion: () -> Unit): Boolean {
+ public fun goForward(animated: Boolean, completion: () -> Unit): Boolean {
launchAndRun({ goForward() }, completion)
return true
}
- override fun goBackward(animated: Boolean, completion: () -> Unit): Boolean {
+ public fun goBackward(animated: Boolean, completion: () -> Unit): Boolean {
launchAndRun({ goBackward() }, completion)
return true
}
- companion object {
+ public companion object {
- suspend fun create(
+ public suspend fun create(
context: Context,
publication: Publication,
initialLocator: Locator?,
@@ -391,7 +403,6 @@ class MediaNavigator private constructor(
player: SessionPlayer = createPlayer(context, publication),
metadataFactory: MediaMetadataFactory = DefaultMetadataFactory(publication)
): Try {
-
val positionRefreshDelay = (1.0 / configuration.positionRefreshRate).seconds
val seekCompletedChannel = Channel(Channel.UNLIMITED)
val callback = SessionPlayerCallback(positionRefreshDelay, seekCompletedChannel)
@@ -459,9 +470,10 @@ class MediaNavigator private constructor(
}
internal fun SessionPlayerResult.toNavigatorResult(): Try