Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor HREF normalization and models #387

Merged
merged 29 commits into from
Sep 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
016d0d1
Remove / prefix from container entries
mickael-menu Aug 30, 2023
04ff6a0
Allow relative base HREFs in `Href`
mickael-menu Aug 30, 2023
80bc53f
Don't normalize `Link`'s href
mickael-menu Aug 30, 2023
6d05ed0
Fix serving EPUB resources
mickael-menu Aug 30, 2023
79e4a2c
Use `publication.<extension>` for the unique HREF of a standalone ass…
mickael-menu Aug 31, 2023
788f9ef
Backward compatibility of hrefs prefixed with `/`
mickael-menu Aug 31, 2023
35f00bb
Update changelog
mickael-menu Aug 31, 2023
20b5965
Refactor `Url` to support relative URLs
mickael-menu Aug 31, 2023
57d0dcd
Use `Url` and `Href` everywhere
mickael-menu Sep 1, 2023
faf8bd7
Move `Href`
mickael-menu Sep 1, 2023
1360072
Normalize the HREFs of an OPDS 2 manifest
mickael-menu Sep 1, 2023
d7540ed
Normalize remote web publication locators
mickael-menu Sep 1, 2023
3d3d023
Minor fixes
mickael-menu Sep 1, 2023
39ca985
Use `MediaType` in `Locator` instead of a string
mickael-menu Sep 1, 2023
433a430
Ergonomics
mickael-menu Sep 3, 2023
0a661ee
Refactor LCP `Link`
mickael-menu Sep 3, 2023
722283c
Fix regression
mickael-menu Sep 4, 2023
6945501
Add guards to crash when attempting to compare an `Url` to an `Href`
mickael-menu Sep 4, 2023
abc816b
Various changes
mickael-menu Sep 6, 2023
41987a6
Fix regression parsing an EPUB table of contents
mickael-menu Sep 7, 2023
d8bbdd6
Prevent creating `Url` with invalid characters
mickael-menu Sep 11, 2023
01c4f58
Merge branch 'v3' into refactor-href-url
mickael-menu Sep 18, 2023
dbf09e2
Rename test app
mickael-menu Sep 18, 2023
65c7c80
Address review comments
mickael-menu Sep 18, 2023
708b18c
Refactor Link href resolution
mickael-menu Sep 18, 2023
d505939
Nullable `Url` path
mickael-menu Sep 19, 2023
e840fc0
Improve error reporting
mickael-menu Sep 19, 2023
924ba0e
Remove useless comparison
mickael-menu Sep 21, 2023
839f91a
Address review comments
mickael-menu Sep 21, 2023
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ 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` do not start with a `/` for packaged publications anymore.
* To ensure backward-compatibility, `href` starting with a `/` are still supported. But you may want to update the locators persisted in your database to drop the `/` prefix for packaged publications.

#### Shared

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import org.readium.r2.shared.resource.Resource
import org.readium.r2.shared.util.getOrThrow
import org.readium.r2.shared.util.pdf.PdfDocument
import org.readium.r2.shared.util.pdf.PdfDocumentFactory
import org.readium.r2.shared.util.toFile
import org.readium.r2.shared.util.use
import timber.log.Timber

Expand Down
103 changes: 55 additions & 48 deletions readium/lcp/src/main/java/org/readium/r2/lcp/LcpContentProtection.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,11 @@ import org.readium.r2.shared.publication.encryption.encryption
import org.readium.r2.shared.publication.flatten
import org.readium.r2.shared.publication.protection.ContentProtection
import org.readium.r2.shared.publication.services.contentProtectionServiceFactory
import org.readium.r2.shared.resource.ArchiveFactory
import org.readium.r2.shared.resource.Resource
import org.readium.r2.shared.resource.ResourceFactory
import org.readium.r2.shared.resource.TransformingContainer
import org.readium.r2.shared.util.AbsoluteUrl
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.flatMap
import org.readium.r2.shared.util.getOrElse

Expand All @@ -45,7 +43,7 @@ internal class LcpContentProtection(
credentials: String?,
allowUserInteraction: Boolean,
sender: Any?
): Try<ContentProtection.Asset, Publication.OpeningException> {
): Try<ContentProtection.Asset, Publication.OpenError> {
return when (asset) {
is Asset.Container -> openPublication(asset, credentials, allowUserInteraction, sender)
is Asset.Resource -> openLicense(asset, credentials, allowUserInteraction, sender)
Expand All @@ -57,7 +55,7 @@ internal class LcpContentProtection(
credentials: String?,
allowUserInteraction: Boolean,
sender: Any?
): Try<ContentProtection.Asset, Publication.OpeningException> {
): Try<ContentProtection.Asset, Publication.OpenError> {
val license = retrieveLicense(asset, credentials, allowUserInteraction, sender)
return createResultAsset(asset, license)
}
Expand All @@ -78,7 +76,7 @@ internal class LcpContentProtection(
private fun createResultAsset(
asset: Asset.Container,
license: Try<LcpLicense, LcpException>
): Try<ContentProtection.Asset, Publication.OpeningException> {
): Try<ContentProtection.Asset, Publication.OpenError> {
val serviceFactory = LcpContentProtectionService
.createFactory(license.getOrNull(), license.failureOrNull())

Expand All @@ -92,7 +90,9 @@ internal class LcpContentProtection(
onCreatePublication = {
decryptor.encryptionData = (manifest.readingOrder + manifest.resources + manifest.links)
.flatten()
.mapNotNull { it.properties.encryption?.let { enc -> it.href to enc } }
.mapNotNull {
it.properties.encryption?.let { enc -> it.url() to enc }
}
.toMap()

servicesBuilder.contentProtectionServiceFactory = serviceFactory
Expand All @@ -107,7 +107,7 @@ internal class LcpContentProtection(
credentials: String?,
allowUserInteraction: Boolean,
sender: Any?
): Try<ContentProtection.Asset, Publication.OpeningException> {
): Try<ContentProtection.Asset, Publication.OpenError> {
val license = retrieveLicense(licenseAsset, credentials, allowUserInteraction, sender)

val licenseDoc = license.getOrNull()?.license
Expand All @@ -117,9 +117,7 @@ internal class LcpContentProtection(
LicenseDocument(it)
} catch (e: Exception) {
return Try.failure(
Publication.OpeningException.ParsingFailed(
ThrowableError(e)
)
Publication.OpenError.InvalidAsset(cause = ThrowableError(e))
)
}
}
Expand All @@ -129,56 +127,65 @@ internal class LcpContentProtection(
)
}

val link = checkNotNull(licenseDoc.link(LicenseDocument.Rel.Publication))
val url = Url(link.url.toString())
mickael-menu marked this conversation as resolved.
Show resolved Hide resolved
val link = licenseDoc.publicationLink
val url = (link.url() as? AbsoluteUrl)
?: return Try.failure(
Publication.OpeningException.ParsingFailed(
ThrowableError(
Publication.OpenError.InvalidAsset(
cause = ThrowableError(
LcpException.Parsing.Url(rel = LicenseDocument.Rel.Publication.value)
)
)
)

return assetRetriever.retrieve(
url,
mediaType = link.mediaType,
assetType = AssetType.Archive
)
.mapFailure { Publication.OpeningException.ParsingFailed(it) }
.flatMap { createResultAsset(it as Asset.Container, license) }
}

private fun ResourceFactory.Error.wrap(): Publication.OpeningException =
when (this) {
is ResourceFactory.Error.NotAResource ->
Publication.OpeningException.NotFound()
is ResourceFactory.Error.Forbidden ->
Publication.OpeningException.Forbidden()
is ResourceFactory.Error.SchemeNotSupported ->
Publication.OpeningException.UnsupportedAsset()
}
val asset =
if (link.mediaType != null) {
assetRetriever.retrieve(
url,
mediaType = link.mediaType,
assetType = AssetType.Archive
)
.map { it as Asset.Container }
.mapFailure { it.wrap() }
} else {
(assetRetriever.retrieve(url) as? Asset.Container)
?.let { Try.success(it) }
?: Try.failure(Publication.OpenError.UnsupportedAsset())
}

private fun ArchiveFactory.Error.wrap(): Publication.OpeningException =
when (this) {
is ArchiveFactory.Error.FormatNotSupported ->
Publication.OpeningException.UnsupportedAsset()
is ArchiveFactory.Error.PasswordsNotSupported ->
Publication.OpeningException.UnsupportedAsset()
is ArchiveFactory.Error.ResourceReading ->
resourceException.wrap()
}
return asset.flatMap { createResultAsset(it, license) }
}

private fun Resource.Exception.wrap(): Publication.OpeningException =
private fun Resource.Exception.wrap(): Publication.OpenError =
when (this) {
is Resource.Exception.Forbidden ->
Publication.OpeningException.Forbidden(ThrowableError(this))
Publication.OpenError.Forbidden(ThrowableError(this))
is Resource.Exception.NotFound ->
Publication.OpeningException.NotFound(ThrowableError(this))
Publication.OpenError.NotFound(ThrowableError(this))
Resource.Exception.Offline, is Resource.Exception.Unavailable ->
Publication.OpeningException.Unavailable(ThrowableError(this))
Publication.OpenError.Unavailable(ThrowableError(this))
is Resource.Exception.Other, is Resource.Exception.BadRequest ->
Publication.OpeningException.Unexpected(this)
Publication.OpenError.Unknown(this)
is Resource.Exception.OutOfMemory ->
Publication.OpeningException.OutOfMemory(ThrowableError(this))
Publication.OpenError.OutOfMemory(ThrowableError(this))
}

private fun AssetRetriever.Error.wrap(): Publication.OpenError =
when (this) {
is AssetRetriever.Error.ArchiveFormatNotSupported ->
Publication.OpenError.UnsupportedAsset(this)
is AssetRetriever.Error.Forbidden ->
Publication.OpenError.Forbidden(this)
is AssetRetriever.Error.InvalidAsset ->
Publication.OpenError.InvalidAsset(this)
is AssetRetriever.Error.NotFound ->
Publication.OpenError.NotFound(this)
is AssetRetriever.Error.OutOfMemory ->
Publication.OpenError.OutOfMemory(this)
is AssetRetriever.Error.SchemeNotSupported ->
Publication.OpenError.UnsupportedAsset(this)
is AssetRetriever.Error.Unavailable ->
Publication.OpenError.Unavailable(this)
is AssetRetriever.Error.Unknown ->
Publication.OpenError.Unknown(this)
}
}
7 changes: 4 additions & 3 deletions readium/lcp/src/main/java/org/readium/r2/lcp/LcpDecryptor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import org.readium.r2.shared.resource.TransformingResource
import org.readium.r2.shared.resource.flatMap
import org.readium.r2.shared.resource.flatMapCatching
import org.readium.r2.shared.resource.mapCatching
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.getOrElse
Expand All @@ -32,7 +33,7 @@ import org.readium.r2.shared.util.getOrThrow
*/
internal class LcpDecryptor(
val license: LcpLicense?,
var encryptionData: Map<String, Encryption> = emptyMap()
var encryptionData: Map<Url, Encryption> = emptyMap()
) {

fun transform(resource: Resource): Resource {
Expand All @@ -41,7 +42,7 @@ internal class LcpDecryptor(
}

return resource.flatMap {
val encryption = encryptionData[resource.path]
val encryption = encryptionData[resource.url]

// Checks if the resource is encrypted and whether the encryption schemes of the resource
// and the DRM license are the same.
Expand Down Expand Up @@ -93,7 +94,7 @@ internal class LcpDecryptor(
private val license: LcpLicense
) : Resource by resource {

override val source: Url? = null
override val source: AbsoluteUrl? = null

private class Cache(
var startIndex: Int? = null,
Expand Down
7 changes: 4 additions & 3 deletions readium/lcp/src/main/java/org/readium/r2/lcp/LcpException.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import androidx.annotation.StringRes
import java.net.SocketTimeoutException
import java.util.*
import org.readium.r2.shared.UserException
import org.readium.r2.shared.util.Url

public sealed class LcpException(
userMessageId: Int,
Expand Down Expand Up @@ -203,17 +204,17 @@ public sealed class LcpException(
public object OpenFailed : Container(R.string.readium_lcp_exception_container_open_failed)

/** The file at given relative path is not found in the Container. */
public class FileNotFound(public val path: String) : Container(
public class FileNotFound(public val url: Url) : Container(
R.string.readium_lcp_exception_container_file_not_found
)

/** Can't read the file at given relative path in the Container. */
public class ReadFailed(public val path: String) : Container(
public class ReadFailed(public val url: Url?) : Container(
R.string.readium_lcp_exception_container_read_failed
)

/** Can't write the file at given relative path in the Container. */
public class WriteFailed(public val path: String) : Container(
public class WriteFailed(public val url: Url?) : Container(
R.string.readium_lcp_exception_container_write_failed
)
}
Expand Down
3 changes: 2 additions & 1 deletion readium/lcp/src/main/java/org/readium/r2/lcp/LcpLicense.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ 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.Try
import org.readium.r2.shared.util.Url
import timber.log.Timber

/**
Expand Down Expand Up @@ -102,7 +103,7 @@ public 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.
*/
public suspend fun openWebPage(url: URL)
public suspend fun openWebPage(url: Url)
}

@Deprecated(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import kotlinx.coroutines.launch
import org.readium.r2.lcp.license.container.createLicenseContainer
import org.readium.r2.lcp.license.model.LicenseDocument
import org.readium.r2.shared.extensions.tryOrLog
import org.readium.r2.shared.util.Url
import org.readium.r2.shared.util.downloads.DownloadManager
import org.readium.r2.shared.util.mediatype.FormatRegistry
import org.readium.r2.shared.util.mediatype.MediaType
import org.readium.r2.shared.util.mediatype.MediaTypeHints
import org.readium.r2.shared.util.mediatype.MediaTypeRetriever

/**
Expand Down Expand Up @@ -152,7 +152,7 @@ public class LcpPublicationRetriever(
private fun fetchPublication(
license: LicenseDocument
): RequestId {
val url = Url(license.publicationLink.url)
val url = license.publicationLink.url()

val requestId = downloadManager.submit(
request = DownloadManager.Request(
Expand Down Expand Up @@ -192,9 +192,11 @@ public class LcpPublicationRetriever(
downloadsRepository.removeDownload(requestId.value)

val mt = mediaTypeRetriever.retrieve(
mediaTypes = listOfNotNull(
license.publicationLink.type,
download.mediaType.toString()
MediaTypeHints(
mediaTypes = listOfNotNull(
license.publicationLink.mediaType,
download.mediaType
)
)
) ?: MediaType.EPUB

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -73,7 +73,7 @@ public class MaterialRenewListener(
.show(fragmentManager, "MaterialRenewListener.DatePicker")
}

override suspend fun openWebPage(url: URL) {
override suspend fun openWebPage(url: Url) {
suspendCoroutine { cont ->
webPageContinuation = cont

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ 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 org.readium.r2.shared.util.AbsoluteUrl
import org.readium.r2.shared.util.toUri
import timber.log.Timber

/**
Expand Down Expand Up @@ -152,7 +154,7 @@ public class LcpDialogAuthentication : LcpAuthenticating {
private fun showHelpDialog(context: Context, links: List<Link>) {
val titles = links.map {
it.title ?: tryOr(context.getString(R.string.readium_lcp_dialog_support)) {
when (Uri.parse(it.href).scheme) {
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)
Expand All @@ -169,9 +171,9 @@ public 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)
Expand All @@ -180,7 +182,7 @@ public class LcpDialogAuthentication : LcpAuthenticating {

startActivity(
Intent(action).apply {
data = url
data = url.toUri()
}
)
}
Expand Down
Loading