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/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..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 @@ -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 = @@ -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..61d9a27582 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,12 +29,9 @@ 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.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 @@ -159,9 +156,8 @@ public interface LcpService { */ public operator fun invoke( context: Context, - mediaTypeRetriever: MediaTypeRetriever = MediaTypeRetriever(), - resourceFactory: ResourceFactory = FileResourceFactory(), - archiveFactory: ArchiveFactory = DefaultArchiveFactory() + assetRetriever: AssetRetriever, + mediaTypeRetriever: MediaTypeRetriever ): 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(mediaTypeRetriever) val device = DeviceService( repository = deviceRepository, network = network, @@ -186,18 +182,17 @@ public interface LcpService { network = network, passphrases = passphrases, context = context, - mediaTypeRetriever = mediaTypeRetriever, - resourceFactory = resourceFactory, - archiveFactory = archiveFactory + assetRetriever = assetRetriever ) } + @Suppress("UNUSED_PARAMETER") @Deprecated( "Use `LcpService()` instead", - ReplaceWith("LcpService(context)"), + ReplaceWith("LcpService(context, AssetRetriever(), MediaTypeRetriever())"), level = DeprecationLevel.ERROR ) - public fun create(context: Context): LcpService? = invoke(context) + public fun create(context: Context): LcpService? = throw NotImplementedError() } @Deprecated( @@ -235,13 +230,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/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/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/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/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..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 @@ -30,13 +30,11 @@ 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.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,17 +44,13 @@ 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 ) : 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) { @@ -73,7 +67,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 +245,7 @@ internal class LicensesService( destination, mediaType = link.type, onProgress = onProgress - ) - ?: mediaTypeRetriever.retrieve(mediaType = link.type) - ?: MediaType.EPUB + ) ?: link.mediaType // Saves the License Document into the downloaded publication val container = createLicenseContainer(destination, mediaType) @@ -266,4 +258,14 @@ internal class LicensesService( 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/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..cae98e3152 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,8 +21,9 @@ 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.util.http.invoke import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeHints import org.readium.r2.shared.util.mediatype.MediaTypeRetriever import timber.log.Timber @@ -138,10 +139,7 @@ internal class NetworkService( } } - mediaTypeRetriever.retrieve( - connection = connection, - mediaType = mediaType - ) + mediaTypeRetriever.retrieve(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/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/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/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/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 { + public suspend fun parseUrlString( + url: String, + client: HttpClient = DefaultHttpClient(MediaTypeRetriever()) + ): Try { return client.fetchWithDecoder(HttpRequest(url)) { this.parse(it.body, URL(url)) } @@ -54,7 +59,7 @@ public class OPDS1Parser { public suspend fun parseRequest( request: HttpRequest, - client: HttpClient = DefaultHttpClient() + client: HttpClient = DefaultHttpClient(MediaTypeRetriever()) ): Try { return client.fetchWithDecoder(request) { this.parse(it.body, URL(request.url)) @@ -128,7 +133,9 @@ public class OPDS1Parser { val newLink = Link( href = Href(href, baseHref = feed.href.toString()).percentEncodedString, - type = link.getAttr("type"), + mediaType = mediaTypeRetriever.retrieve( + mediaType = link.getAttr("type") + ), title = entry.getFirst("title", Namespaces.Atom)?.text, rels = listOfNotNull(link.getAttr("rel")).toSet(), properties = Properties(otherProperties = otherProperties) @@ -147,7 +154,7 @@ public class OPDS1Parser { val hrefAttr = link.getAttr("href") ?: continue val href = Href(hrefAttr, baseHref = feed.href.toString()).percentEncodedString val title = link.getAttr("title") - val type = link.getAttr("type") + val type = mediaTypeRetriever.retrieve(link.getAttr("type")) val rels = listOfNotNull(link.getAttr("rel")).toSet() val facetGroupName = link.getAttrNs("facetGroup", Namespaces.Opds) @@ -159,14 +166,14 @@ public class OPDS1Parser { } val newLink = Link( href = href, - type = type, + mediaType = type, title = title, rels = rels, properties = Properties(otherProperties = otherProperties) ) addFacet(feed, newLink, facetGroupName) } else { - feed.links.add(Link(href = href, type = type, title = title, rels = rels)) + feed.links.add(Link(href = href, mediaType = type, title = title, rels = rels)) } } return feed @@ -186,14 +193,17 @@ public class OPDS1Parser { } @Suppress("unused") - public suspend fun retrieveOpenSearchTemplate(feed: Feed): Try { + public suspend fun retrieveOpenSearchTemplate( + feed: Feed, + client: HttpClient = DefaultHttpClient(MediaTypeRetriever()) + ): Try { 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) @@ -204,7 +214,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) @@ -214,7 +224,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) @@ -268,7 +278,7 @@ public class OPDS1Parser { Link( href = Href(href, baseHref = baseUrl.toString()).percentEncodedString, - type = element.getAttr("type"), + mediaType = mediaTypeRetriever.retrieve(element.getAttr("type")), title = element.getAttr("title"), rels = listOfNotNull(rel).toSet(), properties = Properties(otherProperties = properties) @@ -415,5 +425,7 @@ public class OPDS1Parser { children = fromXML(child) ) } + + public var mediaTypeRetriever: MediaTypeRetriever = MediaTypeRetriever() } } 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..b7c137af48 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,7 +15,11 @@ 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 @@ -24,6 +28,7 @@ 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.MediaTypeRetriever public enum class OPDS2ParserError { MetadataNotFound, @@ -39,7 +44,10 @@ 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 = DefaultHttpClient(MediaTypeRetriever()) + ): Try { return client.fetchWithDecoder(HttpRequest(url)) { this.parse(it.body, URL(url)) } @@ -47,7 +55,7 @@ public class OPDS2Parser { public suspend fun parseRequest( request: HttpRequest, - client: HttpClient = DefaultHttpClient() + client: HttpClient = DefaultHttpClient(MediaTypeRetriever()) ): Try { return client.fetchWithDecoder(request) { this.parse(it.body, URL(request.url)) @@ -60,7 +68,10 @@ public class OPDS2Parser { } else { ParseData( null, - Manifest.fromJSON(JSONObject(String(jsonData)))?.let { Publication(it) }, + Manifest.fromJSON( + JSONObject(String(jsonData)), + mediaTypeRetriever = mediaTypeRetriever + )?.let { Publication(it) }, 2 ) } @@ -203,7 +214,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, mediaTypeRetriever = mediaTypeRetriever)?.let { manifest -> feed.publications.add(Publication(manifest)) } } @@ -253,7 +264,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, mediaTypeRetriever = mediaTypeRetriever)?.let { manifest -> group.publications.add(Publication(manifest)) } } @@ -264,12 +275,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, mediaTypeRetriever, normalizeHref = { Href( it, baseUrl.toString() ).string }) } + + public var mediaTypeRetriever: MediaTypeRetriever = MediaTypeRetriever() } } diff --git a/readium/opds/src/test/java/org/readium/r2/opds/OPDS1ParserTest.kt b/readium/opds/src/test/java/org/readium/r2/opds/OPDS1ParserTest.kt index 46f483e184..701ede334d 100644 --- a/readium/opds/src/test/java/org/readium/r2/opds/OPDS1ParserTest.kt +++ b/readium/opds/src/test/java/org/readium/r2/opds/OPDS1ParserTest.kt @@ -13,6 +13,7 @@ import org.readium.r2.shared.opds.OpdsMetadata import org.readium.r2.shared.opds.ParseData import org.readium.r2.shared.publication.* import org.readium.r2.shared.publication.Properties +import org.readium.r2.shared.util.mediatype.MediaType import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) @@ -35,32 +36,42 @@ class OPDS1ParserTest { links = mutableListOf( Link( href = "https://example.com/opds-catalogs/root.xml", - type = "application/atom+xml;profile=opds-catalog;kind=navigation", + mediaType = MediaType( + "application/atom+xml;profile=opds-catalog;kind=navigation" + )!!, rels = setOf("self"), properties = Properties() ), Link( href = "https://example.com/opds-catalogs/root.xml", - type = "application/atom+xml;profile=opds-catalog;kind=navigation", + mediaType = MediaType( + "application/atom+xml;profile=opds-catalog;kind=navigation" + )!!, rels = setOf("start") ) ), navigation = mutableListOf( Link( href = "https://example.com/opds-catalogs/popular.xml", - type = "application/atom+xml;profile=opds-catalog;kind=acquisition", + mediaType = MediaType( + "application/atom+xml;profile=opds-catalog;kind=acquisition" + )!!, title = "Popular Publications", rels = setOf("http://opds-spec.org/sort/popular") ), Link( href = "https://example.com/opds-catalogs/new.xml", - type = "application/atom+xml;profile=opds-catalog;kind=acquisition", + mediaType = MediaType( + "application/atom+xml;profile=opds-catalog;kind=acquisition" + )!!, title = "New Publications", rels = setOf("http://opds-spec.org/sort/new") ), Link( href = "https://example.com/opds-catalogs/unpopular.xml", - type = "application/atom+xml;profile=opds-catalog;kind=acquisition", + mediaType = MediaType( + "application/atom+xml;profile=opds-catalog;kind=acquisition" + )!!, title = "Unpopular Publications", rels = setOf("subsection") ) @@ -93,22 +104,30 @@ class OPDS1ParserTest { mutableListOf( Link( href = "https://example.com/opds-catalogs/vampire.farming.xml", - type = "application/atom+xml;profile=opds-catalog;kind=acquisition", + mediaType = MediaType( + "application/atom+xml;profile=opds-catalog;kind=acquisition" + )!!, rels = setOf("related") ), Link( href = "https://example.com/opds-catalogs/unpopular.xml", - type = "application/atom+xml;profile=opds-catalog;kind=acquisition", + mediaType = MediaType( + "application/atom+xml;profile=opds-catalog;kind=acquisition" + )!!, rels = setOf("self") ), Link( href = "https://example.com/opds-catalogs/root.xml", - type = "application/atom+xml;profile=opds-catalog;kind=navigation", + mediaType = MediaType( + "application/atom+xml;profile=opds-catalog;kind=navigation" + )!!, rels = setOf("start") ), Link( href = "https://example.com/opds-catalogs/root.xml", - type = "application/atom+xml;profile=opds-catalog;kind=navigation", + mediaType = MediaType( + "application/atom+xml;profile=opds-catalog;kind=navigation" + )!!, rels = setOf("up") ) ), @@ -164,23 +183,25 @@ class OPDS1ParserTest { links = listOf( Link( href = "https://example.com/covers/4561.thmb.gif", - type = "image/gif", + mediaType = MediaType("image/gif")!!, rels = setOf("http://opds-spec.org/image/thumbnail") ), Link( href = "https://example.com/opds-catalogs/entries/4571.complete.xml", - type = "application/atom+xml;type=entry;profile=opds-catalog", + mediaType = MediaType( + "application/atom+xml;type=entry;profile=opds-catalog" + )!!, title = "Complete Catalog Entry for Bob, Son of Bob", rels = setOf("alternate") ), Link( href = "https://example.com/content/free/4561.epub", - type = "application/epub+zip", + mediaType = MediaType("application/epub+zip")!!, rels = setOf("http://opds-spec.org/acquisition") ), Link( href = "https://example.com/content/free/4561.mobi", - type = "application/x-mobipocket-ebook", + mediaType = MediaType("application/x-mobipocket-ebook")!!, rels = setOf("http://opds-spec.org/acquisition") ) ), @@ -190,7 +211,7 @@ class OPDS1ParserTest { links = listOf( Link( href = "https://example.com/covers/4561.lrg.png", - type = "image/png", + mediaType = MediaType("image/png")!!, rels = setOf("http://opds-spec.org/image") ) ) @@ -230,7 +251,7 @@ class OPDS1ParserTest { links = listOf( Link( href = "https://example.com/content/buy/11241.epub", - type = "application/epub+zip", + mediaType = MediaType("application/epub+zip")!!, rels = setOf("http://opds-spec.org/acquisition/buy"), properties = Properties( mapOf("price" to mapOf("currency" to "USD", "value" to 18.99)) @@ -243,7 +264,7 @@ class OPDS1ParserTest { links = listOf( Link( href = "https://example.com/covers/11241.lrg.jpg", - type = "image/jpeg", + mediaType = MediaType("image/jpeg")!!, rels = setOf("http://opds-spec.org/image") ) ) @@ -286,22 +307,24 @@ class OPDS1ParserTest { links = listOf( Link( href = "https://example.com/covers/4561.thmb.gif", - type = "image/gif", + mediaType = MediaType("image/gif")!!, rels = setOf("http://opds-spec.org/image/thumbnail") ), Link( href = "https://example.com/opds-catalogs/entries/4571.complete.xml", - type = "application/atom+xml;type=entry;profile=opds-catalog", + mediaType = MediaType( + "application/atom+xml;type=entry;profile=opds-catalog" + )!!, rels = setOf("self") ), Link( href = "https://example.com/content/free/4561.epub", - type = "application/epub+zip", + mediaType = MediaType("application/epub+zip")!!, rels = setOf("http://opds-spec.org/acquisition") ), Link( href = "https://example.com/content/free/4561.mobi", - type = "application/x-mobipocket-ebook", + mediaType = MediaType("application/x-mobipocket-ebook")!!, rels = setOf("http://opds-spec.org/acquisition") ) ), @@ -311,7 +334,7 @@ class OPDS1ParserTest { links = listOf( Link( href = "https://example.com/covers/4561.lrg.png", - type = "image/png", + mediaType = MediaType("image/png")!!, rels = setOf("http://opds-spec.org/image") ) ) 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/Asset.kt b/readium/shared/src/main/java/org/readium/r2/shared/asset/Asset.kt index 8cf2d010c6..2650b284b1 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,6 +6,8 @@ package org.readium.r2.shared.asset +import org.readium.r2.shared.resource.Container as SharedContainer +import org.readium.r2.shared.resource.Resource as SharedResource import org.readium.r2.shared.util.mediatype.MediaType /** @@ -14,14 +16,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 assetType: AssetType /** - * Type of the asset source. + * Media type of the asset. */ - public abstract val assetType: AssetType + public abstract val mediaType: MediaType /** * Releases in-memory resources related to this asset. @@ -36,7 +38,7 @@ public sealed class Asset { */ public class Resource( override val mediaType: MediaType, - public val resource: org.readium.r2.shared.resource.Resource + public val resource: SharedResource ) : Asset() { override val assetType: AssetType = @@ -57,7 +59,7 @@ public sealed class Asset { public class Container( override val mediaType: MediaType, exploded: Boolean, - public val container: org.readium.r2.shared.resource.Container + public val container: SharedContainer ) : Asset() { override val assetType: AssetType = 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..3397366111 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 @@ -9,30 +9,54 @@ 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.resource.* +import org.readium.r2.shared.error.getOrElse +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.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.ResourceMediaTypeSnifferContent +import org.readium.r2.shared.util.Either 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.mediatype.MediaTypeHints +import org.readium.r2.shared.util.mediatype.MediaTypeRetriever import org.readium.r2.shared.util.toUrl +/** + * Retrieves an [Asset] instance providing reading access to the resource(s) of an asset stored at a + * given [Url]. + */ public class AssetRetriever( + private val mediaTypeRetriever: MediaTypeRetriever, private val resourceFactory: ResourceFactory, private val containerFactory: ContainerFactory, private val archiveFactory: ArchiveFactory, - contentResolver: ContentResolver, - sniffers: List + private val contentResolver: ContentResolver ) { - public constructor(context: Context) : this( - resourceFactory = FileResourceFactory(), - containerFactory = DirectoryContainerFactory(), - archiveFactory = DefaultArchiveFactory(), - contentResolver = context.contentResolver, - sniffers = MediaType.sniffers - ) + public companion object { + public operator fun invoke(context: Context): AssetRetriever { + val mediaTypeRetriever = MediaTypeRetriever() + return AssetRetriever( + mediaTypeRetriever = mediaTypeRetriever, + resourceFactory = FileResourceFactory(mediaTypeRetriever), + containerFactory = DirectoryContainerFactory(mediaTypeRetriever), + archiveFactory = DefaultArchiveFactory(mediaTypeRetriever), + contentResolver = context.contentResolver + ) + } + } public sealed class Error : org.readium.r2.shared.error.Error { @@ -129,21 +153,24 @@ public class AssetRetriever( } /** - * Retrieves an asset from a known media and asset type again. + * Retrieves an asset from a known media and asset type. */ public suspend fun retrieve( url: Url, mediaType: MediaType, assetType: AssetType - ): Try = - when (assetType) { + ): Try { + return when (assetType) { AssetType.Archive -> retrieveArchiveAsset(url, mediaType) + AssetType.Directory -> retrieveDirectoryAsset(url, mediaType) + AssetType.Resource -> retrieveResourceAsset(url, mediaType) } + } private suspend fun retrieveArchiveAsset( url: Url, @@ -227,130 +254,94 @@ 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 - ): 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) - } + public suspend fun retrieve(file: File): Asset? = + retrieve(file.toUrl()) /** - * Retrieves an asset from an Uri. + * Retrieves an asset from a [Uri]. */ - public suspend fun retrieve( - uri: Uri, - mediaType: String? = null, - fileExtension: String? = null - ): Asset? = - retrieve( - uri, - mediaTypes = listOfNotNull(mediaType), - fileExtensions = listOfNotNull(fileExtension) - ) - - /** - * Retrieves an asset from a Uri. - */ - public suspend fun retrieve( - uri: Uri, - mediaTypes: List, - fileExtensions: List - ): Asset? { + public suspend fun retrieve(uri: Uri): Asset? { val url = uri.toUrl() ?: return null - return retrieve(url, mediaTypes, fileExtensions) + return retrieve(url) } /** - * Retrieves an asset from a Url. + * Retrieves an asset from a [Url]. */ - public suspend fun retrieve( + public suspend fun retrieve(url: Url): Asset? { + val resource = resourceFactory + .create(url) + .getOrElse { error -> + when (error) { + is ResourceFactory.Error.NotAResource -> + return containerFactory.create(url).getOrNull() + ?.let { retrieve(url, it, exploded = true) } + else -> return null + } + } + + return archiveFactory.create(resource, password = null) + .fold( + { retrieve(url, container = it, exploded = false) }, + { retrieve(url, resource) } + ) + } + + private suspend fun retrieve( url: Url, - mediaType: String? = null, - fileExtension: String? = null + container: Container, + exploded: Boolean ): Asset? { - return retrieve(url, listOfNotNull(mediaType), listOfNotNull(fileExtension)) + val mediaType = retrieveMediaType(url, Either(container)) + ?: return null + return Asset.Container(mediaType, exploded = exploded, container = container) } - /** - * Retrieves an asset from a Url. - */ - public suspend fun retrieve( + private suspend fun retrieve(url: Url, resource: Resource): Asset? { + val mediaType = retrieveMediaType(url, Either(resource)) + ?: return null + return Asset.Resource(mediaType, resource = resource) + } + + private suspend fun retrieveMediaType( url: Url, - mediaTypes: List, - fileExtensions: List - ): Asset? { - val context = snifferContextFactory - .createContext( - url, - mediaTypes = mediaTypes, - fileExtensions = buildList { - addAll(fileExtensions) - url.extension?.let { add(it) } + asset: Either + ): MediaType? { + suspend fun retrieve(hints: MediaTypeHints): MediaType? = + mediaTypeRetriever.retrieve( + hints = hints, + content = when (asset) { + is Either.Left -> ResourceMediaTypeSnifferContent(asset.value) + is Either.Right -> ContainerMediaTypeSnifferContent(asset.value) } ) - ?: return null - return retrieve(context) - } + retrieve(MediaTypeHints(fileExtensions = listOfNotNull(url.extension))) + ?.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 } + ) - 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 - ) + retrieve(contentHints)?.let { return it } } + + return 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) 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/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/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/Contributor.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/Contributor.kt index b2a97601cf..3346970353 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,14 @@ 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.MediaTypeRetriever /** * Contributor Object for the Readium Web Publication Manifest. @@ -81,6 +86,7 @@ public data class Contributor( */ public fun fromJSON( json: Any?, + mediaTypeRetriever: MediaTypeRetriever = MediaTypeRetriever(), normalizeHref: LinkHrefNormalizer = LinkHrefNormalizerIdentity, warnings: WarningLogger? = null ): Contributor? { @@ -103,7 +109,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"), + mediaTypeRetriever, + normalizeHref, + warnings + ) ) } @@ -116,15 +127,23 @@ public data class Contributor( */ public fun fromJSONArray( json: Any?, + mediaTypeRetriever: MediaTypeRetriever = MediaTypeRetriever(), 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, + mediaTypeRetriever, + normalizeHref, + warnings + ) + } is JSONArray -> - json.parseObjects { fromJSON(it, normalizeHref, warnings) } + json.parseObjects { fromJSON(it, mediaTypeRetriever, 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 4f3adcb831..595e0985d5 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,18 @@ 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.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeRetriever /** * Function used to recursively transform the href of a [Link] when parsing its JSON @@ -39,7 +45,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 +62,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 +76,6 @@ public data class Link( val children: List = listOf() ) : JSONable, Parcelable { - /** Media type of the linked resource. */ - val mediaType: MediaType get() = - type?.let { MediaType.parse(it) } ?: MediaType.BINARY - /** * List of URI template parameter keys, if the [Link] is templated. */ @@ -113,7 +115,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 +145,7 @@ public data class Link( */ public fun fromJSON( json: JSONObject?, + mediaTypeRetriever: MediaTypeRetriever = MediaTypeRetriever(), normalizeHref: LinkHrefNormalizer = LinkHrefNormalizerIdentity, warnings: WarningLogger? = null ): Link? { @@ -154,7 +157,8 @@ public data class Link( return Link( href = normalizeHref(href), - type = json.optNullableString("type"), + mediaType = json.optNullableString("type") + ?.let { mediaTypeRetriever.retrieve(it) }, templated = json.optBoolean("templated", false), title = json.optNullableString("title"), rels = json.optStringsFromArrayOrSingle("rel").toSet(), @@ -164,8 +168,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"), + mediaTypeRetriever, + normalizeHref + ), + children = fromJSONArray( + json.optJSONArray("children"), + mediaTypeRetriever, + normalizeHref + ) ) } @@ -177,16 +189,30 @@ public data class Link( */ public fun fromJSONArray( json: JSONArray?, + mediaTypeRetriever: MediaTypeRetriever = MediaTypeRetriever(), normalizeHref: LinkHrefNormalizer = LinkHrefNormalizerIdentity, warnings: WarningLogger? = null ): List { - return json.parseObjects { fromJSON(it as? JSONObject, normalizeHref, warnings) } + return json.parseObjects { + fromJSON( + it as? JSONObject, + mediaTypeRetriever, + 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 +245,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..442886003d 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,7 @@ 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.MediaTypeRetriever /** * Represents a precise location in a publication in a format that can be stored and shared. @@ -215,17 +216,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 +277,18 @@ public data class LocatorCollection( public companion object { - public fun fromJSON(json: JSONObject?, warnings: WarningLogger? = null): LocatorCollection { + public fun fromJSON( + json: JSONObject?, + mediaTypeRetriever: MediaTypeRetriever = MediaTypeRetriever(), + 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"), + mediaTypeRetriever, + 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 b4b285ed96..ace4d4ef3c 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 @@ -21,6 +21,7 @@ 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.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeRetriever /** * Holds the metadata of a Readium publication, as described in the Readium Web Publication Manifest. @@ -111,7 +112,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 +156,7 @@ public data class Manifest( public fun fromJSON( json: JSONObject?, packaged: Boolean = false, + mediaTypeRetriever: MediaTypeRetriever = MediaTypeRetriever(), warnings: WarningLogger? = null ): Manifest? { json ?: return null @@ -163,7 +165,11 @@ public data class Manifest( if (packaged) { "/" } else { - Link.fromJSONArray(json.optJSONArray("links"), warnings = warnings) + Link.fromJSONArray( + json.optJSONArray("links"), + mediaTypeRetriever, + warnings = warnings + ) .firstWithRel("self") ?.href ?.toUrlOrNull() @@ -178,6 +184,7 @@ public data class Manifest( val metadata = Metadata.fromJSON( json.remove("metadata") as? JSONObject, + mediaTypeRetriever, normalizeHref, warnings ) @@ -188,33 +195,39 @@ public data class Manifest( val links = Link.fromJSONArray( json.remove("links") as? JSONArray, + mediaTypeRetriever, normalizeHref, 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 } } // [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, + mediaTypeRetriever, + normalizeHref, + warnings + ) + .filter { it.mediaType != null } val resources = Link.fromJSONArray( json.remove("resources") as? JSONArray, + mediaTypeRetriever, normalizeHref, warnings ) - .filter { it.type != null } + .filter { it.mediaType != null } val tableOfContents = Link.fromJSONArray( json.remove("toc") as? JSONArray, + mediaTypeRetriever, normalizeHref, warnings ) @@ -222,6 +235,7 @@ public data class Manifest( // Parses subcollections from the remaining JSON properties. val subcollections = PublicationCollection.collectionsFromJSON( json, + mediaTypeRetriever, 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..d21a2440d3 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,7 @@ 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.MediaTypeRetriever /** * https://readium.org/webpub-manifest/schema/metadata.schema.json @@ -255,6 +256,7 @@ public data class Metadata( */ public fun fromJSON( json: JSONObject?, + mediaTypeRetriever: MediaTypeRetriever = MediaTypeRetriever(), normalizeHref: LinkHrefNormalizer = LinkHrefNormalizerIdentity, warnings: WarningLogger? = null ): Metadata? { @@ -276,53 +278,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"), + mediaTypeRetriever, + normalizeHref, + warnings + ) + val authors = Contributor.fromJSONArray( + json.remove("author"), + mediaTypeRetriever, + normalizeHref, + warnings + ) val translators = Contributor.fromJSONArray( json.remove("translator"), + mediaTypeRetriever, + normalizeHref, + warnings + ) + val editors = Contributor.fromJSONArray( + json.remove("editor"), + mediaTypeRetriever, + normalizeHref, + warnings + ) + val artists = Contributor.fromJSONArray( + json.remove("artist"), + mediaTypeRetriever, 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"), + mediaTypeRetriever, normalizeHref, warnings ) val letterers = Contributor.fromJSONArray( json.remove("letterer"), + mediaTypeRetriever, normalizeHref, warnings ) val pencilers = Contributor.fromJSONArray( json.remove("penciler"), + mediaTypeRetriever, normalizeHref, warnings ) val colorists = Contributor.fromJSONArray( json.remove("colorist"), + mediaTypeRetriever, + normalizeHref, + warnings + ) + val inkers = Contributor.fromJSONArray( + json.remove("inker"), + mediaTypeRetriever, normalizeHref, warnings ) - val inkers = Contributor.fromJSONArray(json.remove("inker"), normalizeHref, warnings) val narrators = Contributor.fromJSONArray( json.remove("narrator"), + mediaTypeRetriever, normalizeHref, warnings ) val contributors = Contributor.fromJSONArray( json.remove("contributor"), + mediaTypeRetriever, normalizeHref, warnings ) val publishers = Contributor.fromJSONArray( json.remove("publisher"), + mediaTypeRetriever, normalizeHref, warnings ) val imprints = Contributor.fromJSONArray( json.remove("imprint"), + mediaTypeRetriever, normalizeHref, warnings ) @@ -343,7 +379,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, + mediaTypeRetriever, + 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 0b856f4c48..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 @@ -180,7 +180,6 @@ public class Publication( null } } - // FIXME: To remove when the `Resource` properly sniffs its content media type. .withMediaType(link.mediaType) } @@ -669,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 { - override suspend fun mediaType(): ResourceTry = +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..734e123e15 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,7 @@ 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.MediaTypeRetriever /** * Core Collection Model @@ -55,6 +56,7 @@ public data class PublicationCollection( */ public fun fromJSON( json: Any?, + mediaTypeRetriever: MediaTypeRetriever = MediaTypeRetriever(), normalizeHref: LinkHrefNormalizer = LinkHrefNormalizerIdentity, warnings: WarningLogger? = null ): PublicationCollection? { @@ -69,16 +71,22 @@ public data class PublicationCollection( is JSONObject -> { links = Link.fromJSONArray( json.remove("links") as? JSONArray, + mediaTypeRetriever, normalizeHref, warnings ) metadata = (json.remove("metadata") as? JSONObject)?.toMap() - subcollections = collectionsFromJSON(json, normalizeHref, warnings) + subcollections = collectionsFromJSON( + json, + mediaTypeRetriever, + normalizeHref, + warnings + ) } // Parses an array of links. is JSONArray -> { - links = Link.fromJSONArray(json, normalizeHref, warnings) + links = Link.fromJSONArray(json, mediaTypeRetriever, normalizeHref, warnings) } else -> { @@ -111,6 +119,7 @@ public data class PublicationCollection( */ public fun collectionsFromJSON( json: JSONObject, + mediaTypeRetriever: MediaTypeRetriever = MediaTypeRetriever(), normalizeHref: LinkHrefNormalizer = LinkHrefNormalizerIdentity, warnings: WarningLogger? = null ): Map> { @@ -119,14 +128,21 @@ 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, mediaTypeRetriever, 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, + mediaTypeRetriever, + 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..c3727ed8bd 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,7 @@ 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.MediaTypeRetriever /** * https://github.com/readium/webpub-manifest/tree/master/contexts/default#subjects @@ -77,6 +78,7 @@ public data class Subject( */ public fun fromJSON( json: Any?, + mediaTypeRetriever: MediaTypeRetriever = MediaTypeRetriever(), normalizeHref: LinkHrefNormalizer = LinkHrefNormalizerIdentity, warnings: WarningLogger? = null ): Subject? { @@ -100,6 +102,7 @@ public data class Subject( code = jsonObject.optNullableString("code"), links = Link.fromJSONArray( jsonObject.optJSONArray("links"), + mediaTypeRetriever, normalizeHref, warnings ) @@ -115,15 +118,23 @@ public data class Subject( */ public fun fromJSONArray( json: Any?, + mediaTypeRetriever: MediaTypeRetriever = MediaTypeRetriever(), 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, + mediaTypeRetriever, + normalizeHref, + warnings + ) + } is JSONArray -> - json.parseObjects { fromJSON(it, normalizeHref, warnings) } + json.parseObjects { fromJSON(it, mediaTypeRetriever, 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..3be98b5b7f 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 @@ -10,7 +10,11 @@ package org.readium.r2.shared.publication.opds import org.json.JSONObject -import org.readium.r2.shared.opds.* +import org.readium.r2.shared.opds.Acquisition +import org.readium.r2.shared.opds.Availability +import org.readium.r2.shared.opds.Copies +import org.readium.r2.shared.opds.Holds +import org.readium.r2.shared.opds.Price import org.readium.r2.shared.publication.Link import org.readium.r2.shared.publication.Properties 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..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 @@ -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( @@ -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.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/ContentProtectionSchemeRetriever.kt b/readium/shared/src/main/java/org/readium/r2/shared/publication/protection/ContentProtectionSchemeRetriever.kt index 8cd3a45ae3..5dad613388 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.MediaTypeRetriever /** * Retrieves [ContentProtection] schemes of assets. */ public class ContentProtectionSchemeRetriever( - contentProtections: List + contentProtections: List, + mediaTypeRetriever: MediaTypeRetriever ) { private val contentProtections: List = contentProtections + listOf( - LcpFallbackContentProtection(), + LcpFallbackContentProtection(mediaTypeRetriever), 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..f96ec028ec 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.MediaTypeRetriever /** * [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 mediaTypeRetriever: MediaTypeRetriever +) : 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, + mediaTypeRetriever = mediaTypeRetriever + ) ?: 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..819f343d08 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 ) @@ -312,16 +316,18 @@ private sealed class RouteHandler { return if (!copyAllowed) { FailureResource(Resource.Exception.Forbidden()) } else { - StringResource("true") + StringResource("true", MediaType.JSON) } } } 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/BytesResource.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/BytesResource.kt index 7b6e84eb01..d142922c2b 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,14 +8,13 @@ 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 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 { @@ -23,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 = @@ -43,30 +42,21 @@ 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() {} } /** Creates a Resource serving a [ByteArray]. */ public class BytesResource( url: Url? = null, - mediaType: MediaType? = null, + mediaType: MediaType, properties: Resource.Properties = Resource.Properties(), bytes: suspend () -> ResourceTry ) : BaseBytesResource(source = url, mediaType = mediaType, properties = properties, bytes = bytes) { public constructor( bytes: ByteArray, + mediaType: MediaType, url: Url? = null, - mediaType: MediaType? = null, properties: Resource.Properties = Resource.Properties() ) : this(url = url, mediaType = mediaType, properties = properties, { Try.success(bytes) }) @@ -78,7 +68,7 @@ public class BytesResource( /** Creates a Resource serving a [String]. */ public class StringResource( url: Url? = null, - mediaType: MediaType? = null, + mediaType: MediaType, properties: Resource.Properties = Resource.Properties(), string: suspend () -> ResourceTry ) : BaseBytesResource( @@ -90,8 +80,8 @@ public class StringResource( public constructor( string: String, + mediaType: MediaType, url: Url? = null, - mediaType: MediaType? = null, 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 aaa0fd99d8..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 @@ -54,8 +54,8 @@ public class ContentResource( override suspend fun properties(): ResourceTry = ResourceTry.success(Resource.Properties()) - override suspend fun mediaType(): ResourceTry = - Try.success(contentResolver.getType(uri)?.let { MediaType.parse(it) }) + override suspend fun mediaType(): ResourceTry = + 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/resource/DefaultArchiveFactory.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/DefaultArchiveFactory.kt index dad5d6def9..e0c2f29d41 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.util.mediatype.MediaTypeRetriever import org.readium.r2.shared.util.toFile -public class DefaultArchiveFactory : ArchiveFactory { +public class DefaultArchiveFactory( + private val mediaTypeRetriever: MediaTypeRetriever +) : 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, mediaTypeRetriever) 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 9b3464a34d..d1d091c7ae 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 @@ -15,17 +15,19 @@ 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.util.Url +import org.readium.r2.shared.util.mediatype.MediaTypeRetriever /** * A file system directory as a [Container]. */ internal class DirectoryContainer( private val root: File, - private val entries: List + private val entries: List, + private val mediaTypeRetriever: MediaTypeRetriever ) : Container { private inner class FileEntry(file: File) : - Container.Entry, Resource by FileResource(file, mediaType = null) { + Container.Entry, Resource by FileResource(file, mediaTypeRetriever) { 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 mediaTypeRetriever: MediaTypeRetriever +) : 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, mediaTypeRetriever) return Try.success(container) } 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 46f0038fb4..f9b500b4b7 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 @@ -17,21 +16,24 @@ 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.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.MediaTypeRetriever /** * 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 mediaTypeRetriever: MediaTypeRetriever? ) : 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, mediaTypeRetriever: MediaTypeRetriever) : this( file, null, mediaTypeRetriever @@ -48,8 +50,14 @@ 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 + ?: mediaTypeRetriever?.retrieve( + hints = MediaTypeHints(fileExtension = file.extension), + content = ResourceMediaTypeSnifferContent(this) + ) + ?: MediaType.BINARY + ) override suspend fun close() { withContext(Dispatchers.IO) { @@ -124,10 +132,12 @@ public class FileResource internal constructor( "${javaClass.simpleName}(${file.path})" } -public class FileResourceFactory : ResourceFactory { +public class FileResourceFactory( + private val mediaTypeRetriever: MediaTypeRetriever +) : 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)) } @@ -141,6 +151,6 @@ public class FileResourceFactory : ResourceFactory { return Try.failure(ResourceFactory.Error.Forbidden(e)) } - return Try.success(FileResource(file, mediaTypeRetriever = null)) + return Try.success(FileResource(file, mediaTypeRetriever)) } } 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/MediaTypeExt.kt b/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt new file mode 100644 index 0000000000..91922234d7 --- /dev/null +++ b/readium/shared/src/main/java/org/readium/r2/shared/resource/MediaTypeExt.kt @@ -0,0 +1,23 @@ +package org.readium.r2.shared.resource + +import org.readium.r2.shared.util.mediatype.ContainerMediaTypeSnifferContent +import org.readium.r2.shared.util.mediatype.ResourceMediaTypeSnifferContent + +public class ResourceMediaTypeSnifferContent( + private val resource: Resource +) : ResourceMediaTypeSnifferContent { + + override suspend fun read(range: LongRange?): ByteArray? = + resource.read(range).getOrNull() +} + +public class ContainerMediaTypeSnifferContent( + private val container: Container +) : ContainerMediaTypeSnifferContent { + + override suspend fun entries(): Set? = + container.entries()?.map { it.path }?.toSet() + + override suspend fun read(path: String, range: LongRange?): ByteArray? = + container.get(path).read(range).getOrNull() +} 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/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/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 307183b550..665f449976 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 @@ -25,22 +25,10 @@ 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.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeHints +import org.readium.r2.shared.util.mediatype.MediaTypeRetriever import org.readium.r2.shared.util.toUrl -/** - * A [Container] representing a Zip archive. - */ -public interface ZipContainer : Container { - - public interface Entry : Container.Entry { - - /** - * Compressed data length. - */ - public val compressedLength: Long? - } -} - /** * Holds information about how the resource is stored in the archive. * @@ -93,17 +81,23 @@ 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 mediaTypeRetriever: MediaTypeRetriever +) : Container { - private inner class FailureEntry(override val path: String) : ZipContainer.Entry { - - override val compressedLength: Long? = null + private inner class FailureEntry(override val path: String) : Container.Entry { override val source: Url? = null - // FIXME: Implement with a sniffer. - override suspend fun mediaType(): ResourceTry = - Try.success(null) + override suspend fun mediaType(): ResourceTry = + Try.success( + mediaTypeRetriever.retrieve( + hints = MediaTypeHints(fileExtension = File(path).extension), + content = ResourceMediaTypeSnifferContent(this) + ) ?: MediaType.BINARY + ) override suspend fun properties(): ResourceTry = Try.failure(Resource.Exception.NotFound()) @@ -118,16 +112,20 @@ internal class JavaZipContainer(private val archive: ZipFile, file: File) : ZipC } } - private inner class Entry(private val entry: ZipEntry) : ZipContainer.Entry { + private inner class Entry(private val entry: ZipEntry) : Container.Entry { override val path: String = entry.name.addPrefix("/") override val source: Url? = null - // FIXME: Implement with a sniffer. - override suspend fun mediaType(): ResourceTry = - Try.success(null) + override suspend fun mediaType(): ResourceTry = + Try.success( + mediaTypeRetriever.retrieve( + hints = MediaTypeHints(fileExtension = File(path).extension), + content = ResourceMediaTypeSnifferContent(this) + ) ?: MediaType.BINARY + ) override suspend fun properties(): ResourceTry = ResourceTry.success( @@ -145,7 +143,7 @@ internal class JavaZipContainer(private val archive: ZipFile, file: File) : ZipC ?.let { Try.success(it) } ?: Try.failure(Resource.Exception.Other(Exception("Unsupported operation"))) - override val compressedLength: Long? = + private val compressedLength: Long? = if (entry.method == ZipEntry.STORED || entry.method == -1) { null } else { 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..6d465f46f5 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,8 +19,8 @@ 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.ResourceMediaTypeSnifferContent import org.readium.r2.shared.resource.ResourceTry -import org.readium.r2.shared.resource.ZipContainer import org.readium.r2.shared.resource.archive import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.archive.channel.compress.archivers.zip.ZipArchiveEntry @@ -28,19 +28,19 @@ 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.MediaTypeRetriever internal class ChannelZipContainer( - private val archive: ZipFile -) : ZipContainer { + private val archive: ZipFile, + private val mediaTypeRetriever: MediaTypeRetriever +) : Container { private inner class FailureEntry( override val path: String - ) : ZipContainer.Entry, Resource by FailureResource(Resource.Exception.NotFound()) { + ) : Container.Entry, Resource by FailureResource(Resource.Exception.NotFound()) - override val compressedLength: Long? = null - } - - private inner class Entry(private val entry: ZipArchiveEntry) : ZipContainer.Entry { + private inner class Entry(private val entry: ZipArchiveEntry) : Container.Entry { override val path: String = entry.name.addPrefix("/") @@ -57,16 +57,20 @@ internal class ChannelZipContainer( } ) - // FIXME: Implement with a sniffer. - override suspend fun mediaType(): ResourceTry = - ResourceTry.success(null) + override suspend fun mediaType(): ResourceTry = + Try.success( + mediaTypeRetriever.retrieve( + hints = MediaTypeHints(fileExtension = File(path).extension), + content = ResourceMediaTypeSnifferContent(this) + ) ?: MediaType.BINARY + ) override suspend fun length(): ResourceTry = entry.size.takeUnless { it == -1L } ?.let { Try.success(it) } ?: Try.failure(Resource.Exception.Other(UnsupportedOperationException())) - override val compressedLength: Long? + private val compressedLength: Long? get() = if (entry.method == ZipArchiveEntry.STORED || entry.method == -1) { null @@ -160,7 +164,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 mediaTypeRetriever: MediaTypeRetriever +) : ArchiveFactory { override suspend fun create( resource: Resource, @@ -174,7 +180,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, mediaTypeRetriever) Try.success(channelZip) } catch (e: Resource.Exception) { Try.failure(ArchiveFactory.Error.ResourceReading(e)) @@ -186,7 +192,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), mediaTypeRetriever) } 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 1130a5d592..1792b07168 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,13 +18,16 @@ 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.BytesResourceMediaTypeSnifferContent import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeHints import org.readium.r2.shared.util.mediatype.MediaTypeRetriever import timber.log.Timber /** * An implementation of [HttpClient] using the native [HttpURLConnection]. * + * @param mediaTypeRetriever 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 @@ -33,6 +36,7 @@ import timber.log.Timber * as the default value, while a timeout of zero as an infinite timeout. */ public class DefaultHttpClient( + private val mediaTypeRetriever: MediaTypeRetriever, private val userAgent: String? = null, private val additionalHeaders: Map = mapOf(), private val connectTimeout: Duration? = null, @@ -112,9 +116,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 = @@ -142,24 +143,21 @@ public class DefaultHttpClient( val body = connection.errorStream?.use { it.readBytes() } val mediaType = body?.let { mediaTypeRetriever.retrieve( - connection = connection, - bytes = { it } + hints = MediaTypeHints(connection), + content = BytesResourceMediaTypeSnifferContent { it } ) } throw HttpException(kind, mediaType, body) } - val mediaType = - mediaTypeRetriever.retrieve( - connection = connection - ) ?: MediaType.BINARY + val mediaType = mediaTypeRetriever.retrieve(MediaTypeHints(connection)) val response = HttpResponse( request = request, url = connection.url.toString(), statusCode = statusCode, headers = connection.safeHeaders, - mediaType = mediaType + mediaType = mediaType ?: MediaType.BINARY ) callback.onResponseReceived(request, response) 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/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..c203773cb2 --- /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.util.mediatype.MediaTypeHints + +public operator fun MediaTypeHints.Companion.invoke( + connection: HttpURLConnection, + mediaType: String? = null +): MediaTypeHints = + MediaTypeHints( + 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/FormatRegistry.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/FormatRegistry.kt new file mode 100644 index 0000000000..79b19dec25 --- /dev/null +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/FormatRegistry.kt @@ -0,0 +1,50 @@ +/* + * 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 + +/** + * Registry of format metadata (e.g. file extension) associated to canonical media types. + */ +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() + + /** + * Registers a new [fileExtension] for the given [mediaType]. + */ + public fun register(mediaType: MediaType, fileExtension: String?) { + if (fileExtension == null) { + fileExtensions.remove(mediaType) + } else { + fileExtensions[mediaType] = fileExtension + } + } + + /** + * Returns the file extension associated to this canonical [mediaType], if any. + */ + public fun fileExtension(mediaType: MediaType): String? = + fileExtensions[mediaType] +} 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..0235a1335a 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,10 +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 org.readium.r2.shared.extensions.tryOrNull +import kotlinx.parcelize.Parcelize /** * Represents a document format, identified by a unique RFC 6838 media type. @@ -29,68 +30,16 @@ 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`. */ +@Parcelize +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 - } +) : Parcelable { /** * Structured syntax suffix, e.g. `+zip` in `application/epub+zip`. @@ -119,13 +68,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 +129,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 +150,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 +215,141 @@ 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_NAVIGATION_FEED: MediaType = MediaType( + "application/atom+xml;profile=opds-catalog;kind=navigation" + )!! + public val OPDS1_ACQUISITION_FEED: MediaType = MediaType( + "application/atom+xml;profile=opds-catalog;kind=acquisition" + )!! 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 +358,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 +375,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 +394,7 @@ public class MediaType( @Suppress("UNUSED_PARAMETER") public fun of( mediaTypes: List, - fileExtensions: List, - sniffers: List = MediaType.sniffers + fileExtensions: List ): MediaType? { TODO() } @@ -470,8 +411,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 +430,7 @@ public class MediaType( public fun ofFile( file: File, mediaTypes: List, - fileExtensions: List, - sniffers: List = MediaType.sniffers + fileExtensions: List ): MediaType? { TODO() } @@ -508,8 +447,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 +466,7 @@ public class MediaType( public fun ofFile( path: String, mediaTypes: List, - fileExtensions: List, - sniffers: List = MediaType.sniffers + fileExtensions: List ): MediaType? { TODO() } @@ -546,8 +483,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 +502,7 @@ public class MediaType( public fun ofBytes( bytes: () -> ByteArray, mediaTypes: List, - fileExtensions: List, - sniffers: List = MediaType.sniffers + fileExtensions: List ): MediaType? { TODO() } @@ -588,8 +523,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 +544,7 @@ public class MediaType( uri: Uri, contentResolver: ContentResolver, mediaTypes: List, - fileExtensions: List, - sniffers: List = MediaType.sniffers + fileExtensions: List ): MediaType? { TODO() } @@ -672,8 +605,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 +613,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 +621,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 +629,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 +638,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 +647,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/MediaTypeHints.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeHints.kt new file mode 100644 index 0000000000..22d905db11 --- /dev/null +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeHints.kt @@ -0,0 +1,77 @@ +/* + * 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 java.nio.charset.Charset + +/** + * Bundle of media type and file extension hints for the [MediaTypeSniffer]. + */ +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 + ) + + /** + * Returns a new [MediaTypeHints] after appending the given [fileExtension] hint. + */ + public fun addFileExtension(fileExtension: String?): MediaTypeHints { + fileExtension ?: return this + return copy(fileExtensions = fileExtensions + fileExtension) + } + + /** 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/MediaTypeRetriever.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeRetriever.kt index 3c49cc7608..8ebc1cb406 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,273 +6,103 @@ 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 - +/** + * Retrieves a canonical [MediaType] for the provided media type and file extension hints and/or + * asset content. + * + * The actual format sniffing is done by the provided [sniffers]. The [defaultSniffers] cover the + * formats supported with Readium by default. + */ 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 sniffers: List = defaultSniffers ) { - 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) + public companion object { + /** + * 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 defaultSniffers: List = listOf( + XhtmlMediaTypeSniffer, + HtmlMediaTypeSniffer, + OpdsMediaTypeSniffer, + LcpLicenseMediaTypeSniffer, + BitmapMediaTypeSniffer, + WebPubManifestMediaTypeSniffer, + WebPubMediaTypeSniffer, + W3cWpubMediaTypeSniffer, + EpubMediaTypeSniffer, + LpfMediaTypeSniffer, + ArchiveMediaTypeSniffer, + PdfMediaTypeSniffer, + JsonMediaTypeSniffer ) } /** - * Resolves a media type from file extension and media type hints without checking the actual - * content. + * Retrieves a canonical [MediaType] for the provided media type and file extension [hints]. */ - public suspend fun retrieve( - mediaTypes: List, - fileExtensions: List - ): MediaType? { - return doRetrieve(null, mediaTypes, fileExtensions) + public fun retrieve(hints: MediaTypeHints): MediaType? { + sniffers.firstNotNullOfOrNull { it.sniffHints(hints) } + ?.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). + SystemMediaTypeSniffer.sniffHints(hints) + ?.let { return it } + + return hints.mediaTypes.firstOrNull() } /** - * Resolves a media type from a local file. + * Retrieves a canonical [MediaType] for the provided [mediaType] and [fileExtension] hints. */ - 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) + public fun retrieve(mediaType: String? = null, fileExtension: String? = null): MediaType? = + retrieve( + MediaTypeHints( + mediaType = mediaType?.let { MediaType(it) }, + fileExtension = fileExtension + ) ) - } /** - * Resolves a media type from bytes, e.g. from an HTTP response. + * Retrieves a canonical [MediaType] for the provided [mediaType] and [fileExtension] hints. */ - public suspend fun retrieve( - bytes: () -> ByteArray, - mediaTypes: List, - fileExtensions: List - ): MediaType? { - return retrieve( - content = Either.Left(bytes), - mediaTypes = mediaTypes, - fileExtensions = fileExtensions - ) - } + public fun retrieve(mediaType: MediaType, fileExtension: String? = null): MediaType = + retrieve(MediaTypeHints(mediaType = mediaType, fileExtension = fileExtension)) ?: mediaType /** - * Resolves a media type from a Uri. + * Retrieves a canonical [MediaType] for the provided [mediaTypes] and [fileExtensions] hints. */ - public suspend fun retrieve( - uri: Uri, - mediaType: String? = null, - fileExtension: String? = null - ): MediaType? { - return retrieve( - uri, - mediaTypes = listOfNotNull(mediaType), - fileExtensions = listOfNotNull(fileExtension) - ) - } + public fun retrieve( + mediaTypes: List = emptyList(), + fileExtensions: List = emptyList() + ): MediaType? = + retrieve(MediaTypeHints(mediaTypes = mediaTypes, fileExtensions = fileExtensions)) /** - * Resolves a media type from a Uri. + * Retrieves a canonical [MediaType] for the provided media type and file extensions [hints] and + * asset [content]. */ 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 + hints: MediaTypeHints = MediaTypeHints(), + content: MediaTypeSnifferContent? = null ): 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 + sniffers.run { + firstNotNullOfOrNull { it.sniffHints(hints) } + ?: content?.let { firstNotNullOfOrNull { it.sniffContent(content) } } + }?.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). + SystemMediaTypeSniffer.run { + sniffHints(hints) ?: content?.let { sniffContent(it) } + }?.let { return it } + + return hints.mediaTypes.firstOrNull() } } 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..63d59d9f66 --- /dev/null +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSniffer.kt @@ -0,0 +1,665 @@ +/* + * 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 + +/** + * Sniffs a [MediaType] from media type and file extension hints or asset content. + */ +public interface MediaTypeSniffer { + + /** + * Sniffs a [MediaType] from media type and file extension hints. + */ + public fun sniffHints(hints: MediaTypeHints): MediaType? = null + + /** + * Sniffs a [MediaType] from an asset [content]. + */ + public suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? = null +} + +/** + * Sniffs an XHTML document. + * + * Must precede the HTML sniffer. + */ +public object XhtmlMediaTypeSniffer : MediaTypeSniffer { + override fun sniffHints(hints: MediaTypeHints): MediaType? { + if ( + hints.hasFileExtension("xht", "xhtml") || + hints.hasMediaType("application/xhtml+xml") + ) { + return MediaType.XHTML + } + + return null + } + + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ResourceMediaTypeSnifferContent) { + return null + } + + content.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 object HtmlMediaTypeSniffer : MediaTypeSniffer { + override fun sniffHints(hints: MediaTypeHints): MediaType? { + if ( + hints.hasFileExtension("htm", "html") || + hints.hasMediaType("text/html") + ) { + return MediaType.HTML + } + + 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 ( + content.contentAsXml()?.name?.lowercase(Locale.ROOT) == "html" || + content.contentAsString()?.trimStart()?.take(15)?.lowercase() == "" + ) { + return MediaType.HTML + } + return null + } +} + +/** Sniffs an OPDS document. */ +public object OpdsMediaTypeSniffer : MediaTypeSniffer { + + override fun sniffHints(hints: MediaTypeHints): MediaType? { + // OPDS 1 + if (hints.hasMediaType("application/atom+xml;type=entry;profile=opds-catalog")) { + return MediaType.OPDS1_ENTRY + } + if (hints.hasMediaType("application/atom+xml;profile=opds-catalog;kind=navigation")) { + return MediaType.OPDS1_NAVIGATION_FEED + } + if (hints.hasMediaType("application/atom+xml;profile=opds-catalog;kind=acquisition")) { + return MediaType.OPDS1_ACQUISITION_FEED + } + if (hints.hasMediaType("application/atom+xml;profile=opds-catalog")) { + return MediaType.OPDS1 + } + + // OPDS 2 + if (hints.hasMediaType("application/opds+json")) { + return MediaType.OPDS2 + } + if (hints.hasMediaType("application/opds-publication+json")) { + return MediaType.OPDS2_PUBLICATION + } + + // OPDS Authentication Document. + if ( + hints.hasMediaType("application/opds-authentication+json") || + hints.hasMediaType("application/vnd.opds.authentication.v1.0+json") + ) { + return MediaType.OPDS_AUTHENTICATION + } + + return null + } + + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ResourceMediaTypeSnifferContent) { + return null + } + + // OPDS 1 + content.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 + content.contentAsRwpm()?.let { rwpm -> + if (rwpm.linkWithRel("self")?.mediaType?.matches("application/opds+json") == true) { + return 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 MediaType.OPDS2_PUBLICATION + } + } + + // OPDS Authentication Document. + if (content.containsJsonKeys("id", "title", "authentication")) { + return MediaType.OPDS_AUTHENTICATION + } + + return null + } +} + +/** Sniffs an LCP License Document. */ +public object LcpLicenseMediaTypeSniffer : MediaTypeSniffer { + override fun sniffHints(hints: MediaTypeHints): MediaType? { + if ( + hints.hasFileExtension("lcpl") || + hints.hasMediaType("application/vnd.readium.lcp.license.v1.0+json") + ) { + return MediaType.LCP_LICENSE_DOCUMENT + } + + return null + } + + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ResourceMediaTypeSnifferContent) { + return null + } + + if (content.containsJsonKeys("id", "issued", "provider", "encryption")) { + return MediaType.LCP_LICENSE_DOCUMENT + } + return null + } +} + +/** Sniffs a bitmap image. */ +public object BitmapMediaTypeSniffer : MediaTypeSniffer { + override fun sniffHints(hints: MediaTypeHints): MediaType? { + if ( + hints.hasFileExtension("avif") || + hints.hasMediaType("image/avif") + ) { + return MediaType.AVIF + } + if ( + hints.hasFileExtension("bmp", "dib") || + hints.hasMediaType("image/bmp", "image/x-bmp") + ) { + return MediaType.BMP + } + if ( + hints.hasFileExtension("gif") || + hints.hasMediaType("image/gif") + ) { + return MediaType.GIF + } + if ( + hints.hasFileExtension("jpg", "jpeg", "jpe", "jif", "jfif", "jfi") || + hints.hasMediaType("image/jpeg") + ) { + return MediaType.JPEG + } + if ( + hints.hasFileExtension("jxl") || + hints.hasMediaType("image/jxl") + ) { + return MediaType.JXL + } + if ( + hints.hasFileExtension("png") || + hints.hasMediaType("image/png") + ) { + return MediaType.PNG + } + if ( + hints.hasFileExtension("tiff", "tif") || + hints.hasMediaType("image/tiff", "image/tiff-fx") + ) { + return MediaType.TIFF + } + if ( + hints.hasFileExtension("webp") || + hints.hasMediaType("image/webp") + ) { + return MediaType.WEBP + } + return null + } +} + +/** 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 (hints.hasMediaType("application/divina+json")) { + return MediaType.DIVINA_MANIFEST + } + + if (hints.hasMediaType("application/webpub+json")) { + return MediaType.READIUM_WEBPUB_MANIFEST + } + + return null + } + + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ResourceMediaTypeSnifferContent) { + return null + } + + val manifest: Manifest = + content.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 object WebPubMediaTypeSniffer : MediaTypeSniffer { + override fun sniffHints(hints: MediaTypeHints): MediaType? { + if ( + hints.hasFileExtension("audiobook") || + hints.hasMediaType("application/audiobook+zip") + ) { + return MediaType.READIUM_AUDIOBOOK + } + + if ( + hints.hasFileExtension("divina") || + hints.hasMediaType("application/divina+zip") + ) { + return MediaType.DIVINA + } + + if ( + hints.hasFileExtension("webpub") || + hints.hasMediaType("application/webpub+zip") + ) { + return MediaType.READIUM_WEBPUB + } + + if ( + hints.hasFileExtension("lcpa") || + hints.hasMediaType("application/audiobook+lcp") + ) { + return MediaType.LCP_PROTECTED_AUDIOBOOK + } + if ( + hints.hasFileExtension("lcpdf") || + hints.hasMediaType("application/pdf+lcp") + ) { + return MediaType.LCP_PROTECTED_PDF + } + + 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 { + content.read("manifest.json") + ?.let { + Manifest.fromJSON(JSONObject(String(it))) + } + } catch (e: Exception) { + null + } + + if (manifest != null) { + val isLcpProtected = content.contains("/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 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 `@content`. + val string = content.contentAsString() ?: "" + if ( + string.contains("@context") && + string.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 object EpubMediaTypeSniffer : MediaTypeSniffer { + override fun sniffHints(hints: MediaTypeHints): MediaType? { + if ( + hints.hasFileExtension("epub") || + hints.hasMediaType("application/epub+zip") + ) { + return MediaType.EPUB + } + + return null + } + + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ContainerMediaTypeSnifferContent) { + return null + } + + val mimetype = content.read("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 object LpfMediaTypeSniffer : MediaTypeSniffer { + override fun sniffHints(hints: MediaTypeHints): MediaType? { + if ( + hints.hasFileExtension("lpf") || + hints.hasMediaType("application/lpf+zip") + ) { + return MediaType.LPF + } + + return null + } + + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ContainerMediaTypeSnifferContent) { + return null + } + + if (content.contains("/index.html")) { + return MediaType.LPF + } + + // 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") + ) { + return MediaType.LPF + } + } + + 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 cbzExtensions = 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 zabExtensions = 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" + ) + + override fun sniffHints(hints: MediaTypeHints): MediaType? { + if ( + hints.hasFileExtension("cbz") || + hints.hasMediaType( + "application/vnd.comicbook+zip", + "application/x-cbz", + "application/x-cbr" + ) + ) { + return MediaType.CBZ + } + if (hints.hasFileExtension("zab")) { + return MediaType.ZAB + } + + 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 = + content.entries()?.all { path -> + val file = File(path) + isIgnored(file) || fileExtensions.contains(file.extension.lowercase(Locale.ROOT)) + } ?: false + + if (archiveContainsOnlyExtensions(cbzExtensions)) { + return MediaType.CBZ + } + if (archiveContainsOnlyExtensions(zabExtensions)) { + return MediaType.ZAB + } + + return null + } +} + +/** + * 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 ( + hints.hasFileExtension("pdf") || + hints.hasMediaType("application/pdf") + ) { + return MediaType.PDF + } + + return null + } + + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ResourceMediaTypeSnifferContent) { + return null + } + + if (content.read(0L until 5L)?.toString(Charsets.UTF_8) == "%PDF-") { + return MediaType.PDF + } + + return 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 + } + + return null + } + + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ResourceMediaTypeSnifferContent) { + return null + } + + if (content.contentAsJson() != null) { + return MediaType.JSON + } + return null + } +} + +/** + * Sniffs the system-wide registered media types using [MimeTypeMap] and + * [URLConnection.guessContentTypeFromStream]. + */ +public object SystemMediaTypeSniffer : MediaTypeSniffer { + + private val mimetypes = tryOrNull { MimeTypeMap.getSingleton() } + + override fun sniffHints(hints: MediaTypeHints): MediaType? { + for (mediaType in hints.mediaTypes) { + return sniffType(mediaType.toString()) ?: continue + } + + for (extension in hints.fileExtensions) { + return sniffExtension(extension) ?: continue + } + + return null + } + + override suspend fun sniffContent(content: MediaTypeSnifferContent): MediaType? { + if (content !is ResourceMediaTypeSnifferContent) { + return null + } + + return withContext(Dispatchers.IO) { + content.contentAsStream() + .let { URLConnection.guessContentTypeFromStream(it) } + ?.let { sniffType(it) } + } + } + + 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/MediaTypeSnifferContent.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContent.kt new file mode 100644 index 0000000000..9f7e359e14 --- /dev/null +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/MediaTypeSnifferContent.kt @@ -0,0 +1,131 @@ +package org.readium.r2.shared.util.mediatype + +import java.io.ByteArrayInputStream +import java.io.InputStream +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.parser.xml.ElementNode +import org.readium.r2.shared.parser.xml.XmlParser +import org.readium.r2.shared.publication.Manifest + +/** + * Provides read access to an asset content. + */ +public sealed interface MediaTypeSnifferContent + +/** + * Provides read access to a resource content. + */ +public interface ResourceMediaTypeSnifferContent : MediaTypeSnifferContent { + + /** + * 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) } + } + } + + /** 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 ResourceMediaTypeSnifferContent.containsJsonKeys(vararg keys: String): Boolean { + val json = contentAsJson() ?: return false + return json.keys().asSequence().toSet().containsAll(keys.toList()) +} + +/** + * Provides read access to a container's resources. + */ +public interface ContainerMediaTypeSnifferContent : MediaTypeSnifferContent { + /** + * 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, range: LongRange? = null): ByteArray? +} + +/** + * Returns whether an entry exists in the container. + */ +public suspend fun ContainerMediaTypeSnifferContent.contains(path: String): Boolean = + entries()?.contains(path) + ?: (read(path, range = 0L..1L) != null) + +/** + * A [ResourceMediaTypeSnifferContent] built from a raw byte array. + */ +public class BytesResourceMediaTypeSnifferContent( + bytes: suspend () -> ByteArray +) : ResourceMediaTypeSnifferContent { + + 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) +} 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 bb16aa2074..0000000000 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/Sniffer.kt +++ /dev/null @@ -1,519 +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 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 dfbda8151b..0000000000 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/mediatype/SnifferContext.kt +++ /dev/null @@ -1,310 +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.parse(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.parse(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) } - ) - } -} 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(MediaTypeRetriever()).supports( Asset.Container( mediaType = mediaType, exploded = false, 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..ff815d84bd 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 @@ -17,7 +17,7 @@ import org.readium.r2.shared.util.mediatype.MediaType class TestContainer(resources: Map = emptyMap()) : Container { private val entries: Map = - resources.mapValues { Entry(it.key, StringResource(it.value)) } + resources.mapValues { Entry(it.key, StringResource(it.value, MediaType.TEXT)) } override suspend fun entries(): Set = entries.values.toSet() @@ -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/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..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 @@ -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) @@ -40,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( @@ -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/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..40544c9e11 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 = """ @@ -180,7 +181,7 @@ class HtmlResourceContentIteratorTest { totalProgressionRange: ClosedRange? = null ): HtmlResourceContentIterator = HtmlResourceContentIterator( - StringResource(html, Url(link.href)), + StringResource(html, MediaType.HTML, Url(link.href)), totalProgressionRange = totalProgressionRange, startLocator ) @@ -407,7 +408,7 @@ class HtmlResourceContentIteratorTest {