From 476a3e6660818d50a6db33092fb518fd60f33a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Fri, 18 Aug 2023 16:10:42 +0200 Subject: [PATCH 01/24] Refactor media types --- .../readium/r2/lcp/LcpContentProtection.kt | 28 +- .../java/org/readium/r2/lcp/LcpService.kt | 27 +- .../lcp/license/container/LicenseContainer.kt | 6 +- .../r2/lcp/license/model/components/Link.kt | 2 +- .../org/readium/r2/lcp/public/Deprecated.kt | 3 +- .../readium/r2/lcp/service/LicensesService.kt | 34 +- .../readium/r2/lcp/service/NetworkService.kt | 17 +- .../java/org/readium/r2/opds/OPDS1Parser.kt | 9 +- .../java/org/readium/r2/opds/OPDS2Parser.kt | 5 +- .../java/org/readium/r2/shared/asset/Asset.kt | 25 +- .../readium/r2/shared/asset/AssetRetriever.kt | 195 +++--- .../readium/r2/shared/extensions/ByteArray.kt | 11 + .../org/readium/r2/shared/extensions/File.kt | 4 +- .../org/readium/r2/shared/format/Format.kt | 157 +++++ .../org/readium/r2/shared/opds/Acquisition.kt | 2 +- .../org/readium/r2/shared/publication/Link.kt | 2 +- .../r2/shared/publication/Publication.kt | 1 - .../AdeptFallbackContentProtection.kt | 13 +- .../LcpFallbackContentProtection.kt | 6 +- .../r2/shared/resource/BytesResource.kt | 12 +- .../r2/shared/resource/ContentResource.kt | 2 +- .../r2/shared/resource/DirectoryContainer.kt | 12 +- .../r2/shared/resource/FileResource.kt | 23 +- .../r2/shared/resource/MediaTypeExt.kt | 34 ++ .../r2/shared/util/http/DefaultHttpClient.kt | 34 +- .../shared/util/http/HttpURLConnectionExt.kt | 23 + .../readium/r2/shared/util/http/MediaType.kt | 68 --- .../r2/shared/util/mediatype/Extensions.kt | 69 +-- .../r2/shared/util/mediatype/MediaType.kt | 372 +++++------- .../util/mediatype/MediaTypeRetriever.kt | 12 +- .../shared/util/mediatype/MediaTypeSniffer.kt | 564 ++++++++++++++++++ .../util/mediatype/MediaTypeSnifferContext.kt | 168 ++++++ .../r2/shared/util/mediatype/Sniffer.kt | 9 +- .../shared/util/mediatype/SnifferContext.kt | 6 +- .../readium/r2/streamer/ParserAssetFactory.kt | 4 +- .../readium/r2/streamer/PublicationFactory.kt | 15 +- .../java/org/readium/r2/testapp/Readium.kt | 31 +- .../r2/testapp/bookshelf/BookRepository.kt | 14 +- .../catalogs/CatalogFeedListViewModel.kt | 7 +- .../r2/testapp/catalogs/CatalogViewModel.kt | 21 +- .../readium/r2/testapp/domain/model/Book.kt | 2 +- .../r2/testapp/utils/extensions/File.kt | 24 +- 42 files changed, 1393 insertions(+), 680 deletions(-) create mode 100644 readium/shared/src/main/java/org/readium/r2/shared/format/Format.kt create mode 100644 readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt create mode 100644 readium/shared/src/main/java/org/readium/r2/shared/util/http/HttpURLConnectionExt.kt delete mode 100644 readium/shared/src/main/java/org/readium/r2/shared/util/http/MediaType.kt create mode 100644 readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt create mode 100644 readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContext.kt 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 9926354904..a34db85408 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 @@ -9,8 +9,11 @@ package org.readium.r2.lcp import org.readium.r2.lcp.auth.LcpPassphraseAuthentication import org.readium.r2.lcp.license.model.LicenseDocument import org.readium.r2.shared.asset.Asset +import org.readium.r2.shared.asset.AssetRetriever +import org.readium.r2.shared.asset.AssetType import org.readium.r2.shared.error.ThrowableError import org.readium.r2.shared.error.Try +import org.readium.r2.shared.error.flatMap import org.readium.r2.shared.error.getOrElse import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.publication.encryption.encryption @@ -27,8 +30,7 @@ import org.readium.r2.shared.util.toFile internal class LcpContentProtection( private val lcpService: LcpService, private val authentication: LcpAuthenticating, - private val resourceFactory: ResourceFactory, - private val archiveFactory: ArchiveFactory + private val assetRetriever: AssetRetriever ) : ContentProtection { override val scheme: ContentProtection.Scheme = @@ -79,7 +81,7 @@ internal class LcpContentProtection( ?.let { lcpService.retrieveLicense( it, - asset.mediaType, + asset.format.mediaType, authentication, allowUserInteraction, sender @@ -88,7 +90,7 @@ internal class LcpContentProtection( ?: lcpService.retrieveLicense(asset, authentication, allowUserInteraction, sender) } - private fun createResultAsset( + private suspend fun createResultAsset( asset: Asset.Container, license: Try ): Try { @@ -100,7 +102,7 @@ internal class LcpContentProtection( val container = TransformingContainer(asset.container, decryptor::transform) val protectedFile = ContentProtection.Asset( - mediaType = asset.mediaType, + mediaType = asset.format.mediaType, container = container, onCreatePublication = { decryptor.encryptionData = (manifest.readingOrder + manifest.resources + manifest.links) @@ -152,15 +154,13 @@ internal class LcpContentProtection( ) ) - val resource = resourceFactory.create(url) - .getOrElse { return Try.failure(it.wrap()) } - - val container = archiveFactory.create(resource, password = null) - .getOrElse { return Try.failure(it.wrap()) } - - val publicationAsset = Asset.Container(link.mediaType, exploded = false, container) - - return createResultAsset(publicationAsset, license) + 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 = diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt index faff6c2359..bf5bfe3c2d 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt @@ -29,14 +29,11 @@ import org.readium.r2.lcp.service.NetworkService import org.readium.r2.lcp.service.PassphrasesRepository import org.readium.r2.lcp.service.PassphrasesService import org.readium.r2.shared.asset.Asset +import org.readium.r2.shared.asset.AssetRetriever import org.readium.r2.shared.error.Try +import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.publication.protection.ContentProtection -import org.readium.r2.shared.resource.ArchiveFactory -import org.readium.r2.shared.resource.DefaultArchiveFactory -import org.readium.r2.shared.resource.FileResourceFactory -import org.readium.r2.shared.resource.ResourceFactory import org.readium.r2.shared.util.mediatype.MediaType -import org.readium.r2.shared.util.mediatype.MediaTypeRetriever /** * Service used to acquire and open publications protected with LCP. @@ -159,9 +156,8 @@ public interface LcpService { */ public operator fun invoke( context: Context, - mediaTypeRetriever: MediaTypeRetriever = MediaTypeRetriever(), - resourceFactory: ResourceFactory = FileResourceFactory(), - archiveFactory: ArchiveFactory = DefaultArchiveFactory() + assetRetriever: AssetRetriever, + formatRegistry: FormatRegistry ): LcpService? { if (!LcpClient.isAvailable()) { return null @@ -171,7 +167,7 @@ public interface LcpService { val deviceRepository = DeviceRepository(db) val passphraseRepository = PassphrasesRepository(db) val licenseRepository = LicensesRepository(db) - val network = NetworkService(mediaTypeRetriever = mediaTypeRetriever) + val network = NetworkService(formatRegistry = formatRegistry) val device = DeviceService( repository = deviceRepository, network = network, @@ -186,18 +182,18 @@ public interface LcpService { network = network, passphrases = passphrases, context = context, - mediaTypeRetriever = mediaTypeRetriever, - resourceFactory = resourceFactory, - archiveFactory = archiveFactory + assetRetriever = assetRetriever, + formatRegistry = formatRegistry ) } + @Suppress("UNUSED_PARAMETER") @Deprecated( "Use `LcpService()` instead", - ReplaceWith("LcpService(context)"), + ReplaceWith("LcpService(context, AssetRetriever(), FormatRegistry())"), level = DeprecationLevel.ERROR ) - public fun create(context: Context): LcpService? = invoke(context) + public fun create(context: Context): LcpService? = throw NotImplementedError() } @Deprecated( @@ -235,13 +231,14 @@ public interface LcpService { } } +@Suppress("UNUSED_PARAMETER") @Deprecated( "Renamed to `LcpService()`", replaceWith = ReplaceWith("LcpService(context)"), level = DeprecationLevel.ERROR ) public fun R2MakeLCPService(context: Context): LcpService = - LcpService(context) ?: throw Exception("liblcp is missing on the classpath") + throw NotImplementedError() @Deprecated( "Renamed to `LcpService.AcquiredPublication`", 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 a9b3893e01..90a1b719b9 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 @@ -42,12 +42,12 @@ internal fun createLicenseContainer( else -> ZIPLicenseContainer(file.path, LICENSE_IN_RPF) } -internal fun createLicenseContainer( +internal suspend fun createLicenseContainer( asset: Asset ): LicenseContainer = when (asset) { - is Asset.Resource -> createLicenseContainer(asset.resource, asset.mediaType) - is Asset.Container -> createLicenseContainer(asset.container, asset.mediaType) + is Asset.Resource -> createLicenseContainer(asset.resource, asset.format.mediaType) + is Asset.Container -> createLicenseContainer(asset.container, asset.format.mediaType) } internal fun createLicenseContainer( 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 a1aecdf51d..ce3cec3932 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 @@ -69,7 +69,7 @@ public data class Link(val json: JSONObject) { get() = url(parameters = emptyMap()) val mediaType: MediaType - get() = type?.let { MediaType.parse(it) } ?: MediaType.BINARY + get() = type?.let { MediaType(it) } ?: MediaType.BINARY /** * List of URI template parameter keys, if the [Link] is templated. diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/public/Deprecated.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/public/Deprecated.kt index 95fa4d212d..b76f1bf63b 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/public/Deprecated.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/public/Deprecated.kt @@ -75,5 +75,6 @@ public typealias LCPError = LcpException ReplaceWith("LcpService()"), level = DeprecationLevel.ERROR ) +@Suppress("UNUSED_PARAMETER") public fun R2MakeLCPService(context: Context): LcpService? = - LcpService(context) + throw NotImplementedError() 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 d3ab1c562d..4e0a4a0830 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 @@ -30,13 +30,12 @@ import org.readium.r2.lcp.license.container.LicenseContainer import org.readium.r2.lcp.license.container.createLicenseContainer import org.readium.r2.lcp.license.model.LicenseDocument import org.readium.r2.shared.asset.Asset +import org.readium.r2.shared.asset.AssetRetriever import org.readium.r2.shared.error.Try import org.readium.r2.shared.extensions.tryOr +import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.publication.protection.ContentProtection -import org.readium.r2.shared.resource.ArchiveFactory -import org.readium.r2.shared.resource.ResourceFactory import org.readium.r2.shared.util.mediatype.MediaType -import org.readium.r2.shared.util.mediatype.MediaTypeRetriever import timber.log.Timber internal class LicensesService( @@ -46,25 +45,22 @@ internal class LicensesService( private val network: NetworkService, private val passphrases: PassphrasesService, private val context: Context, - private val mediaTypeRetriever: MediaTypeRetriever, - private val resourceFactory: ResourceFactory, - private val archiveFactory: ArchiveFactory + private val assetRetriever: AssetRetriever, + private val formatRegistry: FormatRegistry ) : LcpService, CoroutineScope by MainScope() { - override suspend fun isLcpProtected(file: File): Boolean = - tryOr(false) { - val mediaType = mediaTypeRetriever.retrieve(file) ?: return false - createLicenseContainer(file, mediaType).read() - true - } + override suspend fun isLcpProtected(file: File): Boolean { + val asset = assetRetriever.retrieve(file) ?: return false + return isLcpProtected(asset) + } override suspend fun isLcpProtected(asset: Asset): Boolean = tryOr(false) { when (asset) { is Asset.Resource -> - asset.mediaType == MediaType.LCP_LICENSE_DOCUMENT + asset.format.mediaType == MediaType.LCP_LICENSE_DOCUMENT is Asset.Container -> { - createLicenseContainer(asset.container, asset.mediaType).read() + createLicenseContainer(asset.container, asset.format.mediaType).read() true } } @@ -73,7 +69,7 @@ internal class LicensesService( override fun contentProtection( authentication: LcpAuthenticating ): ContentProtection = - LcpContentProtection(this, authentication, resourceFactory, archiveFactory) + LcpContentProtection(this, authentication, assetRetriever) override suspend fun acquirePublication(lcpl: ByteArray, onProgress: (Double) -> Unit): Try = try { @@ -251,9 +247,9 @@ internal class LicensesService( destination, mediaType = link.type, onProgress = onProgress - ) - ?: mediaTypeRetriever.retrieve(mediaType = link.type) - ?: MediaType.EPUB + ) ?: link.mediaType + + val format = formatRegistry.retrieve(mediaType) // Saves the License Document into the downloaded publication val container = createLicenseContainer(destination, mediaType) @@ -261,7 +257,7 @@ internal class LicensesService( return LcpService.AcquiredPublication( localFile = destination, - suggestedFilename = "${license.id}.${mediaType.fileExtension}", + suggestedFilename = "${license.id}.${format.fileExtension ?: "epub"}", mediaType = mediaType, licenseDocument = license ) 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 e689cd7674..81e5a6050d 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,9 +21,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.readium.r2.lcp.LcpException import org.readium.r2.shared.error.Try -import org.readium.r2.shared.util.http.retrieve +import org.readium.r2.shared.format.FormatHints +import org.readium.r2.shared.format.FormatRegistry +import org.readium.r2.shared.util.http.invoke +import org.readium.r2.shared.util.mediatype.HintMediaTypeSnifferContext import org.readium.r2.shared.util.mediatype.MediaType -import org.readium.r2.shared.util.mediatype.MediaTypeRetriever import timber.log.Timber internal typealias URLParameters = Map @@ -34,7 +36,7 @@ internal class NetworkException(val status: Int?, cause: Throwable? = null) : Ex ) internal class NetworkService( - private val mediaTypeRetriever: MediaTypeRetriever + private val formatRegistry: FormatRegistry ) { enum class Method(val value: String) { GET("GET"), POST("POST"), PUT("PUT"); @@ -138,10 +140,11 @@ internal class NetworkService( } } - mediaTypeRetriever.retrieve( - connection = connection, - mediaType = mediaType - ) + formatRegistry.retrieve( + HintMediaTypeSnifferContext( + hints = FormatHints(connection, mediaType = mediaType) + ) + )?.mediaType } catch (e: Exception) { Timber.e(e) throw LcpException.Network(e) diff --git a/readium/opds/src/main/java/org/readium/r2/opds/OPDS1Parser.kt b/readium/opds/src/main/java/org/readium/r2/opds/OPDS1Parser.kt index 23318096b0..bb33612d10 100644 --- a/readium/opds/src/main/java/org/readium/r2/opds/OPDS1Parser.kt +++ b/readium/opds/src/main/java/org/readium/r2/opds/OPDS1Parser.kt @@ -20,7 +20,6 @@ import org.readium.r2.shared.parser.xml.XmlParser import org.readium.r2.shared.publication.* import org.readium.r2.shared.toJSON import org.readium.r2.shared.util.Href -import org.readium.r2.shared.util.http.DefaultHttpClient import org.readium.r2.shared.util.http.HttpClient import org.readium.r2.shared.util.http.HttpRequest import org.readium.r2.shared.util.http.fetchWithDecoder @@ -46,7 +45,7 @@ public object Namespaces { public class OPDS1Parser { public companion object { - public suspend fun parseUrlString(url: String, client: HttpClient = DefaultHttpClient()): Try { + public suspend fun parseUrlString(url: String, client: HttpClient): Try { return client.fetchWithDecoder(HttpRequest(url)) { this.parse(it.body, URL(url)) } @@ -54,7 +53,7 @@ public class OPDS1Parser { public suspend fun parseRequest( request: HttpRequest, - client: HttpClient = DefaultHttpClient() + client: HttpClient ): Try { return client.fetchWithDecoder(request) { this.parse(it.body, URL(request.url)) @@ -186,7 +185,7 @@ public class OPDS1Parser { } @Suppress("unused") - public suspend fun retrieveOpenSearchTemplate(feed: Feed): Try { + public suspend fun retrieveOpenSearchTemplate(feed: Feed, client: HttpClient): Try { var openSearchURL: URL? = null var selfMimeType: String? = null @@ -204,7 +203,7 @@ public class OPDS1Parser { return@let it } - return DefaultHttpClient().fetchWithDecoder(HttpRequest(unwrappedURL.toString())) { + return client.fetchWithDecoder(HttpRequest(unwrappedURL.toString())) { val document = XmlParser().parse(it.body.inputStream()) val urls = document.get("Url", Namespaces.Search) diff --git a/readium/opds/src/main/java/org/readium/r2/opds/OPDS2Parser.kt b/readium/opds/src/main/java/org/readium/r2/opds/OPDS2Parser.kt index 11f4c268be..b9b48d037e 100644 --- a/readium/opds/src/main/java/org/readium/r2/opds/OPDS2Parser.kt +++ b/readium/opds/src/main/java/org/readium/r2/opds/OPDS2Parser.kt @@ -20,7 +20,6 @@ import org.readium.r2.shared.publication.Link import org.readium.r2.shared.publication.Manifest import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.util.Href -import org.readium.r2.shared.util.http.DefaultHttpClient import org.readium.r2.shared.util.http.HttpClient import org.readium.r2.shared.util.http.HttpRequest import org.readium.r2.shared.util.http.fetchWithDecoder @@ -39,7 +38,7 @@ public class OPDS2Parser { private lateinit var feed: Feed - public suspend fun parseUrlString(url: String, client: HttpClient = DefaultHttpClient()): Try { + public suspend fun parseUrlString(url: String, client: HttpClient): Try { return client.fetchWithDecoder(HttpRequest(url)) { this.parse(it.body, URL(url)) } @@ -47,7 +46,7 @@ public class OPDS2Parser { public suspend fun parseRequest( request: HttpRequest, - client: HttpClient = DefaultHttpClient() + client: HttpClient ): Try { return client.fetchWithDecoder(request) { this.parse(it.body, URL(request.url)) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/asset/Asset.kt b/readium/shared/src/main/java/org/readium/r2/shared/asset/Asset.kt index 8cf2d010c6..1e48e72618 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/asset/Asset.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/asset/Asset.kt @@ -6,7 +6,8 @@ package org.readium.r2.shared.asset -import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.format.Format +import org.readium.r2.shared.resource.Resource as SharedResource /** * An asset which is either a single resource or a container that holds multiple resources. @@ -14,14 +15,14 @@ import org.readium.r2.shared.util.mediatype.MediaType public sealed class Asset { /** - * Media type of the asset. + * Type of the asset source. */ - public abstract val mediaType: MediaType + public abstract val type: AssetType /** - * Type of the asset source. + * Media format of the asset. */ - public abstract val assetType: AssetType + public abstract val format: Format /** * Releases in-memory resources related to this asset. @@ -31,15 +32,15 @@ public sealed class Asset { /** * A single resource asset. * - * @param mediaType Media type of the asset. + * @param format Media format of the asset. * @param resource Opened resource to access the asset. */ public class Resource( - override val mediaType: MediaType, - public val resource: org.readium.r2.shared.resource.Resource + override val format: Format, + public val resource: SharedResource ) : Asset() { - override val assetType: AssetType = + override val type: AssetType = AssetType.Resource override suspend fun close() { @@ -50,17 +51,17 @@ public sealed class Asset { /** * A container asset providing access to several resources. * - * @param mediaType Media type of the asset. + * @param format Media format of the asset. * @param exploded If this container is an exploded or packaged container. * @param container Opened container to access asset resources. */ public class Container( - override val mediaType: MediaType, + override val format: Format, exploded: Boolean, public val container: org.readium.r2.shared.resource.Container ) : Asset() { - override val assetType: AssetType = + override val type: AssetType = if (exploded) { AssetType.Directory } else { diff --git a/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt b/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt index 0de03b5ed8..f0154c9115 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt @@ -6,33 +6,47 @@ package org.readium.r2.shared.asset -import android.content.ContentResolver -import android.content.Context import android.net.Uri import java.io.File import org.readium.r2.shared.error.ThrowableError import org.readium.r2.shared.error.Try import org.readium.r2.shared.error.flatMap -import org.readium.r2.shared.resource.* +import org.readium.r2.shared.error.getOrElse +import org.readium.r2.shared.format.Format +import org.readium.r2.shared.format.FormatHints +import org.readium.r2.shared.format.FormatRegistry +import org.readium.r2.shared.resource.ArchiveFactory +import org.readium.r2.shared.resource.Container +import org.readium.r2.shared.resource.ContainerFactory +import org.readium.r2.shared.resource.ContainerMediaTypeSnifferContext +import org.readium.r2.shared.resource.DefaultArchiveFactory +import org.readium.r2.shared.resource.DirectoryContainerFactory +import org.readium.r2.shared.resource.FileResourceFactory +import org.readium.r2.shared.resource.Resource +import org.readium.r2.shared.resource.ResourceFactory +import org.readium.r2.shared.resource.ResourceMediaTypeSnifferContext import org.readium.r2.shared.util.Url -import org.readium.r2.shared.util.mediatype.* +import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.toUrl public class AssetRetriever( + private val formatRegistry: FormatRegistry, private val resourceFactory: ResourceFactory, private val containerFactory: ContainerFactory, - private val archiveFactory: ArchiveFactory, - contentResolver: ContentResolver, - sniffers: List + private val archiveFactory: ArchiveFactory ) { - public constructor(context: Context) : this( - resourceFactory = FileResourceFactory(), - containerFactory = DirectoryContainerFactory(), - archiveFactory = DefaultArchiveFactory(), - contentResolver = context.contentResolver, - sniffers = MediaType.sniffers - ) + public companion object { + public operator fun invoke(): AssetRetriever { + val formatRegistry = FormatRegistry() + return AssetRetriever( + formatRegistry = formatRegistry, + resourceFactory = FileResourceFactory(formatRegistry), + containerFactory = DirectoryContainerFactory(formatRegistry), + archiveFactory = DefaultArchiveFactory() + ) + } + } public sealed class Error : org.readium.r2.shared.error.Error { @@ -135,19 +149,24 @@ public class AssetRetriever( url: Url, mediaType: MediaType, assetType: AssetType - ): Try = - when (assetType) { + ): Try { + val format = formatRegistry.retrieve(mediaType) + + return when (assetType) { AssetType.Archive -> - retrieveArchiveAsset(url, mediaType) + retrieveArchiveAsset(url, format) + AssetType.Directory -> - retrieveDirectoryAsset(url, mediaType) + retrieveDirectoryAsset(url, format) + AssetType.Resource -> - retrieveResourceAsset(url, mediaType) + retrieveResourceAsset(url, format) } + } private suspend fun retrieveArchiveAsset( url: Url, - mediaType: MediaType + format: Format ): Try { return retrieveResource(url) .flatMap { resource: Resource -> @@ -163,16 +182,16 @@ public class AssetRetriever( } } } - .map { container -> Asset.Container(mediaType, exploded = false, container) } + .map { container -> Asset.Container(format, exploded = false, container) } } private suspend fun retrieveDirectoryAsset( url: Url, - mediaType: MediaType + format: Format ): Try { return containerFactory.create(url) .map { container -> - Asset.Container(mediaType, exploded = true, container) + Asset.Container(format, exploded = true, container) } .mapFailure { error -> when (error) { @@ -188,10 +207,10 @@ public class AssetRetriever( private suspend fun retrieveResourceAsset( url: Url, - mediaType: MediaType + format: Format ): Try { return retrieveResource(url) - .map { resource -> Asset.Resource(mediaType, resource) } + .map { resource -> Asset.Resource(format, resource) } } private suspend fun retrieveResource( @@ -227,76 +246,26 @@ public class AssetRetriever( /* Sniff unknown assets */ - private val snifferContextFactory: UrlSnifferContextFactory = - UrlSnifferContextFactory(resourceFactory, containerFactory, archiveFactory) - - private val mediaTypeRetriever: MediaTypeRetriever = - MediaTypeRetriever( - resourceFactory, - containerFactory, - archiveFactory, - contentResolver, - sniffers - ) - /** * Retrieves an asset from a local file. */ public suspend fun retrieve( file: File, - mediaType: String? = null, - fileExtension: String? = null + hints: FormatHints = FormatHints() ): Asset? = - retrieve( - file, - mediaTypes = listOfNotNull(mediaType), - fileExtensions = listOfNotNull(fileExtension) - ) - - /** - * Retrieves an asset from a local file. - */ - public suspend fun retrieve( - file: File, - mediaTypes: List, - fileExtensions: List - ): Asset? { - val context = snifferContextFactory - .createContext( - file.toUrl(), - mediaTypes = mediaTypes, - fileExtensions = listOf(file.extension) + fileExtensions - ) ?: return null - - return retrieve(context) - } - - /** - * Retrieves an asset from an Uri. - */ - public suspend fun retrieve( - uri: Uri, - mediaType: String? = null, - fileExtension: String? = null - ): Asset? = - retrieve( - uri, - mediaTypes = listOfNotNull(mediaType), - fileExtensions = listOfNotNull(fileExtension) - ) + retrieve(file.toUrl(), hints) /** * Retrieves an asset from a Uri. */ public suspend fun retrieve( uri: Uri, - mediaTypes: List, - fileExtensions: List + hints: FormatHints = FormatHints() ): Asset? { val url = uri.toUrl() ?: return null - return retrieve(url, mediaTypes, fileExtensions) + return retrieve(url, hints) } /** @@ -304,53 +273,39 @@ public class AssetRetriever( */ public suspend fun retrieve( url: Url, - mediaType: String? = null, - fileExtension: String? = null + hints: FormatHints = FormatHints() ): Asset? { - return retrieve(url, listOfNotNull(mediaType), listOfNotNull(fileExtension)) - } + @Suppress("NAME_SHADOWING") + val hints = + hints + FormatHints(fileExtension = url.extension) - /** - * Retrieves an asset from a Url. - */ - public suspend fun retrieve( - url: Url, - mediaTypes: List, - fileExtensions: List - ): Asset? { - val context = snifferContextFactory - .createContext( - url, - mediaTypes = mediaTypes, - fileExtensions = buildList { - addAll(fileExtensions) - url.extension?.let { add(it) } + val resource = resourceFactory + .create(url) + .getOrElse { error -> + when (error) { + is ResourceFactory.Error.NotAResource -> + return containerFactory.create(url).getOrNull() + ?.let { retrieve(it, exploded = true, hints) } + else -> return null } + } + + return archiveFactory.create(resource, password = null) + .fold( + { retrieve(container = it, exploded = false, hints) }, + { retrieve(resource, hints) } ) - ?: return null + } - return retrieve(context) + private suspend fun retrieve(container: Container, exploded: Boolean, hints: FormatHints): Asset? { + val format = formatRegistry.retrieve(ContainerMediaTypeSnifferContext(container, hints)) + ?: return null + return Asset.Container(format, exploded = exploded, container = container) } - private suspend fun retrieve(context: ContentAwareSnifferContext): Asset? { - val mediaType = mediaTypeRetriever.doRetrieve( - fullContext = { context }, - mediaTypes = context.mediaTypes.map(MediaType::toString), - fileExtensions = context.fileExtensions - ) ?: return null - - return when (context) { - is ContainerSnifferContext -> - Asset.Container( - mediaType = mediaType, - exploded = context.isExploded, - container = context.container - ) - is ResourceSnifferContext -> - Asset.Resource( - mediaType = mediaType, - resource = context.resource - ) - } + private suspend fun retrieve(resource: Resource, hints: FormatHints): Asset? { + val format = formatRegistry.retrieve(ResourceMediaTypeSnifferContext(resource, hints)) + ?: return null + return Asset.Resource(format, resource = resource) } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/extensions/ByteArray.kt b/readium/shared/src/main/java/org/readium/r2/shared/extensions/ByteArray.kt index 19149a5bd1..9606e762e2 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/extensions/ByteArray.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/extensions/ByteArray.kt @@ -47,3 +47,14 @@ public fun ByteArray.md5(): String? = Timber.e(e) null } + +internal fun ByteArray.read(range: LongRange?): ByteArray { + range ?: return this + + @Suppress("NAME_SHADOWING") + val range = range + .coerceIn(0L until size) + .requireLengthFitInt() + + return sliceArray(range.map(Long::toInt)) +} diff --git a/readium/shared/src/main/java/org/readium/r2/shared/extensions/File.kt b/readium/shared/src/main/java/org/readium/r2/shared/extensions/File.kt index f380f89ca9..bc52a94ead 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/extensions/File.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/extensions/File.kt @@ -14,7 +14,6 @@ import java.io.FileInputStream import java.security.MessageDigest import org.readium.r2.shared.InternalReadiumApi import org.readium.r2.shared.util.mediatype.MediaType -import org.readium.r2.shared.util.mediatype.MediaTypeRetriever import timber.log.Timber /** @@ -68,6 +67,7 @@ public fun File.isParentOf(other: File): Boolean { * * If unknown, fallback on `MediaType.BINARY`. */ +@Suppress("UnusedReceiverParameter", "RedundantSuspendModifier", "UNUSED_PARAMETER") @Deprecated("Explicitly use MediaTypeRetriever", level = DeprecationLevel.ERROR) public suspend fun File.mediaType(mediaTypeHint: String? = null): MediaType = - MediaTypeRetriever().retrieve(this, mediaType = mediaTypeHint) ?: MediaType.BINARY + throw NotImplementedError() diff --git a/readium/shared/src/main/java/org/readium/r2/shared/format/Format.kt b/readium/shared/src/main/java/org/readium/r2/shared/format/Format.kt new file mode 100644 index 0000000000..cf6b6e0b8e --- /dev/null +++ b/readium/shared/src/main/java/org/readium/r2/shared/format/Format.kt @@ -0,0 +1,157 @@ +/* + * Copyright 2023 Readium Foundation. All rights reserved. + * Use of this source code is governed by the BSD-style license + * available in the top-level LICENSE file of the project. + */ + +package org.readium.r2.shared.format + +import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer +import org.readium.r2.shared.util.mediatype.HintMediaTypeSnifferContext +import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeSniffer +import org.readium.r2.shared.util.mediatype.MediaTypeSnifferContext + +public class FormatRegistry( + formats: List = listOf( + // The known formats are not declared as constants to discourage comparing the format + // instance instead of the media type for equality. + Format( + MediaType.ACSM, + name = "Adobe Content Server Message", + fileExtension = "acsm" + ), + Format( + MediaType.CBZ, + name = "Comic Book Archive", + fileExtension = "cbz" + ), + Format( + MediaType.DIVINA, + name = "Digital Visual Narratives", + fileExtension = "divina" + ), + Format( + MediaType.DIVINA_MANIFEST, + name = "Digital Visual Narratives", + fileExtension = "json" + ), + Format( + MediaType.EPUB, + name = "EPUB", + fileExtension = "epub" + ), + Format( + MediaType.LCP_LICENSE_DOCUMENT, + name = "LCP License", + fileExtension = "lcpl" + ), + Format( + MediaType.LCP_PROTECTED_AUDIOBOOK, + name = "LCP Protected Audiobook", + fileExtension = "lcpa" + ), + Format( + MediaType.LCP_PROTECTED_PDF, + name = "LCP Protected PDF", + fileExtension = "lcpdf" + ), + Format( + MediaType.PDF, + name = "PDF", + fileExtension = "pdf" + ), + Format( + MediaType.READIUM_AUDIOBOOK, + name = "Readium Audiobook", + fileExtension = "audiobook" + ), + Format( + MediaType.READIUM_AUDIOBOOK_MANIFEST, + name = "Readium Audiobook", + fileExtension = "json" + ), + Format( + MediaType.READIUM_WEBPUB, + name = "Readium Web Publication", + fileExtension = "webpub" + ), + Format( + MediaType.READIUM_WEBPUB_MANIFEST, + name = "Readium Web Publication", + fileExtension = "json" + ), + Format( + MediaType.W3C_WPUB_MANIFEST, + name = "Web Publication", + fileExtension = "json" + ), + Format( + MediaType.ZAB, + name = "Zipped Audio Book", + fileExtension = "zab" + ) + ), + private val sniffer: MediaTypeSniffer = DefaultMediaTypeSniffer() +) { + private val formats: MutableMap = + formats.associateBy { it.mediaType }.toMutableMap() + + public fun register(format: Format) { + formats[format.mediaType] = format + } + + public suspend fun canonicalize(mediaType: MediaType): MediaType = + retrieve(mediaType).mediaType + + public suspend fun retrieve(mediaType: MediaType): Format = + retrieve(HintMediaTypeSnifferContext(hints = FormatHints(mediaType))) + ?: Format(mediaType) + + public suspend fun retrieve(context: MediaTypeSnifferContext): Format? = + sniffer.sniff(context)?.let { + formats[it] ?: Format(it) + } +} + +/** + * Represents a media format, identified by a unique RFC 6838 media type. + * + * @param mediaType Canonical media type for this format. + * @param name A human readable name identifying the format, which may be presented to the user. + * @param fileExtension The default file extension to use for this format. + */ +public data class Format( + public val mediaType: MediaType, + public val name: String? = null, + public val fileExtension: String? = null +) { + + override fun toString(): String = + name ?: mediaType.toString() +} + +public data class FormatHints( + val mediaTypes: List = emptyList(), + val fileExtensions: List = emptyList() +) { + public companion object { + public operator fun invoke(mediaType: MediaType? = null, fileExtension: String? = null): FormatHints = + FormatHints( + mediaTypes = listOfNotNull(mediaType), + fileExtensions = listOfNotNull(fileExtension) + ) + + public operator fun invoke( + mediaTypes: List = emptyList(), + fileExtensions: List = emptyList() + ): FormatHints = + FormatHints(mediaTypes.mapNotNull { MediaType(it) }, fileExtensions = fileExtensions) + } + + public operator fun plus(other: FormatHints): FormatHints = + FormatHints( + mediaTypes = mediaTypes + other.mediaTypes, + fileExtensions = fileExtensions + other.fileExtensions + ) +} diff --git a/readium/shared/src/main/java/org/readium/r2/shared/opds/Acquisition.kt b/readium/shared/src/main/java/org/readium/r2/shared/opds/Acquisition.kt index b3bbfcde13..0ced3d2727 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/opds/Acquisition.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/opds/Acquisition.kt @@ -34,7 +34,7 @@ public data class Acquisition( /** Media type of the resource to acquire. */ val mediaType: MediaType get() = - MediaType.parse(type) ?: MediaType.BINARY + MediaType(type) ?: MediaType.BINARY /** * Serializes an [Acquisition] to its JSON representation. diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/Link.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/Link.kt index 4f3adcb831..5b460c91a4 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/Link.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/Link.kt @@ -72,7 +72,7 @@ public data class Link( /** Media type of the linked resource. */ val mediaType: MediaType get() = - type?.let { MediaType.parse(it) } ?: MediaType.BINARY + type?.let { MediaType(it) } ?: MediaType.BINARY /** * List of URI template parameter keys, if the [Link] is templated. diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/Publication.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/Publication.kt index 0b856f4c48..5ff5395956 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/Publication.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/Publication.kt @@ -180,7 +180,6 @@ public class Publication( null } } - // FIXME: To remove when the `Resource` properly sniffs its content media type. .withMediaType(link.mediaType) } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/AdeptFallbackContentProtection.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/AdeptFallbackContentProtection.kt index 4f638ae9c7..bc9b5c180c 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/AdeptFallbackContentProtection.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/AdeptFallbackContentProtection.kt @@ -13,7 +13,6 @@ import org.readium.r2.shared.parser.xml.ElementNode import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.publication.protection.ContentProtection.Scheme import org.readium.r2.shared.publication.services.contentProtectionServiceFactory -import org.readium.r2.shared.resource.Container import org.readium.r2.shared.resource.Resource import org.readium.r2.shared.resource.readAsXml import org.readium.r2.shared.util.mediatype.MediaType @@ -32,7 +31,7 @@ public class AdeptFallbackContentProtection : ContentProtection { return false } - return isAdept(asset.container, asset.mediaType) + return isAdept(asset) } override suspend fun open( @@ -48,7 +47,7 @@ public class AdeptFallbackContentProtection : ContentProtection { } val protectedFile = ContentProtection.Asset( - asset.mediaType, + asset.format.mediaType, asset.container, onCreatePublication = { servicesBuilder.contentProtectionServiceFactory = @@ -59,12 +58,12 @@ public class AdeptFallbackContentProtection : ContentProtection { return Try.success(protectedFile) } - private suspend fun isAdept(container: Container, mediaType: MediaType): Boolean { - if (!mediaType.matches(MediaType.EPUB)) { + private suspend fun isAdept(asset: Asset.Container): Boolean { + if (asset.format.mediaType.matches(MediaType.EPUB)) { return false } - val rightsXml = container.get("/META-INF/rights.xml").readAsXmlOrNull() - val encryptionXml = container.get("/META-INF/encryption.xml").readAsXmlOrNull() + val rightsXml = asset.container.get("/META-INF/rights.xml").readAsXmlOrNull() + val encryptionXml = asset.container.get("/META-INF/encryption.xml").readAsXmlOrNull() return encryptionXml != null && ( rightsXml?.namespace == "http://ns.adobe.com/adept" || diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/LcpFallbackContentProtection.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/LcpFallbackContentProtection.kt index bf74e69db9..2a7398a562 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/LcpFallbackContentProtection.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/LcpFallbackContentProtection.kt @@ -34,8 +34,8 @@ public class LcpFallbackContentProtection : ContentProtection { override suspend fun supports(asset: Asset): Boolean = when (asset) { - is Asset.Container -> isLcpProtected(asset.container, asset.mediaType) - is Asset.Resource -> asset.mediaType.matches(MediaType.LCP_LICENSE_DOCUMENT) + is Asset.Container -> isLcpProtected(asset.container, asset.format.mediaType) + is Asset.Resource -> asset.format.mediaType.matches(MediaType.LCP_LICENSE_DOCUMENT) } override suspend fun open( @@ -51,7 +51,7 @@ public class LcpFallbackContentProtection : ContentProtection { } val protectedFile = ContentProtection.Asset( - asset.mediaType, + asset.format.mediaType, asset.container, onCreatePublication = { servicesBuilder.contentProtectionServiceFactory = diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/BytesResource.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/BytesResource.kt index 7b6e84eb01..74ba3391fe 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/BytesResource.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/BytesResource.kt @@ -8,8 +8,7 @@ package org.readium.r2.shared.resource import kotlinx.coroutines.runBlocking import org.readium.r2.shared.error.Try -import org.readium.r2.shared.extensions.coerceIn -import org.readium.r2.shared.extensions.requireLengthFitInt +import org.readium.r2.shared.extensions.read import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.mediatype.MediaType @@ -43,15 +42,6 @@ public sealed class BaseBytesResource( return _bytes.map { it.read(range) } } - private fun ByteArray.read(range: LongRange): ByteArray { - @Suppress("NAME_SHADOWING") - val range = range - .coerceIn(0L until size) - .requireLengthFitInt() - - return sliceArray(range.map(Long::toInt)) - } - override suspend fun close() {} } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/ContentResource.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/ContentResource.kt index aaa0fd99d8..8ae1585886 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/ContentResource.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/ContentResource.kt @@ -55,7 +55,7 @@ public class ContentResource( ResourceTry.success(Resource.Properties()) override suspend fun mediaType(): ResourceTry = - Try.success(contentResolver.getType(uri)?.let { MediaType.parse(it) }) + Try.success(contentResolver.getType(uri)?.let { MediaType(it) }) override suspend fun close() { } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/DirectoryContainer.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/DirectoryContainer.kt index 9b3464a34d..f5768b46d6 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/DirectoryContainer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/DirectoryContainer.kt @@ -14,6 +14,7 @@ import org.readium.r2.shared.error.Try import org.readium.r2.shared.extensions.addPrefix import org.readium.r2.shared.extensions.isParentOf import org.readium.r2.shared.extensions.tryOr +import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.util.Url /** @@ -21,11 +22,12 @@ import org.readium.r2.shared.util.Url */ internal class DirectoryContainer( private val root: File, - private val entries: List + private val entries: List, + private val formatRegistry: FormatRegistry ) : Container { private inner class FileEntry(file: File) : - Container.Entry, Resource by FileResource(file, mediaType = null) { + Container.Entry, Resource by FileResource(file, formatRegistry) { override val path: String = file.relativeTo(root).path.addPrefix("/") @@ -49,7 +51,9 @@ internal class DirectoryContainer( override suspend fun close() {} } -public class DirectoryContainerFactory : ContainerFactory { +public class DirectoryContainerFactory( + private val formatRegistry: FormatRegistry +) : ContainerFactory { override suspend fun create(url: Url): Try { if (url.scheme != ContentResolver.SCHEME_FILE) { @@ -78,7 +82,7 @@ public class DirectoryContainerFactory : ContainerFactory { return Try.failure(ContainerFactory.Error.Forbidden(e)) } - val container = DirectoryContainer(file, entries) + val container = DirectoryContainer(file, entries, formatRegistry) return Try.success(container) } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt index 46f0038fb4..b19617418c 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt @@ -16,10 +16,10 @@ import kotlinx.coroutines.withContext import org.readium.r2.shared.error.Try import org.readium.r2.shared.error.getOrThrow import org.readium.r2.shared.extensions.* +import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.isLazyInitialized import org.readium.r2.shared.util.mediatype.MediaType -import org.readium.r2.shared.util.mediatype.MediaTypeRetriever /** * A [Resource] to access a [file]. @@ -27,14 +27,15 @@ import org.readium.r2.shared.util.mediatype.MediaTypeRetriever public class FileResource internal constructor( private val file: File, private val mediaType: MediaType?, - private val mediaTypeRetriever: MediaTypeRetriever? + private val formatRegistry: FormatRegistry? ) : Resource { - public constructor(file: File, mediaType: MediaType?) : this(file, mediaType, null) - public constructor(file: File, mediaTypeRetriever: MediaTypeRetriever?) : this( + public constructor(file: File, mediaType: MediaType) : this(file, mediaType, null) + + public constructor(file: File, formatRegistry: FormatRegistry) : this( file, null, - mediaTypeRetriever + formatRegistry ) private val randomAccessFile by lazy { @@ -48,8 +49,10 @@ public class FileResource internal constructor( override suspend fun properties(): ResourceTry = ResourceTry.success(Resource.Properties()) - override suspend fun mediaType(): ResourceTry = - Try.success(mediaType ?: mediaTypeRetriever?.retrieve(file)) + override suspend fun mediaType(): ResourceTry = Try.success( + mediaType + ?: formatRegistry?.retrieve(ResourceMediaTypeSnifferContext(this))?.mediaType + ) override suspend fun close() { withContext(Dispatchers.IO) { @@ -124,7 +127,9 @@ public class FileResource internal constructor( "${javaClass.simpleName}(${file.path})" } -public class FileResourceFactory : ResourceFactory { +public class FileResourceFactory( + private val formatRegistry: FormatRegistry +) : ResourceFactory { override suspend fun create(url: Url): Try { if (url.scheme != ContentResolver.SCHEME_FILE) { @@ -141,6 +146,6 @@ public class FileResourceFactory : ResourceFactory { return Try.failure(ResourceFactory.Error.Forbidden(e)) } - return Try.success(FileResource(file, mediaTypeRetriever = null)) + return Try.success(FileResource(file, formatRegistry)) } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt new file mode 100644 index 0000000000..c87084e7a6 --- /dev/null +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt @@ -0,0 +1,34 @@ +package org.readium.r2.shared.resource + +import org.readium.r2.shared.format.FormatHints +import org.readium.r2.shared.util.mediatype.ContainerMediaTypeSnifferContext as BaseContainerMediaTypeSnifferContext +import org.readium.r2.shared.util.mediatype.ContentMediaTypeSnifferContext + +public class ResourceMediaTypeSnifferContext( + private val resource: Resource, + override val hints: FormatHints = FormatHints() +) : ContentMediaTypeSnifferContext { + + override suspend fun read(range: LongRange?): ByteArray? = + resource.read(range).getOrNull() + + override suspend fun close() { + // We don't own the resource, not our responsibility to close it. + } +} + +public class ContainerMediaTypeSnifferContext( + private val container: Container, + override val hints: FormatHints +) : BaseContainerMediaTypeSnifferContext { + + override suspend fun entries(): Set? = + container.entries()?.map { it.path }?.toSet() + + override suspend fun read(path: String): ByteArray? = + container.get(path).read().getOrNull() + + override suspend fun close() { + // We don't own the container, not our responsibility to close it. + } +} diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/http/DefaultHttpClient.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/http/DefaultHttpClient.kt index 1130a5d592..6c0afbb98d 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/http/DefaultHttpClient.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/http/DefaultHttpClient.kt @@ -17,14 +17,19 @@ import kotlinx.coroutines.withContext import org.readium.r2.shared.error.Try import org.readium.r2.shared.error.flatMap import org.readium.r2.shared.error.tryRecover +import org.readium.r2.shared.format.FormatHints +import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.util.http.HttpRequest.Method +import org.readium.r2.shared.util.mediatype.BytesContentMediaTypeSnifferContext +import org.readium.r2.shared.util.mediatype.HintMediaTypeSnifferContext import org.readium.r2.shared.util.mediatype.MediaType -import org.readium.r2.shared.util.mediatype.MediaTypeRetriever import timber.log.Timber /** * An implementation of [HttpClient] using the native [HttpURLConnection]. * + * @param formatRegistry Registry of supported media formats to resolve the media type of a + * response. * @param userAgent Custom user agent to use for requests. * @param additionalHeaders A dictionary of additional headers to send with requests. * @param connectTimeout Timeout used when establishing a connection to the resource. A null timeout @@ -33,6 +38,7 @@ import timber.log.Timber * as the default value, while a timeout of zero as an infinite timeout. */ public class DefaultHttpClient( + private val formatRegistry: FormatRegistry, private val userAgent: String? = null, private val additionalHeaders: Map = mapOf(), private val connectTimeout: Duration? = null, @@ -112,9 +118,6 @@ public class DefaultHttpClient( public suspend fun onRequestFailed(request: HttpRequest, error: HttpException) {} } - private val mediaTypeRetriever: MediaTypeRetriever = - MediaTypeRetriever() - // We are using Dispatchers.IO but we still get this warning... override suspend fun stream(request: HttpRequest): HttpTry { suspend fun tryStream(request: HttpRequest): HttpTry = @@ -140,26 +143,29 @@ public class DefaultHttpClient( // Reads the full body, since it might contain an error representation such as // JSON Problem Details or OPDS Authentication Document val body = connection.errorStream?.use { it.readBytes() } - val mediaType = body?.let { - mediaTypeRetriever.retrieve( - connection = connection, - bytes = { it } + val format = body?.let { + formatRegistry.retrieve( + BytesContentMediaTypeSnifferContext( + hints = FormatHints(connection), + bytes = { it } + ) ) } - throw HttpException(kind, mediaType, body) + throw HttpException(kind, format?.mediaType, body) } - val mediaType = - mediaTypeRetriever.retrieve( - connection = connection - ) ?: MediaType.BINARY + val format = formatRegistry.retrieve( + HintMediaTypeSnifferContext( + hints = FormatHints(connection) + ) + ) val response = HttpResponse( request = request, url = connection.url.toString(), statusCode = statusCode, headers = connection.safeHeaders, - mediaType = mediaType + mediaType = format?.mediaType ?: MediaType.BINARY ) callback.onResponseReceived(request, response) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/http/HttpURLConnectionExt.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/http/HttpURLConnectionExt.kt new file mode 100644 index 0000000000..9fc077e480 --- /dev/null +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/http/HttpURLConnectionExt.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2023 Readium Foundation. All rights reserved. + * Use of this source code is governed by the BSD-style license + * available in the top-level LICENSE file of the project. + */ + +package org.readium.r2.shared.util.http + +import java.net.HttpURLConnection +import org.readium.r2.shared.extensions.extension +import org.readium.r2.shared.format.FormatHints + +public operator fun FormatHints.Companion.invoke( + connection: HttpURLConnection, + mediaType: String? = null +): FormatHints = + FormatHints( + mediaTypes = listOfNotNull(connection.contentType, mediaType), + fileExtensions = listOfNotNull( + connection.url.extension + // TODO: The suggested filename extension, part of the HTTP header `Content-Disposition`. + ) + ) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/http/MediaType.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/http/MediaType.kt deleted file mode 100644 index 5d17a28a4c..0000000000 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/http/MediaType.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2023 Readium Foundation. All rights reserved. - * Use of this source code is governed by the BSD-style license - * available in the top-level LICENSE file of the project. - */ - -package org.readium.r2.shared.util.http - -import java.net.HttpURLConnection -import org.readium.r2.shared.extensions.extension -import org.readium.r2.shared.resource.DefaultArchiveFactory -import org.readium.r2.shared.util.mediatype.BytesSnifferContextFactory -import org.readium.r2.shared.util.mediatype.MediaType -import org.readium.r2.shared.util.mediatype.MediaTypeRetriever - -/** - * Resolves the format for this [HttpURLConnection], with optional extra file extension and media type - * hints. - */ -public suspend fun MediaTypeRetriever.retrieve( - connection: HttpURLConnection, - bytes: (() -> ByteArray)?, - mediaTypes: List, - fileExtensions: List -): MediaType? { - val allMediaTypes = mediaTypes.toMutableList() - val allFileExtensions = fileExtensions.toMutableList() - - // The value of the `Content-Type` HTTP header. - connection.contentType?.let { - allMediaTypes.add(0, it) - } - - // The URL file extension. - connection.url.extension?.let { - allFileExtensions.add(0, it) - } - - // TODO: The suggested filename extension, part of the HTTP header `Content-Disposition`. - - return if (bytes != null) { - doRetrieve( - { - BytesSnifferContextFactory(DefaultArchiveFactory()) - .createContext( - bytes.invoke(), - mediaTypes = allMediaTypes, - fileExtensions = allFileExtensions - ) - }, - mediaTypes = allMediaTypes, - fileExtensions = allFileExtensions - ) - } else { - retrieve(mediaTypes = allMediaTypes, fileExtensions = allFileExtensions) - } -} - -/** - * Resolves the format for this [HttpURLConnection], with optional extra file extension and media type - * hints. - */ -public suspend fun MediaTypeRetriever.retrieve( - connection: HttpURLConnection, - bytes: (() -> ByteArray)? = null, - mediaType: String? = null, - fileExtension: String? = null -): MediaType? = retrieve(connection, bytes, listOfNotNull(mediaType), listOfNotNull(fileExtension)) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Extensions.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Extensions.kt index 1b4deb3055..e96284c5df 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Extensions.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Extensions.kt @@ -8,71 +8,16 @@ package org.readium.r2.shared.util.mediatype import java.io.File import java.net.HttpURLConnection -import org.readium.r2.shared.extensions.extension -import org.readium.r2.shared.resource.DefaultArchiveFactory -/** - * Resolves the format for this [HttpURLConnection], with optional extra file extension and media type - * hints. - */ -@Deprecated( - "Use the MediaTypeRetriever extension instead.", - replaceWith = ReplaceWith( - "mediaTypeRetriever.retrieve(connection, bytes, mediaTypes, fileExtensions)", - "org.readium.r2.shared.util.http.retrieve" - ), - level = DeprecationLevel.ERROR -) +@Suppress("UnusedReceiverParameter", "RedundantSuspendModifier", "UNUSED_PARAMETER") +@Deprecated("Use your own solution instead", level = DeprecationLevel.ERROR) public suspend fun HttpURLConnection.sniffMediaType( bytes: (() -> ByteArray)? = null, mediaTypes: List = emptyList(), - fileExtensions: List = emptyList(), - sniffers: List = MediaType.sniffers -): MediaType? { - val allMediaTypes = mediaTypes.toMutableList() - val allFileExtensions = fileExtensions.toMutableList() - - // The value of the `Content-Type` HTTP header. - contentType?.let { - allMediaTypes.add(0, it) - } - - // The URL file extension. - url.extension?.let { - allFileExtensions.add(0, it) - } - - // TODO: The suggested filename extension, part of the HTTP header `Content-Disposition`. - - val mediaTypeRetriever = MediaTypeRetriever(sniffers = sniffers) - - return if (bytes != null) { - mediaTypeRetriever.doRetrieve( - { - BytesSnifferContextFactory(DefaultArchiveFactory()) - .createContext( - bytes.invoke(), - mediaTypes = allMediaTypes, - fileExtensions = allFileExtensions - ) - }, - mediaTypes = allMediaTypes, - fileExtensions = allFileExtensions - ) - } else { - mediaTypeRetriever.retrieve(mediaTypes = allMediaTypes, fileExtensions = allFileExtensions) - } -} + fileExtensions: List = emptyList() +): MediaType? = throw NotImplementedError() -/** -* Sniffs the media type of the file. -* -* If unknown, fallback on `MediaType.BINARY`. -*/ -@Deprecated( - "Use MediaTypeRetriever explicitly.", - replaceWith = ReplaceWith("mediaTypeRetriever.retrieve(mediaType = mediaTypeHint)"), - level = DeprecationLevel.ERROR -) +@Suppress("UnusedReceiverParameter", "RedundantSuspendModifier", "UNUSED_PARAMETER") +@Deprecated("Use your own solution instead", level = DeprecationLevel.ERROR) public suspend fun File.mediaType(mediaTypeHint: String? = null): MediaType = - MediaTypeRetriever().retrieve(this, mediaType = mediaTypeHint) ?: MediaType.BINARY + throw NotImplementedError() diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaType.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaType.kt index aa438eb399..55dc552bc5 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaType.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaType.kt @@ -14,7 +14,6 @@ import android.net.Uri import java.io.File import java.nio.charset.Charset import java.util.* -import org.readium.r2.shared.extensions.tryOrNull /** * Represents a document format, identified by a unique RFC 6838 media type. @@ -29,68 +28,15 @@ import org.readium.r2.shared.extensions.tryOrNull * * Specification: https://tools.ietf.org/html/rfc6838 * - * @param string String representation for this media type. - * @param name A human readable name identifying the media type, which may be presented to the user. - * @param fileExtension The default file extension to use for this media type. + * @param type The type component, e.g. `application` in `application/epub+zip`. + * @param subtype The subtype component, e.g. `epub+zip` in `application/epub+zip`. + * @param parameters The parameters in the media type, such as `charset=utf-8`. */ -public class MediaType( - string: String, - public val name: String? = null, - public val fileExtension: String? = null -) { - - /** The type component, e.g. `application` in `application/epub+zip`. */ - public val type: String - - /** The subtype component, e.g. `epub+zip` in `application/epub+zip`. */ - public val subtype: String - - /** The parameters in the media type, such as `charset=utf-8`. */ +public class MediaType private constructor( + public val type: String, + public val subtype: String, public val parameters: Map - - init { - if (string.isEmpty()) { - throw IllegalArgumentException("Invalid media type: $string") - } - - // Grammar: https://tools.ietf.org/html/rfc2045#section-5.1 - val components = string.split(";") - .map { it.trim() } - val types = components[0].split("/") - if (types.size != 2) { - throw IllegalArgumentException("Invalid media type: $string") - } - - // > Both top-level type and subtype names are case-insensitive. - this.type = types[0].lowercase(Locale.ROOT) - this.subtype = types[1].lowercase(Locale.ROOT) - - // > Parameter names are case-insensitive and no meaning is attached to the order in which - // > they appear. - val parameters = components.drop(1) - .map { it.split("=") } - .filter { it.size == 2 } - .associate { Pair(it[0].lowercase(Locale.ROOT), it[1]) } - .toMutableMap() - - // For now, we only support case-insensitive `charset`. - // - // > Parameter values might or might not be case-sensitive, depending on the semantics of - // > the parameter name. - // > https://tools.ietf.org/html/rfc2616#section-3.7 - // - // > The character set names may be up to 40 characters taken from the printable characters - // > of US-ASCII. However, no distinction is made between use of upper and lower case - // > letters. - // > https://www.iana.org/assignments/character-sets/character-sets.xhtml - parameters["charset"]?.let { - parameters["charset"] = - (try { Charset.forName(it).name() } catch (e: Exception) { it }) - .uppercase(Locale.ROOT) - } - - this.parameters = parameters - } +) { /** * Structured syntax suffix, e.g. `+zip` in `application/epub+zip`. @@ -119,13 +65,11 @@ public class MediaType( * Non-significant parameters are also discarded. */ @Deprecated( - "Use MediaTypeRetriever instead", - replaceWith = ReplaceWith("mediaTypeRetriever.canonicalMediaType()"), + "Use FormatRegistry.canonicalize() instead", + replaceWith = ReplaceWith("formatRegistry.canonicalize(this)"), level = DeprecationLevel.ERROR ) - public fun canonicalMediaType(): MediaType { - TODO() - } + public fun canonicalMediaType(): MediaType = TODO() /** The string representation of this media type. */ override fun toString(): String { @@ -182,7 +126,7 @@ public class MediaType( * Returns whether the given [other] media type is included in this media type. */ public fun contains(other: String?): Boolean { - val mediaType = other?.let { parse(it) } + val mediaType = other?.let { MediaType(it) } ?: return false return contains(mediaType) @@ -203,7 +147,7 @@ public class MediaType( * in both media types. */ public fun matches(other: String?): Boolean = - matches(other?.let { parse(it) }) + matches(other?.let { MediaType(it) }) /** * Returns whether this media type matches any of the `others` media types. @@ -268,146 +212,135 @@ public class MediaType( /** * Creates a [MediaType] from its RFC 6838 string representation. - * - * @param name A human readable name identifying the media type, which may be presented to the user. - * @param fileExtension The default file extension to use for this media type. */ + public operator fun invoke(string: String): MediaType? { + if (string.isEmpty()) { + return null + } + + // Grammar: https://tools.ietf.org/html/rfc2045#section-5.1 + val components = string.split(";") + .map { it.trim() } + val types = components[0].split("/") + if (types.size != 2) { + return null + } + + // > Both top-level type and subtype names are case-insensitive. + val type = types[0].lowercase(Locale.ROOT) + val subtype = types[1].lowercase(Locale.ROOT) + + // > Parameter names are case-insensitive and no meaning is attached to the order in which + // > they appear. + val parameters = components.drop(1) + .map { it.split("=") } + .filter { it.size == 2 } + .associate { Pair(it[0].lowercase(Locale.ROOT), it[1]) } + .toMutableMap() + + // For now, we only support case-insensitive `charset`. + // + // > Parameter values might or might not be case-sensitive, depending on the semantics of + // > the parameter name. + // > https://tools.ietf.org/html/rfc2616#section-3.7 + // + // > The character set names may be up to 40 characters taken from the printable characters + // > of US-ASCII. However, no distinction is made between use of upper and lower case + // > letters. + // > https://www.iana.org/assignments/character-sets/character-sets.xhtml + parameters["charset"]?.let { + parameters["charset"] = + (try { Charset.forName(it).name() } catch (e: Exception) { it }) + .uppercase(Locale.ROOT) + } + + return MediaType( + type = type, + subtype = subtype, + parameters = parameters + ) + } + + @Suppress("UNUSED_PARAMETER") + @Deprecated( + "Use `MediaType(string)` instead", + replaceWith = ReplaceWith("MediaType(string)"), + level = DeprecationLevel.ERROR + ) public fun parse(string: String, name: String? = null, fileExtension: String? = null): MediaType? = - tryOrNull { MediaType(string = string, name = name, fileExtension = fileExtension) } + MediaType(string) // Known Media Types // // Reading apps are welcome to extend the static constants with additional media types. - public val AAC: MediaType = MediaType("audio/aac", fileExtension = "aac") - public val ACSM: MediaType = MediaType( - "application/vnd.adobe.adept+xml", - name = "Adobe Content Server Message", - fileExtension = "acsm" - ) - public val AIFF: MediaType = MediaType("audio/aiff", fileExtension = "aiff") - public val AVI: MediaType = MediaType("video/x-msvideo", fileExtension = "avi") - public val AVIF: MediaType = MediaType("image/avif", fileExtension = "avif") - public val BINARY: MediaType = MediaType("application/octet-stream") - public val BMP: MediaType = MediaType("image/bmp", fileExtension = "bmp") - public val CBZ: MediaType = MediaType( - "application/vnd.comicbook+zip", - name = "Comic Book Archive", - fileExtension = "cbz" - ) - public val CSS: MediaType = MediaType("text/css", fileExtension = "css") - public val DIVINA: MediaType = MediaType( - "application/divina+zip", - name = "Digital Visual Narratives", - fileExtension = "divina" - ) - public val DIVINA_MANIFEST: MediaType = MediaType( - "application/divina+json", - name = "Digital Visual Narratives", - fileExtension = "json" - ) - public val EPUB: MediaType = MediaType( - "application/epub+zip", - name = "EPUB", - fileExtension = "epub" - ) - public val GIF: MediaType = MediaType("image/gif", fileExtension = "gif") - public val GZ: MediaType = MediaType("application/gzip", fileExtension = "gz") - public val HTML: MediaType = MediaType("text/html", fileExtension = "html") - public val JAVASCRIPT: MediaType = MediaType("text/javascript", fileExtension = "js") - public val JPEG: MediaType = MediaType("image/jpeg", fileExtension = "jpeg") - public val JSON: MediaType = MediaType("application/json") - public val JSON_PROBLEM_DETAILS: MediaType = MediaType( - "application/problem+json", - name = "HTTP Problem Details", - fileExtension = "json" - ) - public val JXL: MediaType = MediaType("image/jxl", fileExtension = "jxl") + public val AAC: MediaType = MediaType("audio/aac")!! + public val ACSM: MediaType = MediaType("application/vnd.adobe.adept+xml")!! + public val AIFF: MediaType = MediaType("audio/aiff")!! + public val AVI: MediaType = MediaType("video/x-msvideo")!! + public val AVIF: MediaType = MediaType("image/avif")!! + public val BINARY: MediaType = MediaType("application/octet-stream")!! + public val BMP: MediaType = MediaType("image/bmp")!! + public val CBZ: MediaType = MediaType("application/vnd.comicbook+zip")!! + public val CSS: MediaType = MediaType("text/css")!! + public val DIVINA: MediaType = MediaType("application/divina+zip")!! + public val DIVINA_MANIFEST: MediaType = MediaType("application/divina+json")!! + public val EPUB: MediaType = MediaType("application/epub+zip")!! + public val GIF: MediaType = MediaType("image/gif")!! + public val GZ: MediaType = MediaType("application/gzip")!! + public val HTML: MediaType = MediaType("text/html")!! + public val JAVASCRIPT: MediaType = MediaType("text/javascript")!! + public val JPEG: MediaType = MediaType("image/jpeg")!! + public val JSON: MediaType = MediaType("application/json")!! + public val JSON_PROBLEM_DETAILS: MediaType = MediaType("application/problem+json")!! + public val JXL: MediaType = MediaType("image/jxl")!! public val LCP_LICENSE_DOCUMENT: MediaType = MediaType( - "application/vnd.readium.lcp.license.v1.0+json", - name = "LCP License", - fileExtension = "lcpl" - ) - public val LCP_PROTECTED_AUDIOBOOK: MediaType = MediaType( - "application/audiobook+lcp", - name = "LCP Protected Audiobook", - fileExtension = "lcpa" - ) - public val LCP_PROTECTED_PDF: MediaType = MediaType( - "application/pdf+lcp", - name = "LCP Protected PDF", - fileExtension = "lcpdf" - ) + "application/vnd.readium.lcp.license.v1.0+json" + )!! + public val LCP_PROTECTED_AUDIOBOOK: MediaType = MediaType("application/audiobook+lcp")!! + public val LCP_PROTECTED_PDF: MediaType = MediaType("application/pdf+lcp")!! public val LCP_STATUS_DOCUMENT: MediaType = MediaType( "application/vnd.readium.license.status.v1.0+json" - ) - public val LPF: MediaType = MediaType("application/lpf+zip", fileExtension = "lpf") - public val MP3: MediaType = MediaType("audio/mpeg", fileExtension = "mp3") - public val MPEG: MediaType = MediaType("video/mpeg", fileExtension = "mpeg") - public val NCX: MediaType = MediaType("application/x-dtbncx+xml", fileExtension = "ncx") - public val OGG: MediaType = MediaType("audio/ogg", fileExtension = "oga") - public val OGV: MediaType = MediaType("video/ogg", fileExtension = "ogv") - public val OPDS1: MediaType = MediaType("application/atom+xml;profile=opds-catalog") + )!! + public val LPF: MediaType = MediaType("application/lpf+zip")!! + public val MP3: MediaType = MediaType("audio/mpeg")!! + public val MPEG: MediaType = MediaType("video/mpeg")!! + public val NCX: MediaType = MediaType("application/x-dtbncx+xml")!! + public val OGG: MediaType = MediaType("audio/ogg")!! + public val OGV: MediaType = MediaType("video/ogg")!! + public val OPDS1: MediaType = MediaType("application/atom+xml;profile=opds-catalog")!! public val OPDS1_ENTRY: MediaType = MediaType( "application/atom+xml;type=entry;profile=opds-catalog" - ) - public val OPDS2: MediaType = MediaType("application/opds+json") - public val OPDS2_PUBLICATION: MediaType = MediaType("application/opds-publication+json") + )!! + public val OPDS2: MediaType = MediaType("application/opds+json")!! + public val OPDS2_PUBLICATION: MediaType = MediaType("application/opds-publication+json")!! public val OPDS_AUTHENTICATION: MediaType = MediaType( "application/opds-authentication+json" - ) - public val OPUS: MediaType = MediaType("audio/opus", fileExtension = "opus") - public val OTF: MediaType = MediaType("font/otf", fileExtension = "otf") - public val PDF: MediaType = MediaType( - "application/pdf", - name = "PDF", - fileExtension = "pdf" - ) - public val PNG: MediaType = MediaType("image/png", fileExtension = "png") - public val READIUM_AUDIOBOOK: MediaType = MediaType( - "application/audiobook+zip", - name = "Readium Audiobook", - fileExtension = "audiobook" - ) - public val READIUM_AUDIOBOOK_MANIFEST: MediaType = MediaType( - "application/audiobook+json", - name = "Readium Audiobook", - fileExtension = "json" - ) - public val READIUM_WEBPUB: MediaType = MediaType( - "application/webpub+zip", - name = "Readium Web Publication", - fileExtension = "webpub" - ) - public val READIUM_WEBPUB_MANIFEST: MediaType = MediaType( - "application/webpub+json", - name = "Readium Web Publication", - fileExtension = "json" - ) - public val SMIL: MediaType = MediaType("application/smil+xml", fileExtension = "smil") - public val SVG: MediaType = MediaType("image/svg+xml", fileExtension = "svg") - public val TEXT: MediaType = MediaType("text/plain", fileExtension = "txt") - public val TIFF: MediaType = MediaType("image/tiff", fileExtension = "tiff") - public val TTF: MediaType = MediaType("font/ttf", fileExtension = "ttf") - public val W3C_WPUB_MANIFEST: MediaType = MediaType( - "application/x.readium.w3c.wpub+json", - name = "Web Publication", - fileExtension = "json" - ) // non-existent - public val WAV: MediaType = MediaType("audio/wav", fileExtension = "wav") - public val WEBM_AUDIO: MediaType = MediaType("audio/webm", fileExtension = "webm") - public val WEBM_VIDEO: MediaType = MediaType("video/webm", fileExtension = "webm") - public val WEBP: MediaType = MediaType("image/webp", fileExtension = "webp") - public val WOFF: MediaType = MediaType("font/woff", fileExtension = "woff") - public val WOFF2: MediaType = MediaType("font/woff2", fileExtension = "woff2") - public val XHTML: MediaType = MediaType("application/xhtml+xml", fileExtension = "xhtml") - public val XML: MediaType = MediaType("application/xml", fileExtension = "xml") - public val ZAB: MediaType = MediaType( - "application/x.readium.zab+zip", - name = "Zipped Audio Book", - fileExtension = "zab" - ) // non-existent - public val ZIP: MediaType = MediaType("application/zip", fileExtension = "zip") + )!! + public val OPUS: MediaType = MediaType("audio/opus")!! + public val OTF: MediaType = MediaType("font/otf")!! + public val PDF: MediaType = MediaType("application/pdf")!! + public val PNG: MediaType = MediaType("image/png")!! + public val READIUM_AUDIOBOOK: MediaType = MediaType("application/audiobook+zip")!! + public val READIUM_AUDIOBOOK_MANIFEST: MediaType = MediaType("application/audiobook+json")!! + public val READIUM_WEBPUB: MediaType = MediaType("application/webpub+zip")!! + public val READIUM_WEBPUB_MANIFEST: MediaType = MediaType("application/webpub+json")!! + public val SMIL: MediaType = MediaType("application/smil+xml")!! + public val SVG: MediaType = MediaType("image/svg+xml")!! + public val TEXT: MediaType = MediaType("text/plain")!! + public val TIFF: MediaType = MediaType("image/tiff")!! + public val TTF: MediaType = MediaType("font/ttf")!! + public val W3C_WPUB_MANIFEST: MediaType = MediaType("application/x.readium.w3c.wpub+json")!! // non-existent + public val WAV: MediaType = MediaType("audio/wav")!! + public val WEBM_AUDIO: MediaType = MediaType("audio/webm")!! + public val WEBM_VIDEO: MediaType = MediaType("video/webm")!! + public val WEBP: MediaType = MediaType("image/webp")!! + public val WOFF: MediaType = MediaType("font/woff")!! + public val WOFF2: MediaType = MediaType("font/woff2")!! + public val XHTML: MediaType = MediaType("application/xhtml+xml")!! + public val XML: MediaType = MediaType("application/xml")!! + public val ZAB: MediaType = MediaType("application/x.readium.zab+zip")!! // non-existent + public val ZIP: MediaType = MediaType("application/zip")!! // Sniffing @@ -416,7 +349,8 @@ public class MediaType( * You can register additional sniffers globally by modifying this list. * The sniffers order is important, because some formats are subsets of other formats. */ - public val sniffers: MutableList = Sniffers.all.toMutableList() + @Deprecated(message = "Use FormatRegistry instead", level = DeprecationLevel.ERROR) + public val sniffers: MutableList = mutableListOf() /** * Resolves a format from a single file extension and media type hint, without checking the actual @@ -432,8 +366,7 @@ public class MediaType( @Suppress("UNUSED_PARAMETER") public fun of( mediaType: String? = null, - fileExtension: String? = null, - sniffers: List = MediaType.sniffers + fileExtension: String? = null ): MediaType? { TODO() } @@ -452,8 +385,7 @@ public class MediaType( @Suppress("UNUSED_PARAMETER") public fun of( mediaTypes: List, - fileExtensions: List, - sniffers: List = MediaType.sniffers + fileExtensions: List ): MediaType? { TODO() } @@ -470,8 +402,7 @@ public class MediaType( public fun ofFile( file: File, mediaType: String? = null, - fileExtension: String? = null, - sniffers: List = MediaType.sniffers + fileExtension: String? = null ): MediaType? { TODO() } @@ -490,8 +421,7 @@ public class MediaType( public fun ofFile( file: File, mediaTypes: List, - fileExtensions: List, - sniffers: List = MediaType.sniffers + fileExtensions: List ): MediaType? { TODO() } @@ -508,8 +438,7 @@ public class MediaType( public fun ofFile( path: String, mediaType: String? = null, - fileExtension: String? = null, - sniffers: List = MediaType.sniffers + fileExtension: String? = null ): MediaType? { TODO() } @@ -528,8 +457,7 @@ public class MediaType( public fun ofFile( path: String, mediaTypes: List, - fileExtensions: List, - sniffers: List = MediaType.sniffers + fileExtensions: List ): MediaType? { TODO() } @@ -546,8 +474,7 @@ public class MediaType( public fun ofBytes( bytes: () -> ByteArray, mediaType: String? = null, - fileExtension: String? = null, - sniffers: List = MediaType.sniffers + fileExtension: String? = null ): MediaType? { TODO() } @@ -566,8 +493,7 @@ public class MediaType( public fun ofBytes( bytes: () -> ByteArray, mediaTypes: List, - fileExtensions: List, - sniffers: List = MediaType.sniffers + fileExtensions: List ): MediaType? { TODO() } @@ -588,8 +514,7 @@ public class MediaType( uri: Uri, contentResolver: ContentResolver, mediaType: String? = null, - fileExtension: String? = null, - sniffers: List = MediaType.sniffers + fileExtension: String? = null ): MediaType? { TODO() } @@ -610,8 +535,7 @@ public class MediaType( uri: Uri, contentResolver: ContentResolver, mediaTypes: List, - fileExtensions: List, - sniffers: List = MediaType.sniffers + fileExtensions: List ): MediaType? { TODO() } @@ -672,8 +596,7 @@ public class MediaType( public fun of( file: File, mediaType: String? = null, - fileExtension: String? = null, - sniffers: List = MediaType.sniffers + fileExtension: String? = null ): MediaType? = null @Suppress("UNUSED_PARAMETER") @@ -681,8 +604,7 @@ public class MediaType( public fun of( file: File, mediaTypes: List, - fileExtensions: List, - sniffers: List = MediaType.sniffers + fileExtensions: List ): MediaType? = null @Suppress("UNUSED_PARAMETER") @@ -690,8 +612,7 @@ public class MediaType( public fun of( bytes: () -> ByteArray, mediaType: String? = null, - fileExtension: String? = null, - sniffers: List = MediaType.sniffers + fileExtension: String? = null ): MediaType? = null @Suppress("UNUSED_PARAMETER") @@ -699,8 +620,7 @@ public class MediaType( public fun of( bytes: () -> ByteArray, mediaTypes: List, - fileExtensions: List, - sniffers: List = MediaType.sniffers + fileExtensions: List ): MediaType? = null @Suppress("UNUSED_PARAMETER") @@ -709,8 +629,7 @@ public class MediaType( uri: Uri, contentResolver: ContentResolver, mediaType: String? = null, - fileExtension: String? = null, - sniffers: List = MediaType.sniffers + fileExtension: String? = null ): MediaType? = null @Suppress("UNUSED_PARAMETER") @@ -719,8 +638,7 @@ public class MediaType( uri: Uri, contentResolver: ContentResolver, mediaTypes: List, - fileExtensions: List, - sniffers: List = MediaType.sniffers + fileExtensions: List ): MediaType? = null } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeRetriever.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeRetriever.kt index 3c49cc7608..ecd6fa77db 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeRetriever.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeRetriever.kt @@ -6,17 +6,9 @@ package org.readium.r2.shared.util.mediatype -import android.content.ContentResolver -import android.net.Uri -import android.provider.MediaStore -import java.io.File -import org.readium.r2.shared.BuildConfig -import org.readium.r2.shared.extensions.queryProjection import org.readium.r2.shared.resource.* -import org.readium.r2.shared.util.Either -import org.readium.r2.shared.util.Url -import org.readium.r2.shared.util.toUrl +/* public class MediaTypeRetriever( resourceFactory: ResourceFactory = FileResourceFactory(), containerFactory: ContainerFactory = DirectoryContainerFactory(), @@ -276,3 +268,5 @@ public class MediaTypeRetriever( return null } } + + */ diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt new file mode 100644 index 0000000000..76779271ea --- /dev/null +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt @@ -0,0 +1,564 @@ +/* + * Copyright 2023 Readium Foundation. All rights reserved. + * Use of this source code is governed by the BSD-style license + * available in the top-level LICENSE file of the project. + */ + +package org.readium.r2.shared.util.mediatype + +import android.webkit.MimeTypeMap +import java.io.File +import java.net.URLConnection +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import org.readium.r2.shared.extensions.tryOrNull +import org.readium.r2.shared.publication.Link +import org.readium.r2.shared.publication.Manifest +import org.readium.r2.shared.publication.Publication + +public fun interface MediaTypeSniffer { + public suspend fun sniff(context: MediaTypeSnifferContext): MediaType? +} + +public open class CompositeMediaTypeSniffer( + private val sniffers: List +) : MediaTypeSniffer { + + override suspend fun sniff(context: MediaTypeSnifferContext): MediaType? = + sniffers.firstNotNullOfOrNull { it.sniff(context) } +} + +/** + * The default sniffer provided by Readium 2 to resolve a [MediaType]. + */ +public class DefaultMediaTypeSniffer : CompositeMediaTypeSniffer(MediaTypeSniffers.all) + +/** + * Default media type sniffers provided by Readium. + */ +public object MediaTypeSniffers { + + /** + * Sniffs an XHTML document. + * + * Must precede the HTML sniffer. + */ + public val xhtml: MediaTypeSniffer = MediaTypeSniffer { context -> + if ( + context.hasFileExtension("xht", "xhtml") || + context.hasMediaType("application/xhtml+xml") + ) { + return@MediaTypeSniffer MediaType.XHTML + } + + if (context !is ContentMediaTypeSnifferContext) { + return@MediaTypeSniffer null + } + + context.contentAsXml()?.let { + if ( + it.name.lowercase(Locale.ROOT) == "html" && + it.namespace.lowercase(Locale.ROOT).contains("xhtml") + ) { + return@MediaTypeSniffer MediaType.XHTML + } + } + return@MediaTypeSniffer null + } + + /** Sniffs an HTML document. */ + public val html: MediaTypeSniffer = MediaTypeSniffer { context -> + if ( + context.hasFileExtension("htm", "html") || + context.hasMediaType("text/html") + ) { + return@MediaTypeSniffer MediaType.HTML + } + + if (context !is ContentMediaTypeSnifferContext) { + return@MediaTypeSniffer null + } + + // [contentAsXml] will fail if the HTML is not a proper XML document, hence the doctype check. + if ( + context.contentAsXml()?.name?.lowercase(Locale.ROOT) == "html" || + context.contentAsString()?.trimStart()?.take(15)?.lowercase() == "" + ) { + return@MediaTypeSniffer MediaType.HTML + } + return@MediaTypeSniffer null + } + + /** Sniffs an OPDS document. */ + public val opds: MediaTypeSniffer = MediaTypeSniffer { context -> + // OPDS 1 + if (context.hasMediaType("application/atom+xml;type=entry;profile=opds-catalog")) { + return@MediaTypeSniffer MediaType.OPDS1_ENTRY + } + if (context.hasMediaType("application/atom+xml;profile=opds-catalog")) { + return@MediaTypeSniffer MediaType.OPDS1 + } + + // OPDS 2 + if (context.hasMediaType("application/opds+json")) { + return@MediaTypeSniffer MediaType.OPDS2 + } + if (context.hasMediaType("application/opds-publication+json")) { + return@MediaTypeSniffer MediaType.OPDS2_PUBLICATION + } + + // OPDS Authentication Document. + if (context.hasMediaType("application/opds-authentication+json") || context.hasMediaType( + "application/vnd.opds.authentication.v1.0+json" + ) + ) { + return@MediaTypeSniffer MediaType.OPDS_AUTHENTICATION + } + + if (context !is ContentMediaTypeSnifferContext) { + return@MediaTypeSniffer null + } + + // OPDS 1 + context.contentAsXml()?.let { xml -> + if (xml.namespace == "http://www.w3.org/2005/Atom") { + if (xml.name == "feed") { + return@MediaTypeSniffer MediaType.OPDS1 + } else if (xml.name == "entry") { + return@MediaTypeSniffer MediaType.OPDS1_ENTRY + } + } + } + + // OPDS 2 + context.contentAsRwpm()?.let { rwpm -> + if (rwpm.linkWithRel("self")?.mediaType?.matches("application/opds+json") == true) { + return@MediaTypeSniffer MediaType.OPDS2 + } + + /** + * Finds the first [Link] having a relation matching the given [predicate]. + */ + fun List.firstWithRelMatching(predicate: (String) -> Boolean): Link? = + firstOrNull { it.rels.any(predicate) } + + if (rwpm.links.firstWithRelMatching { it.startsWith("http://opds-spec.org/acquisition") } != null) { + return@MediaTypeSniffer MediaType.OPDS2_PUBLICATION + } + } + + // OPDS Authentication Document. + if (context.containsJsonKeys("id", "title", "authentication")) { + return@MediaTypeSniffer MediaType.OPDS_AUTHENTICATION + } + + return@MediaTypeSniffer null + } + + /** Sniffs an LCP License Document. */ + public val lcpLicense: MediaTypeSniffer = MediaTypeSniffer { context -> + if (context.hasFileExtension("lcpl") || context.hasMediaType( + "application/vnd.readium.lcp.license.v1.0+json" + ) + ) { + return@MediaTypeSniffer MediaType.LCP_LICENSE_DOCUMENT + } + + if (context !is ContentMediaTypeSnifferContext) { + return@MediaTypeSniffer null + } + + if (context.containsJsonKeys("id", "issued", "provider", "encryption")) { + return@MediaTypeSniffer MediaType.LCP_LICENSE_DOCUMENT + } + return@MediaTypeSniffer null + } + + /** Sniffs a bitmap image. */ + public val bitmap: MediaTypeSniffer = MediaTypeSniffer { context -> + if (context.hasFileExtension("avif") || context.hasMediaType("image/avif")) { + return@MediaTypeSniffer MediaType.AVIF + } + if (context.hasFileExtension("bmp", "dib") || context.hasMediaType( + "image/bmp", + "image/x-bmp" + ) + ) { + return@MediaTypeSniffer MediaType.BMP + } + if (context.hasFileExtension("gif") || context.hasMediaType("image/gif")) { + return@MediaTypeSniffer MediaType.GIF + } + if (context.hasFileExtension("jpg", "jpeg", "jpe", "jif", "jfif", "jfi") || context.hasMediaType( + "image/jpeg" + ) + ) { + return@MediaTypeSniffer MediaType.JPEG + } + if (context.hasFileExtension("jxl") || context.hasMediaType("image/jxl")) { + return@MediaTypeSniffer MediaType.JXL + } + if (context.hasFileExtension("png") || context.hasMediaType("image/png")) { + return@MediaTypeSniffer MediaType.PNG + } + if (context.hasFileExtension("tiff", "tif") || context.hasMediaType( + "image/tiff", + "image/tiff-fx" + ) + ) { + return@MediaTypeSniffer MediaType.TIFF + } + if (context.hasFileExtension("webp") || context.hasMediaType("image/webp")) { + return@MediaTypeSniffer MediaType.WEBP + } + return@MediaTypeSniffer null + } + + /** Sniffs a Readium Web Manifest. */ + public val webpubManifest: MediaTypeSniffer = MediaTypeSniffer { context -> + if (context.hasMediaType("application/audiobook+json")) { + return@MediaTypeSniffer MediaType.READIUM_AUDIOBOOK_MANIFEST + } + + if (context.hasMediaType("application/divina+json")) { + return@MediaTypeSniffer MediaType.DIVINA_MANIFEST + } + + if (context.hasMediaType("application/webpub+json")) { + return@MediaTypeSniffer MediaType.READIUM_WEBPUB_MANIFEST + } + + if (context !is ContentMediaTypeSnifferContext) { + return@MediaTypeSniffer null + } + + val manifest: Manifest = + context.contentAsRwpm() ?: return@MediaTypeSniffer null + + if (manifest.conformsTo(Publication.Profile.AUDIOBOOK)) { + return@MediaTypeSniffer MediaType.READIUM_AUDIOBOOK_MANIFEST + } + + if (manifest.conformsTo(Publication.Profile.DIVINA)) { + return@MediaTypeSniffer MediaType.DIVINA_MANIFEST + } + if (manifest.linkWithRel("self")?.mediaType?.matches("application/webpub+json") == true) { + return@MediaTypeSniffer MediaType.READIUM_WEBPUB_MANIFEST + } + + return@MediaTypeSniffer null + } + + /** Sniffs a Readium Web Publication, protected or not by LCP. */ + public val webpub: MediaTypeSniffer = MediaTypeSniffer { context -> + if (context.hasFileExtension("audiobook") || context.hasMediaType( + "application/audiobook+zip" + ) + ) { + return@MediaTypeSniffer MediaType.READIUM_AUDIOBOOK + } + + if (context.hasFileExtension("divina") || context.hasMediaType("application/divina+zip")) { + return@MediaTypeSniffer MediaType.DIVINA + } + + if (context.hasFileExtension("webpub") || context.hasMediaType("application/webpub+zip")) { + return@MediaTypeSniffer MediaType.READIUM_WEBPUB + } + + if (context.hasFileExtension("lcpa") || context.hasMediaType("application/audiobook+lcp")) { + return@MediaTypeSniffer MediaType.LCP_PROTECTED_AUDIOBOOK + } + if (context.hasFileExtension("lcpdf") || context.hasMediaType("application/pdf+lcp")) { + return@MediaTypeSniffer MediaType.LCP_PROTECTED_PDF + } + + if (context !is ContainerMediaTypeSnifferContext) { + return@MediaTypeSniffer null + } + + // Reads a RWPM from a manifest.json archive entry. + val manifest: Manifest? = + try { + context.read("manifest.json") + ?.let { Manifest.fromJSON(JSONObject(String(it))) } + } catch (e: Exception) { + null + } + + if (manifest != null) { + val isLcpProtected = context.contains("license.lcpl") + + if (manifest.conformsTo(Publication.Profile.AUDIOBOOK)) { + return@MediaTypeSniffer if (isLcpProtected) MediaType.LCP_PROTECTED_AUDIOBOOK else MediaType.READIUM_AUDIOBOOK + } + if (manifest.conformsTo(Publication.Profile.DIVINA)) { + return@MediaTypeSniffer MediaType.DIVINA + } + if (isLcpProtected && manifest.conformsTo(Publication.Profile.PDF)) { + return@MediaTypeSniffer MediaType.LCP_PROTECTED_PDF + } + if (manifest.linkWithRel("self")?.mediaType?.matches("application/webpub+json") == true) { + return@MediaTypeSniffer MediaType.READIUM_WEBPUB + } + } + + return@MediaTypeSniffer null + } + + /** Sniffs a W3C Web Publication Manifest. */ + public val w3cWPUB: MediaTypeSniffer = MediaTypeSniffer { context -> + if (context !is ContentMediaTypeSnifferContext) { + return@MediaTypeSniffer null + } + + // Somehow, [JSONObject] can't access JSON-LD keys such as `@context`. + val content = context.contentAsString() ?: "" + if (content.contains("@context") && content.contains("https://www.w3.org/ns/wp-context")) { + return@MediaTypeSniffer MediaType.W3C_WPUB_MANIFEST + } + + return@MediaTypeSniffer null + } + + /** + * Sniffs an EPUB publication. + * + * Reference: https://www.w3.org/publishing/epub3/epub-ocf.html#sec-zip-container-mime + */ + public val epub: MediaTypeSniffer = MediaTypeSniffer { context -> + if (context.hasFileExtension("epub") || context.hasMediaType("application/epub+zip")) { + return@MediaTypeSniffer MediaType.EPUB + } + + if (context !is ContainerMediaTypeSnifferContext) { + return@MediaTypeSniffer null + } + + val mimetype = context.read("mimetype") + ?.let { String(it, charset = Charsets.US_ASCII).trim() } + if (mimetype == "application/epub+zip") { + return@MediaTypeSniffer MediaType.EPUB + } + + return@MediaTypeSniffer null + } + + /** + * Sniffs a Lightweight Packaging Format (LPF). + * + * References: + * - https://www.w3.org/TR/lpf/ + * - https://www.w3.org/TR/pub-manifest/ + */ + public val lpf: MediaTypeSniffer = MediaTypeSniffer { context -> + if (context.hasFileExtension("lpf") || context.hasMediaType("application/lpf+zip")) { + return@MediaTypeSniffer MediaType.LPF + } + + if (context !is ContainerMediaTypeSnifferContext) { + return@MediaTypeSniffer null + } + + if (context.contains("index.html")) { + return@MediaTypeSniffer MediaType.LPF + } + + // Somehow, [JSONObject] can't access JSON-LD keys such as `@context`. + context.read("publication.json") + ?.let { String(it) } + ?.let { manifest -> + if (manifest.contains("@context") && manifest.contains( + "https://www.w3.org/ns/pub-context" + ) + ) { + return@MediaTypeSniffer MediaType.LPF + } + } + + return@MediaTypeSniffer null + } + + /** + * Authorized extensions for resources in a CBZ archive. + * Reference: https://wiki.mobileread.com/wiki/CBR_and_CBZ + */ + private val CBZ_EXTENSIONS = listOf( + // bitmap + "bmp", "dib", "gif", "jif", "jfi", "jfif", "jpg", "jpeg", "png", "tif", "tiff", "webp", + // metadata + "acbf", "xml" + ) + + /** + * Authorized extensions for resources in a ZAB archive (Zipped Audio Book). + */ + private val ZAB_EXTENSIONS = listOf( + // audio + "aac", "aiff", "alac", "flac", "m4a", "m4b", "mp3", "ogg", "oga", "mogg", "opus", "wav", "webm", + // playlist + "asx", "bio", "m3u", "m3u8", "pla", "pls", "smil", "vlc", "wpl", "xspf", "zpl" + ) + + /** + * Sniffs a simple Archive-based format, like Comic Book Archive or Zipped Audio Book. + * + * Reference: https://wiki.mobileread.com/wiki/CBR_and_CBZ + */ + public val archive: MediaTypeSniffer = MediaTypeSniffer { context -> + if (context.hasFileExtension("cbz") || context.hasMediaType( + "application/vnd.comicbook+zip", + "application/x-cbz", + "application/x-cbr" + ) + ) { + return@MediaTypeSniffer MediaType.CBZ + } + if (context.hasFileExtension("zab")) { + return@MediaTypeSniffer MediaType.ZAB + } + + if (context !is ContainerMediaTypeSnifferContext) { + return@MediaTypeSniffer null + } + + fun isIgnored(file: File): Boolean = + file.name.startsWith(".") || file.name == "Thumbs.db" + + suspend fun archiveContainsOnlyExtensions(fileExtensions: List): Boolean = + context.entries()?.all { path -> + val file = File(path) + isIgnored(file) || fileExtensions.contains(file.extension.lowercase(Locale.ROOT)) + } ?: false + + if (archiveContainsOnlyExtensions(CBZ_EXTENSIONS)) { + return@MediaTypeSniffer MediaType.CBZ + } + if (archiveContainsOnlyExtensions(ZAB_EXTENSIONS)) { + return@MediaTypeSniffer MediaType.ZAB + } + + return@MediaTypeSniffer null + } + + /** + * Sniffs a PDF document. + * + * Reference: https://www.loc.gov/preservation/digital/formats/fdd/fdd000123.shtml + */ + public val pdf: MediaTypeSniffer = MediaTypeSniffer { context -> + if (context.hasFileExtension("pdf") || context.hasMediaType("application/pdf")) { + return@MediaTypeSniffer MediaType.PDF + } + + if (context !is ContentMediaTypeSnifferContext) { + return@MediaTypeSniffer null + } + + if (context.read(0L until 5L)?.toString(Charsets.UTF_8) == "%PDF-") { + return@MediaTypeSniffer MediaType.PDF + } + + return@MediaTypeSniffer null + } + + /** Sniffs a JSON document. */ + public val json: MediaTypeSniffer = MediaTypeSniffer { context -> + if (context.hasMediaType("application/problem+json")) { + return@MediaTypeSniffer MediaType.JSON_PROBLEM_DETAILS + } + + if (context !is ContentMediaTypeSnifferContext) { + return@MediaTypeSniffer null + } + + if (context.contentAsJson() != null) { + return@MediaTypeSniffer MediaType.JSON + } + return@MediaTypeSniffer null + } + + /** Sniffs an XML document. */ + public val xml: MediaTypeSniffer = MediaTypeSniffer { context -> + if (context is ContentMediaTypeSnifferContext && context.contentAsXml() != null) { + return@MediaTypeSniffer MediaType.XML + } + return@MediaTypeSniffer null + } + + /** Sniffs a ZIP archive. */ + public val zip: MediaTypeSniffer = MediaTypeSniffer { context -> + if (context.hasMediaType("application/zip") && context is ContainerMediaTypeSnifferContext) { + return@MediaTypeSniffer MediaType.ZIP + } + return@MediaTypeSniffer null + } + + /** + * Sniffs the system-wide registered media types using [MimeTypeMap] and + * [URLConnection.guessContentTypeFromStream]. + */ + public fun system(excluded: List): MediaTypeSniffer = MediaTypeSniffer { context -> + val mimetypes = tryOrNull { MimeTypeMap.getSingleton() } + ?: return@MediaTypeSniffer null + + fun sniffExtension(extension: String): MediaType? = + mimetypes.getMimeTypeFromExtension(extension) + ?.let { MediaType(it) } + ?.takeUnless { it in excluded } + + fun sniffType(type: String): MediaType? { + val extension = mimetypes.getExtensionFromMimeType(type) + ?: return null + val preferredType = mimetypes.getMimeTypeFromExtension(extension) + ?: return null + return MediaType(preferredType) + .takeUnless { it in excluded } + } + + for (mediaType in context.hints.mediaTypes) { + return@MediaTypeSniffer sniffType(mediaType.toString()) ?: continue + } + + for (extension in context.hints.fileExtensions) { + return@MediaTypeSniffer sniffExtension(extension) ?: continue + } + + if (context !is ContentMediaTypeSnifferContext) { + return@MediaTypeSniffer null + } + + return@MediaTypeSniffer withContext(Dispatchers.IO) { + context.contentAsStream() + .let { URLConnection.guessContentTypeFromStream(it) } + ?.let { sniffType(it) } + } + } + + /** + * The default sniffers provided by Readium 2 for all known formats. + * The sniffers order is important, because some formats are subsets of other formats. + */ + public val all: List = listOf( + xhtml, + html, + opds, + lcpLicense, + bitmap, + webpubManifest, + webpub, + w3cWPUB, + epub, + lpf, + archive, + pdf, + json, + xml, + zip, + // Note: We exclude JSON, XML or ZIP formats otherwise they will be detected during the + // light sniffing step and bypass the RWPM or EPUB heavy sniffing. + system(excluded = listOf(MediaType.JSON, MediaType.XML, MediaType.ZIP)) + ) +} diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContext.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContext.kt new file mode 100644 index 0000000000..95678686eb --- /dev/null +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContext.kt @@ -0,0 +1,168 @@ +package org.readium.r2.shared.util.mediatype + +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.nio.charset.Charset +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import org.readium.r2.shared.extensions.read +import org.readium.r2.shared.extensions.tryOrNull +import org.readium.r2.shared.format.FormatHints +import org.readium.r2.shared.parser.xml.ElementNode +import org.readium.r2.shared.parser.xml.XmlParser +import org.readium.r2.shared.publication.Manifest +import org.readium.r2.shared.util.SuspendingCloseable + +public interface MediaTypeSnifferContext : SuspendingCloseable { + /** Format hints. */ + public val hints: FormatHints +} + +/** Finds the first [Charset] declared in the media types' `charset` parameter. */ +public val MediaTypeSnifferContext.charset: Charset? get() = + hints.mediaTypes.firstNotNullOfOrNull { it.charset } + +/** Returns whether this context has any of the given file extensions, ignoring case. */ +public fun MediaTypeSnifferContext.hasFileExtension(vararg fileExtensions: String): Boolean { + for (fileExtension in fileExtensions.map { it.lowercase() }) { + if (hints.fileExtensions.contains(fileExtension.lowercase(Locale.ROOT))) { + return true + } + } + return false +} + +/** + * Returns whether this context has any of the given media type, ignoring case and extra + * parameters. + * + * Implementation note: Use [MediaType] to handle the comparison to avoid edge cases. + */ +public fun MediaTypeSnifferContext.hasMediaType(vararg mediaTypes: String): Boolean { + @Suppress("NAME_SHADOWING") + val mediaTypes = mediaTypes.mapNotNull { MediaType(it) } + for (mediaType in mediaTypes) { + if (hints.mediaTypes.any { mediaType.contains(it) }) { + return true + } + } + return false +} + +public interface ContentMediaTypeSnifferContext : MediaTypeSnifferContext { + + /** + * Reads all the bytes or the given [range]. + * + * It can be used to check a file signature, aka magic number. + * See https://en.wikipedia.org/wiki/List_of_file_signatures + */ + public suspend fun read(range: LongRange? = null): ByteArray? + + /** + * Content as plain text. + * + * It will extract the charset parameter from the media type hints to figure out an encoding. + * Otherwise, fallback on UTF-8. + */ + public suspend fun contentAsString(): String? = + read()?.let { + tryOrNull { + withContext(Dispatchers.Default) { + String(it, charset = charset ?: Charsets.UTF_8) + } + } + } + + /** Content as an XML document. */ + public suspend fun contentAsXml(): ElementNode? = + read()?.let { + tryOrNull { + withContext(Dispatchers.Default) { + XmlParser().parse(ByteArrayInputStream(it)) + } + } + } + + /** + * Content parsed from JSON. + */ + public suspend fun contentAsJson(): JSONObject? = + contentAsString()?.let { + tryOrNull { + withContext(Dispatchers.Default) { + JSONObject(it) + } + } + } + + /** Readium Web Publication Manifest parsed from the content. */ + public suspend fun contentAsRwpm(): Manifest? = + Manifest.fromJSON(contentAsJson()) + + /** + * Raw bytes stream of the content. + * + * A byte stream can be useful when sniffers only need to read a few bytes at the beginning of + * the file. + */ + public suspend fun contentAsStream(): InputStream = + ByteArrayInputStream(read() ?: ByteArray(0)) +} + +/** + * Returns whether the content is a JSON object containing all of the given root keys. + */ +public suspend fun ContentMediaTypeSnifferContext.containsJsonKeys(vararg keys: String): Boolean { + val json = contentAsJson() ?: return false + return json.keys().asSequence().toSet().containsAll(keys.toList()) +} + +public interface ContainerMediaTypeSnifferContext : MediaTypeSnifferContext { + /** + * Returns all the known entry paths in the container. + */ + public suspend fun entries(): Set? + + /** + * Returns the entry data at the given [path] in this container. + */ + public suspend fun read(path: String): ByteArray? +} + +/** + * Returns whether an entry exists in the container. + */ +public suspend fun ContainerMediaTypeSnifferContext.contains(path: String): Boolean = + entries()?.contains(path) + ?: (read(path) != null) + +public class HintMediaTypeSnifferContext( + override val hints: FormatHints +) : MediaTypeSnifferContext { + override suspend fun close() {} +} + +public class BytesContentMediaTypeSnifferContext( + override val hints: FormatHints = FormatHints(), + bytes: suspend () -> ByteArray +) : ContentMediaTypeSnifferContext { + + private val bytesFactory = bytes + private lateinit var _bytes: ByteArray + + private suspend fun bytes(): ByteArray { + if (::_bytes.isInitialized) { + return _bytes + } + _bytes = bytesFactory() + return _bytes + } + + override suspend fun read(range: LongRange?): ByteArray = + bytes().read(range) + + override suspend fun close() {} +} diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Sniffer.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Sniffer.kt index bb16aa2074..e463cef236 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Sniffer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Sniffer.kt @@ -6,6 +6,11 @@ package org.readium.r2.shared.util.mediatype +@Deprecated(message = "Use MediaTypeSniffer instead", level = DeprecationLevel.ERROR) +public typealias Sniffer = MediaTypeSniffer + +/* + import android.webkit.MimeTypeMap import java.io.File import java.net.URLConnection @@ -15,7 +20,6 @@ import kotlinx.coroutines.withContext import org.json.JSONObject import org.readium.r2.shared.extensions.tryOrNull import org.readium.r2.shared.publication.* - /** * Determines if the provided content matches a known media type. * @@ -517,3 +521,6 @@ public object Sniffers { */ private fun List.firstWithRelMatching(predicate: (String) -> Boolean): Link? = firstOrNull { it.rels.any(predicate) } + + + */ diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/SnifferContext.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/SnifferContext.kt index dfbda8151b..4249ac1405 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/SnifferContext.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/SnifferContext.kt @@ -6,6 +6,7 @@ package org.readium.r2.shared.util.mediatype +/* import java.io.File import java.io.InputStream import java.nio.charset.Charset @@ -27,7 +28,7 @@ public sealed class SnifferContext( ) { /** Media type hints. */ public val mediaTypes: List = mediaTypes - .mapNotNull { MediaType.parse(it) } + .mapNotNull { MediaType(it) } /** File extension hints. */ public val fileExtensions: List = fileExtensions @@ -55,7 +56,7 @@ public sealed class SnifferContext( */ public fun hasMediaType(vararg mediaTypes: String): Boolean { @Suppress("NAME_SHADOWING") - val mediaTypes = mediaTypes.mapNotNull { MediaType.parse(it) } + val mediaTypes = mediaTypes.mapNotNull { MediaType(it) } for (mediaType in mediaTypes) { if (this.mediaTypes.any { mediaType.contains(it) }) { return true @@ -308,3 +309,4 @@ internal class BytesSnifferContextFactory( ) } } + */ diff --git a/readium/streamer/src/main/java/org/readium/r2/streamer/ParserAssetFactory.kt b/readium/streamer/src/main/java/org/readium/r2/streamer/ParserAssetFactory.kt index bc69d4deb5..2a16c927f4 100644 --- a/readium/streamer/src/main/java/org/readium/r2/streamer/ParserAssetFactory.kt +++ b/readium/streamer/src/main/java/org/readium/r2/streamer/ParserAssetFactory.kt @@ -35,9 +35,9 @@ internal class ParserAssetFactory( ): Try { return when (asset) { is Asset.Container -> - createParserAssetForContainer(asset.container, asset.mediaType) + createParserAssetForContainer(asset.container, asset.format.mediaType) is Asset.Resource -> - createParserAssetForResource(asset.resource, asset.mediaType) + createParserAssetForResource(asset.resource, asset.format.mediaType) } } diff --git a/readium/streamer/src/main/java/org/readium/r2/streamer/PublicationFactory.kt b/readium/streamer/src/main/java/org/readium/r2/streamer/PublicationFactory.kt index baea40ce60..1c4829dd4e 100644 --- a/readium/streamer/src/main/java/org/readium/r2/streamer/PublicationFactory.kt +++ b/readium/streamer/src/main/java/org/readium/r2/streamer/PublicationFactory.kt @@ -16,7 +16,6 @@ import org.readium.r2.shared.publication.protection.AdeptFallbackContentProtecti import org.readium.r2.shared.publication.protection.ContentProtection import org.readium.r2.shared.publication.protection.LcpFallbackContentProtection import org.readium.r2.shared.resource.Resource -import org.readium.r2.shared.util.http.DefaultHttpClient import org.readium.r2.shared.util.http.HttpClient import org.readium.r2.shared.util.logging.WarningLogger import org.readium.r2.shared.util.pdf.PdfDocumentFactory @@ -38,22 +37,22 @@ internal typealias PublicationTry = Try? = null, + contentProtections: List = emptyList(), parsers: List = emptyList(), ignoreDefaultParsers: Boolean = false, - contentProtections: List = emptyList(), - pdfFactory: PdfDocumentFactory<*>? = null, - httpClient: HttpClient = DefaultHttpClient(), private val onCreatePublication: Publication.Builder.() -> Unit = {} ) { diff --git a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt index 7d343005b3..4148e22e1b 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt @@ -14,6 +14,7 @@ import org.readium.r2.shared.ExperimentalReadiumApi import org.readium.r2.shared.UserException import org.readium.r2.shared.asset.AssetRetriever import org.readium.r2.shared.error.Try +import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.publication.protection.ContentProtectionSchemeRetriever import org.readium.r2.shared.resource.CompositeArchiveFactory import org.readium.r2.shared.resource.CompositeResourceFactory @@ -24,8 +25,6 @@ import org.readium.r2.shared.resource.FileResourceFactory import org.readium.r2.shared.util.archive.channel.ChannelZipArchiveFactory import org.readium.r2.shared.util.http.DefaultHttpClient import org.readium.r2.shared.util.http.HttpResourceFactory -import org.readium.r2.shared.util.mediatype.MediaType -import org.readium.r2.shared.util.mediatype.MediaTypeRetriever import org.readium.r2.streamer.PublicationFactory /** @@ -33,7 +32,11 @@ import org.readium.r2.streamer.PublicationFactory */ class Readium(context: Context) { - private val httpClient = DefaultHttpClient() + private val formatRegistry = FormatRegistry() + + val httpClient = DefaultHttpClient( + formatRegistry = formatRegistry + ) private val archiveFactory = CompositeArchiveFactory( DefaultArchiveFactory(), @@ -41,34 +44,29 @@ class Readium(context: Context) { ) private val resourceFactory = CompositeResourceFactory( - FileResourceFactory(), + FileResourceFactory(formatRegistry), CompositeResourceFactory( ContentResourceFactory(context.contentResolver), HttpResourceFactory(httpClient) ) ) - private val containerFactory = DirectoryContainerFactory() - - private val mediaTypeRetriever = MediaTypeRetriever( - resourceFactory, - containerFactory, - archiveFactory + private val containerFactory = DirectoryContainerFactory( + formatRegistry ) val assetRetriever = AssetRetriever( + formatRegistry, resourceFactory, containerFactory, - archiveFactory, - context.contentResolver, - MediaType.sniffers + archiveFactory ) /** * The LCP service decrypts LCP-protected publication and acquire publications from a * license file. */ - val lcpService = LcpService(context, mediaTypeRetriever, resourceFactory, archiveFactory) + val lcpService = LcpService(context, assetRetriever, formatRegistry) ?.let { Try.success(it) } ?: Try.failure(UserException("liblcp is missing on the classpath")) @@ -83,9 +81,10 @@ class Readium(context: Context) { */ val publicationFactory = PublicationFactory( context, - contentProtections = contentProtections, + httpClient = httpClient, // Only required if you want to support PDF files using the PDFium adapter. - pdfFactory = PdfiumDocumentFactory(context) + pdfFactory = PdfiumDocumentFactory(context), + contentProtections = contentProtections ) } diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt index 196fe2177f..5e474dec92 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt @@ -195,7 +195,7 @@ class BookRepository( suspend fun addRemoteBook( url: Url ): Try { - val asset = assetRetriever.retrieve(url, fileExtension = url.extension) + val asset = assetRetriever.retrieve(url) ?: return Try.failure( ImportError.PublicationError( PublicationError.UnsupportedPublication( @@ -236,7 +236,7 @@ class BookRepository( ) val (publicationTempFile, publicationTempAsset) = - if (sourceAsset.mediaType != MediaType.LCP_LICENSE_DOCUMENT) { + if (sourceAsset.format.mediaType != MediaType.LCP_LICENSE_DOCUMENT) { tempFile to sourceAsset } else { lcpService @@ -263,7 +263,7 @@ class BookRepository( ) } - val fileName = "${UUID.randomUUID()}.${publicationTempAsset.mediaType.fileExtension}" + val fileName = "${UUID.randomUUID()}.${publicationTempAsset.format.fileExtension}" val libraryFile = File(storageDir, fileName) val libraryUrl = libraryFile.toUrl() @@ -277,8 +277,8 @@ class BookRepository( val libraryAsset = assetRetriever.retrieve( libraryUrl, - publicationTempAsset.mediaType, - publicationTempAsset.assetType + publicationTempAsset.format.mediaType, + publicationTempAsset.type ).getOrElse { return Try.failure(ImportError.PublicationError(it)) } return addBook( @@ -315,8 +315,8 @@ class BookRepository( val id = insertBookIntoDatabase( url.toString(), - asset.mediaType, - asset.assetType, + asset.format.mediaType, + asset.type, drmScheme, publication, coverFile.path diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListViewModel.kt index 81dc0c94ae..c10cadae18 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListViewModel.kt @@ -6,7 +6,6 @@ package org.readium.r2.testapp.catalogs -import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import java.net.URL @@ -17,14 +16,14 @@ import org.readium.r2.opds.OPDS1Parser import org.readium.r2.opds.OPDS2Parser import org.readium.r2.shared.error.Try import org.readium.r2.shared.opds.ParseData -import org.readium.r2.shared.util.http.DefaultHttpClient import org.readium.r2.shared.util.http.HttpRequest import org.readium.r2.shared.util.http.fetchWithDecoder +import org.readium.r2.testapp.Application import org.readium.r2.testapp.db.BookDatabase import org.readium.r2.testapp.domain.model.Catalog import org.readium.r2.testapp.utils.EventChannel -class CatalogFeedListViewModel(application: Application) : AndroidViewModel(application) { +class CatalogFeedListViewModel(private val application: Application) : AndroidViewModel(application) { private val catalogDao = BookDatabase.getDatabase(application).catalogDao() private val repository = CatalogRepository(catalogDao) @@ -56,7 +55,7 @@ class CatalogFeedListViewModel(application: Application) : AndroidViewModel(appl } private suspend fun parseURL(url: URL): Try { - return DefaultHttpClient().fetchWithDecoder(HttpRequest(url.toString())) { + return application.readium.httpClient.fetchWithDecoder(HttpRequest(url.toString())) { val result = it.body if (isJson(result)) { OPDS2Parser.parse(result, url) diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt index 7aa15b19fb..3c125a636d 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt @@ -6,7 +6,6 @@ package org.readium.r2.testapp.catalogs -import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import java.io.File @@ -24,15 +23,13 @@ import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.publication.opds.images import org.readium.r2.shared.util.http.HttpRequest import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.testapp.Application import org.readium.r2.testapp.domain.model.Catalog import org.readium.r2.testapp.utils.EventChannel import org.readium.r2.testapp.utils.extensions.downloadTo import timber.log.Timber -class CatalogViewModel(application: Application) : AndroidViewModel(application) { - - private val app get() = - getApplication() +class CatalogViewModel(private val application: Application) : AndroidViewModel(application) { val detailChannel = EventChannel(Channel(Channel.BUFFERED), viewModelScope) val eventChannel = EventChannel(Channel(Channel.BUFFERED), viewModelScope) @@ -44,9 +41,9 @@ class CatalogViewModel(application: Application) : AndroidViewModel(application) val request = HttpRequest(it) try { parseRequest = if (catalog.type == 1) { - OPDS1Parser.parseRequest(request) + OPDS1Parser.parseRequest(request, application.readium.httpClient) } else { - OPDS2Parser.parseRequest(request) + OPDS2Parser.parseRequest(request, application.readium.httpClient) } } catch (e: MalformedURLException) { eventChannel.send(Event.FeedEvent.CatalogParseFailed) @@ -63,14 +60,18 @@ class CatalogViewModel(application: Application) : AndroidViewModel(application) fun downloadPublication(publication: Publication) = viewModelScope.launch { val filename = UUID.randomUUID().toString() - val dest = File(app.storageDir, filename) + val dest = File(application.storageDir, filename) getDownloadURL(publication) .flatMap { url -> - url.downloadTo(dest) + url.downloadTo( + dest, + httpClient = application.readium.httpClient, + assetRetriever = application.readium.assetRetriever + ) }.flatMap { val opdsCover = publication.images.firstOrNull()?.href - app.bookRepository.addLocalBook(dest, opdsCover) + application.bookRepository.addLocalBook(dest, opdsCover) }.onSuccess { detailChannel.send(Event.DetailEvent.ImportPublicationSuccess) }.onFailure { diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/model/Book.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/model/Book.kt index 7351d93473..42206cb07d 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/model/Book.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/model/Book.kt @@ -67,7 +67,7 @@ data class Book( ) val mediaType: MediaType get() = - MediaType(rawMediaType) + MediaType(rawMediaType) ?: MediaType.BINARY val drmScheme: ContentProtection.Scheme? get() = drm?.let { ContentProtection.Scheme(it) } diff --git a/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/File.kt b/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/File.kt index 6e03911db1..608d35d392 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/File.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/File.kt @@ -17,11 +17,15 @@ import java.net.URL import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext +import org.readium.r2.shared.asset.AssetRetriever import org.readium.r2.shared.error.Try import org.readium.r2.shared.error.flatMap -import org.readium.r2.shared.util.http.* +import org.readium.r2.shared.util.http.HttpClient +import org.readium.r2.shared.util.http.HttpException +import org.readium.r2.shared.util.http.HttpRequest +import org.readium.r2.shared.util.http.HttpResponse +import org.readium.r2.shared.util.http.HttpTry import org.readium.r2.shared.util.mediatype.MediaType -import org.readium.r2.shared.util.mediatype.MediaTypeRetriever import org.readium.r2.testapp.BuildConfig import timber.log.Timber @@ -42,7 +46,9 @@ fun File.listFilesSafely(filter: FileFilter? = null): List { suspend fun URL.downloadTo( dest: File, - maxRedirections: Int = 2 + maxRedirections: Int = 2, + httpClient: HttpClient, + assetRetriever: AssetRetriever ): Try { if (maxRedirections == 0) { return Try.Failure(Exception("Too many HTTP redirections.")) @@ -51,7 +57,7 @@ suspend fun URL.downloadTo( val urlString = toString() if (BuildConfig.DEBUG) Timber.i("download url $urlString") - return DefaultHttpClient().download(HttpRequest(toString()), dest, MediaTypeRetriever()) + return httpClient.download(HttpRequest(toString()), dest, assetRetriever) .flatMap { try { if (BuildConfig.DEBUG) Timber.i("response url ${it.url}") @@ -59,7 +65,7 @@ suspend fun URL.downloadTo( if (urlString == it.url) { Try.success(Unit) } else { - URL(it.url).downloadTo(dest, maxRedirections - 1) + URL(it.url).downloadTo(dest, maxRedirections - 1, httpClient, assetRetriever) } } catch (e: Exception) { Try.failure(e) @@ -70,7 +76,7 @@ suspend fun URL.downloadTo( private suspend fun HttpClient.download( request: HttpRequest, destination: File, - mediaTypeRetriever: MediaTypeRetriever + assetRetriever: AssetRetriever ): HttpTry = try { stream(request).flatMap { res -> @@ -89,9 +95,9 @@ private suspend fun HttpClient.download( } var response = res.response if (response.mediaType.matches(MediaType.BINARY)) { - response = response.copy( - mediaType = mediaTypeRetriever.retrieve(destination) ?: response.mediaType - ) + assetRetriever.retrieve(destination)?.format?.mediaType?.let { + response = response.copy(mediaType = it) + } } Try.success(response) } From 2cfc2e7dd7a56e6b1bab667199fbed4cc2e22b5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Fri, 18 Aug 2023 16:29:13 +0200 Subject: [PATCH 02/24] Implement the media type in resources thanks to the format registry --- .../readium/r2/shared/asset/AssetRetriever.kt | 2 +- .../r2/shared/resource/DefaultArchiveFactory.kt | 7 +++++-- .../readium/r2/shared/resource/MediaTypeExt.kt | 2 +- .../readium/r2/shared/resource/ZipContainer.kt | 13 ++++++++----- .../util/archive/channel/ChannelZipContainer.kt | 16 ++++++++++------ .../main/java/org/readium/r2/testapp/Readium.kt | 4 ++-- 6 files changed, 27 insertions(+), 17 deletions(-) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt b/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt index f0154c9115..c3a190a93f 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt @@ -43,7 +43,7 @@ public class AssetRetriever( formatRegistry = formatRegistry, resourceFactory = FileResourceFactory(formatRegistry), containerFactory = DirectoryContainerFactory(formatRegistry), - archiveFactory = DefaultArchiveFactory() + archiveFactory = DefaultArchiveFactory(formatRegistry) ) } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/DefaultArchiveFactory.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/DefaultArchiveFactory.kt index dad5d6def9..86074a39d7 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/DefaultArchiveFactory.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/DefaultArchiveFactory.kt @@ -13,9 +13,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.readium.r2.shared.error.MessageError import org.readium.r2.shared.error.Try +import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.util.toFile -public class DefaultArchiveFactory : ArchiveFactory { +public class DefaultArchiveFactory( + private val formatRegistry: FormatRegistry +) : ArchiveFactory { override suspend fun create(resource: Resource, password: String?): Try { if (password != null) { @@ -35,7 +38,7 @@ public class DefaultArchiveFactory : ArchiveFactory { internal suspend fun open(file: File): Try = withContext(Dispatchers.IO) { try { - val archive = JavaZipContainer(ZipFile(file), file) + val archive = JavaZipContainer(ZipFile(file), file, formatRegistry) Try.success(archive) } catch (e: ZipException) { Try.failure(ArchiveFactory.Error.FormatNotSupported(e)) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt index c87084e7a6..3423f5fe6e 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt @@ -19,7 +19,7 @@ public class ResourceMediaTypeSnifferContext( public class ContainerMediaTypeSnifferContext( private val container: Container, - override val hints: FormatHints + override val hints: FormatHints = FormatHints() ) : BaseContainerMediaTypeSnifferContext { override suspend fun entries(): Set? = diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt index 307183b550..ee6a269e82 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt @@ -22,6 +22,7 @@ import org.readium.r2.shared.extensions.optNullableLong import org.readium.r2.shared.extensions.readFully import org.readium.r2.shared.extensions.toMap import org.readium.r2.shared.extensions.tryOrLog +import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.io.CountingInputStream import org.readium.r2.shared.util.mediatype.MediaType @@ -93,7 +94,11 @@ public var Resource.Properties.Builder.archive: ArchiveProperties? } } -internal class JavaZipContainer(private val archive: ZipFile, file: File) : ZipContainer { +internal class JavaZipContainer( + private val archive: ZipFile, + file: File, + private val formatRegistry: FormatRegistry +) : ZipContainer { private inner class FailureEntry(override val path: String) : ZipContainer.Entry { @@ -101,9 +106,8 @@ internal class JavaZipContainer(private val archive: ZipFile, file: File) : ZipC override val source: Url? = null - // FIXME: Implement with a sniffer. override suspend fun mediaType(): ResourceTry = - Try.success(null) + Try.success(formatRegistry.retrieve(ResourceMediaTypeSnifferContext(this))?.mediaType) override suspend fun properties(): ResourceTry = Try.failure(Resource.Exception.NotFound()) @@ -125,9 +129,8 @@ internal class JavaZipContainer(private val archive: ZipFile, file: File) : ZipC override val source: Url? = null - // FIXME: Implement with a sniffer. override suspend fun mediaType(): ResourceTry = - Try.success(null) + Try.success(formatRegistry.retrieve(ResourceMediaTypeSnifferContext(this))?.mediaType) override suspend fun properties(): ResourceTry = ResourceTry.success( diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt index 0009d93428..10e7162461 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt @@ -14,11 +14,13 @@ import org.readium.r2.shared.error.getOrElse import org.readium.r2.shared.extensions.addPrefix import org.readium.r2.shared.extensions.readFully import org.readium.r2.shared.extensions.tryOrLog +import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.resource.ArchiveFactory import org.readium.r2.shared.resource.ArchiveProperties import org.readium.r2.shared.resource.Container import org.readium.r2.shared.resource.FailureResource import org.readium.r2.shared.resource.Resource +import org.readium.r2.shared.resource.ResourceMediaTypeSnifferContext import org.readium.r2.shared.resource.ResourceTry import org.readium.r2.shared.resource.ZipContainer import org.readium.r2.shared.resource.archive @@ -30,7 +32,8 @@ import org.readium.r2.shared.util.io.CountingInputStream import org.readium.r2.shared.util.mediatype.MediaType internal class ChannelZipContainer( - private val archive: ZipFile + private val archive: ZipFile, + private val formatRegistry: FormatRegistry ) : ZipContainer { private inner class FailureEntry( @@ -57,9 +60,8 @@ internal class ChannelZipContainer( } ) - // FIXME: Implement with a sniffer. override suspend fun mediaType(): ResourceTry = - ResourceTry.success(null) + Try.success(formatRegistry.retrieve(ResourceMediaTypeSnifferContext(this))?.mediaType) override suspend fun length(): ResourceTry = entry.size.takeUnless { it == -1L } @@ -160,7 +162,9 @@ internal class ChannelZipContainer( /** * An [ArchiveFactory] able to open a ZIP archive served through an HTTP server. */ -public class ChannelZipArchiveFactory : ArchiveFactory { +public class ChannelZipArchiveFactory( + private val formatRegistry: FormatRegistry +) : ArchiveFactory { override suspend fun create( resource: Resource, @@ -174,7 +178,7 @@ public class ChannelZipArchiveFactory : ArchiveFactory { val resourceChannel = ResourceChannel(resource) val channel = wrapBaseChannel(resourceChannel) val zipFile = ZipFile(channel, true) - val channelZip = ChannelZipContainer(zipFile) + val channelZip = ChannelZipContainer(zipFile, formatRegistry) Try.success(channelZip) } catch (e: Resource.Exception) { Try.failure(ArchiveFactory.Error.ResourceReading(e)) @@ -186,7 +190,7 @@ public class ChannelZipArchiveFactory : ArchiveFactory { internal fun openFile(file: File): Container { val fileChannel = FileChannelAdapter(file, "r") val channel = wrapBaseChannel(fileChannel) - return ChannelZipContainer(ZipFile(channel)) + return ChannelZipContainer(ZipFile(channel), formatRegistry) } private fun wrapBaseChannel(channel: SeekableByteChannel): SeekableByteChannel { diff --git a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt index 4148e22e1b..a7dd9a03bf 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt @@ -39,8 +39,8 @@ class Readium(context: Context) { ) private val archiveFactory = CompositeArchiveFactory( - DefaultArchiveFactory(), - ChannelZipArchiveFactory() + DefaultArchiveFactory(formatRegistry), + ChannelZipArchiveFactory(formatRegistry) ) private val resourceFactory = CompositeResourceFactory( From de0a9e14673c881aba5521f88887b7e978938cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Fri, 18 Aug 2023 18:02:12 +0200 Subject: [PATCH 03/24] Optimization --- .../readium/r2/lcp/LcpContentProtection.kt | 2 +- .../readium/r2/shared/asset/AssetRetriever.kt | 56 ++++++-- .../org/readium/r2/shared/format/Format.kt | 31 ++++- .../r2/shared/resource/FileResource.kt | 12 +- .../r2/shared/resource/ZipContainer.kt | 19 ++- .../archive/channel/ChannelZipContainer.kt | 10 +- .../shared/util/mediatype/MediaTypeSniffer.kt | 125 ++++++++++++------ .../java/org/readium/r2/testapp/Readium.kt | 3 +- .../r2/testapp/utils/extensions/Uri.kt | 32 ++++- 9 files changed, 225 insertions(+), 65 deletions(-) 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 a34db85408..f79cea44be 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 @@ -90,7 +90,7 @@ internal class LcpContentProtection( ?: lcpService.retrieveLicense(asset, authentication, allowUserInteraction, sender) } - private suspend fun createResultAsset( + private fun createResultAsset( asset: Asset.Container, license: Try ): Try { diff --git a/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt b/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt index c3a190a93f..53887f1bb1 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt @@ -6,12 +6,16 @@ package org.readium.r2.shared.asset +import android.content.ContentResolver +import android.content.Context import android.net.Uri +import android.provider.MediaStore import java.io.File import org.readium.r2.shared.error.ThrowableError import org.readium.r2.shared.error.Try import org.readium.r2.shared.error.flatMap import org.readium.r2.shared.error.getOrElse +import org.readium.r2.shared.extensions.queryProjection import org.readium.r2.shared.format.Format import org.readium.r2.shared.format.FormatHints import org.readium.r2.shared.format.FormatRegistry @@ -33,17 +37,19 @@ public class AssetRetriever( private val formatRegistry: FormatRegistry, private val resourceFactory: ResourceFactory, private val containerFactory: ContainerFactory, - private val archiveFactory: ArchiveFactory + private val archiveFactory: ArchiveFactory, + private val contentResolver: ContentResolver ) { public companion object { - public operator fun invoke(): AssetRetriever { + public operator fun invoke(context: Context): AssetRetriever { val formatRegistry = FormatRegistry() return AssetRetriever( formatRegistry = formatRegistry, resourceFactory = FileResourceFactory(formatRegistry), containerFactory = DirectoryContainerFactory(formatRegistry), - archiveFactory = DefaultArchiveFactory(formatRegistry) + archiveFactory = DefaultArchiveFactory(formatRegistry), + contentResolver = context.contentResolver ) } } @@ -275,9 +281,41 @@ public class AssetRetriever( url: Url, hints: FormatHints = FormatHints() ): Asset? { - @Suppress("NAME_SHADOWING") - val hints = - hints + FormatHints(fileExtension = url.extension) + val allHints = FormatHints( + mediaTypes = buildList { + addAll(hints.mediaTypes) + + if (url.scheme == ContentResolver.SCHEME_CONTENT) { + contentResolver.getType(url.uri) + ?.let { MediaType(it) } + // Note: We exclude JSON, XML or ZIP formats otherwise they will be detected + // during the light sniffing step and bypass the RWPM or EPUB heavy + // sniffing. + ?.takeUnless { + it.matchesAny( + MediaType.BINARY, + MediaType.JSON, + MediaType.ZIP, + MediaType.XML + ) + } + ?.let { add(it) } + } + }, + fileExtensions = buildList { + addAll(hints.fileExtensions) + + url.extension?.let { add(it) } + + if (url.scheme == ContentResolver.SCHEME_CONTENT) { + contentResolver.queryProjection(url.uri, MediaStore.MediaColumns.DISPLAY_NAME)?.let { filename -> + File(filename).extension + .takeUnless { it.isBlank() } + ?.let { add(it) } + } + } + } + ) val resource = resourceFactory .create(url) @@ -285,15 +323,15 @@ public class AssetRetriever( when (error) { is ResourceFactory.Error.NotAResource -> return containerFactory.create(url).getOrNull() - ?.let { retrieve(it, exploded = true, hints) } + ?.let { retrieve(it, exploded = true, allHints) } else -> return null } } return archiveFactory.create(resource, password = null) .fold( - { retrieve(container = it, exploded = false, hints) }, - { retrieve(resource, hints) } + { retrieve(container = it, exploded = false, allHints) }, + { retrieve(resource, allHints) } ) } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/format/Format.kt b/readium/shared/src/main/java/org/readium/r2/shared/format/Format.kt index cf6b6e0b8e..1f1ff8b68e 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/format/Format.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/format/Format.kt @@ -14,8 +14,8 @@ import org.readium.r2.shared.util.mediatype.MediaTypeSnifferContext public class FormatRegistry( formats: List = listOf( - // The known formats are not declared as constants to discourage comparing the format - // instance instead of the media type for equality. + // The known formats are not declared as public constants to discourage comparing the format + // instead of the media type for equality. Format( MediaType.ACSM, name = "Adobe Content Server Message", @@ -108,10 +108,31 @@ public class FormatRegistry( retrieve(HintMediaTypeSnifferContext(hints = FormatHints(mediaType))) ?: Format(mediaType) - public suspend fun retrieve(context: MediaTypeSnifferContext): Format? = - sniffer.sniff(context)?.let { - formats[it] ?: Format(it) + public suspend fun retrieve(context: MediaTypeSnifferContext): Format? { + suspend fun doRetrieve(context: MediaTypeSnifferContext): Format? = + sniffer.sniff(context)?.let { + formats[it] ?: Format(it) + } + + // Light sniffing with only media type hints + if (context.hints.mediaTypes.isNotEmpty()) { + doRetrieve( + HintMediaTypeSnifferContext( + hints = context.hints.copy(fileExtensions = emptyList()) + ) + ) + ?.let { return it } + } + + // Light sniffing with both media type hints and file extensions + if (context.hints.fileExtensions.isNotEmpty()) { + doRetrieve(HintMediaTypeSnifferContext(hints = context.hints)) + ?.let { return it } } + + // Fallback on heavy sniffing + return doRetrieve(context) + } } /** diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt index b19617418c..37d1466d3a 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt @@ -6,7 +6,6 @@ package org.readium.r2.shared.resource -import android.content.ContentResolver import java.io.File import java.io.FileNotFoundException import java.io.RandomAccessFile @@ -16,8 +15,10 @@ import kotlinx.coroutines.withContext import org.readium.r2.shared.error.Try import org.readium.r2.shared.error.getOrThrow import org.readium.r2.shared.extensions.* +import org.readium.r2.shared.format.FormatHints import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.util.Url +import org.readium.r2.shared.util.isFile import org.readium.r2.shared.util.isLazyInitialized import org.readium.r2.shared.util.mediatype.MediaType @@ -51,7 +52,12 @@ public class FileResource internal constructor( override suspend fun mediaType(): ResourceTry = Try.success( mediaType - ?: formatRegistry?.retrieve(ResourceMediaTypeSnifferContext(this))?.mediaType + ?: formatRegistry?.retrieve( + ResourceMediaTypeSnifferContext( + resource = this, + hints = FormatHints(fileExtension = file.extension) + ) + )?.mediaType ) override suspend fun close() { @@ -132,7 +138,7 @@ public class FileResourceFactory( ) : ResourceFactory { override suspend fun create(url: Url): Try { - if (url.scheme != ContentResolver.SCHEME_FILE) { + if (!url.isFile()) { return Try.failure(ResourceFactory.Error.SchemeNotSupported(url.scheme)) } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt index ee6a269e82..e3809e75b9 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt @@ -22,6 +22,7 @@ import org.readium.r2.shared.extensions.optNullableLong import org.readium.r2.shared.extensions.readFully import org.readium.r2.shared.extensions.toMap import org.readium.r2.shared.extensions.tryOrLog +import org.readium.r2.shared.format.FormatHints import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.io.CountingInputStream @@ -107,7 +108,14 @@ internal class JavaZipContainer( override val source: Url? = null override suspend fun mediaType(): ResourceTry = - Try.success(formatRegistry.retrieve(ResourceMediaTypeSnifferContext(this))?.mediaType) + Try.success( + formatRegistry.retrieve( + ResourceMediaTypeSnifferContext( + resource = this, + hints = FormatHints(fileExtension = File(path).extension) + ) + )?.mediaType + ) override suspend fun properties(): ResourceTry = Try.failure(Resource.Exception.NotFound()) @@ -130,7 +138,14 @@ internal class JavaZipContainer( override val source: Url? = null override suspend fun mediaType(): ResourceTry = - Try.success(formatRegistry.retrieve(ResourceMediaTypeSnifferContext(this))?.mediaType) + Try.success( + formatRegistry.retrieve( + ResourceMediaTypeSnifferContext( + resource = this, + hints = FormatHints(fileExtension = File(path).extension) + ) + )?.mediaType + ) override suspend fun properties(): ResourceTry = ResourceTry.success( diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt index 10e7162461..4c962cb2c9 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt @@ -14,6 +14,7 @@ import org.readium.r2.shared.error.getOrElse import org.readium.r2.shared.extensions.addPrefix import org.readium.r2.shared.extensions.readFully import org.readium.r2.shared.extensions.tryOrLog +import org.readium.r2.shared.format.FormatHints import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.resource.ArchiveFactory import org.readium.r2.shared.resource.ArchiveProperties @@ -61,7 +62,14 @@ internal class ChannelZipContainer( ) override suspend fun mediaType(): ResourceTry = - Try.success(formatRegistry.retrieve(ResourceMediaTypeSnifferContext(this))?.mediaType) + Try.success( + formatRegistry.retrieve( + ResourceMediaTypeSnifferContext( + resource = this, + hints = FormatHints(fileExtension = File(path).extension) + ) + )?.mediaType + ) override suspend fun length(): ResourceTry = entry.size.takeUnless { it == -1L } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt index 76779271ea..ab38dad75b 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt @@ -110,9 +110,9 @@ public object MediaTypeSniffers { } // OPDS Authentication Document. - if (context.hasMediaType("application/opds-authentication+json") || context.hasMediaType( - "application/vnd.opds.authentication.v1.0+json" - ) + if ( + context.hasMediaType("application/opds-authentication+json") || + context.hasMediaType("application/vnd.opds.authentication.v1.0+json") ) { return@MediaTypeSniffer MediaType.OPDS_AUTHENTICATION } @@ -159,9 +159,9 @@ public object MediaTypeSniffers { /** Sniffs an LCP License Document. */ public val lcpLicense: MediaTypeSniffer = MediaTypeSniffer { context -> - if (context.hasFileExtension("lcpl") || context.hasMediaType( - "application/vnd.readium.lcp.license.v1.0+json" - ) + if ( + context.hasFileExtension("lcpl") || + context.hasMediaType("application/vnd.readium.lcp.license.v1.0+json") ) { return@MediaTypeSniffer MediaType.LCP_LICENSE_DOCUMENT } @@ -178,39 +178,52 @@ public object MediaTypeSniffers { /** Sniffs a bitmap image. */ public val bitmap: MediaTypeSniffer = MediaTypeSniffer { context -> - if (context.hasFileExtension("avif") || context.hasMediaType("image/avif")) { + if ( + context.hasFileExtension("avif") || + context.hasMediaType("image/avif") + ) { return@MediaTypeSniffer MediaType.AVIF } - if (context.hasFileExtension("bmp", "dib") || context.hasMediaType( - "image/bmp", - "image/x-bmp" - ) + if ( + context.hasFileExtension("bmp", "dib") || + context.hasMediaType("image/bmp", "image/x-bmp") ) { return@MediaTypeSniffer MediaType.BMP } - if (context.hasFileExtension("gif") || context.hasMediaType("image/gif")) { + if ( + context.hasFileExtension("gif") || + context.hasMediaType("image/gif") + ) { return@MediaTypeSniffer MediaType.GIF } - if (context.hasFileExtension("jpg", "jpeg", "jpe", "jif", "jfif", "jfi") || context.hasMediaType( - "image/jpeg" - ) + if ( + context.hasFileExtension("jpg", "jpeg", "jpe", "jif", "jfif", "jfi") || + context.hasMediaType("image/jpeg") ) { return@MediaTypeSniffer MediaType.JPEG } - if (context.hasFileExtension("jxl") || context.hasMediaType("image/jxl")) { + if ( + context.hasFileExtension("jxl") || + context.hasMediaType("image/jxl") + ) { return@MediaTypeSniffer MediaType.JXL } - if (context.hasFileExtension("png") || context.hasMediaType("image/png")) { + if ( + context.hasFileExtension("png") || + context.hasMediaType("image/png") + ) { return@MediaTypeSniffer MediaType.PNG } - if (context.hasFileExtension("tiff", "tif") || context.hasMediaType( - "image/tiff", - "image/tiff-fx" - ) + if ( + context.hasFileExtension("tiff", "tif") || + context.hasMediaType("image/tiff", "image/tiff-fx") ) { return@MediaTypeSniffer MediaType.TIFF } - if (context.hasFileExtension("webp") || context.hasMediaType("image/webp")) { + if ( + context.hasFileExtension("webp") || + context.hasMediaType("image/webp") + ) { return@MediaTypeSniffer MediaType.WEBP } return@MediaTypeSniffer null @@ -253,29 +266,43 @@ public object MediaTypeSniffers { /** Sniffs a Readium Web Publication, protected or not by LCP. */ public val webpub: MediaTypeSniffer = MediaTypeSniffer { context -> - if (context.hasFileExtension("audiobook") || context.hasMediaType( - "application/audiobook+zip" - ) + if ( + context.hasFileExtension("audiobook") || + context.hasMediaType("application/audiobook+zip") ) { return@MediaTypeSniffer MediaType.READIUM_AUDIOBOOK } - if (context.hasFileExtension("divina") || context.hasMediaType("application/divina+zip")) { + if ( + context.hasFileExtension("divina") || + context.hasMediaType("application/divina+zip") + ) { return@MediaTypeSniffer MediaType.DIVINA } - if (context.hasFileExtension("webpub") || context.hasMediaType("application/webpub+zip")) { + if ( + context.hasFileExtension("webpub") || + context.hasMediaType("application/webpub+zip") + ) { return@MediaTypeSniffer MediaType.READIUM_WEBPUB } - if (context.hasFileExtension("lcpa") || context.hasMediaType("application/audiobook+lcp")) { + if ( + context.hasFileExtension("lcpa") || + context.hasMediaType("application/audiobook+lcp") + ) { return@MediaTypeSniffer MediaType.LCP_PROTECTED_AUDIOBOOK } - if (context.hasFileExtension("lcpdf") || context.hasMediaType("application/pdf+lcp")) { + if ( + context.hasFileExtension("lcpdf") || + context.hasMediaType("application/pdf+lcp") + ) { return@MediaTypeSniffer MediaType.LCP_PROTECTED_PDF } - if (context !is ContainerMediaTypeSnifferContext) { + if ( + context !is ContainerMediaTypeSnifferContext + ) { return@MediaTypeSniffer null } @@ -289,10 +316,14 @@ public object MediaTypeSniffers { } if (manifest != null) { - val isLcpProtected = context.contains("license.lcpl") + val isLcpProtected = context.contains("/license.lcpl") if (manifest.conformsTo(Publication.Profile.AUDIOBOOK)) { - return@MediaTypeSniffer if (isLcpProtected) MediaType.LCP_PROTECTED_AUDIOBOOK else MediaType.READIUM_AUDIOBOOK + return@MediaTypeSniffer if (isLcpProtected) { + MediaType.LCP_PROTECTED_AUDIOBOOK + } else { + MediaType.READIUM_AUDIOBOOK + } } if (manifest.conformsTo(Publication.Profile.DIVINA)) { return@MediaTypeSniffer MediaType.DIVINA @@ -316,7 +347,10 @@ public object MediaTypeSniffers { // Somehow, [JSONObject] can't access JSON-LD keys such as `@context`. val content = context.contentAsString() ?: "" - if (content.contains("@context") && content.contains("https://www.w3.org/ns/wp-context")) { + if ( + content.contains("@context") && + content.contains("https://www.w3.org/ns/wp-context") + ) { return@MediaTypeSniffer MediaType.W3C_WPUB_MANIFEST } @@ -329,7 +363,10 @@ public object MediaTypeSniffers { * Reference: https://www.w3.org/publishing/epub3/epub-ocf.html#sec-zip-container-mime */ public val epub: MediaTypeSniffer = MediaTypeSniffer { context -> - if (context.hasFileExtension("epub") || context.hasMediaType("application/epub+zip")) { + if ( + context.hasFileExtension("epub") || + context.hasMediaType("application/epub+zip") + ) { return@MediaTypeSniffer MediaType.EPUB } @@ -354,7 +391,10 @@ public object MediaTypeSniffers { * - https://www.w3.org/TR/pub-manifest/ */ public val lpf: MediaTypeSniffer = MediaTypeSniffer { context -> - if (context.hasFileExtension("lpf") || context.hasMediaType("application/lpf+zip")) { + if ( + context.hasFileExtension("lpf") || + context.hasMediaType("application/lpf+zip") + ) { return@MediaTypeSniffer MediaType.LPF } @@ -362,7 +402,7 @@ public object MediaTypeSniffers { return@MediaTypeSniffer null } - if (context.contains("index.html")) { + if (context.contains("/index.html")) { return@MediaTypeSniffer MediaType.LPF } @@ -408,7 +448,9 @@ public object MediaTypeSniffers { * Reference: https://wiki.mobileread.com/wiki/CBR_and_CBZ */ public val archive: MediaTypeSniffer = MediaTypeSniffer { context -> - if (context.hasFileExtension("cbz") || context.hasMediaType( + if ( + context.hasFileExtension("cbz") || + context.hasMediaType( "application/vnd.comicbook+zip", "application/x-cbz", "application/x-cbr" @@ -449,7 +491,10 @@ public object MediaTypeSniffers { * Reference: https://www.loc.gov/preservation/digital/formats/fdd/fdd000123.shtml */ public val pdf: MediaTypeSniffer = MediaTypeSniffer { context -> - if (context.hasFileExtension("pdf") || context.hasMediaType("application/pdf")) { + if ( + context.hasFileExtension("pdf") || + context.hasMediaType("application/pdf") + ) { return@MediaTypeSniffer MediaType.PDF } @@ -490,7 +535,9 @@ public object MediaTypeSniffers { /** Sniffs a ZIP archive. */ public val zip: MediaTypeSniffer = MediaTypeSniffer { context -> - if (context.hasMediaType("application/zip") && context is ContainerMediaTypeSnifferContext) { + if ( + context.hasMediaType("application/zip") && context is ContainerMediaTypeSnifferContext + ) { return@MediaTypeSniffer MediaType.ZIP } return@MediaTypeSniffer null diff --git a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt index a7dd9a03bf..865d29035a 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt @@ -59,7 +59,8 @@ class Readium(context: Context) { formatRegistry, resourceFactory, containerFactory, - archiveFactory + archiveFactory, + context.contentResolver ) /** diff --git a/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/Uri.kt b/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/Uri.kt index e3f672035c..3b02e3da19 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/Uri.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/Uri.kt @@ -6,22 +6,46 @@ package org.readium.r2.testapp.utils.extensions +import android.content.ContentResolver import android.content.Context import android.net.Uri +import android.provider.MediaStore import java.io.File import java.util.* import org.readium.r2.shared.error.Try import org.readium.r2.testapp.utils.ContentResolverUtil +import org.readium.r2.testapp.utils.tryOrNull suspend fun Uri.copyToTempFile(context: Context, dir: File): Try = try { val filename = UUID.randomUUID().toString() - val extension = path - ?.let { File(it).extension } - ?: "tmp" - val file = File(dir, "$filename.$extension") + val file = File(dir, "$filename.${extension(context)}") ContentResolverUtil.getContentInputStream(context, this, file) Try.success(file) } catch (e: Exception) { Try.failure(e) } + +private fun Uri.extension(context: Context): String? { + if (scheme == ContentResolver.SCHEME_CONTENT) { + tryOrNull { + context.contentResolver.queryProjection(this, MediaStore.MediaColumns.DISPLAY_NAME) + ?.let { filename -> + File(filename).extension + .takeUnless { it.isBlank() } + } + }?.let { return it } + } + + return path?.let { File(it).extension } +} + +private fun ContentResolver.queryProjection(uri: Uri, projection: String): String? = + tryOrNull { + query(uri, arrayOf(projection), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + return cursor.getString(0) + } + return null + } + } From 14d8e022e7555639f6aaa1827528cfd11b5d346b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Fri, 18 Aug 2023 18:36:31 +0200 Subject: [PATCH 04/24] Remove legacy code --- .../util/mediatype/MediaTypeRetriever.kt | 272 --------- .../r2/shared/util/mediatype/Sniffer.kt | 526 ------------------ .../shared/util/mediatype/SnifferContext.kt | 312 ----------- 3 files changed, 1110 deletions(-) delete mode 100644 readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeRetriever.kt delete mode 100644 readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Sniffer.kt delete mode 100644 readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/SnifferContext.kt diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeRetriever.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeRetriever.kt deleted file mode 100644 index ecd6fa77db..0000000000 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeRetriever.kt +++ /dev/null @@ -1,272 +0,0 @@ -/* - * Copyright 2023 Readium Foundation. All rights reserved. - * Use of this source code is governed by the BSD-style license - * available in the top-level LICENSE file of the project. - */ - -package org.readium.r2.shared.util.mediatype - -import org.readium.r2.shared.resource.* - -/* -public class MediaTypeRetriever( - resourceFactory: ResourceFactory = FileResourceFactory(), - containerFactory: ContainerFactory = DirectoryContainerFactory(), - archiveFactory: ArchiveFactory = DefaultArchiveFactory(), - private val contentResolver: ContentResolver? = null, - private val sniffers: List = Sniffers.all -) { - private val urlSnifferContextFactory: UrlSnifferContextFactory = - UrlSnifferContextFactory(resourceFactory, containerFactory, archiveFactory) - - private val bytesSnifferContextFactory: BytesSnifferContextFactory = - BytesSnifferContextFactory(archiveFactory) - - public suspend fun canonicalMediaType(mediaType: MediaType): MediaType = - retrieve(mediaType = mediaType.toString()) ?: mediaType - - /** - * Resolves a media type from a single file extension and media type hint, without checking the actual - * content. - */ - public suspend fun retrieve( - mediaType: String? = null, - fileExtension: String? = null - ): MediaType? { - if (BuildConfig.DEBUG && mediaType?.startsWith("/") == true) { - throw IllegalArgumentException( - "The provided media type is incorrect: $mediaType. To pass a file path, you must wrap it in a File()." - ) - } - return retrieve( - mediaTypes = listOfNotNull(mediaType), - fileExtensions = listOfNotNull(fileExtension) - ) - } - - /** - * Resolves a media type from file extension and media type hints without checking the actual - * content. - */ - public suspend fun retrieve( - mediaTypes: List, - fileExtensions: List - ): MediaType? { - return doRetrieve(null, mediaTypes, fileExtensions) - } - - /** - * Resolves a media type from a local file. - */ - public suspend fun retrieve( - file: File, - mediaType: String? = null, - fileExtension: String? = null - ): MediaType? { - return retrieve( - file, - mediaTypes = listOfNotNull(mediaType), - fileExtensions = listOfNotNull(fileExtension) - ) - } - - /** - * Resolves a media type from a local file. - */ - public suspend fun retrieve( - file: File, - mediaTypes: List, - fileExtensions: List - ): MediaType? { - return retrieve( - content = Either.Right(file.toUrl()), - mediaTypes = mediaTypes, - fileExtensions = listOf(file.extension) + fileExtensions - ) - } - - /** - * Resolves a media type from bytes, e.g. from an HTTP response. - */ - public suspend fun retrieve( - bytes: () -> ByteArray, - mediaType: String? = null, - fileExtension: String? = null - ): MediaType? { - return retrieve( - bytes, - mediaTypes = listOfNotNull(mediaType), - fileExtensions = listOfNotNull(fileExtension) - ) - } - - /** - * Resolves a media type from bytes, e.g. from an HTTP response. - */ - public suspend fun retrieve( - bytes: () -> ByteArray, - mediaTypes: List, - fileExtensions: List - ): MediaType? { - return retrieve( - content = Either.Left(bytes), - mediaTypes = mediaTypes, - fileExtensions = fileExtensions - ) - } - - /** - * Resolves a media type from a Uri. - */ - public suspend fun retrieve( - uri: Uri, - mediaType: String? = null, - fileExtension: String? = null - ): MediaType? { - return retrieve( - uri, - mediaTypes = listOfNotNull(mediaType), - fileExtensions = listOfNotNull(fileExtension) - ) - } - - /** - * Resolves a media type from a Uri. - */ - public suspend fun retrieve( - uri: Uri, - mediaTypes: List, - fileExtensions: List - ): MediaType? { - val url = uri.toUrl() ?: return null - return retrieve( - content = Either.Right(url), - mediaTypes = mediaTypes, - fileExtensions = fileExtensions - ) - } - - /** - * Resolves a media type from a sniffer context. - * - * Sniffing a media type is done in two rounds, because we want to give an opportunity to all - * sniffers to return a [MediaType] quickly before inspecting the content itself: - * - Light Sniffing checks only the provided file extension or media type hints. - * - Heavy Sniffing reads the bytes to perform more advanced sniffing. - */ - private suspend fun retrieve( - content: Either<() -> ByteArray, Url>?, - mediaTypes: List, - fileExtensions: List - ): MediaType? { - val fullContext = suspend { - when (content) { - is Either.Left -> - bytesSnifferContextFactory.createContext( - content.value.invoke(), - mediaTypes, - fileExtensions - ) - is Either.Right -> - urlSnifferContextFactory.createContext( - content.value, - mediaTypes, - fileExtensions - ) - null -> null - } - } - - doRetrieve(fullContext, mediaTypes, fileExtensions)?.let { return it } - - // Falls back on the [contentResolver] in case of content Uri. - // Note: This is done after the heavy sniffing of the provided [sniffers], because - // otherwise it will detect JSON, XML or ZIP formats before we have a chance of sniffing - // their content (for example, for RWPM). - - val url = (content as? Either.Right)?.value - ?: return null - - val allMediaTypes = mediaTypes.toMutableList() - val allFileExtensions = fileExtensions.toMutableList() - - if (url.scheme == ContentResolver.SCHEME_CONTENT && contentResolver != null) { - contentResolver.getType(url.uri) - ?.takeUnless { MediaType.BINARY.matches(it) } - ?.let { allMediaTypes.add(0, it) } - - contentResolver.queryProjection(url.uri, MediaStore.MediaColumns.DISPLAY_NAME)?.let { filename -> - allFileExtensions.add(0, File(filename).extension) - } - } - - return doRetrieve(fullContext, allMediaTypes, allFileExtensions) - } - - /** - * Resolves a media type from a sniffer context. - * - * Sniffing a media type is done in two rounds, because we want to give an opportunity to all - * sniffers to return a [MediaType] quickly before inspecting the content itself: - * - Light Sniffing checks only the provided file extension or media type hints. - * - Heavy Sniffing reads the bytes to perform more advanced sniffing. - */ - internal suspend fun doRetrieve( - fullContext: (suspend () -> SnifferContext?)?, - mediaTypes: List, - fileExtensions: List - ): MediaType? { - // Light sniffing with only media type hints - if (mediaTypes.isNotEmpty()) { - val context = HintSnifferContext(mediaTypes = mediaTypes) - for (sniffer in sniffers) { - val mediaType = sniffer(context) - if (mediaType != null) { - return mediaType - } - } - } - - // Light sniffing with both media type hints and file extensions - if (fileExtensions.isNotEmpty()) { - val context = HintSnifferContext( - mediaTypes = mediaTypes, - fileExtensions = fileExtensions - ) - for (sniffer in sniffers) { - val mediaType = sniffer(context) - if (mediaType != null) { - return mediaType - } - } - } - - // Heavy sniffing - val context = fullContext?.invoke() - - if (context != null) { - for (sniffer in sniffers) { - val mediaType = sniffer(context) - if (mediaType != null) { - return mediaType - } - } - } - - // Falls back on the system-wide registered media types using [MimeTypeMap]. - // Note: This is done after the heavy sniffing of the provided [sniffers], because - // otherwise it will detect JSON, XML or ZIP formats before we have a chance of sniffing - // their content (for example, for RWPM). - val systemContext = context ?: HintSnifferContext(mediaTypes, fileExtensions) - Sniffers.system(systemContext)?.let { return it } - - // If nothing else worked, we try to parse the first valid media type hint. - for (mediaType in mediaTypes) { - MediaType.parse(mediaType)?.let { return it } - } - - return null - } -} - - */ diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Sniffer.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Sniffer.kt deleted file mode 100644 index e463cef236..0000000000 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Sniffer.kt +++ /dev/null @@ -1,526 +0,0 @@ -/* - * Copyright 2020 Readium Foundation. All rights reserved. - * Use of this source code is governed by the BSD-style license - * available in the top-level LICENSE file of the project. - */ - -package org.readium.r2.shared.util.mediatype - -@Deprecated(message = "Use MediaTypeSniffer instead", level = DeprecationLevel.ERROR) -public typealias Sniffer = MediaTypeSniffer - -/* - -import android.webkit.MimeTypeMap -import java.io.File -import java.net.URLConnection -import java.util.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.json.JSONObject -import org.readium.r2.shared.extensions.tryOrNull -import org.readium.r2.shared.publication.* -/** - * Determines if the provided content matches a known media type. - * - * The context holds the file metadata and cached content, which are shared among the sniffers. - */ -public typealias Sniffer = suspend (context: SnifferContext) -> MediaType? - -/** - * Default media type sniffers provided by Readium. - */ -public object Sniffers { - - /** - * The default sniffers provided by Readium 2 to resolve a [MediaType]. - * The sniffers order is important, because some formats are subsets of other formats. - */ - public val all: List = listOf( - ::xhtml, ::html, ::opds, ::lcpLicense, ::bitmap, ::webpubManifest, ::webpub, ::w3cWPUB, - ::epub, ::lpf, ::archive, ::pdf, ::json - ) - - /** - * Sniffs an XHTML document. - * - * Must precede the HTML sniffer. - */ - public suspend fun xhtml(context: SnifferContext): MediaType? { - if (context.hasFileExtension("xht", "xhtml") || context.hasMediaType( - "application/xhtml+xml" - ) - ) { - return MediaType.XHTML - } - - if (context !is ResourceSnifferContext) { - return null - } - - context.contentAsXml()?.let { - if (it.name.lowercase(Locale.ROOT) == "html" && it.namespace.lowercase(Locale.ROOT).contains( - "xhtml" - ) - ) { - return MediaType.XHTML - } - } - return null - } - - /** Sniffs an HTML document. */ - public suspend fun html(context: SnifferContext): MediaType? { - if (context.hasFileExtension("htm", "html") || context.hasMediaType("text/html")) { - return MediaType.HTML - } - - if (context !is ResourceSnifferContext) { - return null - } - - // [contentAsXml] will fail if the HTML is not a proper XML document, hence the doctype check. - if ( - context.contentAsXml()?.name?.lowercase(Locale.ROOT) == "html" || - context.contentAsString()?.trimStart()?.take(15)?.lowercase() == "" - ) { - return MediaType.HTML - } - return null - } - - /** Sniffs an OPDS document. */ - public suspend fun opds(context: SnifferContext): MediaType? { - // OPDS 1 - if (context.hasMediaType("application/atom+xml;type=entry;profile=opds-catalog")) { - return MediaType.OPDS1_ENTRY - } - if (context.hasMediaType("application/atom+xml;profile=opds-catalog")) { - return MediaType.OPDS1 - } - - // OPDS 2 - if (context.hasMediaType("application/opds+json")) { - return MediaType.OPDS2 - } - if (context.hasMediaType("application/opds-publication+json")) { - return MediaType.OPDS2_PUBLICATION - } - - // OPDS Authentication Document. - if (context.hasMediaType("application/opds-authentication+json") || context.hasMediaType( - "application/vnd.opds.authentication.v1.0+json" - ) - ) { - return MediaType.OPDS_AUTHENTICATION - } - - if (context !is ResourceSnifferContext) { - return null - } - - // OPDS 1 - context.contentAsXml()?.let { xml -> - if (xml.namespace == "http://www.w3.org/2005/Atom") { - if (xml.name == "feed") { - return MediaType.OPDS1 - } else if (xml.name == "entry") { - return MediaType.OPDS1_ENTRY - } - } - } - - // OPDS 2 - context.contentAsRwpm()?.let { rwpm -> - if (rwpm.linkWithRel("self")?.mediaType?.matches("application/opds+json") == true) { - return MediaType.OPDS2 - } - if (rwpm.links.firstWithRelMatching { it.startsWith("http://opds-spec.org/acquisition") } != null) { - return MediaType.OPDS2_PUBLICATION - } - } - - // OPDS Authentication Document. - if (context.containsJsonKeys("id", "title", "authentication")) { - return MediaType.OPDS_AUTHENTICATION - } - - return null - } - - /** Sniffs an LCP License Document. */ - public suspend fun lcpLicense(context: SnifferContext): MediaType? { - if (context.hasFileExtension("lcpl") || context.hasMediaType( - "application/vnd.readium.lcp.license.v1.0+json" - ) - ) { - return MediaType.LCP_LICENSE_DOCUMENT - } - - if (context !is ResourceSnifferContext) { - return null - } - - if (context.containsJsonKeys("id", "issued", "provider", "encryption")) { - return MediaType.LCP_LICENSE_DOCUMENT - } - return null - } - - /** Sniffs a bitmap image. */ - @Suppress("RedundantSuspendModifier") - public suspend fun bitmap(context: SnifferContext): MediaType? { - if (context.hasFileExtension("avif") || context.hasMediaType("image/avif")) { - return MediaType.AVIF - } - if (context.hasFileExtension("bmp", "dib") || context.hasMediaType( - "image/bmp", - "image/x-bmp" - ) - ) { - return MediaType.BMP - } - if (context.hasFileExtension("gif") || context.hasMediaType("image/gif")) { - return MediaType.GIF - } - if (context.hasFileExtension("jpg", "jpeg", "jpe", "jif", "jfif", "jfi") || context.hasMediaType( - "image/jpeg" - ) - ) { - return MediaType.JPEG - } - if (context.hasFileExtension("jxl") || context.hasMediaType("image/jxl")) { - return MediaType.JXL - } - if (context.hasFileExtension("png") || context.hasMediaType("image/png")) { - return MediaType.PNG - } - if (context.hasFileExtension("tiff", "tif") || context.hasMediaType( - "image/tiff", - "image/tiff-fx" - ) - ) { - return MediaType.TIFF - } - if (context.hasFileExtension("webp") || context.hasMediaType("image/webp")) { - return MediaType.WEBP - } - return null - } - - /** Sniffs a Readium Web Manifest. */ - public suspend fun webpubManifest(context: SnifferContext): MediaType? { - if (context.hasMediaType("application/audiobook+json")) { - return MediaType.READIUM_AUDIOBOOK_MANIFEST - } - - if (context.hasMediaType("application/divina+json")) { - return MediaType.DIVINA_MANIFEST - } - - if (context.hasMediaType("application/webpub+json")) { - return MediaType.READIUM_WEBPUB_MANIFEST - } - - if (context !is ResourceSnifferContext) { - return null - } - - val manifest: Manifest = - context.contentAsRwpm() ?: return null - - if (manifest.conformsTo(Publication.Profile.AUDIOBOOK)) { - return MediaType.READIUM_AUDIOBOOK_MANIFEST - } - - if (manifest.conformsTo(Publication.Profile.DIVINA)) { - return MediaType.DIVINA_MANIFEST - } - if (manifest.linkWithRel("self")?.mediaType?.matches("application/webpub+json") == true) { - return MediaType.READIUM_WEBPUB_MANIFEST - } - - return null - } - - /** Sniffs a Readium Web Publication, protected or not by LCP. */ - public suspend fun webpub(context: SnifferContext): MediaType? { - if (context.hasFileExtension("audiobook") || context.hasMediaType( - "application/audiobook+zip" - ) - ) { - return MediaType.READIUM_AUDIOBOOK - } - - if (context.hasFileExtension("divina") || context.hasMediaType("application/divina+zip")) { - return MediaType.DIVINA - } - - if (context.hasFileExtension("webpub") || context.hasMediaType("application/webpub+zip")) { - return MediaType.READIUM_WEBPUB - } - - if (context.hasFileExtension("lcpa") || context.hasMediaType("application/audiobook+lcp")) { - return MediaType.LCP_PROTECTED_AUDIOBOOK - } - if (context.hasFileExtension("lcpdf") || context.hasMediaType("application/pdf+lcp")) { - return MediaType.LCP_PROTECTED_PDF - } - - if (context !is ContainerSnifferContext) { - return null - } - - // Reads a RWPM from a manifest.json archive entry. - val manifest: Manifest? = - try { - context.readArchiveEntryAt("manifest.json") - ?.let { Manifest.fromJSON(JSONObject(String(it))) } - } catch (e: Exception) { - null - } - - if (manifest != null) { - val isLcpProtected = context.containsArchiveEntryAt("license.lcpl") - - if (manifest.conformsTo(Publication.Profile.AUDIOBOOK)) { - return if (isLcpProtected) MediaType.LCP_PROTECTED_AUDIOBOOK else MediaType.READIUM_AUDIOBOOK - } - if (manifest.conformsTo(Publication.Profile.DIVINA)) { - return MediaType.DIVINA - } - if (isLcpProtected && manifest.conformsTo(Publication.Profile.PDF)) { - return MediaType.LCP_PROTECTED_PDF - } - if (manifest.linkWithRel("self")?.mediaType?.matches("application/webpub+json") == true) { - return MediaType.READIUM_WEBPUB - } - } - - return null - } - - /** Sniffs a W3C Web Publication Manifest. */ - public suspend fun w3cWPUB(context: SnifferContext): MediaType? { - if (context !is ResourceSnifferContext) { - return null - } - - // Somehow, [JSONObject] can't access JSON-LD keys such as `@context`. - val content = context.contentAsString() ?: "" - if (content.contains("@context") && content.contains("https://www.w3.org/ns/wp-context")) { - return MediaType.W3C_WPUB_MANIFEST - } - - return null - } - - /** - * Sniffs an EPUB publication. - * - * Reference: https://www.w3.org/publishing/epub3/epub-ocf.html#sec-zip-container-mime - */ - public suspend fun epub(context: SnifferContext): MediaType? { - if (context.hasFileExtension("epub") || context.hasMediaType("application/epub+zip")) { - return MediaType.EPUB - } - - if (context !is ContainerSnifferContext) { - return null - } - - val mimetype = context.readArchiveEntryAt("mimetype") - ?.let { String(it, charset = Charsets.US_ASCII).trim() } - if (mimetype == "application/epub+zip") { - return MediaType.EPUB - } - - return null - } - - /** - * Sniffs a Lightweight Packaging Format (LPF). - * - * References: - * - https://www.w3.org/TR/lpf/ - * - https://www.w3.org/TR/pub-manifest/ - */ - public suspend fun lpf(context: SnifferContext): MediaType? { - if (context.hasFileExtension("lpf") || context.hasMediaType("application/lpf+zip")) { - return MediaType.LPF - } - - if (context !is ContainerSnifferContext) { - return null - } - - if (context.containsArchiveEntryAt("index.html")) { - return MediaType.LPF - } - - // Somehow, [JSONObject] can't access JSON-LD keys such as `@context`. - context.readArchiveEntryAt("publication.json") - ?.let { String(it) } - ?.let { manifest -> - if (manifest.contains("@context") && manifest.contains( - "https://www.w3.org/ns/pub-context" - ) - ) { - return MediaType.LPF - } - } - - return null - } - - /** - * Authorized extensions for resources in a CBZ archive. - * Reference: https://wiki.mobileread.com/wiki/CBR_and_CBZ - */ - private val CBZ_EXTENSIONS = listOf( - // bitmap - "bmp", "dib", "gif", "jif", "jfi", "jfif", "jpg", "jpeg", "png", "tif", "tiff", "webp", - // metadata - "acbf", "xml" - ) - - /** - * Authorized extensions for resources in a ZAB archive (Zipped Audio Book). - */ - private val ZAB_EXTENSIONS = listOf( - // audio - "aac", "aiff", "alac", "flac", "m4a", "m4b", "mp3", "ogg", "oga", "mogg", "opus", "wav", "webm", - // playlist - "asx", "bio", "m3u", "m3u8", "pla", "pls", "smil", "vlc", "wpl", "xspf", "zpl" - ) - - /** - * Sniffs a simple Archive-based format, like Comic Book Archive or Zipped Audio Book. - * - * Reference: https://wiki.mobileread.com/wiki/CBR_and_CBZ - */ - public suspend fun archive(context: SnifferContext): MediaType? { - if (context.hasFileExtension("cbz") || context.hasMediaType( - "application/vnd.comicbook+zip", - "application/x-cbz", - "application/x-cbr" - ) - ) { - return MediaType.CBZ - } - if (context.hasFileExtension("zab")) { - return MediaType.ZAB - } - - if (context !is ContainerSnifferContext) { - return null - } - - fun isIgnored(file: File): Boolean = - file.name.startsWith(".") || file.name == "Thumbs.db" - - suspend fun archiveContainsOnlyExtensions(fileExtensions: List): Boolean = - context.archiveEntriesAllSatisfy { entry -> - val file = File(entry.path) - isIgnored(file) || fileExtensions.contains(file.extension.lowercase(Locale.ROOT)) - } - - if (archiveContainsOnlyExtensions(CBZ_EXTENSIONS)) { - return MediaType.CBZ - } - if (archiveContainsOnlyExtensions(ZAB_EXTENSIONS)) { - return MediaType.ZAB - } - - return null - } - - /** - * Sniffs a PDF document. - * - * Reference: https://www.loc.gov/preservation/digital/formats/fdd/fdd000123.shtml - */ - public suspend fun pdf(context: SnifferContext): MediaType? { - if (context.hasFileExtension("pdf") || context.hasMediaType("application/pdf")) { - return MediaType.PDF - } - - if (context !is ResourceSnifferContext) { - return null - } - - if (context.read(0L until 5L)?.toString(Charsets.UTF_8) == "%PDF-") { - return MediaType.PDF - } - - return null - } - - /** Sniffs a JSON document. */ - public suspend fun json(context: SnifferContext): MediaType? { - if (context.hasMediaType("application/problem+json")) { - return MediaType.JSON_PROBLEM_DETAILS - } - - if (context !is ResourceSnifferContext) { - return null - } - - if (context.contentAsJson() != null) { - return MediaType.JSON - } - return null - } - - /** - * Sniffs the system-wide registered media types using [MimeTypeMap] and - * [URLConnection.guessContentTypeFromStream]. - */ - public suspend fun system(context: SnifferContext): MediaType? { - val mimetypes = tryOrNull { MimeTypeMap.getSingleton() } - ?: return null - - fun sniffExtension(extension: String): MediaType? { - val type = mimetypes.getMimeTypeFromExtension(extension) - ?: return null - val preferredExtension = mimetypes.getExtensionFromMimeType(type) - ?: return null - return MediaType.parse(type, fileExtension = preferredExtension) - } - - fun sniffType(type: String): MediaType? { - val extension = mimetypes.getExtensionFromMimeType(type) - ?: return null - val preferredType = mimetypes.getMimeTypeFromExtension(extension) - ?: return null - return MediaType.parse(preferredType, fileExtension = extension) - } - - for (mediaType in context.mediaTypes) { - return sniffType(mediaType.toString()) ?: continue - } - - for (extension in context.fileExtensions) { - return sniffExtension(extension) ?: continue - } - - if (context !is ResourceSnifferContext) { - return null - } - - return withContext(Dispatchers.IO) { - context.contentAsStream() - .let { URLConnection.guessContentTypeFromStream(it) } - ?.let { sniffType(it) } - } - } -} - -/** - * Finds the first [Link] having a relation matching the given [predicate]. - */ -private fun List.firstWithRelMatching(predicate: (String) -> Boolean): Link? = - firstOrNull { it.rels.any(predicate) } - - - */ diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/SnifferContext.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/SnifferContext.kt deleted file mode 100644 index 4249ac1405..0000000000 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/SnifferContext.kt +++ /dev/null @@ -1,312 +0,0 @@ -/* - * Copyright 2020 Readium Foundation. All rights reserved. - * Use of this source code is governed by the BSD-style license - * available in the top-level LICENSE file of the project. - */ - -package org.readium.r2.shared.util.mediatype - -/* -import java.io.File -import java.io.InputStream -import java.nio.charset.Charset -import java.util.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.json.JSONObject -import org.readium.r2.shared.error.getOrElse -import org.readium.r2.shared.parser.xml.ElementNode -import org.readium.r2.shared.publication.Manifest -import org.readium.r2.shared.resource.* -import org.readium.r2.shared.util.Url -import org.readium.r2.shared.util.use -import timber.log.Timber - -public sealed class SnifferContext( - mediaTypes: List = emptyList(), - fileExtensions: List = emptyList() -) { - /** Media type hints. */ - public val mediaTypes: List = mediaTypes - .mapNotNull { MediaType(it) } - - /** File extension hints. */ - public val fileExtensions: List = fileExtensions - .map { it.lowercase(Locale.ROOT) } - - /** Finds the first [Charset] declared in the media types' `charset` parameter. */ - public val charset: Charset? get() = - this.mediaTypes.firstNotNullOfOrNull { it.charset } - - /** Returns whether this context has any of the given file extensions, ignoring case. */ - public fun hasFileExtension(vararg fileExtensions: String): Boolean { - for (fileExtension in fileExtensions) { - if (this.fileExtensions.contains(fileExtension.lowercase(Locale.ROOT))) { - return true - } - } - return false - } - - /** - * Returns whether this context has any of the given media type, ignoring case and extra - * parameters. - * - * Implementation note: Use [MediaType] to handle the comparison to avoid edge cases. - */ - public fun hasMediaType(vararg mediaTypes: String): Boolean { - @Suppress("NAME_SHADOWING") - val mediaTypes = mediaTypes.mapNotNull { MediaType(it) } - for (mediaType in mediaTypes) { - if (this.mediaTypes.any { mediaType.contains(it) }) { - return true - } - } - return false - } - - public abstract suspend fun release() -} - -public class HintSnifferContext( - mediaTypes: List = emptyList(), - fileExtensions: List = emptyList() -) : SnifferContext(mediaTypes, fileExtensions) { - - override suspend fun release() {} -} - -public sealed class ContentAwareSnifferContext( - mediaTypes: List = emptyList(), - fileExtensions: List = emptyList() -) : SnifferContext(mediaTypes, fileExtensions) - -/** - * A companion type of [Sniffer] holding the type hints (file extensions, media types) and - * providing an access to the file content. - * - * @param resource Underlying content holder. - * @param mediaTypes Media type hints. - * @param fileExtensions File extension hints. - */ -public class ResourceSnifferContext internal constructor( - public val resource: Resource, - mediaTypes: List = emptyList(), - fileExtensions: List = emptyList() -) : ContentAwareSnifferContext(mediaTypes, fileExtensions) { - - /** - * Content as plain text. - * - * It will extract the charset parameter from the media type hints to figure out an encoding. - * Otherwise, fallback on UTF-8. - */ - public suspend fun contentAsString(): String? = - try { - if (!loadedContentAsString) { - loadedContentAsString = true - _contentAsString = resource - .readAsString(charset ?: Charset.defaultCharset()) - .getOrNull() - } - _contentAsString - } catch (e: OutOfMemoryError) { // We don't want to catch any Error, only OOM. - Timber.e(e) - null - } - - private var loadedContentAsString: Boolean = false - private var _contentAsString: String? = null - - /** Content as an XML document. */ - public suspend fun contentAsXml(): ElementNode? { - if (!loadedContentAsXml) { - loadedContentAsXml = true - _contentAsXml = withContext(Dispatchers.IO) { - try { - resource.readAsXml().getOrNull() - } catch (e: Exception) { - null - } - } - } - - return _contentAsXml - } - - private var loadedContentAsXml: Boolean = false - private var _contentAsXml: ElementNode? = null - - /** - * Content parsed from JSON. - */ - public suspend fun contentAsJson(): JSONObject? = - try { - contentAsString()?.let { JSONObject(it) } - } catch (e: Exception) { - null - } - - /** Readium Web Publication Manifest parsed from the content. */ - public suspend fun contentAsRwpm(): Manifest? = - Manifest.fromJSON(contentAsJson()) - - /** - * Raw bytes stream of the content. - * - * A byte stream can be useful when sniffers only need to read a few bytes at the beginning of - * the file. - */ - public suspend fun contentAsStream(): InputStream = - ResourceInputStream(resource) - - /** - * Reads all the bytes or the given [range]. - * - * It can be used to check a file signature, aka magic number. - * See https://en.wikipedia.org/wiki/List_of_file_signatures - */ - public suspend fun read(range: LongRange? = null): ByteArray? = - resource.read(range).getOrNull() - - /** - * Returns whether the content is a JSON object containing all of the given root keys. - */ - internal suspend fun containsJsonKeys(vararg keys: String): Boolean { - val json = contentAsJson() ?: return false - return json.keys().asSequence().toSet().containsAll(keys.toList()) - } - - override suspend fun release() { - resource.close() - } -} - -/** - * A companion type of [Sniffer] holding the type hints (file extensions, media types) and - * providing an access to the file content. - * - * @param container Underlying content holder. - * @param mediaTypes Media type hints. - * @param fileExtensions File extension hints. - */ -public class ContainerSnifferContext internal constructor( - public val container: Container, - public val isExploded: Boolean, - mediaTypes: List = emptyList(), - fileExtensions: List = emptyList() -) : ContentAwareSnifferContext(mediaTypes, fileExtensions) { - - /** - * Returns whether an Archive entry exists in this file. - */ - internal suspend fun containsArchiveEntryAt(path: String): Boolean = - container.get(path).read(0 until 16L).isSuccess - - /** - * Returns the Archive entry data at the given [path] in this file. - */ - internal suspend fun readArchiveEntryAt(path: String): ByteArray? { - val archive = container - - return withContext(Dispatchers.IO) { - archive.get(path).use { - it.read().getOrNull() - } - } - } - - /** - * Returns whether all the Archive entry paths satisfy the given `predicate`. - */ - internal suspend fun archiveEntriesAllSatisfy(predicate: (Container.Entry) -> Boolean): Boolean = - container.entries()?.all(predicate) ?: false - - override suspend fun release() { - container.close() - } -} - -internal class UrlSnifferContextFactory( - private val resourceFactory: ResourceFactory, - private val containerFactory: ContainerFactory, - private val archiveFactory: ArchiveFactory -) { - - suspend fun createContext( - url: Url, - mediaTypes: List = emptyList(), - fileExtensions: List = emptyList() - ): ContentAwareSnifferContext? { - val resource = resourceFactory - .create(url) - .getOrElse { - when (it) { - is ResourceFactory.Error.NotAResource -> - return tryCreateContainerContext( - url = url, - mediaTypes = mediaTypes, - fileExtensions = fileExtensions - ) - else -> return null - } - } - - return archiveFactory.create(resource, password = null) - .fold( - { - ContainerSnifferContext( - container = it, - isExploded = false, - mediaTypes = mediaTypes, - fileExtensions = fileExtensions - ) - }, - { - ResourceSnifferContext( - resource = resource, - mediaTypes = mediaTypes + - listOfNotNull(resource.mediaType().getOrNull()?.toString()), - fileExtensions = fileExtensions + - listOfNotNull(resource.source?.filename?.let { File(it).extension }) - ) - } - ) - } - - private suspend fun tryCreateContainerContext( - url: Url, - mediaTypes: List, - fileExtensions: List - ): ContentAwareSnifferContext? { - val container = containerFactory.create(url) - .getOrNull() - ?: return null - - return ContainerSnifferContext( - container = container, - isExploded = true, - mediaTypes = mediaTypes, - fileExtensions = fileExtensions - ) - } -} - -internal class BytesSnifferContextFactory( - private val archiveFactory: ArchiveFactory -) { - - suspend fun createContext( - bytes: ByteArray, - mediaTypes: List = emptyList(), - fileExtensions: List = emptyList() - ): ContentAwareSnifferContext { - val resource: Resource = BytesResource(bytes) - return archiveFactory.create(resource, password = null) - .fold( - { ContainerSnifferContext(it, false, mediaTypes, fileExtensions) }, - { ResourceSnifferContext(resource, mediaTypes, fileExtensions) } - ) - } -} - */ From c27c939f6f6084bb70fd1ee9d13d0d507602b730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Sun, 20 Aug 2023 11:50:45 +0200 Subject: [PATCH 05/24] Fix reading remote web publication --- .../readium/r2/shared/publication/Manifest.kt | 8 ++-- .../r2/shared/resource/RoutingContainer.kt | 6 +-- .../readium/r2/streamer/ParserAssetFactory.kt | 48 +++++++++++-------- .../readium/r2/streamer/PublicationFactory.kt | 1 + .../r2/streamer/parser/PublicationParser.kt | 12 +++++ .../parser/readium/ReadiumWebPubParser.kt | 5 +- .../r2/testapp/reader/ReaderRepository.kt | 3 +- 7 files changed, 54 insertions(+), 29 deletions(-) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/Manifest.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/Manifest.kt index b4b285ed96..6b6acc509f 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/Manifest.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/Manifest.kt @@ -192,12 +192,10 @@ public data class Manifest( warnings ) .map { - if (!packaged || "self" !in it.rels) { - it + if (packaged && "self" in it.rels) { + it.copy(rels = it.rels - "self" + "alternate") } else { - it.copy( - rels = it.rels - "self" + "alternate" - ) + it } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/RoutingContainer.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/RoutingContainer.kt index 14e0dc535c..df32dc7f2d 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/RoutingContainer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/RoutingContainer.kt @@ -7,7 +7,7 @@ package org.readium.r2.shared.resource import org.readium.r2.shared.util.Url -import org.readium.r2.shared.util.isFile +import org.readium.r2.shared.util.isHttp /** * Routes requests to child containers, depending on a provided predicate. @@ -50,6 +50,6 @@ public class RoutingContainer(private val routes: List) : Container { } private fun isLocal(path: String): Boolean { - val url = Url(path) ?: return false - return url.isFile() + val url = Url(path) ?: return true + return !url.isHttp() } diff --git a/readium/streamer/src/main/java/org/readium/r2/streamer/ParserAssetFactory.kt b/readium/streamer/src/main/java/org/readium/r2/streamer/ParserAssetFactory.kt index 2a16c927f4..61aad00092 100644 --- a/readium/streamer/src/main/java/org/readium/r2/streamer/ParserAssetFactory.kt +++ b/readium/streamer/src/main/java/org/readium/r2/streamer/ParserAssetFactory.kt @@ -17,7 +17,6 @@ import org.readium.r2.shared.error.getOrElse import org.readium.r2.shared.error.getOrThrow import org.readium.r2.shared.publication.Manifest import org.readium.r2.shared.publication.Publication -import org.readium.r2.shared.resource.Container import org.readium.r2.shared.resource.Resource import org.readium.r2.shared.resource.ResourceContainer import org.readium.r2.shared.resource.RoutingContainer @@ -35,32 +34,36 @@ internal class ParserAssetFactory( ): Try { return when (asset) { is Asset.Container -> - createParserAssetForContainer(asset.container, asset.format.mediaType) + createParserAssetForContainer(asset) is Asset.Resource -> - createParserAssetForResource(asset.resource, asset.format.mediaType) + createParserAssetForResource(asset) } } private fun createParserAssetForContainer( - container: Container, - mediaType: MediaType + asset: Asset.Container ): Try = - Try.success(PublicationParser.Asset(mediaType, container)) + Try.success( + PublicationParser.Asset( + sourceAsset = asset, + mediaType = asset.format.mediaType, + container = asset.container + ) + ) private suspend fun createParserAssetForResource( - resource: Resource, - mediaType: MediaType + asset: Asset.Resource ): Try = - if (mediaType.isRwpm) { - createParserAssetForManifest(resource) + if (asset.format.mediaType.isRwpm) { + createParserAssetForManifest(asset) } else { - createParserAssetForContent(resource, mediaType) + createParserAssetForContent(asset) } private suspend fun createParserAssetForManifest( - resource: Resource + asset: Asset.Resource ): Try { - val manifest = resource.readAsRwpm(packaged = false) + val manifest = asset.resource.readAsRwpm(packaged = false) .mapFailure { Publication.OpeningException.ParsingFailed(ThrowableError(it)) } .getOrElse { return Try.failure(it) } @@ -82,26 +85,33 @@ internal class ParserAssetFactory( val container = RoutingContainer( - local = ResourceContainer("/manifest.json", resource), + local = ResourceContainer("/manifest.json", asset.resource), remote = HttpContainer(httpClient, baseUrl) ) return Try.success( - PublicationParser.Asset(MediaType.READIUM_WEBPUB, container) + PublicationParser.Asset( + sourceAsset = asset, + mediaType = MediaType.READIUM_WEBPUB, + container = container + ) ) } private fun createParserAssetForContent( - resource: Resource, - mediaType: MediaType + asset: Asset.Resource ): Try { // Historically, the reading order of a standalone file contained a single link with the // HREF "/$assetName". This was fragile if the asset named changed, or was different on // other devices. To avoid this, we now use a single link with the HREF ".". - val container = ResourceContainer(".", resource) + val container = ResourceContainer(".", asset.resource) return Try.success( - PublicationParser.Asset(mediaType, container) + PublicationParser.Asset( + sourceAsset = asset, + mediaType = asset.format.mediaType, + container = container + ) ) } diff --git a/readium/streamer/src/main/java/org/readium/r2/streamer/PublicationFactory.kt b/readium/streamer/src/main/java/org/readium/r2/streamer/PublicationFactory.kt index 1c4829dd4e..47204c7c80 100644 --- a/readium/streamer/src/main/java/org/readium/r2/streamer/PublicationFactory.kt +++ b/readium/streamer/src/main/java/org/readium/r2/streamer/PublicationFactory.kt @@ -167,6 +167,7 @@ public class PublicationFactory( ?: return Try.failure(Publication.OpeningException.Forbidden()) val parserAsset = PublicationParser.Asset( + sourceAsset = asset, protectedAsset.mediaType, protectedAsset.container ) diff --git a/readium/streamer/src/main/java/org/readium/r2/streamer/parser/PublicationParser.kt b/readium/streamer/src/main/java/org/readium/r2/streamer/parser/PublicationParser.kt index 5388d3682c..6feb4058a9 100644 --- a/readium/streamer/src/main/java/org/readium/r2/streamer/parser/PublicationParser.kt +++ b/readium/streamer/src/main/java/org/readium/r2/streamer/parser/PublicationParser.kt @@ -6,6 +6,7 @@ package org.readium.r2.streamer.parser +import org.readium.r2.shared.asset.Asset as SharedAsset import org.readium.r2.shared.error.MessageError import org.readium.r2.shared.error.ThrowableError import org.readium.r2.shared.error.Try @@ -20,7 +21,18 @@ import org.readium.r2.shared.util.mediatype.MediaType */ public interface PublicationParser { + /** + * Full publication asset. + * + * @param sourceAsset Asset of the source used to build the publication. It can be a package, + * a JSON manifest, a LCP license, etc. + * @param mediaType Media type of the "virtual" publication asset, built from the source asset. + * For example, if the source asset was a `application/audiobook+json`, the "virtual" asset + * media type will be `application/audiobook+zip`. + * @param container Container granting access to the resources of the publication. + */ public data class Asset( + val sourceAsset: SharedAsset, val mediaType: MediaType, val container: Container ) diff --git a/readium/streamer/src/main/java/org/readium/r2/streamer/parser/readium/ReadiumWebPubParser.kt b/readium/streamer/src/main/java/org/readium/r2/streamer/parser/readium/ReadiumWebPubParser.kt index 12a3b95b0d..5963a2de1e 100644 --- a/readium/streamer/src/main/java/org/readium/r2/streamer/parser/readium/ReadiumWebPubParser.kt +++ b/readium/streamer/src/main/java/org/readium/r2/streamer/parser/readium/ReadiumWebPubParser.kt @@ -40,7 +40,10 @@ public class ReadiumWebPubParser( .readAsJson() .getOrElse { return Try.failure(PublicationParser.Error.IO(it)) } - val manifest = Manifest.fromJSON(manifestJson, packaged = !asset.mediaType.isRwpm) + val manifest = Manifest.fromJSON( + manifestJson, + packaged = !asset.sourceAsset.format.mediaType.isRwpm + ) ?: return Try.failure( PublicationParser.Error.ParsingFailed("Failed to parse the RWPM Manifest") ) diff --git a/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt index da554ecfdc..ff8ac63e83 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt @@ -25,6 +25,7 @@ import org.readium.r2.shared.error.Try import org.readium.r2.shared.error.getOrElse import org.readium.r2.shared.publication.Locator import org.readium.r2.shared.publication.Publication +import org.readium.r2.shared.publication.allAreHtml import org.readium.r2.shared.publication.services.isRestricted import org.readium.r2.shared.util.Url import org.readium.r2.testapp.PublicationError @@ -118,7 +119,7 @@ class ReaderRepository( val readerInitData = when { publication.conformsTo(Publication.Profile.AUDIOBOOK) -> openAudio(bookId, publication, initialLocator) - publication.conformsTo(Publication.Profile.EPUB) -> + publication.conformsTo(Publication.Profile.EPUB) || publication.readingOrder.allAreHtml -> openEpub(bookId, publication, initialLocator) publication.conformsTo(Publication.Profile.PDF) -> openPdf(bookId, publication, initialLocator) From bf320f3d91db7468eb032cbfedf8ae72fdabfa92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Sun, 20 Aug 2023 11:53:11 +0200 Subject: [PATCH 06/24] Fix database naming convention in the test app --- .gitignore | 3 ++- .../r2/testapp/domain/model/Bookmark.kt | 22 +++++++++---------- .../r2/testapp/domain/model/Catalog.kt | 10 ++++----- test-app/src/main/res/values/arrays.xml | 6 ++--- 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index 3070fa2ac9..972d91fb72 100644 --- a/.gitignore +++ b/.gitignore @@ -82,4 +82,5 @@ lint/reports/ docs/readium docs/index.md docs/package-list -site/ \ No newline at end of file +site/ +androidTestResultsUserPreferences.xml diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/model/Bookmark.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/model/Bookmark.kt index 8ac7ef2580..b2dd2cbb1a 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/model/Bookmark.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/model/Bookmark.kt @@ -17,7 +17,7 @@ import org.readium.r2.shared.publication.Locator tableName = Bookmark.TABLE_NAME, indices = [ Index( - value = ["BOOK_ID", "LOCATION"], + value = [Bookmark.BOOK_ID, Bookmark.LOCATION], unique = true ) ] @@ -55,15 +55,15 @@ data class Bookmark( companion object { - const val TABLE_NAME = "BOOKMARKS" - const val ID = "ID" - const val CREATION_DATE = "CREATION_DATE" - const val BOOK_ID = "BOOK_ID" - const val RESOURCE_INDEX = "RESOURCE_INDEX" - const val RESOURCE_HREF = "RESOURCE_HREF" - const val RESOURCE_TYPE = "RESOURCE_TYPE" - const val RESOURCE_TITLE = "RESOURCE_TITLE" - const val LOCATION = "LOCATION" - const val LOCATOR_TEXT = "LOCATOR_TEXT" + const val TABLE_NAME = "bookmarks" + const val ID = "id" + const val CREATION_DATE = "creation_date" + const val BOOK_ID = "book_id" + const val RESOURCE_INDEX = "resource_index" + const val RESOURCE_HREF = "resource_href" + const val RESOURCE_TYPE = "resource_type" + const val RESOURCE_TITLE = "resource_title" + const val LOCATION = "location" + const val LOCATOR_TEXT = "locator_text" } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/model/Catalog.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/model/Catalog.kt index 2eb3073a0a..f04c61fe18 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/model/Catalog.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/model/Catalog.kt @@ -27,10 +27,10 @@ data class Catalog( ) : Parcelable { companion object { - const val TABLE_NAME = "CATALOG" - const val ID = "ID" - const val TITLE = "TITLE" - const val HREF = "HREF" - const val TYPE = "TYPE" + const val TABLE_NAME = "catalogs" + const val ID = "id" + const val TITLE = "title" + const val HREF = "href" + const val TYPE = "type" } } diff --git a/test-app/src/main/res/values/arrays.xml b/test-app/src/main/res/values/arrays.xml index 500d03ecaa..8cb4f5dc9a 100644 --- a/test-app/src/main/res/values/arrays.xml +++ b/test-app/src/main/res/values/arrays.xml @@ -1,9 +1,9 @@ - Import to app storage - Add from shared storage - Add from the Web + Copy to app storage + Read from shared storage + Stream from the Web \ No newline at end of file From a67f52d5cf68961ece6171ec360a5119a9e4d821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Mon, 21 Aug 2023 10:44:32 +0200 Subject: [PATCH 07/24] Move multi rounds sniffing strategy to `OptimizedRoundsMediaTypeSniffer` --- .../java/org/readium/r2/lcp/LcpService.kt | 4 ++- .../lcp/license/container/LicenseContainer.kt | 2 +- .../readium/r2/lcp/service/NetworkService.kt | 8 ++--- .../readium/r2/shared/asset/AssetRetriever.kt | 10 +++--- .../org/readium/r2/shared/format/Format.kt | 33 ++++--------------- .../shared/resource/DefaultArchiveFactory.kt | 6 ++-- .../r2/shared/resource/DirectoryContainer.kt | 10 +++--- .../r2/shared/resource/FileResource.kt | 18 +++++----- .../r2/shared/resource/ZipContainer.kt | 12 +++---- .../archive/channel/ChannelZipContainer.kt | 14 ++++---- .../r2/shared/util/http/DefaultHttpClient.kt | 17 +++++----- .../shared/util/mediatype/MediaTypeSniffer.kt | 33 ++++++++++++++++--- .../java/org/readium/r2/testapp/Readium.kt | 15 +++++---- 13 files changed, 96 insertions(+), 86 deletions(-) diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt index bf5bfe3c2d..534f97095d 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt @@ -167,7 +167,9 @@ public interface LcpService { val deviceRepository = DeviceRepository(db) val passphraseRepository = PassphrasesRepository(db) val licenseRepository = LicensesRepository(db) - val network = NetworkService(formatRegistry = formatRegistry) + val network = NetworkService( + mediaTypeSniffer = { formatRegistry.retrieve(it)?.mediaType } + ) val device = DeviceService( repository = deviceRepository, network = network, 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 90a1b719b9..2376601a4b 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 @@ -42,7 +42,7 @@ internal fun createLicenseContainer( else -> ZIPLicenseContainer(file.path, LICENSE_IN_RPF) } -internal suspend fun createLicenseContainer( +internal fun createLicenseContainer( asset: Asset ): LicenseContainer = when (asset) { 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 81e5a6050d..2116eb534b 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 @@ -22,10 +22,10 @@ import kotlinx.coroutines.withContext import org.readium.r2.lcp.LcpException import org.readium.r2.shared.error.Try import org.readium.r2.shared.format.FormatHints -import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.util.http.invoke import org.readium.r2.shared.util.mediatype.HintMediaTypeSnifferContext import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeSniffer import timber.log.Timber internal typealias URLParameters = Map @@ -36,7 +36,7 @@ internal class NetworkException(val status: Int?, cause: Throwable? = null) : Ex ) internal class NetworkService( - private val formatRegistry: FormatRegistry + private val mediaTypeSniffer: MediaTypeSniffer ) { enum class Method(val value: String) { GET("GET"), POST("POST"), PUT("PUT"); @@ -140,11 +140,11 @@ internal class NetworkService( } } - formatRegistry.retrieve( + mediaTypeSniffer.sniff( HintMediaTypeSnifferContext( hints = FormatHints(connection, mediaType = mediaType) ) - )?.mediaType + ) } catch (e: Exception) { Timber.e(e) throw LcpException.Network(e) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt b/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt index 53887f1bb1..d935f87385 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt @@ -30,6 +30,7 @@ import org.readium.r2.shared.resource.Resource import org.readium.r2.shared.resource.ResourceFactory import org.readium.r2.shared.resource.ResourceMediaTypeSnifferContext import org.readium.r2.shared.util.Url +import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.toUrl @@ -43,12 +44,13 @@ public class AssetRetriever( public companion object { public operator fun invoke(context: Context): AssetRetriever { - val formatRegistry = FormatRegistry() + val mediaTypeSniffer = DefaultMediaTypeSniffer() + val formatRegistry = FormatRegistry(mediaTypeSniffer) return AssetRetriever( formatRegistry = formatRegistry, - resourceFactory = FileResourceFactory(formatRegistry), - containerFactory = DirectoryContainerFactory(formatRegistry), - archiveFactory = DefaultArchiveFactory(formatRegistry), + resourceFactory = FileResourceFactory(mediaTypeSniffer), + containerFactory = DirectoryContainerFactory(mediaTypeSniffer), + archiveFactory = DefaultArchiveFactory(mediaTypeSniffer), contentResolver = context.contentResolver ) } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/format/Format.kt b/readium/shared/src/main/java/org/readium/r2/shared/format/Format.kt index 1f1ff8b68e..452fd17141 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/format/Format.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/format/Format.kt @@ -6,13 +6,13 @@ package org.readium.r2.shared.format -import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer import org.readium.r2.shared.util.mediatype.HintMediaTypeSnifferContext import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.mediatype.MediaTypeSniffer import org.readium.r2.shared.util.mediatype.MediaTypeSnifferContext public class FormatRegistry( + private val sniffer: MediaTypeSniffer, formats: List = listOf( // The known formats are not declared as public constants to discourage comparing the format // instead of the media type for equality. @@ -91,9 +91,9 @@ public class FormatRegistry( name = "Zipped Audio Book", fileExtension = "zab" ) - ), - private val sniffer: MediaTypeSniffer = DefaultMediaTypeSniffer() + ) ) { + private val formats: MutableMap = formats.associateBy { it.mediaType }.toMutableMap() @@ -108,31 +108,10 @@ public class FormatRegistry( retrieve(HintMediaTypeSnifferContext(hints = FormatHints(mediaType))) ?: Format(mediaType) - public suspend fun retrieve(context: MediaTypeSnifferContext): Format? { - suspend fun doRetrieve(context: MediaTypeSnifferContext): Format? = - sniffer.sniff(context)?.let { - formats[it] ?: Format(it) - } - - // Light sniffing with only media type hints - if (context.hints.mediaTypes.isNotEmpty()) { - doRetrieve( - HintMediaTypeSnifferContext( - hints = context.hints.copy(fileExtensions = emptyList()) - ) - ) - ?.let { return it } + public suspend fun retrieve(context: MediaTypeSnifferContext): Format? = + sniffer.sniff(context)?.let { + formats[it] ?: Format(it) } - - // Light sniffing with both media type hints and file extensions - if (context.hints.fileExtensions.isNotEmpty()) { - doRetrieve(HintMediaTypeSnifferContext(hints = context.hints)) - ?.let { return it } - } - - // Fallback on heavy sniffing - return doRetrieve(context) - } } /** diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/DefaultArchiveFactory.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/DefaultArchiveFactory.kt index 86074a39d7..8f4b21ddba 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/DefaultArchiveFactory.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/DefaultArchiveFactory.kt @@ -13,11 +13,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.readium.r2.shared.error.MessageError import org.readium.r2.shared.error.Try -import org.readium.r2.shared.format.FormatRegistry +import org.readium.r2.shared.util.mediatype.MediaTypeSniffer import org.readium.r2.shared.util.toFile public class DefaultArchiveFactory( - private val formatRegistry: FormatRegistry + private val mediaTypeSniffer: MediaTypeSniffer ) : ArchiveFactory { override suspend fun create(resource: Resource, password: String?): Try { @@ -38,7 +38,7 @@ public class DefaultArchiveFactory( internal suspend fun open(file: File): Try = withContext(Dispatchers.IO) { try { - val archive = JavaZipContainer(ZipFile(file), file, formatRegistry) + val archive = JavaZipContainer(ZipFile(file), file, mediaTypeSniffer) Try.success(archive) } catch (e: ZipException) { Try.failure(ArchiveFactory.Error.FormatNotSupported(e)) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/DirectoryContainer.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/DirectoryContainer.kt index f5768b46d6..df93e7cd90 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/DirectoryContainer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/DirectoryContainer.kt @@ -14,8 +14,8 @@ import org.readium.r2.shared.error.Try import org.readium.r2.shared.extensions.addPrefix import org.readium.r2.shared.extensions.isParentOf import org.readium.r2.shared.extensions.tryOr -import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.util.Url +import org.readium.r2.shared.util.mediatype.MediaTypeSniffer /** * A file system directory as a [Container]. @@ -23,11 +23,11 @@ import org.readium.r2.shared.util.Url internal class DirectoryContainer( private val root: File, private val entries: List, - private val formatRegistry: FormatRegistry + private val mediaTypeSniffer: MediaTypeSniffer ) : Container { private inner class FileEntry(file: File) : - Container.Entry, Resource by FileResource(file, formatRegistry) { + Container.Entry, Resource by FileResource(file, mediaTypeSniffer) { override val path: String = file.relativeTo(root).path.addPrefix("/") @@ -52,7 +52,7 @@ internal class DirectoryContainer( } public class DirectoryContainerFactory( - private val formatRegistry: FormatRegistry + private val mediaTypeSniffer: MediaTypeSniffer ) : ContainerFactory { override suspend fun create(url: Url): Try { @@ -82,7 +82,7 @@ public class DirectoryContainerFactory( return Try.failure(ContainerFactory.Error.Forbidden(e)) } - val container = DirectoryContainer(file, entries, formatRegistry) + val container = DirectoryContainer(file, entries, mediaTypeSniffer) return Try.success(container) } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt index 37d1466d3a..e5d346865a 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt @@ -16,27 +16,27 @@ import org.readium.r2.shared.error.Try import org.readium.r2.shared.error.getOrThrow import org.readium.r2.shared.extensions.* import org.readium.r2.shared.format.FormatHints -import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.isFile import org.readium.r2.shared.util.isLazyInitialized import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeSniffer /** * A [Resource] to access a [file]. */ -public class FileResource internal constructor( +public class FileResource private constructor( private val file: File, private val mediaType: MediaType?, - private val formatRegistry: FormatRegistry? + private val mediaTypeSniffer: MediaTypeSniffer? ) : Resource { public constructor(file: File, mediaType: MediaType) : this(file, mediaType, null) - public constructor(file: File, formatRegistry: FormatRegistry) : this( + public constructor(file: File, mediaTypeSniffer: MediaTypeSniffer) : this( file, null, - formatRegistry + mediaTypeSniffer ) private val randomAccessFile by lazy { @@ -52,12 +52,12 @@ public class FileResource internal constructor( override suspend fun mediaType(): ResourceTry = Try.success( mediaType - ?: formatRegistry?.retrieve( + ?: mediaTypeSniffer?.sniff( ResourceMediaTypeSnifferContext( resource = this, hints = FormatHints(fileExtension = file.extension) ) - )?.mediaType + ) ) override suspend fun close() { @@ -134,7 +134,7 @@ public class FileResource internal constructor( } public class FileResourceFactory( - private val formatRegistry: FormatRegistry + private val mediaTypeSniffer: MediaTypeSniffer ) : ResourceFactory { override suspend fun create(url: Url): Try { @@ -152,6 +152,6 @@ public class FileResourceFactory( return Try.failure(ResourceFactory.Error.Forbidden(e)) } - return Try.success(FileResource(file, formatRegistry)) + return Try.success(FileResource(file, mediaTypeSniffer)) } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt index e3809e75b9..52134295c0 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt @@ -23,10 +23,10 @@ import org.readium.r2.shared.extensions.readFully import org.readium.r2.shared.extensions.toMap import org.readium.r2.shared.extensions.tryOrLog import org.readium.r2.shared.format.FormatHints -import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.io.CountingInputStream import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeSniffer import org.readium.r2.shared.util.toUrl /** @@ -98,7 +98,7 @@ public var Resource.Properties.Builder.archive: ArchiveProperties? internal class JavaZipContainer( private val archive: ZipFile, file: File, - private val formatRegistry: FormatRegistry + private val mediaTypeSniffer: MediaTypeSniffer ) : ZipContainer { private inner class FailureEntry(override val path: String) : ZipContainer.Entry { @@ -109,12 +109,12 @@ internal class JavaZipContainer( override suspend fun mediaType(): ResourceTry = Try.success( - formatRegistry.retrieve( + mediaTypeSniffer.sniff( ResourceMediaTypeSnifferContext( resource = this, hints = FormatHints(fileExtension = File(path).extension) ) - )?.mediaType + ) ) override suspend fun properties(): ResourceTry = @@ -139,12 +139,12 @@ internal class JavaZipContainer( override suspend fun mediaType(): ResourceTry = Try.success( - formatRegistry.retrieve( + mediaTypeSniffer.sniff( ResourceMediaTypeSnifferContext( resource = this, hints = FormatHints(fileExtension = File(path).extension) ) - )?.mediaType + ) ) override suspend fun properties(): ResourceTry = diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt index 4c962cb2c9..cbc0f8abe8 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt @@ -15,7 +15,6 @@ import org.readium.r2.shared.extensions.addPrefix import org.readium.r2.shared.extensions.readFully import org.readium.r2.shared.extensions.tryOrLog import org.readium.r2.shared.format.FormatHints -import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.resource.ArchiveFactory import org.readium.r2.shared.resource.ArchiveProperties import org.readium.r2.shared.resource.Container @@ -31,10 +30,11 @@ import org.readium.r2.shared.util.archive.channel.compress.archivers.zip.ZipFile import org.readium.r2.shared.util.archive.channel.jvm.SeekableByteChannel import org.readium.r2.shared.util.io.CountingInputStream import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeSniffer internal class ChannelZipContainer( private val archive: ZipFile, - private val formatRegistry: FormatRegistry + private val mediaTypeSniffer: MediaTypeSniffer ) : ZipContainer { private inner class FailureEntry( @@ -63,12 +63,12 @@ internal class ChannelZipContainer( override suspend fun mediaType(): ResourceTry = Try.success( - formatRegistry.retrieve( + mediaTypeSniffer.sniff( ResourceMediaTypeSnifferContext( resource = this, hints = FormatHints(fileExtension = File(path).extension) ) - )?.mediaType + ) ) override suspend fun length(): ResourceTry = @@ -171,7 +171,7 @@ internal class ChannelZipContainer( * An [ArchiveFactory] able to open a ZIP archive served through an HTTP server. */ public class ChannelZipArchiveFactory( - private val formatRegistry: FormatRegistry + private val mediaTypeSniffer: MediaTypeSniffer ) : ArchiveFactory { override suspend fun create( @@ -186,7 +186,7 @@ public class ChannelZipArchiveFactory( val resourceChannel = ResourceChannel(resource) val channel = wrapBaseChannel(resourceChannel) val zipFile = ZipFile(channel, true) - val channelZip = ChannelZipContainer(zipFile, formatRegistry) + val channelZip = ChannelZipContainer(zipFile, mediaTypeSniffer) Try.success(channelZip) } catch (e: Resource.Exception) { Try.failure(ArchiveFactory.Error.ResourceReading(e)) @@ -198,7 +198,7 @@ public class ChannelZipArchiveFactory( internal fun openFile(file: File): Container { val fileChannel = FileChannelAdapter(file, "r") val channel = wrapBaseChannel(fileChannel) - return ChannelZipContainer(ZipFile(channel), formatRegistry) + return ChannelZipContainer(ZipFile(channel), mediaTypeSniffer) } private fun wrapBaseChannel(channel: SeekableByteChannel): SeekableByteChannel { diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/http/DefaultHttpClient.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/http/DefaultHttpClient.kt index 6c0afbb98d..a734754895 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/http/DefaultHttpClient.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/http/DefaultHttpClient.kt @@ -18,18 +18,17 @@ import org.readium.r2.shared.error.Try import org.readium.r2.shared.error.flatMap import org.readium.r2.shared.error.tryRecover import org.readium.r2.shared.format.FormatHints -import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.util.http.HttpRequest.Method import org.readium.r2.shared.util.mediatype.BytesContentMediaTypeSnifferContext import org.readium.r2.shared.util.mediatype.HintMediaTypeSnifferContext import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeSniffer import timber.log.Timber /** * An implementation of [HttpClient] using the native [HttpURLConnection]. * - * @param formatRegistry Registry of supported media formats to resolve the media type of a - * response. + * @param mediaTypeSniffer Component used to sniff the media type of the HTTP response. * @param userAgent Custom user agent to use for requests. * @param additionalHeaders A dictionary of additional headers to send with requests. * @param connectTimeout Timeout used when establishing a connection to the resource. A null timeout @@ -38,7 +37,7 @@ import timber.log.Timber * as the default value, while a timeout of zero as an infinite timeout. */ public class DefaultHttpClient( - private val formatRegistry: FormatRegistry, + private val mediaTypeSniffer: MediaTypeSniffer, private val userAgent: String? = null, private val additionalHeaders: Map = mapOf(), private val connectTimeout: Duration? = null, @@ -143,18 +142,18 @@ public class DefaultHttpClient( // Reads the full body, since it might contain an error representation such as // JSON Problem Details or OPDS Authentication Document val body = connection.errorStream?.use { it.readBytes() } - val format = body?.let { - formatRegistry.retrieve( + val mediaType = body?.let { + mediaTypeSniffer.sniff( BytesContentMediaTypeSnifferContext( hints = FormatHints(connection), bytes = { it } ) ) } - throw HttpException(kind, format?.mediaType, body) + throw HttpException(kind, mediaType, body) } - val format = formatRegistry.retrieve( + val mediaType = mediaTypeSniffer.sniff( HintMediaTypeSnifferContext( hints = FormatHints(connection) ) @@ -165,7 +164,7 @@ public class DefaultHttpClient( url = connection.url.toString(), statusCode = statusCode, headers = connection.safeHeaders, - mediaType = format?.mediaType ?: MediaType.BINARY + mediaType = mediaType ?: MediaType.BINARY ) callback.onResponseReceived(request, response) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt index ab38dad75b..08d308482b 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt @@ -22,6 +22,12 @@ public fun interface MediaTypeSniffer { public suspend fun sniff(context: MediaTypeSnifferContext): MediaType? } +/** + * The default sniffer provided by Readium 2 to resolve a [MediaType]. + */ +public class DefaultMediaTypeSniffer : + OptimizedRoundsMediaTypeSniffer(CompositeMediaTypeSniffer(MediaTypeSniffers.all)) + public open class CompositeMediaTypeSniffer( private val sniffers: List ) : MediaTypeSniffer { @@ -30,10 +36,29 @@ public open class CompositeMediaTypeSniffer( sniffers.firstNotNullOfOrNull { it.sniff(context) } } -/** - * The default sniffer provided by Readium 2 to resolve a [MediaType]. - */ -public class DefaultMediaTypeSniffer : CompositeMediaTypeSniffer(MediaTypeSniffers.all) +public open class OptimizedRoundsMediaTypeSniffer( + private val sniffer: MediaTypeSniffer +) : MediaTypeSniffer { + override suspend fun sniff(context: MediaTypeSnifferContext): MediaType? { + // Light sniffing with only media type hints + if (context.hints.mediaTypes.isNotEmpty()) { + sniffer.sniff( + HintMediaTypeSnifferContext( + hints = context.hints.copy(fileExtensions = emptyList()) + ) + )?.let { return it } + } + + // Light sniffing with both media type hints and file extensions + if (context.hints.fileExtensions.isNotEmpty()) { + sniffer.sniff(HintMediaTypeSnifferContext(hints = context.hints)) + ?.let { return it } + } + + // Fallback on heavy sniffing + return sniffer.sniff(context) + } +} /** * Default media type sniffers provided by Readium. diff --git a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt index 865d29035a..3cd37399e0 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt @@ -25,6 +25,7 @@ import org.readium.r2.shared.resource.FileResourceFactory import org.readium.r2.shared.util.archive.channel.ChannelZipArchiveFactory import org.readium.r2.shared.util.http.DefaultHttpClient import org.readium.r2.shared.util.http.HttpResourceFactory +import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer import org.readium.r2.streamer.PublicationFactory /** @@ -32,19 +33,21 @@ import org.readium.r2.streamer.PublicationFactory */ class Readium(context: Context) { - private val formatRegistry = FormatRegistry() + private val mediaTypeSniffer = DefaultMediaTypeSniffer() + + private val formatRegistry = FormatRegistry(mediaTypeSniffer) val httpClient = DefaultHttpClient( - formatRegistry = formatRegistry + mediaTypeSniffer = mediaTypeSniffer ) private val archiveFactory = CompositeArchiveFactory( - DefaultArchiveFactory(formatRegistry), - ChannelZipArchiveFactory(formatRegistry) + DefaultArchiveFactory(mediaTypeSniffer), + ChannelZipArchiveFactory(mediaTypeSniffer) ) private val resourceFactory = CompositeResourceFactory( - FileResourceFactory(formatRegistry), + FileResourceFactory(mediaTypeSniffer), CompositeResourceFactory( ContentResourceFactory(context.contentResolver), HttpResourceFactory(httpClient) @@ -52,7 +55,7 @@ class Readium(context: Context) { ) private val containerFactory = DirectoryContainerFactory( - formatRegistry + mediaTypeSniffer ) val assetRetriever = AssetRetriever( From 1fdf5d37fdb171e96c80e2078627dc99abf3db4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Mon, 21 Aug 2023 11:22:59 +0200 Subject: [PATCH 08/24] Remove Format from Asset --- .../readium/r2/lcp/LcpContentProtection.kt | 4 +-- .../java/org/readium/r2/lcp/LcpService.kt | 13 +++---- .../lcp/license/container/LicenseContainer.kt | 4 +-- .../readium/r2/lcp/service/LicensesService.kt | 22 +++++++----- .../java/org/readium/r2/shared/asset/Asset.kt | 20 +++++------ .../readium/r2/shared/asset/AssetRetriever.kt | 36 +++++++++---------- .../AdeptFallbackContentProtection.kt | 4 +-- .../LcpFallbackContentProtection.kt | 6 ++-- .../readium/r2/streamer/ParserAssetFactory.kt | 6 ++-- .../parser/readium/ReadiumWebPubParser.kt | 2 +- .../org/readium/r2/testapp/Application.kt | 3 +- .../java/org/readium/r2/testapp/Readium.kt | 6 ++-- .../r2/testapp/bookshelf/BookRepository.kt | 18 ++++++---- .../r2/testapp/utils/extensions/File.kt | 2 +- 14 files changed, 75 insertions(+), 71 deletions(-) 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 f79cea44be..9b713ab0fe 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 @@ -81,7 +81,7 @@ internal class LcpContentProtection( ?.let { lcpService.retrieveLicense( it, - asset.format.mediaType, + asset.mediaType, authentication, allowUserInteraction, sender @@ -102,7 +102,7 @@ internal class LcpContentProtection( val container = TransformingContainer(asset.container, decryptor::transform) val protectedFile = ContentProtection.Asset( - mediaType = asset.format.mediaType, + mediaType = asset.mediaType, container = container, onCreatePublication = { decryptor.encryptionData = (manifest.readingOrder + manifest.resources + manifest.links) diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt index 534f97095d..72a977ea7d 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt @@ -31,9 +31,9 @@ import org.readium.r2.lcp.service.PassphrasesService import org.readium.r2.shared.asset.Asset import org.readium.r2.shared.asset.AssetRetriever import org.readium.r2.shared.error.Try -import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.publication.protection.ContentProtection import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeSniffer /** * Service used to acquire and open publications protected with LCP. @@ -157,7 +157,7 @@ public interface LcpService { public operator fun invoke( context: Context, assetRetriever: AssetRetriever, - formatRegistry: FormatRegistry + mediaTypeSniffer: MediaTypeSniffer ): LcpService? { if (!LcpClient.isAvailable()) { return null @@ -167,9 +167,7 @@ public interface LcpService { val deviceRepository = DeviceRepository(db) val passphraseRepository = PassphrasesRepository(db) val licenseRepository = LicensesRepository(db) - val network = NetworkService( - mediaTypeSniffer = { formatRegistry.retrieve(it)?.mediaType } - ) + val network = NetworkService(mediaTypeSniffer) val device = DeviceService( repository = deviceRepository, network = network, @@ -184,15 +182,14 @@ public interface LcpService { network = network, passphrases = passphrases, context = context, - assetRetriever = assetRetriever, - formatRegistry = formatRegistry + assetRetriever = assetRetriever ) } @Suppress("UNUSED_PARAMETER") @Deprecated( "Use `LcpService()` instead", - ReplaceWith("LcpService(context, AssetRetriever(), FormatRegistry())"), + ReplaceWith("LcpService(context, AssetRetriever(), DefaultMediaTypeSniffer())"), level = DeprecationLevel.ERROR ) public fun create(context: Context): LcpService? = throw NotImplementedError() 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 2376601a4b..a9b3893e01 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 @@ -46,8 +46,8 @@ internal fun createLicenseContainer( asset: Asset ): LicenseContainer = when (asset) { - is Asset.Resource -> createLicenseContainer(asset.resource, asset.format.mediaType) - is Asset.Container -> createLicenseContainer(asset.container, asset.format.mediaType) + is Asset.Resource -> createLicenseContainer(asset.resource, asset.mediaType) + is Asset.Container -> createLicenseContainer(asset.container, asset.mediaType) } internal fun createLicenseContainer( 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 4e0a4a0830..b0e0b6d78a 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 @@ -33,7 +33,6 @@ import org.readium.r2.shared.asset.Asset import org.readium.r2.shared.asset.AssetRetriever import org.readium.r2.shared.error.Try import org.readium.r2.shared.extensions.tryOr -import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.publication.protection.ContentProtection import org.readium.r2.shared.util.mediatype.MediaType import timber.log.Timber @@ -45,8 +44,7 @@ internal class LicensesService( private val network: NetworkService, private val passphrases: PassphrasesService, private val context: Context, - private val assetRetriever: AssetRetriever, - private val formatRegistry: FormatRegistry + private val assetRetriever: AssetRetriever ) : LcpService, CoroutineScope by MainScope() { override suspend fun isLcpProtected(file: File): Boolean { @@ -58,9 +56,9 @@ internal class LicensesService( tryOr(false) { when (asset) { is Asset.Resource -> - asset.format.mediaType == MediaType.LCP_LICENSE_DOCUMENT + asset.mediaType == MediaType.LCP_LICENSE_DOCUMENT is Asset.Container -> { - createLicenseContainer(asset.container, asset.format.mediaType).read() + createLicenseContainer(asset.container, asset.mediaType).read() true } } @@ -249,17 +247,25 @@ internal class LicensesService( onProgress = onProgress ) ?: link.mediaType - val format = formatRegistry.retrieve(mediaType) - // Saves the License Document into the downloaded publication val container = createLicenseContainer(destination, mediaType) container.write(license) return LcpService.AcquiredPublication( localFile = destination, - suggestedFilename = "${license.id}.${format.fileExtension ?: "epub"}", + suggestedFilename = "${license.id}.${mediaType.fileExtension}", mediaType = mediaType, licenseDocument = license ) } + + private val MediaType.fileExtension: String get() = + when { + matches(MediaType.DIVINA) -> "divina" + matches(MediaType.EPUB) -> "epub" + matches(MediaType.LCP_PROTECTED_PDF) -> "pdf" + matches(MediaType.READIUM_AUDIOBOOK) -> "audiobook" + matches(MediaType.READIUM_WEBPUB) -> "webpub" + else -> "epub" + } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/asset/Asset.kt b/readium/shared/src/main/java/org/readium/r2/shared/asset/Asset.kt index 1e48e72618..803317b6d7 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/asset/Asset.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/asset/Asset.kt @@ -6,8 +6,8 @@ package org.readium.r2.shared.asset -import org.readium.r2.shared.format.Format import org.readium.r2.shared.resource.Resource as SharedResource +import org.readium.r2.shared.util.mediatype.MediaType /** * An asset which is either a single resource or a container that holds multiple resources. @@ -17,12 +17,12 @@ public sealed class Asset { /** * Type of the asset source. */ - public abstract val type: AssetType + public abstract val assetType: AssetType /** - * Media format of the asset. + * Media type of the asset. */ - public abstract val format: Format + public abstract val mediaType: MediaType /** * Releases in-memory resources related to this asset. @@ -32,15 +32,15 @@ public sealed class Asset { /** * A single resource asset. * - * @param format Media format of the asset. + * @param mediaType Media type of the asset. * @param resource Opened resource to access the asset. */ public class Resource( - override val format: Format, + override val mediaType: MediaType, public val resource: SharedResource ) : Asset() { - override val type: AssetType = + override val assetType: AssetType = AssetType.Resource override suspend fun close() { @@ -51,17 +51,17 @@ public sealed class Asset { /** * A container asset providing access to several resources. * - * @param format Media format of the asset. + * @param mediaType Media type of the asset. * @param exploded If this container is an exploded or packaged container. * @param container Opened container to access asset resources. */ public class Container( - override val format: Format, + override val mediaType: MediaType, exploded: Boolean, public val container: org.readium.r2.shared.resource.Container ) : Asset() { - override val type: AssetType = + override val assetType: AssetType = if (exploded) { AssetType.Directory } else { diff --git a/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt b/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt index d935f87385..6ee54b4d8c 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt @@ -16,9 +16,7 @@ import org.readium.r2.shared.error.Try import org.readium.r2.shared.error.flatMap import org.readium.r2.shared.error.getOrElse import org.readium.r2.shared.extensions.queryProjection -import org.readium.r2.shared.format.Format import org.readium.r2.shared.format.FormatHints -import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.resource.ArchiveFactory import org.readium.r2.shared.resource.Container import org.readium.r2.shared.resource.ContainerFactory @@ -32,10 +30,11 @@ import org.readium.r2.shared.resource.ResourceMediaTypeSnifferContext import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeSniffer import org.readium.r2.shared.util.toUrl public class AssetRetriever( - private val formatRegistry: FormatRegistry, + private val mediaTypeSniffer: MediaTypeSniffer, private val resourceFactory: ResourceFactory, private val containerFactory: ContainerFactory, private val archiveFactory: ArchiveFactory, @@ -45,9 +44,8 @@ public class AssetRetriever( public companion object { public operator fun invoke(context: Context): AssetRetriever { val mediaTypeSniffer = DefaultMediaTypeSniffer() - val formatRegistry = FormatRegistry(mediaTypeSniffer) return AssetRetriever( - formatRegistry = formatRegistry, + mediaTypeSniffer = mediaTypeSniffer, resourceFactory = FileResourceFactory(mediaTypeSniffer), containerFactory = DirectoryContainerFactory(mediaTypeSniffer), archiveFactory = DefaultArchiveFactory(mediaTypeSniffer), @@ -158,23 +156,21 @@ public class AssetRetriever( mediaType: MediaType, assetType: AssetType ): Try { - val format = formatRegistry.retrieve(mediaType) - return when (assetType) { AssetType.Archive -> - retrieveArchiveAsset(url, format) + retrieveArchiveAsset(url, mediaType) AssetType.Directory -> - retrieveDirectoryAsset(url, format) + retrieveDirectoryAsset(url, mediaType) AssetType.Resource -> - retrieveResourceAsset(url, format) + retrieveResourceAsset(url, mediaType) } } private suspend fun retrieveArchiveAsset( url: Url, - format: Format + mediaType: MediaType ): Try { return retrieveResource(url) .flatMap { resource: Resource -> @@ -190,16 +186,16 @@ public class AssetRetriever( } } } - .map { container -> Asset.Container(format, exploded = false, container) } + .map { container -> Asset.Container(mediaType, exploded = false, container) } } private suspend fun retrieveDirectoryAsset( url: Url, - format: Format + mediaType: MediaType ): Try { return containerFactory.create(url) .map { container -> - Asset.Container(format, exploded = true, container) + Asset.Container(mediaType, exploded = true, container) } .mapFailure { error -> when (error) { @@ -215,10 +211,10 @@ public class AssetRetriever( private suspend fun retrieveResourceAsset( url: Url, - format: Format + mediaType: MediaType ): Try { return retrieveResource(url) - .map { resource -> Asset.Resource(format, resource) } + .map { resource -> Asset.Resource(mediaType, resource) } } private suspend fun retrieveResource( @@ -338,14 +334,14 @@ public class AssetRetriever( } private suspend fun retrieve(container: Container, exploded: Boolean, hints: FormatHints): Asset? { - val format = formatRegistry.retrieve(ContainerMediaTypeSnifferContext(container, hints)) + val mediaType = mediaTypeSniffer.sniff(ContainerMediaTypeSnifferContext(container, hints)) ?: return null - return Asset.Container(format, exploded = exploded, container = container) + return Asset.Container(mediaType, exploded = exploded, container = container) } private suspend fun retrieve(resource: Resource, hints: FormatHints): Asset? { - val format = formatRegistry.retrieve(ResourceMediaTypeSnifferContext(resource, hints)) + val mediaType = mediaTypeSniffer.sniff(ResourceMediaTypeSnifferContext(resource, hints)) ?: return null - return Asset.Resource(format, resource = resource) + return Asset.Resource(mediaType, resource = resource) } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/AdeptFallbackContentProtection.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/AdeptFallbackContentProtection.kt index bc9b5c180c..1096bfb4e1 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/AdeptFallbackContentProtection.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/AdeptFallbackContentProtection.kt @@ -47,7 +47,7 @@ public class AdeptFallbackContentProtection : ContentProtection { } val protectedFile = ContentProtection.Asset( - asset.format.mediaType, + asset.mediaType, asset.container, onCreatePublication = { servicesBuilder.contentProtectionServiceFactory = @@ -59,7 +59,7 @@ public class AdeptFallbackContentProtection : ContentProtection { } private suspend fun isAdept(asset: Asset.Container): Boolean { - if (asset.format.mediaType.matches(MediaType.EPUB)) { + if (asset.mediaType.matches(MediaType.EPUB)) { return false } val rightsXml = asset.container.get("/META-INF/rights.xml").readAsXmlOrNull() diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/LcpFallbackContentProtection.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/LcpFallbackContentProtection.kt index 2a7398a562..bf74e69db9 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/LcpFallbackContentProtection.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/LcpFallbackContentProtection.kt @@ -34,8 +34,8 @@ public class LcpFallbackContentProtection : ContentProtection { override suspend fun supports(asset: Asset): Boolean = when (asset) { - is Asset.Container -> isLcpProtected(asset.container, asset.format.mediaType) - is Asset.Resource -> asset.format.mediaType.matches(MediaType.LCP_LICENSE_DOCUMENT) + is Asset.Container -> isLcpProtected(asset.container, asset.mediaType) + is Asset.Resource -> asset.mediaType.matches(MediaType.LCP_LICENSE_DOCUMENT) } override suspend fun open( @@ -51,7 +51,7 @@ public class LcpFallbackContentProtection : ContentProtection { } val protectedFile = ContentProtection.Asset( - asset.format.mediaType, + asset.mediaType, asset.container, onCreatePublication = { servicesBuilder.contentProtectionServiceFactory = diff --git a/readium/streamer/src/main/java/org/readium/r2/streamer/ParserAssetFactory.kt b/readium/streamer/src/main/java/org/readium/r2/streamer/ParserAssetFactory.kt index 61aad00092..3c12f6220c 100644 --- a/readium/streamer/src/main/java/org/readium/r2/streamer/ParserAssetFactory.kt +++ b/readium/streamer/src/main/java/org/readium/r2/streamer/ParserAssetFactory.kt @@ -46,7 +46,7 @@ internal class ParserAssetFactory( Try.success( PublicationParser.Asset( sourceAsset = asset, - mediaType = asset.format.mediaType, + mediaType = asset.mediaType, container = asset.container ) ) @@ -54,7 +54,7 @@ internal class ParserAssetFactory( private suspend fun createParserAssetForResource( asset: Asset.Resource ): Try = - if (asset.format.mediaType.isRwpm) { + if (asset.mediaType.isRwpm) { createParserAssetForManifest(asset) } else { createParserAssetForContent(asset) @@ -109,7 +109,7 @@ internal class ParserAssetFactory( return Try.success( PublicationParser.Asset( sourceAsset = asset, - mediaType = asset.format.mediaType, + mediaType = asset.mediaType, container = container ) ) diff --git a/readium/streamer/src/main/java/org/readium/r2/streamer/parser/readium/ReadiumWebPubParser.kt b/readium/streamer/src/main/java/org/readium/r2/streamer/parser/readium/ReadiumWebPubParser.kt index 5963a2de1e..950e99931f 100644 --- a/readium/streamer/src/main/java/org/readium/r2/streamer/parser/readium/ReadiumWebPubParser.kt +++ b/readium/streamer/src/main/java/org/readium/r2/streamer/parser/readium/ReadiumWebPubParser.kt @@ -42,7 +42,7 @@ public class ReadiumWebPubParser( val manifest = Manifest.fromJSON( manifestJson, - packaged = !asset.sourceAsset.format.mediaType.isRwpm + packaged = !asset.sourceAsset.mediaType.isRwpm ) ?: return Try.failure( PublicationParser.Error.ParsingFailed("Failed to parse the RWPM Manifest") diff --git a/test-app/src/main/java/org/readium/r2/testapp/Application.kt b/test-app/src/main/java/org/readium/r2/testapp/Application.kt index 95101c09b9..9b2f24ac04 100755 --- a/test-app/src/main/java/org/readium/r2/testapp/Application.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Application.kt @@ -61,7 +61,8 @@ class Application : android.app.Application() { readium.lcpService, readium.publicationFactory, readium.assetRetriever, - readium.protectionRetriever + readium.protectionRetriever, + readium.formatRegistry ) } diff --git a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt index 3cd37399e0..a83c967cc1 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt @@ -35,7 +35,7 @@ class Readium(context: Context) { private val mediaTypeSniffer = DefaultMediaTypeSniffer() - private val formatRegistry = FormatRegistry(mediaTypeSniffer) + val formatRegistry = FormatRegistry(mediaTypeSniffer) val httpClient = DefaultHttpClient( mediaTypeSniffer = mediaTypeSniffer @@ -59,7 +59,7 @@ class Readium(context: Context) { ) val assetRetriever = AssetRetriever( - formatRegistry, + mediaTypeSniffer, resourceFactory, containerFactory, archiveFactory, @@ -70,7 +70,7 @@ class Readium(context: Context) { * The LCP service decrypts LCP-protected publication and acquire publications from a * license file. */ - val lcpService = LcpService(context, assetRetriever, formatRegistry) + val lcpService = LcpService(context, assetRetriever, mediaTypeSniffer) ?.let { Try.success(it) } ?: Try.failure(UserException("liblcp is missing on the classpath")) diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt index 5e474dec92..2ebdd5890f 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt @@ -31,6 +31,7 @@ import org.readium.r2.shared.asset.AssetType import org.readium.r2.shared.error.Try import org.readium.r2.shared.error.flatMap import org.readium.r2.shared.error.getOrElse +import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.publication.Locator import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.publication.indexOfFirstWithHref @@ -61,7 +62,8 @@ class BookRepository( private val lcpService: Try, private val publicationFactory: PublicationFactory, private val assetRetriever: AssetRetriever, - private val protectionRetriever: ContentProtectionSchemeRetriever + private val protectionRetriever: ContentProtectionSchemeRetriever, + private val formatRegistry: FormatRegistry ) { private val coverDir: File = File(storageDir, "covers/") @@ -236,7 +238,7 @@ class BookRepository( ) val (publicationTempFile, publicationTempAsset) = - if (sourceAsset.format.mediaType != MediaType.LCP_LICENSE_DOCUMENT) { + if (sourceAsset.mediaType != MediaType.LCP_LICENSE_DOCUMENT) { tempFile to sourceAsset } else { lcpService @@ -263,7 +265,9 @@ class BookRepository( ) } - val fileName = "${UUID.randomUUID()}.${publicationTempAsset.format.fileExtension}" + val format = formatRegistry.retrieve(publicationTempAsset.mediaType) + val fileExtension = format.fileExtension ?: "epub" + val fileName = "${UUID.randomUUID()}.$fileExtension" val libraryFile = File(storageDir, fileName) val libraryUrl = libraryFile.toUrl() @@ -277,8 +281,8 @@ class BookRepository( val libraryAsset = assetRetriever.retrieve( libraryUrl, - publicationTempAsset.format.mediaType, - publicationTempAsset.type + publicationTempAsset.mediaType, + publicationTempAsset.assetType ).getOrElse { return Try.failure(ImportError.PublicationError(it)) } return addBook( @@ -315,8 +319,8 @@ class BookRepository( val id = insertBookIntoDatabase( url.toString(), - asset.format.mediaType, - asset.type, + asset.mediaType, + asset.assetType, drmScheme, publication, coverFile.path diff --git a/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/File.kt b/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/File.kt index 608d35d392..d960ed76fc 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/File.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/File.kt @@ -95,7 +95,7 @@ private suspend fun HttpClient.download( } var response = res.response if (response.mediaType.matches(MediaType.BINARY)) { - assetRetriever.retrieve(destination)?.format?.mediaType?.let { + assetRetriever.retrieve(destination)?.mediaType?.let { response = response.copy(mediaType = it) } } From af681303bdf2f58185a44bed2ebcab14b59bac96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Mon, 21 Aug 2023 11:29:39 +0200 Subject: [PATCH 09/24] Restore default HTTP client in the OPDS parsers --- .../src/main/java/org/readium/r2/opds/OPDS1Parser.kt | 10 +++++++--- .../src/main/java/org/readium/r2/opds/OPDS2Parser.kt | 11 ++++++++--- .../readium/r2/shared/util/http/DefaultHttpClient.kt | 3 ++- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/readium/opds/src/main/java/org/readium/r2/opds/OPDS1Parser.kt b/readium/opds/src/main/java/org/readium/r2/opds/OPDS1Parser.kt index bb33612d10..b99067e24b 100644 --- a/readium/opds/src/main/java/org/readium/r2/opds/OPDS1Parser.kt +++ b/readium/opds/src/main/java/org/readium/r2/opds/OPDS1Parser.kt @@ -20,6 +20,7 @@ import org.readium.r2.shared.parser.xml.XmlParser import org.readium.r2.shared.publication.* import org.readium.r2.shared.toJSON import org.readium.r2.shared.util.Href +import org.readium.r2.shared.util.http.DefaultHttpClient import org.readium.r2.shared.util.http.HttpClient import org.readium.r2.shared.util.http.HttpRequest import org.readium.r2.shared.util.http.fetchWithDecoder @@ -45,7 +46,7 @@ public object Namespaces { public class OPDS1Parser { public companion object { - public suspend fun parseUrlString(url: String, client: HttpClient): Try { + public suspend fun parseUrlString(url: String, client: HttpClient = DefaultHttpClient()): Try { return client.fetchWithDecoder(HttpRequest(url)) { this.parse(it.body, URL(url)) } @@ -53,7 +54,7 @@ public class OPDS1Parser { public suspend fun parseRequest( request: HttpRequest, - client: HttpClient + client: HttpClient = DefaultHttpClient() ): Try { return client.fetchWithDecoder(request) { this.parse(it.body, URL(request.url)) @@ -185,7 +186,10 @@ public class OPDS1Parser { } @Suppress("unused") - public suspend fun retrieveOpenSearchTemplate(feed: Feed, client: HttpClient): Try { + public suspend fun retrieveOpenSearchTemplate( + feed: Feed, + client: HttpClient = DefaultHttpClient() + ): Try { var openSearchURL: URL? = null var selfMimeType: String? = null diff --git a/readium/opds/src/main/java/org/readium/r2/opds/OPDS2Parser.kt b/readium/opds/src/main/java/org/readium/r2/opds/OPDS2Parser.kt index b9b48d037e..9aa89c10ed 100644 --- a/readium/opds/src/main/java/org/readium/r2/opds/OPDS2Parser.kt +++ b/readium/opds/src/main/java/org/readium/r2/opds/OPDS2Parser.kt @@ -15,11 +15,16 @@ import org.json.JSONArray import org.json.JSONObject import org.readium.r2.shared.error.Try import org.readium.r2.shared.extensions.removeLastComponent -import org.readium.r2.shared.opds.* +import org.readium.r2.shared.opds.Facet +import org.readium.r2.shared.opds.Feed +import org.readium.r2.shared.opds.Group +import org.readium.r2.shared.opds.OpdsMetadata +import org.readium.r2.shared.opds.ParseData import org.readium.r2.shared.publication.Link import org.readium.r2.shared.publication.Manifest import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.util.Href +import org.readium.r2.shared.util.http.DefaultHttpClient import org.readium.r2.shared.util.http.HttpClient import org.readium.r2.shared.util.http.HttpRequest import org.readium.r2.shared.util.http.fetchWithDecoder @@ -38,7 +43,7 @@ public class OPDS2Parser { private lateinit var feed: Feed - public suspend fun parseUrlString(url: String, client: HttpClient): Try { + public suspend fun parseUrlString(url: String, client: HttpClient = DefaultHttpClient()): Try { return client.fetchWithDecoder(HttpRequest(url)) { this.parse(it.body, URL(url)) } @@ -46,7 +51,7 @@ public class OPDS2Parser { public suspend fun parseRequest( request: HttpRequest, - client: HttpClient + client: HttpClient = DefaultHttpClient() ): Try { return client.fetchWithDecoder(request) { this.parse(it.body, URL(request.url)) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/http/DefaultHttpClient.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/http/DefaultHttpClient.kt index a734754895..27853c77c7 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/http/DefaultHttpClient.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/http/DefaultHttpClient.kt @@ -20,6 +20,7 @@ import org.readium.r2.shared.error.tryRecover import org.readium.r2.shared.format.FormatHints import org.readium.r2.shared.util.http.HttpRequest.Method import org.readium.r2.shared.util.mediatype.BytesContentMediaTypeSnifferContext +import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer import org.readium.r2.shared.util.mediatype.HintMediaTypeSnifferContext import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.mediatype.MediaTypeSniffer @@ -37,7 +38,7 @@ import timber.log.Timber * as the default value, while a timeout of zero as an infinite timeout. */ public class DefaultHttpClient( - private val mediaTypeSniffer: MediaTypeSniffer, + private val mediaTypeSniffer: MediaTypeSniffer = DefaultMediaTypeSniffer(), private val userAgent: String? = null, private val additionalHeaders: Map = mapOf(), private val connectTimeout: Duration? = null, From 3a54463129b8cc352326a147927bebeab4d3a503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Mon, 21 Aug 2023 11:58:00 +0200 Subject: [PATCH 10/24] Simplify `Format` --- .../readium/r2/lcp/service/NetworkService.kt | 4 +- .../readium/r2/shared/asset/AssetRetriever.kt | 14 +- .../org/readium/r2/shared/format/Format.kt | 157 ------------------ .../r2/shared/resource/FileResource.kt | 4 +- .../r2/shared/resource/MediaTypeExt.kt | 6 +- .../r2/shared/resource/ZipContainer.kt | 6 +- .../archive/channel/ChannelZipContainer.kt | 4 +- .../r2/shared/util/http/DefaultHttpClient.kt | 6 +- .../shared/util/http/HttpURLConnectionExt.kt | 8 +- .../r2/shared/util/mediatype/Format.kt | 101 +++++++++++ .../shared/util/mediatype/MediaTypeHints.kt | 32 ++++ .../util/mediatype/MediaTypeSnifferContext.kt | 7 +- .../readium/r2/streamer/ParserAssetFactory.kt | 6 +- .../readium/r2/streamer/PublicationFactory.kt | 2 +- .../r2/streamer/parser/PublicationParser.kt | 7 +- .../parser/readium/ReadiumWebPubParser.kt | 2 +- .../parser/epub/EpubDeobfuscatorTest.kt | 3 +- .../streamer/parser/image/ImageParserTest.kt | 8 +- .../java/org/readium/r2/testapp/Readium.kt | 2 +- .../r2/testapp/bookshelf/BookRepository.kt | 4 +- 20 files changed, 181 insertions(+), 202 deletions(-) delete mode 100644 readium/shared/src/main/java/org/readium/r2/shared/format/Format.kt create mode 100644 readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Format.kt create mode 100644 readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeHints.kt 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 2116eb534b..9d2a81d87d 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,10 +21,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.readium.r2.lcp.LcpException import org.readium.r2.shared.error.Try -import org.readium.r2.shared.format.FormatHints import org.readium.r2.shared.util.http.invoke import org.readium.r2.shared.util.mediatype.HintMediaTypeSnifferContext import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeHints import org.readium.r2.shared.util.mediatype.MediaTypeSniffer import timber.log.Timber @@ -142,7 +142,7 @@ internal class NetworkService( mediaTypeSniffer.sniff( HintMediaTypeSnifferContext( - hints = FormatHints(connection, mediaType = mediaType) + hints = MediaTypeHints(connection, mediaType = mediaType) ) ) } catch (e: Exception) { diff --git a/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt b/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt index 6ee54b4d8c..0c02593fe9 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt @@ -16,7 +16,6 @@ import org.readium.r2.shared.error.Try import org.readium.r2.shared.error.flatMap import org.readium.r2.shared.error.getOrElse import org.readium.r2.shared.extensions.queryProjection -import org.readium.r2.shared.format.FormatHints import org.readium.r2.shared.resource.ArchiveFactory import org.readium.r2.shared.resource.Container import org.readium.r2.shared.resource.ContainerFactory @@ -30,6 +29,7 @@ import org.readium.r2.shared.resource.ResourceMediaTypeSnifferContext import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeHints import org.readium.r2.shared.util.mediatype.MediaTypeSniffer import org.readium.r2.shared.util.toUrl @@ -255,7 +255,7 @@ public class AssetRetriever( */ public suspend fun retrieve( file: File, - hints: FormatHints = FormatHints() + hints: MediaTypeHints = MediaTypeHints() ): Asset? = retrieve(file.toUrl(), hints) @@ -264,7 +264,7 @@ public class AssetRetriever( */ public suspend fun retrieve( uri: Uri, - hints: FormatHints = FormatHints() + hints: MediaTypeHints = MediaTypeHints() ): Asset? { val url = uri.toUrl() ?: return null @@ -277,9 +277,9 @@ public class AssetRetriever( */ public suspend fun retrieve( url: Url, - hints: FormatHints = FormatHints() + hints: MediaTypeHints = MediaTypeHints() ): Asset? { - val allHints = FormatHints( + val allHints = MediaTypeHints( mediaTypes = buildList { addAll(hints.mediaTypes) @@ -333,13 +333,13 @@ public class AssetRetriever( ) } - private suspend fun retrieve(container: Container, exploded: Boolean, hints: FormatHints): Asset? { + private suspend fun retrieve(container: Container, exploded: Boolean, hints: MediaTypeHints): Asset? { val mediaType = mediaTypeSniffer.sniff(ContainerMediaTypeSnifferContext(container, hints)) ?: return null return Asset.Container(mediaType, exploded = exploded, container = container) } - private suspend fun retrieve(resource: Resource, hints: FormatHints): Asset? { + private suspend fun retrieve(resource: Resource, hints: MediaTypeHints): Asset? { val mediaType = mediaTypeSniffer.sniff(ResourceMediaTypeSnifferContext(resource, hints)) ?: return null return Asset.Resource(mediaType, resource = resource) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/format/Format.kt b/readium/shared/src/main/java/org/readium/r2/shared/format/Format.kt deleted file mode 100644 index 452fd17141..0000000000 --- a/readium/shared/src/main/java/org/readium/r2/shared/format/Format.kt +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright 2023 Readium Foundation. All rights reserved. - * Use of this source code is governed by the BSD-style license - * available in the top-level LICENSE file of the project. - */ - -package org.readium.r2.shared.format - -import org.readium.r2.shared.util.mediatype.HintMediaTypeSnifferContext -import org.readium.r2.shared.util.mediatype.MediaType -import org.readium.r2.shared.util.mediatype.MediaTypeSniffer -import org.readium.r2.shared.util.mediatype.MediaTypeSnifferContext - -public class FormatRegistry( - private val sniffer: MediaTypeSniffer, - formats: List = listOf( - // The known formats are not declared as public constants to discourage comparing the format - // instead of the media type for equality. - Format( - MediaType.ACSM, - name = "Adobe Content Server Message", - fileExtension = "acsm" - ), - Format( - MediaType.CBZ, - name = "Comic Book Archive", - fileExtension = "cbz" - ), - Format( - MediaType.DIVINA, - name = "Digital Visual Narratives", - fileExtension = "divina" - ), - Format( - MediaType.DIVINA_MANIFEST, - name = "Digital Visual Narratives", - fileExtension = "json" - ), - Format( - MediaType.EPUB, - name = "EPUB", - fileExtension = "epub" - ), - Format( - MediaType.LCP_LICENSE_DOCUMENT, - name = "LCP License", - fileExtension = "lcpl" - ), - Format( - MediaType.LCP_PROTECTED_AUDIOBOOK, - name = "LCP Protected Audiobook", - fileExtension = "lcpa" - ), - Format( - MediaType.LCP_PROTECTED_PDF, - name = "LCP Protected PDF", - fileExtension = "lcpdf" - ), - Format( - MediaType.PDF, - name = "PDF", - fileExtension = "pdf" - ), - Format( - MediaType.READIUM_AUDIOBOOK, - name = "Readium Audiobook", - fileExtension = "audiobook" - ), - Format( - MediaType.READIUM_AUDIOBOOK_MANIFEST, - name = "Readium Audiobook", - fileExtension = "json" - ), - Format( - MediaType.READIUM_WEBPUB, - name = "Readium Web Publication", - fileExtension = "webpub" - ), - Format( - MediaType.READIUM_WEBPUB_MANIFEST, - name = "Readium Web Publication", - fileExtension = "json" - ), - Format( - MediaType.W3C_WPUB_MANIFEST, - name = "Web Publication", - fileExtension = "json" - ), - Format( - MediaType.ZAB, - name = "Zipped Audio Book", - fileExtension = "zab" - ) - ) -) { - - private val formats: MutableMap = - formats.associateBy { it.mediaType }.toMutableMap() - - public fun register(format: Format) { - formats[format.mediaType] = format - } - - public suspend fun canonicalize(mediaType: MediaType): MediaType = - retrieve(mediaType).mediaType - - public suspend fun retrieve(mediaType: MediaType): Format = - retrieve(HintMediaTypeSnifferContext(hints = FormatHints(mediaType))) - ?: Format(mediaType) - - public suspend fun retrieve(context: MediaTypeSnifferContext): Format? = - sniffer.sniff(context)?.let { - formats[it] ?: Format(it) - } -} - -/** - * Represents a media format, identified by a unique RFC 6838 media type. - * - * @param mediaType Canonical media type for this format. - * @param name A human readable name identifying the format, which may be presented to the user. - * @param fileExtension The default file extension to use for this format. - */ -public data class Format( - public val mediaType: MediaType, - public val name: String? = null, - public val fileExtension: String? = null -) { - - override fun toString(): String = - name ?: mediaType.toString() -} - -public data class FormatHints( - val mediaTypes: List = emptyList(), - val fileExtensions: List = emptyList() -) { - public companion object { - public operator fun invoke(mediaType: MediaType? = null, fileExtension: String? = null): FormatHints = - FormatHints( - mediaTypes = listOfNotNull(mediaType), - fileExtensions = listOfNotNull(fileExtension) - ) - - public operator fun invoke( - mediaTypes: List = emptyList(), - fileExtensions: List = emptyList() - ): FormatHints = - FormatHints(mediaTypes.mapNotNull { MediaType(it) }, fileExtensions = fileExtensions) - } - - public operator fun plus(other: FormatHints): FormatHints = - FormatHints( - mediaTypes = mediaTypes + other.mediaTypes, - fileExtensions = fileExtensions + other.fileExtensions - ) -} diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt index e5d346865a..01e26bd0ca 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt @@ -15,11 +15,11 @@ import kotlinx.coroutines.withContext import org.readium.r2.shared.error.Try import org.readium.r2.shared.error.getOrThrow import org.readium.r2.shared.extensions.* -import org.readium.r2.shared.format.FormatHints import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.isFile import org.readium.r2.shared.util.isLazyInitialized import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeHints import org.readium.r2.shared.util.mediatype.MediaTypeSniffer /** @@ -55,7 +55,7 @@ public class FileResource private constructor( ?: mediaTypeSniffer?.sniff( ResourceMediaTypeSnifferContext( resource = this, - hints = FormatHints(fileExtension = file.extension) + hints = MediaTypeHints(fileExtension = file.extension) ) ) ) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt index 3423f5fe6e..d4ab46b2dd 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt @@ -1,12 +1,12 @@ package org.readium.r2.shared.resource -import org.readium.r2.shared.format.FormatHints import org.readium.r2.shared.util.mediatype.ContainerMediaTypeSnifferContext as BaseContainerMediaTypeSnifferContext import org.readium.r2.shared.util.mediatype.ContentMediaTypeSnifferContext +import org.readium.r2.shared.util.mediatype.MediaTypeHints public class ResourceMediaTypeSnifferContext( private val resource: Resource, - override val hints: FormatHints = FormatHints() + override val hints: MediaTypeHints = MediaTypeHints() ) : ContentMediaTypeSnifferContext { override suspend fun read(range: LongRange?): ByteArray? = @@ -19,7 +19,7 @@ public class ResourceMediaTypeSnifferContext( public class ContainerMediaTypeSnifferContext( private val container: Container, - override val hints: FormatHints = FormatHints() + override val hints: MediaTypeHints = MediaTypeHints() ) : BaseContainerMediaTypeSnifferContext { override suspend fun entries(): Set? = diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt index 52134295c0..7734eff09d 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt @@ -22,10 +22,10 @@ import org.readium.r2.shared.extensions.optNullableLong import org.readium.r2.shared.extensions.readFully import org.readium.r2.shared.extensions.toMap import org.readium.r2.shared.extensions.tryOrLog -import org.readium.r2.shared.format.FormatHints import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.io.CountingInputStream import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeHints import org.readium.r2.shared.util.mediatype.MediaTypeSniffer import org.readium.r2.shared.util.toUrl @@ -112,7 +112,7 @@ internal class JavaZipContainer( mediaTypeSniffer.sniff( ResourceMediaTypeSnifferContext( resource = this, - hints = FormatHints(fileExtension = File(path).extension) + hints = MediaTypeHints(fileExtension = File(path).extension) ) ) ) @@ -142,7 +142,7 @@ internal class JavaZipContainer( mediaTypeSniffer.sniff( ResourceMediaTypeSnifferContext( resource = this, - hints = FormatHints(fileExtension = File(path).extension) + hints = MediaTypeHints(fileExtension = File(path).extension) ) ) ) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt index cbc0f8abe8..bcba05fcdb 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt @@ -14,7 +14,6 @@ import org.readium.r2.shared.error.getOrElse import org.readium.r2.shared.extensions.addPrefix import org.readium.r2.shared.extensions.readFully import org.readium.r2.shared.extensions.tryOrLog -import org.readium.r2.shared.format.FormatHints import org.readium.r2.shared.resource.ArchiveFactory import org.readium.r2.shared.resource.ArchiveProperties import org.readium.r2.shared.resource.Container @@ -30,6 +29,7 @@ import org.readium.r2.shared.util.archive.channel.compress.archivers.zip.ZipFile import org.readium.r2.shared.util.archive.channel.jvm.SeekableByteChannel import org.readium.r2.shared.util.io.CountingInputStream import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeHints import org.readium.r2.shared.util.mediatype.MediaTypeSniffer internal class ChannelZipContainer( @@ -66,7 +66,7 @@ internal class ChannelZipContainer( mediaTypeSniffer.sniff( ResourceMediaTypeSnifferContext( resource = this, - hints = FormatHints(fileExtension = File(path).extension) + hints = MediaTypeHints(fileExtension = File(path).extension) ) ) ) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/http/DefaultHttpClient.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/http/DefaultHttpClient.kt index 27853c77c7..9ec1e2ebdf 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/http/DefaultHttpClient.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/http/DefaultHttpClient.kt @@ -17,12 +17,12 @@ import kotlinx.coroutines.withContext import org.readium.r2.shared.error.Try import org.readium.r2.shared.error.flatMap import org.readium.r2.shared.error.tryRecover -import org.readium.r2.shared.format.FormatHints import org.readium.r2.shared.util.http.HttpRequest.Method import org.readium.r2.shared.util.mediatype.BytesContentMediaTypeSnifferContext import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer import org.readium.r2.shared.util.mediatype.HintMediaTypeSnifferContext import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeHints import org.readium.r2.shared.util.mediatype.MediaTypeSniffer import timber.log.Timber @@ -146,7 +146,7 @@ public class DefaultHttpClient( val mediaType = body?.let { mediaTypeSniffer.sniff( BytesContentMediaTypeSnifferContext( - hints = FormatHints(connection), + hints = MediaTypeHints(connection), bytes = { it } ) ) @@ -156,7 +156,7 @@ public class DefaultHttpClient( val mediaType = mediaTypeSniffer.sniff( HintMediaTypeSnifferContext( - hints = FormatHints(connection) + hints = MediaTypeHints(connection) ) ) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/http/HttpURLConnectionExt.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/http/HttpURLConnectionExt.kt index 9fc077e480..c203773cb2 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/http/HttpURLConnectionExt.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/http/HttpURLConnectionExt.kt @@ -8,13 +8,13 @@ package org.readium.r2.shared.util.http import java.net.HttpURLConnection import org.readium.r2.shared.extensions.extension -import org.readium.r2.shared.format.FormatHints +import org.readium.r2.shared.util.mediatype.MediaTypeHints -public operator fun FormatHints.Companion.invoke( +public operator fun MediaTypeHints.Companion.invoke( connection: HttpURLConnection, mediaType: String? = null -): FormatHints = - FormatHints( +): MediaTypeHints = + MediaTypeHints( mediaTypes = listOfNotNull(connection.contentType, mediaType), fileExtensions = listOfNotNull( connection.url.extension diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Format.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Format.kt new file mode 100644 index 0000000000..b8ba0ca16f --- /dev/null +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Format.kt @@ -0,0 +1,101 @@ +/* + * Copyright 2023 Readium Foundation. All rights reserved. + * Use of this source code is governed by the BSD-style license + * available in the top-level LICENSE file of the project. + */ + +package org.readium.r2.shared.util.mediatype + +public class FormatRegistry( + private val sniffer: MediaTypeSniffer, + formats: Map = mapOf( + MediaType.ACSM to Format( + name = "Adobe Content Server Message", + fileExtension = "acsm" + ), + MediaType.CBZ to Format( + name = "Comic Book Archive", + fileExtension = "cbz" + ), + MediaType.DIVINA to Format( + name = "Digital Visual Narratives", + fileExtension = "divina" + ), + MediaType.DIVINA_MANIFEST to Format( + name = "Digital Visual Narratives", + fileExtension = "json" + ), + MediaType.EPUB to Format( + name = "EPUB", + fileExtension = "epub" + ), + MediaType.LCP_LICENSE_DOCUMENT to Format( + name = "LCP License", + fileExtension = "lcpl" + ), + MediaType.LCP_PROTECTED_AUDIOBOOK to Format( + name = "LCP Protected Audiobook", + fileExtension = "lcpa" + ), + MediaType.LCP_PROTECTED_PDF to Format( + name = "LCP Protected PDF", + fileExtension = "lcpdf" + ), + MediaType.PDF to Format( + name = "PDF", + fileExtension = "pdf" + ), + MediaType.READIUM_AUDIOBOOK to Format( + name = "Readium Audiobook", + fileExtension = "audiobook" + ), + MediaType.READIUM_AUDIOBOOK_MANIFEST to Format( + name = "Readium Audiobook", + fileExtension = "json" + ), + MediaType.READIUM_WEBPUB to Format( + name = "Readium Web Publication", + fileExtension = "webpub" + ), + MediaType.READIUM_WEBPUB_MANIFEST to Format( + name = "Readium Web Publication", + fileExtension = "json" + ), + MediaType.W3C_WPUB_MANIFEST to Format( + name = "Web Publication", + fileExtension = "json" + ), + MediaType.ZAB to Format( + name = "Zipped Audio Book", + fileExtension = "zab" + ) + ) +) { + + private val formats: MutableMap = formats.toMutableMap() + + public fun register(mediaType: MediaType, format: Format) { + formats[mediaType] = format + } + + public suspend fun retrieve(mediaType: MediaType): Format? = + formats[canonicalize(mediaType)] + + public suspend fun canonicalize(mediaType: MediaType): MediaType = + sniffer.sniff(HintMediaTypeSnifferContext(hints = MediaTypeHints(mediaType))) + ?: mediaType +} + +/** + * Represents a media format, identified by a unique RFC 6838 media type. + * + * @param name A human readable name identifying the format, which may be presented to the user. + * @param fileExtension The default file extension to use for this format. + */ +public data class Format( + public val name: String, + public val fileExtension: String? = null +) { + + override fun toString(): String = name +} diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeHints.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeHints.kt new file mode 100644 index 0000000000..96ef610dd4 --- /dev/null +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeHints.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023 Readium Foundation. All rights reserved. + * Use of this source code is governed by the BSD-style license + * available in the top-level LICENSE file of the project. + */ + +package org.readium.r2.shared.util.mediatype + +public data class MediaTypeHints( + val mediaTypes: List = emptyList(), + val fileExtensions: List = emptyList() +) { + public companion object { + public operator fun invoke(mediaType: MediaType? = null, fileExtension: String? = null): MediaTypeHints = + MediaTypeHints( + mediaTypes = listOfNotNull(mediaType), + fileExtensions = listOfNotNull(fileExtension) + ) + + public operator fun invoke( + mediaTypes: List = emptyList(), + fileExtensions: List = emptyList() + ): MediaTypeHints = + MediaTypeHints(mediaTypes.mapNotNull { MediaType(it) }, fileExtensions = fileExtensions) + } + + public operator fun plus(other: MediaTypeHints): MediaTypeHints = + MediaTypeHints( + mediaTypes = mediaTypes + other.mediaTypes, + fileExtensions = fileExtensions + other.fileExtensions + ) +} diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContext.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContext.kt index 95678686eb..a95394edae 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContext.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContext.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.withContext import org.json.JSONObject import org.readium.r2.shared.extensions.read import org.readium.r2.shared.extensions.tryOrNull -import org.readium.r2.shared.format.FormatHints import org.readium.r2.shared.parser.xml.ElementNode import org.readium.r2.shared.parser.xml.XmlParser import org.readium.r2.shared.publication.Manifest @@ -17,7 +16,7 @@ import org.readium.r2.shared.util.SuspendingCloseable public interface MediaTypeSnifferContext : SuspendingCloseable { /** Format hints. */ - public val hints: FormatHints + public val hints: MediaTypeHints } /** Finds the first [Charset] declared in the media types' `charset` parameter. */ @@ -140,13 +139,13 @@ public suspend fun ContainerMediaTypeSnifferContext.contains(path: String): Bool ?: (read(path) != null) public class HintMediaTypeSnifferContext( - override val hints: FormatHints + override val hints: MediaTypeHints ) : MediaTypeSnifferContext { override suspend fun close() {} } public class BytesContentMediaTypeSnifferContext( - override val hints: FormatHints = FormatHints(), + override val hints: MediaTypeHints = MediaTypeHints(), bytes: suspend () -> ByteArray ) : ContentMediaTypeSnifferContext { diff --git a/readium/streamer/src/main/java/org/readium/r2/streamer/ParserAssetFactory.kt b/readium/streamer/src/main/java/org/readium/r2/streamer/ParserAssetFactory.kt index 3c12f6220c..5cc8dee700 100644 --- a/readium/streamer/src/main/java/org/readium/r2/streamer/ParserAssetFactory.kt +++ b/readium/streamer/src/main/java/org/readium/r2/streamer/ParserAssetFactory.kt @@ -45,8 +45,8 @@ internal class ParserAssetFactory( ): Try = Try.success( PublicationParser.Asset( - sourceAsset = asset, mediaType = asset.mediaType, + sourceMediaType = asset.mediaType, container = asset.container ) ) @@ -91,8 +91,8 @@ internal class ParserAssetFactory( return Try.success( PublicationParser.Asset( - sourceAsset = asset, mediaType = MediaType.READIUM_WEBPUB, + sourceMediaType = asset.mediaType, container = container ) ) @@ -108,8 +108,8 @@ internal class ParserAssetFactory( return Try.success( PublicationParser.Asset( - sourceAsset = asset, mediaType = asset.mediaType, + sourceMediaType = asset.mediaType, container = container ) ) diff --git a/readium/streamer/src/main/java/org/readium/r2/streamer/PublicationFactory.kt b/readium/streamer/src/main/java/org/readium/r2/streamer/PublicationFactory.kt index 47204c7c80..0491fe09fc 100644 --- a/readium/streamer/src/main/java/org/readium/r2/streamer/PublicationFactory.kt +++ b/readium/streamer/src/main/java/org/readium/r2/streamer/PublicationFactory.kt @@ -167,8 +167,8 @@ public class PublicationFactory( ?: return Try.failure(Publication.OpeningException.Forbidden()) val parserAsset = PublicationParser.Asset( - sourceAsset = asset, protectedAsset.mediaType, + sourceMediaType = asset.mediaType, protectedAsset.container ) diff --git a/readium/streamer/src/main/java/org/readium/r2/streamer/parser/PublicationParser.kt b/readium/streamer/src/main/java/org/readium/r2/streamer/parser/PublicationParser.kt index 6feb4058a9..a3a959e594 100644 --- a/readium/streamer/src/main/java/org/readium/r2/streamer/parser/PublicationParser.kt +++ b/readium/streamer/src/main/java/org/readium/r2/streamer/parser/PublicationParser.kt @@ -6,7 +6,6 @@ package org.readium.r2.streamer.parser -import org.readium.r2.shared.asset.Asset as SharedAsset import org.readium.r2.shared.error.MessageError import org.readium.r2.shared.error.ThrowableError import org.readium.r2.shared.error.Try @@ -24,16 +23,16 @@ public interface PublicationParser { /** * Full publication asset. * - * @param sourceAsset Asset of the source used to build the publication. It can be a package, - * a JSON manifest, a LCP license, etc. + * @param sourceMediaType Media type of the source used to build the publication. It can be a + * package, a JSON manifest, a LCP license, etc. * @param mediaType Media type of the "virtual" publication asset, built from the source asset. * For example, if the source asset was a `application/audiobook+json`, the "virtual" asset * media type will be `application/audiobook+zip`. * @param container Container granting access to the resources of the publication. */ public data class Asset( - val sourceAsset: SharedAsset, val mediaType: MediaType, + val sourceMediaType: MediaType, val container: Container ) diff --git a/readium/streamer/src/main/java/org/readium/r2/streamer/parser/readium/ReadiumWebPubParser.kt b/readium/streamer/src/main/java/org/readium/r2/streamer/parser/readium/ReadiumWebPubParser.kt index 950e99931f..b0882562b9 100644 --- a/readium/streamer/src/main/java/org/readium/r2/streamer/parser/readium/ReadiumWebPubParser.kt +++ b/readium/streamer/src/main/java/org/readium/r2/streamer/parser/readium/ReadiumWebPubParser.kt @@ -42,7 +42,7 @@ public class ReadiumWebPubParser( val manifest = Manifest.fromJSON( manifestJson, - packaged = !asset.sourceAsset.mediaType.isRwpm + packaged = !asset.sourceMediaType.isRwpm ) ?: return Try.failure( PublicationParser.Error.ParsingFailed("Failed to parse the RWPM Manifest") diff --git a/readium/streamer/src/test/java/org/readium/r2/streamer/parser/epub/EpubDeobfuscatorTest.kt b/readium/streamer/src/test/java/org/readium/r2/streamer/parser/epub/EpubDeobfuscatorTest.kt index 1be97523bf..b54f48b5c6 100644 --- a/readium/streamer/src/test/java/org/readium/r2/streamer/parser/epub/EpubDeobfuscatorTest.kt +++ b/readium/streamer/src/test/java/org/readium/r2/streamer/parser/epub/EpubDeobfuscatorTest.kt @@ -19,6 +19,7 @@ import org.readium.r2.shared.resource.DirectoryContainerFactory import org.readium.r2.shared.resource.Resource import org.readium.r2.shared.resource.flatMap import org.readium.r2.shared.util.Url +import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer import org.readium.r2.streamer.readBlocking import org.robolectric.RobolectricTestRunner @@ -35,7 +36,7 @@ class EpubDeobfuscatorTest { private val container = runBlocking { requireNotNull( - DirectoryContainerFactory().create(deobfuscationDir).getOrNull() + DirectoryContainerFactory(DefaultMediaTypeSniffer()).create(deobfuscationDir).getOrNull() ) } diff --git a/readium/streamer/src/test/java/org/readium/r2/streamer/parser/image/ImageParserTest.kt b/readium/streamer/src/test/java/org/readium/r2/streamer/parser/image/ImageParserTest.kt index b56e08137a..88e245c18d 100644 --- a/readium/streamer/src/test/java/org/readium/r2/streamer/parser/image/ImageParserTest.kt +++ b/readium/streamer/src/test/java/org/readium/r2/streamer/parser/image/ImageParserTest.kt @@ -21,6 +21,7 @@ import org.readium.r2.shared.publication.firstWithRel import org.readium.r2.shared.resource.DefaultArchiveFactory import org.readium.r2.shared.resource.FileResource import org.readium.r2.shared.resource.ResourceContainer +import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.streamer.parseBlocking import org.readium.r2.streamer.parser.PublicationParser @@ -35,8 +36,11 @@ class ImageParserTest { val path = pathForResource("futuristic_tales.cbz") val file = File(path) val resource = FileResource(file, mediaType = MediaType.CBZ) - val archive = DefaultArchiveFactory().create(resource, password = null).getOrNull()!! - PublicationParser.Asset(MediaType.CBZ, archive) + val archive = DefaultArchiveFactory(DefaultMediaTypeSniffer()).create( + resource, + password = null + ).getOrNull()!! + PublicationParser.Asset(sourceAsset = MediaType.CBZ, mediaType = MediaType.CBZ, archive) } private val jpgAsset = runBlocking { diff --git a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt index a83c967cc1..aba405fc94 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt @@ -14,7 +14,6 @@ import org.readium.r2.shared.ExperimentalReadiumApi import org.readium.r2.shared.UserException import org.readium.r2.shared.asset.AssetRetriever import org.readium.r2.shared.error.Try -import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.publication.protection.ContentProtectionSchemeRetriever import org.readium.r2.shared.resource.CompositeArchiveFactory import org.readium.r2.shared.resource.CompositeResourceFactory @@ -26,6 +25,7 @@ import org.readium.r2.shared.util.archive.channel.ChannelZipArchiveFactory import org.readium.r2.shared.util.http.DefaultHttpClient import org.readium.r2.shared.util.http.HttpResourceFactory import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer +import org.readium.r2.shared.util.mediatype.FormatRegistry import org.readium.r2.streamer.PublicationFactory /** diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt index 2ebdd5890f..b9259f1c2c 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt @@ -31,7 +31,6 @@ import org.readium.r2.shared.asset.AssetType import org.readium.r2.shared.error.Try import org.readium.r2.shared.error.flatMap import org.readium.r2.shared.error.getOrElse -import org.readium.r2.shared.format.FormatRegistry import org.readium.r2.shared.publication.Locator import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.publication.indexOfFirstWithHref @@ -39,6 +38,7 @@ import org.readium.r2.shared.publication.protection.ContentProtection import org.readium.r2.shared.publication.protection.ContentProtectionSchemeRetriever import org.readium.r2.shared.publication.services.cover import org.readium.r2.shared.util.Url +import org.readium.r2.shared.util.mediatype.FormatRegistry import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.toUrl import org.readium.r2.streamer.PublicationFactory @@ -266,7 +266,7 @@ class BookRepository( } val format = formatRegistry.retrieve(publicationTempAsset.mediaType) - val fileExtension = format.fileExtension ?: "epub" + val fileExtension = format?.fileExtension ?: "epub" val fileName = "${UUID.randomUUID()}.$fileExtension" val libraryFile = File(storageDir, fileName) val libraryUrl = libraryFile.toUrl() From d0653987b6ca8d0e2d771bf24622198bbec56dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Mon, 21 Aug 2023 12:02:53 +0200 Subject: [PATCH 11/24] Make `Resource.mediaType` non nullable --- .../org/readium/r2/navigator/epub/HtmlInjector.kt | 2 +- .../org/readium/r2/shared/publication/Publication.kt | 2 +- .../org/readium/r2/shared/resource/BytesResource.kt | 12 ++++++------ .../readium/r2/shared/resource/ContentResource.kt | 2 +- .../readium/r2/shared/resource/FallbackResource.kt | 2 +- .../r2/shared/resource/FileChannelResource.kt | 4 ++-- .../org/readium/r2/shared/resource/FileResource.kt | 3 ++- .../org/readium/r2/shared/resource/LazyResource.kt | 2 +- .../java/org/readium/r2/shared/resource/Resource.kt | 4 ++-- .../r2/shared/resource/SynchronizedResource.kt | 2 +- .../org/readium/r2/shared/resource/ZipContainer.kt | 8 ++++---- .../util/archive/channel/ChannelZipContainer.kt | 4 ++-- .../org/readium/r2/shared/util/http/HttpResource.kt | 2 +- .../shared/publication/protection/TestContainer.kt | 2 +- .../r2/streamer/extensions/ContainerEntryTest.kt | 2 +- .../streamer/parser/epub/EpubPositionsServiceTest.kt | 2 +- 16 files changed, 28 insertions(+), 27 deletions(-) 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 7f7fa97b6f..3fcb00034c 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 @@ -36,7 +36,7 @@ internal fun Resource.injectHtml( .getOrElse { return@TransformingResource ResourceTry.failure(it) } - ?.takeIf { it.isHtml } + .takeIf { it.isHtml } ?: return@TransformingResource ResourceTry.success(bytes) var content = bytes.toString(mediaType.charset ?: Charsets.UTF_8).trim() diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/Publication.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/Publication.kt index 5ff5395956..63d871a2cc 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/Publication.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/Publication.kt @@ -670,6 +670,6 @@ public class Publication( private fun Resource.withMediaType(mediaType: MediaType): Resource = object : Resource by this { - override suspend fun mediaType(): ResourceTry = + override suspend fun mediaType(): ResourceTry = ResourceTry.success(mediaType) } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/BytesResource.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/BytesResource.kt index 74ba3391fe..5480f7f710 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/BytesResource.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/BytesResource.kt @@ -14,7 +14,7 @@ import org.readium.r2.shared.util.mediatype.MediaType public sealed class BaseBytesResource( override val source: Url?, - private val mediaType: MediaType?, + private val mediaType: MediaType, private val properties: Resource.Properties, protected val bytes: suspend () -> Try ) : Resource { @@ -22,7 +22,7 @@ public sealed class BaseBytesResource( override suspend fun properties(): ResourceTry = Try.success(properties) - override suspend fun mediaType(): ResourceTry = + override suspend fun mediaType(): ResourceTry = Try.success(mediaType) override suspend fun length(): ResourceTry = @@ -48,7 +48,7 @@ public sealed class BaseBytesResource( /** Creates a Resource serving a [ByteArray]. */ public class BytesResource( url: Url? = null, - mediaType: MediaType? = null, + mediaType: MediaType = MediaType.BINARY, properties: Resource.Properties = Resource.Properties(), bytes: suspend () -> ResourceTry ) : BaseBytesResource(source = url, mediaType = mediaType, properties = properties, bytes = bytes) { @@ -56,7 +56,7 @@ public class BytesResource( public constructor( bytes: ByteArray, url: Url? = null, - mediaType: MediaType? = null, + mediaType: MediaType = MediaType.BINARY, properties: Resource.Properties = Resource.Properties() ) : this(url = url, mediaType = mediaType, properties = properties, { Try.success(bytes) }) @@ -68,7 +68,7 @@ public class BytesResource( /** Creates a Resource serving a [String]. */ public class StringResource( url: Url? = null, - mediaType: MediaType? = null, + mediaType: MediaType = MediaType.TEXT, properties: Resource.Properties = Resource.Properties(), string: suspend () -> ResourceTry ) : BaseBytesResource( @@ -81,7 +81,7 @@ public class StringResource( public constructor( string: String, url: Url? = null, - mediaType: MediaType? = null, + mediaType: MediaType = MediaType.TEXT, properties: Resource.Properties = Resource.Properties() ) : this(url = url, mediaType = mediaType, properties = properties, { Try.success(string) }) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/ContentResource.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/ContentResource.kt index 8ae1585886..d1c8492207 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/ContentResource.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/ContentResource.kt @@ -54,7 +54,7 @@ public class ContentResource( override suspend fun properties(): ResourceTry = ResourceTry.success(Resource.Properties()) - override suspend fun mediaType(): ResourceTry = + override suspend fun mediaType(): ResourceTry = Try.success(contentResolver.getType(uri)?.let { MediaType(it) }) override suspend fun close() { diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/FallbackResource.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/FallbackResource.kt index 46d4781356..b233ff9ec6 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/FallbackResource.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/FallbackResource.kt @@ -19,7 +19,7 @@ public class FallbackResource( override val source: Url? = null - override suspend fun mediaType(): ResourceTry = + override suspend fun mediaType(): ResourceTry = withResource { mediaType() } override suspend fun properties(): ResourceTry = diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/FileChannelResource.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/FileChannelResource.kt index 6875867520..76e8e5f270 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/FileChannelResource.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/FileChannelResource.kt @@ -26,8 +26,8 @@ internal class FileChannelResource( private lateinit var _length: ResourceTry - override suspend fun mediaType(): ResourceTry = - ResourceTry.success(null) + override suspend fun mediaType(): ResourceTry = + ResourceTry.success(MediaType.BINARY) override suspend fun properties(): ResourceTry = ResourceTry.success(Resource.Properties()) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt index 01e26bd0ca..051f8e7b5e 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt @@ -50,7 +50,7 @@ public class FileResource private constructor( override suspend fun properties(): ResourceTry = ResourceTry.success(Resource.Properties()) - override suspend fun mediaType(): ResourceTry = Try.success( + override suspend fun mediaType(): ResourceTry = Try.success( mediaType ?: mediaTypeSniffer?.sniff( ResourceMediaTypeSnifferContext( @@ -58,6 +58,7 @@ public class FileResource private constructor( hints = MediaTypeHints(fileExtension = file.extension) ) ) + ?: MediaType.BINARY ) override suspend fun close() { diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/LazyResource.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/LazyResource.kt index b99d1e5d62..68f0626f2a 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/LazyResource.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/LazyResource.kt @@ -27,7 +27,7 @@ public open class LazyResource( return _resource } - override suspend fun mediaType(): ResourceTry = + override suspend fun mediaType(): ResourceTry = resource().mediaType() override suspend fun properties(): ResourceTry = diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/Resource.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/Resource.kt index 5e079133c1..ea5515dccb 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/Resource.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/Resource.kt @@ -37,7 +37,7 @@ public interface Resource : SuspendingCloseable { /** * Returns the resource media type if known. */ - public suspend fun mediaType(): ResourceTry + public suspend fun mediaType(): ResourceTry /** * Properties associated to the resource. @@ -154,7 +154,7 @@ public class FailureResource( internal constructor(cause: Throwable) : this(Resource.Exception.wrap(cause)) override val source: Url? = null - override suspend fun mediaType(): ResourceTry = Try.failure(error) + override suspend fun mediaType(): ResourceTry = Try.failure(error) override suspend fun properties(): ResourceTry = Try.failure(error) override suspend fun length(): ResourceTry = Try.failure(error) override suspend fun read(range: LongRange?): ResourceTry = Try.failure(error) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/SynchronizedResource.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/SynchronizedResource.kt index 93cefd9a1d..31d1f473c8 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/SynchronizedResource.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/SynchronizedResource.kt @@ -27,7 +27,7 @@ public class SynchronizedResource( override suspend fun properties(): ResourceTry = mutex.withLock { resource.properties() } - override suspend fun mediaType(): ResourceTry = + override suspend fun mediaType(): ResourceTry = mutex.withLock { resource.mediaType() } override suspend fun length(): ResourceTry = diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt index 7734eff09d..9da24eafa1 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt @@ -107,14 +107,14 @@ internal class JavaZipContainer( override val source: Url? = null - override suspend fun mediaType(): ResourceTry = + override suspend fun mediaType(): ResourceTry = Try.success( mediaTypeSniffer.sniff( ResourceMediaTypeSnifferContext( resource = this, hints = MediaTypeHints(fileExtension = File(path).extension) ) - ) + ) ?: MediaType.BINARY ) override suspend fun properties(): ResourceTry = @@ -137,14 +137,14 @@ internal class JavaZipContainer( override val source: Url? = null - override suspend fun mediaType(): ResourceTry = + override suspend fun mediaType(): ResourceTry = Try.success( mediaTypeSniffer.sniff( ResourceMediaTypeSnifferContext( resource = this, hints = MediaTypeHints(fileExtension = File(path).extension) ) - ) + ) ?: MediaType.BINARY ) override suspend fun properties(): ResourceTry = diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt index bcba05fcdb..9bdc77daee 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt @@ -61,14 +61,14 @@ internal class ChannelZipContainer( } ) - override suspend fun mediaType(): ResourceTry = + override suspend fun mediaType(): ResourceTry = Try.success( mediaTypeSniffer.sniff( ResourceMediaTypeSnifferContext( resource = this, hints = MediaTypeHints(fileExtension = File(path).extension) ) - ) + ) ?: MediaType.BINARY ) override suspend fun length(): ResourceTry = diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/http/HttpResource.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/http/HttpResource.kt index d605c2f59e..39f42720f7 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/http/HttpResource.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/http/HttpResource.kt @@ -20,7 +20,7 @@ public class HttpResource( private val maxSkipBytes: Long = MAX_SKIP_BYTES ) : Resource { - override suspend fun mediaType(): ResourceTry = + override suspend fun mediaType(): ResourceTry = headResponse().map { it.mediaType } override suspend fun properties(): ResourceTry = diff --git a/readium/shared/src/test/java/org/readium/r2/shared/publication/protection/TestContainer.kt b/readium/shared/src/test/java/org/readium/r2/shared/publication/protection/TestContainer.kt index e714e09026..9b44feacbf 100644 --- a/readium/shared/src/test/java/org/readium/r2/shared/publication/protection/TestContainer.kt +++ b/readium/shared/src/test/java/org/readium/r2/shared/publication/protection/TestContainer.kt @@ -33,7 +33,7 @@ class TestContainer(resources: Map = emptyMap()) : Container { override val source: Url? = null - override suspend fun mediaType(): ResourceTry = + override suspend fun mediaType(): ResourceTry = Try.failure(Resource.Exception.NotFound()) override suspend fun properties(): ResourceTry = diff --git a/readium/streamer/src/test/java/org/readium/r2/streamer/extensions/ContainerEntryTest.kt b/readium/streamer/src/test/java/org/readium/r2/streamer/extensions/ContainerEntryTest.kt index 1f0d0e67f1..e79ec6c62a 100644 --- a/readium/streamer/src/test/java/org/readium/r2/streamer/extensions/ContainerEntryTest.kt +++ b/readium/streamer/src/test/java/org/readium/r2/streamer/extensions/ContainerEntryTest.kt @@ -22,7 +22,7 @@ class ContainerEntryTest { class Entry(override val path: String) : Container.Entry { override val source: Url? = null - override suspend fun mediaType(): ResourceTry = + override suspend fun mediaType(): ResourceTry = throw NotImplementedError() override suspend fun properties(): ResourceTry = throw NotImplementedError() diff --git a/readium/streamer/src/test/java/org/readium/r2/streamer/parser/epub/EpubPositionsServiceTest.kt b/readium/streamer/src/test/java/org/readium/r2/streamer/parser/epub/EpubPositionsServiceTest.kt index f5a4c60976..e5f6cb39a9 100644 --- a/readium/streamer/src/test/java/org/readium/r2/streamer/parser/epub/EpubPositionsServiceTest.kt +++ b/readium/streamer/src/test/java/org/readium/r2/streamer/parser/epub/EpubPositionsServiceTest.kt @@ -499,7 +499,7 @@ class EpubPositionsServiceTest { override val source: Url? = null - override suspend fun mediaType(): ResourceTry = + override suspend fun mediaType(): ResourceTry = Try.success(item.link.mediaType) override suspend fun properties(): ResourceTry = From 05a7017111027f209f65b571bb72afcff4d608e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Mon, 21 Aug 2023 12:03:46 +0200 Subject: [PATCH 12/24] `MediaTypeSnifferContext` is not closeable --- .../java/org/readium/r2/shared/resource/MediaTypeExt.kt | 8 -------- .../r2/shared/util/mediatype/MediaTypeSnifferContext.kt | 9 ++------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt index d4ab46b2dd..7a64f2f03d 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt @@ -11,10 +11,6 @@ public class ResourceMediaTypeSnifferContext( override suspend fun read(range: LongRange?): ByteArray? = resource.read(range).getOrNull() - - override suspend fun close() { - // We don't own the resource, not our responsibility to close it. - } } public class ContainerMediaTypeSnifferContext( @@ -27,8 +23,4 @@ public class ContainerMediaTypeSnifferContext( override suspend fun read(path: String): ByteArray? = container.get(path).read().getOrNull() - - override suspend fun close() { - // We don't own the container, not our responsibility to close it. - } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContext.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContext.kt index a95394edae..4d659b618b 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContext.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContext.kt @@ -12,9 +12,8 @@ import org.readium.r2.shared.extensions.tryOrNull import org.readium.r2.shared.parser.xml.ElementNode import org.readium.r2.shared.parser.xml.XmlParser import org.readium.r2.shared.publication.Manifest -import org.readium.r2.shared.util.SuspendingCloseable -public interface MediaTypeSnifferContext : SuspendingCloseable { +public interface MediaTypeSnifferContext { /** Format hints. */ public val hints: MediaTypeHints } @@ -140,9 +139,7 @@ public suspend fun ContainerMediaTypeSnifferContext.contains(path: String): Bool public class HintMediaTypeSnifferContext( override val hints: MediaTypeHints -) : MediaTypeSnifferContext { - override suspend fun close() {} -} +) : MediaTypeSnifferContext public class BytesContentMediaTypeSnifferContext( override val hints: MediaTypeHints = MediaTypeHints(), @@ -162,6 +159,4 @@ public class BytesContentMediaTypeSnifferContext( override suspend fun read(range: LongRange?): ByteArray = bytes().read(range) - - override suspend fun close() {} } From 595add03ac9de6d10c6281684d6aa7850d51679f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Mon, 21 Aug 2023 12:40:25 +0200 Subject: [PATCH 13/24] Fix tests --- .../AdeptFallbackContentProtection.kt | 2 +- .../r2/shared/resource/ContentResource.kt | 2 +- .../util/mediatype/MediaTypeSnifferContext.kt | 4 +- .../publication/services/CoverServiceTest.kt | 3 +- .../shared/resource/DirectoryContainerTest.kt | 5 +- .../resource/ResourceInputStreamTest.kt | 3 +- .../r2/shared/resource/ZipContainerTest.kt | 13 +- .../util/mediatype/FormatRegistryTest.kt | 54 ++ .../util/mediatype/MediaTypeRetrieverTest.kt | 26 - .../util/mediatype/MediaTypeSnifferTest.kt | 528 ++++++++++++++++++ .../r2/shared/util/mediatype/MediaTypeTest.kt | 270 ++++----- .../r2/shared/util/mediatype/SnifferTest.kt | 516 ----------------- .../streamer/parser/image/ImageParserTest.kt | 8 +- 13 files changed, 744 insertions(+), 690 deletions(-) create mode 100644 readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/FormatRegistryTest.kt delete mode 100644 readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/MediaTypeRetrieverTest.kt create mode 100644 readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferTest.kt delete mode 100644 readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/SnifferTest.kt diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/AdeptFallbackContentProtection.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/AdeptFallbackContentProtection.kt index 1096bfb4e1..a3e974d8c0 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/AdeptFallbackContentProtection.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/AdeptFallbackContentProtection.kt @@ -59,7 +59,7 @@ public class AdeptFallbackContentProtection : ContentProtection { } private suspend fun isAdept(asset: Asset.Container): Boolean { - if (asset.mediaType.matches(MediaType.EPUB)) { + if (!asset.mediaType.matches(MediaType.EPUB)) { return false } val rightsXml = asset.container.get("/META-INF/rights.xml").readAsXmlOrNull() diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/ContentResource.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/ContentResource.kt index d1c8492207..2bdf6bf6e0 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/ContentResource.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/ContentResource.kt @@ -55,7 +55,7 @@ public class ContentResource( ResourceTry.success(Resource.Properties()) override suspend fun mediaType(): ResourceTry = - Try.success(contentResolver.getType(uri)?.let { MediaType(it) }) + Try.success(contentResolver.getType(uri)?.let { MediaType(it) } ?: MediaType.BINARY) override suspend fun close() { } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContext.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContext.kt index 4d659b618b..e3b8763bdc 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContext.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContext.kt @@ -3,7 +3,6 @@ package org.readium.r2.shared.util.mediatype import java.io.ByteArrayInputStream import java.io.InputStream import java.nio.charset.Charset -import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject @@ -24,8 +23,9 @@ public val MediaTypeSnifferContext.charset: Charset? get() = /** Returns whether this context has any of the given file extensions, ignoring case. */ public fun MediaTypeSnifferContext.hasFileExtension(vararg fileExtensions: String): Boolean { + val fileExtensionsHints = hints.fileExtensions.map { it.lowercase() } for (fileExtension in fileExtensions.map { it.lowercase() }) { - if (hints.fileExtensions.contains(fileExtension.lowercase(Locale.ROOT))) { + if (fileExtensionsHints.contains(fileExtension)) { return true } } diff --git a/readium/shared/src/test/java/org/readium/r2/shared/publication/services/CoverServiceTest.kt b/readium/shared/src/test/java/org/readium/r2/shared/publication/services/CoverServiceTest.kt index 72bd116879..4f76d5c761 100644 --- a/readium/shared/src/test/java/org/readium/r2/shared/publication/services/CoverServiceTest.kt +++ b/readium/shared/src/test/java/org/readium/r2/shared/publication/services/CoverServiceTest.kt @@ -23,6 +23,7 @@ import org.readium.r2.shared.publication.* import org.readium.r2.shared.readBlocking import org.readium.r2.shared.resource.FileResource import org.readium.r2.shared.resource.ResourceContainer +import org.readium.r2.shared.util.mediatype.MediaType import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) @@ -53,7 +54,7 @@ class CoverServiceTest { ), container = ResourceContainer( coverPath, - FileResource(File(coverPath), mediaType = null) + FileResource(File(coverPath), mediaType = MediaType.JPEG) ) ) } diff --git a/readium/shared/src/test/java/org/readium/r2/shared/resource/DirectoryContainerTest.kt b/readium/shared/src/test/java/org/readium/r2/shared/resource/DirectoryContainerTest.kt index 88826a0b5b..0a722ae9b8 100644 --- a/readium/shared/src/test/java/org/readium/r2/shared/resource/DirectoryContainerTest.kt +++ b/readium/shared/src/test/java/org/readium/r2/shared/resource/DirectoryContainerTest.kt @@ -21,6 +21,7 @@ import org.junit.runner.RunWith import org.readium.r2.shared.lengthBlocking import org.readium.r2.shared.readBlocking import org.readium.r2.shared.util.Url +import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows @@ -32,7 +33,9 @@ class DirectoryContainerTest { ).let { Url(it) } private fun sut(): Container = runBlocking { - assertNotNull(DirectoryContainerFactory().create(directory).getOrNull()) + assertNotNull( + DirectoryContainerFactory(DefaultMediaTypeSniffer()).create(directory).getOrNull() + ) } @Test diff --git a/readium/shared/src/test/java/org/readium/r2/shared/resource/ResourceInputStreamTest.kt b/readium/shared/src/test/java/org/readium/r2/shared/resource/ResourceInputStreamTest.kt index 685681f333..698a1eeea6 100644 --- a/readium/shared/src/test/java/org/readium/r2/shared/resource/ResourceInputStreamTest.kt +++ b/readium/shared/src/test/java/org/readium/r2/shared/resource/ResourceInputStreamTest.kt @@ -6,6 +6,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue import org.junit.Test import org.junit.runner.RunWith +import org.readium.r2.shared.util.mediatype.MediaType import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) @@ -19,7 +20,7 @@ class ResourceInputStreamTest { @Test fun `stream can be read by chunks`() { - val resource = FileResource(file, mediaType = null) + val resource = FileResource(file, mediaType = MediaType.EPUB) val resourceStream = ResourceInputStream(resource) val outputStream = ByteArrayOutputStream(fileContent.size) resourceStream.copyTo(outputStream, bufferSize = bufferSize) diff --git a/readium/shared/src/test/java/org/readium/r2/shared/resource/ZipContainerTest.kt b/readium/shared/src/test/java/org/readium/r2/shared/resource/ZipContainerTest.kt index 2f5643f848..ddc7390fa8 100644 --- a/readium/shared/src/test/java/org/readium/r2/shared/resource/ZipContainerTest.kt +++ b/readium/shared/src/test/java/org/readium/r2/shared/resource/ZipContainerTest.kt @@ -20,6 +20,8 @@ import org.junit.Test import org.junit.runner.RunWith import org.readium.r2.shared.error.getOrThrow import org.readium.r2.shared.util.archive.channel.ChannelZipArchiveFactory +import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer +import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.use import org.robolectric.ParameterizedRobolectricTestRunner @@ -36,14 +38,17 @@ class ZipContainerTest(val sut: suspend () -> Container) { val zipArchive = suspend { assertNotNull( - DefaultArchiveFactory() - .create(FileResource(File(epubZip.path), mediaType = null), password = null) + DefaultArchiveFactory(DefaultMediaTypeSniffer()) + .create( + FileResource(File(epubZip.path), mediaType = MediaType.EPUB), + password = null + ) .getOrNull() ) } val apacheZipArchive = suspend { - ChannelZipArchiveFactory() + ChannelZipArchiveFactory(DefaultMediaTypeSniffer()) .openFile(File(epubZip.path)) } @@ -51,7 +56,7 @@ class ZipContainerTest(val sut: suspend () -> Container) { assertNotNull(epubExploded) val explodedArchive = suspend { assertNotNull( - DirectoryContainerFactory() + DirectoryContainerFactory(DefaultMediaTypeSniffer()) .create(File(epubExploded.path)) .getOrNull() ) diff --git a/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/FormatRegistryTest.kt b/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/FormatRegistryTest.kt new file mode 100644 index 0000000000..a3104d87df --- /dev/null +++ b/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/FormatRegistryTest.kt @@ -0,0 +1,54 @@ +package org.readium.r2.shared.util.mediatype + +import kotlin.test.assertEquals +import kotlinx.coroutines.runBlocking +import org.junit.Test + +class FormatRegistryTest { + + private fun sut() = FormatRegistry(DefaultMediaTypeSniffer()) + + @Test + fun `canonicalize media type`() = runBlocking { + assertEquals( + MediaType("text/html")!!, + sut().canonicalize(MediaType("text/html;charset=utf-8")!!) + ) + assertEquals( + MediaType("application/atom+xml;profile=opds-catalog")!!, + sut().canonicalize( + MediaType("application/atom+xml;profile=opds-catalog;charset=utf-8")!! + ) + ) + assertEquals( + MediaType("application/unknown;charset=utf-8")!!, + sut().canonicalize(MediaType("application/unknown;charset=utf-8")!!) + ) + } + + @Test + fun `get known format from canonical media type`() = runBlocking { + assertEquals( + Format(name = "EPUB", fileExtension = "epub"), + sut().retrieve(MediaType("application/epub+zip")!!) + ) + } + + @Test + fun `get known format from non-canonical media type`() = runBlocking { + assertEquals( + Format(name = "EPUB", fileExtension = "epub"), + sut().retrieve(MediaType("application/epub+zip;param=value")!!) + ) + } + + @Test + fun `register new format`() = runBlocking { + val mediaType = MediaType("application/test")!! + val format = Format(name = "Test", fileExtension = "tst") + val sut = sut() + sut.register(mediaType, format) + + assertEquals(format, sut.retrieve(mediaType)) + } +} diff --git a/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/MediaTypeRetrieverTest.kt b/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/MediaTypeRetrieverTest.kt deleted file mode 100644 index 1fd2905f6a..0000000000 --- a/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/MediaTypeRetrieverTest.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.readium.r2.shared.util.mediatype - -import kotlin.test.assertEquals -import kotlinx.coroutines.runBlocking -import org.junit.Test - -class MediaTypeRetrieverTest { - - private val mediaTypeRetriever = MediaTypeRetriever() - - @Test - fun `canonicalize media type`() = runBlocking { - assertEquals( - MediaType.parse("text/html", fileExtension = "html")!!, - mediaTypeRetriever.canonicalMediaType(MediaType.parse("text/html;charset=utf-8")!!) - ) - /*assertEquals( - MediaType.parse("application/atom+xml;profile=opds-catalog")!!, - mediaTypeRetriever.canonicalMediaType(MediaType.parse("application/atom+xml;profile=opds-catalog;charset=utf-8")!!) - ) - assertEquals( - MediaType.parse("application/unknown;charset=utf-8")!!, - mediaTypeRetriever.canonicalMediaType(MediaType.parse("application/unknown;charset=utf-8")!!.canonicalMediaType()) - )*/ - } -} diff --git a/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferTest.kt b/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferTest.kt new file mode 100644 index 0000000000..893215bc07 --- /dev/null +++ b/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferTest.kt @@ -0,0 +1,528 @@ +package org.readium.r2.shared.util.mediatype + +import android.webkit.MimeTypeMap +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith +import org.readium.r2.shared.Fixtures +import org.readium.r2.shared.resource.DefaultArchiveFactory +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +class MediaTypeSnifferTest { + + val fixtures = Fixtures("format") + + private val sniffer = DefaultMediaTypeSniffer() + + @Test + fun `sniff ignores extension case`() = runBlocking { + assertEquals(MediaType.EPUB, sniffer.sniff(fileExtension = "EPUB")) + } + + @Test + fun `sniff ignores media type case`() = runBlocking { + assertEquals( + MediaType.EPUB, + sniffer.sniff(mediaType = "APPLICATION/EPUB+ZIP") + ) + } + + @Test + fun `sniff ignores media type extra parameters`() = runBlocking { + assertEquals( + MediaType.EPUB, + sniffer.sniff(mediaType = "application/epub+zip;param=value") + ) + } + + @Test + fun `sniff from metadata`() = runBlocking { + assertNull(sniffer.sniff(fileExtension = null)) + assertEquals( + MediaType.READIUM_AUDIOBOOK, + sniffer.sniff(fileExtension = "audiobook") + ) + assertNull(sniffer.sniff(mediaType = null)) + assertEquals( + MediaType.READIUM_AUDIOBOOK, + sniffer.sniff(mediaType = "application/audiobook+zip") + ) + assertEquals( + MediaType.READIUM_AUDIOBOOK, + sniffer.sniff(mediaType = "application/audiobook+zip") + ) + assertEquals( + MediaType.READIUM_AUDIOBOOK, + sniffer.sniff( + mediaType = "application/audiobook+zip", + fileExtension = "audiobook" + ) + ) + assertEquals( + MediaType.READIUM_AUDIOBOOK, + sniffer.sniff( + mediaTypes = listOf("application/audiobook+zip"), + fileExtensions = listOf("audiobook") + ) + ) + } + + @Test + fun `sniff from bytes`() = runBlocking { + assertEquals( + MediaType.READIUM_AUDIOBOOK_MANIFEST, + sniffer.sniffResource(fixtures.fileAt("audiobook.json")) + ) + } + + @Test + fun `sniff unknown format`() = runBlocking { + assertNull(sniffer.sniff(mediaType = "invalid")) + assertNull(sniffer.sniffResource(fixtures.fileAt("unknown"))) + } + + @Test + fun `sniff audiobook`() = runBlocking { + assertEquals( + MediaType.READIUM_AUDIOBOOK, + sniffer.sniff(fileExtension = "audiobook") + ) + assertEquals( + MediaType.READIUM_AUDIOBOOK, + sniffer.sniff(mediaType = "application/audiobook+zip") + ) + assertEquals( + MediaType.READIUM_AUDIOBOOK, + sniffer.sniffArchive(fixtures.fileAt("audiobook-package.unknown")) + ) + } + + @Test + fun `sniff audiobook manifest`() = runBlocking { + assertEquals( + MediaType.READIUM_AUDIOBOOK_MANIFEST, + sniffer.sniff(mediaType = "application/audiobook+json") + ) + assertEquals( + MediaType.READIUM_AUDIOBOOK_MANIFEST, + sniffer.sniffResource(fixtures.fileAt("audiobook.json")) + ) + assertEquals( + MediaType.READIUM_AUDIOBOOK_MANIFEST, + sniffer.sniffResource(fixtures.fileAt("audiobook-wrongtype.json")) + ) + } + + @Test + fun `sniff BMP`() = runBlocking { + assertEquals(MediaType.BMP, sniffer.sniff(fileExtension = "bmp")) + assertEquals(MediaType.BMP, sniffer.sniff(fileExtension = "dib")) + assertEquals(MediaType.BMP, sniffer.sniff(mediaType = "image/bmp")) + assertEquals(MediaType.BMP, sniffer.sniff(mediaType = "image/x-bmp")) + } + + @Test + fun `sniff CBZ`() = runBlocking { + assertEquals(MediaType.CBZ, sniffer.sniff(fileExtension = "cbz")) + assertEquals( + MediaType.CBZ, + sniffer.sniff(mediaType = "application/vnd.comicbook+zip") + ) + assertEquals(MediaType.CBZ, sniffer.sniff(mediaType = "application/x-cbz")) + assertEquals(MediaType.CBZ, sniffer.sniff(mediaType = "application/x-cbr")) + assertEquals(MediaType.CBZ, sniffer.sniffArchive(fixtures.fileAt("cbz.unknown"))) + } + + @Test + fun `sniff DiViNa`() = runBlocking { + assertEquals(MediaType.DIVINA, sniffer.sniff(fileExtension = "divina")) + assertEquals( + MediaType.DIVINA, + sniffer.sniff(mediaType = "application/divina+zip") + ) + assertEquals( + MediaType.DIVINA, + sniffer.sniffArchive(fixtures.fileAt("divina-package.unknown")) + ) + } + + @Test + fun `sniff DiViNa manifest`() = runBlocking { + assertEquals( + MediaType.DIVINA_MANIFEST, + sniffer.sniff(mediaType = "application/divina+json") + ) + assertEquals( + MediaType.DIVINA_MANIFEST, + sniffer.sniffResource(fixtures.fileAt("divina.json")) + ) + } + + @Test + fun `sniff EPUB`() = runBlocking { + assertEquals(MediaType.EPUB, sniffer.sniff(fileExtension = "epub")) + assertEquals( + MediaType.EPUB, + sniffer.sniff(mediaType = "application/epub+zip") + ) + assertEquals(MediaType.EPUB, sniffer.sniffArchive(fixtures.fileAt("epub.unknown"))) + } + + @Test + fun `sniff AVIF`() = runBlocking { + assertEquals(MediaType.AVIF, sniffer.sniff(fileExtension = "avif")) + assertEquals(MediaType.AVIF, sniffer.sniff(mediaType = "image/avif")) + } + + @Test + fun `sniff GIF`() = runBlocking { + assertEquals(MediaType.GIF, sniffer.sniff(fileExtension = "gif")) + assertEquals(MediaType.GIF, sniffer.sniff(mediaType = "image/gif")) + } + + @Test + fun `sniff HTML`() = runBlocking { + assertEquals(MediaType.HTML, sniffer.sniff(fileExtension = "htm")) + assertEquals(MediaType.HTML, sniffer.sniff(fileExtension = "html")) + assertEquals(MediaType.HTML, sniffer.sniff(mediaType = "text/html")) + assertEquals(MediaType.HTML, sniffer.sniffResource(fixtures.fileAt("html.unknown"))) + assertEquals( + MediaType.HTML, + sniffer.sniffResource(fixtures.fileAt("html-doctype-case.unknown")) + ) + } + + @Test + fun `sniff XHTML`() = runBlocking { + assertEquals(MediaType.XHTML, sniffer.sniff(fileExtension = "xht")) + assertEquals(MediaType.XHTML, sniffer.sniff(fileExtension = "xhtml")) + assertEquals( + MediaType.XHTML, + sniffer.sniff(mediaType = "application/xhtml+xml") + ) + assertEquals(MediaType.XHTML, sniffer.sniffResource(fixtures.fileAt("xhtml.unknown"))) + } + + @Test + fun `sniff JPEG`() = runBlocking { + assertEquals(MediaType.JPEG, sniffer.sniff(fileExtension = "jpg")) + assertEquals(MediaType.JPEG, sniffer.sniff(fileExtension = "jpeg")) + assertEquals(MediaType.JPEG, sniffer.sniff(fileExtension = "jpe")) + assertEquals(MediaType.JPEG, sniffer.sniff(fileExtension = "jif")) + assertEquals(MediaType.JPEG, sniffer.sniff(fileExtension = "jfif")) + assertEquals(MediaType.JPEG, sniffer.sniff(fileExtension = "jfi")) + assertEquals(MediaType.JPEG, sniffer.sniff(mediaType = "image/jpeg")) + } + + @Test + fun `sniff JXL`() = runBlocking { + assertEquals(MediaType.JXL, sniffer.sniff(fileExtension = "jxl")) + assertEquals(MediaType.JXL, sniffer.sniff(mediaType = "image/jxl")) + } + + @Test + fun `sniff OPDS 1 feed`() = runBlocking { + assertEquals( + MediaType.OPDS1, + sniffer.sniff(mediaType = "application/atom+xml;profile=opds-catalog") + ) + assertEquals( + MediaType.OPDS1, + sniffer.sniffResource(fixtures.fileAt("opds1-feed.unknown")) + ) + } + + @Test + fun `sniff OPDS 1 entry`() = runBlocking { + assertEquals( + MediaType.OPDS1_ENTRY, + sniffer.sniff( + mediaType = "application/atom+xml;type=entry;profile=opds-catalog" + ) + ) + assertEquals( + MediaType.OPDS1_ENTRY, + sniffer.sniffResource(fixtures.fileAt("opds1-entry.unknown")) + ) + } + + @Test + fun `sniff OPDS 2 feed`() = runBlocking { + assertEquals( + MediaType.OPDS2, + sniffer.sniff(mediaType = "application/opds+json") + ) + assertEquals( + MediaType.OPDS2, + sniffer.sniffResource(fixtures.fileAt("opds2-feed.json")) + ) + } + + @Test + fun `sniff OPDS 2 publication`() = runBlocking { + assertEquals( + MediaType.OPDS2_PUBLICATION, + sniffer.sniff(mediaType = "application/opds-publication+json") + ) + assertEquals( + MediaType.OPDS2_PUBLICATION, + sniffer.sniffResource(fixtures.fileAt("opds2-publication.json")) + ) + } + + @Test + fun `sniff OPDS authentication document`() = runBlocking { + assertEquals( + MediaType.OPDS_AUTHENTICATION, + sniffer.sniff(mediaType = "application/opds-authentication+json") + ) + assertEquals( + MediaType.OPDS_AUTHENTICATION, + sniffer.sniff(mediaType = "application/vnd.opds.authentication.v1.0+json") + ) + assertEquals( + MediaType.OPDS_AUTHENTICATION, + sniffer.sniffResource(fixtures.fileAt("opds-authentication.json")) + ) + } + + @Test + fun `sniff LCP protected audiobook`() = runBlocking { + assertEquals( + MediaType.LCP_PROTECTED_AUDIOBOOK, + sniffer.sniff(fileExtension = "lcpa") + ) + assertEquals( + MediaType.LCP_PROTECTED_AUDIOBOOK, + sniffer.sniff(mediaType = "application/audiobook+lcp") + ) + assertEquals( + MediaType.LCP_PROTECTED_AUDIOBOOK, + sniffer.sniffArchive(fixtures.fileAt("audiobook-lcp.unknown")) + ) + } + + @Test + fun `sniff LCP protected PDF`() = runBlocking { + assertEquals( + MediaType.LCP_PROTECTED_PDF, + sniffer.sniff(fileExtension = "lcpdf") + ) + assertEquals( + MediaType.LCP_PROTECTED_PDF, + sniffer.sniff(mediaType = "application/pdf+lcp") + ) + assertEquals( + MediaType.LCP_PROTECTED_PDF, + sniffer.sniffArchive(fixtures.fileAt("pdf-lcp.unknown")) + ) + } + + @Test + fun `sniff LCP license document`() = runBlocking { + assertEquals( + MediaType.LCP_LICENSE_DOCUMENT, + sniffer.sniff(fileExtension = "lcpl") + ) + assertEquals( + MediaType.LCP_LICENSE_DOCUMENT, + sniffer.sniff(mediaType = "application/vnd.readium.lcp.license.v1.0+json") + ) + assertEquals( + MediaType.LCP_LICENSE_DOCUMENT, + sniffer.sniffResource(fixtures.fileAt("lcpl.unknown")) + ) + } + + @Test + fun `sniff LPF`() = runBlocking { + assertEquals(MediaType.LPF, sniffer.sniff(fileExtension = "lpf")) + assertEquals(MediaType.LPF, sniffer.sniff(mediaType = "application/lpf+zip")) + assertEquals(MediaType.LPF, sniffer.sniffArchive(fixtures.fileAt("lpf.unknown"))) + assertEquals( + MediaType.LPF, + sniffer.sniffArchive(fixtures.fileAt("lpf-index-html.unknown")) + ) + } + + @Test + fun `sniff PDF`() = runBlocking { + assertEquals(MediaType.PDF, sniffer.sniff(fileExtension = "pdf")) + assertEquals(MediaType.PDF, sniffer.sniff(mediaType = "application/pdf")) + assertEquals(MediaType.PDF, sniffer.sniffResource(fixtures.fileAt("pdf.unknown"))) + } + + @Test + fun `sniff PNG`() = runBlocking { + assertEquals(MediaType.PNG, sniffer.sniff(fileExtension = "png")) + assertEquals(MediaType.PNG, sniffer.sniff(mediaType = "image/png")) + } + + @Test + fun `sniff TIFF`() = runBlocking { + assertEquals(MediaType.TIFF, sniffer.sniff(fileExtension = "tiff")) + assertEquals(MediaType.TIFF, sniffer.sniff(fileExtension = "tif")) + assertEquals(MediaType.TIFF, sniffer.sniff(mediaType = "image/tiff")) + assertEquals(MediaType.TIFF, sniffer.sniff(mediaType = "image/tiff-fx")) + } + + @Test + fun `sniff WebP`() = runBlocking { + assertEquals(MediaType.WEBP, sniffer.sniff(fileExtension = "webp")) + assertEquals(MediaType.WEBP, sniffer.sniff(mediaType = "image/webp")) + } + + @Test + fun `sniff WebPub`() = runBlocking { + assertEquals( + MediaType.READIUM_WEBPUB, + sniffer.sniff(fileExtension = "webpub") + ) + assertEquals( + MediaType.READIUM_WEBPUB, + sniffer.sniff(mediaType = "application/webpub+zip") + ) + assertEquals( + MediaType.READIUM_WEBPUB, + sniffer.sniffArchive(fixtures.fileAt("webpub-package.unknown")) + ) + } + + @Test + fun `sniff WebPub manifest`() = runBlocking { + assertEquals( + MediaType.READIUM_WEBPUB_MANIFEST, + sniffer.sniff(mediaType = "application/webpub+json") + ) + assertEquals( + MediaType.READIUM_WEBPUB_MANIFEST, + sniffer.sniffResource(fixtures.fileAt("webpub.json")) + ) + } + + @Test + fun `sniff W3C WPUB manifest`() = runBlocking { + assertEquals( + MediaType.W3C_WPUB_MANIFEST, + sniffer.sniffResource(fixtures.fileAt("w3c-wpub.json")) + ) + } + + @Test + fun `sniff ZAB`() = runBlocking { + assertEquals(MediaType.ZAB, sniffer.sniff(fileExtension = "zab")) + assertEquals(MediaType.ZAB, sniffer.sniffArchive(fixtures.fileAt("zab.unknown"))) + } + + @Test + fun `sniff JSON`() = runBlocking { + assertEquals(MediaType.JSON, sniffer.sniffResource(fixtures.fileAt("any.json"))) + } + + @Test + fun `sniff JSON problem details`() = runBlocking { + assertEquals( + MediaType.JSON_PROBLEM_DETAILS, + sniffer.sniff(mediaType = "application/problem+json") + ) + assertEquals( + MediaType.JSON_PROBLEM_DETAILS, + sniffer.sniff(mediaType = "application/problem+json; charset=utf-8") + ) + + // The sniffing of a JSON document should not take precedence over the JSON problem details. + assertEquals( + MediaType.JSON_PROBLEM_DETAILS, + sniffer.sniff( + BytesContentMediaTypeSnifferContext( + hints = MediaTypeHints(mediaType = MediaType("application/problem+json")!!), + bytes = { """{"title": "Message"}""".toByteArray() } + ) + ) + ) + } + + @Test + fun `sniff system media types`() = runBlocking { + shadowOf(MimeTypeMap.getSingleton()).addExtensionMimeTypMapping( + "xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + val xlsx = MediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")!! + assertEquals( + xlsx, + sniffer.sniff( + mediaTypes = emptyList(), + fileExtensions = listOf("foobar", "xlsx") + ) + ) + assertEquals( + xlsx, + sniffer.sniff( + mediaTypes = listOf( + "applicaton/foobar", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + fileExtensions = emptyList() + ) + ) + } + + @Test + fun `sniff system media types from bytes`() = runBlocking { + shadowOf(MimeTypeMap.getSingleton()).addExtensionMimeTypMapping("png", "image/png") + val png = MediaType("image/png")!! + assertEquals(png, sniffer.sniffResource(fixtures.fileAt("png.unknown"))) + } + + // Convenience + + private suspend fun MediaTypeSniffer.sniff( + mediaType: String? = null, + fileExtension: String? = null + ): MediaType? = + sniff( + HintMediaTypeSnifferContext( + MediaTypeHints( + mediaType = mediaType?.let { MediaType(it) }, + fileExtension = fileExtension + ) + ) + ) + + private suspend fun MediaTypeSniffer.sniff( + mediaTypes: List = emptyList(), + fileExtensions: List = emptyList() + ): MediaType? = + sniff( + HintMediaTypeSnifferContext( + MediaTypeHints( + mediaTypes = mediaTypes, + fileExtensions = fileExtensions + ) + ) + ) + + private suspend fun MediaTypeSniffer.sniffResource(file: File): MediaType? = + sniff(BytesContentMediaTypeSnifferContext { file.readBytes() }) + + private suspend fun MediaTypeSniffer.sniffArchive(file: File): MediaType? { + val archive = assertNotNull(DefaultArchiveFactory(this).open(file).getOrNull()) + + return sniff(object : ContainerMediaTypeSnifferContext { + override suspend fun entries(): Set? = + archive.entries()?.map { it.path }?.toSet() + + override suspend fun read(path: String): ByteArray? = + archive.get(path).read().getOrNull() + + override val hints: MediaTypeHints = MediaTypeHints() + }) + } +} diff --git a/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/MediaTypeTest.kt b/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/MediaTypeTest.kt index e099b931b9..4e4a1f8417 100644 --- a/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/MediaTypeTest.kt +++ b/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/MediaTypeTest.kt @@ -7,15 +7,15 @@ class MediaTypeTest { @Test fun `returns null for invalid types`() { - assertNull(MediaType.parse("application")) - assertNull(MediaType.parse("application/atom+xml/extra")) + assertNull(MediaType("application")) + assertNull(MediaType("application/atom+xml/extra")) } @Test fun `to string`() { assertEquals( "application/atom+xml;profile=opds-catalog", - MediaType.parse("application/atom+xml;profile=opds-catalog")?.toString() + MediaType("application/atom+xml;profile=opds-catalog")?.toString() ) } @@ -23,16 +23,16 @@ class MediaTypeTest { fun `to string is normalized`() { assertEquals( "application/atom+xml;a=0;profile=OPDS-CATALOG", - MediaType.parse("APPLICATION/ATOM+XML;PROFILE=OPDS-CATALOG ; a=0")?.toString() + MediaType("APPLICATION/ATOM+XML;PROFILE=OPDS-CATALOG ; a=0")?.toString() ) // Parameters are sorted by name assertEquals( "application/atom+xml;a=0;b=1", - MediaType.parse("application/atom+xml;a=0;b=1")?.toString() + MediaType("application/atom+xml;a=0;b=1")?.toString() ) assertEquals( "application/atom+xml;a=0;b=1", - MediaType.parse("application/atom+xml;b=1;a=0")?.toString() + MediaType("application/atom+xml;b=1;a=0")?.toString() ) } @@ -40,18 +40,18 @@ class MediaTypeTest { fun `get type`() { assertEquals( "application", - MediaType.parse("application/atom+xml;profile=opds-catalog")?.type + MediaType("application/atom+xml;profile=opds-catalog")?.type ) - assertEquals("*", MediaType.parse("*/jpeg")?.type) + assertEquals("*", MediaType("*/jpeg")?.type) } @Test fun `get subtype`() { assertEquals( "atom+xml", - MediaType.parse("application/atom+xml;profile=opds-catalog")?.subtype + MediaType("application/atom+xml;profile=opds-catalog")?.subtype ) - assertEquals("*", MediaType.parse("image/*")?.subtype) + assertEquals("*", MediaType("image/*")?.subtype) } @Test @@ -61,13 +61,13 @@ class MediaTypeTest { "type" to "entry", "profile" to "opds-catalog" ), - MediaType.parse("application/atom+xml;type=entry;profile=opds-catalog")?.parameters + MediaType("application/atom+xml;type=entry;profile=opds-catalog")?.parameters ) } @Test fun `get empty parameters`() { - assertTrue(MediaType.parse("application/atom+xml")!!.parameters.isEmpty()) + assertTrue(MediaType("application/atom+xml")!!.parameters.isEmpty()) } @Test @@ -77,7 +77,7 @@ class MediaTypeTest { "type" to "entry", "profile" to "opds-catalog" ), - MediaType.parse( + MediaType( "application/atom+xml ; type=entry ; profile=opds-catalog " )?.parameters ) @@ -85,22 +85,22 @@ class MediaTypeTest { @Test fun `get structured syntax suffix`() { - assertNull(MediaType.parse("foo/bar")?.structuredSyntaxSuffix) - assertNull(MediaType.parse("application/zip")?.structuredSyntaxSuffix) - assertEquals("+zip", MediaType.parse("application/epub+zip")?.structuredSyntaxSuffix) - assertEquals("+zip", MediaType.parse("foo/bar+json+zip")?.structuredSyntaxSuffix) + assertNull(MediaType("foo/bar")?.structuredSyntaxSuffix) + assertNull(MediaType("application/zip")?.structuredSyntaxSuffix) + assertEquals("+zip", MediaType("application/epub+zip")?.structuredSyntaxSuffix) + assertEquals("+zip", MediaType("foo/bar+json+zip")?.structuredSyntaxSuffix) } @Test fun `get charset`() { - assertNull(MediaType.parse("text/html")?.charset) - assertEquals(Charsets.UTF_8, MediaType.parse("text/html;charset=utf-8")?.charset) - assertEquals(Charsets.UTF_16, MediaType.parse("text/html;charset=utf-16")?.charset) + assertNull(MediaType("text/html")?.charset) + assertEquals(Charsets.UTF_8, MediaType("text/html;charset=utf-8")?.charset) + assertEquals(Charsets.UTF_16, MediaType("text/html;charset=utf-16")?.charset) } @Test fun `type, subtype and parameter names are lowercased`() { - val mediaType = MediaType.parse("APPLICATION/ATOM+XML;PROFILE=OPDS-CATALOG") + val mediaType = MediaType("APPLICATION/ATOM+XML;PROFILE=OPDS-CATALOG") assertEquals("application", mediaType?.type) assertEquals("atom+xml", mediaType?.subtype) assertEquals(mapOf("profile" to "OPDS-CATALOG"), mediaType?.parameters) @@ -110,7 +110,7 @@ class MediaTypeTest { fun `charset value is uppercased`() { assertEquals( "UTF-8", - MediaType.parse("text/html;charset=utf-8")?.parameters?.get("charset") + MediaType("text/html;charset=utf-8")?.parameters?.get("charset") ) } @@ -118,71 +118,71 @@ class MediaTypeTest { fun `charset value is canonicalized`() { assertEquals( "US-ASCII", - MediaType.parse("text/html;charset=ascii")?.parameters?.get("charset") + MediaType("text/html;charset=ascii")?.parameters?.get("charset") ) assertEquals( "UNKNOWN", - MediaType.parse("text/html;charset=unknown")?.parameters?.get("charset") + MediaType("text/html;charset=unknown")?.parameters?.get("charset") ) } @Test fun equality() { assertEquals( - MediaType.parse("application/atom+xml")!!, - MediaType.parse("application/atom+xml")!! + MediaType("application/atom+xml")!!, + MediaType("application/atom+xml")!! ) assertEquals( - MediaType.parse("application/atom+xml;profile=opds-catalog")!!, - MediaType.parse("application/atom+xml;profile=opds-catalog")!! + MediaType("application/atom+xml;profile=opds-catalog")!!, + MediaType("application/atom+xml;profile=opds-catalog")!! ) assertNotEquals( - MediaType.parse("application/atom+xml")!!, - MediaType.parse("application/atom")!! + MediaType("application/atom+xml")!!, + MediaType("application/atom")!! ) assertNotEquals( - MediaType.parse("application/atom+xml")!!, - MediaType.parse("text/atom+xml")!! + MediaType("application/atom+xml")!!, + MediaType("text/atom+xml")!! ) assertNotEquals( - MediaType.parse("application/atom+xml;profile=opds-catalog")!!, - MediaType.parse("application/atom+xml")!! + MediaType("application/atom+xml;profile=opds-catalog")!!, + MediaType("application/atom+xml")!! ) } @Test fun `equality ignores case of type, subtype and parameter names`() { assertEquals( - MediaType.parse("application/atom+xml;profile=opds-catalog")!!, - MediaType.parse("APPLICATION/ATOM+XML;PROFILE=opds-catalog")!! + MediaType("application/atom+xml;profile=opds-catalog")!!, + MediaType("APPLICATION/ATOM+XML;PROFILE=opds-catalog")!! ) assertNotEquals( - MediaType.parse("application/atom+xml;profile=opds-catalog")!!, - MediaType.parse("APPLICATION/ATOM+XML;PROFILE=OPDS-CATALOG")!! + MediaType("application/atom+xml;profile=opds-catalog")!!, + MediaType("APPLICATION/ATOM+XML;PROFILE=OPDS-CATALOG")!! ) } @Test fun `equality ignores parameters order`() { assertEquals( - MediaType.parse("application/atom+xml;type=entry;profile=opds-catalog")!!, - MediaType.parse("application/atom+xml;profile=opds-catalog;type=entry")!! + MediaType("application/atom+xml;type=entry;profile=opds-catalog")!!, + MediaType("application/atom+xml;profile=opds-catalog;type=entry")!! ) } @Test fun `equality ignores charset case`() { assertEquals( - MediaType.parse("application/atom+xml;charset=utf-8")!!, - MediaType.parse("application/atom+xml;charset=UTF-8")!! + MediaType("application/atom+xml;charset=utf-8")!!, + MediaType("application/atom+xml;charset=UTF-8")!! ) } @Test fun `contains equal media type`() { assertTrue( - MediaType.parse("text/html;charset=utf-8")!!.contains( - MediaType.parse("text/html;charset=utf-8") + MediaType("text/html;charset=utf-8")!!.contains( + MediaType("text/html;charset=utf-8") ) ) } @@ -190,20 +190,20 @@ class MediaTypeTest { @Test fun `contains must match parameters`() { assertFalse( - MediaType.parse("text/html;charset=utf-8")!!.contains( - MediaType.parse("text/html;charset=ascii") + MediaType("text/html;charset=utf-8")!!.contains( + MediaType("text/html;charset=ascii") ) ) assertFalse( - MediaType.parse("text/html;charset=utf-8")!!.contains(MediaType.parse("text/html")) + MediaType("text/html;charset=utf-8")!!.contains(MediaType("text/html")) ) } @Test fun `contains ignores parameters order`() { assertTrue( - MediaType.parse("text/html;charset=utf-8;type=entry")!!.contains( - MediaType.parse("text/html;type=entry;charset=utf-8") + MediaType("text/html;charset=utf-8;type=entry")!!.contains( + MediaType("text/html;type=entry;charset=utf-8") ) ) } @@ -211,35 +211,35 @@ class MediaTypeTest { @Test fun `contains ignore extra parameters`() { assertTrue( - MediaType.parse("text/html")!!.contains(MediaType.parse("text/html;charset=utf-8")) + MediaType("text/html")!!.contains(MediaType("text/html;charset=utf-8")) ) } @Test fun `contains supports wildcards`() { assertTrue( - MediaType.parse("*/*")!!.contains(MediaType.parse("text/html;charset=utf-8")) + MediaType("*/*")!!.contains(MediaType("text/html;charset=utf-8")) ) assertTrue( - MediaType.parse("text/*")!!.contains(MediaType.parse("text/html;charset=utf-8")) + MediaType("text/*")!!.contains(MediaType("text/html;charset=utf-8")) ) assertFalse( - MediaType.parse("text/*")!!.contains(MediaType.parse("application/zip")) + MediaType("text/*")!!.contains(MediaType("application/zip")) ) } @Test fun `contains from string`() { assertTrue( - MediaType.parse("text/html;charset=utf-8")!!.contains("text/html;charset=utf-8") + MediaType("text/html;charset=utf-8")!!.contains("text/html;charset=utf-8") ) } @Test fun `matches equal media type`() { assertTrue( - MediaType.parse("text/html;charset=utf-8")!!.matches( - MediaType.parse("text/html;charset=utf-8") + MediaType("text/html;charset=utf-8")!!.matches( + MediaType("text/html;charset=utf-8") ) ) } @@ -247,8 +247,8 @@ class MediaTypeTest { @Test fun `matches must match parameters`() { assertFalse( - MediaType.parse("text/html;charset=ascii")!!.matches( - MediaType.parse("text/html;charset=utf-8") + MediaType("text/html;charset=ascii")!!.matches( + MediaType("text/html;charset=utf-8") ) ) } @@ -256,8 +256,8 @@ class MediaTypeTest { @Test fun `matches ignores parameters order`() { assertTrue( - MediaType.parse("text/html;charset=utf-8;type=entry")!!.matches( - MediaType.parse("text/html;type=entry;charset=utf-8") + MediaType("text/html;charset=utf-8;type=entry")!!.matches( + MediaType("text/html;type=entry;charset=utf-8") ) ) } @@ -265,146 +265,146 @@ class MediaTypeTest { @Test fun `matches ignores extra parameters`() { assertTrue( - MediaType.parse("text/html;charset=utf-8")!!.matches( - MediaType.parse("text/html;charset=utf-8;extra=param") + MediaType("text/html;charset=utf-8")!!.matches( + MediaType("text/html;charset=utf-8;extra=param") ) ) assertTrue( - MediaType.parse("text/html;charset=utf-8;extra=param")!!.matches( - MediaType.parse("text/html;charset=utf-8") + MediaType("text/html;charset=utf-8;extra=param")!!.matches( + MediaType("text/html;charset=utf-8") ) ) } @Test fun `matches supports wildcards`() { - assertTrue(MediaType.parse("text/html;charset=utf-8")!!.matches(MediaType.parse("*/*"))) - assertTrue(MediaType.parse("text/html;charset=utf-8")!!.matches(MediaType.parse("text/*"))) - assertFalse(MediaType.parse("application/zip")!!.matches(MediaType.parse("text/*"))) - assertTrue(MediaType.parse("*/*")!!.matches(MediaType.parse("text/html;charset=utf-8"))) - assertTrue(MediaType.parse("text/*")!!.matches(MediaType.parse("text/html;charset=utf-8"))) - assertFalse(MediaType.parse("text/*")!!.matches(MediaType.parse("application/zip"))) + assertTrue(MediaType("text/html;charset=utf-8")!!.matches(MediaType("*/*"))) + assertTrue(MediaType("text/html;charset=utf-8")!!.matches(MediaType("text/*"))) + assertFalse(MediaType("application/zip")!!.matches(MediaType("text/*"))) + assertTrue(MediaType("*/*")!!.matches(MediaType("text/html;charset=utf-8"))) + assertTrue(MediaType("text/*")!!.matches(MediaType("text/html;charset=utf-8"))) + assertFalse(MediaType("text/*")!!.matches(MediaType("application/zip"))) } @Test fun `matches from string`() { assertTrue( - MediaType.parse("text/html;charset=utf-8")!!.matches("text/html;charset=utf-8") + MediaType("text/html;charset=utf-8")!!.matches("text/html;charset=utf-8") ) } @Test fun `matches any media type`() { assertTrue( - MediaType.parse("text/html")!!.matchesAny( - MediaType.parse("application/zip")!!, - MediaType.parse("text/html;charset=utf-8")!! + MediaType("text/html")!!.matchesAny( + MediaType("application/zip")!!, + MediaType("text/html;charset=utf-8")!! ) ) assertFalse( - MediaType.parse("text/html")!!.matchesAny( - MediaType.parse("application/zip")!!, - MediaType.parse("text/plain;charset=utf-8")!! + MediaType("text/html")!!.matchesAny( + MediaType("application/zip")!!, + MediaType("text/plain;charset=utf-8")!! ) ) assertTrue( - MediaType.parse("text/html")!!.matchesAny("application/zip", "text/html;charset=utf-8") + MediaType("text/html")!!.matchesAny("application/zip", "text/html;charset=utf-8") ) assertFalse( - MediaType.parse("text/html")!!.matchesAny("application/zip", "text/plain;charset=utf-8") + MediaType("text/html")!!.matchesAny("application/zip", "text/plain;charset=utf-8") ) } @Test fun `is ZIP`() { - assertFalse(MediaType.parse("text/plain")!!.isZip) - assertTrue(MediaType.parse("application/zip")!!.isZip) - assertTrue(MediaType.parse("application/zip;charset=utf-8")!!.isZip) - assertTrue(MediaType.parse("application/epub+zip")!!.isZip) + assertFalse(MediaType("text/plain")!!.isZip) + assertTrue(MediaType("application/zip")!!.isZip) + assertTrue(MediaType("application/zip;charset=utf-8")!!.isZip) + assertTrue(MediaType("application/epub+zip")!!.isZip) // These media types must be explicitly matched since they don't have any ZIP hint - assertTrue(MediaType.parse("application/audiobook+lcp")!!.isZip) - assertTrue(MediaType.parse("application/pdf+lcp")!!.isZip) + assertTrue(MediaType("application/audiobook+lcp")!!.isZip) + assertTrue(MediaType("application/pdf+lcp")!!.isZip) } @Test fun `is JSON`() { - assertFalse(MediaType.parse("text/plain")!!.isJson) - assertTrue(MediaType.parse("application/json")!!.isJson) - assertTrue(MediaType.parse("application/json;charset=utf-8")!!.isJson) - assertTrue(MediaType.parse("application/opds+json")!!.isJson) + assertFalse(MediaType("text/plain")!!.isJson) + assertTrue(MediaType("application/json")!!.isJson) + assertTrue(MediaType("application/json;charset=utf-8")!!.isJson) + assertTrue(MediaType("application/opds+json")!!.isJson) } @Test fun `is OPDS`() { - assertFalse(MediaType.parse("text/html")!!.isOpds) - assertTrue(MediaType.parse("application/atom+xml;profile=opds-catalog")!!.isOpds) - assertTrue(MediaType.parse("application/atom+xml;type=entry;profile=opds-catalog")!!.isOpds) - assertTrue(MediaType.parse("application/opds+json")!!.isOpds) - assertTrue(MediaType.parse("application/opds-publication+json")!!.isOpds) - assertTrue(MediaType.parse("application/opds+json;charset=utf-8")!!.isOpds) - assertTrue(MediaType.parse("application/opds-authentication+json")!!.isOpds) + assertFalse(MediaType("text/html")!!.isOpds) + assertTrue(MediaType("application/atom+xml;profile=opds-catalog")!!.isOpds) + assertTrue(MediaType("application/atom+xml;type=entry;profile=opds-catalog")!!.isOpds) + assertTrue(MediaType("application/opds+json")!!.isOpds) + assertTrue(MediaType("application/opds-publication+json")!!.isOpds) + assertTrue(MediaType("application/opds+json;charset=utf-8")!!.isOpds) + assertTrue(MediaType("application/opds-authentication+json")!!.isOpds) } @Test fun `is HTML`() { - assertFalse(MediaType.parse("application/opds+json")!!.isHtml) - assertTrue(MediaType.parse("text/html")!!.isHtml) - assertTrue(MediaType.parse("application/xhtml+xml")!!.isHtml) - assertTrue(MediaType.parse("text/html;charset=utf-8")!!.isHtml) + assertFalse(MediaType("application/opds+json")!!.isHtml) + assertTrue(MediaType("text/html")!!.isHtml) + assertTrue(MediaType("application/xhtml+xml")!!.isHtml) + assertTrue(MediaType("text/html;charset=utf-8")!!.isHtml) } @Test fun `is bitmap`() { - assertFalse(MediaType.parse("text/html")!!.isBitmap) - assertTrue(MediaType.parse("image/bmp")!!.isBitmap) - assertTrue(MediaType.parse("image/gif")!!.isBitmap) - assertTrue(MediaType.parse("image/jpeg")!!.isBitmap) - assertTrue(MediaType.parse("image/png")!!.isBitmap) - assertTrue(MediaType.parse("image/tiff")!!.isBitmap) - assertTrue(MediaType.parse("image/tiff")!!.isBitmap) - assertTrue(MediaType.parse("image/tiff;charset=utf-8")!!.isBitmap) + assertFalse(MediaType("text/html")!!.isBitmap) + assertTrue(MediaType("image/bmp")!!.isBitmap) + assertTrue(MediaType("image/gif")!!.isBitmap) + assertTrue(MediaType("image/jpeg")!!.isBitmap) + assertTrue(MediaType("image/png")!!.isBitmap) + assertTrue(MediaType("image/tiff")!!.isBitmap) + assertTrue(MediaType("image/tiff")!!.isBitmap) + assertTrue(MediaType("image/tiff;charset=utf-8")!!.isBitmap) } @Test fun `is audio`() { - assertFalse(MediaType.parse("text/html")!!.isAudio) - assertTrue(MediaType.parse("audio/unknown")!!.isAudio) - assertTrue(MediaType.parse("audio/mpeg;param=value")!!.isAudio) + assertFalse(MediaType("text/html")!!.isAudio) + assertTrue(MediaType("audio/unknown")!!.isAudio) + assertTrue(MediaType("audio/mpeg;param=value")!!.isAudio) } @Test fun `is video`() { - assertFalse(MediaType.parse("text/html")!!.isVideo) - assertTrue(MediaType.parse("video/unknown")!!.isVideo) - assertTrue(MediaType.parse("video/mpeg;param=value")!!.isVideo) + assertFalse(MediaType("text/html")!!.isVideo) + assertTrue(MediaType("video/unknown")!!.isVideo) + assertTrue(MediaType("video/mpeg;param=value")!!.isVideo) } @Test fun `is RWPM`() { - assertFalse(MediaType.parse("text/html")!!.isRwpm) - assertTrue(MediaType.parse("application/audiobook+json")!!.isRwpm) - assertTrue(MediaType.parse("application/divina+json")!!.isRwpm) - assertTrue(MediaType.parse("application/webpub+json")!!.isRwpm) - assertTrue(MediaType.parse("application/webpub+json;charset=utf-8")!!.isRwpm) + assertFalse(MediaType("text/html")!!.isRwpm) + assertTrue(MediaType("application/audiobook+json")!!.isRwpm) + assertTrue(MediaType("application/divina+json")!!.isRwpm) + assertTrue(MediaType("application/webpub+json")!!.isRwpm) + assertTrue(MediaType("application/webpub+json;charset=utf-8")!!.isRwpm) } @Test fun `is publication`() { - assertFalse(MediaType.parse("text/html")!!.isPublication) - assertTrue(MediaType.parse("application/audiobook+zip")!!.isPublication) - assertTrue(MediaType.parse("application/audiobook+json")!!.isPublication) - assertTrue(MediaType.parse("application/audiobook+lcp")!!.isPublication) - assertTrue(MediaType.parse("application/audiobook+json;charset=utf-8")!!.isPublication) - assertTrue(MediaType.parse("application/divina+zip")!!.isPublication) - assertTrue(MediaType.parse("application/divina+json")!!.isPublication) - assertTrue(MediaType.parse("application/webpub+zip")!!.isPublication) - assertTrue(MediaType.parse("application/webpub+json")!!.isPublication) - assertTrue(MediaType.parse("application/vnd.comicbook+zip")!!.isPublication) - assertTrue(MediaType.parse("application/epub+zip")!!.isPublication) - assertTrue(MediaType.parse("application/lpf+zip")!!.isPublication) - assertTrue(MediaType.parse("application/pdf")!!.isPublication) - assertTrue(MediaType.parse("application/pdf+lcp")!!.isPublication) - assertTrue(MediaType.parse("application/x.readium.w3c.wpub+json")!!.isPublication) - assertTrue(MediaType.parse("application/x.readium.zab+zip")!!.isPublication) + assertFalse(MediaType("text/html")!!.isPublication) + assertTrue(MediaType("application/audiobook+zip")!!.isPublication) + assertTrue(MediaType("application/audiobook+json")!!.isPublication) + assertTrue(MediaType("application/audiobook+lcp")!!.isPublication) + assertTrue(MediaType("application/audiobook+json;charset=utf-8")!!.isPublication) + assertTrue(MediaType("application/divina+zip")!!.isPublication) + assertTrue(MediaType("application/divina+json")!!.isPublication) + assertTrue(MediaType("application/webpub+zip")!!.isPublication) + assertTrue(MediaType("application/webpub+json")!!.isPublication) + assertTrue(MediaType("application/vnd.comicbook+zip")!!.isPublication) + assertTrue(MediaType("application/epub+zip")!!.isPublication) + assertTrue(MediaType("application/lpf+zip")!!.isPublication) + assertTrue(MediaType("application/pdf")!!.isPublication) + assertTrue(MediaType("application/pdf+lcp")!!.isPublication) + assertTrue(MediaType("application/x.readium.w3c.wpub+json")!!.isPublication) + assertTrue(MediaType("application/x.readium.zab+zip")!!.isPublication) } } diff --git a/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/SnifferTest.kt b/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/SnifferTest.kt deleted file mode 100644 index 87d70aa54e..0000000000 --- a/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/SnifferTest.kt +++ /dev/null @@ -1,516 +0,0 @@ -package org.readium.r2.shared.util.mediatype - -import android.webkit.MimeTypeMap -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlinx.coroutines.runBlocking -import org.junit.Test -import org.junit.runner.RunWith -import org.readium.r2.shared.Fixtures -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows.shadowOf - -@RunWith(RobolectricTestRunner::class) -class SnifferTest { - - val fixtures = Fixtures("format") - - private val mediaTypeRetriever: MediaTypeRetriever = - MediaTypeRetriever() - - @Test - fun `sniff ignores extension case`() = runBlocking { - assertEquals(MediaType.EPUB, mediaTypeRetriever.retrieve(fileExtension = "EPUB")) - } - - @Test - fun `sniff ignores media type case`() = runBlocking { - assertEquals( - MediaType.EPUB, - mediaTypeRetriever.retrieve(mediaType = "APPLICATION/EPUB+ZIP") - ) - } - - @Test - fun `sniff ignores media type extra parameters`() = runBlocking { - assertEquals( - MediaType.EPUB, - mediaTypeRetriever.retrieve(mediaType = "application/epub+zip;param=value") - ) - } - - @Test - fun `sniff from metadata`() = runBlocking { - assertNull(mediaTypeRetriever.retrieve(fileExtension = null)) - assertEquals( - MediaType.READIUM_AUDIOBOOK, - mediaTypeRetriever.retrieve(fileExtension = "audiobook") - ) - assertNull(mediaTypeRetriever.retrieve(mediaType = null)) - assertEquals( - MediaType.READIUM_AUDIOBOOK, - mediaTypeRetriever.retrieve(mediaType = "application/audiobook+zip") - ) - assertEquals( - MediaType.READIUM_AUDIOBOOK, - mediaTypeRetriever.retrieve(mediaType = "application/audiobook+zip") - ) - assertEquals( - MediaType.READIUM_AUDIOBOOK, - mediaTypeRetriever.retrieve( - mediaType = "application/audiobook+zip", - fileExtension = "audiobook" - ) - ) - assertEquals( - MediaType.READIUM_AUDIOBOOK, - mediaTypeRetriever.retrieve( - mediaTypes = listOf("application/audiobook+zip"), - fileExtensions = listOf("audiobook") - ) - ) - } - - @Test - fun `sniff from a file`() = runBlocking { - assertEquals( - MediaType.READIUM_AUDIOBOOK_MANIFEST, - mediaTypeRetriever.retrieve(fixtures.fileAt("audiobook.json")) - ) - } - - @Test - fun `sniff from bytes`() = runBlocking { - assertEquals( - MediaType.READIUM_AUDIOBOOK_MANIFEST, - mediaTypeRetriever.retrieve({ fixtures.fileAt("audiobook.json").readBytes() }) - ) - } - - @Test - fun `sniff unknown format`() = runBlocking { - assertNull(mediaTypeRetriever.retrieve(mediaType = "invalid")) - assertNull(mediaTypeRetriever.retrieve(fixtures.fileAt("unknown"))) - } - - @Test - fun `sniff falls back on parsing the given media type if it's valid`() = runBlocking { - val expected = MediaType.parse("fruit/grapes")!! - assertEquals(expected, mediaTypeRetriever.retrieve(mediaType = "fruit/grapes")) - assertEquals(expected, mediaTypeRetriever.retrieve(mediaType = "fruit/grapes")) - assertEquals( - expected, - mediaTypeRetriever.retrieve( - mediaTypes = listOf("invalid", "fruit/grapes"), - fileExtensions = emptyList() - ) - ) - assertEquals( - expected, - mediaTypeRetriever.retrieve( - mediaTypes = listOf("fruit/grapes", "vegetable/brocoli"), - fileExtensions = emptyList() - ) - ) - } - - @Test - fun `sniff audiobook`() = runBlocking { - assertEquals( - MediaType.READIUM_AUDIOBOOK, - mediaTypeRetriever.retrieve(fileExtension = "audiobook") - ) - assertEquals( - MediaType.READIUM_AUDIOBOOK, - mediaTypeRetriever.retrieve(mediaType = "application/audiobook+zip") - ) - assertEquals( - MediaType.READIUM_AUDIOBOOK, - mediaTypeRetriever.retrieve(fixtures.fileAt("audiobook-package.unknown")) - ) - } - - @Test - fun `sniff audiobook manifest`() = runBlocking { - assertEquals( - MediaType.READIUM_AUDIOBOOK_MANIFEST, - mediaTypeRetriever.retrieve(mediaType = "application/audiobook+json") - ) - assertEquals( - MediaType.READIUM_AUDIOBOOK_MANIFEST, - mediaTypeRetriever.retrieve(fixtures.fileAt("audiobook.json")) - ) - assertEquals( - MediaType.READIUM_AUDIOBOOK_MANIFEST, - mediaTypeRetriever.retrieve(fixtures.fileAt("audiobook-wrongtype.json")) - ) - } - - @Test - fun `sniff BMP`() = runBlocking { - assertEquals(MediaType.BMP, mediaTypeRetriever.retrieve(fileExtension = "bmp")) - assertEquals(MediaType.BMP, mediaTypeRetriever.retrieve(fileExtension = "dib")) - assertEquals(MediaType.BMP, mediaTypeRetriever.retrieve(mediaType = "image/bmp")) - assertEquals(MediaType.BMP, mediaTypeRetriever.retrieve(mediaType = "image/x-bmp")) - } - - @Test - fun `sniff CBZ`() = runBlocking { - assertEquals(MediaType.CBZ, mediaTypeRetriever.retrieve(fileExtension = "cbz")) - assertEquals( - MediaType.CBZ, - mediaTypeRetriever.retrieve(mediaType = "application/vnd.comicbook+zip") - ) - assertEquals(MediaType.CBZ, mediaTypeRetriever.retrieve(mediaType = "application/x-cbz")) - assertEquals(MediaType.CBZ, mediaTypeRetriever.retrieve(mediaType = "application/x-cbr")) - assertEquals(MediaType.CBZ, mediaTypeRetriever.retrieve(fixtures.fileAt("cbz.unknown"))) - } - - @Test - fun `sniff DiViNa`() = runBlocking { - assertEquals(MediaType.DIVINA, mediaTypeRetriever.retrieve(fileExtension = "divina")) - assertEquals( - MediaType.DIVINA, - mediaTypeRetriever.retrieve(mediaType = "application/divina+zip") - ) - assertEquals( - MediaType.DIVINA, - mediaTypeRetriever.retrieve(fixtures.fileAt("divina-package.unknown")) - ) - } - - @Test - fun `sniff DiViNa manifest`() = runBlocking { - assertEquals( - MediaType.DIVINA_MANIFEST, - mediaTypeRetriever.retrieve(mediaType = "application/divina+json") - ) - assertEquals( - MediaType.DIVINA_MANIFEST, - mediaTypeRetriever.retrieve(fixtures.fileAt("divina.json")) - ) - } - - @Test - fun `sniff EPUB`() = runBlocking { - assertEquals(MediaType.EPUB, mediaTypeRetriever.retrieve(fileExtension = "epub")) - assertEquals( - MediaType.EPUB, - mediaTypeRetriever.retrieve(mediaType = "application/epub+zip") - ) - assertEquals(MediaType.EPUB, mediaTypeRetriever.retrieve(fixtures.fileAt("epub.unknown"))) - } - - @Test - fun `sniff AVIF`() = runBlocking { - assertEquals(MediaType.AVIF, mediaTypeRetriever.retrieve(fileExtension = "avif")) - assertEquals(MediaType.AVIF, mediaTypeRetriever.retrieve(mediaType = "image/avif")) - } - - @Test - fun `sniff GIF`() = runBlocking { - assertEquals(MediaType.GIF, mediaTypeRetriever.retrieve(fileExtension = "gif")) - assertEquals(MediaType.GIF, mediaTypeRetriever.retrieve(mediaType = "image/gif")) - } - - @Test - fun `sniff HTML`() = runBlocking { - assertEquals(MediaType.HTML, mediaTypeRetriever.retrieve(fileExtension = "htm")) - assertEquals(MediaType.HTML, mediaTypeRetriever.retrieve(fileExtension = "html")) - assertEquals(MediaType.HTML, mediaTypeRetriever.retrieve(mediaType = "text/html")) - assertEquals(MediaType.HTML, mediaTypeRetriever.retrieve(fixtures.fileAt("html.unknown"))) - assertEquals( - MediaType.HTML, - mediaTypeRetriever.retrieve(fixtures.fileAt("html-doctype-case.unknown")) - ) - } - - @Test - fun `sniff XHTML`() = runBlocking { - assertEquals(MediaType.XHTML, mediaTypeRetriever.retrieve(fileExtension = "xht")) - assertEquals(MediaType.XHTML, mediaTypeRetriever.retrieve(fileExtension = "xhtml")) - assertEquals( - MediaType.XHTML, - mediaTypeRetriever.retrieve(mediaType = "application/xhtml+xml") - ) - assertEquals(MediaType.XHTML, mediaTypeRetriever.retrieve(fixtures.fileAt("xhtml.unknown"))) - } - - @Test - fun `sniff JPEG`() = runBlocking { - assertEquals(MediaType.JPEG, mediaTypeRetriever.retrieve(fileExtension = "jpg")) - assertEquals(MediaType.JPEG, mediaTypeRetriever.retrieve(fileExtension = "jpeg")) - assertEquals(MediaType.JPEG, mediaTypeRetriever.retrieve(fileExtension = "jpe")) - assertEquals(MediaType.JPEG, mediaTypeRetriever.retrieve(fileExtension = "jif")) - assertEquals(MediaType.JPEG, mediaTypeRetriever.retrieve(fileExtension = "jfif")) - assertEquals(MediaType.JPEG, mediaTypeRetriever.retrieve(fileExtension = "jfi")) - assertEquals(MediaType.JPEG, mediaTypeRetriever.retrieve(mediaType = "image/jpeg")) - } - - @Test - fun `sniff JXL`() = runBlocking { - assertEquals(MediaType.JXL, mediaTypeRetriever.retrieve(fileExtension = "jxl")) - assertEquals(MediaType.JXL, mediaTypeRetriever.retrieve(mediaType = "image/jxl")) - } - - @Test - fun `sniff OPDS 1 feed`() = runBlocking { - assertEquals( - MediaType.OPDS1, - mediaTypeRetriever.retrieve(mediaType = "application/atom+xml;profile=opds-catalog") - ) - assertEquals( - MediaType.OPDS1, - mediaTypeRetriever.retrieve(fixtures.fileAt("opds1-feed.unknown")) - ) - } - - @Test - fun `sniff OPDS 1 entry`() = runBlocking { - assertEquals( - MediaType.OPDS1_ENTRY, - mediaTypeRetriever.retrieve( - mediaType = "application/atom+xml;type=entry;profile=opds-catalog" - ) - ) - assertEquals( - MediaType.OPDS1_ENTRY, - mediaTypeRetriever.retrieve(fixtures.fileAt("opds1-entry.unknown")) - ) - } - - @Test - fun `sniff OPDS 2 feed`() = runBlocking { - assertEquals( - MediaType.OPDS2, - mediaTypeRetriever.retrieve(mediaType = "application/opds+json") - ) - assertEquals( - MediaType.OPDS2, - mediaTypeRetriever.retrieve(fixtures.fileAt("opds2-feed.json")) - ) - } - - @Test - fun `sniff OPDS 2 publication`() = runBlocking { - assertEquals( - MediaType.OPDS2_PUBLICATION, - mediaTypeRetriever.retrieve(mediaType = "application/opds-publication+json") - ) - assertEquals( - MediaType.OPDS2_PUBLICATION, - mediaTypeRetriever.retrieve(fixtures.fileAt("opds2-publication.json")) - ) - } - - @Test - fun `sniff OPDS authentication document`() = runBlocking { - assertEquals( - MediaType.OPDS_AUTHENTICATION, - mediaTypeRetriever.retrieve(mediaType = "application/opds-authentication+json") - ) - assertEquals( - MediaType.OPDS_AUTHENTICATION, - mediaTypeRetriever.retrieve(mediaType = "application/vnd.opds.authentication.v1.0+json") - ) - assertEquals( - MediaType.OPDS_AUTHENTICATION, - mediaTypeRetriever.retrieve(fixtures.fileAt("opds-authentication.json")) - ) - } - - @Test - fun `sniff LCP protected audiobook`() = runBlocking { - assertEquals( - MediaType.LCP_PROTECTED_AUDIOBOOK, - mediaTypeRetriever.retrieve(fileExtension = "lcpa") - ) - assertEquals( - MediaType.LCP_PROTECTED_AUDIOBOOK, - mediaTypeRetriever.retrieve(mediaType = "application/audiobook+lcp") - ) - assertEquals( - MediaType.LCP_PROTECTED_AUDIOBOOK, - mediaTypeRetriever.retrieve(fixtures.fileAt("audiobook-lcp.unknown")) - ) - } - - @Test - fun `sniff LCP protected PDF`() = runBlocking { - assertEquals( - MediaType.LCP_PROTECTED_PDF, - mediaTypeRetriever.retrieve(fileExtension = "lcpdf") - ) - assertEquals( - MediaType.LCP_PROTECTED_PDF, - mediaTypeRetriever.retrieve(mediaType = "application/pdf+lcp") - ) - assertEquals( - MediaType.LCP_PROTECTED_PDF, - mediaTypeRetriever.retrieve(fixtures.fileAt("pdf-lcp.unknown")) - ) - } - - @Test - fun `sniff LCP license document`() = runBlocking { - assertEquals( - MediaType.LCP_LICENSE_DOCUMENT, - mediaTypeRetriever.retrieve(fileExtension = "lcpl") - ) - assertEquals( - MediaType.LCP_LICENSE_DOCUMENT, - mediaTypeRetriever.retrieve(mediaType = "application/vnd.readium.lcp.license.v1.0+json") - ) - assertEquals( - MediaType.LCP_LICENSE_DOCUMENT, - mediaTypeRetriever.retrieve(fixtures.fileAt("lcpl.unknown")) - ) - } - - @Test - fun `sniff LPF`() = runBlocking { - assertEquals(MediaType.LPF, mediaTypeRetriever.retrieve(fileExtension = "lpf")) - assertEquals(MediaType.LPF, mediaTypeRetriever.retrieve(mediaType = "application/lpf+zip")) - assertEquals(MediaType.LPF, mediaTypeRetriever.retrieve(fixtures.fileAt("lpf.unknown"))) - assertEquals( - MediaType.LPF, - mediaTypeRetriever.retrieve(fixtures.fileAt("lpf-index-html.unknown")) - ) - } - - @Test - fun `sniff PDF`() = runBlocking { - assertEquals(MediaType.PDF, mediaTypeRetriever.retrieve(fileExtension = "pdf")) - assertEquals(MediaType.PDF, mediaTypeRetriever.retrieve(mediaType = "application/pdf")) - assertEquals(MediaType.PDF, mediaTypeRetriever.retrieve(fixtures.fileAt("pdf.unknown"))) - } - - @Test - fun `sniff PNG`() = runBlocking { - assertEquals(MediaType.PNG, mediaTypeRetriever.retrieve(fileExtension = "png")) - assertEquals(MediaType.PNG, mediaTypeRetriever.retrieve(mediaType = "image/png")) - } - - @Test - fun `sniff TIFF`() = runBlocking { - assertEquals(MediaType.TIFF, mediaTypeRetriever.retrieve(fileExtension = "tiff")) - assertEquals(MediaType.TIFF, mediaTypeRetriever.retrieve(fileExtension = "tif")) - assertEquals(MediaType.TIFF, mediaTypeRetriever.retrieve(mediaType = "image/tiff")) - assertEquals(MediaType.TIFF, mediaTypeRetriever.retrieve(mediaType = "image/tiff-fx")) - } - - @Test - fun `sniff WebP`() = runBlocking { - assertEquals(MediaType.WEBP, mediaTypeRetriever.retrieve(fileExtension = "webp")) - assertEquals(MediaType.WEBP, mediaTypeRetriever.retrieve(mediaType = "image/webp")) - } - - @Test - fun `sniff WebPub`() = runBlocking { - assertEquals( - MediaType.READIUM_WEBPUB, - mediaTypeRetriever.retrieve(fileExtension = "webpub") - ) - assertEquals( - MediaType.READIUM_WEBPUB, - mediaTypeRetriever.retrieve(mediaType = "application/webpub+zip") - ) - assertEquals( - MediaType.READIUM_WEBPUB, - mediaTypeRetriever.retrieve(fixtures.fileAt("webpub-package.unknown")) - ) - } - - @Test - fun `sniff WebPub manifest`() = runBlocking { - assertEquals( - MediaType.READIUM_WEBPUB_MANIFEST, - mediaTypeRetriever.retrieve(mediaType = "application/webpub+json") - ) - assertEquals( - MediaType.READIUM_WEBPUB_MANIFEST, - mediaTypeRetriever.retrieve(fixtures.fileAt("webpub.json")) - ) - } - - @Test - fun `sniff W3C WPUB manifest`() = runBlocking { - assertEquals( - MediaType.W3C_WPUB_MANIFEST, - mediaTypeRetriever.retrieve(fixtures.fileAt("w3c-wpub.json")) - ) - } - - @Test - fun `sniff ZAB`() = runBlocking { - assertEquals(MediaType.ZAB, mediaTypeRetriever.retrieve(fileExtension = "zab")) - assertEquals(MediaType.ZAB, mediaTypeRetriever.retrieve(fixtures.fileAt("zab.unknown"))) - } - - @Test - fun `sniff JSON`() = runBlocking { - assertEquals(MediaType.JSON, mediaTypeRetriever.retrieve(fixtures.fileAt("any.json"))) - } - - @Test - fun `sniff JSON problem details`() = runBlocking { - assertEquals( - MediaType.JSON_PROBLEM_DETAILS, - mediaTypeRetriever.retrieve(mediaType = "application/problem+json") - ) - assertEquals( - MediaType.JSON_PROBLEM_DETAILS, - mediaTypeRetriever.retrieve(mediaType = "application/problem+json; charset=utf-8") - ) - - // The sniffing of a JSON document should not take precedence over the JSON problem details. - assertEquals( - MediaType.JSON_PROBLEM_DETAILS, - mediaTypeRetriever.retrieve( - { """{"title": "Message"}""".toByteArray() }, - mediaType = "application/problem+json" - ) - ) - } - - @Test - fun `sniff system media types`() = runBlocking { - shadowOf(MimeTypeMap.getSingleton()).addExtensionMimeTypMapping( - "xlsx", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - ) - val xlsx = MediaType.parse( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - name = "XLSX", - fileExtension = "xlsx" - )!! - assertEquals( - xlsx, - mediaTypeRetriever.retrieve( - mediaTypes = emptyList(), - fileExtensions = listOf("foobar", "xlsx") - ) - ) - assertEquals( - xlsx, - mediaTypeRetriever.retrieve( - mediaTypes = listOf( - "applicaton/foobar", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - ), - fileExtensions = emptyList() - ) - ) - } - - @Test - fun `sniff system media types from bytes`() = runBlocking { - shadowOf(MimeTypeMap.getSingleton()).addExtensionMimeTypMapping("png", "image/png") - val png = MediaType.parse( - "image/png", - name = "PNG", - fileExtension = "png" - )!! - assertEquals(png, mediaTypeRetriever.retrieve(fixtures.fileAt("png.unknown"))) - } -} diff --git a/readium/streamer/src/test/java/org/readium/r2/streamer/parser/image/ImageParserTest.kt b/readium/streamer/src/test/java/org/readium/r2/streamer/parser/image/ImageParserTest.kt index 88e245c18d..2f2f4b4a08 100644 --- a/readium/streamer/src/test/java/org/readium/r2/streamer/parser/image/ImageParserTest.kt +++ b/readium/streamer/src/test/java/org/readium/r2/streamer/parser/image/ImageParserTest.kt @@ -40,13 +40,17 @@ class ImageParserTest { resource, password = null ).getOrNull()!! - PublicationParser.Asset(sourceAsset = MediaType.CBZ, mediaType = MediaType.CBZ, archive) + PublicationParser.Asset(mediaType = MediaType.CBZ, sourceMediaType = MediaType.CBZ, archive) } private val jpgAsset = runBlocking { val path = pathForResource("futuristic_tales.jpg") val resource = FileResource(File(path), mediaType = MediaType.JPEG) - PublicationParser.Asset(MediaType.JPEG, ResourceContainer(path, resource)) + PublicationParser.Asset( + mediaType = MediaType.JPEG, + sourceMediaType = MediaType.JPEG, + ResourceContainer(path, resource) + ) } private fun pathForResource(resource: String): String { val path = ImageParserTest::class.java.getResource(resource)?.path From 05a09bd1c33624e9f33d04948e7c0b63dffd929d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Mon, 21 Aug 2023 13:02:20 +0200 Subject: [PATCH 14/24] Make system and content URI sniffing generic --- .../readium/r2/shared/asset/AssetRetriever.kt | 56 ++++++++++++++++--- .../shared/util/mediatype/MediaTypeSniffer.kt | 34 +++++++---- .../util/mediatype/MediaTypeSnifferTest.kt | 7 ++- 3 files changed, 77 insertions(+), 20 deletions(-) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt b/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt index 0c02593fe9..65f9344a22 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt @@ -26,6 +26,7 @@ import org.readium.r2.shared.resource.FileResourceFactory import org.readium.r2.shared.resource.Resource import org.readium.r2.shared.resource.ResourceFactory import org.readium.r2.shared.resource.ResourceMediaTypeSnifferContext +import org.readium.r2.shared.util.Either import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer import org.readium.r2.shared.util.mediatype.MediaType @@ -321,27 +322,68 @@ public class AssetRetriever( when (error) { is ResourceFactory.Error.NotAResource -> return containerFactory.create(url).getOrNull() - ?.let { retrieve(it, exploded = true, allHints) } + ?.let { retrieve(url, it, exploded = true, allHints) } else -> return null } } return archiveFactory.create(resource, password = null) .fold( - { retrieve(container = it, exploded = false, allHints) }, - { retrieve(resource, allHints) } + { retrieve(url, container = it, exploded = false, allHints) }, + { retrieve(url, resource, allHints) } ) } - private suspend fun retrieve(container: Container, exploded: Boolean, hints: MediaTypeHints): Asset? { - val mediaType = mediaTypeSniffer.sniff(ContainerMediaTypeSnifferContext(container, hints)) + private suspend fun retrieve( + url: Url, + container: Container, + exploded: Boolean, + hints: MediaTypeHints + ): Asset? { + val mediaType = sniffMediaType(url, Either(container), hints) ?: return null return Asset.Container(mediaType, exploded = exploded, container = container) } - private suspend fun retrieve(resource: Resource, hints: MediaTypeHints): Asset? { - val mediaType = mediaTypeSniffer.sniff(ResourceMediaTypeSnifferContext(resource, hints)) + private suspend fun retrieve(url: Url, resource: Resource, hints: MediaTypeHints): Asset? { + val mediaType = sniffMediaType(url, Either(resource), hints) ?: return null return Asset.Resource(mediaType, resource = resource) } + + private suspend fun sniffMediaType( + url: Url, + asset: Either, + hints: MediaTypeHints + ): MediaType? { + suspend fun sniff(hints: MediaTypeHints): MediaType? { + val context = when (asset) { + is Either.Left -> ResourceMediaTypeSnifferContext(asset.value, hints) + is Either.Right -> ContainerMediaTypeSnifferContext(asset.value, hints) + } + return mediaTypeSniffer.sniff(context) + } + + sniff(hints)?.let { return it } + + // Falls back on the [contentResolver] in case of content Uri. + // Note: This is done after the heavy sniffing of the provided [sniffers], because + // otherwise it will detect JSON, XML or ZIP formats before we have a chance of sniffing + // their content (for example, for RWPM). + + if (url.scheme == ContentResolver.SCHEME_CONTENT) { + val contentHints = MediaTypeHints( + mediaType = contentResolver.getType(url.uri) + ?.let { MediaType(it)!! } + ?.takeUnless { it.matches(MediaType.BINARY) }, + fileExtension = contentResolver + .queryProjection(url.uri, MediaStore.MediaColumns.DISPLAY_NAME) + ?.let { filename -> File(filename).extension } + ) + + sniff(contentHints)?.let { return it } + } + + return null + } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt index 08d308482b..0885b516e5 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt @@ -25,10 +25,27 @@ public fun interface MediaTypeSniffer { /** * The default sniffer provided by Readium 2 to resolve a [MediaType]. */ -public class DefaultMediaTypeSniffer : - OptimizedRoundsMediaTypeSniffer(CompositeMediaTypeSniffer(MediaTypeSniffers.all)) +public class DefaultMediaTypeSniffer : MediaTypeSniffer { + private val sniffer = OptimizedRoundsMediaTypeSniffer( + CompositeMediaTypeSniffer(MediaTypeSniffers.all) + ) + + override suspend fun sniff(context: MediaTypeSnifferContext): MediaType? { + sniffer.sniff(context) + ?.let { return it } + + // Falls back on the system-wide registered media types using MimeTypeMap. + // Note: This is done after the default sniffers, because otherwise it will detect JSON, XML + // or ZIP formats before we have a chance of sniffing their content (for example, for RWPM). + MediaTypeSniffers.system.sniff(context) + ?.let { return it } + + // If nothing else worked, we return the first media type hint. + return context.hints.mediaTypes.firstOrNull() + } +} -public open class CompositeMediaTypeSniffer( +public class CompositeMediaTypeSniffer( private val sniffers: List ) : MediaTypeSniffer { @@ -36,7 +53,7 @@ public open class CompositeMediaTypeSniffer( sniffers.firstNotNullOfOrNull { it.sniff(context) } } -public open class OptimizedRoundsMediaTypeSniffer( +public class OptimizedRoundsMediaTypeSniffer( private val sniffer: MediaTypeSniffer ) : MediaTypeSniffer { override suspend fun sniff(context: MediaTypeSnifferContext): MediaType? { @@ -572,14 +589,13 @@ public object MediaTypeSniffers { * Sniffs the system-wide registered media types using [MimeTypeMap] and * [URLConnection.guessContentTypeFromStream]. */ - public fun system(excluded: List): MediaTypeSniffer = MediaTypeSniffer { context -> + public val system: MediaTypeSniffer = MediaTypeSniffer { context -> val mimetypes = tryOrNull { MimeTypeMap.getSingleton() } ?: return@MediaTypeSniffer null fun sniffExtension(extension: String): MediaType? = mimetypes.getMimeTypeFromExtension(extension) ?.let { MediaType(it) } - ?.takeUnless { it in excluded } fun sniffType(type: String): MediaType? { val extension = mimetypes.getExtensionFromMimeType(type) @@ -587,7 +603,6 @@ public object MediaTypeSniffers { val preferredType = mimetypes.getMimeTypeFromExtension(extension) ?: return null return MediaType(preferredType) - .takeUnless { it in excluded } } for (mediaType in context.hints.mediaTypes) { @@ -628,9 +643,6 @@ public object MediaTypeSniffers { pdf, json, xml, - zip, - // Note: We exclude JSON, XML or ZIP formats otherwise they will be detected during the - // light sniffing step and bypass the RWPM or EPUB heavy sniffing. - system(excluded = listOf(MediaType.JSON, MediaType.XML, MediaType.ZIP)) + zip ) } diff --git a/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferTest.kt b/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferTest.kt index 893215bc07..a73f0ba4e4 100644 --- a/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferTest.kt +++ b/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferTest.kt @@ -512,7 +512,10 @@ class MediaTypeSnifferTest { private suspend fun MediaTypeSniffer.sniffResource(file: File): MediaType? = sniff(BytesContentMediaTypeSnifferContext { file.readBytes() }) - private suspend fun MediaTypeSniffer.sniffArchive(file: File): MediaType? { + private suspend fun MediaTypeSniffer.sniffArchive( + file: File, + hints: MediaTypeHints = MediaTypeHints() + ): MediaType? { val archive = assertNotNull(DefaultArchiveFactory(this).open(file).getOrNull()) return sniff(object : ContainerMediaTypeSnifferContext { @@ -522,7 +525,7 @@ class MediaTypeSnifferTest { override suspend fun read(path: String): ByteArray? = archive.get(path).read().getOrNull() - override val hints: MediaTypeHints = MediaTypeHints() + override val hints: MediaTypeHints = hints }) } } From 26eec0338053351cee34232bcec122526244ab16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Mon, 21 Aug 2023 14:02:53 +0200 Subject: [PATCH 15/24] Fix lint --- .../org/readium/r2/lcp/license/LicenseValidation.kt | 2 +- .../r2/lcp/license/model/components/lsd/Event.kt | 10 +++++----- .../divina/{Deprecated.kt => R2DiViNaActivity.kt} | 3 --- .../src/main/java/org/readium/r2/shared/drm/DRM.kt | 8 ++------ 4 files changed, 8 insertions(+), 15 deletions(-) rename readium/navigator/src/main/java/org/readium/r2/navigator/divina/{Deprecated.kt => R2DiViNaActivity.kt} (90%) diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/LicenseValidation.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/LicenseValidation.kt index 2ade404ba4..32227057f7 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/LicenseValidation.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/LicenseValidation.kt @@ -414,7 +414,7 @@ internal class LicenseValidation( StatusDocument.Status.Returned -> LcpException.LicenseStatus.Returned(date) StatusDocument.Status.Revoked -> { val devicesCount = status.events( - org.readium.r2.lcp.license.model.components.lsd.Event.EventType.register + org.readium.r2.lcp.license.model.components.lsd.Event.EventType.Register ).size LcpException.LicenseStatus.Revoked(date, devicesCount = devicesCount) } diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lsd/Event.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lsd/Event.kt index c87ce9e63c..8664ca9836 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lsd/Event.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/components/lsd/Event.kt @@ -21,11 +21,11 @@ public data class Event(val json: JSONObject) { val date: Date? = json.optNullableString("timestamp")?.iso8601ToDate() public enum class EventType(public val value: String) { - register("register"), - renew("renew"), - `return`("return"), - revoke("revoke"), - cancel("cancel"); + Register("register"), + Renew("renew"), + Return("return"), + Revoke("revoke"), + Cancel("cancel"); @Deprecated("Use [value] instead", ReplaceWith("value"), level = DeprecationLevel.ERROR) public val rawValue: String get() = value diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/divina/Deprecated.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/divina/R2DiViNaActivity.kt similarity index 90% rename from readium/navigator/src/main/java/org/readium/r2/navigator/divina/Deprecated.kt rename to readium/navigator/src/main/java/org/readium/r2/navigator/divina/R2DiViNaActivity.kt index 7e29966515..e4c655e3fb 100755 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/divina/Deprecated.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/divina/R2DiViNaActivity.kt @@ -14,6 +14,3 @@ package org.readium.r2.navigator.divina level = DeprecationLevel.ERROR ) public open class R2DiViNaActivity - -// This is for lint to pass. -private val fake = null diff --git a/readium/shared/src/main/java/org/readium/r2/shared/drm/DRM.kt b/readium/shared/src/main/java/org/readium/r2/shared/drm/DRM.kt index 30784496a7..4f8cd7178f 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/drm/DRM.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/drm/DRM.kt @@ -15,14 +15,10 @@ import java.io.Serializable public class DRM { @Deprecated("Not used anymore", level = DeprecationLevel.ERROR) - public enum class Brand(public val rawValue: String) : Serializable { - lcp("lcp"); - } + public enum class Brand(public val rawValue: String) : Serializable @Deprecated("Not used anymore", level = DeprecationLevel.ERROR) - public enum class Scheme(public val rawValue: String) : Serializable { - lcp("http://readium.org/2014/01/lcp"); - } + public enum class Scheme(public val rawValue: String) : Serializable } @Deprecated("Not used anymore", level = DeprecationLevel.ERROR) From 49f6cbc52c2440d0328590b907fb2a3ebb499bdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Mon, 21 Aug 2023 14:09:16 +0200 Subject: [PATCH 16/24] Simplify `FormatRegistry` --- .../r2/shared/util/mediatype/Format.kt | 101 ------------------ .../shared/util/mediatype/FormatRegistry.kt | 41 +++++++ .../util/mediatype/FormatRegistryTest.kt | 41 ++----- .../java/org/readium/r2/testapp/Readium.kt | 2 +- .../r2/testapp/bookshelf/BookRepository.kt | 3 +- 5 files changed, 50 insertions(+), 138 deletions(-) delete mode 100644 readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Format.kt create mode 100644 readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/FormatRegistry.kt diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Format.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Format.kt deleted file mode 100644 index b8ba0ca16f..0000000000 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Format.kt +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright 2023 Readium Foundation. All rights reserved. - * Use of this source code is governed by the BSD-style license - * available in the top-level LICENSE file of the project. - */ - -package org.readium.r2.shared.util.mediatype - -public class FormatRegistry( - private val sniffer: MediaTypeSniffer, - formats: Map = mapOf( - MediaType.ACSM to Format( - name = "Adobe Content Server Message", - fileExtension = "acsm" - ), - MediaType.CBZ to Format( - name = "Comic Book Archive", - fileExtension = "cbz" - ), - MediaType.DIVINA to Format( - name = "Digital Visual Narratives", - fileExtension = "divina" - ), - MediaType.DIVINA_MANIFEST to Format( - name = "Digital Visual Narratives", - fileExtension = "json" - ), - MediaType.EPUB to Format( - name = "EPUB", - fileExtension = "epub" - ), - MediaType.LCP_LICENSE_DOCUMENT to Format( - name = "LCP License", - fileExtension = "lcpl" - ), - MediaType.LCP_PROTECTED_AUDIOBOOK to Format( - name = "LCP Protected Audiobook", - fileExtension = "lcpa" - ), - MediaType.LCP_PROTECTED_PDF to Format( - name = "LCP Protected PDF", - fileExtension = "lcpdf" - ), - MediaType.PDF to Format( - name = "PDF", - fileExtension = "pdf" - ), - MediaType.READIUM_AUDIOBOOK to Format( - name = "Readium Audiobook", - fileExtension = "audiobook" - ), - MediaType.READIUM_AUDIOBOOK_MANIFEST to Format( - name = "Readium Audiobook", - fileExtension = "json" - ), - MediaType.READIUM_WEBPUB to Format( - name = "Readium Web Publication", - fileExtension = "webpub" - ), - MediaType.READIUM_WEBPUB_MANIFEST to Format( - name = "Readium Web Publication", - fileExtension = "json" - ), - MediaType.W3C_WPUB_MANIFEST to Format( - name = "Web Publication", - fileExtension = "json" - ), - MediaType.ZAB to Format( - name = "Zipped Audio Book", - fileExtension = "zab" - ) - ) -) { - - private val formats: MutableMap = formats.toMutableMap() - - public fun register(mediaType: MediaType, format: Format) { - formats[mediaType] = format - } - - public suspend fun retrieve(mediaType: MediaType): Format? = - formats[canonicalize(mediaType)] - - public suspend fun canonicalize(mediaType: MediaType): MediaType = - sniffer.sniff(HintMediaTypeSnifferContext(hints = MediaTypeHints(mediaType))) - ?: mediaType -} - -/** - * Represents a media format, identified by a unique RFC 6838 media type. - * - * @param name A human readable name identifying the format, which may be presented to the user. - * @param fileExtension The default file extension to use for this format. - */ -public data class Format( - public val name: String, - public val fileExtension: String? = null -) { - - override fun toString(): String = name -} diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/FormatRegistry.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/FormatRegistry.kt new file mode 100644 index 0000000000..f274778fb7 --- /dev/null +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/FormatRegistry.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2023 Readium Foundation. All rights reserved. + * Use of this source code is governed by the BSD-style license + * available in the top-level LICENSE file of the project. + */ + +package org.readium.r2.shared.util.mediatype + +public class FormatRegistry( + fileExtensions: Map = mapOf( + MediaType.ACSM to "acsm", + MediaType.CBZ to "cbz", + MediaType.DIVINA to "divina", + MediaType.DIVINA_MANIFEST to "json", + MediaType.EPUB to "epub", + MediaType.LCP_LICENSE_DOCUMENT to "lcpl", + MediaType.LCP_PROTECTED_AUDIOBOOK to "lcpa", + MediaType.LCP_PROTECTED_PDF to "lcpdf", + MediaType.PDF to "pdf", + MediaType.READIUM_AUDIOBOOK to "audiobook", + MediaType.READIUM_AUDIOBOOK_MANIFEST to "json", + MediaType.READIUM_WEBPUB to "webpub", + MediaType.READIUM_WEBPUB_MANIFEST to "json", + MediaType.W3C_WPUB_MANIFEST to "json", + MediaType.ZAB to "zab" + ) +) { + + private val fileExtensions: MutableMap = fileExtensions.toMutableMap() + + public fun register(mediaType: MediaType, fileExtension: String?) { + if (fileExtension == null) { + fileExtensions.remove(mediaType) + } else { + fileExtensions[mediaType] = fileExtension + } + } + + public fun fileExtension(mediaType: MediaType): String? = + fileExtensions[mediaType] +} diff --git a/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/FormatRegistryTest.kt b/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/FormatRegistryTest.kt index a3104d87df..850051ed1e 100644 --- a/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/FormatRegistryTest.kt +++ b/readium/shared/src/test/java/org/readium/r2/shared/util/mediatype/FormatRegistryTest.kt @@ -6,49 +6,22 @@ import org.junit.Test class FormatRegistryTest { - private fun sut() = FormatRegistry(DefaultMediaTypeSniffer()) + private fun sut() = FormatRegistry() @Test - fun `canonicalize media type`() = runBlocking { + fun `get known file extension from canonical media type`() = runBlocking { assertEquals( - MediaType("text/html")!!, - sut().canonicalize(MediaType("text/html;charset=utf-8")!!) - ) - assertEquals( - MediaType("application/atom+xml;profile=opds-catalog")!!, - sut().canonicalize( - MediaType("application/atom+xml;profile=opds-catalog;charset=utf-8")!! - ) - ) - assertEquals( - MediaType("application/unknown;charset=utf-8")!!, - sut().canonicalize(MediaType("application/unknown;charset=utf-8")!!) - ) - } - - @Test - fun `get known format from canonical media type`() = runBlocking { - assertEquals( - Format(name = "EPUB", fileExtension = "epub"), - sut().retrieve(MediaType("application/epub+zip")!!) - ) - } - - @Test - fun `get known format from non-canonical media type`() = runBlocking { - assertEquals( - Format(name = "EPUB", fileExtension = "epub"), - sut().retrieve(MediaType("application/epub+zip;param=value")!!) + "epub", + sut().fileExtension(MediaType.EPUB) ) } @Test - fun `register new format`() = runBlocking { + fun `register new file extensions`() = runBlocking { val mediaType = MediaType("application/test")!! - val format = Format(name = "Test", fileExtension = "tst") val sut = sut() - sut.register(mediaType, format) + sut.register(mediaType, fileExtension = "tst") - assertEquals(format, sut.retrieve(mediaType)) + assertEquals(sut.fileExtension(mediaType), "tst") } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt index aba405fc94..d368f34e18 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt @@ -35,7 +35,7 @@ class Readium(context: Context) { private val mediaTypeSniffer = DefaultMediaTypeSniffer() - val formatRegistry = FormatRegistry(mediaTypeSniffer) + val formatRegistry = FormatRegistry() val httpClient = DefaultHttpClient( mediaTypeSniffer = mediaTypeSniffer diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt index b9259f1c2c..6675f61d86 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt @@ -265,8 +265,7 @@ class BookRepository( ) } - val format = formatRegistry.retrieve(publicationTempAsset.mediaType) - val fileExtension = format?.fileExtension ?: "epub" + val fileExtension = formatRegistry.fileExtension(publicationTempAsset.mediaType) ?: "epub" val fileName = "${UUID.randomUUID()}.$fileExtension" val libraryFile = File(storageDir, fileName) val libraryUrl = libraryFile.toUrl() From 25bc2d3528c0beee9b3865cf301fb0ac54a7d4a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Mon, 21 Aug 2023 16:44:13 +0200 Subject: [PATCH 17/24] Make sure we always return canonical media types, and separate light from heavy sniffing --- .../readium/r2/lcp/service/NetworkService.kt | 8 +- .../navigator/epub/EpubNavigatorFragment.kt | 4 +- .../r2/navigator/epub/WebViewServer.kt | 13 +- .../r2/navigator/pdf/PdfNavigatorFragment.kt | 2 +- .../java/org/readium/r2/opds/OPDS1Parser.kt | 28 +- .../java/org/readium/r2/opds/OPDS2Parser.kt | 15 +- .../java/org/readium/r2/shared/Deprecated.kt | 6 +- .../readium/r2/shared/asset/AssetRetriever.kt | 20 +- .../org/readium/r2/shared/extensions/JSON.kt | 4 +- .../r2/shared/publication/Contributor.kt | 28 +- .../org/readium/r2/shared/publication/Link.kt | 71 +- .../readium/r2/shared/publication/Locator.kt | 26 +- .../readium/r2/shared/publication/Manifest.kt | 27 +- .../readium/r2/shared/publication/Metadata.kt | 54 +- .../r2/shared/publication/Publication.kt | 9 +- .../publication/PublicationCollection.kt | 18 +- .../readium/r2/shared/publication/Subject.kt | 16 +- .../r2/shared/publication/opds/Properties.kt | 3 +- .../ContentProtectionSchemeRetriever.kt | 6 +- .../LcpFallbackContentProtection.kt | 10 +- .../services/ContentProtectionService.kt | 14 +- .../publication/services/CoverService.kt | 5 +- .../publication/services/PositionsService.kt | 12 +- .../iterators/HtmlResourceContentIterator.kt | 2 +- .../r2/shared/resource/FileResource.kt | 7 +- .../r2/shared/resource/MediaTypeExt.kt | 19 +- .../r2/shared/resource/ZipContainer.kt | 13 +- .../archive/channel/ChannelZipContainer.kt | 9 +- .../r2/shared/util/http/DefaultHttpClient.kt | 16 +- .../r2/shared/util/mediatype/MediaType.kt | 5 +- .../shared/util/mediatype/MediaTypeHints.kt | 34 + .../shared/util/mediatype/MediaTypeSniffer.kt | 722 ++++++++++-------- ...rContext.kt => MediaTypeSnifferContent.kt} | 61 +- .../readium/r2/shared/util/pdf/PdfDocument.kt | 2 +- .../readium/r2/shared/publication/LinkTest.kt | 14 +- .../r2/shared/publication/LocatorTest.kt | 9 +- .../r2/shared/publication/ManifestTest.kt | 41 +- .../r2/shared/publication/PublicationTest.kt | 49 +- .../shared/publication/opds/PropertiesTest.kt | 3 +- .../LcpFallbackContentProtectionTest.kt | 3 +- .../publication/services/CoverServiceTest.kt | 2 +- .../services/LocatorServiceTest.kt | 11 +- .../services/PositionsServiceTest.kt | 7 +- .../HtmlResourceContentIteratorTest.kt | 13 +- .../util/mediatype/MediaTypeSnifferTest.kt | 43 +- .../readium/r2/streamer/ParserAssetFactory.kt | 10 +- .../readium/r2/streamer/PublicationFactory.kt | 42 +- .../readium/r2/streamer/extensions/Link.kt | 2 +- .../parser/audio/AudioLocatorService.kt | 2 +- .../r2/streamer/parser/epub/EpubParser.kt | 7 +- .../parser/epub/EpubPositionsService.kt | 3 +- .../streamer/parser/epub/ManifestAdapter.kt | 8 +- .../streamer/parser/epub/MetadataAdapter.kt | 2 +- .../r2/streamer/parser/epub/MetadataParser.kt | 11 +- .../streamer/parser/epub/PackageDocument.kt | 5 +- .../streamer/parser/epub/ResourceAdapter.kt | 9 +- .../parser/pdf/PdfPositionsService.kt | 2 +- .../parser/readium/LcpdfPositionsService.kt | 2 +- .../parser/readium/ReadiumWebPubParser.kt | 9 +- .../java/org/readium/r2/testapp/Readium.kt | 7 +- .../r2/testapp/catalogs/CatalogViewModel.kt | 2 +- 61 files changed, 940 insertions(+), 667 deletions(-) rename readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/{MediaTypeSnifferContext.kt => MediaTypeSnifferContent.kt} (59%) 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 9d2a81d87d..49f0ce18dc 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 @@ -22,10 +22,10 @@ import kotlinx.coroutines.withContext import org.readium.r2.lcp.LcpException import org.readium.r2.shared.error.Try import org.readium.r2.shared.util.http.invoke -import org.readium.r2.shared.util.mediatype.HintMediaTypeSnifferContext import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.mediatype.MediaTypeHints import org.readium.r2.shared.util.mediatype.MediaTypeSniffer +import org.readium.r2.shared.util.mediatype.WebPubMediaTypeSniffer.sniff import timber.log.Timber internal typealias URLParameters = Map @@ -140,11 +140,7 @@ internal class NetworkService( } } - mediaTypeSniffer.sniff( - HintMediaTypeSnifferContext( - hints = MediaTypeHints(connection, mediaType = mediaType) - ) - ) + mediaTypeSniffer.sniff(MediaTypeHints(connection, mediaType = mediaType)) } catch (e: Exception) { Timber.e(e) throw LcpException.Network(e) 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 1aacf11ed7..7607939b78 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 @@ -955,7 +955,7 @@ public class EpubNavigatorFragment internal constructor( return currentReflowablePageFragment?.webView?.findFirstVisibleLocator() ?.copy( href = resource.href, - type = resource.type ?: MediaType.XHTML.toString() + type = (resource.mediaType ?: MediaType.XHTML).toString() ) } @@ -1026,7 +1026,7 @@ public class EpubNavigatorFragment internal constructor( val currentLocator = Locator( href = link.href, - type = link.type ?: MediaType.XHTML.toString(), + type = (link.mediaType ?: MediaType.XHTML).toString(), title = tableOfContentsTitleByHref[link.href] ?: positionLocator?.title ?: link.title, locations = (positionLocator?.locations ?: Locator.Locations()).copy( progression = progression 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 dca2acdc13..e223348c0c 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 @@ -90,7 +90,7 @@ internal class WebViewServer( var resource = publication.get(linkWithoutAnchor) .fallback { errorResource(link, error = it) } - if (link.mediaType.isHtml) { + if (link.mediaType?.isHtml == true) { resource = resource.injectHtml( publication, css, @@ -105,7 +105,7 @@ internal class WebViewServer( if (range == null) { return WebResourceResponse( - link.type, + link.mediaType?.toString(), null, 200, "OK", @@ -119,7 +119,14 @@ internal class WebViewServer( headers["Content-Range"] = "bytes ${longRange.first}-${longRange.last}/$length" // Content-Length will automatically be filled by the WebView using the Content-Range header. // headers["Content-Length"] = (longRange.last - longRange.first + 1).toString() - return WebResourceResponse(link.type, null, 206, "Partial Content", headers, stream) + return WebResourceResponse( + link.mediaType?.toString(), + null, + 206, + "Partial Content", + headers, + stream + ) } } diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/pdf/PdfNavigatorFragment.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/pdf/PdfNavigatorFragment.kt index 96009cbff9..995d37adce 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/pdf/PdfNavigatorFragment.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/pdf/PdfNavigatorFragment.kt @@ -111,7 +111,7 @@ public class PdfNavigatorFragment { var openSearchURL: URL? = null - var selfMimeType: String? = null + var selfMimeType: MediaType? = null for (link in feed.links) { if (link.rels.contains("self")) { - if (link.type != null) { - selfMimeType = link.type + if (link.mediaType != null) { + selfMimeType = link.mediaType } } else if (link.rels.contains("search")) { openSearchURL = URL(link.href) @@ -217,7 +221,7 @@ public class OPDS1Parser { selfMimeType?.let { s -> - val selfMimeParams = parseMimeType(mimeTypeString = s) + val selfMimeParams = parseMimeType(mimeTypeString = s.toString()) for (url in urls) { val urlMimeType = url.getAttr("type") ?: continue val otherMimeParams = parseMimeType(mimeTypeString = urlMimeType) @@ -271,7 +275,7 @@ public class OPDS1Parser { Link( href = Href(href, baseHref = baseUrl.toString()).percentEncodedString, - type = element.getAttr("type"), + mediaType = sniff(element.getAttr("type")), title = element.getAttr("title"), rels = listOfNotNull(rel).toSet(), properties = Properties(otherProperties = properties) @@ -418,5 +422,11 @@ public class OPDS1Parser { children = fromXML(child) ) } + + public var mediaTypeSniffer: MediaTypeSniffer = DefaultMediaTypeSniffer() + + private fun sniff(mediaType: String?): MediaType? = + mediaType?.let { MediaType(it) } + ?.let { mediaTypeSniffer.sniffHints(MediaTypeHints(mediaType = it)) ?: it } } } diff --git a/readium/opds/src/main/java/org/readium/r2/opds/OPDS2Parser.kt b/readium/opds/src/main/java/org/readium/r2/opds/OPDS2Parser.kt index 9aa89c10ed..5246d0820d 100644 --- a/readium/opds/src/main/java/org/readium/r2/opds/OPDS2Parser.kt +++ b/readium/opds/src/main/java/org/readium/r2/opds/OPDS2Parser.kt @@ -28,6 +28,8 @@ import org.readium.r2.shared.util.http.DefaultHttpClient import org.readium.r2.shared.util.http.HttpClient import org.readium.r2.shared.util.http.HttpRequest import org.readium.r2.shared.util.http.fetchWithDecoder +import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer +import org.readium.r2.shared.util.mediatype.MediaTypeSniffer public enum class OPDS2ParserError { MetadataNotFound, @@ -64,7 +66,10 @@ public class OPDS2Parser { } else { ParseData( null, - Manifest.fromJSON(JSONObject(String(jsonData)))?.let { Publication(it) }, + Manifest.fromJSON( + JSONObject(String(jsonData)), + mediaTypeSniffer = mediaTypeSniffer + )?.let { Publication(it) }, 2 ) } @@ -207,7 +212,7 @@ public class OPDS2Parser { private fun parsePublications(feed: Feed, publications: JSONArray) { for (i in 0 until publications.length()) { val pubDict = publications.getJSONObject(i) - Manifest.fromJSON(pubDict)?.let { manifest -> + Manifest.fromJSON(pubDict, mediaTypeSniffer = mediaTypeSniffer)?.let { manifest -> feed.publications.add(Publication(manifest)) } } @@ -257,7 +262,7 @@ public class OPDS2Parser { ?: throw Exception(OPDS2ParserError.InvalidGroup.name) for (j in 0 until publications.length()) { val pubDict = publications.getJSONObject(j) - Manifest.fromJSON(pubDict)?.let { manifest -> + Manifest.fromJSON(pubDict, mediaTypeSniffer = mediaTypeSniffer)?.let { manifest -> group.publications.add(Publication(manifest)) } } @@ -268,12 +273,14 @@ public class OPDS2Parser { private fun parseLink(feed: Feed, json: JSONObject): Link? { val baseUrl = feed.href.removeLastComponent() - return Link.fromJSON(json, normalizeHref = { + return Link.fromJSON(json, mediaTypeSniffer, normalizeHref = { Href( it, baseUrl.toString() ).string }) } + + public var mediaTypeSniffer: MediaTypeSniffer = DefaultMediaTypeSniffer() } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/Deprecated.kt b/readium/shared/src/main/java/org/readium/r2/shared/Deprecated.kt index bce0da7811..3d2afcded9 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/Deprecated.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/Deprecated.kt @@ -169,10 +169,8 @@ public typealias RenditionSpread = Presentation.Spread ReplaceWith("Manifest.fromJSON(pubDict)", "org.readium.r2.shared.publication.Manifest"), level = DeprecationLevel.ERROR ) -public fun parsePublication(pubDict: JSONObject): org.readium.r2.shared.publication.Publication { - return org.readium.r2.shared.publication.Manifest.fromJSON(pubDict)?.let { Publication(it) } - ?: throw Exception("Invalid publication") -} +public fun parsePublication(): org.readium.r2.shared.publication.Publication = + throw NotImplementedError() @Suppress("Unused_parameter") @Deprecated( diff --git a/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt b/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt index 65f9344a22..af7218914f 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/asset/AssetRetriever.kt @@ -19,16 +19,17 @@ import org.readium.r2.shared.extensions.queryProjection import org.readium.r2.shared.resource.ArchiveFactory import org.readium.r2.shared.resource.Container import org.readium.r2.shared.resource.ContainerFactory -import org.readium.r2.shared.resource.ContainerMediaTypeSnifferContext +import org.readium.r2.shared.resource.ContainerMediaTypeSnifferContent import org.readium.r2.shared.resource.DefaultArchiveFactory import org.readium.r2.shared.resource.DirectoryContainerFactory import org.readium.r2.shared.resource.FileResourceFactory import org.readium.r2.shared.resource.Resource import org.readium.r2.shared.resource.ResourceFactory -import org.readium.r2.shared.resource.ResourceMediaTypeSnifferContext +import org.readium.r2.shared.resource.ResourceMediaTypeSnifferContent import org.readium.r2.shared.util.Either import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer +import org.readium.r2.shared.util.mediatype.EpubMediaTypeSniffer.sniff import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.mediatype.MediaTypeHints import org.readium.r2.shared.util.mediatype.MediaTypeSniffer @@ -356,13 +357,14 @@ public class AssetRetriever( asset: Either, hints: MediaTypeHints ): MediaType? { - suspend fun sniff(hints: MediaTypeHints): MediaType? { - val context = when (asset) { - is Either.Left -> ResourceMediaTypeSnifferContext(asset.value, hints) - is Either.Right -> ContainerMediaTypeSnifferContext(asset.value, hints) - } - return mediaTypeSniffer.sniff(context) - } + suspend fun sniff(hints: MediaTypeHints): MediaType? = + mediaTypeSniffer.sniff( + hints = hints, + content = when (asset) { + is Either.Left -> ResourceMediaTypeSnifferContent(asset.value) + is Either.Right -> ContainerMediaTypeSnifferContent(asset.value) + } + ) sniff(hints)?.let { return it } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/extensions/JSON.kt b/readium/shared/src/main/java/org/readium/r2/shared/extensions/JSON.kt index 1c506afc18..9d811c538e 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/extensions/JSON.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/extensions/JSON.kt @@ -302,7 +302,7 @@ public fun JSONObject.mapNotNull(transform: (Pair) -> T?): List * If the tranform returns `null`, it is not included in the output list. */ @InternalReadiumApi -public fun JSONArray.mapNotNull(transform: (Any) -> T?): List { +public inline fun JSONArray.mapNotNull(transform: (Any) -> T?): List { val result = mutableListOf() for (i in 0 until length()) { val transformedValue = transform(get(i)) @@ -331,7 +331,7 @@ internal fun JSONArray.filterIsInstance(klass: Class): List { /** * Parses a [JSONArray] of [JSONObject] into a [List] of models using the given [factory]. */ -internal fun JSONArray?.parseObjects(factory: (Any) -> T?): List { +internal inline fun JSONArray?.parseObjects(factory: (Any) -> T?): List { this ?: return emptyList() val models = mutableListOf() diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/Contributor.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/Contributor.kt index b2a97601cf..cc44e5c830 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/Contributor.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/Contributor.kt @@ -14,9 +14,15 @@ import kotlinx.parcelize.Parcelize import org.json.JSONArray import org.json.JSONObject import org.readium.r2.shared.JSONable -import org.readium.r2.shared.extensions.* +import org.readium.r2.shared.extensions.optNullableDouble +import org.readium.r2.shared.extensions.optNullableString +import org.readium.r2.shared.extensions.optStringsFromArrayOrSingle +import org.readium.r2.shared.extensions.parseObjects +import org.readium.r2.shared.extensions.putIfNotEmpty import org.readium.r2.shared.util.logging.WarningLogger import org.readium.r2.shared.util.logging.log +import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer +import org.readium.r2.shared.util.mediatype.MediaTypeSniffer /** * Contributor Object for the Readium Web Publication Manifest. @@ -81,6 +87,7 @@ public data class Contributor( */ public fun fromJSON( json: Any?, + mediaTypeSniffer: MediaTypeSniffer = DefaultMediaTypeSniffer(), normalizeHref: LinkHrefNormalizer = LinkHrefNormalizerIdentity, warnings: WarningLogger? = null ): Contributor? { @@ -103,7 +110,12 @@ public data class Contributor( localizedSortAs = LocalizedString.fromJSON(jsonObject.remove("sortAs"), warnings), roles = jsonObject.optStringsFromArrayOrSingle("role").toSet(), position = jsonObject.optNullableDouble("position"), - links = Link.fromJSONArray(jsonObject.optJSONArray("links"), normalizeHref) + links = Link.fromJSONArray( + jsonObject.optJSONArray("links"), + mediaTypeSniffer, + normalizeHref, + warnings + ) ) } @@ -116,15 +128,23 @@ public data class Contributor( */ public fun fromJSONArray( json: Any?, + mediaTypeSniffer: MediaTypeSniffer = DefaultMediaTypeSniffer(), normalizeHref: LinkHrefNormalizer = LinkHrefNormalizerIdentity, warnings: WarningLogger? = null ): List { return when (json) { is String, is JSONObject -> - listOf(json).mapNotNull { fromJSON(it, normalizeHref, warnings) } + listOf(json).mapNotNull { + fromJSON( + it, + mediaTypeSniffer, + normalizeHref, + warnings + ) + } is JSONArray -> - json.parseObjects { fromJSON(it, normalizeHref, warnings) } + json.parseObjects { fromJSON(it, mediaTypeSniffer, normalizeHref, warnings) } else -> emptyList() } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/Link.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/Link.kt index 5b460c91a4..a4718d4a4a 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/Link.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/Link.kt @@ -15,12 +15,20 @@ import kotlinx.parcelize.Parcelize import org.json.JSONArray import org.json.JSONObject import org.readium.r2.shared.JSONable -import org.readium.r2.shared.extensions.* +import org.readium.r2.shared.extensions.optNullableString +import org.readium.r2.shared.extensions.optPositiveDouble +import org.readium.r2.shared.extensions.optPositiveInt +import org.readium.r2.shared.extensions.optStringsFromArrayOrSingle +import org.readium.r2.shared.extensions.parseObjects +import org.readium.r2.shared.extensions.putIfNotEmpty import org.readium.r2.shared.util.Href import org.readium.r2.shared.util.URITemplate import org.readium.r2.shared.util.logging.WarningLogger import org.readium.r2.shared.util.logging.log +import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeSniffer +import org.readium.r2.shared.util.mediatype.sniff /** * Function used to recursively transform the href of a [Link] when parsing its JSON @@ -39,7 +47,7 @@ public val LinkHrefNormalizerIdentity: LinkHrefNormalizer = { it } * https://readium.org/webpub-manifest/schema/link.schema.json * * @param href URI or URI template of the linked resource. - * @param type MIME type of the linked resource. + * @param mediaType Media type of the linked resource. * @param templated Indicates that a URI template is used in href. * @param title Title of the linked resource. * @param rels Relation between the linked resource and its containing collection. @@ -56,7 +64,7 @@ public val LinkHrefNormalizerIdentity: LinkHrefNormalizer = { it } @Parcelize public data class Link( val href: String, - val type: String? = null, + val mediaType: MediaType? = null, val templated: Boolean = false, val title: String? = null, val rels: Set = setOf(), @@ -70,10 +78,6 @@ public data class Link( val children: List = listOf() ) : JSONable, Parcelable { - /** Media type of the linked resource. */ - val mediaType: MediaType get() = - type?.let { MediaType(it) } ?: MediaType.BINARY - /** * List of URI template parameter keys, if the [Link] is templated. */ @@ -113,7 +117,7 @@ public data class Link( */ override fun toJSON(): JSONObject = JSONObject().apply { put("href", href) - put("type", type) + put("type", mediaType?.toString()) put("templated", templated) put("title", title) putIfNotEmpty("rel", rels) @@ -143,6 +147,7 @@ public data class Link( */ public fun fromJSON( json: JSONObject?, + mediaTypeSniffer: MediaTypeSniffer = DefaultMediaTypeSniffer(), normalizeHref: LinkHrefNormalizer = LinkHrefNormalizerIdentity, warnings: WarningLogger? = null ): Link? { @@ -154,7 +159,9 @@ public data class Link( return Link( href = normalizeHref(href), - type = json.optNullableString("type"), + mediaType = json.optNullableString("type") + ?.let { MediaType(it) } + ?.let { mediaTypeSniffer.sniff(it) }, templated = json.optBoolean("templated", false), title = json.optNullableString("title"), rels = json.optStringsFromArrayOrSingle("rel").toSet(), @@ -164,8 +171,16 @@ public data class Link( bitrate = json.optPositiveDouble("bitrate"), duration = json.optPositiveDouble("duration"), languages = json.optStringsFromArrayOrSingle("language"), - alternates = fromJSONArray(json.optJSONArray("alternate"), normalizeHref), - children = fromJSONArray(json.optJSONArray("children"), normalizeHref) + alternates = fromJSONArray( + json.optJSONArray("alternate"), + mediaTypeSniffer, + normalizeHref + ), + children = fromJSONArray( + json.optJSONArray("children"), + mediaTypeSniffer, + normalizeHref + ) ) } @@ -177,16 +192,30 @@ public data class Link( */ public fun fromJSONArray( json: JSONArray?, + mediaTypeSniffer: MediaTypeSniffer = DefaultMediaTypeSniffer(), normalizeHref: LinkHrefNormalizer = LinkHrefNormalizerIdentity, warnings: WarningLogger? = null ): List { - return json.parseObjects { fromJSON(it as? JSONObject, normalizeHref, warnings) } + return json.parseObjects { + fromJSON( + it as? JSONObject, + mediaTypeSniffer, + normalizeHref, + warnings + ) + } } } + @Deprecated( + "Use [mediaType.toString()] instead", + ReplaceWith("mediaType.toString()"), + level = DeprecationLevel.ERROR + ) + val type: String? get() = throw NotImplementedError() + @Deprecated("Use [type] instead", ReplaceWith("type"), level = DeprecationLevel.ERROR) - val typeLink: String? - get() = type + val typeLink: String? get() = throw NotImplementedError() @Deprecated("Use [rels] instead.", ReplaceWith("rels"), level = DeprecationLevel.ERROR) val rel: List @@ -219,49 +248,49 @@ public fun List.filterByRel(rel: String): List = filter { it.rels.co * Finds the first link matching the given media type. */ public fun List.firstWithMediaType(mediaType: MediaType): Link? = firstOrNull { - it.mediaType.matches(mediaType) + mediaType.matches(it.mediaType) } /** * Finds all the links matching the given media type. */ public fun List.filterByMediaType(mediaType: MediaType): List = filter { - it.mediaType.matches(mediaType) + mediaType.matches(it.mediaType) } /** * Finds all the links matching any of the given media types. */ public fun List.filterByMediaTypes(mediaTypes: List): List = filter { - mediaTypes.any { mediaType -> mediaType.matches(it.type) } + mediaTypes.any { mediaType -> mediaType.matches(it.mediaType) } } /** * Returns whether all the resources in the collection are bitmaps. */ public val List.allAreBitmap: Boolean get() = isNotEmpty() && all { - it.mediaType.isBitmap + it.mediaType?.isBitmap ?: false } /** * Returns whether all the resources in the collection are audio clips. */ public val List.allAreAudio: Boolean get() = isNotEmpty() && all { - it.mediaType.isAudio + it.mediaType?.isAudio ?: false } /** * Returns whether all the resources in the collection are video clips. */ public val List.allAreVideo: Boolean get() = isNotEmpty() && all { - it.mediaType.isVideo + it.mediaType?.isVideo ?: false } /** * Returns whether all the resources in the collection are HTML documents. */ public val List.allAreHtml: Boolean get() = isNotEmpty() && all { - it.mediaType.isHtml + it.mediaType?.isHtml ?: false } /** diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/Locator.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/Locator.kt index ce450cec6d..ba764ae645 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/Locator.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/Locator.kt @@ -19,6 +19,8 @@ import org.readium.r2.shared.extensions.* import org.readium.r2.shared.toJSON import org.readium.r2.shared.util.logging.WarningLogger import org.readium.r2.shared.util.logging.log +import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer +import org.readium.r2.shared.util.mediatype.MediaTypeSniffer /** * Represents a precise location in a publication in a format that can be stored and shared. @@ -215,17 +217,7 @@ public data class Locator( "This may create an incorrect `Locator` if the link `type` is missing. Use `publication.locatorFromLink()` instead.", level = DeprecationLevel.ERROR ) -public fun Link.toLocator(): Locator { - val components = href.split("#", limit = 2) - return Locator( - href = components.firstOrNull() ?: href, - type = type ?: "", - title = title, - locations = Locator.Locations( - fragments = listOfNotNull(components.getOrNull(1)) - ) - ) -} +public fun Link.toLocator(): Locator = throw NotImplementedError() /** * Represents a sequential list of `Locator` objects. @@ -286,10 +278,18 @@ public data class LocatorCollection( public companion object { - public fun fromJSON(json: JSONObject?, warnings: WarningLogger? = null): LocatorCollection { + public fun fromJSON( + json: JSONObject?, + mediaTypeSniffer: MediaTypeSniffer = DefaultMediaTypeSniffer(), + warnings: WarningLogger? = null + ): LocatorCollection { return LocatorCollection( metadata = Metadata.fromJSON(json?.optJSONObject("metadata"), warnings), - links = Link.fromJSONArray(json?.optJSONArray("links"), warnings = warnings), + links = Link.fromJSONArray( + json?.optJSONArray("links"), + mediaTypeSniffer, + warnings = warnings + ), locators = Locator.fromJSONArray(json?.optJSONArray("locators"), warnings) ) } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/Manifest.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/Manifest.kt index 6b6acc509f..629fb050a6 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/Manifest.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/Manifest.kt @@ -20,7 +20,9 @@ import org.readium.r2.shared.toJSON import org.readium.r2.shared.util.Href import org.readium.r2.shared.util.logging.WarningLogger import org.readium.r2.shared.util.logging.log +import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeSniffer /** * Holds the metadata of a Readium publication, as described in the Readium Web Publication Manifest. @@ -111,7 +113,7 @@ public data class Manifest( val components = link.href.split("#", limit = 2) val href = components.firstOrNull() ?: link.href val resourceLink = linkWithHref(href) ?: return null - val type = resourceLink.type ?: return null + val type = resourceLink.mediaType?.toString() ?: return null val fragment = components.getOrNull(1) return Locator( @@ -155,6 +157,7 @@ public data class Manifest( public fun fromJSON( json: JSONObject?, packaged: Boolean = false, + mediaTypeSniffer: MediaTypeSniffer = DefaultMediaTypeSniffer(), warnings: WarningLogger? = null ): Manifest? { json ?: return null @@ -163,7 +166,11 @@ public data class Manifest( if (packaged) { "/" } else { - Link.fromJSONArray(json.optJSONArray("links"), warnings = warnings) + Link.fromJSONArray( + json.optJSONArray("links"), + mediaTypeSniffer, + warnings = warnings + ) .firstWithRel("self") ?.href ?.toUrlOrNull() @@ -178,6 +185,7 @@ public data class Manifest( val metadata = Metadata.fromJSON( json.remove("metadata") as? JSONObject, + mediaTypeSniffer, normalizeHref, warnings ) @@ -188,6 +196,7 @@ public data class Manifest( val links = Link.fromJSONArray( json.remove("links") as? JSONArray, + mediaTypeSniffer, normalizeHref, warnings ) @@ -201,18 +210,25 @@ public data class Manifest( // [readingOrder] used to be [spine], so we parse [spine] as a fallback. val readingOrderJSON = (json.remove("readingOrder") ?: json.remove("spine")) as? JSONArray - val readingOrder = Link.fromJSONArray(readingOrderJSON, normalizeHref, warnings) - .filter { it.type != null } + val readingOrder = Link.fromJSONArray( + readingOrderJSON, + mediaTypeSniffer, + normalizeHref, + warnings + ) + .filter { it.mediaType != null } val resources = Link.fromJSONArray( json.remove("resources") as? JSONArray, + mediaTypeSniffer, normalizeHref, warnings ) - .filter { it.type != null } + .filter { it.mediaType != null } val tableOfContents = Link.fromJSONArray( json.remove("toc") as? JSONArray, + mediaTypeSniffer, normalizeHref, warnings ) @@ -220,6 +236,7 @@ public data class Manifest( // Parses subcollections from the remaining JSON properties. val subcollections = PublicationCollection.collectionsFromJSON( json, + mediaTypeSniffer, normalizeHref, warnings ) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/Metadata.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/Metadata.kt index 1d7d350b77..62f6d07f36 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/Metadata.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/Metadata.kt @@ -27,6 +27,8 @@ import org.readium.r2.shared.publication.presentation.presentation import org.readium.r2.shared.util.Language import org.readium.r2.shared.util.logging.WarningLogger import org.readium.r2.shared.util.logging.log +import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer +import org.readium.r2.shared.util.mediatype.MediaTypeSniffer /** * https://readium.org/webpub-manifest/schema/metadata.schema.json @@ -255,6 +257,7 @@ public data class Metadata( */ public fun fromJSON( json: JSONObject?, + mediaTypeSniffer: MediaTypeSniffer = DefaultMediaTypeSniffer(), normalizeHref: LinkHrefNormalizer = LinkHrefNormalizerIdentity, warnings: WarningLogger? = null ): Metadata? { @@ -276,53 +279,87 @@ public data class Metadata( val accessibility = Accessibility.fromJSON(json.remove("accessibility")) val languages = json.optStringsFromArrayOrSingle("language", remove = true) val localizedSortAs = LocalizedString.fromJSON(json.remove("sortAs"), warnings) - val subjects = Subject.fromJSONArray(json.remove("subject"), normalizeHref, warnings) - val authors = Contributor.fromJSONArray(json.remove("author"), normalizeHref, warnings) + val subjects = Subject.fromJSONArray( + json.remove("subject"), + mediaTypeSniffer, + normalizeHref, + warnings + ) + val authors = Contributor.fromJSONArray( + json.remove("author"), + mediaTypeSniffer, + normalizeHref, + warnings + ) val translators = Contributor.fromJSONArray( json.remove("translator"), + mediaTypeSniffer, + normalizeHref, + warnings + ) + val editors = Contributor.fromJSONArray( + json.remove("editor"), + mediaTypeSniffer, + normalizeHref, + warnings + ) + val artists = Contributor.fromJSONArray( + json.remove("artist"), + mediaTypeSniffer, normalizeHref, warnings ) - val editors = Contributor.fromJSONArray(json.remove("editor"), normalizeHref, warnings) - val artists = Contributor.fromJSONArray(json.remove("artist"), normalizeHref, warnings) val illustrators = Contributor.fromJSONArray( json.remove("illustrator"), + mediaTypeSniffer, normalizeHref, warnings ) val letterers = Contributor.fromJSONArray( json.remove("letterer"), + mediaTypeSniffer, normalizeHref, warnings ) val pencilers = Contributor.fromJSONArray( json.remove("penciler"), + mediaTypeSniffer, normalizeHref, warnings ) val colorists = Contributor.fromJSONArray( json.remove("colorist"), + mediaTypeSniffer, + normalizeHref, + warnings + ) + val inkers = Contributor.fromJSONArray( + json.remove("inker"), + mediaTypeSniffer, normalizeHref, warnings ) - val inkers = Contributor.fromJSONArray(json.remove("inker"), normalizeHref, warnings) val narrators = Contributor.fromJSONArray( json.remove("narrator"), + mediaTypeSniffer, normalizeHref, warnings ) val contributors = Contributor.fromJSONArray( json.remove("contributor"), + mediaTypeSniffer, normalizeHref, warnings ) val publishers = Contributor.fromJSONArray( json.remove("publisher"), + mediaTypeSniffer, normalizeHref, warnings ) val imprints = Contributor.fromJSONArray( json.remove("imprint"), + mediaTypeSniffer, normalizeHref, warnings ) @@ -343,7 +380,12 @@ public data class Metadata( for (key in belongsToJson.keys()) { if (!belongsToJson.isNull(key)) { val value = belongsToJson.get(key) - belongsTo[key] = Collection.fromJSONArray(value, normalizeHref, warnings) + belongsTo[key] = Collection.fromJSONArray( + value, + mediaTypeSniffer, + normalizeHref, + warnings + ) } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/Publication.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/Publication.kt index 63d871a2cc..eba3934190 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/Publication.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/Publication.kt @@ -668,8 +668,13 @@ public class Publication( public fun contentLayoutForLanguage(language: String?): ReadingProgression = metadata.effectiveReadingProgression } -private fun Resource.withMediaType(mediaType: MediaType): Resource = - object : Resource by this { +private fun Resource.withMediaType(mediaType: MediaType?): Resource { + if (mediaType == null) { + return this + } + + return object : Resource by this { override suspend fun mediaType(): ResourceTry = ResourceTry.success(mediaType) } +} diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/PublicationCollection.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/PublicationCollection.kt index ca8a4e5210..4e54c3d290 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/PublicationCollection.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/PublicationCollection.kt @@ -21,6 +21,8 @@ import org.readium.r2.shared.extensions.putIfNotEmpty import org.readium.r2.shared.extensions.toMap import org.readium.r2.shared.util.logging.WarningLogger import org.readium.r2.shared.util.logging.log +import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer +import org.readium.r2.shared.util.mediatype.MediaTypeSniffer /** * Core Collection Model @@ -55,6 +57,7 @@ public data class PublicationCollection( */ public fun fromJSON( json: Any?, + mediaTypeSniffer: MediaTypeSniffer = DefaultMediaTypeSniffer(), normalizeHref: LinkHrefNormalizer = LinkHrefNormalizerIdentity, warnings: WarningLogger? = null ): PublicationCollection? { @@ -69,16 +72,22 @@ public data class PublicationCollection( is JSONObject -> { links = Link.fromJSONArray( json.remove("links") as? JSONArray, + mediaTypeSniffer, normalizeHref, warnings ) metadata = (json.remove("metadata") as? JSONObject)?.toMap() - subcollections = collectionsFromJSON(json, normalizeHref, warnings) + subcollections = collectionsFromJSON( + json, + mediaTypeSniffer, + normalizeHref, + warnings + ) } // Parses an array of links. is JSONArray -> { - links = Link.fromJSONArray(json, normalizeHref, warnings) + links = Link.fromJSONArray(json, mediaTypeSniffer, normalizeHref, warnings) } else -> { @@ -111,6 +120,7 @@ public data class PublicationCollection( */ public fun collectionsFromJSON( json: JSONObject, + mediaTypeSniffer: MediaTypeSniffer = DefaultMediaTypeSniffer(), normalizeHref: LinkHrefNormalizer = LinkHrefNormalizerIdentity, warnings: WarningLogger? = null ): Map> { @@ -119,14 +129,14 @@ public data class PublicationCollection( val subJSON = json.get(role) // Parses a list of links or a single collection object. - val collection = fromJSON(subJSON, normalizeHref, warnings) + val collection = fromJSON(subJSON, mediaTypeSniffer, normalizeHref, warnings) if (collection != null) { collections.getOrPut(role) { mutableListOf() }.add(collection) // Parses a list of collection objects. } else if (subJSON is JSONArray) { collections.getOrPut(role) { mutableListOf() }.addAll( - subJSON.mapNotNull { fromJSON(it, normalizeHref, warnings) } + subJSON.mapNotNull { fromJSON(it, mediaTypeSniffer, normalizeHref, warnings) } ) } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/Subject.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/Subject.kt index facff1bbaf..c4138e658a 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/Subject.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/Subject.kt @@ -19,6 +19,8 @@ import org.readium.r2.shared.extensions.parseObjects import org.readium.r2.shared.extensions.putIfNotEmpty import org.readium.r2.shared.util.logging.WarningLogger import org.readium.r2.shared.util.logging.log +import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer +import org.readium.r2.shared.util.mediatype.MediaTypeSniffer /** * https://github.com/readium/webpub-manifest/tree/master/contexts/default#subjects @@ -77,6 +79,7 @@ public data class Subject( */ public fun fromJSON( json: Any?, + mediaTypeSniffer: MediaTypeSniffer = DefaultMediaTypeSniffer(), normalizeHref: LinkHrefNormalizer = LinkHrefNormalizerIdentity, warnings: WarningLogger? = null ): Subject? { @@ -100,6 +103,7 @@ public data class Subject( code = jsonObject.optNullableString("code"), links = Link.fromJSONArray( jsonObject.optJSONArray("links"), + mediaTypeSniffer, normalizeHref, warnings ) @@ -115,15 +119,23 @@ public data class Subject( */ public fun fromJSONArray( json: Any?, + mediaTypeSniffer: MediaTypeSniffer = DefaultMediaTypeSniffer(), normalizeHref: LinkHrefNormalizer = LinkHrefNormalizerIdentity, warnings: WarningLogger? = null ): List { return when (json) { is String, is JSONObject -> - listOf(json).mapNotNull { fromJSON(it, normalizeHref, warnings) } + listOf(json).mapNotNull { + fromJSON( + it, + mediaTypeSniffer, + normalizeHref, + warnings + ) + } is JSONArray -> - json.parseObjects { fromJSON(it, normalizeHref, warnings) } + json.parseObjects { fromJSON(it, mediaTypeSniffer, normalizeHref, warnings) } else -> emptyList() } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/opds/Properties.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/opds/Properties.kt index 208c87fad5..653b124918 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/opds/Properties.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/opds/Properties.kt @@ -13,6 +13,7 @@ import org.json.JSONObject import org.readium.r2.shared.opds.* import org.readium.r2.shared.publication.Link import org.readium.r2.shared.publication.Properties +import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer // OPDS extensions for link [Properties]. // https://drafts.opds.io/schema/properties.schema.json @@ -83,4 +84,4 @@ public val Properties.availability: Availability? */ public val Properties.authenticate: Link? get() = (this["authenticate"] as? Map<*, *>) - ?.let { Link.fromJSON(JSONObject(it)) } + ?.let { Link.fromJSON(JSONObject(it), mediaTypeSniffer = DefaultMediaTypeSniffer()) } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/ContentProtectionSchemeRetriever.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/ContentProtectionSchemeRetriever.kt index 8cd3a45ae3..f23ea297d2 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/ContentProtectionSchemeRetriever.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/ContentProtectionSchemeRetriever.kt @@ -7,16 +7,18 @@ package org.readium.r2.shared.publication.protection import org.readium.r2.shared.asset.Asset +import org.readium.r2.shared.util.mediatype.MediaTypeSniffer /** * Retrieves [ContentProtection] schemes of assets. */ public class ContentProtectionSchemeRetriever( - contentProtections: List + contentProtections: List, + mediaTypeSniffer: MediaTypeSniffer ) { private val contentProtections: List = contentProtections + listOf( - LcpFallbackContentProtection(), + LcpFallbackContentProtection(mediaTypeSniffer), AdeptFallbackContentProtection() ) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/LcpFallbackContentProtection.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/LcpFallbackContentProtection.kt index bf74e69db9..1610a87111 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/LcpFallbackContentProtection.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/LcpFallbackContentProtection.kt @@ -21,13 +21,16 @@ import org.readium.r2.shared.resource.Resource import org.readium.r2.shared.resource.readAsJson import org.readium.r2.shared.resource.readAsXml import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeSniffer /** * [ContentProtection] implementation used as a fallback by the Streamer to detect LCP DRM * if it is not supported by the app. */ @InternalReadiumApi -public class LcpFallbackContentProtection : ContentProtection { +public class LcpFallbackContentProtection( + private val mediaTypeSniffer: MediaTypeSniffer +) : ContentProtection { override val scheme: Scheme = Scheme.Lcp @@ -74,7 +77,10 @@ public class LcpFallbackContentProtection : ContentProtection { val manifestAsJson = container.get("/manifest.json").readAsJsonOrNull() ?: return false - val manifest = Manifest.fromJSON(manifestAsJson) + val manifest = Manifest.fromJSON( + manifestAsJson, + mediaTypeSniffer = mediaTypeSniffer + ) ?: return false return manifest diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/services/ContentProtectionService.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/services/ContentProtectionService.kt index 6df50617a5..15114b53d0 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/services/ContentProtectionService.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/services/ContentProtectionService.kt @@ -255,9 +255,11 @@ private sealed class RouteHandler { object ContentProtectionHandler : RouteHandler() { + private val mediaType = MediaType("application/vnd.readium.content-protection+json")!! + override val link = Link( href = "/~readium/content-protection", - type = "application/vnd.readium.content-protection+json" + mediaType = mediaType ) override fun acceptRequest(link: Link): Boolean = link.href == this.link.href @@ -265,7 +267,7 @@ private sealed class RouteHandler { override fun handleRequest(link: Link, service: ContentProtectionService): Resource = StringResource( url = Url(link.href), - mediaType = link.mediaType + mediaType = mediaType ) { Try.success( JSONObject().apply { @@ -280,9 +282,11 @@ private sealed class RouteHandler { object RightsCopyHandler : RouteHandler() { + private val mediaType = MediaType("application/vnd.readium.rights.copy+json")!! + override val link: Link = Link( href = "/~readium/rights/copy{?text,peek}", - type = "application/vnd.readium.rights.copy+json", + mediaType = mediaType, templated = true ) @@ -319,9 +323,11 @@ private sealed class RouteHandler { object RightsPrintHandler : RouteHandler() { + private val mediaType = MediaType("application/vnd.readium.rights.print+json")!! + override val link = Link( href = "/~readium/rights/print{?pageCount,peek}", - type = "application/vnd.readium.rights.print+json", + mediaType = mediaType, templated = true ) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/services/CoverService.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/services/CoverService.kt index 244db47a69..10f4e5876e 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/services/CoverService.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/services/CoverService.kt @@ -22,6 +22,7 @@ import org.readium.r2.shared.resource.FailureResource import org.readium.r2.shared.resource.LazyResource import org.readium.r2.shared.resource.Resource import org.readium.r2.shared.util.Url +import org.readium.r2.shared.util.mediatype.MediaType /** * Provides an easy access to a bitmap version of the publication cover. @@ -91,7 +92,7 @@ public abstract class GeneratedCoverService : CoverService { private val coverLink = Link( href = "/~readium/cover", - type = "image/png", + mediaType = MediaType.PNG, rels = setOf("cover") ) @@ -112,7 +113,7 @@ public abstract class GeneratedCoverService : CoverService { val error = Exception("Unable to convert cover to PNG.") FailureResource(error) } else { - BytesResource(png, url = Url(coverLink.href), mediaType = coverLink.mediaType) + BytesResource(png, url = Url(coverLink.href), mediaType = MediaType.PNG) } } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/services/PositionsService.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/services/PositionsService.kt index e365310efd..807125b714 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/services/PositionsService.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/services/PositionsService.kt @@ -26,10 +26,14 @@ import org.readium.r2.shared.resource.StringResource import org.readium.r2.shared.resource.readAsString import org.readium.r2.shared.toJSON import org.readium.r2.shared.util.Url +import org.readium.r2.shared.util.mediatype.MediaType + +private val positionsMediaType = + MediaType("application/vnd.readium.position-list+json")!! private val positionsLink = Link( href = "/~readium/positions", - type = "application/vnd.readium.position-list+json" + mediaType = positionsMediaType ) /** @@ -56,7 +60,7 @@ public interface PositionsService : Publication.Service { return StringResource( url = Url(positionsLink.href), - mediaType = positionsLink.mediaType + mediaType = positionsMediaType ) { val positions = positions() Try.success( @@ -120,7 +124,7 @@ public class PerResourcePositionsService( listOf( Locator( href = link.href, - type = link.type ?: fallbackMediaType, + type = link.mediaType?.toString() ?: fallbackMediaType, title = link.title, locations = Locator.Locations( position = index + 1, @@ -150,7 +154,7 @@ internal class WebPositionsService( override val links: List = listOfNotNull( - manifest.links.firstWithMediaType(positionsLink.mediaType) + manifest.links.firstWithMediaType(positionsMediaType) ) override suspend fun positions(): List { diff --git a/readium/shared/src/main/java/org/readium/r2/shared/publication/services/content/iterators/HtmlResourceContentIterator.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/services/content/iterators/HtmlResourceContentIterator.kt index 88154d5b26..47113bc084 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/publication/services/content/iterators/HtmlResourceContentIterator.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/publication/services/content/iterators/HtmlResourceContentIterator.kt @@ -311,7 +311,7 @@ public class HtmlResourceContentIterator internal constructor( source.srcRelativeToHref(baseLocator.href)?.let { href -> Link( href = href, - type = source.attr("type").takeUnless { it.isBlank() } + mediaType = MediaType(source.attr("type")) ) } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt index 051f8e7b5e..2e4affea19 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/FileResource.kt @@ -18,6 +18,7 @@ import org.readium.r2.shared.extensions.* import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.isFile import org.readium.r2.shared.util.isLazyInitialized +import org.readium.r2.shared.util.mediatype.EpubMediaTypeSniffer.sniff import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.mediatype.MediaTypeHints import org.readium.r2.shared.util.mediatype.MediaTypeSniffer @@ -53,10 +54,8 @@ public class FileResource private constructor( override suspend fun mediaType(): ResourceTry = Try.success( mediaType ?: mediaTypeSniffer?.sniff( - ResourceMediaTypeSnifferContext( - resource = this, - hints = MediaTypeHints(fileExtension = file.extension) - ) + hints = MediaTypeHints(fileExtension = file.extension), + content = ResourceMediaTypeSnifferContent(this) ) ?: MediaType.BINARY ) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt index 7a64f2f03d..ee2f4d8166 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt @@ -1,22 +1,19 @@ package org.readium.r2.shared.resource -import org.readium.r2.shared.util.mediatype.ContainerMediaTypeSnifferContext as BaseContainerMediaTypeSnifferContext -import org.readium.r2.shared.util.mediatype.ContentMediaTypeSnifferContext -import org.readium.r2.shared.util.mediatype.MediaTypeHints +import org.readium.r2.shared.util.mediatype.ContainerMediaTypeSnifferContent +import org.readium.r2.shared.util.mediatype.ResourceMediaTypeSnifferContent -public class ResourceMediaTypeSnifferContext( - private val resource: Resource, - override val hints: MediaTypeHints = MediaTypeHints() -) : ContentMediaTypeSnifferContext { +public class ResourceMediaTypeSnifferContent( + private val resource: Resource +) : ResourceMediaTypeSnifferContent { override suspend fun read(range: LongRange?): ByteArray? = resource.read(range).getOrNull() } -public class ContainerMediaTypeSnifferContext( - private val container: Container, - override val hints: MediaTypeHints = MediaTypeHints() -) : BaseContainerMediaTypeSnifferContext { +public class ContainerMediaTypeSnifferContent( + private val container: Container +) : ContainerMediaTypeSnifferContent { override suspend fun entries(): Set? = container.entries()?.map { it.path }?.toSet() diff --git a/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt index 9da24eafa1..488c77b940 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/ZipContainer.kt @@ -24,6 +24,7 @@ import org.readium.r2.shared.extensions.toMap import org.readium.r2.shared.extensions.tryOrLog import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.io.CountingInputStream +import org.readium.r2.shared.util.mediatype.EpubMediaTypeSniffer.sniff import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.mediatype.MediaTypeHints import org.readium.r2.shared.util.mediatype.MediaTypeSniffer @@ -110,10 +111,8 @@ internal class JavaZipContainer( override suspend fun mediaType(): ResourceTry = Try.success( mediaTypeSniffer.sniff( - ResourceMediaTypeSnifferContext( - resource = this, - hints = MediaTypeHints(fileExtension = File(path).extension) - ) + hints = MediaTypeHints(fileExtension = File(path).extension), + content = ResourceMediaTypeSnifferContent(this) ) ?: MediaType.BINARY ) @@ -140,10 +139,8 @@ internal class JavaZipContainer( override suspend fun mediaType(): ResourceTry = Try.success( mediaTypeSniffer.sniff( - ResourceMediaTypeSnifferContext( - resource = this, - hints = MediaTypeHints(fileExtension = File(path).extension) - ) + hints = MediaTypeHints(fileExtension = File(path).extension), + content = ResourceMediaTypeSnifferContent(this) ) ?: MediaType.BINARY ) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt index 9bdc77daee..9f006c026c 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/archive/channel/ChannelZipContainer.kt @@ -19,7 +19,7 @@ import org.readium.r2.shared.resource.ArchiveProperties import org.readium.r2.shared.resource.Container import org.readium.r2.shared.resource.FailureResource import org.readium.r2.shared.resource.Resource -import org.readium.r2.shared.resource.ResourceMediaTypeSnifferContext +import org.readium.r2.shared.resource.ResourceMediaTypeSnifferContent import org.readium.r2.shared.resource.ResourceTry import org.readium.r2.shared.resource.ZipContainer import org.readium.r2.shared.resource.archive @@ -28,6 +28,7 @@ import org.readium.r2.shared.util.archive.channel.compress.archivers.zip.ZipArch import org.readium.r2.shared.util.archive.channel.compress.archivers.zip.ZipFile import org.readium.r2.shared.util.archive.channel.jvm.SeekableByteChannel import org.readium.r2.shared.util.io.CountingInputStream +import org.readium.r2.shared.util.mediatype.EpubMediaTypeSniffer.sniff import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.mediatype.MediaTypeHints import org.readium.r2.shared.util.mediatype.MediaTypeSniffer @@ -64,10 +65,8 @@ internal class ChannelZipContainer( override suspend fun mediaType(): ResourceTry = Try.success( mediaTypeSniffer.sniff( - ResourceMediaTypeSnifferContext( - resource = this, - hints = MediaTypeHints(fileExtension = File(path).extension) - ) + hints = MediaTypeHints(fileExtension = File(path).extension), + content = ResourceMediaTypeSnifferContent(this) ) ?: MediaType.BINARY ) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/http/DefaultHttpClient.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/http/DefaultHttpClient.kt index 9ec1e2ebdf..62f2ff575c 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/http/DefaultHttpClient.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/http/DefaultHttpClient.kt @@ -18,9 +18,9 @@ import org.readium.r2.shared.error.Try import org.readium.r2.shared.error.flatMap import org.readium.r2.shared.error.tryRecover import org.readium.r2.shared.util.http.HttpRequest.Method -import org.readium.r2.shared.util.mediatype.BytesContentMediaTypeSnifferContext +import org.readium.r2.shared.util.mediatype.BytesResourceMediaTypeSnifferContent import org.readium.r2.shared.util.mediatype.DefaultMediaTypeSniffer -import org.readium.r2.shared.util.mediatype.HintMediaTypeSnifferContext +import org.readium.r2.shared.util.mediatype.EpubMediaTypeSniffer.sniff import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.mediatype.MediaTypeHints import org.readium.r2.shared.util.mediatype.MediaTypeSniffer @@ -145,20 +145,14 @@ public class DefaultHttpClient( val body = connection.errorStream?.use { it.readBytes() } val mediaType = body?.let { mediaTypeSniffer.sniff( - BytesContentMediaTypeSnifferContext( - hints = MediaTypeHints(connection), - bytes = { it } - ) + hints = MediaTypeHints(connection), + content = BytesResourceMediaTypeSnifferContent { it } ) } throw HttpException(kind, mediaType, body) } - val mediaType = mediaTypeSniffer.sniff( - HintMediaTypeSnifferContext( - hints = MediaTypeHints(connection) - ) - ) + val mediaType = mediaTypeSniffer.sniff(MediaTypeHints(connection)) val response = HttpResponse( request = request, diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaType.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaType.kt index 55dc552bc5..9fc7323c5e 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaType.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaType.kt @@ -11,9 +11,11 @@ package org.readium.r2.shared.util.mediatype import android.content.ContentResolver import android.net.Uri +import android.os.Parcelable import java.io.File import java.nio.charset.Charset import java.util.* +import kotlinx.parcelize.Parcelize /** * Represents a document format, identified by a unique RFC 6838 media type. @@ -32,11 +34,12 @@ import java.util.* * @param subtype The subtype component, e.g. `epub+zip` in `application/epub+zip`. * @param parameters The parameters in the media type, such as `charset=utf-8`. */ +@Parcelize public class MediaType private constructor( public val type: String, public val subtype: String, public val parameters: Map -) { +) : Parcelable { /** * Structured syntax suffix, e.g. `+zip` in `application/epub+zip`. diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeHints.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeHints.kt index 96ef610dd4..1e3b19b698 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeHints.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeHints.kt @@ -6,6 +6,8 @@ package org.readium.r2.shared.util.mediatype +import java.nio.charset.Charset + public data class MediaTypeHints( val mediaTypes: List = emptyList(), val fileExtensions: List = emptyList() @@ -29,4 +31,36 @@ public data class MediaTypeHints( mediaTypes = mediaTypes + other.mediaTypes, fileExtensions = fileExtensions + other.fileExtensions ) + + /** Finds the first [Charset] declared in the media types' `charset` parameter. */ + public val charset: Charset? get() = + mediaTypes.firstNotNullOfOrNull { it.charset } + + /** Returns whether this context has any of the given file extensions, ignoring case. */ + public fun hasFileExtension(vararg fileExtensions: String): Boolean { + val fileExtensionsHints = this.fileExtensions.map { it.lowercase() } + for (fileExtension in fileExtensions.map { it.lowercase() }) { + if (fileExtensionsHints.contains(fileExtension)) { + return true + } + } + return false + } + + /** + * Returns whether this context has any of the given media type, ignoring case and extra + * parameters. + * + * Implementation note: Use [MediaType] to handle the comparison to avoid edge cases. + */ + public fun hasMediaType(vararg mediaTypes: String): Boolean { + @Suppress("NAME_SHADOWING") + val mediaTypes = mediaTypes.mapNotNull { MediaType(it) } + for (mediaType in mediaTypes) { + if (this.mediaTypes.any { mediaType.contains(it) }) { + return true + } + } + return false + } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt index 0885b516e5..924bfbcbbe 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt @@ -18,30 +18,68 @@ import org.readium.r2.shared.publication.Link import org.readium.r2.shared.publication.Manifest import org.readium.r2.shared.publication.Publication -public fun interface MediaTypeSniffer { - public suspend fun sniff(context: MediaTypeSnifferContext): MediaType? +public interface MediaTypeSniffer { + public fun sniffHints(hints: MediaTypeHints): MediaType? = null + public suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? = null + + public suspend fun MediaTypeSniffer.sniff( + hints: MediaTypeHints = MediaTypeHints(), + content: MediaTypeSnifferContent? = null + ): MediaType? = + sniffHints(hints) + ?: content?.let { sniffContent(it) } + ?: hints.mediaTypes.firstOrNull() } +public fun MediaTypeSniffer.sniff(mediaType: MediaType, fileExtension: String? = null): MediaType = + sniffHints(MediaTypeHints(mediaType = mediaType, fileExtension = fileExtension)) ?: mediaType + /** * The default sniffer provided by Readium 2 to resolve a [MediaType]. */ -public class DefaultMediaTypeSniffer : MediaTypeSniffer { - private val sniffer = OptimizedRoundsMediaTypeSniffer( - CompositeMediaTypeSniffer(MediaTypeSniffers.all) +public class DefaultMediaTypeSniffer private constructor( + sniffer: MediaTypeSniffer +) : MediaTypeSniffer by sniffer { + + /** + * The default sniffers provided by Readium 2 for all known formats. + * The sniffers order is important, because some formats are subsets of other formats. + */ + public constructor() : this( + CompositeMediaTypeSniffer( + listOf( + XhtmlMediaTypeSniffer, + HtmlMediaTypeSniffer, + OpdsMediaTypeSniffer, + LcpLicenseMediaTypeSniffer, + BitmapMediaTypeSniffer, + WebPubManifestMediaTypeSniffer, + WebPubMediaTypeSniffer, + W3cWpubMediaTypeSniffer, + EpubMediaTypeSniffer, + LpfMediaTypeSniffer, + ArchiveMediaTypeSniffer, + PdfMediaTypeSniffer, + JsonMediaTypeSniffer + ) + ) ) - override suspend fun sniff(context: MediaTypeSnifferContext): MediaType? { - sniffer.sniff(context) + override suspend fun MediaTypeSniffer.sniff( + hints: MediaTypeHints, + content: MediaTypeSnifferContent? + ): MediaType? { + (sniffHints(hints) ?: content?.let { sniffContent(it) }) ?.let { return it } // Falls back on the system-wide registered media types using MimeTypeMap. - // Note: This is done after the default sniffers, because otherwise it will detect JSON, XML - // or ZIP formats before we have a chance of sniffing their content (for example, for RWPM). - MediaTypeSniffers.system.sniff(context) + // Note: This is done after the default light sniffers, because otherwise it will detect + // JSON, XML or ZIP formats before we have a chance of sniffing their content (for example, + // for RWPM). + SystemMediaTypeSniffer.sniff(hints, content) ?.let { return it } - // If nothing else worked, we return the first media type hint. - return context.hints.mediaTypes.firstOrNull() + return hints.mediaTypes.firstOrNull() } } @@ -49,135 +87,127 @@ public class CompositeMediaTypeSniffer( private val sniffers: List ) : MediaTypeSniffer { - override suspend fun sniff(context: MediaTypeSnifferContext): MediaType? = - sniffers.firstNotNullOfOrNull { it.sniff(context) } -} - -public class OptimizedRoundsMediaTypeSniffer( - private val sniffer: MediaTypeSniffer -) : MediaTypeSniffer { - override suspend fun sniff(context: MediaTypeSnifferContext): MediaType? { - // Light sniffing with only media type hints - if (context.hints.mediaTypes.isNotEmpty()) { - sniffer.sniff( - HintMediaTypeSnifferContext( - hints = context.hints.copy(fileExtensions = emptyList()) - ) - )?.let { return it } - } + override fun sniffHints(hints: MediaTypeHints): MediaType? = + sniffers.firstNotNullOfOrNull { it.sniffHints(hints) } - // Light sniffing with both media type hints and file extensions - if (context.hints.fileExtensions.isNotEmpty()) { - sniffer.sniff(HintMediaTypeSnifferContext(hints = context.hints)) - ?.let { return it } - } - - // Fallback on heavy sniffing - return sniffer.sniff(context) - } + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? = + sniffers.firstNotNullOfOrNull { it.sniffContent(content) } } /** - * Default media type sniffers provided by Readium. + * Sniffs an XHTML document. + * + * Must precede the HTML sniffer. */ -public object MediaTypeSniffers { - - /** - * Sniffs an XHTML document. - * - * Must precede the HTML sniffer. - */ - public val xhtml: MediaTypeSniffer = MediaTypeSniffer { context -> +public object XhtmlMediaTypeSniffer : MediaTypeSniffer { + override fun sniffHints(hints: MediaTypeHints): MediaType? { if ( - context.hasFileExtension("xht", "xhtml") || - context.hasMediaType("application/xhtml+xml") + hints.hasFileExtension("xht", "xhtml") || + hints.hasMediaType("application/xhtml+xml") ) { - return@MediaTypeSniffer MediaType.XHTML + return MediaType.XHTML } - if (context !is ContentMediaTypeSnifferContext) { - return@MediaTypeSniffer null + return null + } + + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ResourceMediaTypeSnifferContent) { + return null } - context.contentAsXml()?.let { + content.contentAsXml()?.let { if ( it.name.lowercase(Locale.ROOT) == "html" && it.namespace.lowercase(Locale.ROOT).contains("xhtml") ) { - return@MediaTypeSniffer MediaType.XHTML + return MediaType.XHTML } } - return@MediaTypeSniffer null + return null } +} - /** Sniffs an HTML document. */ - public val html: MediaTypeSniffer = MediaTypeSniffer { context -> +/** Sniffs an HTML document. */ +public object HtmlMediaTypeSniffer : MediaTypeSniffer { + override fun sniffHints(hints: MediaTypeHints): MediaType? { if ( - context.hasFileExtension("htm", "html") || - context.hasMediaType("text/html") + hints.hasFileExtension("htm", "html") || + hints.hasMediaType("text/html") ) { - return@MediaTypeSniffer MediaType.HTML + return MediaType.HTML } - if (context !is ContentMediaTypeSnifferContext) { - return@MediaTypeSniffer null + return null + } + + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ResourceMediaTypeSnifferContent) { + return null } // [contentAsXml] will fail if the HTML is not a proper XML document, hence the doctype check. if ( - context.contentAsXml()?.name?.lowercase(Locale.ROOT) == "html" || - context.contentAsString()?.trimStart()?.take(15)?.lowercase() == "" + content.contentAsXml()?.name?.lowercase(Locale.ROOT) == "html" || + content.contentAsString()?.trimStart()?.take(15)?.lowercase() == "" ) { - return@MediaTypeSniffer MediaType.HTML + return MediaType.HTML } - return@MediaTypeSniffer null + return null } +} - /** Sniffs an OPDS document. */ - public val opds: MediaTypeSniffer = MediaTypeSniffer { context -> +/** Sniffs an OPDS document. */ +public object OpdsMediaTypeSniffer : MediaTypeSniffer { + + override fun sniffHints(hints: MediaTypeHints): MediaType? { // OPDS 1 - if (context.hasMediaType("application/atom+xml;type=entry;profile=opds-catalog")) { - return@MediaTypeSniffer MediaType.OPDS1_ENTRY + if (hints.hasMediaType("application/atom+xml;type=entry;profile=opds-catalog")) { + return MediaType.OPDS1_ENTRY } - if (context.hasMediaType("application/atom+xml;profile=opds-catalog")) { - return@MediaTypeSniffer MediaType.OPDS1 + if (hints.hasMediaType("application/atom+xml;profile=opds-catalog")) { + return MediaType.OPDS1 } // OPDS 2 - if (context.hasMediaType("application/opds+json")) { - return@MediaTypeSniffer MediaType.OPDS2 + if (hints.hasMediaType("application/opds+json")) { + return MediaType.OPDS2 } - if (context.hasMediaType("application/opds-publication+json")) { - return@MediaTypeSniffer MediaType.OPDS2_PUBLICATION + if (hints.hasMediaType("application/opds-publication+json")) { + return MediaType.OPDS2_PUBLICATION } // OPDS Authentication Document. if ( - context.hasMediaType("application/opds-authentication+json") || - context.hasMediaType("application/vnd.opds.authentication.v1.0+json") + hints.hasMediaType("application/opds-authentication+json") || + hints.hasMediaType("application/vnd.opds.authentication.v1.0+json") ) { - return@MediaTypeSniffer MediaType.OPDS_AUTHENTICATION + return MediaType.OPDS_AUTHENTICATION } - if (context !is ContentMediaTypeSnifferContext) { - return@MediaTypeSniffer null + return null + } + + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ResourceMediaTypeSnifferContent) { + return null } // OPDS 1 - context.contentAsXml()?.let { xml -> + content.contentAsXml()?.let { xml -> if (xml.namespace == "http://www.w3.org/2005/Atom") { if (xml.name == "feed") { - return@MediaTypeSniffer MediaType.OPDS1 + return MediaType.OPDS1 } else if (xml.name == "entry") { - return@MediaTypeSniffer MediaType.OPDS1_ENTRY + return MediaType.OPDS1_ENTRY } } } // OPDS 2 - context.contentAsRwpm()?.let { rwpm -> + content.contentAsRwpm()?.let { rwpm -> if (rwpm.linkWithRel("self")?.mediaType?.matches("application/opds+json") == true) { - return@MediaTypeSniffer MediaType.OPDS2 + return MediaType.OPDS2 } /** @@ -187,287 +217,332 @@ public object MediaTypeSniffers { firstOrNull { it.rels.any(predicate) } if (rwpm.links.firstWithRelMatching { it.startsWith("http://opds-spec.org/acquisition") } != null) { - return@MediaTypeSniffer MediaType.OPDS2_PUBLICATION + return MediaType.OPDS2_PUBLICATION } } // OPDS Authentication Document. - if (context.containsJsonKeys("id", "title", "authentication")) { - return@MediaTypeSniffer MediaType.OPDS_AUTHENTICATION + if (content.containsJsonKeys("id", "title", "authentication")) { + return MediaType.OPDS_AUTHENTICATION } - return@MediaTypeSniffer null + return null } +} - /** Sniffs an LCP License Document. */ - public val lcpLicense: MediaTypeSniffer = MediaTypeSniffer { context -> +/** Sniffs an LCP License Document. */ +public object LcpLicenseMediaTypeSniffer : MediaTypeSniffer { + override fun sniffHints(hints: MediaTypeHints): MediaType? { if ( - context.hasFileExtension("lcpl") || - context.hasMediaType("application/vnd.readium.lcp.license.v1.0+json") + hints.hasFileExtension("lcpl") || + hints.hasMediaType("application/vnd.readium.lcp.license.v1.0+json") ) { - return@MediaTypeSniffer MediaType.LCP_LICENSE_DOCUMENT + return MediaType.LCP_LICENSE_DOCUMENT } - if (context !is ContentMediaTypeSnifferContext) { - return@MediaTypeSniffer null + return null + } + + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ResourceMediaTypeSnifferContent) { + return null } - if (context.containsJsonKeys("id", "issued", "provider", "encryption")) { - return@MediaTypeSniffer MediaType.LCP_LICENSE_DOCUMENT + if (content.containsJsonKeys("id", "issued", "provider", "encryption")) { + return MediaType.LCP_LICENSE_DOCUMENT } - return@MediaTypeSniffer null + return null } +} - /** Sniffs a bitmap image. */ - public val bitmap: MediaTypeSniffer = MediaTypeSniffer { context -> +/** Sniffs a bitmap image. */ +public object BitmapMediaTypeSniffer : MediaTypeSniffer { + override fun sniffHints(hints: MediaTypeHints): MediaType? { if ( - context.hasFileExtension("avif") || - context.hasMediaType("image/avif") + hints.hasFileExtension("avif") || + hints.hasMediaType("image/avif") ) { - return@MediaTypeSniffer MediaType.AVIF + return MediaType.AVIF } if ( - context.hasFileExtension("bmp", "dib") || - context.hasMediaType("image/bmp", "image/x-bmp") + hints.hasFileExtension("bmp", "dib") || + hints.hasMediaType("image/bmp", "image/x-bmp") ) { - return@MediaTypeSniffer MediaType.BMP + return MediaType.BMP } if ( - context.hasFileExtension("gif") || - context.hasMediaType("image/gif") + hints.hasFileExtension("gif") || + hints.hasMediaType("image/gif") ) { - return@MediaTypeSniffer MediaType.GIF + return MediaType.GIF } if ( - context.hasFileExtension("jpg", "jpeg", "jpe", "jif", "jfif", "jfi") || - context.hasMediaType("image/jpeg") + hints.hasFileExtension("jpg", "jpeg", "jpe", "jif", "jfif", "jfi") || + hints.hasMediaType("image/jpeg") ) { - return@MediaTypeSniffer MediaType.JPEG + return MediaType.JPEG } if ( - context.hasFileExtension("jxl") || - context.hasMediaType("image/jxl") + hints.hasFileExtension("jxl") || + hints.hasMediaType("image/jxl") ) { - return@MediaTypeSniffer MediaType.JXL + return MediaType.JXL } if ( - context.hasFileExtension("png") || - context.hasMediaType("image/png") + hints.hasFileExtension("png") || + hints.hasMediaType("image/png") ) { - return@MediaTypeSniffer MediaType.PNG + return MediaType.PNG } if ( - context.hasFileExtension("tiff", "tif") || - context.hasMediaType("image/tiff", "image/tiff-fx") + hints.hasFileExtension("tiff", "tif") || + hints.hasMediaType("image/tiff", "image/tiff-fx") ) { - return@MediaTypeSniffer MediaType.TIFF + return MediaType.TIFF } if ( - context.hasFileExtension("webp") || - context.hasMediaType("image/webp") + hints.hasFileExtension("webp") || + hints.hasMediaType("image/webp") ) { - return@MediaTypeSniffer MediaType.WEBP + return MediaType.WEBP } - return@MediaTypeSniffer null + return null } +} - /** Sniffs a Readium Web Manifest. */ - public val webpubManifest: MediaTypeSniffer = MediaTypeSniffer { context -> - if (context.hasMediaType("application/audiobook+json")) { - return@MediaTypeSniffer MediaType.READIUM_AUDIOBOOK_MANIFEST +/** Sniffs a Readium Web Manifest. */ +public object WebPubManifestMediaTypeSniffer : MediaTypeSniffer { + override fun sniffHints(hints: MediaTypeHints): MediaType? { + if (hints.hasMediaType("application/audiobook+json")) { + return MediaType.READIUM_AUDIOBOOK_MANIFEST } - if (context.hasMediaType("application/divina+json")) { - return@MediaTypeSniffer MediaType.DIVINA_MANIFEST + if (hints.hasMediaType("application/divina+json")) { + return MediaType.DIVINA_MANIFEST } - if (context.hasMediaType("application/webpub+json")) { - return@MediaTypeSniffer MediaType.READIUM_WEBPUB_MANIFEST + if (hints.hasMediaType("application/webpub+json")) { + return MediaType.READIUM_WEBPUB_MANIFEST } - if (context !is ContentMediaTypeSnifferContext) { - return@MediaTypeSniffer null + return null + } + + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ResourceMediaTypeSnifferContent) { + return null } val manifest: Manifest = - context.contentAsRwpm() ?: return@MediaTypeSniffer null + content.contentAsRwpm() ?: return null if (manifest.conformsTo(Publication.Profile.AUDIOBOOK)) { - return@MediaTypeSniffer MediaType.READIUM_AUDIOBOOK_MANIFEST + return MediaType.READIUM_AUDIOBOOK_MANIFEST } if (manifest.conformsTo(Publication.Profile.DIVINA)) { - return@MediaTypeSniffer MediaType.DIVINA_MANIFEST + return MediaType.DIVINA_MANIFEST } if (manifest.linkWithRel("self")?.mediaType?.matches("application/webpub+json") == true) { - return@MediaTypeSniffer MediaType.READIUM_WEBPUB_MANIFEST + return MediaType.READIUM_WEBPUB_MANIFEST } - return@MediaTypeSniffer null + return null } +} - /** Sniffs a Readium Web Publication, protected or not by LCP. */ - public val webpub: MediaTypeSniffer = MediaTypeSniffer { context -> +/** Sniffs a Readium Web Publication, protected or not by LCP. */ +public object WebPubMediaTypeSniffer : MediaTypeSniffer { + override fun sniffHints(hints: MediaTypeHints): MediaType? { if ( - context.hasFileExtension("audiobook") || - context.hasMediaType("application/audiobook+zip") + hints.hasFileExtension("audiobook") || + hints.hasMediaType("application/audiobook+zip") ) { - return@MediaTypeSniffer MediaType.READIUM_AUDIOBOOK + return MediaType.READIUM_AUDIOBOOK } if ( - context.hasFileExtension("divina") || - context.hasMediaType("application/divina+zip") + hints.hasFileExtension("divina") || + hints.hasMediaType("application/divina+zip") ) { - return@MediaTypeSniffer MediaType.DIVINA + return MediaType.DIVINA } if ( - context.hasFileExtension("webpub") || - context.hasMediaType("application/webpub+zip") + hints.hasFileExtension("webpub") || + hints.hasMediaType("application/webpub+zip") ) { - return@MediaTypeSniffer MediaType.READIUM_WEBPUB + return MediaType.READIUM_WEBPUB } if ( - context.hasFileExtension("lcpa") || - context.hasMediaType("application/audiobook+lcp") + hints.hasFileExtension("lcpa") || + hints.hasMediaType("application/audiobook+lcp") ) { - return@MediaTypeSniffer MediaType.LCP_PROTECTED_AUDIOBOOK + return MediaType.LCP_PROTECTED_AUDIOBOOK } if ( - context.hasFileExtension("lcpdf") || - context.hasMediaType("application/pdf+lcp") + hints.hasFileExtension("lcpdf") || + hints.hasMediaType("application/pdf+lcp") ) { - return@MediaTypeSniffer MediaType.LCP_PROTECTED_PDF + return MediaType.LCP_PROTECTED_PDF } - if ( - context !is ContainerMediaTypeSnifferContext - ) { - return@MediaTypeSniffer null + return null + } + + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ContainerMediaTypeSnifferContent) { + return null } // Reads a RWPM from a manifest.json archive entry. val manifest: Manifest? = try { - context.read("manifest.json") - ?.let { Manifest.fromJSON(JSONObject(String(it))) } + content.read("manifest.json") + ?.let { + Manifest.fromJSON( + JSONObject(String(it)), + mediaTypeSniffer = DefaultMediaTypeSniffer() + ) + } } catch (e: Exception) { null } if (manifest != null) { - val isLcpProtected = context.contains("/license.lcpl") + val isLcpProtected = content.contains("/license.lcpl") if (manifest.conformsTo(Publication.Profile.AUDIOBOOK)) { - return@MediaTypeSniffer if (isLcpProtected) { + return if (isLcpProtected) { MediaType.LCP_PROTECTED_AUDIOBOOK } else { MediaType.READIUM_AUDIOBOOK } } if (manifest.conformsTo(Publication.Profile.DIVINA)) { - return@MediaTypeSniffer MediaType.DIVINA + return MediaType.DIVINA } if (isLcpProtected && manifest.conformsTo(Publication.Profile.PDF)) { - return@MediaTypeSniffer MediaType.LCP_PROTECTED_PDF + return MediaType.LCP_PROTECTED_PDF } if (manifest.linkWithRel("self")?.mediaType?.matches("application/webpub+json") == true) { - return@MediaTypeSniffer MediaType.READIUM_WEBPUB + return MediaType.READIUM_WEBPUB } } - return@MediaTypeSniffer null + return null } +} - /** Sniffs a W3C Web Publication Manifest. */ - public val w3cWPUB: MediaTypeSniffer = MediaTypeSniffer { context -> - if (context !is ContentMediaTypeSnifferContext) { - return@MediaTypeSniffer null +/** Sniffs a W3C Web Publication Manifest. */ +public object W3cWpubMediaTypeSniffer : MediaTypeSniffer { + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ResourceMediaTypeSnifferContent) { + return null } - // Somehow, [JSONObject] can't access JSON-LD keys such as `@context`. - val content = context.contentAsString() ?: "" + // Somehow, [JSONObject] can't access JSON-LD keys such as `@content`. + val string = content.contentAsString() ?: "" if ( - content.contains("@context") && - content.contains("https://www.w3.org/ns/wp-context") + string.contains("@context") && + string.contains("https://www.w3.org/ns/wp-context") ) { - return@MediaTypeSniffer MediaType.W3C_WPUB_MANIFEST + return MediaType.W3C_WPUB_MANIFEST } - return@MediaTypeSniffer null + return null } +} - /** - * Sniffs an EPUB publication. - * - * Reference: https://www.w3.org/publishing/epub3/epub-ocf.html#sec-zip-container-mime - */ - public val epub: MediaTypeSniffer = MediaTypeSniffer { context -> +/** + * Sniffs an EPUB publication. + * + * Reference: https://www.w3.org/publishing/epub3/epub-ocf.html#sec-zip-container-mime + */ +public object EpubMediaTypeSniffer : MediaTypeSniffer { + override fun sniffHints(hints: MediaTypeHints): MediaType? { if ( - context.hasFileExtension("epub") || - context.hasMediaType("application/epub+zip") + hints.hasFileExtension("epub") || + hints.hasMediaType("application/epub+zip") ) { - return@MediaTypeSniffer MediaType.EPUB + return MediaType.EPUB } - if (context !is ContainerMediaTypeSnifferContext) { - return@MediaTypeSniffer null + return null + } + + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ContainerMediaTypeSnifferContent) { + return null } - val mimetype = context.read("mimetype") + val mimetype = content.read("mimetype") ?.let { String(it, charset = Charsets.US_ASCII).trim() } if (mimetype == "application/epub+zip") { - return@MediaTypeSniffer MediaType.EPUB + return MediaType.EPUB } - return@MediaTypeSniffer null + return null } +} - /** - * Sniffs a Lightweight Packaging Format (LPF). - * - * References: - * - https://www.w3.org/TR/lpf/ - * - https://www.w3.org/TR/pub-manifest/ - */ - public val lpf: MediaTypeSniffer = MediaTypeSniffer { context -> +/** + * Sniffs a Lightweight Packaging Format (LPF). + * + * References: + * - https://www.w3.org/TR/lpf/ + * - https://www.w3.org/TR/pub-manifest/ + */ +public object LpfMediaTypeSniffer : MediaTypeSniffer { + override fun sniffHints(hints: MediaTypeHints): MediaType? { if ( - context.hasFileExtension("lpf") || - context.hasMediaType("application/lpf+zip") + hints.hasFileExtension("lpf") || + hints.hasMediaType("application/lpf+zip") ) { - return@MediaTypeSniffer MediaType.LPF + return MediaType.LPF } - if (context !is ContainerMediaTypeSnifferContext) { - return@MediaTypeSniffer null + return null + } + + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ContainerMediaTypeSnifferContent) { + return null } - if (context.contains("/index.html")) { - return@MediaTypeSniffer MediaType.LPF + if (content.contains("/index.html")) { + return MediaType.LPF } - // Somehow, [JSONObject] can't access JSON-LD keys such as `@context`. - context.read("publication.json") + // Somehow, [JSONObject] can't access JSON-LD keys such as `@content`. + content.read("publication.json") ?.let { String(it) } ?.let { manifest -> - if (manifest.contains("@context") && manifest.contains( - "https://www.w3.org/ns/pub-context" - ) + if ( + manifest.contains("@context") && + manifest.contains("https://www.w3.org/ns/pub-context") ) { - return@MediaTypeSniffer MediaType.LPF + return MediaType.LPF } } - return@MediaTypeSniffer null + return null } +} + +/** + * Sniffs a simple Archive-based format, like Comic Book Archive or Zipped Audio Book. + * + * Reference: https://wiki.mobileread.com/wiki/CBR_and_CBZ + */ +public object ArchiveMediaTypeSniffer : MediaTypeSniffer { /** * Authorized extensions for resources in a CBZ archive. * Reference: https://wiki.mobileread.com/wiki/CBR_and_CBZ */ - private val CBZ_EXTENSIONS = listOf( + private val cbzExtensions = listOf( // bitmap "bmp", "dib", "gif", "jif", "jfi", "jfif", "jpg", "jpeg", "png", "tif", "tiff", "webp", // metadata @@ -477,172 +552,171 @@ public object MediaTypeSniffers { /** * Authorized extensions for resources in a ZAB archive (Zipped Audio Book). */ - private val ZAB_EXTENSIONS = listOf( + private val zabExtensions = listOf( // audio - "aac", "aiff", "alac", "flac", "m4a", "m4b", "mp3", "ogg", "oga", "mogg", "opus", "wav", "webm", + "aac", + "aiff", + "alac", + "flac", + "m4a", + "m4b", + "mp3", + "ogg", + "oga", + "mogg", + "opus", + "wav", + "webm", // playlist - "asx", "bio", "m3u", "m3u8", "pla", "pls", "smil", "vlc", "wpl", "xspf", "zpl" + "asx", + "bio", + "m3u", + "m3u8", + "pla", + "pls", + "smil", + "vlc", + "wpl", + "xspf", + "zpl" ) - /** - * Sniffs a simple Archive-based format, like Comic Book Archive or Zipped Audio Book. - * - * Reference: https://wiki.mobileread.com/wiki/CBR_and_CBZ - */ - public val archive: MediaTypeSniffer = MediaTypeSniffer { context -> + override fun sniffHints(hints: MediaTypeHints): MediaType? { if ( - context.hasFileExtension("cbz") || - context.hasMediaType( + hints.hasFileExtension("cbz") || + hints.hasMediaType( "application/vnd.comicbook+zip", "application/x-cbz", "application/x-cbr" ) ) { - return@MediaTypeSniffer MediaType.CBZ + return MediaType.CBZ } - if (context.hasFileExtension("zab")) { - return@MediaTypeSniffer MediaType.ZAB + if (hints.hasFileExtension("zab")) { + return MediaType.ZAB } - if (context !is ContainerMediaTypeSnifferContext) { - return@MediaTypeSniffer null + return null + } + + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ContainerMediaTypeSnifferContent) { + return null } fun isIgnored(file: File): Boolean = file.name.startsWith(".") || file.name == "Thumbs.db" suspend fun archiveContainsOnlyExtensions(fileExtensions: List): Boolean = - context.entries()?.all { path -> + content.entries()?.all { path -> val file = File(path) isIgnored(file) || fileExtensions.contains(file.extension.lowercase(Locale.ROOT)) } ?: false - if (archiveContainsOnlyExtensions(CBZ_EXTENSIONS)) { - return@MediaTypeSniffer MediaType.CBZ + if (archiveContainsOnlyExtensions(cbzExtensions)) { + return MediaType.CBZ } - if (archiveContainsOnlyExtensions(ZAB_EXTENSIONS)) { - return@MediaTypeSniffer MediaType.ZAB + if (archiveContainsOnlyExtensions(zabExtensions)) { + return MediaType.ZAB } - return@MediaTypeSniffer null + return null } +} - /** - * Sniffs a PDF document. - * - * Reference: https://www.loc.gov/preservation/digital/formats/fdd/fdd000123.shtml - */ - public val pdf: MediaTypeSniffer = MediaTypeSniffer { context -> +/** + * Sniffs a PDF document. + * + * Reference: https://www.loc.gov/preservation/digital/formats/fdd/fdd000123.shtml + */ +public object PdfMediaTypeSniffer : MediaTypeSniffer { + override fun sniffHints(hints: MediaTypeHints): MediaType? { if ( - context.hasFileExtension("pdf") || - context.hasMediaType("application/pdf") + hints.hasFileExtension("pdf") || + hints.hasMediaType("application/pdf") ) { - return@MediaTypeSniffer MediaType.PDF + return MediaType.PDF } - if (context !is ContentMediaTypeSnifferContext) { - return@MediaTypeSniffer null + return null + } + + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ResourceMediaTypeSnifferContent) { + return null } - if (context.read(0L until 5L)?.toString(Charsets.UTF_8) == "%PDF-") { - return@MediaTypeSniffer MediaType.PDF + if (content.read(0L until 5L)?.toString(Charsets.UTF_8) == "%PDF-") { + return MediaType.PDF } - return@MediaTypeSniffer null + return null } +} - /** Sniffs a JSON document. */ - public val json: MediaTypeSniffer = MediaTypeSniffer { context -> - if (context.hasMediaType("application/problem+json")) { - return@MediaTypeSniffer MediaType.JSON_PROBLEM_DETAILS - } - - if (context !is ContentMediaTypeSnifferContext) { - return@MediaTypeSniffer null +/** Sniffs a JSON document. */ +public object JsonMediaTypeSniffer : MediaTypeSniffer { + override fun sniffHints(hints: MediaTypeHints): MediaType? { + if (hints.hasMediaType("application/problem+json")) { + return MediaType.JSON_PROBLEM_DETAILS } - if (context.contentAsJson() != null) { - return@MediaTypeSniffer MediaType.JSON - } - return@MediaTypeSniffer null + return null } - /** Sniffs an XML document. */ - public val xml: MediaTypeSniffer = MediaTypeSniffer { context -> - if (context is ContentMediaTypeSnifferContext && context.contentAsXml() != null) { - return@MediaTypeSniffer MediaType.XML + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ResourceMediaTypeSnifferContent) { + return null } - return@MediaTypeSniffer null - } - /** Sniffs a ZIP archive. */ - public val zip: MediaTypeSniffer = MediaTypeSniffer { context -> - if ( - context.hasMediaType("application/zip") && context is ContainerMediaTypeSnifferContext - ) { - return@MediaTypeSniffer MediaType.ZIP + if (content.contentAsJson() != null) { + return MediaType.JSON } - return@MediaTypeSniffer null + return null } +} - /** - * Sniffs the system-wide registered media types using [MimeTypeMap] and - * [URLConnection.guessContentTypeFromStream]. - */ - public val system: MediaTypeSniffer = MediaTypeSniffer { context -> - val mimetypes = tryOrNull { MimeTypeMap.getSingleton() } - ?: return@MediaTypeSniffer null +/** + * Sniffs the system-wide registered media types using [MimeTypeMap] and + * [URLConnection.guessContentTypeFromStream]. + */ +public object SystemMediaTypeSniffer : MediaTypeSniffer { - fun sniffExtension(extension: String): MediaType? = - mimetypes.getMimeTypeFromExtension(extension) - ?.let { MediaType(it) } + private val mimetypes = tryOrNull { MimeTypeMap.getSingleton() } - fun sniffType(type: String): MediaType? { - val extension = mimetypes.getExtensionFromMimeType(type) - ?: return null - val preferredType = mimetypes.getMimeTypeFromExtension(extension) - ?: return null - return MediaType(preferredType) + override fun sniffHints(hints: MediaTypeHints): MediaType? { + for (mediaType in hints.mediaTypes) { + return sniffType(mediaType.toString()) ?: continue } - for (mediaType in context.hints.mediaTypes) { - return@MediaTypeSniffer sniffType(mediaType.toString()) ?: continue + for (extension in hints.fileExtensions) { + return sniffExtension(extension) ?: continue } - for (extension in context.hints.fileExtensions) { - return@MediaTypeSniffer sniffExtension(extension) ?: continue - } + return null + } - if (context !is ContentMediaTypeSnifferContext) { - return@MediaTypeSniffer null + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ResourceMediaTypeSnifferContent) { + return null } - return@MediaTypeSniffer withContext(Dispatchers.IO) { - context.contentAsStream() + return withContext(Dispatchers.IO) { + content.contentAsStream() .let { URLConnection.guessContentTypeFromStream(it) } ?.let { sniffType(it) } } } - /** - * The default sniffers provided by Readium 2 for all known formats. - * The sniffers order is important, because some formats are subsets of other formats. - */ - public val all: List = listOf( - xhtml, - html, - opds, - lcpLicense, - bitmap, - webpubManifest, - webpub, - w3cWPUB, - epub, - lpf, - archive, - pdf, - json, - xml, - zip - ) + private fun sniffType(type: String): MediaType? { + val extension = mimetypes?.getExtensionFromMimeType(type) + ?: return null + val preferredType = mimetypes.getMimeTypeFromExtension(extension) + ?: return null + return MediaType(preferredType) + } + + private fun sniffExtension(extension: String): MediaType? = + mimetypes?.getMimeTypeFromExtension(extension) + ?.let { MediaType(it) } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContext.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContent.kt similarity index 59% rename from readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContext.kt rename to readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContent.kt index e3b8763bdc..06912d85e4 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContext.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContent.kt @@ -2,7 +2,6 @@ package org.readium.r2.shared.util.mediatype import java.io.ByteArrayInputStream import java.io.InputStream -import java.nio.charset.Charset import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject @@ -12,44 +11,9 @@ import org.readium.r2.shared.parser.xml.ElementNode import org.readium.r2.shared.parser.xml.XmlParser import org.readium.r2.shared.publication.Manifest -public interface MediaTypeSnifferContext { - /** Format hints. */ - public val hints: MediaTypeHints -} - -/** Finds the first [Charset] declared in the media types' `charset` parameter. */ -public val MediaTypeSnifferContext.charset: Charset? get() = - hints.mediaTypes.firstNotNullOfOrNull { it.charset } +public sealed interface MediaTypeSnifferContent -/** Returns whether this context has any of the given file extensions, ignoring case. */ -public fun MediaTypeSnifferContext.hasFileExtension(vararg fileExtensions: String): Boolean { - val fileExtensionsHints = hints.fileExtensions.map { it.lowercase() } - for (fileExtension in fileExtensions.map { it.lowercase() }) { - if (fileExtensionsHints.contains(fileExtension)) { - return true - } - } - return false -} - -/** - * Returns whether this context has any of the given media type, ignoring case and extra - * parameters. - * - * Implementation note: Use [MediaType] to handle the comparison to avoid edge cases. - */ -public fun MediaTypeSnifferContext.hasMediaType(vararg mediaTypes: String): Boolean { - @Suppress("NAME_SHADOWING") - val mediaTypes = mediaTypes.mapNotNull { MediaType(it) } - for (mediaType in mediaTypes) { - if (hints.mediaTypes.any { mediaType.contains(it) }) { - return true - } - } - return false -} - -public interface ContentMediaTypeSnifferContext : MediaTypeSnifferContext { +public interface ResourceMediaTypeSnifferContent : MediaTypeSnifferContent { /** * Reads all the bytes or the given [range]. @@ -68,9 +32,7 @@ public interface ContentMediaTypeSnifferContext : MediaTypeSnifferContext { public suspend fun contentAsString(): String? = read()?.let { tryOrNull { - withContext(Dispatchers.Default) { - String(it, charset = charset ?: Charsets.UTF_8) - } + withContext(Dispatchers.Default) { String(it) } } } @@ -98,7 +60,7 @@ public interface ContentMediaTypeSnifferContext : MediaTypeSnifferContext { /** Readium Web Publication Manifest parsed from the content. */ public suspend fun contentAsRwpm(): Manifest? = - Manifest.fromJSON(contentAsJson()) + Manifest.fromJSON(contentAsJson(), mediaTypeSniffer = DefaultMediaTypeSniffer()) /** * Raw bytes stream of the content. @@ -113,12 +75,12 @@ public interface ContentMediaTypeSnifferContext : MediaTypeSnifferContext { /** * Returns whether the content is a JSON object containing all of the given root keys. */ -public suspend fun ContentMediaTypeSnifferContext.containsJsonKeys(vararg keys: String): Boolean { +public suspend fun ResourceMediaTypeSnifferContent.containsJsonKeys(vararg keys: String): Boolean { val json = contentAsJson() ?: return false return json.keys().asSequence().toSet().containsAll(keys.toList()) } -public interface ContainerMediaTypeSnifferContext : MediaTypeSnifferContext { +public interface ContainerMediaTypeSnifferContent : MediaTypeSnifferContent { /** * Returns all the known entry paths in the container. */ @@ -133,18 +95,13 @@ public interface ContainerMediaTypeSnifferContext : MediaTypeSnifferContext { /** * Returns whether an entry exists in the container. */ -public suspend fun ContainerMediaTypeSnifferContext.contains(path: String): Boolean = +public suspend fun ContainerMediaTypeSnifferContent.contains(path: String): Boolean = entries()?.contains(path) ?: (read(path) != null) -public class HintMediaTypeSnifferContext( - override val hints: MediaTypeHints -) : MediaTypeSnifferContext - -public class BytesContentMediaTypeSnifferContext( - override val hints: MediaTypeHints = MediaTypeHints(), +public class BytesResourceMediaTypeSnifferContent( bytes: suspend () -> ByteArray -) : ContentMediaTypeSnifferContext { +) : ResourceMediaTypeSnifferContent { private val bytesFactory = bytes private lateinit var _bytes: ByteArray diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/pdf/PdfDocument.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/pdf/PdfDocument.kt index 49a13557ab..bcd19dc66e 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/pdf/PdfDocument.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/pdf/PdfDocument.kt @@ -151,7 +151,7 @@ public fun List.toLinks(documentHref: String): List): Boolean = runBlocking { - LcpFallbackContentProtection().supports( + LcpFallbackContentProtection(DefaultMediaTypeSniffer()).supports( Asset.Container( mediaType = mediaType, exploded = false, diff --git a/readium/shared/src/test/java/org/readium/r2/shared/publication/services/CoverServiceTest.kt b/readium/shared/src/test/java/org/readium/r2/shared/publication/services/CoverServiceTest.kt index 4f76d5c761..ae00051dae 100644 --- a/readium/shared/src/test/java/org/readium/r2/shared/publication/services/CoverServiceTest.kt +++ b/readium/shared/src/test/java/org/readium/r2/shared/publication/services/CoverServiceTest.kt @@ -41,7 +41,7 @@ class CoverServiceTest { coverBytes = cover.readBytes() coverBitmap = BitmapFactory.decodeByteArray(coverBytes, 0, coverBytes.size) coverPath = cover.path - coverLink = Link(href = coverPath, type = "image/jpeg", width = 598, height = 800) + coverLink = Link(href = coverPath, mediaType = MediaType.JPEG, width = 598, height = 800) publication = Publication( manifest = Manifest( diff --git a/readium/shared/src/test/java/org/readium/r2/shared/publication/services/LocatorServiceTest.kt b/readium/shared/src/test/java/org/readium/r2/shared/publication/services/LocatorServiceTest.kt index 4d698aeacb..94c0cb83d5 100644 --- a/readium/shared/src/test/java/org/readium/r2/shared/publication/services/LocatorServiceTest.kt +++ b/readium/shared/src/test/java/org/readium/r2/shared/publication/services/LocatorServiceTest.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Test import org.readium.r2.shared.publication.Link import org.readium.r2.shared.publication.Locator +import org.readium.r2.shared.util.mediatype.MediaType @OptIn(ExperimentalCoroutinesApi::class) class LocatorServiceTest { @@ -22,9 +23,9 @@ class LocatorServiceTest { fun `locate from Locator`() = runTest { val service = createService( readingOrder = listOf( - Link(href = "chap1", type = "application/xml"), - Link(href = "chap2", type = "application/xml"), - Link(href = "chap3", type = "application/xml") + Link(href = "chap1", mediaType = MediaType.XML), + Link(href = "chap2", mediaType = MediaType.XML), + Link(href = "chap3", mediaType = MediaType.XML) ) ) val locator = Locator( @@ -50,8 +51,8 @@ class LocatorServiceTest { fun `locate from Locator not found`() = runTest { val service = createService( readingOrder = listOf( - Link(href = "chap1", type = "application/xml"), - Link(href = "chap3", type = "application/xml") + Link(href = "chap1", mediaType = MediaType.XML), + Link(href = "chap3", mediaType = MediaType.XML) ) ) val locator = Locator( diff --git a/readium/shared/src/test/java/org/readium/r2/shared/publication/services/PositionsServiceTest.kt b/readium/shared/src/test/java/org/readium/r2/shared/publication/services/PositionsServiceTest.kt index 956d9593ed..bb328b9fbd 100644 --- a/readium/shared/src/test/java/org/readium/r2/shared/publication/services/PositionsServiceTest.kt +++ b/readium/shared/src/test/java/org/readium/r2/shared/publication/services/PositionsServiceTest.kt @@ -21,6 +21,7 @@ import org.readium.r2.shared.publication.Link import org.readium.r2.shared.publication.Locator import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.resource.readAsString +import org.readium.r2.shared.util.mediatype.MediaType import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) @@ -120,7 +121,7 @@ class PerResourcePositionsServiceTest { @Test fun `Positions from a {readingOrder} with one resource`() { val service = PerResourcePositionsService( - readingOrder = listOf(Link(href = "res", type = "image/png")), + readingOrder = listOf(Link(href = "res", mediaType = MediaType.PNG)), fallbackMediaType = "" ) @@ -144,8 +145,8 @@ class PerResourcePositionsServiceTest { val service = PerResourcePositionsService( readingOrder = listOf( Link(href = "res"), - Link(href = "chap1", type = "image/png"), - Link(href = "chap2", type = "image/png", title = "Chapter 2") + Link(href = "chap1", mediaType = MediaType.PNG), + Link(href = "chap2", mediaType = MediaType.PNG, title = "Chapter 2") ), fallbackMediaType = "" ) diff --git a/readium/shared/src/test/java/org/readium/r2/shared/publication/services/content/iterators/HtmlResourceContentIteratorTest.kt b/readium/shared/src/test/java/org/readium/r2/shared/publication/services/content/iterators/HtmlResourceContentIteratorTest.kt index 6db163703e..e668d9de83 100644 --- a/readium/shared/src/test/java/org/readium/r2/shared/publication/services/content/iterators/HtmlResourceContentIteratorTest.kt +++ b/readium/shared/src/test/java/org/readium/r2/shared/publication/services/content/iterators/HtmlResourceContentIteratorTest.kt @@ -19,13 +19,14 @@ import org.readium.r2.shared.publication.services.content.Content.TextElement.Se import org.readium.r2.shared.resource.StringResource import org.readium.r2.shared.util.Language import org.readium.r2.shared.util.Url +import org.readium.r2.shared.util.mediatype.MediaType import org.robolectric.RobolectricTestRunner @OptIn(ExperimentalReadiumApi::class) @RunWith(RobolectricTestRunner::class) class HtmlResourceContentIteratorTest { - private val link = Link(href = "/dir/res.xhtml", type = "application/xhtml+xml") + private val link = Link(href = "/dir/res.xhtml", mediaType = MediaType.XHTML) private val locator = Locator(href = "/dir/res.xhtml", type = "application/xhtml+xml") private val html = """ @@ -407,7 +408,7 @@ class HtmlResourceContentIteratorTest {