Skip to content

Latest commit

 

History

History
1045 lines (737 loc) · 52.7 KB

migration-guide.md

File metadata and controls

1045 lines (737 loc) · 52.7 KB

Migration Guide

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.

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 for inspiration.

Alternatively, you can copy the deprecated helpers 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.

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

⚠️ 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:

val MIGRATION_HREF = object : Migration(1, 2) {

    override fun migrate(db: SupportSQLiteDatabase) {
        val normalizedHrefs: Map<Long, String> = 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:

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:

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.

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.

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.

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.

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

suspend fun LcpService.acquirePublication(
    lcplAsset: ResourceAsset,
    onProgress: (Double) -> Unit
): Try<LcpService.AcquiredPublication, Error> {
    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.

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

Maven Central

Readium is now distributed with Maven Central. You must update your Gradle configuration.

allprojects {
    repositories {
-       maven { url 'https://jitpack.io' }
+       mavenCentral()
    }
}

The group ID of the Readium modules is now org.readium.kotlin-toolkit, for instance:

dependencies {
    implementation "org.readium.kotlin-toolkit:readium-shared:$readium_version"
    implementation "org.readium.kotlin-toolkit:readium-streamer:$readium_version"
    implementation "org.readium.kotlin-toolkit:readium-navigator:$readium_version"
    implementation "org.readium.kotlin-toolkit:readium-opds:$readium_version"
    implementation "org.readium.kotlin-toolkit:readium-lcp:$readium_version"
}

2.3.0

Decoration.extras

Decoration.extras is now a Map<String, Any> instead of Bundle. You will need to update your app if you were storing custom data in extras, for example:

val decoration = Decoration(...,
    extras = mapOf("id" to id)
)

val id = decoration.extras["id"] as? Long

PDF support

The PDF navigator got refactored to support arbitrary third-party PDF engines. As a consequence, PdfiumAndroid (the open source PDF renderer we previously used) was extracted into its own adapter package. This is a breaking change if you were supporting PDF in your application.

This new version ships with an adapter for the commercial PDF engine PSPDFKit, see the instructions under readium/adapter/pspdfkit to set it up.

If you wish to keep using the open source library PdfiumAndroid, you need to migrate your app.

Migrating to the PdfiumAndroid adapter

First, add the new dependency in your app's build.gradle.

dependencies {
    implementation "com.github.readium.kotlin-toolkit:readium-adapter-pdfium:$readium_version"
    // Or, if you need only the parser but not the navigator:
    implementation "com.github.readium.kotlin-toolkit:readium-adapter-pdfium-document:$readium_version"
}

Then, setup the Streamer with the adapter factory:

Streamer(...,
    pdfFactory = PdfiumDocumentFactory(context)
)

Finally, provide the new PdfiumEngineProvider to PdfNavigatorFactory:

val navigatorFactory = PdfNavigatorFactory(
    publication = publication,
    pdfEngineProvider = PdfiumEngineProvider()
)
override fun onCreate(savedInstanceState: Bundle?) {
    childFragmentManager.fragmentFactory =
        navigatorFactory.createFragmentFactory(...)
    super.onCreate(savedInstanceState)
}

Removing the HTTP server

The local HTTP server is not needed anymore to render EPUB publications. You can safely drop all occurrences of Server from your project and remove the baseUrl parameter when calling EpubNavigatorFragment.createFactory().

⚠️ You must adopt the new Preferences API to remove the HTTP server, as described in the next section of this migration guide.

Serving app assets

If you were serving assets/ files (e.g. fonts or scripts) to the EPUB resources, you can still do so with the new API.

First, declare the assets/ paths that will be available to EPUB resources when creating the navigator. You can use simple glob patterns to allow multiple assets in one go, e.g. fonts/.*.

EpubNavigatorFragment.createFactory(
    ...,
    config = EpubNavigatorFragment.Configuration(
        servedAssets = listOf(
            "fonts/.*",
            "annotation-icon.svg"
        )
    )
)

Then, use the base URL https://readium/assets/ to fetch your app assets from the web views. For example:

https://readium/assets/annotation-icon.svg

Edge tap navigation

After removing the HTTP server, tapping on the edge of the screen will not turn pages anymore. If you wish to keep this behavior, you can add it in your app by implementing VisualNavigator.Listener.onTap(). An instance of EdgeTapNavigation can help to compute the page turns by taking into account the publication reading progression and custom thresholds. See an example in the test app.

override fun onTap(point: PointF): Boolean {
    val navigated = EdgeTapNavigation(navigator).onTap(point, requireView())
    if (!navigated) {
        toggleAppBar()
    }
    return true
}

Upgrading to the new Preferences API

The 2.3.0 release introduces a brand new user preferences API for configuring the EPUB and PDF Navigators. This new API is easier and safer to use. To learn how to integrate it in your app, please refer to the user guide.

If you integrated the EPUB navigator from a previous version, follow these steps to migrate:

  1. Get familiar with the concepts of this new API.
  2. Remove the local HTTP server from your app, as explained in the previous section.
  3. Remove the whole UserSettings.kt file from your app, if you copied it from the Test App.
  4. Adapt your user settings interface to the new API using preferences editors. The Test App and the user guide contain examples using Jetpack Compose.
  5. Handle the persistence of the user preferences. The settings are not stored in the SharedPreferences with name org.readium.r2.settings anymore. Instead, you are responsible for persisting and restoring the user preferences as you see fit (e.g. as a JSON file).
    • If you want to migrate the legacy SharedPreferences settings, you can use the helper EpubPreferences.fromLegacyEpubSettings() which will create a new EpubPreferences object after translating the existing user settings.
  6. Make sure you restore the stored user preferences when initializing the EPUB navigator.

Please refer to the following table for the correspondence between legacy settings and new ones.

Legacy New
APPEARANCE_REF theme
COLUMN_COUNT_REF columnCount (reflowable) and spread (fixed-layout)
FONT_FAMILY_REF fontFamily
FONT_OVERRIDE_REF N/A (handled automatically)
FONT_SIZE_REF fontSize
LETTER_SPACING_REF letterSpacing
LINE_HEIGHT_REF lineHeight
PAGE_MARGINS_REF pageMargins
PUBLISHER_DEFAULT_REF publisherStyles
reader_brightness N/A (out of scope for Readium)
SCROLL_REF overflow (scrolled)
TEXT_ALIGNMENT_REF textAlign
WORD_SPACING_REF wordSpacing

Deprecation of userSettingsUIPreset

publication.userSettingsUIPreset is now deprecated, but you might still have this code in your application:

publication.userSettingsUIPreset[ReadiumCSSName.ref(SCROLL_REF)] = true

You can remove it, as the support for screen readers will be added directly to the navigator in a coming release. However if you want to keep it, here is the equivalent with the new API:

navigator.submitPreferences(currentPreferences.copy(scroll = true))

2.2.1

This hotfix release fixes an issue pulling a third-party dependency (NanoHTTPD) from JitPack.

After upgrading, make sure to remove the dependency to NanoHTTPD from your app's build.gradle file before building:

-implementation("com.github.edrlab.nanohttpd:nanohttpd:master-SNAPSHOT") {
-    exclude(group = "org.parboiled")
-}
-implementation("com.github.edrlab.nanohttpd:nanohttpd-nanolets:master-SNAPSHOT") {
-    exclude(group = "org.parboiled")
-}

☝️ If you are stuck with an older version of Readium, you can use this workaround in your root build.gradle, as an alternative.

2.1.0

With this new release, we migrated all the r2-*-kotlin repositories to a single kotlin-toolkit repository.

Using JitPack

If you are integrating Readium with the JitPack Maven repository, the same Readium modules are available as before. Just replace the former dependency notations with the new ones, per the README.

dependencies {
    implementation "com.github.readium.kotlin-toolkit:readium-shared:$readium_version"
    implementation "com.github.readium.kotlin-toolkit:readium-streamer:$readium_version"
    implementation "com.github.readium.kotlin-toolkit:readium-navigator:$readium_version"
    implementation "com.github.readium.kotlin-toolkit:readium-opds:$readium_version"
    implementation "com.github.readium.kotlin-toolkit:readium-lcp:$readium_version"
}

Using a fork

If you are integrating your own forks of the Readium modules, you will need to migrate them to a single fork and port your changes. Follow strictly the given steps and it should go painlessly.

  1. Upgrade your forks to the latest Readium 2.1.0 version from the legacy repositories, as you would with any update. The 2.1.0 version is available on both the legacy repositories and the new kotlin-toolkit one. It will be used to port your changes over to the single repository.
  2. Fork the new kotlin-toolkit repository on your own GitHub space.
  3. In a new local directory, clone your legacy forks as well as the new single fork:
    mkdir readium-migration
    cd readium-migration
    
    # Clone the legacy forks
    git clone https://github.com/USERNAME/r2-shared-kotlin.git
    git clone https://github.com/USERNAME/r2-streamer-kotlin.git
    git clone https://github.com/USERNAME/r2-navigator-kotlin.git
    git clone https://github.com/USERNAME/r2-opds-kotlin.git
    git clone https://github.com/USERNAME/r2-lcp-kotlin.git
    
    # Clone the new single fork
    git clone https://github.com/USERNAME/kotlin-toolkit.git
  4. Reset the new fork to be in the same state as the 2.1.0 release.
    cd kotlin-toolkit
    git reset --hard 2.1.0
  5. For each Readium module, port your changes over to the new fork.
    rm -rf readium/*/src
    
    cp -r ../r2-shared-kotlin/r2-shared/src readium/shared
    cp -r ../r2-streamer-kotlin/r2-streamer/src readium/streamer
    cp -r ../r2-navigator-kotlin/r2-navigator/src readium/navigator
    cp -r ../r2-opds-kotlin/r2-opds/src readium/opds
    cp -r ../r2-lcp-kotlin/r2-lcp/src readium/lcp
  6. Review your changes, then commit.
    git add readium
    git commit -m "Apply local changes to Readium"
  7. Finally, pull the changes to upgrade to the latest version of the fork. You might need to fix some conflicts.
    git pull --rebase
    git push

Your fork is now ready! To integrate it in your app as a local Git clone or submodule, follow the instructions from the README.

Nothing to change in your app to upgrade from 2.0.0-beta.2 to the final 2.0.0 release! Please follow the relevant sections if you are upgrading from an older version.

This new beta is the last one before the final 2.0.0 release. It is mostly focused on bug fixes but we also adjusted the LCP and HTTP server APIs before setting it in stone for the 2.x versions.

Serving publications with the HTTP server

The API used to serve Publication resources with the Streamer's HTTP server was simplified. See the test app changes in PR #387.

Replace addEpub() with addPublication(), which does not expect the publication filename anymore. If the Publication is servable, addPublication() will return its base URL. This means that you do not need to:

  • Call Publication.localBaseUrlOf() to get the base URL. Use the one returned by addPublication() instead.
  • Set the server port in the $key-publicationPort SharedPreferences property.
    • If you copied the R2ScreenReader from the test app, you will need to update it to use directly the base URL instead of the $key-publicationPort property. See this commit.

R2EpubActivity and R2AudiobookActivity are expecting an additional Intent extra: baseUrl. Use the base URL returned by addPublication().

LCP changes

Find all the changes made in the test app related to LCP in PR #379.

Replacing org.joda.time.DateTime with java.util.Date

We replaced all occurrences of Joda's DateTime with java.util.Date in r2-lcp-kotlin, to reduce the dependency on third-party libraries. You will need to update any code using LcpLicense. The easiest way would be to keep using Joda in your own app and create DateTime object from the Date ones. For example:

lcpLicense?.license?.issued?.let { DateTime(it) }

Revamped loan renew API

The API to renew an LCP loan got revamped to better support renewal through a web page. You will need to implement LcpLicense.RenewListener to coordinate the UX interaction.

For Material Design apps

If your application fits Material Design guidelines, you may use the provided MaterialRenewListener implementation directly. This will only work if your theme extends a MaterialComponents one, for example:

<style name="AppTheme" parent="Theme.MaterialComponents.Light.DarkActionBar">

MaterialRenewListener expects an ActivityResultCaller instance for argument. Any ComponentActivity or Fragment object can be used as ActivityResultCaller.

val activity: FragmentActivity

license.renewLoan(MaterialRenewListener(
    license = lcpLicense,
    caller = activity,
    fragmentManager = activity.supportFragmentManager
))

The version 2.0.0-beta.1 is mostly stabilizing the new APIs and fixing existing bugs. We also upgraded the libraries to be compatible with Kotlin 1.4 and Gradle 4.1.

Replacing Format by MediaType

To simplify the new format API, we merged Format into MediaType to offer a single interface. If you were using Format, you should be able to replace it by MediaType seamlessly.

Replacing File by FileAsset

Streamer.open() is now expecting an implementation of PublicationAsset instead of an instance of File. This allows to open publications which are not represented as files on the device. For example a stream, an URL or any other custom structure.

Readium ships with a default implementation named FileAsset replacing the previous File type. The API is the same so you can just replace File by FileAsset in your project.

Support for display cutouts

This new version is now compatible with display cutouts. However, this is an opt-in feature. To support display cutouts, follow these instructions:

  • IMPORTANT: You need to remove any setPadding() statement from your app in UserSettings.kt, if you copied it from the test app.
  • If you embed a navigator fragment (e.g. EpubNavigatorFragment) yourself, you need to opt-in by specifying the layoutInDisplayCutoutMode of the host Activity.
  • R2EpubActivity and R2CbzActivity automatically apply LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES to their window's layoutInDisplayCutoutMode.
  • PdfNavigatorFragment is not yet compatible with display cutouts, because of limitations from the underlying PDF viewer.

2.0.0-alpha.2

The 2.0.0 introduces numerous new APIs in the Shared Models, Streamer and LCP libraries, which are detailed in the following proposals. We highly recommend skimming over the "Developer Guide" section of each proposal before upgrading to this new major version.

This r2-testapp-kotlin commit showcases all the changes required to upgrade the Test App.

Please reach out on Slack if you have any issue migrating your app to Readium 2.0.0, after checking the troubleshooting section.

Replacing the Parsers with Streamer

A new Streamer class deprecates the use of individual PublicationParser implementations, which you will need to replace in your app.

Opening a Publication

Call Streamer::open() to parse a publication. It will return a self-contained Publication model which handles metadata, resource access and DRM decryption. This means that Container, PubBox and DRM are not needed anymore, you can remove any reference from your app.

The allowUserInteraction parameter should be set to true if you intend to render the parsed publication to the user. It will allow the Streamer to display interactive dialogs, for example to enter DRM credentials. You can set it to false if you're parsing a publication in a background process, for example during bulk import.

val streamer = Streamer(context)

val publication = streamer.open(File(path), allowUserInteraction = true)
    .getOrElse { error ->
        alert(error.getUserMessage(context))
        return
    }

Parsing a Readium Web Publication Manifest

You can't use Publication.fromJSON() to parse directly a manifest anymore. Instead, you can use Manifest.fromJSON(), which gives you access to the metadata embedded in the manifest.

Then, if you really need a Publication model, you can build one yourself from the Manifest and optionally a Fetcher and Publication Services.

-val publication = Publication.fromJSON(json)
+val publication = Manifest.fromJSON(json)?.let { Publication(it) }

However, the best way to parse a RWPM is to use the Streamer, like with any other publication format. This way the Publication model will be initialized with appropriate Fetcher and Publication Services.

Error Feedback

In case of failure, a Publication.OpeningException is returned. It implements UserException and can be used directly to present an error message to the user with getUserMessage(Context).

If you wish to customize the error messages or add translations, you can override the strings declared in r2-shared-kotlin/r2-shared/src/main/res/values/strings.xml in your own app module. This goes for LCP errors as well, which are declared in r2-lcp-kotlin/r2-lcp/src/main/res/values/strings.xml.

Advanced Usage

Streamer offers other useful APIs to extend the capabilities of the Readium toolkit. Take a look at its documentation for more details, but here's an overview:

  • Add new custom parsers.
  • Integrated DRM support, such as LCP.
  • Provide different implementations for third-party tools, e.g. ZIP, PDF and XML.
  • Customize the Publication's metadata or Fetcher upon creation.
  • Collect authoring warnings from parsers.

Accessing a Publication's Resources

Container is Deprecated

Since the new Publication model is self-contained, you can replace any use of the Container API by publication.get(Link). This works for any publication format supported by the Streamer's parsers.

The test app used to have special cases for DiViNa and Audiobooks, by unpacking manually the ZIP archives. You should remove this code and streamline any resource access using publication.get().

Extracting Publication Covers

Extracting the cover of a publication for caching purposes can be done with a single call to publication.cover(), instead of reaching for a Link with cover relation. You can use publication.coverFitting(Size) to select the best resolution without exceeding a given size. It can be useful to avoid saving very large cover images.

-val cover =
-    try {
-        publication.coverLink
-            ?.let { container.data(it.href) }
-            ?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
-    } catch (e: Exception) {
-        null
-    }

+val cover = publication.coverFitting(Size(width = 100, height = 100))

Observing a Navigator's Current Locator

Navigator::currentLocator is now a StateFlow instead of LiveData, to better support chromeless navigators such as an audiobook navigator in the future.

If you were observing currentLocator from an Activity or Fragment, you can continue to do so with currentLocator.asLiveData().

- navigator.currentLocator.observe(this, Observer { locator -> })
+ navigator.currentLocator.asLiveData().observe(this, Observer { locator -> })

If you access directly the value through navigator.currentLocator.value, you might need to add the following annotation to the enclosing class:

@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)

Despite being still experimental, StateFlow is deemed stable for use.

LCP and Other DRMs

Opening an LCP Protected Publication

Support for LCP is now fully integrated with the Streamer, which means that you don't need to retrieve the LCP license and fill container.drm yourself after opening a Publication anymore.

To enable the support for LCP in the Streamer, you need to initialize it with a ContentProtection implementation provided by r2-lcp-kotlin.

val lcpService = LcpService(context)
val streamer = Streamer(
    context = context,
    contentProtections = listOfNotNull(
        lcpService?.contentProtection()
    )
)

Then, to prompt the user for their passphrase, you need to set allowUserInteraction to true and provide the instance of the hosting Activity, Fragment or View with the sender parameter when opening the publication.

streamer.open(File(path), allowUserInteraction = true, sender = activity)

Alternatively, if you already have the passphrase, you can pass it directly to the credentials parameter. If it's valid, the user won't be prompted.

Customizing the Passphrase Dialog

The LCP Service now ships with a default passphrase dialog. You can remove the former implementation from your app if you copied it from the test app. But if you still want to use a custom implementation of LcpAuthenticating, for example to have a different layout, you can pass it when creating the ContentProtection.

lcpService.contentProtection(CustomLCPAuthentication())

Presenting a Protected Publication with a Navigator

In case the credentials were incorrect or missing, the Streamer will still return a Publication, but in a "restricted" state. This allows reading apps to import publications by accessing their metadata without having the passphrase.

But if you need to present the publication with a Navigator, you will need to first check if the Publication is not restricted.

Besides missing credentials, a publication can be restricted if the Content Protection returned an error, for example when the publication is expired. In which case, you must display the error to the user by checking the presence of a publication.protectionError.

if (publication.isRestricted) {
    publication.protectionError?.let { error ->
        // A status error occurred, for example the publication expired
        alert(error.getUserMessage(context))
    }
} else {
    presentNavigator(publication)
}

Accessing an LCP License Information

To check if a publication is protected with a known DRM, you can use publication.isProtected.

If you need to access an LCP license's information, you can use the helper publication.lcpLicense, which will return the LcpLicense if the publication is protected with LCP and the passphrase was known. Alternatively, you can use LcpService::retrieveLicense() as before.

Acquiring a Publication from an LCPL

LcpService.importPublication() was replaced with acquirePublication(), which is a cancellable suspending function. It doesn't require the user to enter its passphrase anymore to download the publication.

Supporting Other DRMs

You can integrate additional DRMs, such as Adobe ACS, by implementing the ContentProtection protocol. This will provide first-class support for this DRM in the Streamer and Navigator.

Take a look at the Content Protection proposal for more details. An example implementation can be found in r2-lcp-kotlin.

Introducing Try

A few of the new APIs are returning a Try object, which is similar to the native Result type. We decided to go for this opiniated approach for error handling instead of throwing Exception because of the type-safety it brings and the constraint on reading apps to properly handle error cases.

You can revert to traditional exceptions by calling getOrThrow() on the Try instance, but the most convenient way to handle the error would be to use getOrElse().

val publication = streamer.open(File(path), allowUserInteraction = true)
    .getOrElse { error ->
        alert(error.getUserMessage(context))
        return
    }

Try also supports map() and flatMap() which are useful to transform the result while forwarding any error handling to upper layers.

fun cover(): Try<Bitmap, ResourceException> =
    publication.get(coverLink)
        .use { resource -> resource.read() } // <- returns a Try<ByteArray, ResourceException>
        .map { bytes -> BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }

Troubleshooting

Attempt to invoke virtual method 'android.content.SharedPreferences android.content.Context.getSharedPreferences(java.lang.String, int)' on a null object reference

Make sure you create the LcpService after onCreate() has been called on an Activity.

LCP publications are blank or LCPL are not imported

Make sure you added the following to your app's build.gradle:

implementation "readium:liblcp:1.0.0@aar"

LCP publications are opening but not decrypted

Make sure you added the content protection to the Streamer, following these instructions.

E/LcpDialogAuthentication: No valid [sender] was passed to LcpDialogAuthentication::retrievePassphrase(). Make sure it is an Activity, a Fragment or a View.

To be able to present the LCP passphrase dialog, the default LcpDialogAuthentication needs a hosting view as context. You must provide it to the sender parameter of Streamer::open().

streamer.open(File(path), allowUserInteraction = true, sender = activity)

IllegalArgumentException: The provided publication is restricted. Check that any DRM was properly unlocked using a Content Protection.

Navigators will refuse to be opened if a publication is protected and not unlocked. You must check if a publication is not restricted by following these instructions.

2.0.0-alpha.1

With this new release, we started a process of modernization of the Readium Kotlin toolkit to:

  • better follow Android best practices and Kotlin conventions,
  • reduce coupling between reading apps and Readium, to ease future migrations and allow refactoring of private core implementations,
  • increase code safety,
  • unify Readium APIs across platforms through public specifications.

As such, this release will break existing codebases. While most changes are facilitated thanks to deprecation warnings with automatic fixes, there are a few changes listed below that you will need to operate manually.

Imports

  • The Publication shared models were moved to their own package. While there are deprecated aliases helping with migration, it doesn't work for Publication.EXTENSION. Therefore, you need to replace all occurrences of org.readium.r2.shared.Publication by org.readium.r2.shared.publication.Publication in your codebase.
  • A few Publication and Link properties, such as images, pageList and numberOfItems were moved to a different package. Simply trigger the "Import" feature of your IDE to resolve them.

Immutability of Shared Models

The Publication shared models are now immutable to increase code safety. This should not impact reading apps much unless you were creating Publication or other models yourself.

However, there are a few places in the Test App that needs to be updated:

Last Read Location

Best practices on observing and restoring the last location were updated in the Test App, and it is highly recommended that you update your codebase as well, to avoid any issues.

Restoring the Last Location

You need to make these changes in your implementations of EpubActivity, ComicActivity and AudiobookActivity:

// Restores the last read location
bookRepository.lastLocatorOfBook(bookId)?.let { locator ->
    go(locator, animated = false)
}

Observing the Current Location

NavigatorDelegate.locationDidChange() is now deprecated in favor of the more idiomatic Navigator.currentLocator: LiveData<Locator?>.

currentLocator.observe(this, Observer { locator ->
    if (locator != null) {
        bookRepository.saveLastLocatorOfBook(bookId, locator)
    }
})

Publication

Locator

  • Locator is now Parcelable instead of Serializable, you must replace all occurrences of getSerializableExtra("locator") by getParcelableExtra("locator").
  • Locations.fragment was renamed to fragments, and is now a List. You need to update your code if you were creating Locations yourself.
  • locations and text are not nullable anymore. Locator's constructor has a default value, so you don't need to pass null for them anymore.
  • Locator is not meant to be subclassed, and extending it is not possible anymore. If your project is based on the Test App, you need to do the following changes in your codebase:
    • Don't extend Locator in Bookmark and Highlight. Instead, add a locator property which will create a Locator object from their properties. Then, in places where you were creating a Locator from a database model, you can use this property directly.
    • For SearchLocator, you have two choices:
      • (Recommended) Replace all occurrences of SearchLocator by Locator. These two models are interchangeable.
      • Use the same strategy described above for Bookmark.
class Bookmark(...) {

    val locator get() = Locator(
        href = resourceHref,
        type = resourceType,
        title = resourceTitle,
        locations = location,
        text = locatorText
    )

}

Server

The CSS, JavaScript and fonts injection in the Server was refactored to reduce the risk of collisions and simplify your codebase. This is a breaking change, to upgrade your app you need to:

  • Provide the application's Context when creating a Server.
  • Remove the following injection statements, which are now handled directly by the Streamer:
server.loadCustomResource(assets.open("scripts/crypto-sha256.js"), "crypto-sha256.js", Injectable.Script)   
server.loadCustomResource(assets.open("scripts/highlight.js"), "highlight.js", Injectable.Script)