Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ package org.readium.r2.navigator.epub

import android.app.Application
import android.os.PatternMatcher
import android.webkit.MimeTypeMap
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import androidx.webkit.WebViewAssetLoader
Expand All @@ -28,9 +29,11 @@ import org.readium.r2.shared.util.data.ReadError
import org.readium.r2.shared.util.data.asInputStream
import org.readium.r2.shared.util.http.HttpHeaders
import org.readium.r2.shared.util.http.HttpRange
import org.readium.r2.shared.util.mediatype.MediaType
import org.readium.r2.shared.util.resource.Resource
import org.readium.r2.shared.util.resource.StringResource
import org.readium.r2.shared.util.resource.fallback
import org.readium.r2.shared.util.toUrl

/**
* Serves the publication resources and application assets in the EPUB navigator web views.
Expand All @@ -44,8 +47,11 @@ internal class WebViewServer(
private val onResourceLoadFailed: (Url, ReadError) -> Unit,
) {
companion object {
val publicationBaseHref = AbsoluteUrl("https://readium/publication/")!!
val assetsBaseHref = AbsoluteUrl("https://readium/assets/")!!
const val READIUM_PACKAGE_HOSTNAME = "readium_package"
const val ASSETS_HOSTNAME = "readium_assets"

val publicationBaseHref = AbsoluteUrl("https://$READIUM_PACKAGE_HOSTNAME/")!!
val assetsBaseHref = AbsoluteUrl("https://$ASSETS_HOSTNAME/")!!

fun assetUrl(path: String): Url? =
Url.fromDecodedPath(path)?.let { assetsBaseHref.resolve(it) }
Expand All @@ -54,16 +60,18 @@ internal class WebViewServer(
/**
* Serves the requests of the navigator web views.
*
* https://readium/publication/ serves the publication resources through its fetcher.
* https://readium/assets/ serves the application assets.
* https://readium_package/ serves the publication resources through its fetcher.
* https://readium_assets/ serves the application assets.
*/
fun shouldInterceptRequest(request: WebResourceRequest, css: ReadiumCss): WebResourceResponse? {
if (request.url.host != "readium") return null
val path = request.url.path ?: return null
val hostname = request.url.host ?: return null
val requestUrl = request.url.toUrl() ?: return null

return when {
path.startsWith("/publication/") -> {
val href = Url.fromDecodedPath(path.removePrefix("/publication/"))
return when (hostname) {
READIUM_PACKAGE_HOSTNAME -> {
// Request is for a packaged resource.
val href = Url.fromDecodedPath(path.removePrefix("/"))
?: return null

servePublicationResource(
Expand All @@ -72,10 +80,30 @@ internal class WebViewServer(
css = css
)
}
path.startsWith("/assets/") && isServedAsset(path.removePrefix("/assets/")) -> {

ASSETS_HOSTNAME if isServedAsset(path.removePrefix("/")) -> {
// Request is for a known asset.
assetsLoader.shouldInterceptRequest(request.url)
}
else -> null

ASSETS_HOSTNAME -> return null // Request is for an unknown asset.

else -> {
// Request is for streaming a resource, if the baseUrl an AbsoluteUrl and hostname
// is not ASSETS_HOSTNAME or READIUM_PACKAGE_HOSTNAME
val baseUrl = publication.baseUrl as? AbsoluteUrl ?: return null
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@qnga I'm unsure, if I should return null here and let the platform handle the request or if it would be better to return an errorResource() or still call servePublicationResource(...)?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is a baseUrl it must be absolute (I wonder why it's not in the signature, we should check). So we're dealing here with the case when there is no baseUrl. Each resource either comes from a package or has got an absolute URL as href. You've already dealt with the package case above, so I guess you should deal with absolute hrefs here.

Shortly, if the request URL is equivalent to some resource href (and the profile is EPUB), then serve the resource from the publication with injection. If it is not, serve an error resource.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is a baseUrl it must be absolute (I wonder why it's not in the signature, we should check).
The baseUrl is a RelativeUrl when it is a packaged publication and an AbsoluteUrl when it is a streamed publication, which was why I added that constraint.

But I think I need to remove it again, because a packaged publication might still use an external resource. Unless we want to prevent that.
I think in our (Nota's) case, this would only happen with media-overlay/guided-navigation books which isn't affected by this.

If it is not, serve an error resource.
I'm not so sure about this.

Could there be a case where the resource url is not listed in any of the links in the publication? For instance if an image is used in the HTML but not listed in resources.

About the profile being EPUB , currently the epub/WebViewServer doesn't check for it but I'll add it.

Copy link
Member

@qnga qnga Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A packaged publication should have no baseUrl because it should have no self link. I checked with Mickaël, you can do an optional cast in Publication.baseUrl to get an absolute URL or nothing. That's already like this in the Swift toolkit.
Regarding external resources, I believe they should be served from the publication, because they are part of it. An HTTP client should probably be added to the container chain when it is built if some resources are remote.

Could there be a case where the resource url is not listed in any of the links in the publication?

In theory that's impossible, it's the point of having a manifest listing the resources. In practice, if you use publication.get you'll get a resource if the href is matching something in the package (not the manifest) and null if it's not.

Copy link
Member

@qnga qnga Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In practice, if you use publication.get you'll get a resource if the href is matching something in the package (not the manifest) and null if it's not.

We might need to double check this in depth but if it is not the case I think that's the soundest approach anyway. I remember me doing something similar in a different case.

Copy link
Author

@m-abs m-abs Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got baseUrl that were a RelativeUrl in my packaged, but might be because our converter always adds a self-link to our RWPM.

According to this a self-link with an absolute URL is required:
https://readium.org/webpub-manifest/#23-links:~:text=Test%20Publication%22%0A%7D-,2.3.%20Links,is%20an%20absolute%20URI%20to%20the%20canonical%20location%20of%20the%20manifest.,-Example%203%3A%20Link

If it can be left our in packaged publications, I think the documentation should be updated :) and we (Nota) should fix our self link to be absolute for streaming.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See readium/webpub-manifest#119
We decided this a while back but the spec often lags behind.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have changed this back to checking for baseUrl being an AbsoluteUrl.

Is there anything more I need to do? We would really like to be able to stream publications :)


// Look up the link in the publication to make sure we have the right resource.
val link = publicationLinkFromHref(baseUrl.resolve(requestUrl))
?: publicationLinkFromHref(baseUrl.relativize(requestUrl))
?: return null // Link not found in publication, we can't serve this resource.

servePublicationResource(
href = link.href.resolve(),
range = HttpHeaders(request.requestHeaders).range,
css = css
)
}
}
}

Expand All @@ -84,15 +112,17 @@ internal class WebViewServer(
*
* If the [Resource] is an HTML document, injects the required JavaScript and CSS files.
*/
private fun servePublicationResource(href: Url, range: HttpRange?, css: ReadiumCss): WebResourceResponse {
val link = publication.linkWithHref(href)
// Query parameters must be kept as they might be relevant for the fetcher.
?.copy(href = Href(href))
?: Link(href = href)
private fun servePublicationResource(
href: Url,
range: HttpRange?,
css: ReadiumCss
): WebResourceResponse {
val link = publicationLinkFromHref(href)
// Link not found, create a Link from the href and guess the MediaType
?: Link(href = href, mediaType = mediaTypeFromUrl(href))

// Drop anchor because it is meant to be interpreted by the client.
val urlWithoutAnchor = href.removeFragment()

var resource = publication
.get(urlWithoutAnchor)
?.fallback {
Expand Down Expand Up @@ -122,17 +152,17 @@ internal class WebViewServer(
"Accept-Ranges" to "bytes"
)

val stream = resource.asInputStream()
if (range == null) {
return WebResourceResponse(
link.mediaType?.toString(),
null,
200,
"OK",
headers,
resource.asInputStream()
stream
)
} else { // Byte range request
val stream = resource.asInputStream()
val length = stream.available()
val longRange = range.toLongRange(length.toLong())
headers["Content-Range"] = "bytes ${longRange.first}-${longRange.last}/$length"
Expand All @@ -149,6 +179,23 @@ internal class WebViewServer(
)
}
}

/**
* Resolve the [MediaType] from a [Url].
*/
private fun mediaTypeFromUrl(href: Url): MediaType? {
val ext = MimeTypeMap.getFileExtensionFromUrl(href.normalize().toString()) ?: return null
val mimetype = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return null

return MediaType.invoke(mimetype)
}

private fun publicationLinkFromHref(href: Url): Link? {
return publication.linkWithHref(href)
// Query parameters must be kept as they might be relevant for the fetcher.
?.copy(href = Href(href))
}

private fun errorResource(): Resource =
StringResource {
withContext(Dispatchers.IO) {
Expand All @@ -169,7 +216,7 @@ internal class WebViewServer(

private val assetsLoader =
WebViewAssetLoader.Builder()
.setDomain("readium")
.addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(application))
.setDomain(assetsBaseHref.host!!)
.addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(application))
.build()
}
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,11 @@ public class AbsoluteUrl private constructor(override val uri: Uri) : Url() {
public val isContent: Boolean get() =
scheme.isContent

/**
* Hostname of the URL.
*/
public val host: String? get() = uri.host

/**
* Converts the URL to a [File], if it's a file URL.
*/
Expand Down