Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Refactor Url and Href #388

Merged
merged 24 commits into from
Sep 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
20b5965
Refactor `Url` to support relative URLs
mickael-menu Aug 31, 2023
57d0dcd
Use `Url` and `Href` everywhere
mickael-menu Sep 1, 2023
faf8bd7
Move `Href`
mickael-menu Sep 1, 2023
1360072
Normalize the HREFs of an OPDS 2 manifest
mickael-menu Sep 1, 2023
d7540ed
Normalize remote web publication locators
mickael-menu Sep 1, 2023
3d3d023
Minor fixes
mickael-menu Sep 1, 2023
39ca985
Use `MediaType` in `Locator` instead of a string
mickael-menu Sep 1, 2023
433a430
Ergonomics
mickael-menu Sep 3, 2023
0a661ee
Refactor LCP `Link`
mickael-menu Sep 3, 2023
722283c
Fix regression
mickael-menu Sep 4, 2023
6945501
Add guards to crash when attempting to compare an `Url` to an `Href`
mickael-menu Sep 4, 2023
abc816b
Various changes
mickael-menu Sep 6, 2023
41987a6
Fix regression parsing an EPUB table of contents
mickael-menu Sep 7, 2023
d8bbdd6
Prevent creating `Url` with invalid characters
mickael-menu Sep 11, 2023
ef8d221
Bugfixes for TTS (#390)
mickael-menu Sep 15, 2023
f85d408
Add a download manager (#381)
qnga Sep 15, 2023
4f4fec1
Bugfixes for TTS (#391)
mickael-menu Sep 15, 2023
e09da40
Write licenses into ZIP through shared storage (#389)
qnga Sep 18, 2023
01c4f58
Merge branch 'v3' into refactor-href-url
mickael-menu Sep 18, 2023
dbf09e2
Rename test app
mickael-menu Sep 18, 2023
65c7c80
Address review comments
mickael-menu Sep 18, 2023
708b18c
Refactor Link href resolution
mickael-menu Sep 18, 2023
d505939
Nullable `Url` path
mickael-menu Sep 19, 2023
e840fc0
Improve error reporting
mickael-menu Sep 19, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import org.readium.r2.shared.resource.Resource
import org.readium.r2.shared.util.getOrThrow
import org.readium.r2.shared.util.pdf.PdfDocument
import org.readium.r2.shared.util.pdf.PdfDocumentFactory
import org.readium.r2.shared.util.toFile
import org.readium.r2.shared.util.use
import timber.log.Timber

Expand Down
2 changes: 1 addition & 1 deletion readium/lcp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,14 @@ dependencies {
exclude(module = "support-v4")
}
implementation(libs.joda.time)
implementation("org.zeroturnaround:zt-zip:1.15")
implementation(libs.androidx.browser)

implementation(libs.bundles.room)
ksp(libs.androidx.room.compiler)

// Tests
testImplementation(libs.junit)
testImplementation(libs.kotlin.junit)

androidTestImplementation(libs.androidx.ext.junit)
androidTestImplementation(libs.androidx.expresso.core)
Expand Down
118 changes: 55 additions & 63 deletions readium/lcp/src/main/java/org/readium/r2/lcp/LcpContentProtection.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,13 @@ import org.readium.r2.shared.publication.encryption.encryption
import org.readium.r2.shared.publication.flatten
import org.readium.r2.shared.publication.protection.ContentProtection
import org.readium.r2.shared.publication.services.contentProtectionServiceFactory
import org.readium.r2.shared.resource.ArchiveFactory
import org.readium.r2.shared.resource.Resource
import org.readium.r2.shared.resource.ResourceFactory
import org.readium.r2.shared.resource.TransformingContainer
import org.readium.r2.shared.util.AbsoluteUrl
import org.readium.r2.shared.util.ThrowableError
import org.readium.r2.shared.util.Try
import org.readium.r2.shared.util.Url
import org.readium.r2.shared.util.flatMap
import org.readium.r2.shared.util.getOrElse
import org.readium.r2.shared.util.toFile

internal class LcpContentProtection(
private val lcpService: LcpService,
Expand All @@ -46,7 +43,7 @@ internal class LcpContentProtection(
credentials: String?,
allowUserInteraction: Boolean,
sender: Any?
): Try<ContentProtection.Asset, Publication.OpeningException> {
): Try<ContentProtection.Asset, Publication.OpenError> {
return when (asset) {
is Asset.Container -> openPublication(asset, credentials, allowUserInteraction, sender)
is Asset.Resource -> openLicense(asset, credentials, allowUserInteraction, sender)
Expand All @@ -58,7 +55,7 @@ internal class LcpContentProtection(
credentials: String?,
allowUserInteraction: Boolean,
sender: Any?
): Try<ContentProtection.Asset, Publication.OpeningException> {
): Try<ContentProtection.Asset, Publication.OpenError> {
val license = retrieveLicense(asset, credentials, allowUserInteraction, sender)
return createResultAsset(asset, license)
}
Expand All @@ -73,27 +70,13 @@ internal class LcpContentProtection(
?.let { LcpPassphraseAuthentication(it, fallback = this.authentication) }
?: this.authentication

val file = (asset as? Asset.Resource)?.resource?.source?.toFile()
?: (asset as? Asset.Container)?.container?.source?.toFile()

return file
// This is less restrictive with regard to network availability.
?.let {
lcpService.retrieveLicense(
it,
asset.mediaType,
authentication,
allowUserInteraction,
sender
)
}
?: lcpService.retrieveLicense(asset, authentication, allowUserInteraction, sender)
return lcpService.retrieveLicense(asset, authentication, allowUserInteraction, sender)
}

private fun createResultAsset(
asset: Asset.Container,
license: Try<LcpLicense, LcpException>
): Try<ContentProtection.Asset, Publication.OpeningException> {
): Try<ContentProtection.Asset, Publication.OpenError> {
val serviceFactory = LcpContentProtectionService
.createFactory(license.getOrNull(), license.failureOrNull())

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

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

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

val link = checkNotNull(licenseDoc.link(LicenseDocument.Rel.Publication))
val url = Url(link.url.toString())
val url = (link.url() as? AbsoluteUrl)
?: return Try.failure(
Publication.OpeningException.ParsingFailed(
ThrowableError(
Publication.OpenError.InvalidAsset(
cause = ThrowableError(
LcpException.Parsing.Url(rel = LicenseDocument.Rel.Publication.value)
)
)
)

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

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

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

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

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

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

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

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

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

private class Cache(
var startIndex: Int? = null,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* 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.lcp

import android.content.Context
import java.io.File
import java.util.LinkedList
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import org.readium.r2.shared.util.CoroutineQueue

internal class LcpDownloadsRepository(
context: Context
) {
private val queue = CoroutineQueue()

private val storageDir: Deferred<File> =
queue.scope.async {
withContext(Dispatchers.IO) {
File(context.noBackupFilesDir, LcpDownloadsRepository::class.qualifiedName!!)
.also { if (!it.exists()) it.mkdirs() }
}
}

private val storageFile: Deferred<File> =
queue.scope.async {
withContext(Dispatchers.IO) {
File(storageDir.await(), "licenses.json")
.also { if (!it.exists()) { it.writeText("{}", Charsets.UTF_8) } }
}
}

private val snapshot: Deferred<MutableMap<String, JSONObject>> =
queue.scope.async {
readSnapshot().toMutableMap()
}

fun addDownload(id: String, license: JSONObject) {
queue.scope.launch {
val snapshotCompleted = snapshot.await()
snapshotCompleted[id] = license
writeSnapshot(snapshotCompleted)
}
}

fun removeDownload(id: String) {
queue.launch {
val snapshotCompleted = snapshot.await()
snapshotCompleted.remove(id)
writeSnapshot(snapshotCompleted)
}
}

suspend fun retrieveLicense(id: String): JSONObject? =
queue.await {
snapshot.await()[id]
}

private suspend fun readSnapshot(): Map<String, JSONObject> {
return withContext(Dispatchers.IO) {
storageFile.await().readText(Charsets.UTF_8).toData().toMutableMap()
}
}

private suspend fun writeSnapshot(snapshot: Map<String, JSONObject>) {
val storageFileCompleted = storageFile.await()
withContext(Dispatchers.IO) {
storageFileCompleted.writeText(snapshot.toJson(), Charsets.UTF_8)
}
}

private fun Map<String, JSONObject>.toJson(): String {
val jsonObject = JSONObject()
for ((id, license) in this.entries) {
jsonObject.put(id, license)
}
return jsonObject.toString()
}

private fun String.toData(): Map<String, JSONObject> {
val jsonObject = JSONObject(this)
val names = jsonObject.keys().iterator().toList()
return names.associateWith { jsonObject.getJSONObject(it) }
}

private fun <T> Iterator<T>.toList(): List<T> =
LinkedList<T>().apply {
while (hasNext())
this += next()
}.toMutableList()
}
7 changes: 4 additions & 3 deletions readium/lcp/src/main/java/org/readium/r2/lcp/LcpException.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import androidx.annotation.StringRes
import java.net.SocketTimeoutException
import java.util.*
import org.readium.r2.shared.UserException
import org.readium.r2.shared.util.Url

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

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

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

/** Can't write the file at given relative path in the Container. */
public class WriteFailed(public val path: String) : Container(
public class WriteFailed(public val url: Url?) : Container(
R.string.readium_lcp_exception_container_write_failed
)
}
Expand Down
Loading