diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ecffd651d..35c58b7539 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/readium/adapters/pdfium/pdfium-document/src/main/java/org/readium/adapters/pdfium/document/PdfiumDocument.kt b/readium/adapters/pdfium/pdfium-document/src/main/java/org/readium/adapters/pdfium/document/PdfiumDocument.kt index 5cad45420f..db93b01aaa 100644 --- a/readium/adapters/pdfium/pdfium-document/src/main/java/org/readium/adapters/pdfium/document/PdfiumDocument.kt +++ b/readium/adapters/pdfium/pdfium-document/src/main/java/org/readium/adapters/pdfium/document/PdfiumDocument.kt @@ -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 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 b396a71154..ee182d4d8c 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 @@ -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 @@ -45,7 +43,7 @@ internal class LcpContentProtection( credentials: String?, allowUserInteraction: Boolean, sender: Any? - ): Try { + ): Try { return when (asset) { is Asset.Container -> openPublication(asset, credentials, allowUserInteraction, sender) is Asset.Resource -> openLicense(asset, credentials, allowUserInteraction, sender) @@ -57,7 +55,7 @@ internal class LcpContentProtection( credentials: String?, allowUserInteraction: Boolean, sender: Any? - ): Try { + ): Try { val license = retrieveLicense(asset, credentials, allowUserInteraction, sender) return createResultAsset(asset, license) } @@ -78,7 +76,7 @@ internal class LcpContentProtection( private fun createResultAsset( asset: Asset.Container, license: Try - ): Try { + ): Try { val serviceFactory = LcpContentProtectionService .createFactory(license.getOrNull(), license.failureOrNull()) @@ -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 @@ -107,7 +107,7 @@ internal class LcpContentProtection( credentials: String?, allowUserInteraction: Boolean, sender: Any? - ): Try { + ): Try { val license = retrieveLicense(licenseAsset, credentials, allowUserInteraction, sender) val licenseDoc = license.getOrNull()?.license @@ -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)) ) } } @@ -129,56 +127,65 @@ internal class LcpContentProtection( ) } - val link = checkNotNull(licenseDoc.link(LicenseDocument.Rel.Publication)) - val url = Url(link.url.toString()) + 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) } } 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 5eef7fd033..ed198891bc 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 @@ -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 @@ -32,7 +33,7 @@ import org.readium.r2.shared.util.getOrThrow */ internal class LcpDecryptor( val license: LcpLicense?, - var encryptionData: Map = emptyMap() + var encryptionData: Map = emptyMap() ) { fun transform(resource: Resource): Resource { @@ -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. @@ -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, 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 index 13c2e59801..cdec0c0df7 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpException.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpException.kt @@ -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, @@ -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 ) } 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 79e58ce04f..9bcfcb2915 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 @@ -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 /** @@ -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( 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 index eebbe697f2..d90208c6a5 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -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 /** @@ -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( @@ -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 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 ea5d97329c..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 @@ -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 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 8ec6709645..e607e86756 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 @@ -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 /** @@ -152,7 +154,7 @@ public class LcpDialogAuthentication : LcpAuthenticating { private fun showHelpDialog(context: Context, links: List) { 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) @@ -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) @@ -180,7 +182,7 @@ public class LcpDialogAuthentication : LcpAuthenticating { startActivity( Intent(action).apply { - data = url + data = url.toUri() } ) } 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 61d2a30233..56fd35cd9e 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 @@ -11,7 +11,6 @@ package org.readium.r2.lcp.license import java.net.HttpURLConnection import java.util.* -import kotlin.time.ExperimentalTime import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -33,7 +32,6 @@ 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( private var documents: ValidatedDocuments, private val validation: LicenseValidation, @@ -163,7 +161,7 @@ internal class License( // 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 @@ -174,7 +172,7 @@ internal class License( 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 -> @@ -191,7 +189,7 @@ 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( @@ -211,7 +209,7 @@ internal class License( ?: throw LcpException.LicenseInteractionNotAvailable val data = - if (link.mediaType.isHtml) { + if (link.mediaType?.isHtml == true) { renewWithWebPage(link) } else { renewProgrammatically(link) 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 index 97e9899291..7f8237bb1b 100644 --- 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 @@ -10,6 +10,7 @@ import kotlinx.coroutines.runBlocking import org.readium.r2.lcp.LcpException import org.readium.r2.shared.resource.Container import org.readium.r2.shared.resource.Resource +import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.getOrThrow /** @@ -17,20 +18,20 @@ import org.readium.r2.shared.util.getOrThrow */ internal class ContainerLicenseContainer( private val container: Container, - private val entryPath: String + private val entryUrl: Url ) : LicenseContainer { override fun read(): ByteArray { return runBlocking { container - .get(entryPath) + .get(entryUrl) .read() .mapFailure { when (it) { - is Resource.Exception.NotFound -> LcpException.Container.FileNotFound( - entryPath - ) - else -> LcpException.Container.ReadFailed(entryPath) + is Resource.Exception.NotFound -> + LcpException.Container.FileNotFound(entryUrl) + else -> + LcpException.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 index e232eec598..221e09244b 100644 --- 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 @@ -17,12 +17,13 @@ import java.util.zip.ZipFile import org.readium.r2.lcp.LcpException import org.readium.r2.lcp.license.model.LicenseDocument import org.readium.r2.shared.resource.Container +import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.toUri internal class ContentZipLicenseContainer( context: Context, private val container: Container, - private val pathInZip: String + private val pathInZip: Url ) : LicenseContainer by ContainerLicenseContainer(container, pathInZip), WritableLicenseContainer { private val zipUri: Uri = @@ -45,7 +46,7 @@ internal class ContentZipLicenseContainer( val outStream = contentResolver.openOutputStream(zipUri, "wt") ?: throw LcpException.Container.WriteFailed(pathInZip) tmpZipFile.addOrReplaceEntry( - pathInZip, + pathInZip.toString(), ByteArrayInputStream(license.toByteArray()), outStream ) diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/FileZipLicenseContainer.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/FileZipLicenseContainer.kt index a6ec9309f3..66be5ec8e7 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/FileZipLicenseContainer.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/FileZipLicenseContainer.kt @@ -14,13 +14,14 @@ import java.io.File import java.util.zip.ZipFile import org.readium.r2.lcp.LcpException import org.readium.r2.lcp.license.model.LicenseDocument +import org.readium.r2.shared.util.Url /** * Access to a License Document stored in a ZIP archive. */ internal class FileZipLicenseContainer( private val zip: String, - private val pathInZIP: String + private val pathInZIP: Url ) : WritableLicenseContainer { override fun read(): ByteArray { @@ -30,7 +31,7 @@ internal class FileZipLicenseContainer( throw LcpException.Container.OpenFailed } val entry = try { - archive.getEntry(pathInZIP)!! + archive.getEntry(pathInZIP.toString())!! } catch (e: Exception) { throw LcpException.Container.FileNotFound(pathInZIP) } @@ -48,7 +49,7 @@ internal class FileZipLicenseContainer( val tmpZip = File("$zip.tmp") val zipFile = ZipFile(source) zipFile.addOrReplaceEntry( - pathInZIP, + pathInZIP.toString(), ByteArrayInputStream(license.toByteArray()), tmpZip ) 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 index a71279489c..74439c7601 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 @@ -12,6 +12,7 @@ package org.readium.r2.lcp.license.container import java.io.File 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). @@ -29,7 +30,7 @@ internal class LcplLicenseContainer(private val licenseFile: File) : WritableLic try { licenseFile.writeBytes(license.toByteArray()) } catch (e: Exception) { - throw LcpException.Container.WriteFailed(licenseFile.path) + throw LcpException.Container.WriteFailed(licenseFile.toUrl()) } } } 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 9aeb73e694..9b028300a0 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,22 +9,18 @@ package org.readium.r2.lcp.license.container -import android.content.ContentResolver import android.content.Context import java.io.File import org.readium.r2.lcp.LcpException import org.readium.r2.lcp.license.model.LicenseDocument import org.readium.r2.shared.asset.Asset -import org.readium.r2.shared.extensions.addPrefix import org.readium.r2.shared.resource.Container import org.readium.r2.shared.resource.Resource -import org.readium.r2.shared.util.isFile +import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.mediatype.MediaType -import org.readium.r2.shared.util.toFile -private const val LICENSE_IN_EPUB = "META-INF/license.lcpl" - -private const val LICENSE_IN_RPF = "license.lcpl" +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, @@ -67,7 +63,7 @@ internal fun createLicenseContainer( } return when { - resource.source?.isFile() == true -> + resource.source?.isFile == true -> LcplLicenseContainer(resource.source!!.toFile()!!) else -> LcplResourceLicenseContainer(resource) @@ -86,11 +82,11 @@ internal fun createLicenseContainer( } return when { - container.source?.isFile() == true -> - FileZipLicenseContainer(container.source!!.path, licensePath) - container.source?.scheme == ContentResolver.SCHEME_CONTENT -> + container.source?.isFile == true -> + FileZipLicenseContainer(container.source!!.path!!, licensePath) + container.source?.isContent == true -> ContentZipLicenseContainer(context, container, licensePath) else -> - ContainerLicenseContainer(container, licensePath.addPrefix("/")) + ContainerLicenseContainer(container, licensePath) } } 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 7df6487d0b..02e99d6952 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,7 +9,6 @@ package org.readium.r2.lcp.license.model -import java.net.URL import java.nio.charset.Charset import java.util.* import org.json.JSONObject @@ -23,6 +22,7 @@ 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.Url import org.readium.r2.shared.util.mediatype.MediaType public class LicenseDocument internal constructor(public val json: JSONObject) { @@ -73,7 +73,7 @@ public class LicenseDocument internal constructor(public val json: JSONObject) { // Check that the acquisition link has a valid URL. try { - link(Rel.Publication)!!.url + link(Rel.Publication)!!.url() } catch (e: Exception) { throw LcpException.Parsing.Url(rel = LicenseDocument.Rel.Publication.value) } @@ -115,12 +115,12 @@ public class LicenseDocument internal constructor(public val json: JSONObject) { rel: Rel, preferredType: MediaType? = null, parameters: URLParameters = emptyMap() - ): URL { + ): Url { val link = link(rel, preferredType) ?: links.firstWithRelAndNoType(rel.value) ?: throw LcpException.Parsing.Url(rel = rel.value) - return link.url(parameters) + return link.url(parameters = parameters) } public val description: String 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 48e19582ff..1ee80e8d94 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,7 +9,6 @@ package org.readium.r2.lcp.license.model -import java.net.URL import java.nio.charset.Charset import java.util.* import org.json.JSONObject @@ -22,6 +21,7 @@ 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 public class StatusDocument(public val data: ByteArray) { @@ -105,12 +105,12 @@ public class StatusDocument(public val data: ByteArray) { rel: Rel, preferredType: MediaType? = null, parameters: URLParameters = emptyMap() - ): URL { + ): Url { val link = link(rel, preferredType) ?: linkWithNoType(rel) ?: throw LcpException.Parsing.Url(rel = rel.value) - return link.url(parameters) + return link.url(parameters = parameters) } public fun events(type: Event.EventType): List = 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 ce3cec3932..00ffb045e5 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,75 +10,73 @@ 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.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 +import org.readium.r2.shared.util.mediatype.MediaTypeRetriever -public data class Link(val json: JSONObject) { - val href: String - var rel: MutableList = 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 { + public companion object { + public operator fun invoke( + json: JSONObject, + mediaTypeRetriever: MediaTypeRetriever = MediaTypeRetriever() + ): Link { + val hrefString = json.optNullableString("href") + ?: throw LcpException.Parsing.Link + val href = Href( + href = hrefString, + templated = json.optBoolean("templated", false) + ) ?: throw LcpException.Parsing.Link - 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()) - } - } - } - - if (rel.isEmpty()) { - throw LcpException.Parsing.Link - } - - 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 - } - - public fun url(parameters: URLParameters): URL { - if (!templated) { - return URL(href) + return Link( + href = href, + mediaType = json.optNullableString("type") + ?.let { mediaTypeRetriever.retrieve(it) }, + title = json.optNullableString("title"), + rels = json.optStringsFromArrayOrSingle("rel").toSet() + .takeIf { it.isNotEmpty() } + ?: throw LcpException.Parsing.Link, + profile = json.optNullableString("profile"), + length = json.optNullableInt("length"), + hash = json.optNullableString("hash") + ) } - - 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(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 d7e236345c..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 @@ -28,13 +28,13 @@ public data class Links(val json: JSONArray) { 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 } 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) public operator fun get(rel: String): List = allWithRel(rel) } 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 29c22ad7c6..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 @@ -70,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/LicensesService.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/service/LicensesService.kt index 1d3398c443..d841f231fc 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 @@ -247,9 +247,7 @@ internal class LicensesService( } 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.value) + val link = license.publicationLink val destination = withContext(Dispatchers.IO) { File.createTempFile("lcp-${System.currentTimeMillis()}", ".tmp") @@ -257,11 +255,11 @@ internal class LicensesService( Timber.i("LCP destination $destination") val mediaType = network.download( - url, + link.url(), destination, - mediaType = link.type, + mediaType = link.mediaType, onProgress = onProgress - ) ?: link.mediaType + ) ?: link.mediaType ?: MediaType.EPUB try { // Saves the License Document into the downloaded publication 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 51fa440403..c365f6c9c1 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 @@ -21,13 +21,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext 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.http.invoke import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.mediatype.MediaTypeHints import org.readium.r2.shared.util.mediatype.MediaTypeRetriever 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", @@ -88,20 +89,18 @@ 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)) } @@ -139,7 +138,9 @@ internal class NetworkService( } } - mediaTypeRetriever.retrieve(MediaTypeHints(connection, mediaType = mediaType)) + mediaTypeRetriever.retrieve( + MediaTypeHints(connection, mediaType = mediaType.toString()) + ) } catch (e: Exception) { Timber.e(e) throw LcpException.Network(e) 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 3a1195cead..844eef8250 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 @@ -22,6 +22,7 @@ import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.resource.Resource import org.readium.r2.shared.resource.buffered import org.readium.r2.shared.util.getOrThrow +import org.readium.r2.shared.util.toUrl public sealed class ExoPlayerDataSourceException(message: String, cause: Throwable?) : IOException( message, @@ -64,7 +65,8 @@ public class ExoPlayerDataSource internal constructor(private val publication: P private var openedResource: OpenedResource? = null override fun open(dataSpec: DataSpec): Long { - val link = publication.linkWithHref(dataSpec.uri.toString()) + val link = dataSpec.uri.toUrl() + ?.let { publication.linkWithHref(it) } ?: throw ExoPlayerDataSourceException.NotFound( "Can't find a [Link] for URI: ${dataSpec.uri}. Make sure you only request resources declared in the manifest." ) 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 4c5529855d..19d4cfc9d3 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 @@ -34,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 @@ -242,7 +244,11 @@ public class MediaNavigator private constructor( /** * Seeks to the given locator. */ + @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 diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/DecorableNavigator.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/DecorableNavigator.kt index 01c48ce8c7..b49146ef80 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/DecorableNavigator.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/DecorableNavigator.kt @@ -21,6 +21,7 @@ import org.json.JSONObject import org.readium.r2.shared.JSONable import org.readium.r2.shared.extensions.JSONParceler import org.readium.r2.shared.publication.Locator +import org.readium.r2.shared.util.Url /** * A navigator able to render arbitrary decorations over a publication. @@ -162,7 +163,7 @@ public sealed class DecorationChange { * The changes need to be applied in the same order, one by one. */ @ExperimentalDecorator -public suspend fun List.changesByHref(target: List): Map> = withContext( +public suspend fun List.changesByHref(target: List): Map> = withContext( Dispatchers.Default ) { val source = this@changesByHref @@ -182,7 +183,7 @@ public suspend fun List.changesByHref(target: List): Map } }) - val changes = mutableMapOf>() + val changes = mutableMapOf>() fun registerChange(change: DecorationChange, locator: Locator) { val resourceChanges = changes[locator.href] ?: emptyList() diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/R2BasicWebView.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/R2BasicWebView.kt index 5bc29a0ad4..90f841b3de 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/R2BasicWebView.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/R2BasicWebView.kt @@ -48,8 +48,9 @@ import org.readium.r2.shared.publication.Link import org.readium.r2.shared.publication.Locator import org.readium.r2.shared.resource.Resource import org.readium.r2.shared.resource.readAsString -import org.readium.r2.shared.util.Href +import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.getOrThrow +import org.readium.r2.shared.util.toUrl import org.readium.r2.shared.util.use import timber.log.Timber @@ -87,7 +88,7 @@ internal open class R2BasicWebView(context: Context, attrs: AttributeSet) : WebV fun shouldInterceptRequest(webView: WebView, request: WebResourceRequest): WebResourceResponse? = null @InternalReadiumApi - fun resourceAtUrl(url: String): Resource? = null + fun resourceAtUrl(url: Url): Resource? = null /** * Requests to load the next resource in the reading order. @@ -114,7 +115,7 @@ internal open class R2BasicWebView(context: Context, attrs: AttributeSet) : WebV var listener: Listener? = null internal var preferences: SharedPreferences? = null - var resourceUrl: String? = null + var resourceUrl: Url? = null internal val scrollModeFlow = MutableStateFlow(false) @@ -338,14 +339,12 @@ internal open class R2BasicWebView(context: Context, attrs: AttributeSet) : WebV val href = tryOrNull { Jsoup.parse(html) } ?.select("a[epub:type=noteref]")?.first() ?.attr("href") + ?.let { Url(it) } ?: return false - val id = href.substringAfter("#", missingDelimiterValue = "") - .takeIf { it.isNotBlank() } - ?: return false + val id = href.fragment ?: return false - val absoluteUrl = Href(href, baseHref = resourceUrl).percentEncodedString - .substringBefore("#") + val absoluteUrl = resourceUrl.resolve(href).removeFragment() val aside = runBlocking { tryOrLog { @@ -596,7 +595,7 @@ internal open class R2BasicWebView(context: Context, attrs: AttributeSet) : WebV } internal fun shouldOverrideUrlLoading(request: WebResourceRequest): Boolean { - if (resourceUrl == request.url?.toString()) return false + if (resourceUrl == request.url.toUrl()) return false return listener?.shouldOverrideUrlLoading(this, request) ?: false } diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/audio/PublicationDataSource.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/audio/PublicationDataSource.kt index 36389997e6..9d7a03eb07 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/audio/PublicationDataSource.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/audio/PublicationDataSource.kt @@ -22,6 +22,7 @@ import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.resource.Resource import org.readium.r2.shared.resource.buffered import org.readium.r2.shared.util.getOrThrow +import org.readium.r2.shared.util.toUrl internal sealed class PublicationDataSourceException(message: String, cause: Throwable?) : IOException( message, @@ -64,7 +65,8 @@ internal class PublicationDataSource(private val publication: Publication) : Bas private var openedResource: OpenedResource? = null override fun open(dataSpec: DataSpec): Long { - val link = publication.linkWithHref(dataSpec.uri.toString()) + val link = dataSpec.uri.toUrl() + ?.let { publication.linkWithHref(it) } ?: throw PublicationDataSourceException.NotFound( "Can't find a [Link] for URI: ${dataSpec.uri}. Make sure you only request resources declared in the manifest." ) diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorFragment.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorFragment.kt index 7607939b78..61f9dc6a3e 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorFragment.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorFragment.kt @@ -57,6 +57,7 @@ import org.readium.r2.navigator.epub.css.FontFamilyDeclaration import org.readium.r2.navigator.epub.css.MutableFontFamilyDeclaration import org.readium.r2.navigator.epub.css.RsProperties import org.readium.r2.navigator.epub.css.buildFontFamilyDeclaration +import org.readium.r2.navigator.extensions.normalizeLocator import org.readium.r2.navigator.extensions.optRectF import org.readium.r2.navigator.extensions.positionsByResource import org.readium.r2.navigator.html.HtmlDecorationTemplates @@ -76,6 +77,7 @@ import org.readium.r2.navigator.preferences.ReadingProgression import org.readium.r2.shared.DelicateReadiumApi import org.readium.r2.shared.ExperimentalReadiumApi import org.readium.r2.shared.extensions.tryOrLog +import org.readium.r2.shared.publication.Href import org.readium.r2.shared.publication.Link import org.readium.r2.shared.publication.Locator import org.readium.r2.shared.publication.Publication @@ -85,8 +87,11 @@ import org.readium.r2.shared.publication.presentation.presentation import org.readium.r2.shared.publication.services.isRestricted import org.readium.r2.shared.publication.services.positionsByReadingOrder import org.readium.r2.shared.resource.Resource +import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.launchWebBrowser import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.toAbsoluteUrl +import org.readium.r2.shared.util.toUri /** * Factory for a [JavascriptInterface] which will be injected in the web views. @@ -519,7 +524,7 @@ public class EpubNavigatorFragment internal constructor( invalidateResourcePager() } is EpubNavigatorViewModel.Event.OpenExternalLink -> { - launchWebBrowser(requireContext(), event.url) + launchWebBrowser(requireContext(), event.url.toUri()) } } } @@ -570,20 +575,24 @@ public class EpubNavigatorFragment internal constructor( notifyCurrentLocation() } + @OptIn(DelicateReadiumApi::class) override fun go(locator: Locator, animated: Boolean, completion: () -> Unit): Boolean { + @Suppress("NAME_SHADOWING") + val locator = publication.normalizeLocator(locator) + listener?.onJumpToLocator(locator) - val href = locator.href - // Remove anchor - .substringBefore("#") + val href = locator.href.removeFragment() fun setCurrent(resources: List) { val page = resources.withIndex().firstOrNull { (_, res) -> when (res) { is PageResource.EpubReflowable -> - res.link.href == href + res.link.url() == href is PageResource.EpubFxl -> - res.leftUrl?.endsWith(href) == true || res.rightUrl?.endsWith(href) == true + res.leftUrl?.toString()?.endsWith(href.toString()) == true || res.rightUrl?.toString()?.endsWith( + href.toString() + ) == true else -> false } } ?: return @@ -717,7 +726,12 @@ public class EpubNavigatorFragment internal constructor( viewModel.removeDecorationListener(listener) } + @OptIn(DelicateReadiumApi::class) override suspend fun applyDecorations(decorations: List, group: String) { + @Suppress("NAME_SHADOWING") + val decorations = decorations + .map { it.copy(locator = publication.normalizeLocator(it.locator)) } + run(viewModel.applyDecorations(decorations, group)) } @@ -799,7 +813,7 @@ public class EpubNavigatorFragment internal constructor( * Prevents opening external links in the web view and handles internal links. */ override fun shouldOverrideUrlLoading(webView: WebView, request: WebResourceRequest): Boolean { - val url = request.url ?: return false + val url = request.url.toAbsoluteUrl() ?: return false viewModel.navigateToUrl(url) return true } @@ -807,7 +821,7 @@ public class EpubNavigatorFragment internal constructor( override fun shouldInterceptRequest(webView: WebView, request: WebResourceRequest): WebResourceResponse? = viewModel.shouldInterceptRequest(request) - override fun resourceAtUrl(url: String): Resource? = + override fun resourceAtUrl(url: Url): Resource? = viewModel.internalLinkFromUrl(url) ?.let { publication.get(it) } } @@ -925,12 +939,12 @@ public class EpubNavigatorFragment internal constructor( * Returns the reflowable page fragment matching the given href, if it is already loaded in the * view pager. */ - private fun loadedFragmentForHref(href: String): R2EpubPageFragment? { + private fun loadedFragmentForHref(href: Url): R2EpubPageFragment? { val adapter = r2PagerAdapter ?: return null adapter.mFragments.forEach { _, fragment -> val pageFragment = fragment as? R2EpubPageFragment ?: return@forEach val link = pageFragment.link ?: return@forEach - if (link.href == href) { + if (link.url() == href) { return pageFragment } } @@ -939,7 +953,7 @@ public class EpubNavigatorFragment internal constructor( override val currentLocator: StateFlow get() = _currentLocator private val _currentLocator = MutableStateFlow( - initialLocator + initialLocator?.let { publication.normalizeLocator(it) } ?: requireNotNull(publication.locatorFromLink(publication.readingOrder.first())) ) @@ -954,8 +968,8 @@ public class EpubNavigatorFragment internal constructor( val resource = publication.readingOrder[resourcePager.currentItem] return currentReflowablePageFragment?.webView?.findFirstVisibleLocator() ?.copy( - href = resource.href, - type = (resource.mediaType ?: MediaType.XHTML).toString() + href = resource.url(), + mediaType = resource.mediaType ?: MediaType.XHTML ) } @@ -968,9 +982,9 @@ public class EpubNavigatorFragment internal constructor( /** * Mapping between reading order hrefs and the table of contents title. */ - private val tableOfContentsTitleByHref: Map by lazy { - fun fulfill(linkList: List): MutableMap { - var result: MutableMap = mutableMapOf() + private val tableOfContentsTitleByHref: Map by lazy { + fun fulfill(linkList: List): MutableMap { + var result: MutableMap = mutableMapOf() for (link in linkList) { val title = link.title ?: "" @@ -981,7 +995,7 @@ public class EpubNavigatorFragment internal constructor( val subResult = fulfill(link.children) - result = (subResult + result) as MutableMap + result = (subResult + result) as MutableMap } return result @@ -1019,14 +1033,14 @@ public class EpubNavigatorFragment internal constructor( "Expected EpubFxl or EpubReflowable page resources" ) } - val positionLocator = publication.positionsByResource[link.href]?.let { positions -> + val positionLocator = publication.positionsByResource[link.url()]?.let { positions -> val index = ceil(progression * (positions.size - 1)).toInt() positions.getOrNull(index) } val currentLocator = Locator( - href = link.href, - type = (link.mediaType ?: MediaType.XHTML).toString(), + href = link.url(), + mediaType = link.mediaType ?: MediaType.XHTML, title = tableOfContentsTitleByHref[link.href] ?: positionLocator?.title ?: link.title, locations = (positionLocator?.locations ?: Locator.Locations()).copy( progression = progression @@ -1077,8 +1091,10 @@ public class EpubNavigatorFragment internal constructor( /** * Returns a URL to the application asset at [path], served in the web views. + * + * Returns null if the given [path] is not valid or an absolute URL. */ - public fun assetUrl(path: String): String = + public fun assetUrl(path: String): Url? = WebViewServer.assetUrl(path) } } diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorViewModel.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorViewModel.kt index 5e41a1e8c1..f8bda8ddda 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorViewModel.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/epub/EpubNavigatorViewModel.kt @@ -11,7 +11,6 @@ package org.readium.r2.navigator.epub import android.app.Application import android.graphics.PointF import android.graphics.RectF -import android.net.Uri import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import androidx.lifecycle.AndroidViewModel @@ -29,12 +28,14 @@ import org.readium.r2.navigator.preferences.* import org.readium.r2.navigator.util.createViewModelFactory import org.readium.r2.shared.DelicateReadiumApi import org.readium.r2.shared.ExperimentalReadiumApi -import org.readium.r2.shared.extensions.addPrefix import org.readium.r2.shared.extensions.mapStateIn +import org.readium.r2.shared.publication.Href import org.readium.r2.shared.publication.Link import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.publication.epub.EpubLayout -import org.readium.r2.shared.util.Href +import org.readium.r2.shared.util.AbsoluteUrl +import org.readium.r2.shared.util.RelativeUrl +import org.readium.r2.shared.util.Url internal enum class DualPage { AUTO, OFF, ON @@ -59,14 +60,14 @@ internal class EpubNavigatorViewModel( sealed class Scope { object CurrentResource : Scope() object LoadedResources : Scope() - data class Resource(val href: String) : Scope() + data class Resource(val href: Url) : Scope() data class WebView(val webView: R2BasicWebView) : Scope() } } sealed class Event { data class GoTo(val target: Link) : Event() - data class OpenExternalLink(val url: Uri) : Event() + data class OpenExternalLink(val url: Url) : Event() /** Refreshes all the resources in the view pager. */ object InvalidateViewPager : Event() @@ -147,7 +148,7 @@ internal class EpubNavigatorViewModel( if (link != null) { for ((group, decorations) in decorations) { val changes = decorations - .filter { it.locator.href == link.href } + .filter { it.locator.href == link.url() } .map { DecorationChange.Added(it) } val groupScript = changes.javascriptForGroup(group, decorationTemplates) ?: continue @@ -160,32 +161,21 @@ internal class EpubNavigatorViewModel( // Serving resources - val baseUrl: String = - publication.linkWithRel("self")?.href + val baseUrl: AbsoluteUrl = + (publication.baseUrl as? AbsoluteUrl) ?: WebViewServer.publicationBaseHref /** * Generates the URL to the given publication link. */ - fun urlTo(link: Link): String = - with(link) { - // Already an absolute URL? - if (Uri.parse(href).scheme != null) { - href - } else { - Href( - href = href.removePrefix("/"), - baseHref = baseUrl - ).percentEncodedString - } - } + fun urlTo(link: Link): AbsoluteUrl = + baseUrl.resolve(link.url()) /** * Intercepts and handles web view navigation to [url]. */ - fun navigateToUrl(url: Uri) = viewModelScope.launch { - val href = url.toString() - val link = internalLinkFromUrl(href) + fun navigateToUrl(url: AbsoluteUrl) = viewModelScope.launch { + val link = internalLinkFromUrl(url) if (link != null) { _events.send(Event.GoTo(link)) } else { @@ -196,13 +186,13 @@ internal class EpubNavigatorViewModel( /** * Gets the publication [Link] targeted by the given [url]. */ - fun internalLinkFromUrl(url: String): Link? { - if (!url.startsWith(baseUrl)) return null + fun internalLinkFromUrl(url: Url): Link? { + val href = (baseUrl.relativize(url) as? RelativeUrl) + ?: return null - val href = url.removePrefix(baseUrl).addPrefix("/") return publication.linkWithHref(href) - // Query parameters must be kept as they might be relevant for the fetcher. - ?.copy(href = href) + // Query parameters must be kept as they might be relevant for the container. + ?.copy(href = Href(href)) } fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? = diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/epub/HtmlInjector.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/epub/HtmlInjector.kt index fb5b32b4c0..5319be1769 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/epub/HtmlInjector.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/epub/HtmlInjector.kt @@ -15,7 +15,9 @@ import org.readium.r2.shared.publication.services.isProtected import org.readium.r2.shared.resource.Resource import org.readium.r2.shared.resource.ResourceTry import org.readium.r2.shared.resource.TransformingResource +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 import timber.log.Timber @@ -28,7 +30,7 @@ import timber.log.Timber internal fun Resource.injectHtml( publication: Publication, css: ReadiumCss, - baseHref: String, + baseHref: AbsoluteUrl, disableSelectionWhenProtected: Boolean ): Resource = TransformingResource(this) { bytes -> @@ -42,12 +44,17 @@ internal fun Resource.injectHtml( var content = bytes.toString(mediaType.charset ?: Charsets.UTF_8).trim() val injectables = mutableListOf() - val baseUri = baseHref.removeSuffix("/") if (publication.metadata.presentation.layout == EpubLayout.REFLOWABLE) { content = css.injectHtml(content) - injectables.add(script("$baseUri/readium/scripts/readium-reflowable.js")) + injectables.add( + script( + baseHref.resolve(Url("readium/scripts/readium-reflowable.js")!!) + ) + ) } else { - injectables.add(script("$baseUri/readium/scripts/readium-fixed.js")) + injectables.add( + script(baseHref.resolve(Url("readium/scripts/readium-fixed.js")!!)) + ) } // Disable the text selection if the publication is protected. @@ -77,5 +84,5 @@ internal fun Resource.injectHtml( Try.success(content.toByteArray()) } -private fun script(src: String): String = +private fun script(src: Url): String = """""" diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/epub/WebViewServer.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/epub/WebViewServer.kt index 06730a39eb..d168c9645e 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/epub/WebViewServer.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/epub/WebViewServer.kt @@ -16,14 +16,16 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.readium.r2.navigator.epub.css.ReadiumCss import org.readium.r2.shared.ExperimentalReadiumApi +import org.readium.r2.shared.publication.Href import org.readium.r2.shared.publication.Link import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.resource.Resource import org.readium.r2.shared.resource.ResourceInputStream import org.readium.r2.shared.resource.StringResource import org.readium.r2.shared.resource.fallback -import org.readium.r2.shared.util.Href +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.http.HttpHeaders import org.readium.r2.shared.util.http.HttpRange import org.readium.r2.shared.util.mediatype.MediaType @@ -39,11 +41,11 @@ internal class WebViewServer( private val disableSelectionWhenProtected: Boolean ) { companion object { - const val publicationBaseHref = "https://readium/publication/" - const val assetsBaseHref = "https://readium/assets/" + val publicationBaseHref = AbsoluteUrl("https://readium/publication/")!! + val assetsBaseHref = AbsoluteUrl("https://readium/assets/")!! - fun assetUrl(path: String): String = - Href(path, baseHref = assetsBaseHref).percentEncodedString + fun assetUrl(path: String): Url? = + Url.fromDecodedPath(path)?.let { assetsBaseHref.resolve(it) } } private val assetManager: AssetManager = application.assets @@ -60,8 +62,11 @@ internal class WebViewServer( return when { path.startsWith("/publication/") -> { + val href = Url.fromDecodedPath(path.removePrefix("/publication/")) + ?: return null + servePublicationResource( - href = path.removePrefix("/publication"), + href = href, range = HttpHeaders(request.requestHeaders).range, css = css ) @@ -78,15 +83,16 @@ internal class WebViewServer( * * If the [Resource] is an HTML document, injects the required JavaScript and CSS files. */ - private fun servePublicationResource(href: String, range: HttpRange?, css: ReadiumCss): WebResourceResponse { + 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) + ?.copy(href = Href(href)) ?: Link(href = href) // Drop anchor because it is meant to be interpreted by the client. - val linkWithoutAnchor = link - .copy(href.takeWhile { it != '#' }) + val linkWithoutAnchor = link.copy( + href = Href(href.removeFragment()) + ) var resource = publication.get(linkWithoutAnchor) .fallback { errorResource(link, error = it) } @@ -138,7 +144,7 @@ internal class WebViewServer( .open("readium/error.xhtml").bufferedReader() .use { it.readText() } .replace("\${error}", error.getUserMessage(application)) - .replace("\${href}", link.href) + .replace("\${href}", link.href.toString()) ) } } diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/epub/css/FontFamilyDeclaration.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/epub/css/FontFamilyDeclaration.kt index 3fc5428833..96af0884cd 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/epub/css/FontFamilyDeclaration.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/epub/css/FontFamilyDeclaration.kt @@ -8,6 +8,7 @@ package org.readium.r2.navigator.epub.css import org.readium.r2.shared.ExperimentalReadiumApi import org.readium.r2.shared.util.Either +import org.readium.r2.shared.util.Url /** * Build a declaration for [fontFamily] using [builderAction]. @@ -47,14 +48,14 @@ internal data class FontFaceDeclaration( var fontWeight: Either>? = null ) { - fun links(urlNormalizer: (String) -> String): List = + fun links(urlNormalizer: (Url) -> Url): List = sources .filter { it.preload } .map { """""" } - fun toCss(urlNormalizer: (String) -> String): String { + fun toCss(urlNormalizer: (Url) -> Url): String { val descriptors = buildMap { set("font-family", """"$fontFamily"""") @@ -89,7 +90,7 @@ internal data class FontFaceDeclaration( * ``. */ internal data class FontFaceSource( - val href: String, + val href: Url, val preload: Boolean = false ) @@ -128,10 +129,24 @@ public data class MutableFontFaceDeclaration internal constructor( /** * Add a source for the font face. * + * @param path Path to the font file. * @param preload Indicates whether this source will be declared for preloading in the HTML * using ``. */ - public fun addSource(href: String, preload: Boolean = false) { + public fun addSource(path: String, preload: Boolean = false) { + val url = requireNotNull(Url.fromDecodedPath(path)) { + "Invalid font path: $path" + } + addSource(url, preload = preload) + } + + /** + * Add a source for the font face. + * + * @param preload Indicates whether this source will be declared for preloading in the HTML + * using ``. + */ + public fun addSource(href: Url, preload: Boolean = false) { this.sources.add(FontFaceSource(href = href, preload = preload)) } diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/epub/css/ReadiumCss.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/epub/css/ReadiumCss.kt index 5107364c61..e0c9b4394c 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/epub/css/ReadiumCss.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/epub/css/ReadiumCss.kt @@ -13,6 +13,7 @@ import org.jsoup.nodes.Element import org.readium.r2.navigator.preferences.FontFamily import org.readium.r2.navigator.preferences.ReadingProgression import org.readium.r2.shared.ExperimentalReadiumApi +import org.readium.r2.shared.util.Url @ExperimentalReadiumApi internal data class ReadiumCss( @@ -21,7 +22,7 @@ internal data class ReadiumCss( val userProperties: UserProperties = UserProperties(), val fontFamilyDeclarations: List = emptyList(), val googleFonts: List = emptyList(), - val assetsBaseHref: String + val assetsBaseHref: Url ) { /** @@ -46,12 +47,6 @@ internal data class ReadiumCss( */ private fun injectStyles(content: StringBuilder) { val hasStyles = content.hasStyles() - val assetsBaseHref = assetsBaseHref.removeSuffix("/") - val stylesheetsFolder = assetsBaseHref + "/readium/readium-css/" + ( - layout.stylesheets.folder?.plus( - "/" - ) ?: "" - ) val headBeforeIndex = content.indexForOpeningTag("head") content.insert( @@ -59,7 +54,7 @@ internal data class ReadiumCss( "\n" + buildList { addAll(fontsInjectableLinks) - add(stylesheetLink(stylesheetsFolder + "ReadiumCSS-before.css")) + add(stylesheetLink(beforeCss)) // Fix Readium CSS issue with the positioning of