From 8b4401c0c77a8329ae720a498ee8c6e6f332bfe3 Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Wed, 16 Aug 2023 10:21:00 +0200 Subject: [PATCH 01/35] Add DownloadManager module --- readium/downloads/build.gradle.kts | 57 +++++ .../downloads/src/main/AndroidManifest.xml | 4 + .../org/readium/downloads/DownloadManager.kt | 109 ++++++++ .../downloads/DownloadManagerProvider.kt | 12 + .../android/AndroidDownloadManager.kt | 166 +++++++++++++ .../android/AndroidDownloadManagerProvider.kt | 32 +++ .../downloads/android/DownloadCursorFacade.kt | 46 ++++ readium/lcp/build.gradle.kts | 2 + .../readium/r2/lcp/LcpPublicationRetriever.kt | 235 ++++++++++++++++++ .../java/org/readium/r2/lcp/LcpService.kt | 18 +- .../readium/r2/lcp/service/LicensesService.kt | 22 +- settings.gradle.kts | 4 + 12 files changed, 702 insertions(+), 5 deletions(-) create mode 100644 readium/downloads/build.gradle.kts create mode 100644 readium/downloads/src/main/AndroidManifest.xml create mode 100644 readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt create mode 100644 readium/downloads/src/main/java/org/readium/downloads/DownloadManagerProvider.kt create mode 100644 readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt create mode 100644 readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManagerProvider.kt create mode 100644 readium/downloads/src/main/java/org/readium/downloads/android/DownloadCursorFacade.kt create mode 100644 readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt diff --git a/readium/downloads/build.gradle.kts b/readium/downloads/build.gradle.kts new file mode 100644 index 0000000000..5d44871a38 --- /dev/null +++ b/readium/downloads/build.gradle.kts @@ -0,0 +1,57 @@ +/* + * 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. + */ + +plugins { + id("com.android.library") + kotlin("android") + kotlin("plugin.parcelize") +} + +android { + resourcePrefix = "readium_" + + compileSdk = 34 + + defaultConfig { + minSdk = 21 + targetSdk = 34 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + freeCompilerArgs = freeCompilerArgs + listOf( + "-opt-in=kotlin.RequiresOptIn", + "-opt-in=org.readium.r2.shared.InternalReadiumApi" + ) + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android.txt")) + } + } + buildFeatures { + viewBinding = true + } + namespace = "org.readium.downloads" +} + +kotlin { + explicitApi() +} + +rootProject.ext["publish.artifactId"] = "readium-downloads" +apply(from = "$rootDir/scripts/publish-module.gradle") + +dependencies { + api(project(":readium:readium-shared")) + + implementation(libs.bundles.coroutines) +} diff --git a/readium/downloads/src/main/AndroidManifest.xml b/readium/downloads/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..a5918e68ab --- /dev/null +++ b/readium/downloads/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt b/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt new file mode 100644 index 0000000000..d27d4d40b5 --- /dev/null +++ b/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt @@ -0,0 +1,109 @@ +/* + * 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.downloads + +import android.net.Uri +import org.readium.r2.shared.error.Error +import org.readium.r2.shared.util.Url + +public interface DownloadManager { + + public data class Request( + val url: Url, + val headers: Map>, + val title: String, + val description: String, + ) + + @JvmInline + public value class RequestId(public val value: Long) + + public sealed class Error : org.readium.r2.shared.error.Error { + + override val cause: org.readium.r2.shared.error.Error? = + null + + public data object NotFound : Error() { + + override val message: String = + "File not found." + } + + public data object Unreachable : Error() { + + override val message: String = + "Server is not reachable." + } + + public data object Server : Error() { + + override val message: String = + "An error occurred on the server-side." + } + + public data object Forbidden : Error() { + + override val message: String = + "Access to the resource was denied" + } + + public data object DeviceNotFound : Error() { + + override val message: String = + "The storage device is missing." + } + + public data object CannotResume : Error() { + + override val message: String = + "Download couldn't be resumed." + } + + public data object InsufficientSpace : Error() { + + override val message: String = + "There is not enough space to complete the download." + } + + public data object FileError : Error() { + + override val message: String = + "IO error on the local device." + } + + public data object HttpData : Error() { + + override val message: String = + "A data error occurred at the HTTP level." + } + + public data object TooManyRedirects : Error() { + + override val message: String = + "Too many redirects." + } + + public data object Unknown : Error() { + + override val message: String = + "An unknown error occurred." + } + } + + public interface Listener { + + public fun onDownloadCompleted(requestId: RequestId, destUri: Uri) + + public fun onDownloadProgressed(requestId: RequestId, downloaded: Long, total: Long) + + public fun onDownloadFailed(requestId: RequestId, error: Error) + } + + public fun submit(request: Request): RequestId + + public fun close() +} diff --git a/readium/downloads/src/main/java/org/readium/downloads/DownloadManagerProvider.kt b/readium/downloads/src/main/java/org/readium/downloads/DownloadManagerProvider.kt new file mode 100644 index 0000000000..d069d00fa7 --- /dev/null +++ b/readium/downloads/src/main/java/org/readium/downloads/DownloadManagerProvider.kt @@ -0,0 +1,12 @@ +/* + * 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.downloads + +public interface DownloadManagerProvider { + + public fun createDownloadManager(listener: DownloadManager.Listener): DownloadManager +} diff --git a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt b/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt new file mode 100644 index 0000000000..a4ddf059d0 --- /dev/null +++ b/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt @@ -0,0 +1,166 @@ +/* + * 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.downloads.android + +import android.app.DownloadManager as SystemDownloadManager +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.util.Log +import java.util.Locale +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.readium.downloads.DownloadManager +import org.readium.r2.shared.units.Hz + +public class AndroidDownloadManager( + private val context: Context, + private val destStorage: Storage, + private val dirType: String, + private val refreshRate: Hz, + private val listener: DownloadManager.Listener +) : DownloadManager { + + public enum class Storage { + App, + Shared; + } + + private val coroutineScope: CoroutineScope = + MainScope() + + private val progressJob: Job = coroutineScope.launch { + while (true) { + val cursor = downloadManager.query(SystemDownloadManager.Query()) + notify(cursor) + delay((1.0 / refreshRate.value).seconds) + } + } + + private val downloadManager: SystemDownloadManager = + context.getSystemService(Context.DOWNLOAD_SERVICE) as SystemDownloadManager + + override fun submit(request: DownloadManager.Request): DownloadManager.RequestId { + val uri = Uri.parse(request.url.toString()) + val filename = filenameForUri(uri.toString()) + val androidRequest = createRequest(uri, filename, request.headers, request.title, request.description) + val downloadId = downloadManager.enqueue(androidRequest) + return DownloadManager.RequestId(downloadId) + } + + private fun filenameForUri(uri: String): String = + uri.substring(uri.lastIndexOf('/') + 1) + .let { it.substring(0, 1).uppercase(Locale.getDefault()) + it.substring(1) } + + private fun createRequest( + uri: Uri, + filename: String, + headers: Map>, + title: String, + description: String + ): SystemDownloadManager.Request = + SystemDownloadManager.Request(uri) + .setNotificationVisibility(SystemDownloadManager.Request.VISIBILITY_VISIBLE) + .setDestination(filename) + .setHeaders(headers) + .setTitle(title) + .setDescription(description) + .setAllowedOverMetered(true) + .setAllowedOverRoaming(true) + + private fun SystemDownloadManager.Request.setHeaders( + headers: Map> + ): SystemDownloadManager.Request { + for (header in headers) { + for (value in header.value) { + addRequestHeader(header.key, value) + } + } + return this + } + + private fun SystemDownloadManager.Request.setDestination( + filename: String + ): SystemDownloadManager.Request { + when (destStorage) { + Storage.App -> + setDestinationInExternalFilesDir(context, dirType, filename) + + Storage.Shared -> + setDestinationInExternalPublicDir(dirType, filename) + } + return this + } + + private fun notify(cursor: Cursor) = cursor.use { + while (cursor.moveToNext()) { + val facade = DownloadCursorFacade(cursor) + + val id = DownloadManager.RequestId(facade.id) + + Log.d("AndroidDownloadManager", "${facade.id} ${facade.localUri}") + + when (facade.status) { + SystemDownloadManager.STATUS_FAILED -> { + listener.onDownloadFailed(id, mapErrorCode(facade.reason!!)) + downloadManager.remove(id.value) + } + SystemDownloadManager.STATUS_PAUSED -> {} + SystemDownloadManager.STATUS_PENDING -> {} + SystemDownloadManager.STATUS_SUCCESSFUL -> { + val destUri = Uri.parse(facade.localUri!!) + listener.onDownloadCompleted(id, destUri) + downloadManager.remove(id.value) + } + SystemDownloadManager.STATUS_RUNNING -> { + val total = facade.total + if (total > 0) { + listener.onDownloadProgressed(id, facade.downloadedSoFar, total) + } + } + } + } + } + + private fun mapErrorCode(code: Int): DownloadManager.Error = + when (code) { + 401, 403 -> + DownloadManager.Error.Forbidden + 404 -> + DownloadManager.Error.NotFound + 500, 501 -> + DownloadManager.Error.Server + 502, 503, 504 -> + DownloadManager.Error.Unreachable + SystemDownloadManager.ERROR_CANNOT_RESUME -> + DownloadManager.Error.CannotResume + SystemDownloadManager.ERROR_DEVICE_NOT_FOUND -> + DownloadManager.Error.DeviceNotFound + SystemDownloadManager.ERROR_FILE_ERROR -> + DownloadManager.Error.FileError + SystemDownloadManager.ERROR_HTTP_DATA_ERROR -> + DownloadManager.Error.HttpData + SystemDownloadManager.ERROR_INSUFFICIENT_SPACE -> + DownloadManager.Error.InsufficientSpace + SystemDownloadManager.ERROR_TOO_MANY_REDIRECTS -> + DownloadManager.Error.TooManyRedirects + SystemDownloadManager.ERROR_UNHANDLED_HTTP_CODE -> + DownloadManager.Error.Unknown + SystemDownloadManager.ERROR_UNKNOWN -> + DownloadManager.Error.Unknown + else -> + DownloadManager.Error.Unknown + } + + public override fun close() { + progressJob.cancel() + } +} diff --git a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManagerProvider.kt b/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManagerProvider.kt new file mode 100644 index 0000000000..d063470409 --- /dev/null +++ b/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManagerProvider.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023 Readium Foundation. All rights reserved. + * Use of this source code is governed by the BSD-style license + * available in the top-level LICENSE file of the project. + */ + +package org.readium.downloads.android + +import android.content.Context +import android.os.Environment +import org.readium.downloads.DownloadManager +import org.readium.downloads.DownloadManagerProvider +import org.readium.r2.shared.units.Hz +import org.readium.r2.shared.units.hz + +public class AndroidDownloadManagerProvider( + private val context: Context, + private val destStorage: AndroidDownloadManager.Storage = AndroidDownloadManager.Storage.App, + private val dirType: String = Environment.DIRECTORY_DOWNLOADS, + private val refreshRate: Hz = 1.0.hz +) : DownloadManagerProvider { + + override fun createDownloadManager(listener: DownloadManager.Listener): DownloadManager { + return AndroidDownloadManager( + context, + destStorage, + dirType, + refreshRate, + listener + ) + } +} diff --git a/readium/downloads/src/main/java/org/readium/downloads/android/DownloadCursorFacade.kt b/readium/downloads/src/main/java/org/readium/downloads/android/DownloadCursorFacade.kt new file mode 100644 index 0000000000..37ee21834d --- /dev/null +++ b/readium/downloads/src/main/java/org/readium/downloads/android/DownloadCursorFacade.kt @@ -0,0 +1,46 @@ +/* + * 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.downloads.android + +import android.app.DownloadManager +import android.database.Cursor + +internal class DownloadCursorFacade( + private val cursor: Cursor +) { + + val id: Long = cursor + .getColumnIndex(DownloadManager.COLUMN_ID) + .also { require(it != -1) } + .let { cursor.getLong(it) } + + val localUri: String? = cursor + .getColumnIndex(DownloadManager.COLUMN_LOCAL_URI) + .also { require(it != -1) } + .let { cursor.getString(it) } + + val status: Int = cursor + .getColumnIndex(DownloadManager.COLUMN_STATUS) + .also { require(it != -1) } + .let { cursor.getInt(it) } + + val total: Long = cursor + .getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) + .also { require(it != -1) } + .let { cursor.getLong(it) } + + val downloadedSoFar: Long = cursor + .getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) + .also { require(it != -1) } + .let { cursor.getLong(it) } + + val reason: Int? = cursor + .getColumnIndex(DownloadManager.COLUMN_REASON) + .also { require(it != -1) } + .takeIf { status == DownloadManager.STATUS_FAILED || status == DownloadManager.STATUS_PAUSED } + ?.let { cursor.getInt(it) } +} diff --git a/readium/lcp/build.gradle.kts b/readium/lcp/build.gradle.kts index 6dc38bd2ec..5112e10b0a 100644 --- a/readium/lcp/build.gradle.kts +++ b/readium/lcp/build.gradle.kts @@ -55,6 +55,7 @@ dependencies { implementation(libs.kotlinx.coroutines.core) api(project(":readium:readium-shared")) + api(project(":readium:readium-downloads")) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.core) @@ -72,6 +73,7 @@ dependencies { implementation(libs.bundles.room) ksp(libs.androidx.room.compiler) + implementation(libs.androidx.datastore.preferences) // Tests testImplementation(libs.junit) diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt new file mode 100644 index 0000000000..1f3f2e12dd --- /dev/null +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -0,0 +1,235 @@ +package org.readium.r2.lcp + +import android.content.Context +import android.net.Uri +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import org.readium.downloads.DownloadManager +import org.readium.downloads.DownloadManagerProvider +import org.readium.r2.lcp.license.container.createLicenseContainer +import org.readium.r2.lcp.license.model.LicenseDocument +import org.readium.r2.shared.error.ThrowableError +import org.readium.r2.shared.error.Try +import org.readium.r2.shared.util.Url +import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.mediatype.MediaTypeRetriever +import timber.log.Timber + +private val Context.dataStore: DataStore by preferencesDataStore(name = "licenses") + +private val licensesKey: Preferences.Key = stringPreferencesKey("licenses") + +public class LcpPublicationRetriever( + private val context: Context, + private val listener: Listener, + private val downloadManagerProvider: DownloadManagerProvider, + private val mediaTypeRetriever: MediaTypeRetriever +) { + + public data class AcquiredPublication( + val localFile: File, + val suggestedFilename: String, + val mediaType: MediaType + ) + + @JvmInline + public value class RequestId(public val value: Long) + + public interface Listener { + + public fun onAcquisitionCompleted( + requestId: RequestId, + acquiredPublication: LcpService.AcquiredPublication + ) + + public fun onAcquisitionProgressed( + requestId: RequestId, + downloaded: Long, + total: Long + ) + + public fun onAcquisitionFailed( + requestId: RequestId, + error: LcpException + ) + } + + private inner class DownloadListener : DownloadManager.Listener { + + private val coroutineScope: CoroutineScope = + MainScope() + + override fun onDownloadCompleted( + requestId: DownloadManager.RequestId, + destUri: Uri + ) { + coroutineScope.launch { + val acquisition = onDownloadCompleted( + requestId.value, + Url(destUri.toString())!! + ) + listener.onAcquisitionCompleted(RequestId(requestId.value), acquisition) + } + } + + override fun onDownloadProgressed( + requestId: DownloadManager.RequestId, + downloaded: Long, + total: Long + ) { + listener.onAcquisitionProgressed( + RequestId(requestId.value), + downloaded, + total + ) + } + + override fun onDownloadFailed( + requestId: DownloadManager.RequestId, + error: DownloadManager.Error + ) { + listener.onAcquisitionFailed( + RequestId(requestId.value), + LcpException.Network(Exception(error.message)) + ) + } + } + + private val downloadManager: DownloadManager = + downloadManagerProvider.createDownloadManager(DownloadListener()) + + private val licenses: Flow> = + context.dataStore.data + .map { data -> data[licensesKey]!! } + .map { json -> json.toLicenseList() } + + public suspend fun retrieve( + license: ByteArray, + downloadTitle: String, + downloadDescription: String + ): Try { + return try { + val licenseDocument = LicenseDocument(license) + Timber.d("license ${licenseDocument.json}") + fetchPublication( + licenseDocument, + downloadTitle, + downloadDescription + ).let { Try.success(it) } + } catch (e: Exception) { + Try.failure(LcpException.wrap(e)) + } + } + + public suspend fun retrieve( + license: File, + downloadTitle: String, + downloadDescription: String + ): Try { + return try { + retrieve(license.readBytes(), downloadTitle, downloadDescription) + } catch (e: Exception) { + Try.failure(LcpException.wrap(e)) + } + } + + private suspend fun fetchPublication( + license: LicenseDocument, + downloadTitle: String, + downloadDescription: String + ): RequestId { + val link = license.link(LicenseDocument.Rel.publication) + val url = link?.url + ?: throw LcpException.Parsing.Url(rel = LicenseDocument.Rel.publication.value) + + val destination = withContext(Dispatchers.IO) { + File.createTempFile("lcp-${System.currentTimeMillis()}", ".tmp") + } + Timber.i("LCP destination $destination") + + val requestId = downloadManager.submit( + DownloadManager.Request( + Url(url.toString())!!, + emptyMap(), + downloadTitle, + downloadDescription + ) + ) + + persistLicense(requestId.value, license.json.toString()) + + return RequestId(requestId.value) + } + + private suspend fun onDownloadCompleted(id: Long, dest: Url): LcpService.AcquiredPublication { + val license = LicenseDocument(licenses.first()[id]!!.toByteArray()) + removeLicense(id) + + val link = license.link(LicenseDocument.Rel.publication)!! + + val mediaType = mediaTypeRetriever.retrieve(mediaType = link.type) + ?: MediaType.EPUB + + val file = File(dest.path) + + // Saves the License Document into the downloaded publication + val container = createLicenseContainer(file, mediaType) + container.write(license) + + return LcpService.AcquiredPublication( + localFile = file, + suggestedFilename = "${license.id}.${mediaType.fileExtension}", + mediaType = mediaType, + licenseDocument = license + ) + } + + private suspend fun persistLicense(id: Long, license: String) { + context.dataStore.edit { data -> + val newEntry = id to licenseToJson(id, license).toString() + val licenses = licenses.first() + newEntry + data[licensesKey] = licenses.toJson() + } + } + + private suspend fun removeLicense(id: Long) { + context.dataStore.edit { data -> + val uris = licenses.first() - id + data[licensesKey] = uris.toJson() + } + } + + private fun licenseToJson(id: Long, license: String): JSONObject = + JSONObject() + .put("id", id) + .put("license", license) + + private fun jsonToLicense(jsonObject: JSONObject): Pair = + jsonObject.getLong("id") to jsonObject.getString("license") + + private fun Map.toJson(): String { + val strings = map { licenseToJson(it.key, it.value) } + val array = JSONArray(strings) + return array.toString() + } + + private fun String.toLicenseList(): Map { + val array = JSONArray(this) + val objects = (0 until array.length()).map { array.getJSONObject(it) } + return objects.associate { jsonToLicense(it) } + } +} 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 c5e758a669..036df6f6f2 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 @@ -9,6 +9,7 @@ package org.readium.r2.lcp +import android.app.DownloadManager import android.content.Context import java.io.File import kotlinx.coroutines.DelicateCoroutinesApi @@ -16,6 +17,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.readium.downloads.DownloadManagerProvider +import org.readium.downloads.android.AndroidDownloadManagerProvider import org.readium.r2.lcp.auth.LcpDialogAuthentication import org.readium.r2.lcp.license.model.LicenseDocument import org.readium.r2.lcp.persistence.LcpDatabase @@ -60,7 +63,10 @@ public interface LcpService { * * @param onProgress Callback to follow the acquisition progress from 0.0 to 1.0. */ - public suspend fun acquirePublication(lcpl: ByteArray, onProgress: (Double) -> Unit = {}): Try + public suspend fun acquirePublication( + lcpl: ByteArray, + onProgress: (Double) -> Unit = {} + ): Try /** * Acquires a protected publication from a standalone LCPL file. @@ -116,6 +122,10 @@ public interface LcpService { sender: Any? ): Try + public fun publicationRetriever( + listener: LcpPublicationRetriever.Listener + ): LcpPublicationRetriever + /** * Creates a [ContentProtection] instance which can be used with a Streamer to unlock * LCP protected publications. @@ -155,7 +165,8 @@ public interface LcpService { context: Context, mediaTypeRetriever: MediaTypeRetriever = MediaTypeRetriever(), resourceFactory: ResourceFactory = FileResourceFactory(), - archiveFactory: ArchiveFactory = DefaultArchiveFactory() + archiveFactory: ArchiveFactory = DefaultArchiveFactory(), + downloadManagerProvider: DownloadManagerProvider = AndroidDownloadManagerProvider(context) ): LcpService? { if (!LcpClient.isAvailable()) return null @@ -177,7 +188,8 @@ public interface LcpService { context = context, mediaTypeRetriever = mediaTypeRetriever, resourceFactory = resourceFactory, - archiveFactory = archiveFactory + archiveFactory = archiveFactory, + downloadManagerProvider = downloadManagerProvider ) } 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 03e88852ad..c4b45a2aa9 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 @@ -19,6 +19,9 @@ import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import org.readium.downloads.DownloadManager +import org.readium.downloads.DownloadManagerProvider +import org.readium.r2.lcp.LcpPublicationRetriever import org.readium.r2.lcp.LcpAuthenticating import org.readium.r2.lcp.LcpContentProtection import org.readium.r2.lcp.LcpException @@ -48,7 +51,8 @@ internal class LicensesService( private val context: Context, private val mediaTypeRetriever: MediaTypeRetriever, private val resourceFactory: ResourceFactory, - private val archiveFactory: ArchiveFactory + private val archiveFactory: ArchiveFactory, + private val downloadManagerProvider: DownloadManagerProvider ) : LcpService, CoroutineScope by MainScope() { override suspend fun isLcpProtected(file: File): Boolean = @@ -75,7 +79,21 @@ internal class LicensesService( ): ContentProtection = LcpContentProtection(this, authentication, mediaTypeRetriever, resourceFactory, archiveFactory) - override suspend fun acquirePublication(lcpl: ByteArray, onProgress: (Double) -> Unit): Try = + override fun publicationRetriever( + listener: LcpPublicationRetriever.Listener + ): LcpPublicationRetriever { + return LcpPublicationRetriever( + context, + listener, + downloadManagerProvider, + mediaTypeRetriever + ) + } + + override suspend fun acquirePublication( + lcpl: ByteArray, + onProgress: (Double) -> Unit + ): Try = try { val licenseDocument = LicenseDocument(lcpl) Timber.d("license ${licenseDocument.json}") diff --git a/settings.gradle.kts b/settings.gradle.kts index f670f64fb9..cfb4a2e829 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -80,6 +80,10 @@ include(":readium:shared") project(":readium:shared") .name = "readium-shared" +include(":readium:downloads") +project(":readium:downloads") + .name = "readium-downloads" + include(":readium:streamer") project(":readium:streamer") .name = "readium-streamer" From 4cf2f22ef69f0e915cd6674343e8821a90febcb5 Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Fri, 18 Aug 2023 12:25:03 +0200 Subject: [PATCH 02/35] Fix multiple DownloadManagers --- readium/downloads/build.gradle.kts | 1 + .../org/readium/downloads/DownloadManager.kt | 4 +- .../downloads/DownloadManagerProvider.kt | 5 +- .../android/AndroidDownloadManager.kt | 20 +++-- .../android/AndroidDownloadManagerProvider.kt | 8 +- .../downloads/android/DownloadsRepository.kt | 83 +++++++++++++++++++ .../readium/r2/lcp/LcpPublicationRetriever.kt | 13 +-- .../java/org/readium/r2/lcp/LcpService.kt | 1 - .../readium/r2/lcp/service/LicensesService.kt | 3 +- .../testapp/{bookshelf => }/BookRepository.kt | 0 10 files changed, 111 insertions(+), 27 deletions(-) create mode 100644 readium/downloads/src/main/java/org/readium/downloads/android/DownloadsRepository.kt rename test-app/src/main/java/org/readium/r2/testapp/{bookshelf => }/BookRepository.kt (100%) diff --git a/readium/downloads/build.gradle.kts b/readium/downloads/build.gradle.kts index 5d44871a38..1ee399de82 100644 --- a/readium/downloads/build.gradle.kts +++ b/readium/downloads/build.gradle.kts @@ -54,4 +54,5 @@ dependencies { api(project(":readium:readium-shared")) implementation(libs.bundles.coroutines) + implementation(libs.androidx.datastore.preferences) } diff --git a/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt b/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt index d27d4d40b5..70632162d2 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt +++ b/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt @@ -103,7 +103,7 @@ public interface DownloadManager { public fun onDownloadFailed(requestId: RequestId, error: Error) } - public fun submit(request: Request): RequestId + public suspend fun submit(request: Request): RequestId - public fun close() + public suspend fun close() } diff --git a/readium/downloads/src/main/java/org/readium/downloads/DownloadManagerProvider.kt b/readium/downloads/src/main/java/org/readium/downloads/DownloadManagerProvider.kt index d069d00fa7..6e6e37d3d5 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/DownloadManagerProvider.kt +++ b/readium/downloads/src/main/java/org/readium/downloads/DownloadManagerProvider.kt @@ -8,5 +8,8 @@ package org.readium.downloads public interface DownloadManagerProvider { - public fun createDownloadManager(listener: DownloadManager.Listener): DownloadManager + public fun createDownloadManager( + listener: DownloadManager.Listener, + name: String = "default" + ): DownloadManager } diff --git a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt b/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt index a4ddf059d0..db2fd09ff4 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt +++ b/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt @@ -10,7 +10,6 @@ import android.app.DownloadManager as SystemDownloadManager import android.content.Context import android.database.Cursor import android.net.Uri -import android.util.Log import java.util.Locale import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope @@ -23,6 +22,7 @@ import org.readium.r2.shared.units.Hz public class AndroidDownloadManager( private val context: Context, + private val name: String, private val destStorage: Storage, private val dirType: String, private val refreshRate: Hz, @@ -39,7 +39,10 @@ public class AndroidDownloadManager( private val progressJob: Job = coroutineScope.launch { while (true) { - val cursor = downloadManager.query(SystemDownloadManager.Query()) + val cursor = downloadManager.query( + SystemDownloadManager.Query() + .setFilterById(*downloadsRepository.idsForName(name).toLongArray()) + ) notify(cursor) delay((1.0 / refreshRate.value).seconds) } @@ -48,11 +51,15 @@ public class AndroidDownloadManager( private val downloadManager: SystemDownloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as SystemDownloadManager - override fun submit(request: DownloadManager.Request): DownloadManager.RequestId { + private val downloadsRepository: DownloadsRepository = + DownloadsRepository(context) + + public override suspend fun submit(request: DownloadManager.Request): DownloadManager.RequestId { val uri = Uri.parse(request.url.toString()) val filename = filenameForUri(uri.toString()) val androidRequest = createRequest(uri, filename, request.headers, request.title, request.description) val downloadId = downloadManager.enqueue(androidRequest) + downloadsRepository.addId(name, downloadId) return DownloadManager.RequestId(downloadId) } @@ -100,14 +107,12 @@ public class AndroidDownloadManager( return this } - private fun notify(cursor: Cursor) = cursor.use { + private suspend fun notify(cursor: Cursor) = cursor.use { while (cursor.moveToNext()) { val facade = DownloadCursorFacade(cursor) val id = DownloadManager.RequestId(facade.id) - Log.d("AndroidDownloadManager", "${facade.id} ${facade.localUri}") - when (facade.status) { SystemDownloadManager.STATUS_FAILED -> { listener.onDownloadFailed(id, mapErrorCode(facade.reason!!)) @@ -119,6 +124,7 @@ public class AndroidDownloadManager( val destUri = Uri.parse(facade.localUri!!) listener.onDownloadCompleted(id, destUri) downloadManager.remove(id.value) + downloadsRepository.removeId(name, id.value) } SystemDownloadManager.STATUS_RUNNING -> { val total = facade.total @@ -160,7 +166,7 @@ public class AndroidDownloadManager( DownloadManager.Error.Unknown } - public override fun close() { + public override suspend fun close() { progressJob.cancel() } } diff --git a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManagerProvider.kt b/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManagerProvider.kt index d063470409..bc001d70f1 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManagerProvider.kt +++ b/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManagerProvider.kt @@ -17,12 +17,16 @@ public class AndroidDownloadManagerProvider( private val context: Context, private val destStorage: AndroidDownloadManager.Storage = AndroidDownloadManager.Storage.App, private val dirType: String = Environment.DIRECTORY_DOWNLOADS, - private val refreshRate: Hz = 1.0.hz + private val refreshRate: Hz = 0.1.hz ) : DownloadManagerProvider { - override fun createDownloadManager(listener: DownloadManager.Listener): DownloadManager { + override fun createDownloadManager( + listener: DownloadManager.Listener, + name: String + ): DownloadManager { return AndroidDownloadManager( context, + name, destStorage, dirType, refreshRate, diff --git a/readium/downloads/src/main/java/org/readium/downloads/android/DownloadsRepository.kt b/readium/downloads/src/main/java/org/readium/downloads/android/DownloadsRepository.kt new file mode 100644 index 0000000000..ab2f51f57a --- /dev/null +++ b/readium/downloads/src/main/java/org/readium/downloads/android/DownloadsRepository.kt @@ -0,0 +1,83 @@ +/* + * 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.downloads.android + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import org.json.JSONArray +import org.json.JSONObject + +private val Context.dataStore: DataStore by preferencesDataStore(name = "readium-downloads-android") + +private val downloadIdsKey: Preferences.Key = stringPreferencesKey("downloadIds") + +internal class DownloadsRepository( + private val context: Context +) { + + private val downloadIds: Flow>> = + context.dataStore.data + .map { data -> data[downloadIdsKey]!! } + .map { string -> string.toData() } + + + public suspend fun addId(name: String, id: Long) { + context.dataStore.edit { data -> + val current = downloadIds.first() + val currentThisName = downloadIds.first()[name].orEmpty() + val newEntryThisName = name to (currentThisName + id) + data[downloadIdsKey] = (current + newEntryThisName).toJson() + } + } + + public suspend fun removeId(name: String, id: Long) { + context.dataStore.edit { data -> + val current = downloadIds.first() + val currentThisName = downloadIds.first()[name].orEmpty() + val newEntryThisName = name to (currentThisName - id) + data[downloadIdsKey] = (current + newEntryThisName).toJson() + } + } + + public suspend fun idsForName(name: String): List { + return downloadIds.first()[name].orEmpty() + } + + private fun Map>.toJson(): String { + val strings = map { idsToJson(it.key, it.value) } + val array = JSONArray(strings) + return array.toString() + } + + private fun String.toData(): Map> { + val array = JSONArray(this) + val objects = (0 until array.length()).map { array.getJSONObject(it) } + return objects.associate { jsonToIds(it) } + } + + private fun idsToJson(name: String, downloads: List): JSONObject = + JSONObject() + .put("name", name) + .put("downloads", JSONArray(downloads)) + + private fun jsonToIds(jsonObject: JSONObject): Pair> { + val name = jsonObject.getString("name") + val downloads = jsonObject.getJSONArray("downloads") + val downloadList = mutableListOf() + for (i in 0 until downloads.length()) { + downloadList.add(downloads.getLong(i)) + } + return name to downloadList + } +} diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index 1f3f2e12dd..306fea3aff 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -29,7 +29,7 @@ import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.mediatype.MediaTypeRetriever import timber.log.Timber -private val Context.dataStore: DataStore by preferencesDataStore(name = "licenses") +private val Context.dataStore: DataStore by preferencesDataStore(name = "readium-lcp-licenses") private val licensesKey: Preferences.Key = stringPreferencesKey("licenses") @@ -40,12 +40,6 @@ public class LcpPublicationRetriever( private val mediaTypeRetriever: MediaTypeRetriever ) { - public data class AcquiredPublication( - val localFile: File, - val suggestedFilename: String, - val mediaType: MediaType - ) - @JvmInline public value class RequestId(public val value: Long) @@ -156,11 +150,6 @@ public class LcpPublicationRetriever( val url = link?.url ?: throw LcpException.Parsing.Url(rel = LicenseDocument.Rel.publication.value) - val destination = withContext(Dispatchers.IO) { - File.createTempFile("lcp-${System.currentTimeMillis()}", ".tmp") - } - Timber.i("LCP destination $destination") - val requestId = downloadManager.submit( DownloadManager.Request( Url(url.toString())!!, 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 036df6f6f2..7a4f4c5d8d 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 @@ -9,7 +9,6 @@ package org.readium.r2.lcp -import android.app.DownloadManager import android.content.Context import java.io.File import kotlinx.coroutines.DelicateCoroutinesApi 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 c4b45a2aa9..2061b5709b 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 @@ -19,13 +19,12 @@ import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import org.readium.downloads.DownloadManager import org.readium.downloads.DownloadManagerProvider -import org.readium.r2.lcp.LcpPublicationRetriever import org.readium.r2.lcp.LcpAuthenticating import org.readium.r2.lcp.LcpContentProtection import org.readium.r2.lcp.LcpException import org.readium.r2.lcp.LcpLicense +import org.readium.r2.lcp.LcpPublicationRetriever import org.readium.r2.lcp.LcpService import org.readium.r2.lcp.license.License import org.readium.r2.lcp.license.LicenseValidation diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/BookRepository.kt similarity index 100% rename from test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt rename to test-app/src/main/java/org/readium/r2/testapp/BookRepository.kt From 7feeb87fb914cfe49c60e277e614ffd6d94375b8 Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Fri, 18 Aug 2023 12:47:14 +0200 Subject: [PATCH 03/35] Move import stuff to BookImporter --- .../org/readium/r2/testapp/Application.kt | 27 +- .../org/readium/r2/testapp/BookImporter.kt | 265 ++++++++++++++++++ .../org/readium/r2/testapp/BookRepository.kt | 253 +---------------- .../testapp/bookshelf/BookshelfViewModel.kt | 12 +- .../r2/testapp/catalogs/CatalogViewModel.kt | 2 +- .../r2/testapp/reader/ReaderRepository.kt | 2 +- .../r2/testapp/reader/ReaderViewModel.kt | 2 +- 7 files changed, 292 insertions(+), 271 deletions(-) create mode 100644 test-app/src/main/java/org/readium/r2/testapp/BookImporter.kt diff --git a/test-app/src/main/java/org/readium/r2/testapp/Application.kt b/test-app/src/main/java/org/readium/r2/testapp/Application.kt index 1be7df37e2..a445312fb0 100755 --- a/test-app/src/main/java/org/readium/r2/testapp/Application.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Application.kt @@ -15,7 +15,6 @@ import java.io.File import java.util.* import kotlinx.coroutines.* import org.readium.r2.testapp.BuildConfig.DEBUG -import org.readium.r2.testapp.bookshelf.BookRepository import org.readium.r2.testapp.db.BookDatabase import org.readium.r2.testapp.reader.ReaderRepository import timber.log.Timber @@ -30,6 +29,9 @@ class Application : android.app.Application() { lateinit var bookRepository: BookRepository private set + lateinit var bookImporter: BookImporter + private set + lateinit var readerRepository: Deferred private set @@ -53,17 +55,18 @@ class Application : android.app.Application() { */ bookRepository = BookDatabase.getDatabase(this).booksDao() - .let { dao -> - BookRepository( - applicationContext, - dao, - storageDir, - readium.lcpService, - readium.publicationFactory, - readium.assetRetriever, - readium.protectionRetriever, - ) - } + .let { dao -> BookRepository(dao) } + + bookImporter = + BookImporter( + applicationContext, + bookRepository, + storageDir, + readium.lcpService, + readium.publicationFactory, + readium.assetRetriever, + readium.protectionRetriever, + ) readerRepository = coroutineScope.async { diff --git a/test-app/src/main/java/org/readium/r2/testapp/BookImporter.kt b/test-app/src/main/java/org/readium/r2/testapp/BookImporter.kt new file mode 100644 index 0000000000..68a5b8667b --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/BookImporter.kt @@ -0,0 +1,265 @@ +/* + * 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.testapp + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.annotation.StringRes +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.readium.r2.lcp.LcpService +import org.readium.r2.shared.UserException +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.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.protection.ContentProtectionSchemeRetriever +import org.readium.r2.shared.publication.services.cover +import org.readium.r2.shared.util.Url +import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.shared.util.toUrl +import org.readium.r2.streamer.PublicationFactory +import org.readium.r2.testapp.utils.extensions.copyToTempFile +import org.readium.r2.testapp.utils.extensions.moveTo +import org.readium.r2.testapp.utils.tryOrNull +import timber.log.Timber + +class BookImporter( + private val context: Context, + private val bookRepository: BookRepository, + private val storageDir: File, + private val lcpService: Try, + private val publicationFactory: PublicationFactory, + private val assetRetriever: AssetRetriever, + private val protectionRetriever: ContentProtectionSchemeRetriever, +) { + + private val coverDir: File = + File(storageDir, "covers/") + .apply { if (!exists()) mkdirs() } + + sealed class ImportError( + content: Content, + cause: Exception? + ) : UserException(content, cause) { + + constructor(@StringRes userMessageId: Int) : + this(Content(userMessageId), null) + + constructor(cause: UserException) : + this(Content(cause), cause) + + class LcpAcquisitionFailed( + override val cause: UserException + ) : ImportError(cause) + + class PublicationError( + override val cause: UserException + ) : ImportError(cause) { + + companion object { + + operator fun invoke( + error: AssetRetriever.Error + ): ImportError = PublicationError(org.readium.r2.testapp.PublicationError(error)) + + operator fun invoke( + error: Publication.OpeningException + ): ImportError = PublicationError(org.readium.r2.testapp.PublicationError(error)) + } + } + + class ImportBookFailed( + override val cause: Throwable + ) : ImportError(R.string.import_publication_unexpected_io_exception) + + class ImportDatabaseFailed : + ImportError(R.string.import_publication_unable_add_pub_database) + } + + suspend fun importBook( + contentUri: Uri + ): Try = + contentUri.copyToTempFile(context, storageDir) + .mapFailure { ImportError.ImportBookFailed(it) } + .flatMap { addLocalBook(it) } + + suspend fun addRemoteBook( + url: Url + ): Try { + val asset = assetRetriever.retrieve(url, fileExtension = url.extension) + ?: return Try.failure( + ImportError.PublicationError( + PublicationError.UnsupportedPublication(Publication.OpeningException.UnsupportedAsset()) + ) + ) + return addBook(url, asset) + } + + suspend fun addSharedStorageBook( + url: Url, + coverUrl: String? = null, + ): Try { + val asset = assetRetriever.retrieve(url) + ?: return Try.failure( + ImportError.PublicationError( + PublicationError.UnsupportedPublication( + Publication.OpeningException.UnsupportedAsset("Unsupported media type") + ) + ) + ) + + return addBook(url, asset, coverUrl) + } + + suspend fun addLocalBook( + tempFile: File, + coverUrl: String? = null, + ): Try { + val sourceAsset = assetRetriever.retrieve(tempFile) + ?: return Try.failure( + ImportError.PublicationError( + PublicationError.UnsupportedPublication(Publication.OpeningException.UnsupportedAsset()) + ) + ) + + val (publicationTempFile, publicationTempAsset) = + if (sourceAsset.mediaType != MediaType.LCP_LICENSE_DOCUMENT) { + tempFile to sourceAsset + } else { + lcpService + .flatMap { + sourceAsset.close() + it.acquirePublication(tempFile) + } + .fold( + { + val file = it.localFile + val asset = assetRetriever.retrieve( + file.toUrl(), + assetType = AssetType.Archive, + mediaType = it.mediaType + ).getOrElse { error -> + return Try.failure(ImportError.PublicationError(error)) + } + file to asset + }, + { + tryOrNull { tempFile.delete() } + return Try.failure(ImportError.LcpAcquisitionFailed(it)) + } + ) + } + + val fileName = "${UUID.randomUUID()}.${publicationTempAsset.mediaType.fileExtension}" + val libraryFile = File(storageDir, fileName) + val libraryUrl = libraryFile.toUrl() + + try { + publicationTempFile.moveTo(libraryFile) + } catch (e: Exception) { + Timber.d(e) + tryOrNull { libraryFile.delete() } + return Try.failure(ImportError.ImportBookFailed(e)) + } + + val libraryAsset = assetRetriever.retrieve( + libraryUrl, + publicationTempAsset.mediaType, + publicationTempAsset.assetType + ).getOrElse { return Try.failure(ImportError.PublicationError(it)) } + + return addBook( + libraryUrl, libraryAsset, coverUrl + ).onFailure { + tryOrNull { libraryFile.delete() } + } + } + + private suspend fun addBook( + url: Url, + asset: Asset, + coverUrl: String? = null, + ): Try { + val drmScheme = + protectionRetriever.retrieve(asset) + + publicationFactory.open( + asset, + contentProtectionScheme = drmScheme, + allowUserInteraction = false + ).onSuccess { publication -> + val coverBitmap: Bitmap? = coverUrl + ?.let { getBitmapFromURL(it) } + ?: publication.cover() + val coverFile = + try { + storeCover(coverBitmap) + } catch (e: Exception) { + return Try.failure(ImportError.ImportBookFailed(e)) + } + + val id = bookRepository.insertBookIntoDatabase( + url.toString(), + asset.mediaType, + asset.assetType, + drmScheme, + publication, + coverFile.path + ) + if (id == -1L) { + coverFile.delete() + return Try.failure(ImportError.ImportDatabaseFailed()) + } + } + .onFailure { + Timber.d("Cannot open publication: $it.") + return Try.failure( + ImportError.PublicationError(PublicationError(it)) + ) + } + + return Try.success(Unit) + } + + private suspend fun storeCover(cover: Bitmap?): File = + withContext(Dispatchers.IO) { + val coverImageFile = File(coverDir, "${UUID.randomUUID()}.png") + val resized = cover?.let { Bitmap.createScaledBitmap(it, 120, 200, true) } + val fos = FileOutputStream(coverImageFile) + resized?.compress(Bitmap.CompressFormat.PNG, 80, fos) + fos.flush() + fos.close() + coverImageFile + } + + private suspend fun getBitmapFromURL(src: String): Bitmap? = + withContext(Dispatchers.IO) { + try { + val url = URL(src) + val connection = url.openConnection() as HttpURLConnection + connection.doInput = true + connection.connect() + val input = connection.inputStream + BitmapFactory.decodeStream(input) + } catch (e: IOException) { + e.printStackTrace() + null + } + } +} diff --git a/test-app/src/main/java/org/readium/r2/testapp/BookRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/BookRepository.kt index a57260ebed..420ad9efa5 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/BookRepository.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/BookRepository.kt @@ -4,69 +4,30 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.r2.testapp.bookshelf +package org.readium.r2.testapp -import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.net.Uri import androidx.annotation.ColorInt -import androidx.annotation.StringRes import androidx.lifecycle.LiveData import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.net.HttpURLConnection -import java.net.URL -import java.util.* -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.withContext import org.joda.time.DateTime -import org.readium.r2.lcp.LcpService -import org.readium.r2.shared.UserException -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.Try -import org.readium.r2.shared.error.flatMap -import org.readium.r2.shared.error.getOrElse import org.readium.r2.shared.publication.Locator import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.publication.indexOfFirstWithHref import org.readium.r2.shared.publication.protection.ContentProtection -import org.readium.r2.shared.publication.protection.ContentProtectionSchemeRetriever -import org.readium.r2.shared.publication.services.cover import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.mediatype.MediaType -import org.readium.r2.shared.util.toUrl -import org.readium.r2.streamer.PublicationFactory -import org.readium.r2.testapp.PublicationError -import org.readium.r2.testapp.R import org.readium.r2.testapp.db.BooksDao import org.readium.r2.testapp.domain.model.Book import org.readium.r2.testapp.domain.model.Bookmark import org.readium.r2.testapp.domain.model.Highlight import org.readium.r2.testapp.utils.extensions.authorName -import org.readium.r2.testapp.utils.extensions.copyToTempFile -import org.readium.r2.testapp.utils.extensions.moveTo import org.readium.r2.testapp.utils.tryOrLog -import org.readium.r2.testapp.utils.tryOrNull -import timber.log.Timber class BookRepository( - private val context: Context, private val booksDao: BooksDao, - private val storageDir: File, - private val lcpService: Try, - private val publicationFactory: PublicationFactory, - private val assetRetriever: AssetRetriever, - private val protectionRetriever: ContentProtectionSchemeRetriever, ) { - private val coverDir: File = - File(storageDir, "covers/") - .apply { if (!exists()) mkdirs() } - fun books(): LiveData> = booksDao.getAllBooks() suspend fun get(id: Long) = booksDao.get(id) @@ -121,7 +82,7 @@ class BookRepository( booksDao.updateHighlightStyle(id, style, tint) } - private suspend fun insertBookIntoDatabase( + suspend fun insertBookIntoDatabase( href: String, mediaType: MediaType, assetType: AssetType, @@ -147,216 +108,6 @@ class BookRepository( private suspend fun deleteBookFromDatabase(id: Long) = booksDao.deleteBook(id) - sealed class ImportError( - content: Content, - cause: Exception? - ) : UserException(content, cause) { - - constructor(@StringRes userMessageId: Int) : - this(Content(userMessageId), null) - - constructor(cause: UserException) : - this(Content(cause), cause) - - class LcpAcquisitionFailed( - override val cause: UserException - ) : ImportError(cause) - - class PublicationError( - override val cause: UserException - ) : ImportError(cause) { - - companion object { - - operator fun invoke( - error: AssetRetriever.Error - ): ImportError = PublicationError(org.readium.r2.testapp.PublicationError(error)) - - operator fun invoke( - error: Publication.OpeningException - ): ImportError = PublicationError(org.readium.r2.testapp.PublicationError(error)) - } - } - - class ImportBookFailed( - override val cause: Throwable - ) : ImportError(R.string.import_publication_unexpected_io_exception) - - class ImportDatabaseFailed : - ImportError(R.string.import_publication_unable_add_pub_database) - } - - suspend fun importBook( - contentUri: Uri - ): Try = - contentUri.copyToTempFile(context, storageDir) - .mapFailure { ImportError.ImportBookFailed(it) } - .flatMap { addLocalBook(it) } - - suspend fun addRemoteBook( - url: Url - ): Try { - val asset = assetRetriever.retrieve(url, fileExtension = url.extension) - ?: return Try.failure( - ImportError.PublicationError( - PublicationError.UnsupportedPublication(Publication.OpeningException.UnsupportedAsset()) - ) - ) - return addBook(url, asset) - } - - suspend fun addSharedStorageBook( - url: Url, - coverUrl: String? = null, - ): Try { - val asset = assetRetriever.retrieve(url) - ?: return Try.failure( - ImportError.PublicationError( - PublicationError.UnsupportedPublication( - Publication.OpeningException.UnsupportedAsset("Unsupported media type") - ) - ) - ) - - return addBook(url, asset, coverUrl) - } - - suspend fun addLocalBook( - tempFile: File, - coverUrl: String? = null, - ): Try { - val sourceAsset = assetRetriever.retrieve(tempFile) - ?: return Try.failure( - ImportError.PublicationError( - PublicationError.UnsupportedPublication(Publication.OpeningException.UnsupportedAsset()) - ) - ) - - val (publicationTempFile, publicationTempAsset) = - if (sourceAsset.mediaType != MediaType.LCP_LICENSE_DOCUMENT) { - tempFile to sourceAsset - } else { - lcpService - .flatMap { - sourceAsset.close() - it.acquirePublication(tempFile) - } - .fold( - { - val file = it.localFile - val asset = assetRetriever.retrieve( - file.toUrl(), - assetType = AssetType.Archive, - mediaType = it.mediaType - ).getOrElse { error -> - return Try.failure(ImportError.PublicationError(error)) - } - file to asset - }, - { - tryOrNull { tempFile.delete() } - return Try.failure(ImportError.LcpAcquisitionFailed(it)) - } - ) - } - - val fileName = "${UUID.randomUUID()}.${publicationTempAsset.mediaType.fileExtension}" - val libraryFile = File(storageDir, fileName) - val libraryUrl = libraryFile.toUrl() - - try { - publicationTempFile.moveTo(libraryFile) - } catch (e: Exception) { - Timber.d(e) - tryOrNull { libraryFile.delete() } - return Try.failure(ImportError.ImportBookFailed(e)) - } - - val libraryAsset = assetRetriever.retrieve( - libraryUrl, - publicationTempAsset.mediaType, - publicationTempAsset.assetType - ).getOrElse { return Try.failure(ImportError.PublicationError(it)) } - - return addBook( - libraryUrl, libraryAsset, coverUrl - ).onFailure { - tryOrNull { libraryFile.delete() } - } - } - - private suspend fun addBook( - url: Url, - asset: Asset, - coverUrl: String? = null, - ): Try { - val drmScheme = - protectionRetriever.retrieve(asset) - - publicationFactory.open( - asset, - contentProtectionScheme = drmScheme, - allowUserInteraction = false - ).onSuccess { publication -> - val coverBitmap: Bitmap? = coverUrl - ?.let { getBitmapFromURL(it) } - ?: publication.cover() - val coverFile = - try { - storeCover(coverBitmap) - } catch (e: Exception) { - return Try.failure(ImportError.ImportBookFailed(e)) - } - - val id = insertBookIntoDatabase( - url.toString(), - asset.mediaType, - asset.assetType, - drmScheme, - publication, - coverFile.path - ) - if (id == -1L) { - coverFile.delete() - return Try.failure(ImportError.ImportDatabaseFailed()) - } - } - .onFailure { - Timber.d("Cannot open publication: $it.") - return Try.failure( - ImportError.PublicationError(PublicationError(it)) - ) - } - - return Try.success(Unit) - } - - private suspend fun storeCover(cover: Bitmap?): File = - withContext(Dispatchers.IO) { - val coverImageFile = File(coverDir, "${UUID.randomUUID()}.png") - val resized = cover?.let { Bitmap.createScaledBitmap(it, 120, 200, true) } - val fos = FileOutputStream(coverImageFile) - resized?.compress(Bitmap.CompressFormat.PNG, 80, fos) - fos.flush() - fos.close() - coverImageFile - } - - private suspend fun getBitmapFromURL(src: String): Bitmap? = - withContext(Dispatchers.IO) { - try { - val url = URL(src) - val connection = url.openConnection() as HttpURLConnection - connection.doInput = true - connection.connect() - val input = connection.inputStream - BitmapFactory.decodeStream(input) - } catch (e: IOException) { - e.printStackTrace() - null - } - } - suspend fun deleteBook(book: Book) { val id = book.id!! val url = Url(book.href)!! diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt index ebf3ef591b..7bbfb11ef5 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt @@ -17,6 +17,8 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.readium.r2.shared.util.Url +import org.readium.r2.testapp.BookImporter +import org.readium.r2.testapp.BookRepository import org.readium.r2.testapp.BuildConfig import org.readium.r2.testapp.domain.model.Book import org.readium.r2.testapp.reader.ReaderActivityContract @@ -50,7 +52,7 @@ class BookshelfViewModel(application: Application) : AndroidViewModel(applicatio val file = app.assets.open("Samples/$element").copyToTempFile(app.storageDir) if (file != null) - app.bookRepository.addLocalBook(file) + app.bookImporter.addLocalBook(file) else if (BuildConfig.DEBUG) error("Unable to load sample into the library") } @@ -66,7 +68,7 @@ class BookshelfViewModel(application: Application) : AndroidViewModel(applicatio fun importPublicationFromUri(uri: Uri) = viewModelScope.launch { - app.bookRepository + app.bookImporter .importBook(uri) .failureOrNull() .let { sendImportFeedback(it) } @@ -74,7 +76,7 @@ class BookshelfViewModel(application: Application) : AndroidViewModel(applicatio fun addSharedStoragePublication(uri: Uri) = viewModelScope.launch { - app.bookRepository + app.bookImporter .addSharedStorageBook(Url(uri.toString())!!) .failureOrNull() .let { sendImportFeedback(it) } @@ -82,14 +84,14 @@ class BookshelfViewModel(application: Application) : AndroidViewModel(applicatio fun addRemotePublication(url: Url) { viewModelScope.launch { - val exception = app.bookRepository + val exception = app.bookImporter .addRemoteBook(url) .failureOrNull() sendImportFeedback(exception) } } - private fun sendImportFeedback(error: BookRepository.ImportError?) { + private fun sendImportFeedback(error: BookImporter.ImportError?) { if (error == null) { channel.send(Event.ImportPublicationSuccess) } else { diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt index 7aa15b19fb..3d8375a785 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt @@ -70,7 +70,7 @@ class CatalogViewModel(application: Application) : AndroidViewModel(application) url.downloadTo(dest) }.flatMap { val opdsCover = publication.images.firstOrNull()?.href - app.bookRepository.addLocalBook(dest, opdsCover) + app.bookImporter.addLocalBook(dest, opdsCover) }.onSuccess { detailChannel.send(Event.DetailEvent.ImportPublicationSuccess) }.onFailure { diff --git a/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt index 4e3295baec..73a02c3b34 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt @@ -29,7 +29,7 @@ import org.readium.r2.shared.publication.services.isRestricted import org.readium.r2.shared.util.Url import org.readium.r2.testapp.PublicationError import org.readium.r2.testapp.Readium -import org.readium.r2.testapp.bookshelf.BookRepository +import org.readium.r2.testapp.BookRepository import org.readium.r2.testapp.reader.preferences.AndroidTtsPreferencesManagerFactory import org.readium.r2.testapp.reader.preferences.EpubPreferencesManagerFactory import org.readium.r2.testapp.reader.preferences.ExoPlayerPreferencesManagerFactory diff --git a/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderViewModel.kt index 30c0f9149f..1133101bc3 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderViewModel.kt @@ -30,7 +30,7 @@ import org.readium.r2.shared.publication.services.search.SearchIterator import org.readium.r2.shared.publication.services.search.SearchTry import org.readium.r2.shared.publication.services.search.search import org.readium.r2.testapp.Application -import org.readium.r2.testapp.bookshelf.BookRepository +import org.readium.r2.testapp.BookRepository import org.readium.r2.testapp.domain.model.Highlight import org.readium.r2.testapp.reader.preferences.UserPreferencesViewModel import org.readium.r2.testapp.reader.tts.TtsViewModel From be4ad76778c273607c40caf83a892eac134153af Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Fri, 18 Aug 2023 15:10:30 +0200 Subject: [PATCH 04/35] Refactor importation code --- .../downloads/android/DownloadsRepository.kt | 11 +-- .../readium/r2/lcp/LcpPublicationRetriever.kt | 9 +- .../org/readium/r2/testapp/Application.kt | 6 +- .../testapp/{BookImporter.kt => Bookshelf.kt} | 98 ++++++++++++++++--- .../testapp/bookshelf/BookshelfViewModel.kt | 41 ++++---- .../r2/testapp/catalogs/CatalogViewModel.kt | 56 +++++------ .../catalogs/PublicationDetailFragment.kt | 1 - .../r2/testapp/reader/ReaderRepository.kt | 2 +- 8 files changed, 139 insertions(+), 85 deletions(-) rename test-app/src/main/java/org/readium/r2/testapp/{BookImporter.kt => Bookshelf.kt} (74%) diff --git a/readium/downloads/src/main/java/org/readium/downloads/android/DownloadsRepository.kt b/readium/downloads/src/main/java/org/readium/downloads/android/DownloadsRepository.kt index ab2f51f57a..b292df2c32 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/android/DownloadsRepository.kt +++ b/readium/downloads/src/main/java/org/readium/downloads/android/DownloadsRepository.kt @@ -31,26 +31,25 @@ internal class DownloadsRepository( .map { data -> data[downloadIdsKey]!! } .map { string -> string.toData() } - - public suspend fun addId(name: String, id: Long) { + suspend fun addId(name: String, id: Long) { context.dataStore.edit { data -> val current = downloadIds.first() val currentThisName = downloadIds.first()[name].orEmpty() - val newEntryThisName = name to (currentThisName + id) + val newEntryThisName = name to (currentThisName + id) data[downloadIdsKey] = (current + newEntryThisName).toJson() } } - public suspend fun removeId(name: String, id: Long) { + suspend fun removeId(name: String, id: Long) { context.dataStore.edit { data -> val current = downloadIds.first() val currentThisName = downloadIds.first()[name].orEmpty() - val newEntryThisName = name to (currentThisName - id) + val newEntryThisName = name to (currentThisName - id) data[downloadIdsKey] = (current + newEntryThisName).toJson() } } - public suspend fun idsForName(name: String): List { + suspend fun idsForName(name: String): List { return downloadIds.first()[name].orEmpty() } diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index 306fea3aff..b8cf698d73 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -9,20 +9,17 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import java.io.File import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject import org.readium.downloads.DownloadManager import org.readium.downloads.DownloadManagerProvider import org.readium.r2.lcp.license.container.createLicenseContainer import org.readium.r2.lcp.license.model.LicenseDocument -import org.readium.r2.shared.error.ThrowableError import org.readium.r2.shared.error.Try import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.mediatype.MediaType @@ -163,7 +160,7 @@ public class LcpPublicationRetriever( return RequestId(requestId.value) } - + private suspend fun onDownloadCompleted(id: Long, dest: Url): LcpService.AcquiredPublication { val license = LicenseDocument(licenses.first()[id]!!.toByteArray()) removeLicense(id) @@ -190,14 +187,14 @@ public class LcpPublicationRetriever( private suspend fun persistLicense(id: Long, license: String) { context.dataStore.edit { data -> val newEntry = id to licenseToJson(id, license).toString() - val licenses = licenses.first() + newEntry + val licenses = licenses.first() + newEntry data[licensesKey] = licenses.toJson() } } private suspend fun removeLicense(id: Long) { context.dataStore.edit { data -> - val uris = licenses.first() - id + val uris = licenses.first() - id data[licensesKey] = uris.toJson() } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/Application.kt b/test-app/src/main/java/org/readium/r2/testapp/Application.kt index a445312fb0..605be18bb7 100755 --- a/test-app/src/main/java/org/readium/r2/testapp/Application.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Application.kt @@ -29,7 +29,7 @@ class Application : android.app.Application() { lateinit var bookRepository: BookRepository private set - lateinit var bookImporter: BookImporter + lateinit var bookshelf: Bookshelf private set lateinit var readerRepository: Deferred @@ -57,8 +57,8 @@ class Application : android.app.Application() { BookDatabase.getDatabase(this).booksDao() .let { dao -> BookRepository(dao) } - bookImporter = - BookImporter( + bookshelf = + Bookshelf( applicationContext, bookRepository, storageDir, diff --git a/test-app/src/main/java/org/readium/r2/testapp/BookImporter.kt b/test-app/src/main/java/org/readium/r2/testapp/Bookshelf.kt similarity index 74% rename from test-app/src/main/java/org/readium/r2/testapp/BookImporter.kt rename to test-app/src/main/java/org/readium/r2/testapp/Bookshelf.kt index 68a5b8667b..353c3f332e 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/BookImporter.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Bookshelf.kt @@ -11,6 +11,7 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri import androidx.annotation.StringRes +import androidx.lifecycle.viewModelScope import java.io.File import java.io.FileOutputStream import java.io.IOException @@ -18,6 +19,9 @@ import java.net.HttpURLConnection import java.net.URL import java.util.UUID import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.readium.r2.lcp.LcpService import org.readium.r2.shared.UserException @@ -28,18 +32,21 @@ 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.opds.images import org.readium.r2.shared.publication.protection.ContentProtectionSchemeRetriever import org.readium.r2.shared.publication.services.cover import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.toUrl import org.readium.r2.streamer.PublicationFactory +import org.readium.r2.testapp.catalogs.CatalogViewModel import org.readium.r2.testapp.utils.extensions.copyToTempFile +import org.readium.r2.testapp.utils.extensions.downloadTo import org.readium.r2.testapp.utils.extensions.moveTo import org.readium.r2.testapp.utils.tryOrNull import timber.log.Timber -class BookImporter( +class Bookshelf( private val context: Context, private val bookRepository: BookRepository, private val storageDir: File, @@ -92,39 +99,95 @@ class BookImporter( ImportError(R.string.import_publication_unable_add_pub_database) } + sealed class Event { + object ImportPublicationSuccess : + Event() + + class ImportPublicationError( + val error: ImportError + ) : Event() + } + + val channel: Channel = + Channel(Channel.BUFFERED) + suspend fun importBook( contentUri: Uri - ): Try = + ) { contentUri.copyToTempFile(context, storageDir) .mapFailure { ImportError.ImportBookFailed(it) } .flatMap { addLocalBook(it) } + .onSuccess { channel.send(Event.ImportPublicationSuccess) } + .onFailure { channel.send(Event.ImportPublicationError(it)) } + } + + suspend fun importOpdsPublication( + publication: Publication + ) { + val filename = UUID.randomUUID().toString() + val dest = File(storageDir, filename) + + getDownloadURL(publication) + .flatMap { url -> + url.downloadTo(dest) + }.flatMap { + val opdsCover = publication.images.firstOrNull()?.href + addLocalBook(dest, opdsCover) + } + } + + private fun getDownloadURL(publication: Publication): Try = + publication.links + .firstOrNull { it.mediaType.isPublication || it.mediaType == MediaType.LCP_LICENSE_DOCUMENT } + ?.let { + try { + Try.success(URL(it.href)) + } catch (e: Exception) { + Try.failure(e) + } + } ?: Try.failure(Exception("No supported link to acquire publication.")) suspend fun addRemoteBook( url: Url - ): Try { + ) { val asset = assetRetriever.retrieve(url, fileExtension = url.extension) - ?: return Try.failure( - ImportError.PublicationError( - PublicationError.UnsupportedPublication(Publication.OpeningException.UnsupportedAsset()) + ?: run { + channel.send( + Event.ImportPublicationError( + ImportError.PublicationError( + PublicationError.UnsupportedPublication(Publication.OpeningException.UnsupportedAsset()) + ) + ) ) - ) - return addBook(url, asset) + return + } + + addBook(url, asset) + .onSuccess { channel.send(Event.ImportPublicationSuccess) } + .onFailure { channel.send(Event.ImportPublicationError(it)) } } suspend fun addSharedStorageBook( url: Url, coverUrl: String? = null, - ): Try { + ) { val asset = assetRetriever.retrieve(url) - ?: return Try.failure( - ImportError.PublicationError( - PublicationError.UnsupportedPublication( - Publication.OpeningException.UnsupportedAsset("Unsupported media type") + ?: run { + channel.send( + Event.ImportPublicationError( + ImportError.PublicationError( + PublicationError.UnsupportedPublication( + Publication.OpeningException.UnsupportedAsset("Unsupported media type") + ) + ) ) ) - ) + return + } - return addBook(url, asset, coverUrl) + addBook(url, asset, coverUrl) + .onSuccess { channel.send(Event.ImportPublicationSuccess) } + .onFailure { channel.send(Event.ImportPublicationError(it)) } } suspend fun addLocalBook( @@ -186,8 +249,11 @@ class BookImporter( return addBook( libraryUrl, libraryAsset, coverUrl - ).onFailure { + ) .onSuccess { + channel.send(Event.ImportPublicationSuccess) + }.onFailure { tryOrNull { libraryFile.delete() } + channel.send(Event.ImportPublicationError(it)) } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt index 7bbfb11ef5..efdd0768b1 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt @@ -14,11 +14,13 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.readium.r2.shared.util.Url -import org.readium.r2.testapp.BookImporter -import org.readium.r2.testapp.BookRepository +import org.readium.r2.testapp.Bookshelf import org.readium.r2.testapp.BuildConfig import org.readium.r2.testapp.domain.model.Book import org.readium.r2.testapp.reader.ReaderActivityContract @@ -38,6 +40,9 @@ class BookshelfViewModel(application: Application) : AndroidViewModel(applicatio init { copySamplesFromAssetsToStorage() + app.bookshelf.channel.receiveAsFlow() + .onEach { sendImportFeedback(it) } + .launchIn(viewModelScope) } private fun copySamplesFromAssetsToStorage() = viewModelScope.launch(Dispatchers.IO) { @@ -52,7 +57,7 @@ class BookshelfViewModel(application: Application) : AndroidViewModel(applicatio val file = app.assets.open("Samples/$element").copyToTempFile(app.storageDir) if (file != null) - app.bookImporter.addLocalBook(file) + app.bookshelf.addLocalBook(file) else if (BuildConfig.DEBUG) error("Unable to load sample into the library") } @@ -68,35 +73,29 @@ class BookshelfViewModel(application: Application) : AndroidViewModel(applicatio fun importPublicationFromUri(uri: Uri) = viewModelScope.launch { - app.bookImporter - .importBook(uri) - .failureOrNull() - .let { sendImportFeedback(it) } + app.bookshelf.importBook(uri) } fun addSharedStoragePublication(uri: Uri) = viewModelScope.launch { - app.bookImporter - .addSharedStorageBook(Url(uri.toString())!!) - .failureOrNull() - .let { sendImportFeedback(it) } + app.bookshelf.addSharedStorageBook(Url(uri.toString())!!) } fun addRemotePublication(url: Url) { viewModelScope.launch { - val exception = app.bookImporter - .addRemoteBook(url) - .failureOrNull() - sendImportFeedback(exception) + app.bookshelf.addRemoteBook(url) } } - private fun sendImportFeedback(error: BookImporter.ImportError?) { - if (error == null) { - channel.send(Event.ImportPublicationSuccess) - } else { - val errorMessage = error.getUserMessage(app) - channel.send(Event.ImportPublicationError(errorMessage)) + private fun sendImportFeedback(event: Bookshelf.Event) { + when (event) { + is Bookshelf.Event.ImportPublicationError -> { + val errorMessage = event.error.getUserMessage(app) + channel.send(Event.ImportPublicationError(errorMessage)) + } + Bookshelf.Event.ImportPublicationSuccess -> { + channel.send(Event.ImportPublicationSuccess) + } } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt index 3d8375a785..63ca50b1ee 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt @@ -9,24 +9,21 @@ package org.readium.r2.testapp.catalogs import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import java.io.File import java.net.MalformedURLException -import java.net.URL -import java.util.* import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import org.readium.r2.opds.OPDS1Parser import org.readium.r2.opds.OPDS2Parser import org.readium.r2.shared.error.Try -import org.readium.r2.shared.error.flatMap import org.readium.r2.shared.opds.ParseData import org.readium.r2.shared.publication.Publication -import org.readium.r2.shared.publication.opds.images import org.readium.r2.shared.util.http.HttpRequest -import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.testapp.Bookshelf import org.readium.r2.testapp.domain.model.Catalog import org.readium.r2.testapp.utils.EventChannel -import org.readium.r2.testapp.utils.extensions.downloadTo import timber.log.Timber class CatalogViewModel(application: Application) : AndroidViewModel(application) { @@ -36,6 +33,13 @@ class CatalogViewModel(application: Application) : AndroidViewModel(application) val detailChannel = EventChannel(Channel(Channel.BUFFERED), viewModelScope) val eventChannel = EventChannel(Channel(Channel.BUFFERED), viewModelScope) + + init { + app.bookshelf.channel.receiveAsFlow() + .onEach { sendImportFeedback(it) } + .launchIn(viewModelScope) + } + lateinit var publication: Publication fun parseCatalog(catalog: Catalog) = viewModelScope.launch { @@ -62,33 +66,21 @@ class CatalogViewModel(application: Application) : AndroidViewModel(application) } fun downloadPublication(publication: Publication) = viewModelScope.launch { - val filename = UUID.randomUUID().toString() - val dest = File(app.storageDir, filename) - - getDownloadURL(publication) - .flatMap { url -> - url.downloadTo(dest) - }.flatMap { - val opdsCover = publication.images.firstOrNull()?.href - app.bookImporter.addLocalBook(dest, opdsCover) - }.onSuccess { + app.bookshelf.importOpdsPublication(publication) + } + + private fun sendImportFeedback(event: Bookshelf.Event) { + when (event) { + is Bookshelf.Event.ImportPublicationError -> { + val errorMessage = event.error.getUserMessage(app) + detailChannel.send(Event.DetailEvent.ImportPublicationFailed(errorMessage)) + } + Bookshelf.Event.ImportPublicationSuccess -> { detailChannel.send(Event.DetailEvent.ImportPublicationSuccess) - }.onFailure { - detailChannel.send(Event.DetailEvent.ImportPublicationFailed) } + } } - private fun getDownloadURL(publication: Publication): Try = - publication.links - .firstOrNull { it.mediaType.isPublication || it.mediaType == MediaType.LCP_LICENSE_DOCUMENT } - ?.let { - try { - Try.success(URL(it.href)) - } catch (e: Exception) { - Try.failure(e) - } - } ?: Try.failure(Exception("No supported link to acquire publication.")) - sealed class Event { sealed class FeedEvent : Event() { @@ -102,7 +94,9 @@ class CatalogViewModel(application: Application) : AndroidViewModel(application) object ImportPublicationSuccess : DetailEvent() - object ImportPublicationFailed : DetailEvent() + class ImportPublicationFailed( + private val message: String + ) : DetailEvent() } } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationDetailFragment.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationDetailFragment.kt index 9aba970547..57443d890b 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationDetailFragment.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationDetailFragment.kt @@ -54,7 +54,6 @@ class PublicationDetailFragment : Fragment() { binding.catalogDetailDownloadButton.setOnClickListener { publication?.let { it1 -> - binding.catalogDetailProgressBar.visibility = View.VISIBLE catalogViewModel.downloadPublication( it1 ) diff --git a/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt index 73a02c3b34..c579afccc3 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt @@ -27,9 +27,9 @@ import org.readium.r2.shared.publication.Locator import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.publication.services.isRestricted import org.readium.r2.shared.util.Url +import org.readium.r2.testapp.BookRepository import org.readium.r2.testapp.PublicationError import org.readium.r2.testapp.Readium -import org.readium.r2.testapp.BookRepository import org.readium.r2.testapp.reader.preferences.AndroidTtsPreferencesManagerFactory import org.readium.r2.testapp.reader.preferences.EpubPreferencesManagerFactory import org.readium.r2.testapp.reader.preferences.ExoPlayerPreferencesManagerFactory From a1ba25c9c4667a31117372fcf333199dbe3c8c99 Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Thu, 24 Aug 2023 16:58:43 +0200 Subject: [PATCH 05/35] Make it work --- .../android/AndroidDownloadManager.kt | 10 +- .../downloads/android/DownloadsRepository.kt | 4 +- .../readium/r2/lcp/LcpPublicationRetriever.kt | 69 +++-- .../r2/lcp/license/model/LicenseDocument.kt | 19 +- .../org/readium/r2/testapp/Application.kt | 13 +- .../java/org/readium/r2/testapp/Bookshelf.kt | 242 ++++++++++++------ .../org/readium/r2/testapp/OpdsDownloader.kt | 115 +++++++++ .../java/org/readium/r2/testapp/Readium.kt | 11 +- .../readium/r2/testapp/db/DownloadDatabase.kt | 46 ++++ .../org/readium/r2/testapp/db/DownloadsDao.kt | 31 +++ .../r2/testapp/domain/model/Download.kt | 31 +++ .../r2/testapp/utils/extensions/File.kt | 9 +- test-app/src/main/res/values/strings.xml | 3 + 13 files changed, 477 insertions(+), 126 deletions(-) create mode 100644 test-app/src/main/java/org/readium/r2/testapp/OpdsDownloader.kt create mode 100644 test-app/src/main/java/org/readium/r2/testapp/db/DownloadDatabase.kt create mode 100644 test-app/src/main/java/org/readium/r2/testapp/db/DownloadsDao.kt create mode 100644 test-app/src/main/java/org/readium/r2/testapp/domain/model/Download.kt diff --git a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt b/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt index b0c60d59be..38ea898947 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt +++ b/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt @@ -39,11 +39,11 @@ public class AndroidDownloadManager( private val progressJob: Job = coroutineScope.launch { while (true) { + val ids = downloadsRepository.idsForName(name) val cursor = downloadManager.query( SystemDownloadManager.Query() - .setFilterById(*downloadsRepository.idsForName(name).toLongArray()) ) - notify(cursor) + notify(cursor, ids) delay((1.0 / refreshRate.value).seconds) } } @@ -113,12 +113,16 @@ public class AndroidDownloadManager( return this } - private suspend fun notify(cursor: Cursor) = cursor.use { + private suspend fun notify(cursor: Cursor, ids: List) = cursor.use { while (cursor.moveToNext()) { val facade = DownloadCursorFacade(cursor) val id = DownloadManager.RequestId(facade.id) + if (id.value !in ids) { + continue + } + when (facade.status) { SystemDownloadManager.STATUS_FAILED -> { listener.onDownloadFailed(id, mapErrorCode(facade.reason!!)) diff --git a/readium/downloads/src/main/java/org/readium/downloads/android/DownloadsRepository.kt b/readium/downloads/src/main/java/org/readium/downloads/android/DownloadsRepository.kt index 964bb2868b..003bc1a9c2 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/android/DownloadsRepository.kt +++ b/readium/downloads/src/main/java/org/readium/downloads/android/DownloadsRepository.kt @@ -30,8 +30,8 @@ internal class DownloadsRepository( private val downloadIds: Flow>> = context.dataStore.data - .map { data -> data[downloadIdsKey]!! } - .map { string -> string.toData() } + .map { data -> data[downloadIdsKey] } + .map { string -> string?.toData().orEmpty() } suspend fun addId(name: String, id: Long) { context.dataStore.edit { data -> diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index e19125460d..75628aa099 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -20,8 +20,10 @@ import org.readium.downloads.DownloadManager import org.readium.downloads.DownloadManagerProvider import org.readium.r2.lcp.license.container.createLicenseContainer import org.readium.r2.lcp.license.model.LicenseDocument +import org.readium.r2.shared.extensions.tryOrLog import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.Url +import org.readium.r2.shared.util.getOrElse import org.readium.r2.shared.util.mediatype.FormatRegistry import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.mediatype.MediaTypeRetriever @@ -72,11 +74,17 @@ public class LcpPublicationRetriever( destUri: Uri ) { coroutineScope.launch { + val lcpRequestId = RequestId(requestId.value) val acquisition = onDownloadCompleted( requestId.value, Url(destUri.toString())!! - ) - listener.onAcquisitionCompleted(RequestId(requestId.value), acquisition) + ).getOrElse { + tryOrLog { File(destUri.path!!).delete() } + listener.onAcquisitionFailed(lcpRequestId, LcpException.wrap(it)) + return@launch + } + + listener.onAcquisitionCompleted(lcpRequestId, acquisition) } } @@ -104,15 +112,18 @@ public class LcpPublicationRetriever( } private val downloadManager: DownloadManager = - downloadManagerProvider.createDownloadManager(DownloadListener()) + downloadManagerProvider.createDownloadManager( + DownloadListener(), + "~readium-lcp-publication-retriever" + ) private val formatRegistry: FormatRegistry = FormatRegistry() - private val licenses: Flow> = + private val licenses: Flow> = context.dataStore.data - .map { data -> data[licensesKey]!! } - .map { json -> json.toLicenseList() } + .map { data -> data[licensesKey] } + .map { json -> json?.toLicenses().orEmpty() } public suspend fun retrieve( license: ByteArray, @@ -162,13 +173,17 @@ public class LcpPublicationRetriever( ) ) - persistLicense(requestId.value, license.json.toString()) + persistLicense(requestId.value, license.json) return RequestId(requestId.value) } - private suspend fun onDownloadCompleted(id: Long, dest: Url): LcpService.AcquiredPublication { - val license = LicenseDocument(licenses.first()[id]!!.toByteArray()) + private suspend fun onDownloadCompleted( + id: Long, + dest: Url + ): Try { + val licenses = licenses.first() + val license = LicenseDocument(licenses[id]!!) removeLicense(id) val link = license.link(LicenseDocument.Rel.Publication)!! @@ -178,21 +193,27 @@ public class LcpPublicationRetriever( val file = File(dest.path) - // Saves the License Document into the downloaded publication - val container = createLicenseContainer(file, mediaType) - container.write(license) + try { + // Saves the License Document into the downloaded publication + val container = createLicenseContainer(file, mediaType) + container.write(license) + } catch (e: Exception) { + return Try.failure(e) + } - return LcpService.AcquiredPublication( + val acquiredPublication = LcpService.AcquiredPublication( localFile = file, suggestedFilename = "${license.id}.${formatRegistry.fileExtension(mediaType) ?: "epub"}", mediaType = mediaType, licenseDocument = license ) + + return Try.success(acquiredPublication) } - private suspend fun persistLicense(id: Long, license: String) { + private suspend fun persistLicense(id: Long, license: JSONObject) { context.dataStore.edit { data -> - val newEntry = id to licenseToJson(id, license).toString() + val newEntry = id to license val licenses = licenses.first() + newEntry data[licensesKey] = licenses.toJson() } @@ -200,26 +221,26 @@ public class LcpPublicationRetriever( private suspend fun removeLicense(id: Long) { context.dataStore.edit { data -> - val uris = licenses.first() - id - data[licensesKey] = uris.toJson() + val licenses = licenses.first() - id + data[licensesKey] = licenses.toJson() } } - private fun licenseToJson(id: Long, license: String): JSONObject = + private fun licenseToJson(id: Long, license: JSONObject): JSONObject = JSONObject() .put("id", id) .put("license", license) - private fun jsonToLicense(jsonObject: JSONObject): Pair = - jsonObject.getLong("id") to jsonObject.getString("license") + private fun jsonToLicense(jsonObject: JSONObject): Pair = + jsonObject.getLong("id") to jsonObject.getJSONObject("license") - private fun Map.toJson(): String { - val strings = map { licenseToJson(it.key, it.value) } - val array = JSONArray(strings) + private fun Map.toJson(): String { + val jsonObjects = map { licenseToJson(it.key, it.value) } + val array = JSONArray(jsonObjects) return array.toString() } - private fun String.toLicenseList(): Map { + private fun String.toLicenses(): Map { val array = JSONArray(this) val objects = (0 until array.length()).map { array.getJSONObject(it) } return objects.associate { jsonToLicense(it) } diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/LicenseDocument.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/LicenseDocument.kt index ea3b6d7733..1b664e8b18 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/LicenseDocument.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/LicenseDocument.kt @@ -25,7 +25,7 @@ import org.readium.r2.shared.extensions.iso8601ToDate import org.readium.r2.shared.extensions.optNullableString import org.readium.r2.shared.util.mediatype.MediaType -public class LicenseDocument(public val data: ByteArray) { +public class LicenseDocument internal constructor(public val json: JSONObject) { public val provider: String public val id: String public val issued: Date @@ -35,7 +35,15 @@ public class LicenseDocument(public val data: ByteArray) { public val user: User public val rights: Rights public val signature: Signature - public val json: JSONObject + public val data: ByteArray + + public constructor(data: ByteArray) : this( + try { + JSONObject(data.toString(Charset.defaultCharset())) + } catch (e: Exception) { + throw LcpException.Parsing.MalformedJSON + } + ) public enum class Rel(public val value: String) { Hint("hint"), @@ -53,12 +61,7 @@ public class LicenseDocument(public val data: ByteArray) { } init { - try { - json = JSONObject(data.toString(Charset.defaultCharset())) - } catch (e: Exception) { - throw LcpException.Parsing.MalformedJSON - } - + data = json.toString().toByteArray(Charset.defaultCharset()) provider = json.optNullableString("provider") ?: throw LcpException.Parsing.LicenseDocument id = json.optNullableString("id") ?: throw LcpException.Parsing.LicenseDocument issued = json.optNullableString("issued")?.iso8601ToDate() ?: throw LcpException.Parsing.LicenseDocument diff --git a/test-app/src/main/java/org/readium/r2/testapp/Application.kt b/test-app/src/main/java/org/readium/r2/testapp/Application.kt index b02348e23f..687013c1be 100755 --- a/test-app/src/main/java/org/readium/r2/testapp/Application.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Application.kt @@ -6,14 +6,17 @@ package org.readium.r2.testapp -import android.content.* +import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore import com.google.android.material.color.DynamicColors import java.io.File -import java.util.* -import kotlinx.coroutines.* +import java.util.Properties +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.async import org.readium.r2.testapp.BuildConfig.DEBUG import org.readium.r2.testapp.db.BookDatabase import org.readium.r2.testapp.reader.ReaderRepository @@ -66,8 +69,8 @@ class Application : android.app.Application() { readium.publicationFactory, readium.assetRetriever, readium.protectionRetriever, - readium.httpClient, - readium.formatRegistry + readium.formatRegistry, + readium.downloadManagerProvider ) readerRepository = diff --git a/test-app/src/main/java/org/readium/r2/testapp/Bookshelf.kt b/test-app/src/main/java/org/readium/r2/testapp/Bookshelf.kt index 99ae363cc9..565cd6055e 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/Bookshelf.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Bookshelf.kt @@ -17,29 +17,33 @@ import java.io.IOException import java.net.HttpURLConnection import java.net.URL import java.util.UUID +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.readium.downloads.DownloadManager +import org.readium.downloads.DownloadManagerProvider +import org.readium.r2.lcp.LcpException +import org.readium.r2.lcp.LcpPublicationRetriever import org.readium.r2.lcp.LcpService import org.readium.r2.shared.UserException 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.publication.Publication -import org.readium.r2.shared.publication.opds.images import org.readium.r2.shared.publication.protection.ContentProtectionSchemeRetriever import org.readium.r2.shared.publication.services.cover 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.http.HttpClient import org.readium.r2.shared.util.mediatype.FormatRegistry import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.toUrl import org.readium.r2.streamer.PublicationFactory +import org.readium.r2.testapp.db.DownloadDatabase import org.readium.r2.testapp.utils.extensions.copyToTempFile -import org.readium.r2.testapp.utils.extensions.downloadTo import org.readium.r2.testapp.utils.extensions.moveTo import org.readium.r2.testapp.utils.tryOrNull import timber.log.Timber @@ -52,14 +56,9 @@ class Bookshelf( private val publicationFactory: PublicationFactory, private val assetRetriever: AssetRetriever, private val protectionRetriever: ContentProtectionSchemeRetriever, - private val httpClient: HttpClient, - private val formatRegistry: FormatRegistry + private val formatRegistry: FormatRegistry, + private val downloadManagerProvider: DownloadManagerProvider ) { - - private val coverDir: File = - File(storageDir, "covers/") - .apply { if (!exists()) mkdirs() } - sealed class ImportError( content: Content, cause: Exception? @@ -95,6 +94,14 @@ class Bookshelf( override val cause: Throwable ) : ImportError(R.string.import_publication_unexpected_io_exception) + class DownloadFailed( + val error: DownloadManager.Error + ) : ImportError(R.string.import_publication_download_failed) + + class OpdsError( + override val cause: Throwable + ) : ImportError(R.string.import_publication_no_acquisition) + class ImportDatabaseFailed : ImportError(R.string.import_publication_unable_add_pub_database) } @@ -108,6 +115,70 @@ class Bookshelf( ) : Event() } + private val coroutineScope: CoroutineScope = + MainScope() + + private val coverDir: File = + File(storageDir, "covers/") + .apply { if (!exists()) mkdirs() } + + private val opdsDownloader = + DownloadDatabase.getDatabase(context).downloadsDao() + .let { dao -> OpdsDownloader(dao, downloadManagerProvider, OpdsDownloaderListener()) } + + private inner class OpdsDownloaderListener : OpdsDownloader.Listener { + override fun onDownloadCompleted(publication: String, cover: String?) { + coroutineScope.launch { + addLocalBook(File(publication), cover) + } + } + + override fun onDownloadFailed(error: DownloadManager.Error) { + coroutineScope.launch { + channel.send( + Event.ImportPublicationError( + ImportError.DownloadFailed(error) + ) + ) + } + } + } + + val lcpPublicationRetriever = lcpService.map { + it.publicationRetriever(LcpRetrieverListener()) + } + + private inner class LcpRetrieverListener : LcpPublicationRetriever.Listener { + override fun onAcquisitionCompleted( + requestId: LcpPublicationRetriever.RequestId, + acquiredPublication: LcpService.AcquiredPublication + ) { + coroutineScope.launch { + addLocalBook(acquiredPublication.localFile) + } + } + + override fun onAcquisitionProgressed( + requestId: LcpPublicationRetriever.RequestId, + downloaded: Long, + total: Long + ) { + } + + override fun onAcquisitionFailed( + requestId: LcpPublicationRetriever.RequestId, + error: LcpException + ) { + coroutineScope.launch { + channel.send( + Event.ImportPublicationError( + ImportError.LcpAcquisitionFailed(error) + ) + ) + } + } + } + val channel: Channel = Channel(Channel.BUFFERED) @@ -116,37 +187,22 @@ class Bookshelf( ) { contentUri.copyToTempFile(context, storageDir) .mapFailure { ImportError.ImportBookFailed(it) } - .flatMap { addLocalBook(it) } - .onSuccess { channel.send(Event.ImportPublicationSuccess) } - .onFailure { channel.send(Event.ImportPublicationError(it)) } + .map { addLocalBook(it) } } suspend fun importOpdsPublication( publication: Publication ) { - val filename = UUID.randomUUID().toString() - val dest = File(storageDir, filename) - - getDownloadURL(publication) - .flatMap { url -> - url.downloadTo(dest, httpClient, assetRetriever) - }.flatMap { - val opdsCover = publication.images.firstOrNull()?.href - addLocalBook(dest, opdsCover) + opdsDownloader.download(publication) + .getOrElse { + channel.send( + Event.ImportPublicationError( + ImportError.OpdsError(it) + ) + ) } } - private fun getDownloadURL(publication: Publication): Try = - publication.links - .firstOrNull { it.mediaType?.isPublication == true || it.mediaType == MediaType.LCP_LICENSE_DOCUMENT } - ?.let { - try { - Try.success(URL(it.href)) - } catch (e: Exception) { - Try.failure(e) - } - } ?: Try.failure(Exception("No supported link to acquire publication.")) - suspend fun addRemoteBook( url: Url ) { @@ -197,73 +253,101 @@ class Bookshelf( suspend fun addLocalBook( tempFile: File, coverUrl: String? = null - ): Try { + ) { val sourceAsset = assetRetriever.retrieve(tempFile) - ?: return Try.failure( - ImportError.PublicationError( - PublicationError.UnsupportedPublication( - Publication.OpeningException.UnsupportedAsset() + ?: run { + channel.send( + Event.ImportPublicationError( + ImportError.PublicationError( + PublicationError.UnsupportedPublication( + Publication.OpeningException.UnsupportedAsset() + ) + ) ) ) - ) - - val (publicationTempFile, publicationTempAsset) = - if (sourceAsset.mediaType != MediaType.LCP_LICENSE_DOCUMENT) { - tempFile to sourceAsset - } else { - lcpService - .flatMap { - sourceAsset.close() - it.acquirePublication(tempFile) - } - .fold( - { - val file = it.localFile - val asset = assetRetriever.retrieve( - file.toUrl(), - assetType = AssetType.Archive, - mediaType = it.mediaType - ).getOrElse { error -> - return Try.failure(ImportError.PublicationError(error)) - } - file to asset - }, - { - tryOrNull { tempFile.delete() } - return Try.failure(ImportError.LcpAcquisitionFailed(it)) - } - ) + return } - val fileExtension = formatRegistry.fileExtension(publicationTempAsset.mediaType) ?: "epub" + if ( + sourceAsset is Asset.Resource && + sourceAsset.mediaType.matches(MediaType.LCP_LICENSE_DOCUMENT) + ) { + acquireLcpPublication(sourceAsset) + return + } + + val fileExtension = formatRegistry.fileExtension(sourceAsset.mediaType) ?: "epub" val fileName = "${UUID.randomUUID()}.$fileExtension" val libraryFile = File(storageDir, fileName) - val libraryUrl = libraryFile.toUrl() try { - publicationTempFile.moveTo(libraryFile) + tempFile.moveTo(libraryFile) } catch (e: Exception) { Timber.d(e) tryOrNull { libraryFile.delete() } - return Try.failure(ImportError.ImportBookFailed(e)) + channel.send( + Event.ImportPublicationError( + ImportError.ImportBookFailed(e) + ) + ) + return } + addActualLocalBook( + libraryFile, + sourceAsset.mediaType, + sourceAsset.assetType, + coverUrl + ).onSuccess { + channel.send(Event.ImportPublicationSuccess) + }.onFailure { + tryOrNull { libraryFile.delete() } + channel.send(Event.ImportPublicationError(it)) + } + } + + private suspend fun acquireLcpPublication(licenceAsset: Asset.Resource) { + val lcpRetriever = lcpPublicationRetriever + .getOrElse { + channel.send( + Event.ImportPublicationError( + ImportError.LcpAcquisitionFailed(it) + ) + ) + return + } + + val license = licenceAsset.resource.read() + .getOrElse { + channel.send( + Event.ImportPublicationError( + ImportError.LcpAcquisitionFailed(it) + ) + ) + return + } + + lcpRetriever.retrieve(license, "LCP Publication", "Downloading") + } + + private suspend fun addActualLocalBook( + libraryFile: File, + mediaType: MediaType, + assetType: AssetType, + coverUrl: String? + ): Try { + val libraryUrl = libraryFile.toUrl() val libraryAsset = assetRetriever.retrieve( libraryUrl, - publicationTempAsset.mediaType, - publicationTempAsset.assetType + mediaType, + assetType ).getOrElse { return Try.failure(ImportError.PublicationError(it)) } return addBook( libraryUrl, libraryAsset, coverUrl - ).onSuccess { - channel.send(Event.ImportPublicationSuccess) - }.onFailure { - tryOrNull { libraryFile.delete() } - channel.send(Event.ImportPublicationError(it)) - } + ) } private suspend fun addBook( diff --git a/test-app/src/main/java/org/readium/r2/testapp/OpdsDownloader.kt b/test-app/src/main/java/org/readium/r2/testapp/OpdsDownloader.kt new file mode 100644 index 0000000000..3345d3fee4 --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/OpdsDownloader.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2021 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.testapp + +import android.net.Uri +import java.net.URL +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import org.readium.downloads.DownloadManager +import org.readium.downloads.DownloadManagerProvider +import org.readium.r2.shared.publication.Publication +import org.readium.r2.shared.publication.opds.images +import org.readium.r2.shared.util.Try +import org.readium.r2.shared.util.Url +import org.readium.r2.shared.util.getOrElse +import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.testapp.db.DownloadsDao +import org.readium.r2.testapp.domain.model.Download + +class OpdsDownloader( + private val downloadsDao: DownloadsDao, + private val downloadManagerProvider: DownloadManagerProvider, + private val listener: Listener +) { + + interface Listener { + + fun onDownloadCompleted(publication: String, cover: String?) + + fun onDownloadFailed(error: DownloadManager.Error) + } + + private val coroutineScope: CoroutineScope = + MainScope() + + private val managerName: String = + "opds-downloader" + + private val downloadManager = downloadManagerProvider.createDownloadManager( + listener = DownloadListener(), + name = managerName + ) + + private inner class DownloadListener : DownloadManager.Listener { + override fun onDownloadCompleted(requestId: DownloadManager.RequestId, destUri: Uri) { + coroutineScope.launch { + val cover = downloadsDao.get(managerName, requestId.value)!!.extra + downloadsDao.delete(managerName, requestId.value) + listener.onDownloadCompleted(destUri.path!!, cover) + } + } + + override fun onDownloadProgressed( + requestId: DownloadManager.RequestId, + downloaded: Long, + total: Long + ) { + } + + override fun onDownloadFailed( + requestId: DownloadManager.RequestId, + error: DownloadManager.Error + ) { + listener.onDownloadFailed(error) + } + } + + fun download(publication: Publication): Try { + val publicationUrl = getDownloadURL(publication) + .getOrElse { return Try.failure(it) } + .toString() + + val coverUrl = publication + .images.firstOrNull()?.href + + coroutineScope.launch { + downloadAsync(publication.metadata.title, publicationUrl, coverUrl) + } + + return Try.success(Unit) + } + + private suspend fun downloadAsync( + publicationTitle: String?, + publicationUrl: String, + coverUrl: String? + ) { + val requestId = downloadManager.submit( + DownloadManager.Request( + Url(publicationUrl)!!, + emptyMap(), + publicationTitle ?: "Untitled publication", + "Downloading" + ) + ) + val download = Download(managerName, requestId.value, coverUrl) + downloadsDao.insert(download) + } + + private fun getDownloadURL(publication: Publication): Try = + publication.links + .firstOrNull { it.mediaType?.isPublication == true || it.mediaType == MediaType.LCP_LICENSE_DOCUMENT } + ?.let { + try { + Try.success(URL(it.href)) + } catch (e: Exception) { + Try.failure(e) + } + } ?: Try.failure(Exception("No supported link to acquire publication.")) +} diff --git a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt index 82b1c0a1d4..0ea66d9551 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt @@ -8,6 +8,7 @@ package org.readium.r2.testapp import android.content.Context import org.readium.adapters.pdfium.document.PdfiumDocumentFactory +import org.readium.downloads.android.AndroidDownloadManagerProvider import org.readium.r2.lcp.LcpService import org.readium.r2.navigator.preferences.FontFamily import org.readium.r2.shared.ExperimentalReadiumApi @@ -66,12 +67,18 @@ class Readium(context: Context) { context.contentResolver ) + val downloadManagerProvider = AndroidDownloadManagerProvider(context) + /** * The LCP service decrypts LCP-protected publication and acquire publications from a * license file. */ - val lcpService = LcpService(context, assetRetriever, mediaTypeRetriever) - ?.let { Try.success(it) } + val lcpService = LcpService( + context, + assetRetriever, + mediaTypeRetriever, + downloadManagerProvider + )?.let { Try.success(it) } ?: Try.failure(UserException("liblcp is missing on the classpath")) private val contentProtections = listOfNotNull( diff --git a/test-app/src/main/java/org/readium/r2/testapp/db/DownloadDatabase.kt b/test-app/src/main/java/org/readium/r2/testapp/db/DownloadDatabase.kt new file mode 100644 index 0000000000..ebe8c19790 --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/db/DownloadDatabase.kt @@ -0,0 +1,46 @@ +/* + * 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.testapp.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import org.readium.r2.testapp.domain.model.* + +@Database( + entities = [Download::class], + version = 1, + exportSchema = false +) +@TypeConverters(HighlightConverters::class) +abstract class DownloadDatabase : RoomDatabase() { + + abstract fun downloadsDao(): DownloadsDao + + companion object { + @Volatile + private var INSTANCE: DownloadDatabase? = null + + fun getDatabase(context: Context): DownloadDatabase { + val tempInstance = INSTANCE + if (tempInstance != null) { + return tempInstance + } + synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + DownloadDatabase::class.java, + "downloads_database" + ).build() + INSTANCE = instance + return instance + } + } + } +} diff --git a/test-app/src/main/java/org/readium/r2/testapp/db/DownloadsDao.kt b/test-app/src/main/java/org/readium/r2/testapp/db/DownloadsDao.kt new file mode 100644 index 0000000000..ef72568368 --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/db/DownloadsDao.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2021 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.testapp.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import org.readium.r2.testapp.domain.model.Download + +@Dao +interface DownloadsDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(download: Download) + + @Query( + "DELETE FROM " + Download.TABLE_NAME + + " WHERE " + Download.ID + " = :id" + " AND " + Download.MANAGER + " = :manager" + ) + suspend fun delete(manager: String, id: Long) + + @Query( + "SELECT * FROM " + Download.TABLE_NAME + + " WHERE " + Download.ID + " = :id" + " AND " + Download.MANAGER + " = :manager" + ) + suspend fun get(manager: String, id: Long): Download? +} diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/model/Download.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/model/Download.kt new file mode 100644 index 0000000000..947444c496 --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/model/Download.kt @@ -0,0 +1,31 @@ +/* + * 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.testapp.domain.model + +import androidx.room.ColumnInfo +import androidx.room.Entity + +@Entity(tableName = Download.TABLE_NAME, primaryKeys = [Download.MANAGER, Download.ID]) +data class Download( + @ColumnInfo(name = MANAGER) + val manager: String, + @ColumnInfo(name = ID) + val id: Long, + @ColumnInfo(name = EXTRA) + val extra: String? = null, + @ColumnInfo(name = CREATION_DATE, defaultValue = "CURRENT_TIMESTAMP") + val creation: Long? = null +) { + + companion object { + const val TABLE_NAME = "downloads" + const val CREATION_DATE = "creation_date" + const val MANAGER = "manager" + const val ID = "id" + const val EXTRA = "cover" + } +} diff --git a/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/File.kt b/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/File.kt index 62bbb4d56b..b5c784da1a 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/File.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/File.kt @@ -12,7 +12,6 @@ package org.readium.r2.testapp.utils.extensions import java.io.File import java.io.FileFilter import java.io.FileOutputStream -import java.io.IOException import java.net.URL import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ensureActive @@ -30,9 +29,13 @@ import org.readium.r2.testapp.BuildConfig import timber.log.Timber suspend fun File.moveTo(target: File) = withContext(Dispatchers.IO) { - if (!this@moveTo.renameTo(target)) { - throw IOException() + if (this@moveTo.renameTo(target)) { + return@withContext } + + // renameTo might be unable to move a file from a filesystem to another. Copy instead. + copyTo(target) + delete() } /** diff --git a/test-app/src/main/res/values/strings.xml b/test-app/src/main/res/values/strings.xml index 6425f936cc..ec84a65f6b 100644 --- a/test-app/src/main/res/values/strings.xml +++ b/test-app/src/main/res/values/strings.xml @@ -94,6 +94,9 @@ Return Unable to add publication due to an unexpected error on your device + Publication download failed. + Publication download failed. + Publication added to your library Unable to add publication to the database From 9b6d1fc4cbf07fa880109589a8f3f8814d8497b6 Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Fri, 25 Aug 2023 10:19:05 +0200 Subject: [PATCH 06/35] Small changes --- .../downloads/src/main/AndroidManifest.xml | 3 +- .../java/org/readium/r2/lcp/LcpService.kt | 15 ++-- .../container/BytesLicenseContainer.kt | 2 +- .../license/container/LCPLLicenseContainer.kt | 2 +- .../license/container/ZIPLicenseContainer.kt | 2 +- .../r2/lcp/license/model/LicenseDocument.kt | 77 ++++++++++++------- .../readium/r2/lcp/service/LicensesService.kt | 6 +- .../java/org/readium/r2/testapp/Bookshelf.kt | 7 +- .../java/org/readium/r2/testapp/Readium.kt | 3 +- 9 files changed, 71 insertions(+), 46 deletions(-) diff --git a/readium/downloads/src/main/AndroidManifest.xml b/readium/downloads/src/main/AndroidManifest.xml index a5918e68ab..9a40236b94 100644 --- a/readium/downloads/src/main/AndroidManifest.xml +++ b/readium/downloads/src/main/AndroidManifest.xml @@ -1,4 +1,3 @@ - - \ No newline at end of file + 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 764d91e7f1..c3dd46c05f 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 @@ -17,7 +17,6 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.readium.downloads.DownloadManagerProvider -import org.readium.downloads.android.AndroidDownloadManagerProvider import org.readium.r2.lcp.auth.LcpDialogAuthentication import org.readium.r2.lcp.license.model.LicenseDocument import org.readium.r2.lcp.persistence.LcpDatabase @@ -117,7 +116,14 @@ public interface LcpService { sender: Any? ): Try + /** + * Creates a [LcpPublicationRetriever] instance which can be used to acquire a protected + * publication from standalone LCPL's bytes. + * + * @param listener listener to implement to be notified about the status of the download. + */ public fun publicationRetriever( + downloadManagerProvider: DownloadManagerProvider, listener: LcpPublicationRetriever.Listener ): LcpPublicationRetriever @@ -163,9 +169,7 @@ public interface LcpService { public operator fun invoke( context: Context, assetRetriever: AssetRetriever, - mediaTypeRetriever: MediaTypeRetriever, - downloadManagerProvider: DownloadManagerProvider = - AndroidDownloadManagerProvider(context) + mediaTypeRetriever: MediaTypeRetriever ): LcpService? { if (!LcpClient.isAvailable()) { return null @@ -191,8 +195,7 @@ public interface LcpService { passphrases = passphrases, context = context, assetRetriever = assetRetriever, - mediaTypeRetriever = mediaTypeRetriever, - downloadManagerProvider = downloadManagerProvider + mediaTypeRetriever = mediaTypeRetriever ) } diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/BytesLicenseContainer.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/BytesLicenseContainer.kt index 0692e18c46..13af2b2bfe 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/BytesLicenseContainer.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/BytesLicenseContainer.kt @@ -19,6 +19,6 @@ internal class BytesLicenseContainer(private var bytes: ByteArray) : LicenseCont override fun read(): ByteArray = bytes override fun write(license: LicenseDocument) { - bytes = license.data + bytes = license.toByteArray() } } diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/LCPLLicenseContainer.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/LCPLLicenseContainer.kt index 0c36e9cb0e..a5c81de802 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/LCPLLicenseContainer.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/LCPLLicenseContainer.kt @@ -27,7 +27,7 @@ internal class LCPLLicenseContainer(private val lcpl: String) : LicenseContainer override fun write(license: LicenseDocument) { try { - File(lcpl).writeBytes(license.data) + File(lcpl).writeBytes(license.toByteArray()) } catch (e: Exception) { throw LcpException.Container.WriteFailed(lcpl) } diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/ZIPLicenseContainer.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/ZIPLicenseContainer.kt index 85f9ee42be..3ad2096548 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/ZIPLicenseContainer.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/ZIPLicenseContainer.kt @@ -52,7 +52,7 @@ internal class ZIPLicenseContainer( if (ZipUtil.containsEntry(tmpZip, pathInZIP)) { ZipUtil.removeEntry(tmpZip, pathInZIP) } - ZipUtil.addEntry(tmpZip, pathInZIP, license.data, source) + ZipUtil.addEntry(tmpZip, pathInZIP, license.toByteArray(), source) tmpZip.delete() } catch (e: Exception) { throw LcpException.Container.WriteFailed(pathInZIP) diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/LicenseDocument.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/LicenseDocument.kt index 1b664e8b18..397ac5a540 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/LicenseDocument.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/LicenseDocument.kt @@ -26,20 +26,55 @@ import org.readium.r2.shared.extensions.optNullableString import org.readium.r2.shared.util.mediatype.MediaType public class LicenseDocument internal constructor(public val json: JSONObject) { - public val provider: String - public val id: String - public val issued: Date - public val updated: Date - public val encryption: Encryption - public val links: Links - public val user: User - public val rights: Rights - public val signature: Signature - public val data: ByteArray + + public val provider: String = + json.optNullableString("provider") + ?: throw LcpException.Parsing.LicenseDocument + + public val id: String = + json.optNullableString("id") + ?: throw LcpException.Parsing.LicenseDocument + + public val issued: Date = + json.optNullableString("issued") + ?.iso8601ToDate() + ?: throw LcpException.Parsing.LicenseDocument + + public val updated: Date = + json.optNullableString("updated") + ?.iso8601ToDate() + ?: issued + + public val encryption: Encryption = + json.optJSONObject("encryption") + ?.let { Encryption(it) } + ?: throw LcpException.Parsing.LicenseDocument + + public val links: Links = + json.optJSONArray("links") + ?.let { Links(it) } + ?: throw LcpException.Parsing.LicenseDocument + + public val user: User = + User(json.optJSONObject("user") ?: JSONObject()) + + public val rights: Rights = + Rights(json.optJSONObject("rights") ?: JSONObject()) + + public val signature: Signature = + json.optJSONObject("signature") + ?.let { Signature(it) } + ?: throw LcpException.Parsing.LicenseDocument + + init { + if (link(Rel.Hint) == null || link(Rel.Publication) == null) { + throw LcpException.Parsing.LicenseDocument + } + } public constructor(data: ByteArray) : this( try { - JSONObject(data.toString(Charset.defaultCharset())) + JSONObject(data.decodeToString()) } catch (e: Exception) { throw LcpException.Parsing.MalformedJSON } @@ -60,23 +95,6 @@ public class LicenseDocument internal constructor(public val json: JSONObject) { } } - init { - data = json.toString().toByteArray(Charset.defaultCharset()) - provider = json.optNullableString("provider") ?: throw LcpException.Parsing.LicenseDocument - id = json.optNullableString("id") ?: throw LcpException.Parsing.LicenseDocument - issued = json.optNullableString("issued")?.iso8601ToDate() ?: throw LcpException.Parsing.LicenseDocument - encryption = json.optJSONObject("encryption")?.let { Encryption(it) } ?: throw LcpException.Parsing.LicenseDocument - signature = json.optJSONObject("signature")?.let { Signature(it) } ?: throw LcpException.Parsing.LicenseDocument - links = json.optJSONArray("links")?.let { Links(it) } ?: throw LcpException.Parsing.LicenseDocument - updated = json.optNullableString("updated")?.iso8601ToDate() ?: issued - user = User(json.optJSONObject("user") ?: JSONObject()) - rights = Rights(json.optJSONObject("rights") ?: JSONObject()) - - if (link(Rel.Hint) == null || link(Rel.Publication) == null) { - throw LcpException.Parsing.LicenseDocument - } - } - public fun link(rel: Rel, type: MediaType? = null): Link? = links.firstWithRel(rel.value, type) @@ -97,4 +115,7 @@ public class LicenseDocument internal constructor(public val json: JSONObject) { public val description: String get() = "License($id)" + + public fun toByteArray(): ByteArray = + json.toString().toByteArray(Charset.defaultCharset()) } 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 fae446cfb7..d81aa7ffb4 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 @@ -48,8 +48,7 @@ internal class LicensesService( private val passphrases: PassphrasesService, private val context: Context, private val assetRetriever: AssetRetriever, - private val mediaTypeRetriever: MediaTypeRetriever, - private val downloadManagerProvider: DownloadManagerProvider + private val mediaTypeRetriever: MediaTypeRetriever ) : LcpService, CoroutineScope by MainScope() { override suspend fun isLcpProtected(file: File): Boolean { @@ -75,6 +74,7 @@ internal class LicensesService( LcpContentProtection(this, authentication, assetRetriever) override fun publicationRetriever( + downloadManagerProvider: DownloadManagerProvider, listener: LcpPublicationRetriever.Listener ): LcpPublicationRetriever { return LcpPublicationRetriever( @@ -205,7 +205,7 @@ internal class LicensesService( } catch (error: Error) { Timber.d("Failed to add the LCP License to the local database: $error") } - if (!licenseDocument.data.contentEquals(initialData)) { + if (!licenseDocument.toByteArray().contentEquals(initialData)) { try { container.write(licenseDocument) Timber.d("licenseDocument ${licenseDocument.json}") diff --git a/test-app/src/main/java/org/readium/r2/testapp/Bookshelf.kt b/test-app/src/main/java/org/readium/r2/testapp/Bookshelf.kt index 565cd6055e..e147e65c65 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/Bookshelf.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Bookshelf.kt @@ -144,8 +144,11 @@ class Bookshelf( } } - val lcpPublicationRetriever = lcpService.map { - it.publicationRetriever(LcpRetrieverListener()) + private val lcpPublicationRetriever = lcpService.map { + it.publicationRetriever( + downloadManagerProvider, + LcpRetrieverListener() + ) } private inner class LcpRetrieverListener : LcpPublicationRetriever.Listener { diff --git a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt index 0ea66d9551..39e47dd41b 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt @@ -76,8 +76,7 @@ class Readium(context: Context) { val lcpService = LcpService( context, assetRetriever, - mediaTypeRetriever, - downloadManagerProvider + mediaTypeRetriever )?.let { Try.success(it) } ?: Try.failure(UserException("liblcp is missing on the classpath")) From 1dc6836b0a111e5e04179fbaec8886157e1467ec Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Fri, 25 Aug 2023 11:54:12 +0200 Subject: [PATCH 07/35] Reorganization --- .../main/java/org/readium/r2/testapp/Application.kt | 4 +++- .../readium/r2/testapp/bookshelf/BookshelfAdapter.kt | 2 +- .../readium/r2/testapp/bookshelf/BookshelfFragment.kt | 2 +- .../readium/r2/testapp/bookshelf/BookshelfViewModel.kt | 4 ++-- .../r2/testapp/catalogs/CatalogFeedListAdapter.kt | 2 +- .../r2/testapp/catalogs/CatalogFeedListFragment.kt | 2 +- .../r2/testapp/catalogs/CatalogFeedListViewModel.kt | 5 +++-- .../org/readium/r2/testapp/catalogs/CatalogFragment.kt | 2 +- .../readium/r2/testapp/catalogs/CatalogViewModel.kt | 4 ++-- .../org/readium/r2/testapp/catalogs/GroupAdapter.kt | 2 +- .../readium/r2/testapp/catalogs/NavigationAdapter.kt | 2 +- .../readium/r2/testapp/{ => data}/BookRepository.kt | 10 +++++----- .../r2/testapp/{catalogs => data}/CatalogRepository.kt | 6 +++--- .../readium/r2/testapp/{ => data}/db/BookDatabase.kt | 7 ++++++- .../org/readium/r2/testapp/{ => data}/db/BooksDao.kt | 8 ++++---- .../org/readium/r2/testapp/{ => data}/db/CatalogDao.kt | 4 ++-- .../r2/testapp/{ => data}/db/DownloadDatabase.kt | 4 +++- .../readium/r2/testapp/{ => data}/db/DownloadsDao.kt | 4 ++-- .../readium/r2/testapp/{domain => data}/model/Book.kt | 2 +- .../r2/testapp/{domain => data}/model/Bookmark.kt | 2 +- .../r2/testapp/{domain => data}/model/Catalog.kt | 2 +- .../r2/testapp/{domain => data}/model/Download.kt | 2 +- .../r2/testapp/{domain => data}/model/Highlight.kt | 2 +- .../org/readium/r2/testapp/{ => domain}/Bookshelf.kt | 7 +++++-- .../readium/r2/testapp/{ => domain}/OpdsDownloader.kt | 6 +++--- .../readium/r2/testapp/outline/BookmarksFragment.kt | 2 +- .../readium/r2/testapp/outline/HighlightsFragment.kt | 2 +- .../org/readium/r2/testapp/reader/ReaderRepository.kt | 2 +- .../org/readium/r2/testapp/reader/ReaderViewModel.kt | 4 ++-- .../readium/r2/testapp/reader/VisualReaderFragment.kt | 2 +- 30 files changed, 61 insertions(+), 48 deletions(-) rename test-app/src/main/java/org/readium/r2/testapp/{ => data}/BookRepository.kt (94%) rename test-app/src/main/java/org/readium/r2/testapp/{catalogs => data}/CatalogRepository.kt (80%) rename test-app/src/main/java/org/readium/r2/testapp/{ => data}/db/BookDatabase.kt (81%) rename test-app/src/main/java/org/readium/r2/testapp/{ => data}/db/BooksDao.kt (95%) rename test-app/src/main/java/org/readium/r2/testapp/{ => data}/db/CatalogDao.kt (94%) rename test-app/src/main/java/org/readium/r2/testapp/{ => data}/db/DownloadDatabase.kt (89%) rename test-app/src/main/java/org/readium/r2/testapp/{ => data}/db/DownloadsDao.kt (90%) rename test-app/src/main/java/org/readium/r2/testapp/{domain => data}/model/Book.kt (98%) rename test-app/src/main/java/org/readium/r2/testapp/{domain => data}/model/Bookmark.kt (97%) rename test-app/src/main/java/org/readium/r2/testapp/{domain => data}/model/Catalog.kt (95%) rename test-app/src/main/java/org/readium/r2/testapp/{domain => data}/model/Download.kt (95%) rename test-app/src/main/java/org/readium/r2/testapp/{domain => data}/model/Highlight.kt (99%) rename test-app/src/main/java/org/readium/r2/testapp/{ => domain}/Bookshelf.kt (98%) rename test-app/src/main/java/org/readium/r2/testapp/{ => domain}/OpdsDownloader.kt (96%) diff --git a/test-app/src/main/java/org/readium/r2/testapp/Application.kt b/test-app/src/main/java/org/readium/r2/testapp/Application.kt index 687013c1be..4d8667b1a0 100755 --- a/test-app/src/main/java/org/readium/r2/testapp/Application.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Application.kt @@ -18,7 +18,9 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.MainScope import kotlinx.coroutines.async import org.readium.r2.testapp.BuildConfig.DEBUG -import org.readium.r2.testapp.db.BookDatabase +import org.readium.r2.testapp.data.BookRepository +import org.readium.r2.testapp.data.db.BookDatabase +import org.readium.r2.testapp.domain.Bookshelf import org.readium.r2.testapp.reader.ReaderRepository import timber.log.Timber diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfAdapter.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfAdapter.kt index 47d3af18d8..4d1016a3be 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfAdapter.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfAdapter.kt @@ -14,8 +14,8 @@ import androidx.recyclerview.widget.RecyclerView import com.squareup.picasso.Picasso import java.io.File import org.readium.r2.testapp.R +import org.readium.r2.testapp.data.model.Book import org.readium.r2.testapp.databinding.ItemRecycleBookBinding -import org.readium.r2.testapp.domain.model.Book import org.readium.r2.testapp.utils.singleClick class BookshelfAdapter( diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfFragment.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfFragment.kt index e6b3bc1f3f..7d4841702e 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfFragment.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfFragment.kt @@ -25,8 +25,8 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import org.readium.r2.shared.util.Url import org.readium.r2.testapp.R +import org.readium.r2.testapp.data.model.Book import org.readium.r2.testapp.databinding.FragmentBookshelfBinding -import org.readium.r2.testapp.domain.model.Book import org.readium.r2.testapp.opds.GridAutoFitLayoutManager import org.readium.r2.testapp.reader.ReaderActivityContract import org.readium.r2.testapp.utils.viewLifecycle diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt index 6de2aa2271..29a1854ce0 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt @@ -20,9 +20,9 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.readium.r2.shared.util.Url -import org.readium.r2.testapp.Bookshelf import org.readium.r2.testapp.BuildConfig -import org.readium.r2.testapp.domain.model.Book +import org.readium.r2.testapp.data.model.Book +import org.readium.r2.testapp.domain.Bookshelf import org.readium.r2.testapp.reader.ReaderActivityContract import org.readium.r2.testapp.utils.EventChannel import org.readium.r2.testapp.utils.extensions.copyToTempFile diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListAdapter.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListAdapter.kt index 954bd194c2..93251e2369 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListAdapter.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListAdapter.kt @@ -14,8 +14,8 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import org.readium.r2.testapp.R +import org.readium.r2.testapp.data.model.Catalog import org.readium.r2.testapp.databinding.ItemRecycleButtonBinding -import org.readium.r2.testapp.domain.model.Catalog class CatalogFeedListAdapter(private val onLongClick: (Catalog) -> Unit) : ListAdapter(CatalogListDiff()) { diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListFragment.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListFragment.kt index f812c7d320..bc298b3e36 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListFragment.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListFragment.kt @@ -23,8 +23,8 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import org.readium.r2.testapp.R +import org.readium.r2.testapp.data.model.Catalog import org.readium.r2.testapp.databinding.FragmentCatalogFeedListBinding -import org.readium.r2.testapp.domain.model.Catalog import org.readium.r2.testapp.utils.viewLifecycle class CatalogFeedListFragment : Fragment() { diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListViewModel.kt index 1078dd30e0..1c10180920 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListViewModel.kt @@ -19,8 +19,9 @@ import org.readium.r2.shared.opds.ParseData import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.http.HttpRequest import org.readium.r2.shared.util.http.fetchWithDecoder -import org.readium.r2.testapp.db.BookDatabase -import org.readium.r2.testapp.domain.model.Catalog +import org.readium.r2.testapp.data.CatalogRepository +import org.readium.r2.testapp.data.db.BookDatabase +import org.readium.r2.testapp.data.model.Catalog import org.readium.r2.testapp.utils.EventChannel class CatalogFeedListViewModel(application: Application) : AndroidViewModel(application) { diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFragment.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFragment.kt index e22619b4ca..8a917a0d7c 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFragment.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFragment.kt @@ -28,8 +28,8 @@ import org.readium.r2.testapp.MainActivity import org.readium.r2.testapp.R import org.readium.r2.testapp.bookshelf.BookshelfFragment import org.readium.r2.testapp.catalogs.CatalogFeedListAdapter.Companion.CATALOGFEED +import org.readium.r2.testapp.data.model.Catalog import org.readium.r2.testapp.databinding.FragmentCatalogBinding -import org.readium.r2.testapp.domain.model.Catalog import org.readium.r2.testapp.opds.GridAutoFitLayoutManager import org.readium.r2.testapp.utils.viewLifecycle diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt index 29d62f41da..4719a80a1b 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt @@ -21,8 +21,8 @@ import org.readium.r2.shared.opds.ParseData import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.http.HttpRequest -import org.readium.r2.testapp.Bookshelf -import org.readium.r2.testapp.domain.model.Catalog +import org.readium.r2.testapp.data.model.Catalog +import org.readium.r2.testapp.domain.Bookshelf import org.readium.r2.testapp.utils.EventChannel import timber.log.Timber diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/GroupAdapter.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/GroupAdapter.kt index dbf0764271..6b94e603b2 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/GroupAdapter.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/GroupAdapter.kt @@ -18,8 +18,8 @@ import androidx.recyclerview.widget.RecyclerView import org.readium.r2.shared.opds.Group import org.readium.r2.shared.publication.Publication import org.readium.r2.testapp.R +import org.readium.r2.testapp.data.model.Catalog import org.readium.r2.testapp.databinding.ItemGroupViewBinding -import org.readium.r2.testapp.domain.model.Catalog class GroupAdapter( val type: Int, diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/NavigationAdapter.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/NavigationAdapter.kt index 70f6b763fd..da41620eb2 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/NavigationAdapter.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/NavigationAdapter.kt @@ -15,8 +15,8 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import org.readium.r2.shared.publication.Link import org.readium.r2.testapp.R +import org.readium.r2.testapp.data.model.Catalog import org.readium.r2.testapp.databinding.ItemRecycleButtonBinding -import org.readium.r2.testapp.domain.model.Catalog class NavigationAdapter(val type: Int) : ListAdapter(LinkDiff()) { diff --git a/test-app/src/main/java/org/readium/r2/testapp/BookRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/data/BookRepository.kt similarity index 94% rename from test-app/src/main/java/org/readium/r2/testapp/BookRepository.kt rename to test-app/src/main/java/org/readium/r2/testapp/data/BookRepository.kt index 6ce0e10b16..90f308fad4 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/BookRepository.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/BookRepository.kt @@ -4,7 +4,7 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.r2.testapp +package org.readium.r2.testapp.data import androidx.annotation.ColorInt import androidx.lifecycle.LiveData @@ -18,10 +18,10 @@ import org.readium.r2.shared.publication.indexOfFirstWithHref import org.readium.r2.shared.publication.protection.ContentProtection import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.mediatype.MediaType -import org.readium.r2.testapp.db.BooksDao -import org.readium.r2.testapp.domain.model.Book -import org.readium.r2.testapp.domain.model.Bookmark -import org.readium.r2.testapp.domain.model.Highlight +import org.readium.r2.testapp.data.db.BooksDao +import org.readium.r2.testapp.data.model.Book +import org.readium.r2.testapp.data.model.Bookmark +import org.readium.r2.testapp.data.model.Highlight import org.readium.r2.testapp.utils.extensions.authorName import org.readium.r2.testapp.utils.tryOrLog diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/data/CatalogRepository.kt similarity index 80% rename from test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogRepository.kt rename to test-app/src/main/java/org/readium/r2/testapp/data/CatalogRepository.kt index c029699336..4f44f4a71c 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogRepository.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/CatalogRepository.kt @@ -4,11 +4,11 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.r2.testapp.catalogs +package org.readium.r2.testapp.data import androidx.lifecycle.LiveData -import org.readium.r2.testapp.db.CatalogDao -import org.readium.r2.testapp.domain.model.Catalog +import org.readium.r2.testapp.data.db.CatalogDao +import org.readium.r2.testapp.data.model.Catalog class CatalogRepository(private val catalogDao: CatalogDao) { diff --git a/test-app/src/main/java/org/readium/r2/testapp/db/BookDatabase.kt b/test-app/src/main/java/org/readium/r2/testapp/data/db/BookDatabase.kt similarity index 81% rename from test-app/src/main/java/org/readium/r2/testapp/db/BookDatabase.kt rename to test-app/src/main/java/org/readium/r2/testapp/data/db/BookDatabase.kt index d4562bad0c..9706975e7b 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/db/BookDatabase.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/db/BookDatabase.kt @@ -4,13 +4,18 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.r2.testapp.db +package org.readium.r2.testapp.data.db import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters +import org.readium.r2.testapp.data.model.Book +import org.readium.r2.testapp.data.model.Bookmark +import org.readium.r2.testapp.data.model.Catalog +import org.readium.r2.testapp.data.model.Highlight +import org.readium.r2.testapp.data.model.HighlightConverters import org.readium.r2.testapp.domain.model.* @Database( diff --git a/test-app/src/main/java/org/readium/r2/testapp/db/BooksDao.kt b/test-app/src/main/java/org/readium/r2/testapp/data/db/BooksDao.kt similarity index 95% rename from test-app/src/main/java/org/readium/r2/testapp/db/BooksDao.kt rename to test-app/src/main/java/org/readium/r2/testapp/data/db/BooksDao.kt index ec53bc5328..af31e8a779 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/db/BooksDao.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/db/BooksDao.kt @@ -4,7 +4,7 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.r2.testapp.db +package org.readium.r2.testapp.data.db import androidx.annotation.ColorInt import androidx.lifecycle.LiveData @@ -13,9 +13,9 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import kotlinx.coroutines.flow.Flow -import org.readium.r2.testapp.domain.model.Book -import org.readium.r2.testapp.domain.model.Bookmark -import org.readium.r2.testapp.domain.model.Highlight +import org.readium.r2.testapp.data.model.Book +import org.readium.r2.testapp.data.model.Bookmark +import org.readium.r2.testapp.data.model.Highlight @Dao interface BooksDao { diff --git a/test-app/src/main/java/org/readium/r2/testapp/db/CatalogDao.kt b/test-app/src/main/java/org/readium/r2/testapp/data/db/CatalogDao.kt similarity index 94% rename from test-app/src/main/java/org/readium/r2/testapp/db/CatalogDao.kt rename to test-app/src/main/java/org/readium/r2/testapp/data/db/CatalogDao.kt index 1298298c83..1d4051e3c7 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/db/CatalogDao.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/db/CatalogDao.kt @@ -4,14 +4,14 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.r2.testapp.db +package org.readium.r2.testapp.data.db import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import org.readium.r2.testapp.domain.model.Catalog +import org.readium.r2.testapp.data.model.Catalog @Dao interface CatalogDao { diff --git a/test-app/src/main/java/org/readium/r2/testapp/db/DownloadDatabase.kt b/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadDatabase.kt similarity index 89% rename from test-app/src/main/java/org/readium/r2/testapp/db/DownloadDatabase.kt rename to test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadDatabase.kt index ebe8c19790..3d15ae333a 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/db/DownloadDatabase.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadDatabase.kt @@ -4,13 +4,15 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.r2.testapp.db +package org.readium.r2.testapp.data.db import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters +import org.readium.r2.testapp.data.model.Download +import org.readium.r2.testapp.data.model.HighlightConverters import org.readium.r2.testapp.domain.model.* @Database( diff --git a/test-app/src/main/java/org/readium/r2/testapp/db/DownloadsDao.kt b/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadsDao.kt similarity index 90% rename from test-app/src/main/java/org/readium/r2/testapp/db/DownloadsDao.kt rename to test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadsDao.kt index ef72568368..f36c04b079 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/db/DownloadsDao.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadsDao.kt @@ -4,13 +4,13 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.r2.testapp.db +package org.readium.r2.testapp.data.db import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import org.readium.r2.testapp.domain.model.Download +import org.readium.r2.testapp.data.model.Download @Dao interface DownloadsDao { diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/model/Book.kt b/test-app/src/main/java/org/readium/r2/testapp/data/model/Book.kt similarity index 98% rename from test-app/src/main/java/org/readium/r2/testapp/domain/model/Book.kt rename to test-app/src/main/java/org/readium/r2/testapp/data/model/Book.kt index 42206cb07d..d698564536 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/model/Book.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/model/Book.kt @@ -4,7 +4,7 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.r2.testapp.domain.model +package org.readium.r2.testapp.data.model import androidx.room.ColumnInfo import androidx.room.Entity diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/model/Bookmark.kt b/test-app/src/main/java/org/readium/r2/testapp/data/model/Bookmark.kt similarity index 97% rename from test-app/src/main/java/org/readium/r2/testapp/domain/model/Bookmark.kt rename to test-app/src/main/java/org/readium/r2/testapp/data/model/Bookmark.kt index b2dd2cbb1a..e9161edeca 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/model/Bookmark.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/model/Bookmark.kt @@ -4,7 +4,7 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.r2.testapp.domain.model +package org.readium.r2.testapp.data.model import androidx.room.ColumnInfo import androidx.room.Entity diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/model/Catalog.kt b/test-app/src/main/java/org/readium/r2/testapp/data/model/Catalog.kt similarity index 95% rename from test-app/src/main/java/org/readium/r2/testapp/domain/model/Catalog.kt rename to test-app/src/main/java/org/readium/r2/testapp/data/model/Catalog.kt index f04c61fe18..86ad9bbba6 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/model/Catalog.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/model/Catalog.kt @@ -4,7 +4,7 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.r2.testapp.domain.model +package org.readium.r2.testapp.data.model import android.os.Parcelable import androidx.room.ColumnInfo diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/model/Download.kt b/test-app/src/main/java/org/readium/r2/testapp/data/model/Download.kt similarity index 95% rename from test-app/src/main/java/org/readium/r2/testapp/domain/model/Download.kt rename to test-app/src/main/java/org/readium/r2/testapp/data/model/Download.kt index 947444c496..cd7d6e2a1b 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/model/Download.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/model/Download.kt @@ -4,7 +4,7 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.r2.testapp.domain.model +package org.readium.r2.testapp.data.model import androidx.room.ColumnInfo import androidx.room.Entity diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/model/Highlight.kt b/test-app/src/main/java/org/readium/r2/testapp/data/model/Highlight.kt similarity index 99% rename from test-app/src/main/java/org/readium/r2/testapp/domain/model/Highlight.kt rename to test-app/src/main/java/org/readium/r2/testapp/data/model/Highlight.kt index 58d770c2e0..2142077042 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/model/Highlight.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/model/Highlight.kt @@ -4,7 +4,7 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.r2.testapp.domain.model +package org.readium.r2.testapp.data.model import androidx.annotation.ColorInt import androidx.room.* diff --git a/test-app/src/main/java/org/readium/r2/testapp/Bookshelf.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt similarity index 98% rename from test-app/src/main/java/org/readium/r2/testapp/Bookshelf.kt rename to test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt index e147e65c65..1f416541d0 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/Bookshelf.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt @@ -4,7 +4,7 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.r2.testapp +package org.readium.r2.testapp.domain import android.content.Context import android.graphics.Bitmap @@ -42,7 +42,10 @@ import org.readium.r2.shared.util.mediatype.FormatRegistry import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.toUrl import org.readium.r2.streamer.PublicationFactory -import org.readium.r2.testapp.db.DownloadDatabase +import org.readium.r2.testapp.PublicationError +import org.readium.r2.testapp.R +import org.readium.r2.testapp.data.BookRepository +import org.readium.r2.testapp.data.db.DownloadDatabase import org.readium.r2.testapp.utils.extensions.copyToTempFile import org.readium.r2.testapp.utils.extensions.moveTo import org.readium.r2.testapp.utils.tryOrNull diff --git a/test-app/src/main/java/org/readium/r2/testapp/OpdsDownloader.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt similarity index 96% rename from test-app/src/main/java/org/readium/r2/testapp/OpdsDownloader.kt rename to test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt index 3345d3fee4..9cf43a4cfe 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/OpdsDownloader.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt @@ -4,7 +4,7 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.r2.testapp +package org.readium.r2.testapp.domain import android.net.Uri import java.net.URL @@ -19,8 +19,8 @@ import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.getOrElse import org.readium.r2.shared.util.mediatype.MediaType -import org.readium.r2.testapp.db.DownloadsDao -import org.readium.r2.testapp.domain.model.Download +import org.readium.r2.testapp.data.db.DownloadsDao +import org.readium.r2.testapp.data.model.Download class OpdsDownloader( private val downloadsDao: DownloadsDao, diff --git a/test-app/src/main/java/org/readium/r2/testapp/outline/BookmarksFragment.kt b/test-app/src/main/java/org/readium/r2/testapp/outline/BookmarksFragment.kt index 8a23edfb71..842c41197c 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/outline/BookmarksFragment.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/outline/BookmarksFragment.kt @@ -23,9 +23,9 @@ import org.joda.time.DateTime import org.joda.time.format.DateTimeFormat import org.readium.r2.shared.publication.Publication import org.readium.r2.testapp.R +import org.readium.r2.testapp.data.model.Bookmark import org.readium.r2.testapp.databinding.FragmentListviewBinding import org.readium.r2.testapp.databinding.ItemRecycleBookmarkBinding -import org.readium.r2.testapp.domain.model.Bookmark import org.readium.r2.testapp.reader.ReaderViewModel import org.readium.r2.testapp.utils.extensions.outlineTitle import org.readium.r2.testapp.utils.viewLifecycle diff --git a/test-app/src/main/java/org/readium/r2/testapp/outline/HighlightsFragment.kt b/test-app/src/main/java/org/readium/r2/testapp/outline/HighlightsFragment.kt index d34db2cb2c..f354c09853 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/outline/HighlightsFragment.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/outline/HighlightsFragment.kt @@ -25,9 +25,9 @@ import org.joda.time.DateTime import org.joda.time.format.DateTimeFormat import org.readium.r2.shared.publication.Publication import org.readium.r2.testapp.R +import org.readium.r2.testapp.data.model.Highlight import org.readium.r2.testapp.databinding.FragmentListviewBinding import org.readium.r2.testapp.databinding.ItemRecycleHighlightBinding -import org.readium.r2.testapp.domain.model.Highlight import org.readium.r2.testapp.reader.ReaderViewModel import org.readium.r2.testapp.utils.viewLifecycle diff --git a/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt index 226119aaa0..4bc02bd87b 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt @@ -28,9 +28,9 @@ import org.readium.r2.shared.publication.services.isRestricted import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.getOrElse -import org.readium.r2.testapp.BookRepository import org.readium.r2.testapp.PublicationError import org.readium.r2.testapp.Readium +import org.readium.r2.testapp.data.BookRepository import org.readium.r2.testapp.reader.preferences.AndroidTtsPreferencesManagerFactory import org.readium.r2.testapp.reader.preferences.EpubPreferencesManagerFactory import org.readium.r2.testapp.reader.preferences.ExoPlayerPreferencesManagerFactory diff --git a/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderViewModel.kt index 0de1378068..8332e08adf 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderViewModel.kt @@ -30,8 +30,8 @@ import org.readium.r2.shared.publication.services.search.SearchTry import org.readium.r2.shared.publication.services.search.search import org.readium.r2.shared.util.Try import org.readium.r2.testapp.Application -import org.readium.r2.testapp.BookRepository -import org.readium.r2.testapp.domain.model.Highlight +import org.readium.r2.testapp.data.BookRepository +import org.readium.r2.testapp.data.model.Highlight import org.readium.r2.testapp.reader.preferences.UserPreferencesViewModel import org.readium.r2.testapp.reader.tts.TtsViewModel import org.readium.r2.testapp.search.SearchPagingSource diff --git a/test-app/src/main/java/org/readium/r2/testapp/reader/VisualReaderFragment.kt b/test-app/src/main/java/org/readium/r2/testapp/reader/VisualReaderFragment.kt index 206b4eeec8..b0bbb61446 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/reader/VisualReaderFragment.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/reader/VisualReaderFragment.kt @@ -51,8 +51,8 @@ import org.readium.r2.shared.ExperimentalReadiumApi import org.readium.r2.shared.publication.Locator import org.readium.r2.shared.util.Language import org.readium.r2.testapp.R +import org.readium.r2.testapp.data.model.Highlight import org.readium.r2.testapp.databinding.FragmentReaderBinding -import org.readium.r2.testapp.domain.model.Highlight import org.readium.r2.testapp.reader.preferences.UserPreferencesBottomSheetDialogFragment import org.readium.r2.testapp.reader.tts.TtsControls import org.readium.r2.testapp.reader.tts.TtsViewModel From d6034aadded3f2dcb6141c2373cbd3ae5fd2df94 Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Fri, 25 Aug 2023 14:15:19 +0200 Subject: [PATCH 08/35] Introduce DownloadRepository --- .../org/readium/r2/testapp/Application.kt | 10 +++++ .../r2/testapp/data/DownloadRepository.kt | 39 +++++++++++++++++++ .../r2/testapp/data/db/BookDatabase.kt | 3 +- .../r2/testapp/data/db/DownloadDatabase.kt | 3 +- .../readium/r2/testapp/domain/Bookshelf.kt | 8 ++-- .../r2/testapp/domain/OpdsDownloader.kt | 16 ++++---- test-app/src/main/res/values/strings.xml | 2 +- 7 files changed, 65 insertions(+), 16 deletions(-) create mode 100644 test-app/src/main/java/org/readium/r2/testapp/data/DownloadRepository.kt diff --git a/test-app/src/main/java/org/readium/r2/testapp/Application.kt b/test-app/src/main/java/org/readium/r2/testapp/Application.kt index 4d8667b1a0..dd95f1ee17 100755 --- a/test-app/src/main/java/org/readium/r2/testapp/Application.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Application.kt @@ -19,7 +19,9 @@ import kotlinx.coroutines.MainScope import kotlinx.coroutines.async import org.readium.r2.testapp.BuildConfig.DEBUG import org.readium.r2.testapp.data.BookRepository +import org.readium.r2.testapp.data.DownloadRepository import org.readium.r2.testapp.data.db.BookDatabase +import org.readium.r2.testapp.data.db.DownloadDatabase import org.readium.r2.testapp.domain.Bookshelf import org.readium.r2.testapp.reader.ReaderRepository import timber.log.Timber @@ -34,6 +36,9 @@ class Application : android.app.Application() { lateinit var bookRepository: BookRepository private set + lateinit var downloadRepository: DownloadRepository + private set + lateinit var bookshelf: Bookshelf private set @@ -62,10 +67,15 @@ class Application : android.app.Application() { BookDatabase.getDatabase(this).booksDao() .let { dao -> BookRepository(dao) } + downloadRepository = + DownloadDatabase.getDatabase(this).downloadsDao() + .let { dao -> DownloadRepository(dao) } + bookshelf = Bookshelf( applicationContext, bookRepository, + downloadRepository, storageDir, readium.lcpService, readium.publicationFactory, diff --git a/test-app/src/main/java/org/readium/r2/testapp/data/DownloadRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/data/DownloadRepository.kt new file mode 100644 index 0000000000..4cb5971129 --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/data/DownloadRepository.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2021 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.testapp.data + +import org.readium.r2.testapp.data.db.DownloadsDao +import org.readium.r2.testapp.data.model.Download + +class DownloadRepository( + private val downloadsDao: DownloadsDao +) { + + suspend fun insertOpdsDownload( + manager: String, + id: Long, + cover: String? + ) { + downloadsDao.insert( + Download(manager = manager, id = id, extra = cover) + ) + } + + suspend fun getOpdsDownloadCover( + manager: String, + id: Long + ): String? { + return downloadsDao.get(manager, id)!!.extra + } + + suspend fun removeDownload( + manager: String, + id: Long + ) { + downloadsDao.delete(manager, id) + } +} diff --git a/test-app/src/main/java/org/readium/r2/testapp/data/db/BookDatabase.kt b/test-app/src/main/java/org/readium/r2/testapp/data/db/BookDatabase.kt index 9706975e7b..4ab5c72b13 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/data/db/BookDatabase.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/db/BookDatabase.kt @@ -11,12 +11,11 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters +import org.readium.r2.testapp.data.model.* import org.readium.r2.testapp.data.model.Book import org.readium.r2.testapp.data.model.Bookmark import org.readium.r2.testapp.data.model.Catalog import org.readium.r2.testapp.data.model.Highlight -import org.readium.r2.testapp.data.model.HighlightConverters -import org.readium.r2.testapp.domain.model.* @Database( entities = [Book::class, Bookmark::class, Highlight::class, Catalog::class], diff --git a/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadDatabase.kt b/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadDatabase.kt index 3d15ae333a..54eeb270c3 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadDatabase.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadDatabase.kt @@ -11,9 +11,8 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters +import org.readium.r2.testapp.data.model.* import org.readium.r2.testapp.data.model.Download -import org.readium.r2.testapp.data.model.HighlightConverters -import org.readium.r2.testapp.domain.model.* @Database( entities = [Download::class], diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt index 1f416541d0..31ce5dd4e9 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt @@ -45,7 +45,7 @@ import org.readium.r2.streamer.PublicationFactory import org.readium.r2.testapp.PublicationError import org.readium.r2.testapp.R import org.readium.r2.testapp.data.BookRepository -import org.readium.r2.testapp.data.db.DownloadDatabase +import org.readium.r2.testapp.data.DownloadRepository import org.readium.r2.testapp.utils.extensions.copyToTempFile import org.readium.r2.testapp.utils.extensions.moveTo import org.readium.r2.testapp.utils.tryOrNull @@ -54,6 +54,7 @@ import timber.log.Timber class Bookshelf( private val context: Context, private val bookRepository: BookRepository, + private val downloadRepository: DownloadRepository, private val storageDir: File, private val lcpService: Try, private val publicationFactory: PublicationFactory, @@ -125,9 +126,8 @@ class Bookshelf( File(storageDir, "covers/") .apply { if (!exists()) mkdirs() } - private val opdsDownloader = - DownloadDatabase.getDatabase(context).downloadsDao() - .let { dao -> OpdsDownloader(dao, downloadManagerProvider, OpdsDownloaderListener()) } + private val opdsDownloader: OpdsDownloader = + OpdsDownloader(downloadRepository, downloadManagerProvider, OpdsDownloaderListener()) private inner class OpdsDownloaderListener : OpdsDownloader.Listener { override fun onDownloadCompleted(publication: String, cover: String?) { diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt index 9cf43a4cfe..6a00538c03 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt @@ -19,11 +19,10 @@ import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.getOrElse import org.readium.r2.shared.util.mediatype.MediaType -import org.readium.r2.testapp.data.db.DownloadsDao -import org.readium.r2.testapp.data.model.Download +import org.readium.r2.testapp.data.DownloadRepository class OpdsDownloader( - private val downloadsDao: DownloadsDao, + private val downloadRepository: DownloadRepository, private val downloadManagerProvider: DownloadManagerProvider, private val listener: Listener ) { @@ -49,8 +48,8 @@ class OpdsDownloader( private inner class DownloadListener : DownloadManager.Listener { override fun onDownloadCompleted(requestId: DownloadManager.RequestId, destUri: Uri) { coroutineScope.launch { - val cover = downloadsDao.get(managerName, requestId.value)!!.extra - downloadsDao.delete(managerName, requestId.value) + val cover = downloadRepository.getOpdsDownloadCover(managerName, requestId.value) + downloadRepository.removeDownload(managerName, requestId.value) listener.onDownloadCompleted(destUri.path!!, cover) } } @@ -98,8 +97,11 @@ class OpdsDownloader( "Downloading" ) ) - val download = Download(managerName, requestId.value, coverUrl) - downloadsDao.insert(download) + downloadRepository.insertOpdsDownload( + manager = managerName, + id = requestId.value, + cover = coverUrl + ) } private fun getDownloadURL(publication: Publication): Try = diff --git a/test-app/src/main/res/values/strings.xml b/test-app/src/main/res/values/strings.xml index ec84a65f6b..5a31044843 100644 --- a/test-app/src/main/res/values/strings.xml +++ b/test-app/src/main/res/values/strings.xml @@ -95,7 +95,7 @@ Unable to add publication due to an unexpected error on your device Publication download failed. - Publication download failed. + Acquisition is not possible. Publication added to your library Unable to add publication to the database From 8a5f1cfae37a3eb7ae8ed75bef286750d9a15beb Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Fri, 25 Aug 2023 15:56:24 +0200 Subject: [PATCH 09/35] Centralize import feedback --- .../org/readium/r2/testapp/MainActivity.kt | 25 ++++- .../org/readium/r2/testapp/MainViewModel.kt | 61 +++++++++++ .../r2/testapp/bookshelf/BookshelfFragment.kt | 62 +++++------ .../testapp/bookshelf/BookshelfViewModel.kt | 100 ++++-------------- .../r2/testapp/catalogs/CatalogFragment.kt | 8 +- .../r2/testapp/catalogs/CatalogViewModel.kt | 48 ++------- .../catalogs/PublicationDetailFragment.kt | 21 ---- .../readium/r2/testapp/data/BookRepository.kt | 17 +-- .../readium/r2/testapp/domain/Bookshelf.kt | 37 +++++-- .../testapp/{ => domain}/PublicationError.kt | 3 +- .../r2/testapp/reader/ReaderRepository.kt | 14 ++- 11 files changed, 183 insertions(+), 213 deletions(-) create mode 100644 test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt rename test-app/src/main/java/org/readium/r2/testapp/{ => domain}/PublicationError.kt (98%) diff --git a/test-app/src/main/java/org/readium/r2/testapp/MainActivity.kt b/test-app/src/main/java/org/readium/r2/testapp/MainActivity.kt index 219afb408d..9a913699ba 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/MainActivity.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/MainActivity.kt @@ -15,12 +15,12 @@ import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.setupActionBarWithNavController import androidx.navigation.ui.setupWithNavController import com.google.android.material.bottomnavigation.BottomNavigationView -import org.readium.r2.testapp.bookshelf.BookshelfViewModel +import com.google.android.material.snackbar.Snackbar class MainActivity : AppCompatActivity() { private lateinit var navController: NavController - private val viewModel: BookshelfViewModel by viewModels() + private val viewModel: MainViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -44,9 +44,30 @@ class MainActivity : AppCompatActivity() { ) setupActionBarWithNavController(navController, appBarConfiguration) navView.setupWithNavController(navController) + + viewModel.channel.receive(this) { handleEvent(it) } } override fun onSupportNavigateUp(): Boolean { return navController.navigateUp() || super.onSupportNavigateUp() } + + private fun handleEvent(event: MainViewModel.Event) { + val message = + when (event) { + is MainViewModel.Event.ImportPublicationSuccess -> + getString(R.string.import_publication_success) + + is MainViewModel.Event.ImportPublicationError -> { + event.errorMessage + } + } + message.let { + Snackbar.make( + findViewById(android.R.id.content), + it, + Snackbar.LENGTH_LONG + ).show() + } + } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt new file mode 100644 index 0000000000..240d808c7b --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt @@ -0,0 +1,61 @@ +/* + * 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.testapp + +import android.app.Application +import android.net.Uri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import org.readium.r2.testapp.domain.Bookshelf +import org.readium.r2.testapp.utils.EventChannel + +class MainViewModel( + application: Application +) : AndroidViewModel(application) { + + private val app = + getApplication() + + val channel: EventChannel = + EventChannel(Channel(Channel.BUFFERED), viewModelScope) + init { + app.bookshelf.channel.receiveAsFlow() + .onEach { sendImportFeedback(it) } + .launchIn(viewModelScope) + } + fun importPublicationFromUri(uri: Uri) = + viewModelScope.launch { + app.bookshelf.copyPublicationToAppStorage(uri) + } + + private fun sendImportFeedback(event: Bookshelf.Event) { + when (event) { + is Bookshelf.Event.ImportPublicationError -> { + val errorMessage = event.error.getUserMessage(app) + channel.send(Event.ImportPublicationError(errorMessage)) + } + Bookshelf.Event.ImportPublicationSuccess -> { + channel.send(Event.ImportPublicationSuccess) + } + } + } + + sealed class Event { + + object ImportPublicationSuccess : + Event() + + class ImportPublicationError( + val errorMessage: String + ) : Event() + } +} diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfFragment.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfFragment.kt index 7d4841702e..332d1809ca 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfFragment.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfFragment.kt @@ -17,7 +17,6 @@ import android.webkit.URLUtil import android.widget.EditText import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.RecyclerView @@ -68,8 +67,7 @@ class BookshelfFragment : Fragment() { appStoragePickerLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> uri?.let { - binding.bookshelfProgressBar.visibility = View.VISIBLE - bookshelfViewModel.importPublicationFromUri(it) + bookshelfViewModel.copyPublicationToAppStorage(it) } } @@ -78,8 +76,7 @@ class BookshelfFragment : Fragment() { uri?.let { val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) - binding.bookshelfProgressBar.visibility = View.VISIBLE - bookshelfViewModel.addSharedStoragePublication(it) + bookshelfViewModel.addPublicationFromSharedStorage(it) } } @@ -98,7 +95,6 @@ class BookshelfFragment : Fragment() { bookshelfAdapter.submitList(it) } - // FIXME embedded dialogs like this are ugly binding.bookshelfAddBookFab.setOnClickListener { var selected = 0 MaterialAlertDialogBuilder(requireContext()) @@ -107,33 +103,10 @@ class BookshelfFragment : Fragment() { dialog.cancel() } .setPositiveButton(getString(R.string.ok)) { _, _ -> - when (selected) { 0 -> appStoragePickerLauncher.launch("*/*") 1 -> sharedStoragePickerLauncher.launch(arrayOf("*/*")) - else -> { - val urlEditText = EditText(requireContext()) - val urlDialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(R.string.add_book)) - .setMessage(R.string.enter_url) - .setView(urlEditText) - .setNegativeButton(R.string.cancel) { dialog, _ -> - dialog.cancel() - } - .setPositiveButton(getString(R.string.ok), null) - .show() - urlDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { - val url = Url(urlEditText.text.toString()) - if (url == null || !URLUtil.isValidUrl(urlEditText.text.toString())) { - urlEditText.error = getString(R.string.invalid_url) - return@setOnClickListener - } - - binding.bookshelfProgressBar.visibility = View.VISIBLE - bookshelfViewModel.addRemotePublication(url) - urlDialog.dismiss() - } - } + else -> askForRemoteUrl() } } .setSingleChoiceItems(R.array.documentSelectorArray, 0) { _, which -> @@ -143,16 +116,30 @@ class BookshelfFragment : Fragment() { } } + private fun askForRemoteUrl() { + val urlEditText = EditText(requireContext()) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.add_book)) + .setMessage(R.string.enter_url) + .setView(urlEditText) + .setNegativeButton(R.string.cancel) { dialog, _ -> + dialog.cancel() + } + .setPositiveButton(getString(R.string.ok)) { _, _ -> + val url = Url(urlEditText.text.toString()) + if (url == null || !URLUtil.isValidUrl(urlEditText.text.toString())) { + urlEditText.error = getString(R.string.invalid_url) + return@setPositiveButton + } + + bookshelfViewModel.addPublicationFromTheWeb(url) + } + .show() + } + private fun handleEvent(event: BookshelfViewModel.Event) { val message = when (event) { - is BookshelfViewModel.Event.ImportPublicationSuccess -> - getString(R.string.import_publication_success) - - is BookshelfViewModel.Event.ImportPublicationError -> { - event.errorMessage - } - is BookshelfViewModel.Event.OpenPublicationError -> { event.errorMessage } @@ -166,7 +153,6 @@ class BookshelfFragment : Fragment() { null } } - binding.bookshelfProgressBar.visibility = View.GONE message?.let { Snackbar.make( requireView(), diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt index 29a1854ce0..ea4e9320db 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt @@ -8,123 +8,67 @@ package org.readium.r2.testapp.bookshelf import android.app.Activity import android.app.Application -import android.content.Context import android.net.Uri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.readium.r2.shared.util.Url -import org.readium.r2.testapp.BuildConfig import org.readium.r2.testapp.data.model.Book -import org.readium.r2.testapp.domain.Bookshelf import org.readium.r2.testapp.reader.ReaderActivityContract import org.readium.r2.testapp.utils.EventChannel -import org.readium.r2.testapp.utils.extensions.copyToTempFile class BookshelfViewModel(application: Application) : AndroidViewModel(application) { private val app get() = getApplication() - private val preferences = - application.getSharedPreferences("org.readium.r2.settings", Context.MODE_PRIVATE) - val channel = EventChannel(Channel(Channel.BUFFERED), viewModelScope) val books = app.bookRepository.books() - init { - copySamplesFromAssetsToStorage() - app.bookshelf.channel.receiveAsFlow() - .onEach { sendImportFeedback(it) } - .launchIn(viewModelScope) - } - - private fun copySamplesFromAssetsToStorage() = viewModelScope.launch(Dispatchers.IO) { - withContext(Dispatchers.IO) { - if (!preferences.contains("samples")) { - val dir = app.storageDir - if (!dir.exists()) { - dir.mkdirs() - } - val samples = app.assets.list("Samples")?.filterNotNull().orEmpty() - for (element in samples) { - val file = - app.assets.open("Samples/$element").copyToTempFile(app.storageDir) - if (file != null) { - app.bookshelf.addLocalBook(file) - } else if (BuildConfig.DEBUG) { - error("Unable to load sample into the library") - } - } - preferences.edit().putBoolean("samples", true).apply() - } - } - } - fun deletePublication(book: Book) = viewModelScope.launch { - app.bookRepository.deleteBook(book) - } - - fun importPublicationFromUri(uri: Uri) = - viewModelScope.launch { - app.bookshelf.importBook(uri) + app.bookshelf.deleteBook(book) } - fun addSharedStoragePublication(uri: Uri) = + fun copyPublicationToAppStorage(uri: Uri) { viewModelScope.launch { - app.bookshelf.addSharedStorageBook(Url(uri.toString())!!) + app.bookshelf.copyPublicationToAppStorage(uri) } + } - fun addRemotePublication(url: Url) { + fun addPublicationFromSharedStorage(uri: Uri) { viewModelScope.launch { - app.bookshelf.addRemoteBook(url) + app.bookshelf.addPublicationFromSharedStorage(Url(uri.toString())!!) } } - private fun sendImportFeedback(event: Bookshelf.Event) { - when (event) { - is Bookshelf.Event.ImportPublicationError -> { - val errorMessage = event.error.getUserMessage(app) - channel.send(Event.ImportPublicationError(errorMessage)) - } - Bookshelf.Event.ImportPublicationSuccess -> { - channel.send(Event.ImportPublicationSuccess) - } + fun addPublicationFromTheWeb(url: Url) { + viewModelScope.launch { + app.bookshelf.addPublicationFromTheWeb(url) } } fun openPublication( bookId: Long, activity: Activity - ) = viewModelScope.launch { - val readerRepository = app.readerRepository.await() - readerRepository.open(bookId, activity) - .onFailure { error -> - val message = error.getUserMessage(app) - channel.send(Event.OpenPublicationError(message)) - } - .onSuccess { - val arguments = ReaderActivityContract.Arguments(bookId) - channel.send(Event.LaunchReader(arguments)) - } + ) { + viewModelScope.launch { + val readerRepository = app.readerRepository.await() + readerRepository.open(bookId, activity) + .onFailure { error -> + val message = error.getUserMessage(app) + channel.send(Event.OpenPublicationError(message)) + } + .onSuccess { + val arguments = ReaderActivityContract.Arguments(bookId) + channel.send(Event.LaunchReader(arguments)) + } + } } sealed class Event { - object ImportPublicationSuccess : - Event() - - class ImportPublicationError( - val errorMessage: String - ) : Event() - class OpenPublicationError( val errorMessage: String ) : Event() diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFragment.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFragment.kt index 8a917a0d7c..6d8df60390 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFragment.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFragment.kt @@ -49,7 +49,7 @@ class CatalogFragment : Fragment() { container: ViewGroup?, savedInstanceState: Bundle? ): View { - catalogViewModel.eventChannel.receive(this) { handleEvent(it) } + catalogViewModel.channel.receive(this) { handleEvent(it) } catalog = arguments?.let { BundleCompat.getParcelable(it, CATALOGFEED, Catalog::class.java) }!! binding = FragmentCatalogBinding.inflate(inflater, container, false) @@ -130,9 +130,9 @@ class CatalogFragment : Fragment() { ) } - private fun handleEvent(event: CatalogViewModel.Event.FeedEvent) { + private fun handleEvent(event: CatalogViewModel.Event) { when (event) { - is CatalogViewModel.Event.FeedEvent.CatalogParseFailed -> { + is CatalogViewModel.Event.CatalogParseFailed -> { Snackbar.make( requireView(), getString(R.string.failed_parsing_catalog), @@ -140,7 +140,7 @@ class CatalogFragment : Fragment() { ).show() } - is CatalogViewModel.Event.FeedEvent.CatalogParseSuccess -> { + is CatalogViewModel.Event.CatalogParseSuccess -> { facets = event.result.feed?.facets ?: mutableListOf() if (facets.size > 0) { diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt index 4719a80a1b..b7fff46481 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt @@ -11,9 +11,6 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import java.net.MalformedURLException import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import org.readium.r2.opds.OPDS1Parser import org.readium.r2.opds.OPDS2Parser @@ -22,24 +19,16 @@ import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.http.HttpRequest import org.readium.r2.testapp.data.model.Catalog -import org.readium.r2.testapp.domain.Bookshelf import org.readium.r2.testapp.utils.EventChannel import timber.log.Timber class CatalogViewModel(application: Application) : AndroidViewModel(application) { - val detailChannel = EventChannel(Channel(Channel.BUFFERED), viewModelScope) - val eventChannel = EventChannel(Channel(Channel.BUFFERED), viewModelScope) + val channel = EventChannel(Channel(Channel.BUFFERED), viewModelScope) lateinit var publication: Publication private val app = getApplication() - init { - app.bookshelf.channel.receiveAsFlow() - .onEach { sendImportFeedback(it) } - .launchIn(viewModelScope) - } - fun parseCatalog(catalog: Catalog) = viewModelScope.launch { var parseRequest: Try? = null catalog.href.let { @@ -51,15 +40,15 @@ class CatalogViewModel(application: Application) : AndroidViewModel(application) OPDS2Parser.parseRequest(request, app.readium.httpClient) } } catch (e: MalformedURLException) { - eventChannel.send(Event.FeedEvent.CatalogParseFailed) + channel.send(Event.CatalogParseFailed) } } parseRequest?.onSuccess { - eventChannel.send(Event.FeedEvent.CatalogParseSuccess(it)) + channel.send(Event.CatalogParseSuccess(it)) } parseRequest?.onFailure { Timber.e(it) - eventChannel.send(Event.FeedEvent.CatalogParseFailed) + channel.send(Event.CatalogParseFailed) } } @@ -67,35 +56,10 @@ class CatalogViewModel(application: Application) : AndroidViewModel(application) app.bookshelf.importOpdsPublication(publication) } - private fun sendImportFeedback(event: Bookshelf.Event) { - when (event) { - is Bookshelf.Event.ImportPublicationError -> { - val errorMessage = event.error.getUserMessage(app) - detailChannel.send(Event.DetailEvent.ImportPublicationFailed(errorMessage)) - } - - Bookshelf.Event.ImportPublicationSuccess -> { - detailChannel.send(Event.DetailEvent.ImportPublicationSuccess) - } - } - } - sealed class Event { - sealed class FeedEvent : Event() { - - object CatalogParseFailed : FeedEvent() - - class CatalogParseSuccess(val result: ParseData) : FeedEvent() - } + object CatalogParseFailed : Event() - sealed class DetailEvent : Event() { - - object ImportPublicationSuccess : DetailEvent() - - class ImportPublicationFailed( - private val message: String - ) : DetailEvent() - } + class CatalogParseSuccess(val result: ParseData) : Event() } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationDetailFragment.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationDetailFragment.kt index 3625d3a7d6..e0dccc8bca 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationDetailFragment.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/PublicationDetailFragment.kt @@ -12,12 +12,10 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import com.google.android.material.snackbar.Snackbar import com.squareup.picasso.Picasso import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.publication.opds.images import org.readium.r2.testapp.MainActivity -import org.readium.r2.testapp.R import org.readium.r2.testapp.databinding.FragmentPublicationDetailBinding class PublicationDetailFragment : Fragment() { @@ -38,7 +36,6 @@ class PublicationDetailFragment : Fragment() { container, false ) - catalogViewModel.detailChannel.receive(this) { handleEvent(it) } publication = catalogViewModel.publication return binding.root } @@ -62,22 +59,4 @@ class PublicationDetailFragment : Fragment() { } } } - - private fun handleEvent(event: CatalogViewModel.Event.DetailEvent) { - val message = - when (event) { - is CatalogViewModel.Event.DetailEvent.ImportPublicationSuccess -> getString( - R.string.import_publication_success - ) - is CatalogViewModel.Event.DetailEvent.ImportPublicationFailed -> getString( - R.string.import_publication_unable_add_pub_database - ) - } - binding.catalogDetailProgressBar.visibility = View.GONE - Snackbar.make( - requireView(), - message, - Snackbar.LENGTH_LONG - ).show() - } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/data/BookRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/data/BookRepository.kt index 90f308fad4..4429322ac1 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/data/BookRepository.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/BookRepository.kt @@ -8,7 +8,6 @@ package org.readium.r2.testapp.data import androidx.annotation.ColorInt import androidx.lifecycle.LiveData -import java.io.File import kotlinx.coroutines.flow.Flow import org.joda.time.DateTime import org.readium.r2.shared.asset.AssetType @@ -16,14 +15,12 @@ import org.readium.r2.shared.publication.Locator import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.publication.indexOfFirstWithHref import org.readium.r2.shared.publication.protection.ContentProtection -import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.testapp.data.db.BooksDao import org.readium.r2.testapp.data.model.Book import org.readium.r2.testapp.data.model.Bookmark import org.readium.r2.testapp.data.model.Highlight import org.readium.r2.testapp.utils.extensions.authorName -import org.readium.r2.testapp.utils.tryOrLog class BookRepository( private val booksDao: BooksDao @@ -81,7 +78,7 @@ class BookRepository( booksDao.updateHighlightStyle(id, style, tint) } - suspend fun insertBookIntoDatabase( + suspend fun insertBook( href: String, mediaType: MediaType, assetType: AssetType, @@ -104,16 +101,6 @@ class BookRepository( return booksDao.insertBook(book) } - private suspend fun deleteBookFromDatabase(id: Long) = + suspend fun deleteBook(id: Long) = booksDao.deleteBook(id) - - suspend fun deleteBook(book: Book) { - val id = book.id!! - val url = Url(book.href)!! - if (url.scheme == "file") { - tryOrLog { File(url.path).delete() } - } - File(book.cover).delete() - deleteBookFromDatabase(id) - } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt index 31ce5dd4e9..7b13a82ab9 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt @@ -42,21 +42,22 @@ import org.readium.r2.shared.util.mediatype.FormatRegistry import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.toUrl import org.readium.r2.streamer.PublicationFactory -import org.readium.r2.testapp.PublicationError import org.readium.r2.testapp.R import org.readium.r2.testapp.data.BookRepository import org.readium.r2.testapp.data.DownloadRepository +import org.readium.r2.testapp.data.model.Book import org.readium.r2.testapp.utils.extensions.copyToTempFile import org.readium.r2.testapp.utils.extensions.moveTo +import org.readium.r2.testapp.utils.tryOrLog import org.readium.r2.testapp.utils.tryOrNull import timber.log.Timber class Bookshelf( private val context: Context, private val bookRepository: BookRepository, - private val downloadRepository: DownloadRepository, + downloadRepository: DownloadRepository, private val storageDir: File, - private val lcpService: Try, + lcpService: Try, private val publicationFactory: PublicationFactory, private val assetRetriever: AssetRetriever, private val protectionRetriever: ContentProtectionSchemeRetriever, @@ -86,11 +87,19 @@ class Bookshelf( operator fun invoke( error: AssetRetriever.Error - ): ImportError = PublicationError(org.readium.r2.testapp.PublicationError(error)) + ): ImportError = PublicationError( + org.readium.r2.testapp.domain.PublicationError( + error + ) + ) operator fun invoke( error: Publication.OpeningException - ): ImportError = PublicationError(org.readium.r2.testapp.PublicationError(error)) + ): ImportError = PublicationError( + org.readium.r2.testapp.domain.PublicationError( + error + ) + ) } } @@ -188,7 +197,7 @@ class Bookshelf( val channel: Channel = Channel(Channel.BUFFERED) - suspend fun importBook( + suspend fun copyPublicationToAppStorage( contentUri: Uri ) { contentUri.copyToTempFile(context, storageDir) @@ -209,7 +218,7 @@ class Bookshelf( } } - suspend fun addRemoteBook( + suspend fun addPublicationFromTheWeb( url: Url ) { val asset = assetRetriever.retrieve(url) @@ -231,7 +240,7 @@ class Bookshelf( .onFailure { channel.send(Event.ImportPublicationError(it)) } } - suspend fun addSharedStorageBook( + suspend fun addPublicationFromSharedStorage( url: Url, coverUrl: String? = null ) { @@ -379,7 +388,7 @@ class Bookshelf( return Try.failure(ImportError.ImportBookFailed(e)) } - val id = bookRepository.insertBookIntoDatabase( + val id = bookRepository.insertBook( url.toString(), asset.mediaType, asset.assetType, @@ -427,4 +436,14 @@ class Bookshelf( null } } + + suspend fun deleteBook(book: Book) { + val id = book.id!! + bookRepository.deleteBook(id) + val url = Url(book.href)!! + if (url.scheme == "file") { + tryOrLog { File(url.path).delete() } + } + File(book.cover).delete() + } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/PublicationError.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationError.kt similarity index 98% rename from test-app/src/main/java/org/readium/r2/testapp/PublicationError.kt rename to test-app/src/main/java/org/readium/r2/testapp/domain/PublicationError.kt index c024a4ee3a..32d32bec2e 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/PublicationError.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationError.kt @@ -4,13 +4,14 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.r2.testapp +package org.readium.r2.testapp.domain import androidx.annotation.StringRes import org.readium.r2.shared.UserException import org.readium.r2.shared.asset.AssetRetriever import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.util.Error +import org.readium.r2.testapp.R sealed class PublicationError(@StringRes userMessageId: Int) : UserException(userMessageId) { diff --git a/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt index 4bc02bd87b..e53136038b 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt @@ -28,9 +28,9 @@ import org.readium.r2.shared.publication.services.isRestricted import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.getOrElse -import org.readium.r2.testapp.PublicationError import org.readium.r2.testapp.Readium import org.readium.r2.testapp.data.BookRepository +import org.readium.r2.testapp.domain.PublicationError import org.readium.r2.testapp.reader.preferences.AndroidTtsPreferencesManagerFactory import org.readium.r2.testapp.reader.preferences.EpubPreferencesManagerFactory import org.readium.r2.testapp.reader.preferences.ExoPlayerPreferencesManagerFactory @@ -70,11 +70,19 @@ class ReaderRepository( operator fun invoke( error: AssetRetriever.Error - ): OpeningError = PublicationError(org.readium.r2.testapp.PublicationError(error)) + ): OpeningError = PublicationError( + org.readium.r2.testapp.domain.PublicationError( + error + ) + ) operator fun invoke( error: Publication.OpeningException - ): OpeningError = PublicationError(org.readium.r2.testapp.PublicationError(error)) + ): OpeningError = PublicationError( + org.readium.r2.testapp.domain.PublicationError( + error + ) + ) } } } From 687aeec555043e76419da8e8b8ba983e17af99ad Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Fri, 25 Aug 2023 17:07:53 +0200 Subject: [PATCH 10/35] Various changes --- readium/downloads/build.gradle.kts | 4 +- .../org/readium/downloads/DownloadManager.kt | 11 ++-- .../downloads/DownloadManagerProvider.kt | 13 ++++ .../android/AndroidDownloadManager.kt | 24 +++----- .../readium/r2/lcp/LcpPublicationRetriever.kt | 32 +++++----- .../org/readium/r2/testapp/MainActivity.kt | 14 ++--- .../org/readium/r2/testapp/MainViewModel.kt | 2 +- .../java/org/readium/r2/testapp/Readium.kt | 6 +- .../r2/testapp/catalogs/CatalogViewModel.kt | 2 +- .../readium/r2/testapp/domain/Bookshelf.kt | 60 ++++++++----------- .../r2/testapp/domain/OpdsDownloader.kt | 14 ++--- 11 files changed, 85 insertions(+), 97 deletions(-) diff --git a/readium/downloads/build.gradle.kts b/readium/downloads/build.gradle.kts index 1ee399de82..8a7fc2ab63 100644 --- a/readium/downloads/build.gradle.kts +++ b/readium/downloads/build.gradle.kts @@ -37,9 +37,7 @@ android { proguardFiles(getDefaultProguardFile("proguard-android.txt")) } } - buildFeatures { - viewBinding = true - } + namespace = "org.readium.downloads" } diff --git a/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt b/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt index 5293d2970f..62a33f069f 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt +++ b/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt @@ -6,17 +6,16 @@ package org.readium.downloads -import android.net.Uri -import org.readium.r2.shared.util.Error +import java.io.File import org.readium.r2.shared.util.Url public interface DownloadManager { public data class Request( val url: Url, - val headers: Map>, val title: String, - val description: String + val description: String? = null, + val headers: Map> = emptyMap() ) @JvmInline @@ -48,7 +47,7 @@ public interface DownloadManager { public data object Forbidden : Error() { override val message: String = - "Access to the resource was denied" + "Access to the resource was denied." } public data object DeviceNotFound : Error() { @@ -96,7 +95,7 @@ public interface DownloadManager { public interface Listener { - public fun onDownloadCompleted(requestId: RequestId, destUri: Uri) + public fun onDownloadCompleted(requestId: RequestId, file: File) public fun onDownloadProgressed(requestId: RequestId, downloaded: Long, total: Long) diff --git a/readium/downloads/src/main/java/org/readium/downloads/DownloadManagerProvider.kt b/readium/downloads/src/main/java/org/readium/downloads/DownloadManagerProvider.kt index 6e6e37d3d5..45c4c06e5d 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/DownloadManagerProvider.kt +++ b/readium/downloads/src/main/java/org/readium/downloads/DownloadManagerProvider.kt @@ -6,8 +6,21 @@ package org.readium.downloads +/** + * To be implemented by custom implementations of [DownloadManager]. + * + * Downloads can keep going on the background and the listener be called at any time. + * Naming [DownloadManager]s is useful to retrieve the downloads they own and + * associated data after app restarted. + */ public interface DownloadManagerProvider { + /** + * Creates a [DownloadManager]. + * + * @param listener listener to implement to observe the status of downloads + * @param name name of the download manager + */ public fun createDownloadManager( listener: DownloadManager.Listener, name: String = "default" diff --git a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt b/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt index 38ea898947..be4485f222 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt +++ b/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt @@ -10,7 +10,7 @@ import android.app.DownloadManager as SystemDownloadManager import android.content.Context import android.database.Cursor import android.net.Uri -import java.util.Locale +import java.io.File import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -19,6 +19,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.readium.downloads.DownloadManager import org.readium.r2.shared.units.Hz +import org.readium.r2.shared.util.toUri public class AndroidDownloadManager( private val context: Context, @@ -40,9 +41,7 @@ public class AndroidDownloadManager( private val progressJob: Job = coroutineScope.launch { while (true) { val ids = downloadsRepository.idsForName(name) - val cursor = downloadManager.query( - SystemDownloadManager.Query() - ) + val cursor = downloadManager.query(SystemDownloadManager.Query()) notify(cursor, ids) delay((1.0 / refreshRate.value).seconds) } @@ -55,11 +54,9 @@ public class AndroidDownloadManager( DownloadsRepository(context) public override suspend fun submit(request: DownloadManager.Request): DownloadManager.RequestId { - val uri = Uri.parse(request.url.toString()) - val filename = filenameForUri(uri.toString()) val androidRequest = createRequest( - uri, - filename, + request.url.toUri(), + request.url.filename, request.headers, request.title, request.description @@ -69,23 +66,19 @@ public class AndroidDownloadManager( return DownloadManager.RequestId(downloadId) } - private fun filenameForUri(uri: String): String = - uri.substring(uri.lastIndexOf('/') + 1) - .let { it.substring(0, 1).uppercase(Locale.getDefault()) + it.substring(1) } - private fun createRequest( uri: Uri, filename: String, headers: Map>, title: String, - description: String + description: String? ): SystemDownloadManager.Request = SystemDownloadManager.Request(uri) .setNotificationVisibility(SystemDownloadManager.Request.VISIBILITY_VISIBLE) .setDestination(filename) .setHeaders(headers) .setTitle(title) - .setDescription(description) + .apply { description?.let { setDescription(it) } } .setAllowedOverMetered(true) .setAllowedOverRoaming(true) @@ -116,7 +109,6 @@ public class AndroidDownloadManager( private suspend fun notify(cursor: Cursor, ids: List) = cursor.use { while (cursor.moveToNext()) { val facade = DownloadCursorFacade(cursor) - val id = DownloadManager.RequestId(facade.id) if (id.value !in ids) { @@ -132,7 +124,7 @@ public class AndroidDownloadManager( SystemDownloadManager.STATUS_PENDING -> {} SystemDownloadManager.STATUS_SUCCESSFUL -> { val destUri = Uri.parse(facade.localUri!!) - listener.onDownloadCompleted(id, destUri) + listener.onDownloadCompleted(id, File(destUri.path!!)) downloadManager.remove(id.value) downloadsRepository.removeId(name, id.value) } diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index 75628aa099..8c968ba020 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -1,7 +1,6 @@ package org.readium.r2.lcp import android.content.Context -import android.net.Uri import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit @@ -27,7 +26,6 @@ import org.readium.r2.shared.util.getOrElse import org.readium.r2.shared.util.mediatype.FormatRegistry import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.mediatype.MediaTypeRetriever -import timber.log.Timber private val Context.dataStore: DataStore by preferencesDataStore( name = "readium-lcp-licenses" @@ -38,7 +36,7 @@ private val licensesKey: Preferences.Key = stringPreferencesKey("license public class LcpPublicationRetriever( private val context: Context, private val listener: Listener, - private val downloadManagerProvider: DownloadManagerProvider, + downloadManagerProvider: DownloadManagerProvider, private val mediaTypeRetriever: MediaTypeRetriever ) { @@ -71,15 +69,15 @@ public class LcpPublicationRetriever( override fun onDownloadCompleted( requestId: DownloadManager.RequestId, - destUri: Uri + file: File ) { coroutineScope.launch { val lcpRequestId = RequestId(requestId.value) val acquisition = onDownloadCompleted( requestId.value, - Url(destUri.toString())!! + file ).getOrElse { - tryOrLog { File(destUri.path!!).delete() } + tryOrLog { file.delete() } listener.onAcquisitionFailed(lcpRequestId, LcpException.wrap(it)) return@launch } @@ -128,16 +126,16 @@ public class LcpPublicationRetriever( public suspend fun retrieve( license: ByteArray, downloadTitle: String, - downloadDescription: String + downloadDescription: String? = null ): Try { return try { val licenseDocument = LicenseDocument(license) - Timber.d("license ${licenseDocument.json}") - fetchPublication( + val requestId = fetchPublication( licenseDocument, downloadTitle, downloadDescription - ).let { Try.success(it) } + ) + Try.success(requestId) } catch (e: Exception) { Try.failure(LcpException.wrap(e)) } @@ -158,7 +156,7 @@ public class LcpPublicationRetriever( private suspend fun fetchPublication( license: LicenseDocument, downloadTitle: String, - downloadDescription: String + downloadDescription: String? ): RequestId { val link = license.link(LicenseDocument.Rel.Publication) val url = link?.url @@ -166,10 +164,10 @@ public class LcpPublicationRetriever( val requestId = downloadManager.submit( DownloadManager.Request( - Url(url.toString())!!, - emptyMap(), - downloadTitle, - downloadDescription + url = Url(url.toString())!!, + title = downloadTitle, + description = downloadDescription, + headers = emptyMap() ) ) @@ -180,7 +178,7 @@ public class LcpPublicationRetriever( private suspend fun onDownloadCompleted( id: Long, - dest: Url + file: File ): Try { val licenses = licenses.first() val license = LicenseDocument(licenses[id]!!) @@ -191,8 +189,6 @@ public class LcpPublicationRetriever( val mediaType = mediaTypeRetriever.retrieve(mediaType = link.type) ?: MediaType.EPUB - val file = File(dest.path) - try { // Saves the License Document into the downloaded publication val container = createLicenseContainer(file, mediaType) diff --git a/test-app/src/main/java/org/readium/r2/testapp/MainActivity.kt b/test-app/src/main/java/org/readium/r2/testapp/MainActivity.kt index 9a913699ba..04464b96ef 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/MainActivity.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/MainActivity.kt @@ -27,7 +27,7 @@ class MainActivity : AppCompatActivity() { setContentView(R.layout.activity_main) intent.data?.let { - viewModel.importPublicationFromUri(it) + viewModel.copyPublicationToAppStorage(it) } val navView: BottomNavigationView = findViewById(R.id.nav_view) @@ -62,12 +62,10 @@ class MainActivity : AppCompatActivity() { event.errorMessage } } - message.let { - Snackbar.make( - findViewById(android.R.id.content), - it, - Snackbar.LENGTH_LONG - ).show() - } + Snackbar.make( + findViewById(android.R.id.content), + message, + Snackbar.LENGTH_LONG + ).show() } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt index 240d808c7b..3a91d5ea22 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt @@ -32,7 +32,7 @@ class MainViewModel( .onEach { sendImportFeedback(it) } .launchIn(viewModelScope) } - fun importPublicationFromUri(uri: Uri) = + fun copyPublicationToAppStorage(uri: Uri) = viewModelScope.launch { app.bookshelf.copyPublicationToAppStorage(uri) } diff --git a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt index 39e47dd41b..50647eba6b 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt @@ -8,6 +8,7 @@ package org.readium.r2.testapp import android.content.Context import org.readium.adapters.pdfium.document.PdfiumDocumentFactory +import org.readium.downloads.android.AndroidDownloadManager import org.readium.downloads.android.AndroidDownloadManagerProvider import org.readium.r2.lcp.LcpService import org.readium.r2.navigator.preferences.FontFamily @@ -67,7 +68,10 @@ class Readium(context: Context) { context.contentResolver ) - val downloadManagerProvider = AndroidDownloadManagerProvider(context) + val downloadManagerProvider = AndroidDownloadManagerProvider( + context = context, + destStorage = AndroidDownloadManager.Storage.App + ) /** * The LCP service decrypts LCP-protected publication and acquire publications from a diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt index b7fff46481..95a867208e 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt @@ -53,7 +53,7 @@ class CatalogViewModel(application: Application) : AndroidViewModel(application) } fun downloadPublication(publication: Publication) = viewModelScope.launch { - app.bookshelf.importOpdsPublication(publication) + app.bookshelf.downloadPublicationFromOpds(publication) } sealed class Event { diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt index 7b13a82ab9..9c0470a1c2 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt @@ -120,7 +120,7 @@ class Bookshelf( } sealed class Event { - object ImportPublicationSuccess : + data object ImportPublicationSuccess : Event() class ImportPublicationError( @@ -139,9 +139,9 @@ class Bookshelf( OpdsDownloader(downloadRepository, downloadManagerProvider, OpdsDownloaderListener()) private inner class OpdsDownloaderListener : OpdsDownloader.Listener { - override fun onDownloadCompleted(publication: String, cover: String?) { + override fun onDownloadCompleted(publication: File, cover: String?) { coroutineScope.launch { - addLocalBook(File(publication), cover) + addLocalBook(publication, cover) } } @@ -201,11 +201,14 @@ class Bookshelf( contentUri: Uri ) { contentUri.copyToTempFile(context, storageDir) - .mapFailure { ImportError.ImportBookFailed(it) } - .map { addLocalBook(it) } + .getOrElse { + channel.send( + Event.ImportPublicationError(ImportError.ImportBookFailed(it)) + ) + } } - suspend fun importOpdsPublication( + suspend fun downloadPublicationFromOpds( publication: Publication ) { opdsDownloader.download(publication) @@ -223,15 +226,7 @@ class Bookshelf( ) { val asset = assetRetriever.retrieve(url) ?: run { - channel.send( - Event.ImportPublicationError( - ImportError.PublicationError( - PublicationError.UnsupportedPublication( - Publication.OpeningException.UnsupportedAsset() - ) - ) - ) - ) + channel.send(mediaTypeNotSupportedEvent()) return } @@ -246,17 +241,7 @@ class Bookshelf( ) { val asset = assetRetriever.retrieve(url) ?: run { - channel.send( - Event.ImportPublicationError( - ImportError.PublicationError( - PublicationError.UnsupportedPublication( - Publication.OpeningException.UnsupportedAsset( - "Unsupported media type" - ) - ) - ) - ) - ) + channel.send(mediaTypeNotSupportedEvent()) return } @@ -271,15 +256,7 @@ class Bookshelf( ) { val sourceAsset = assetRetriever.retrieve(tempFile) ?: run { - channel.send( - Event.ImportPublicationError( - ImportError.PublicationError( - PublicationError.UnsupportedPublication( - Publication.OpeningException.UnsupportedAsset() - ) - ) - ) - ) + channel.send(mediaTypeNotSupportedEvent()) return } @@ -321,6 +298,17 @@ class Bookshelf( } } + private fun mediaTypeNotSupportedEvent(): Event.ImportPublicationError = + Event.ImportPublicationError( + ImportError.PublicationError( + PublicationError.UnsupportedPublication( + Publication.OpeningException.UnsupportedAsset( + "Unsupported media type" + ) + ) + ) + ) + private suspend fun acquireLcpPublication(licenceAsset: Asset.Resource) { val lcpRetriever = lcpPublicationRetriever .getOrElse { @@ -444,6 +432,6 @@ class Bookshelf( if (url.scheme == "file") { tryOrLog { File(url.path).delete() } } - File(book.cover).delete() + tryOrLog { File(book.cover).delete() } } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt index 6a00538c03..9bcbb605f4 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt @@ -6,7 +6,7 @@ package org.readium.r2.testapp.domain -import android.net.Uri +import java.io.File import java.net.URL import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope @@ -29,7 +29,7 @@ class OpdsDownloader( interface Listener { - fun onDownloadCompleted(publication: String, cover: String?) + fun onDownloadCompleted(publication: File, cover: String?) fun onDownloadFailed(error: DownloadManager.Error) } @@ -46,11 +46,11 @@ class OpdsDownloader( ) private inner class DownloadListener : DownloadManager.Listener { - override fun onDownloadCompleted(requestId: DownloadManager.RequestId, destUri: Uri) { + override fun onDownloadCompleted(requestId: DownloadManager.RequestId, file: File) { coroutineScope.launch { val cover = downloadRepository.getOpdsDownloadCover(managerName, requestId.value) downloadRepository.removeDownload(managerName, requestId.value) - listener.onDownloadCompleted(destUri.path!!, cover) + listener.onDownloadCompleted(file, cover) } } @@ -92,9 +92,9 @@ class OpdsDownloader( val requestId = downloadManager.submit( DownloadManager.Request( Url(publicationUrl)!!, - emptyMap(), - publicationTitle ?: "Untitled publication", - "Downloading" + title = publicationTitle ?: "Untitled publication", + description = "Downloading", + headers = emptyMap() ) ) downloadRepository.insertOpdsDownload( From a4a879b6ff4b4ef6436f11a7eb500c9ed9ad1c40 Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Fri, 25 Aug 2023 19:53:08 +0200 Subject: [PATCH 11/35] Various changes --- .../org/readium/r2/lcp/LcpPublicationRetriever.kt | 4 ++++ .../main/java/org/readium/r2/testapp/MainViewModel.kt | 1 + .../readium/r2/testapp/data/db/DownloadDatabase.kt | 2 -- .../java/org/readium/r2/testapp/domain/Bookshelf.kt | 11 +++++++++-- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index 8c968ba020..2f7b290916 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -153,6 +153,10 @@ public class LcpPublicationRetriever( } } + public suspend fun close() { + downloadManager.close() + } + private suspend fun fetchPublication( license: LicenseDocument, downloadTitle: String, diff --git a/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt index 3a91d5ea22..c74b906a88 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt @@ -27,6 +27,7 @@ class MainViewModel( val channel: EventChannel = EventChannel(Channel(Channel.BUFFERED), viewModelScope) + init { app.bookshelf.channel.receiveAsFlow() .onEach { sendImportFeedback(it) } diff --git a/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadDatabase.kt b/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadDatabase.kt index 54eeb270c3..ff10c78062 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadDatabase.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadDatabase.kt @@ -10,7 +10,6 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase -import androidx.room.TypeConverters import org.readium.r2.testapp.data.model.* import org.readium.r2.testapp.data.model.Download @@ -19,7 +18,6 @@ import org.readium.r2.testapp.data.model.Download version = 1, exportSchema = false ) -@TypeConverters(HighlightConverters::class) abstract class DownloadDatabase : RoomDatabase() { abstract fun downloadsDao(): DownloadsDao diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt index 9c0470a1c2..5ff0800a80 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt @@ -136,7 +136,11 @@ class Bookshelf( .apply { if (!exists()) mkdirs() } private val opdsDownloader: OpdsDownloader = - OpdsDownloader(downloadRepository, downloadManagerProvider, OpdsDownloaderListener()) + OpdsDownloader( + downloadRepository, + downloadManagerProvider, + OpdsDownloaderListener() + ) private inner class OpdsDownloaderListener : OpdsDownloader.Listener { override fun onDownloadCompleted(publication: File, cover: String?) { @@ -200,12 +204,15 @@ class Bookshelf( suspend fun copyPublicationToAppStorage( contentUri: Uri ) { - contentUri.copyToTempFile(context, storageDir) + val tempFile = contentUri.copyToTempFile(context, storageDir) .getOrElse { channel.send( Event.ImportPublicationError(ImportError.ImportBookFailed(it)) ) + return } + + addLocalBook(tempFile) } suspend fun downloadPublicationFromOpds( From e715b5d3a482a9fe3acf926ce258609c344899ed Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Thu, 31 Aug 2023 20:25:21 +0200 Subject: [PATCH 12/35] Small changess --- .../org/readium/downloads/DownloadManager.kt | 2 +- .../android/AndroidDownloadManager.kt | 69 +++++++++++++------ .../android/AndroidDownloadManagerProvider.kt | 3 +- .../downloads/android/DownloadCursorFacade.kt | 8 ++- .../downloads/android/DownloadsRepository.kt | 55 ++++++++------- .../readium/r2/lcp/LcpPublicationRetriever.kt | 18 ++--- .../org/readium/r2/testapp/Application.kt | 7 +- .../catalogs/CatalogFeedListViewModel.kt | 4 +- .../db/{BookDatabase.kt => AppDatabase.kt} | 14 ++-- .../r2/testapp/data/db/DownloadDatabase.kt | 45 ------------ .../readium/r2/testapp/domain/Bookshelf.kt | 2 +- .../r2/testapp/domain/OpdsDownloader.kt | 2 +- 12 files changed, 112 insertions(+), 117 deletions(-) rename test-app/src/main/java/org/readium/r2/testapp/data/db/{BookDatabase.kt => AppDatabase.kt} (80%) delete mode 100644 test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadDatabase.kt diff --git a/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt b/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt index 62a33f069f..75120a7ed0 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt +++ b/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt @@ -97,7 +97,7 @@ public interface DownloadManager { public fun onDownloadCompleted(requestId: RequestId, file: File) - public fun onDownloadProgressed(requestId: RequestId, downloaded: Long, total: Long) + public fun onDownloadProgressed(requestId: RequestId, downloaded: Long, expected: Long?) public fun onDownloadFailed(requestId: RequestId, error: Error) } diff --git a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt b/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt index be4485f222..e9c6c8f98b 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt +++ b/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt @@ -15,13 +15,14 @@ import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.readium.downloads.DownloadManager import org.readium.r2.shared.units.Hz import org.readium.r2.shared.util.toUri -public class AndroidDownloadManager( +public class AndroidDownloadManager internal constructor( private val context: Context, private val name: String, private val destStorage: Storage, @@ -38,28 +39,31 @@ public class AndroidDownloadManager( private val coroutineScope: CoroutineScope = MainScope() - private val progressJob: Job = coroutineScope.launch { - while (true) { - val ids = downloadsRepository.idsForName(name) - val cursor = downloadManager.query(SystemDownloadManager.Query()) - notify(cursor, ids) - delay((1.0 / refreshRate.value).seconds) - } - } - private val downloadManager: SystemDownloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as SystemDownloadManager private val downloadsRepository: DownloadsRepository = DownloadsRepository(context) + private var observeProgressJob: Job? = + null + + init { + coroutineScope.launch { + if (downloadsRepository.hasDownloadsOngoing()) { + startObservingProgress() + } + } + } + public override suspend fun submit(request: DownloadManager.Request): DownloadManager.RequestId { + startObservingProgress() val androidRequest = createRequest( - request.url.toUri(), - request.url.filename, - request.headers, - request.title, - request.description + uri = request.url.toUri(), + filename = request.url.filename, + headers = request.headers, + title = request.title, + description = request.description ) val downloadId = downloadManager.enqueue(androidRequest) downloadsRepository.addId(name, downloadId) @@ -106,6 +110,26 @@ public class AndroidDownloadManager( return this } + private fun startObservingProgress() { + if (observeProgressJob != null) { + return + } + + observeProgressJob = coroutineScope.launch { + while (true) { + val ids = downloadsRepository.idsForName(name) + val cursor = downloadManager.query(SystemDownloadManager.Query()) + notify(cursor, ids) + delay((1.0 / refreshRate.value).seconds) + } + } + } + + private fun stopObservingProgress() { + observeProgressJob?.cancel() + observeProgressJob = null + } + private suspend fun notify(cursor: Cursor, ids: List) = cursor.use { while (cursor.moveToNext()) { val facade = DownloadCursorFacade(cursor) @@ -119,6 +143,10 @@ public class AndroidDownloadManager( SystemDownloadManager.STATUS_FAILED -> { listener.onDownloadFailed(id, mapErrorCode(facade.reason!!)) downloadManager.remove(id.value) + downloadsRepository.removeId(name, id.value) + if (!downloadsRepository.hasDownloadsOngoing()) { + stopObservingProgress() + } } SystemDownloadManager.STATUS_PAUSED -> {} SystemDownloadManager.STATUS_PENDING -> {} @@ -127,12 +155,13 @@ public class AndroidDownloadManager( listener.onDownloadCompleted(id, File(destUri.path!!)) downloadManager.remove(id.value) downloadsRepository.removeId(name, id.value) + if (!downloadsRepository.hasDownloadsOngoing()) { + stopObservingProgress() + } } SystemDownloadManager.STATUS_RUNNING -> { - val total = facade.total - if (total > 0) { - listener.onDownloadProgressed(id, facade.downloadedSoFar, total) - } + val expected = facade.expected + listener.onDownloadProgressed(id, facade.downloadedSoFar, expected) } } } @@ -169,6 +198,6 @@ public class AndroidDownloadManager( } public override suspend fun close() { - progressJob.cancel() + coroutineScope.cancel() } } diff --git a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManagerProvider.kt b/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManagerProvider.kt index bc001d70f1..4da7a300d2 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManagerProvider.kt +++ b/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManagerProvider.kt @@ -16,7 +16,6 @@ import org.readium.r2.shared.units.hz public class AndroidDownloadManagerProvider( private val context: Context, private val destStorage: AndroidDownloadManager.Storage = AndroidDownloadManager.Storage.App, - private val dirType: String = Environment.DIRECTORY_DOWNLOADS, private val refreshRate: Hz = 0.1.hz ) : DownloadManagerProvider { @@ -28,7 +27,7 @@ public class AndroidDownloadManagerProvider( context, name, destStorage, - dirType, + Environment.DIRECTORY_DOWNLOADS, refreshRate, listener ) diff --git a/readium/downloads/src/main/java/org/readium/downloads/android/DownloadCursorFacade.kt b/readium/downloads/src/main/java/org/readium/downloads/android/DownloadCursorFacade.kt index 37ee21834d..58f0b2d89a 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/android/DownloadCursorFacade.kt +++ b/readium/downloads/src/main/java/org/readium/downloads/android/DownloadCursorFacade.kt @@ -21,17 +21,19 @@ internal class DownloadCursorFacade( val localUri: String? = cursor .getColumnIndex(DownloadManager.COLUMN_LOCAL_URI) .also { require(it != -1) } - .let { cursor.getString(it) } + .takeUnless { cursor.isNull(it) } + ?.let { cursor.getString(it) } val status: Int = cursor .getColumnIndex(DownloadManager.COLUMN_STATUS) .also { require(it != -1) } .let { cursor.getInt(it) } - val total: Long = cursor + val expected: Long? = cursor .getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) .also { require(it != -1) } - .let { cursor.getLong(it) } + .takeUnless { cursor.isNull(it) } + ?.let { cursor.getLong(it) } val downloadedSoFar: Long = cursor .getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) diff --git a/readium/downloads/src/main/java/org/readium/downloads/android/DownloadsRepository.kt b/readium/downloads/src/main/java/org/readium/downloads/android/DownloadsRepository.kt index 003bc1a9c2..26d92c9ee3 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/android/DownloadsRepository.kt +++ b/readium/downloads/src/main/java/org/readium/downloads/android/DownloadsRepository.kt @@ -12,6 +12,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import java.util.LinkedList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -30,13 +31,12 @@ internal class DownloadsRepository( private val downloadIds: Flow>> = context.dataStore.data - .map { data -> data[downloadIdsKey] } - .map { string -> string?.toData().orEmpty() } + .map { data -> data.ids } suspend fun addId(name: String, id: Long) { context.dataStore.edit { data -> - val current = downloadIds.first() - val currentThisName = downloadIds.first()[name].orEmpty() + val current = data.ids + val currentThisName = current[name].orEmpty() val newEntryThisName = name to (currentThisName + id) data[downloadIdsKey] = (current + newEntryThisName).toJson() } @@ -44,8 +44,8 @@ internal class DownloadsRepository( suspend fun removeId(name: String, id: Long) { context.dataStore.edit { data -> - val current = downloadIds.first() - val currentThisName = downloadIds.first()[name].orEmpty() + val current = data.ids + val currentThisName = current[name].orEmpty() val newEntryThisName = name to (currentThisName - id) data[downloadIdsKey] = (current + newEntryThisName).toJson() } @@ -55,30 +55,37 @@ internal class DownloadsRepository( return downloadIds.first()[name].orEmpty() } + suspend fun hasDownloadsOngoing(): Boolean = + downloadIds.first().values.flatten().isNotEmpty() + + private val Preferences.ids: Map> + get() = get(downloadIdsKey)?.toData().orEmpty() + private fun Map>.toJson(): String { - val strings = map { idsToJson(it.key, it.value) } - val array = JSONArray(strings) - return array.toString() + val jsonObject = JSONObject() + for ((name, ids) in this.entries) { + jsonObject.put(name, JSONArray(ids)) + } + return jsonObject.toString() } private fun String.toData(): Map> { - val array = JSONArray(this) - val objects = (0 until array.length()).map { array.getJSONObject(it) } - return objects.associate { jsonToIds(it) } + val jsonObject = JSONObject(this) + val names = jsonObject.keys().iterator().toList() + return names.associateWith { jsonToIds(jsonObject.getJSONArray(it)) } } - private fun idsToJson(name: String, downloads: List): JSONObject = - JSONObject() - .put("name", name) - .put("downloads", JSONArray(downloads)) - - private fun jsonToIds(jsonObject: JSONObject): Pair> { - val name = jsonObject.getString("name") - val downloads = jsonObject.getJSONArray("downloads") - val downloadList = mutableListOf() - for (i in 0 until downloads.length()) { - downloadList.add(downloads.getLong(i)) + private fun jsonToIds(jsonArray: JSONArray): List { + val list = mutableListOf() + for (i in 0 until jsonArray.length()) { + list.add(jsonArray.getLong(i)) } - return name to downloadList + return list } + + private fun Iterator.toList(): List = + LinkedList().apply { + while (hasNext()) + this += next() + }.toMutableList() } diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index 2f7b290916..948f863797 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -53,7 +53,7 @@ public class LcpPublicationRetriever( public fun onAcquisitionProgressed( requestId: RequestId, downloaded: Long, - total: Long + expected: Long? ) public fun onAcquisitionFailed( @@ -89,12 +89,12 @@ public class LcpPublicationRetriever( override fun onDownloadProgressed( requestId: DownloadManager.RequestId, downloaded: Long, - total: Long + expected: Long? ) { listener.onAcquisitionProgressed( RequestId(requestId.value), downloaded, - total + expected ) } @@ -120,8 +120,7 @@ public class LcpPublicationRetriever( private val licenses: Flow> = context.dataStore.data - .map { data -> data[licensesKey] } - .map { json -> json?.toLicenses().orEmpty() } + .map { data -> data.licenses } public suspend fun retrieve( license: ByteArray, @@ -168,7 +167,7 @@ public class LcpPublicationRetriever( val requestId = downloadManager.submit( DownloadManager.Request( - url = Url(url.toString())!!, + url = Url(url), title = downloadTitle, description = downloadDescription, headers = emptyMap() @@ -214,18 +213,21 @@ public class LcpPublicationRetriever( private suspend fun persistLicense(id: Long, license: JSONObject) { context.dataStore.edit { data -> val newEntry = id to license - val licenses = licenses.first() + newEntry + val licenses = data.licenses + newEntry data[licensesKey] = licenses.toJson() } } private suspend fun removeLicense(id: Long) { context.dataStore.edit { data -> - val licenses = licenses.first() - id + val licenses = data.licenses - id data[licensesKey] = licenses.toJson() } } + private val Preferences.licenses: Map + get() = get(licensesKey)?.toLicenses().orEmpty() + private fun licenseToJson(id: Long, license: JSONObject): JSONObject = JSONObject() .put("id", id) diff --git a/test-app/src/main/java/org/readium/r2/testapp/Application.kt b/test-app/src/main/java/org/readium/r2/testapp/Application.kt index dd95f1ee17..3120552b77 100755 --- a/test-app/src/main/java/org/readium/r2/testapp/Application.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Application.kt @@ -20,8 +20,7 @@ import kotlinx.coroutines.async import org.readium.r2.testapp.BuildConfig.DEBUG import org.readium.r2.testapp.data.BookRepository import org.readium.r2.testapp.data.DownloadRepository -import org.readium.r2.testapp.data.db.BookDatabase -import org.readium.r2.testapp.data.db.DownloadDatabase +import org.readium.r2.testapp.data.db.AppDatabase import org.readium.r2.testapp.domain.Bookshelf import org.readium.r2.testapp.reader.ReaderRepository import timber.log.Timber @@ -64,11 +63,11 @@ class Application : android.app.Application() { * Initializing repositories */ bookRepository = - BookDatabase.getDatabase(this).booksDao() + AppDatabase.getDatabase(this).booksDao() .let { dao -> BookRepository(dao) } downloadRepository = - DownloadDatabase.getDatabase(this).downloadsDao() + AppDatabase.getDatabase(this).downloadsDao() .let { dao -> DownloadRepository(dao) } bookshelf = diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListViewModel.kt index 1c10180920..ad89cf92e5 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogFeedListViewModel.kt @@ -20,14 +20,14 @@ import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.http.HttpRequest import org.readium.r2.shared.util.http.fetchWithDecoder import org.readium.r2.testapp.data.CatalogRepository -import org.readium.r2.testapp.data.db.BookDatabase +import org.readium.r2.testapp.data.db.AppDatabase import org.readium.r2.testapp.data.model.Catalog import org.readium.r2.testapp.utils.EventChannel class CatalogFeedListViewModel(application: Application) : AndroidViewModel(application) { private val httpClient = getApplication().readium.httpClient - private val catalogDao = BookDatabase.getDatabase(application).catalogDao() + private val catalogDao = AppDatabase.getDatabase(application).catalogDao() private val repository = CatalogRepository(catalogDao) val eventChannel = EventChannel(Channel(Channel.BUFFERED), viewModelScope) diff --git a/test-app/src/main/java/org/readium/r2/testapp/data/db/BookDatabase.kt b/test-app/src/main/java/org/readium/r2/testapp/data/db/AppDatabase.kt similarity index 80% rename from test-app/src/main/java/org/readium/r2/testapp/data/db/BookDatabase.kt rename to test-app/src/main/java/org/readium/r2/testapp/data/db/AppDatabase.kt index 4ab5c72b13..5b5d387065 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/data/db/BookDatabase.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/db/AppDatabase.kt @@ -18,22 +18,24 @@ import org.readium.r2.testapp.data.model.Catalog import org.readium.r2.testapp.data.model.Highlight @Database( - entities = [Book::class, Bookmark::class, Highlight::class, Catalog::class], + entities = [Book::class, Bookmark::class, Highlight::class, Catalog::class, Download::class], version = 1, exportSchema = false ) @TypeConverters(HighlightConverters::class) -abstract class BookDatabase : RoomDatabase() { +abstract class AppDatabase : RoomDatabase() { abstract fun booksDao(): BooksDao abstract fun catalogDao(): CatalogDao + abstract fun downloadsDao(): DownloadsDao + companion object { @Volatile - private var INSTANCE: BookDatabase? = null + private var INSTANCE: AppDatabase? = null - fun getDatabase(context: Context): BookDatabase { + fun getDatabase(context: Context): AppDatabase { val tempInstance = INSTANCE if (tempInstance != null) { return tempInstance @@ -41,8 +43,8 @@ abstract class BookDatabase : RoomDatabase() { synchronized(this) { val instance = Room.databaseBuilder( context.applicationContext, - BookDatabase::class.java, - "books_database" + AppDatabase::class.java, + "database" ).build() INSTANCE = instance return instance diff --git a/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadDatabase.kt b/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadDatabase.kt deleted file mode 100644 index ff10c78062..0000000000 --- a/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadDatabase.kt +++ /dev/null @@ -1,45 +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.testapp.data.db - -import android.content.Context -import androidx.room.Database -import androidx.room.Room -import androidx.room.RoomDatabase -import org.readium.r2.testapp.data.model.* -import org.readium.r2.testapp.data.model.Download - -@Database( - entities = [Download::class], - version = 1, - exportSchema = false -) -abstract class DownloadDatabase : RoomDatabase() { - - abstract fun downloadsDao(): DownloadsDao - - companion object { - @Volatile - private var INSTANCE: DownloadDatabase? = null - - fun getDatabase(context: Context): DownloadDatabase { - val tempInstance = INSTANCE - if (tempInstance != null) { - return tempInstance - } - synchronized(this) { - val instance = Room.databaseBuilder( - context.applicationContext, - DownloadDatabase::class.java, - "downloads_database" - ).build() - INSTANCE = instance - return instance - } - } - } -} diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt index 5ff0800a80..69e67ee57e 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt @@ -180,7 +180,7 @@ class Bookshelf( override fun onAcquisitionProgressed( requestId: LcpPublicationRetriever.RequestId, downloaded: Long, - total: Long + expected: Long? ) { } diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt index 9bcbb605f4..b34dd05561 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt @@ -57,7 +57,7 @@ class OpdsDownloader( override fun onDownloadProgressed( requestId: DownloadManager.RequestId, downloaded: Long, - total: Long + expected: Long? ) { } From 490849ccd53cc1b3b9971da98c5b743b26f18059 Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Fri, 1 Sep 2023 10:31:21 +0200 Subject: [PATCH 13/35] More changes --- .../org/readium/downloads/DownloadManager.kt | 100 +++++++----------- .../android/AndroidDownloadManager.kt | 55 ++++++---- .../readium/r2/lcp/LcpPublicationRetriever.kt | 30 +++--- .../r2/testapp/data/DownloadRepository.kt | 6 +- .../r2/testapp/data/db/DownloadsDao.kt | 4 +- .../readium/r2/testapp/data/model/Download.kt | 2 +- 6 files changed, 97 insertions(+), 100 deletions(-) diff --git a/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt b/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt index 75120a7ed0..40e2cd54b5 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt +++ b/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt @@ -19,78 +19,56 @@ public interface DownloadManager { ) @JvmInline - public value class RequestId(public val value: Long) + public value class RequestId(public val value: String) - public sealed class Error : org.readium.r2.shared.util.Error { + public sealed class Error( + override val message: String, + override val cause: org.readium.r2.shared.util.Error? = null + ) : org.readium.r2.shared.util.Error { - override val cause: org.readium.r2.shared.util.Error? = - null + public class NotFound( + cause: org.readium.r2.shared.util.Error? = null + ) : Error("File not found.", cause) - public data object NotFound : Error() { + public class Unreachable( + cause: org.readium.r2.shared.util.Error? = null + ) : Error("Server is not reachable.", cause) - override val message: String = - "File not found." - } + public class Server( + cause: org.readium.r2.shared.util.Error? = null + ) : Error("An error occurred on the server-side.", cause) - public data object Unreachable : Error() { + public class Forbidden( + cause: org.readium.r2.shared.util.Error? = null + ) : Error("Access to the resource was denied.", cause) - override val message: String = - "Server is not reachable." - } + public class DeviceNotFound( + cause: org.readium.r2.shared.util.Error? = null + ) : Error("The storage device is missing.", cause) - public data object Server : Error() { + public class CannotResume( + cause: org.readium.r2.shared.util.Error? = null + ) : Error("Download couldn't be resumed.", cause) - override val message: String = - "An error occurred on the server-side." - } + public class InsufficientSpace( + cause: org.readium.r2.shared.util.Error? = null + ) : Error("There is not enough space to complete the download.", cause) - public data object Forbidden : Error() { + public class FileError( + cause: org.readium.r2.shared.util.Error? = null + ) : Error("IO error on the local device.", cause) - override val message: String = - "Access to the resource was denied." - } + public class HttpData( + cause: org.readium.r2.shared.util.Error? = null + ) : Error("A data error occurred at the HTTP level.", cause) - public data object DeviceNotFound : Error() { + public class TooManyRedirects( + cause: org.readium.r2.shared.util.Error? = null + ) : Error("Too many redirects.", cause) - override val message: String = - "The storage device is missing." - } - - public data object CannotResume : Error() { - - override val message: String = - "Download couldn't be resumed." - } - - public data object InsufficientSpace : Error() { - - override val message: String = - "There is not enough space to complete the download." - } - - public data object FileError : Error() { - - override val message: String = - "IO error on the local device." - } - - public data object HttpData : Error() { - - override val message: String = - "A data error occurred at the HTTP level." - } - - public data object TooManyRedirects : Error() { - - override val message: String = - "Too many redirects." - } - - public data object Unknown : Error() { - - override val message: String = - "An unknown error occurred." - } + public class Unknown( + cause: org.readium.r2.shared.util.Error? = null + ) : Error("An unknown error occurred.", cause) } public interface Listener { @@ -104,5 +82,7 @@ public interface DownloadManager { public suspend fun submit(request: Request): RequestId + public suspend fun cancel(requestId: RequestId) + public suspend fun close() } diff --git a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt b/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt index e9c6c8f98b..d7985162aa 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt +++ b/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt @@ -11,6 +11,7 @@ import android.content.Context import android.database.Cursor import android.net.Uri import java.io.File +import java.util.UUID import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -58,16 +59,28 @@ public class AndroidDownloadManager internal constructor( public override suspend fun submit(request: DownloadManager.Request): DownloadManager.RequestId { startObservingProgress() + val dottedExtension = request.url.extension + ?.let { ".$it" } + .orEmpty() val androidRequest = createRequest( uri = request.url.toUri(), - filename = request.url.filename, + filename = "${UUID.randomUUID()}$dottedExtension}", headers = request.headers, title = request.title, description = request.description ) val downloadId = downloadManager.enqueue(androidRequest) downloadsRepository.addId(name, downloadId) - return DownloadManager.RequestId(downloadId) + return DownloadManager.RequestId(downloadId.toString()) + } + + public override suspend fun cancel(requestId: DownloadManager.RequestId) { + val longId = requestId.value.toLong() + downloadManager.remove() + downloadsRepository.removeId(name, longId) + if (!downloadsRepository.hasDownloadsOngoing()) { + stopObservingProgress() + } } private fun createRequest( @@ -133,17 +146,17 @@ public class AndroidDownloadManager internal constructor( private suspend fun notify(cursor: Cursor, ids: List) = cursor.use { while (cursor.moveToNext()) { val facade = DownloadCursorFacade(cursor) - val id = DownloadManager.RequestId(facade.id) + val id = DownloadManager.RequestId(facade.id.toString()) - if (id.value !in ids) { + if (facade.id !in ids) { continue } when (facade.status) { SystemDownloadManager.STATUS_FAILED -> { listener.onDownloadFailed(id, mapErrorCode(facade.reason!!)) - downloadManager.remove(id.value) - downloadsRepository.removeId(name, id.value) + downloadManager.remove(facade.id) + downloadsRepository.removeId(name, facade.id) if (!downloadsRepository.hasDownloadsOngoing()) { stopObservingProgress() } @@ -153,8 +166,8 @@ public class AndroidDownloadManager internal constructor( SystemDownloadManager.STATUS_SUCCESSFUL -> { val destUri = Uri.parse(facade.localUri!!) listener.onDownloadCompleted(id, File(destUri.path!!)) - downloadManager.remove(id.value) - downloadsRepository.removeId(name, id.value) + downloadManager.remove(facade.id) + downloadsRepository.removeId(name, facade.id) if (!downloadsRepository.hasDownloadsOngoing()) { stopObservingProgress() } @@ -170,31 +183,31 @@ public class AndroidDownloadManager internal constructor( private fun mapErrorCode(code: Int): DownloadManager.Error = when (code) { 401, 403 -> - DownloadManager.Error.Forbidden + DownloadManager.Error.Forbidden() 404 -> - DownloadManager.Error.NotFound + DownloadManager.Error.NotFound() 500, 501 -> - DownloadManager.Error.Server + DownloadManager.Error.Server() 502, 503, 504 -> - DownloadManager.Error.Unreachable + DownloadManager.Error.Unreachable() SystemDownloadManager.ERROR_CANNOT_RESUME -> - DownloadManager.Error.CannotResume + DownloadManager.Error.CannotResume() SystemDownloadManager.ERROR_DEVICE_NOT_FOUND -> - DownloadManager.Error.DeviceNotFound + DownloadManager.Error.DeviceNotFound() SystemDownloadManager.ERROR_FILE_ERROR -> - DownloadManager.Error.FileError + DownloadManager.Error.FileError() SystemDownloadManager.ERROR_HTTP_DATA_ERROR -> - DownloadManager.Error.HttpData + DownloadManager.Error.HttpData() SystemDownloadManager.ERROR_INSUFFICIENT_SPACE -> - DownloadManager.Error.InsufficientSpace + DownloadManager.Error.InsufficientSpace() SystemDownloadManager.ERROR_TOO_MANY_REDIRECTS -> - DownloadManager.Error.TooManyRedirects + DownloadManager.Error.TooManyRedirects() SystemDownloadManager.ERROR_UNHANDLED_HTTP_CODE -> - DownloadManager.Error.Unknown + DownloadManager.Error.Unknown() SystemDownloadManager.ERROR_UNKNOWN -> - DownloadManager.Error.Unknown + DownloadManager.Error.Unknown() else -> - DownloadManager.Error.Unknown + DownloadManager.Error.Unknown() } public override suspend fun close() { diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index 948f863797..f5e9946aa8 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -41,7 +41,7 @@ public class LcpPublicationRetriever( ) { @JvmInline - public value class RequestId(public val value: Long) + public value class RequestId(public val value: String) public interface Listener { @@ -73,7 +73,7 @@ public class LcpPublicationRetriever( ) { coroutineScope.launch { val lcpRequestId = RequestId(requestId.value) - val acquisition = onDownloadCompleted( + val acquisition = onDownloadCompletedImpl( requestId.value, file ).getOrElse { @@ -118,7 +118,7 @@ public class LcpPublicationRetriever( private val formatRegistry: FormatRegistry = FormatRegistry() - private val licenses: Flow> = + private val licenses: Flow> = context.dataStore.data .map { data -> data.licenses } @@ -156,6 +156,10 @@ public class LcpPublicationRetriever( downloadManager.close() } + public suspend fun cancel(requestId: RequestId) { + downloadManager.cancel(DownloadManager.RequestId(requestId.value)) + } + private suspend fun fetchPublication( license: LicenseDocument, downloadTitle: String, @@ -179,8 +183,8 @@ public class LcpPublicationRetriever( return RequestId(requestId.value) } - private suspend fun onDownloadCompleted( - id: Long, + private suspend fun onDownloadCompletedImpl( + id: String, file: File ): Try { val licenses = licenses.first() @@ -210,7 +214,7 @@ public class LcpPublicationRetriever( return Try.success(acquiredPublication) } - private suspend fun persistLicense(id: Long, license: JSONObject) { + private suspend fun persistLicense(id: String, license: JSONObject) { context.dataStore.edit { data -> val newEntry = id to license val licenses = data.licenses + newEntry @@ -218,31 +222,31 @@ public class LcpPublicationRetriever( } } - private suspend fun removeLicense(id: Long) { + private suspend fun removeLicense(id: String) { context.dataStore.edit { data -> val licenses = data.licenses - id data[licensesKey] = licenses.toJson() } } - private val Preferences.licenses: Map + private val Preferences.licenses: Map get() = get(licensesKey)?.toLicenses().orEmpty() - private fun licenseToJson(id: Long, license: JSONObject): JSONObject = + private fun licenseToJson(id: String, license: JSONObject): JSONObject = JSONObject() .put("id", id) .put("license", license) - private fun jsonToLicense(jsonObject: JSONObject): Pair = - jsonObject.getLong("id") to jsonObject.getJSONObject("license") + private fun jsonToLicense(jsonObject: JSONObject): Pair = + jsonObject.getString("id") to jsonObject.getJSONObject("license") - private fun Map.toJson(): String { + private fun Map.toJson(): String { val jsonObjects = map { licenseToJson(it.key, it.value) } val array = JSONArray(jsonObjects) return array.toString() } - private fun String.toLicenses(): Map { + private fun String.toLicenses(): Map { val array = JSONArray(this) val objects = (0 until array.length()).map { array.getJSONObject(it) } return objects.associate { jsonToLicense(it) } diff --git a/test-app/src/main/java/org/readium/r2/testapp/data/DownloadRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/data/DownloadRepository.kt index 4cb5971129..d2f03b3fec 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/data/DownloadRepository.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/DownloadRepository.kt @@ -15,7 +15,7 @@ class DownloadRepository( suspend fun insertOpdsDownload( manager: String, - id: Long, + id: String, cover: String? ) { downloadsDao.insert( @@ -25,14 +25,14 @@ class DownloadRepository( suspend fun getOpdsDownloadCover( manager: String, - id: Long + id: String ): String? { return downloadsDao.get(manager, id)!!.extra } suspend fun removeDownload( manager: String, - id: Long + id: String ) { downloadsDao.delete(manager, id) } diff --git a/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadsDao.kt b/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadsDao.kt index f36c04b079..e2e4d13ec7 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadsDao.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadsDao.kt @@ -21,11 +21,11 @@ interface DownloadsDao { "DELETE FROM " + Download.TABLE_NAME + " WHERE " + Download.ID + " = :id" + " AND " + Download.MANAGER + " = :manager" ) - suspend fun delete(manager: String, id: Long) + suspend fun delete(manager: String, id: String) @Query( "SELECT * FROM " + Download.TABLE_NAME + " WHERE " + Download.ID + " = :id" + " AND " + Download.MANAGER + " = :manager" ) - suspend fun get(manager: String, id: Long): Download? + suspend fun get(manager: String, id: String): Download? } diff --git a/test-app/src/main/java/org/readium/r2/testapp/data/model/Download.kt b/test-app/src/main/java/org/readium/r2/testapp/data/model/Download.kt index cd7d6e2a1b..240aa632c1 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/data/model/Download.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/model/Download.kt @@ -14,7 +14,7 @@ data class Download( @ColumnInfo(name = MANAGER) val manager: String, @ColumnInfo(name = ID) - val id: Long, + val id: String, @ColumnInfo(name = EXTRA) val extra: String? = null, @ColumnInfo(name = CREATION_DATE, defaultValue = "CURRENT_TIMESTAMP") From ab5ef5b70bc2a95aee6aff8e38aa8729aa25b7f3 Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Fri, 1 Sep 2023 12:07:48 +0200 Subject: [PATCH 14/35] A few more changes --- .../android/AndroidDownloadManager.kt | 21 ++++++++++++++----- .../readium/r2/lcp/LcpPublicationRetriever.kt | 2 +- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt b/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt index d7985162aa..986243e887 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt +++ b/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt @@ -59,12 +59,10 @@ public class AndroidDownloadManager internal constructor( public override suspend fun submit(request: DownloadManager.Request): DownloadManager.RequestId { startObservingProgress() - val dottedExtension = request.url.extension - ?.let { ".$it" } - .orEmpty() + val androidRequest = createRequest( uri = request.url.toUri(), - filename = "${UUID.randomUUID()}$dottedExtension}", + filename = generateFileName(extension = request.url.extension), headers = request.headers, title = request.title, description = request.description @@ -74,6 +72,13 @@ public class AndroidDownloadManager internal constructor( return DownloadManager.RequestId(downloadId.toString()) } + private fun generateFileName(extension: String?): String { + val dottedExtension = extension + ?.let { ".$it" } + .orEmpty() + return "${UUID.randomUUID()}$dottedExtension}" + } + public override suspend fun cancel(requestId: DownloadManager.RequestId) { val longId = requestId.value.toLong() downloadManager.remove() @@ -165,7 +170,13 @@ public class AndroidDownloadManager internal constructor( SystemDownloadManager.STATUS_PENDING -> {} SystemDownloadManager.STATUS_SUCCESSFUL -> { val destUri = Uri.parse(facade.localUri!!) - listener.onDownloadCompleted(id, File(destUri.path!!)) + val destFile = File(destUri.path!!) + val newDest = File(destFile.parent, generateFileName(destFile.extension)) + if (destFile.renameTo(newDest)) { + listener.onDownloadCompleted(id, newDest) + } else { + listener.onDownloadFailed(id, DownloadManager.Error.FileError()) + } downloadManager.remove(facade.id) downloadsRepository.removeId(name, facade.id) if (!downloadsRepository.hasDownloadsOngoing()) { diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index f5e9946aa8..9e166f062d 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -112,7 +112,7 @@ public class LcpPublicationRetriever( private val downloadManager: DownloadManager = downloadManagerProvider.createDownloadManager( DownloadListener(), - "~readium-lcp-publication-retriever" + "org.readium.lcp.LcpPublicationRetriever" ) private val formatRegistry: FormatRegistry = From 57413ec54ab5b2cf0ca79c71c4b079d6da5f865e Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Fri, 1 Sep 2023 13:32:05 +0200 Subject: [PATCH 15/35] Move to shared --- readium/downloads/build.gradle.kts | 56 ------------------- .../downloads/src/main/AndroidManifest.xml | 3 - readium/lcp/build.gradle.kts | 1 - .../readium/r2/lcp/LcpPublicationRetriever.kt | 4 +- .../java/org/readium/r2/lcp/LcpService.kt | 2 +- .../readium/r2/lcp/service/LicensesService.kt | 2 +- readium/shared/build.gradle.kts | 1 + .../shared/util}/downloads/DownloadManager.kt | 2 +- .../downloads/DownloadManagerProvider.kt | 2 +- .../android/AndroidDownloadManager.kt | 4 +- .../android/AndroidDownloadManagerProvider.kt | 6 +- .../downloads/android/DownloadCursorFacade.kt | 2 +- .../downloads/android/DownloadsRepository.kt | 2 +- settings.gradle.kts | 4 -- .../java/org/readium/r2/testapp/Readium.kt | 4 +- .../readium/r2/testapp/domain/Bookshelf.kt | 4 +- .../r2/testapp/domain/OpdsDownloader.kt | 4 +- 17 files changed, 20 insertions(+), 83 deletions(-) delete mode 100644 readium/downloads/build.gradle.kts delete mode 100644 readium/downloads/src/main/AndroidManifest.xml rename readium/{downloads/src/main/java/org/readium => shared/src/main/java/org/readium/r2/shared/util}/downloads/DownloadManager.kt (98%) rename readium/{downloads/src/main/java/org/readium => shared/src/main/java/org/readium/r2/shared/util}/downloads/DownloadManagerProvider.kt (94%) rename readium/{downloads/src/main/java/org/readium => shared/src/main/java/org/readium/r2/shared/util}/downloads/android/AndroidDownloadManager.kt (98%) rename readium/{downloads/src/main/java/org/readium => shared/src/main/java/org/readium/r2/shared/util}/downloads/android/AndroidDownloadManagerProvider.kt (83%) rename readium/{downloads/src/main/java/org/readium => shared/src/main/java/org/readium/r2/shared/util}/downloads/android/DownloadCursorFacade.kt (96%) rename readium/{downloads/src/main/java/org/readium => shared/src/main/java/org/readium/r2/shared/util}/downloads/android/DownloadsRepository.kt (98%) diff --git a/readium/downloads/build.gradle.kts b/readium/downloads/build.gradle.kts deleted file mode 100644 index 8a7fc2ab63..0000000000 --- a/readium/downloads/build.gradle.kts +++ /dev/null @@ -1,56 +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. - */ - -plugins { - id("com.android.library") - kotlin("android") - kotlin("plugin.parcelize") -} - -android { - resourcePrefix = "readium_" - - compileSdk = 34 - - defaultConfig { - minSdk = 21 - targetSdk = 34 - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - freeCompilerArgs = freeCompilerArgs + listOf( - "-opt-in=kotlin.RequiresOptIn", - "-opt-in=org.readium.r2.shared.InternalReadiumApi" - ) - } - buildTypes { - getByName("release") { - isMinifyEnabled = false - proguardFiles(getDefaultProguardFile("proguard-android.txt")) - } - } - - namespace = "org.readium.downloads" -} - -kotlin { - explicitApi() -} - -rootProject.ext["publish.artifactId"] = "readium-downloads" -apply(from = "$rootDir/scripts/publish-module.gradle") - -dependencies { - api(project(":readium:readium-shared")) - - implementation(libs.bundles.coroutines) - implementation(libs.androidx.datastore.preferences) -} diff --git a/readium/downloads/src/main/AndroidManifest.xml b/readium/downloads/src/main/AndroidManifest.xml deleted file mode 100644 index 9a40236b94..0000000000 --- a/readium/downloads/src/main/AndroidManifest.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/readium/lcp/build.gradle.kts b/readium/lcp/build.gradle.kts index 5112e10b0a..d70daa2204 100644 --- a/readium/lcp/build.gradle.kts +++ b/readium/lcp/build.gradle.kts @@ -55,7 +55,6 @@ dependencies { implementation(libs.kotlinx.coroutines.core) api(project(":readium:readium-shared")) - api(project(":readium:readium-downloads")) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.core) diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index 9e166f062d..b0fc73bb49 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -15,13 +15,13 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.json.JSONArray import org.json.JSONObject -import org.readium.downloads.DownloadManager -import org.readium.downloads.DownloadManagerProvider import org.readium.r2.lcp.license.container.createLicenseContainer import org.readium.r2.lcp.license.model.LicenseDocument import org.readium.r2.shared.extensions.tryOrLog import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.Url +import org.readium.r2.shared.util.downloads.DownloadManager +import org.readium.r2.shared.util.downloads.DownloadManagerProvider import org.readium.r2.shared.util.getOrElse import org.readium.r2.shared.util.mediatype.FormatRegistry import org.readium.r2.shared.util.mediatype.MediaType 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 c3dd46c05f..9a9c3fac83 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 @@ -16,7 +16,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.readium.downloads.DownloadManagerProvider import org.readium.r2.lcp.auth.LcpDialogAuthentication import org.readium.r2.lcp.license.model.LicenseDocument import org.readium.r2.lcp.persistence.LcpDatabase @@ -33,6 +32,7 @@ import org.readium.r2.shared.asset.Asset import org.readium.r2.shared.asset.AssetRetriever import org.readium.r2.shared.publication.protection.ContentProtection import org.readium.r2.shared.util.Try +import org.readium.r2.shared.util.downloads.DownloadManagerProvider import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.mediatype.MediaTypeRetriever 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 cf504638ac..d48beba62a 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 @@ -19,7 +19,6 @@ import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import org.readium.downloads.DownloadManagerProvider import org.readium.r2.lcp.LcpAuthenticating import org.readium.r2.lcp.LcpContentProtection import org.readium.r2.lcp.LcpException @@ -36,6 +35,7 @@ import org.readium.r2.shared.asset.AssetRetriever import org.readium.r2.shared.extensions.tryOr import org.readium.r2.shared.publication.protection.ContentProtection import org.readium.r2.shared.util.Try +import org.readium.r2.shared.util.downloads.DownloadManagerProvider import org.readium.r2.shared.util.mediatype.FormatRegistry import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.mediatype.MediaTypeRetriever diff --git a/readium/shared/build.gradle.kts b/readium/shared/build.gradle.kts index 8282274403..ee140a1783 100644 --- a/readium/shared/build.gradle.kts +++ b/readium/shared/build.gradle.kts @@ -56,6 +56,7 @@ apply(from = "$rootDir/scripts/publish-module.gradle") dependencies { implementation(libs.androidx.appcompat) implementation(libs.androidx.browser) + implementation(libs.androidx.datastore.preferences) implementation(libs.timber) implementation(libs.joda.time) implementation(libs.kotlin.reflect) diff --git a/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt similarity index 98% rename from readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt rename to readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt index 40e2cd54b5..44e189a3e0 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/DownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt @@ -4,7 +4,7 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.downloads +package org.readium.r2.shared.util.downloads import java.io.File import org.readium.r2.shared.util.Url diff --git a/readium/downloads/src/main/java/org/readium/downloads/DownloadManagerProvider.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManagerProvider.kt similarity index 94% rename from readium/downloads/src/main/java/org/readium/downloads/DownloadManagerProvider.kt rename to readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManagerProvider.kt index 45c4c06e5d..38e00dae8c 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/DownloadManagerProvider.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManagerProvider.kt @@ -4,7 +4,7 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.downloads +package org.readium.r2.shared.util.downloads /** * To be implemented by custom implementations of [DownloadManager]. diff --git a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt similarity index 98% rename from readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt rename to readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt index 986243e887..a8d47c9205 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt @@ -4,7 +4,7 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.downloads.android +package org.readium.r2.shared.util.downloads.android import android.app.DownloadManager as SystemDownloadManager import android.content.Context @@ -19,8 +19,8 @@ import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.readium.downloads.DownloadManager import org.readium.r2.shared.units.Hz +import org.readium.r2.shared.util.downloads.DownloadManager import org.readium.r2.shared.util.toUri public class AndroidDownloadManager internal constructor( diff --git a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManagerProvider.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManagerProvider.kt similarity index 83% rename from readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManagerProvider.kt rename to readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManagerProvider.kt index 4da7a300d2..8aac51ebfe 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/android/AndroidDownloadManagerProvider.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManagerProvider.kt @@ -4,14 +4,14 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.downloads.android +package org.readium.r2.shared.util.downloads.android import android.content.Context import android.os.Environment -import org.readium.downloads.DownloadManager -import org.readium.downloads.DownloadManagerProvider import org.readium.r2.shared.units.Hz import org.readium.r2.shared.units.hz +import org.readium.r2.shared.util.downloads.DownloadManager +import org.readium.r2.shared.util.downloads.DownloadManagerProvider public class AndroidDownloadManagerProvider( private val context: Context, diff --git a/readium/downloads/src/main/java/org/readium/downloads/android/DownloadCursorFacade.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadCursorFacade.kt similarity index 96% rename from readium/downloads/src/main/java/org/readium/downloads/android/DownloadCursorFacade.kt rename to readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadCursorFacade.kt index 58f0b2d89a..1ce7424a8a 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/android/DownloadCursorFacade.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadCursorFacade.kt @@ -4,7 +4,7 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.downloads.android +package org.readium.r2.shared.util.downloads.android import android.app.DownloadManager import android.database.Cursor diff --git a/readium/downloads/src/main/java/org/readium/downloads/android/DownloadsRepository.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadsRepository.kt similarity index 98% rename from readium/downloads/src/main/java/org/readium/downloads/android/DownloadsRepository.kt rename to readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadsRepository.kt index 26d92c9ee3..f0a3f816f1 100644 --- a/readium/downloads/src/main/java/org/readium/downloads/android/DownloadsRepository.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadsRepository.kt @@ -4,7 +4,7 @@ * available in the top-level LICENSE file of the project. */ -package org.readium.downloads.android +package org.readium.r2.shared.util.downloads.android import android.content.Context import androidx.datastore.core.DataStore diff --git a/settings.gradle.kts b/settings.gradle.kts index 786518c511..9b6225b8f7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -80,10 +80,6 @@ include(":readium:shared") project(":readium:shared") .name = "readium-shared" -include(":readium:downloads") -project(":readium:downloads") - .name = "readium-downloads" - include(":readium:streamer") project(":readium:streamer") .name = "readium-streamer" diff --git a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt index 50647eba6b..94ee79994c 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt @@ -8,8 +8,6 @@ package org.readium.r2.testapp import android.content.Context import org.readium.adapters.pdfium.document.PdfiumDocumentFactory -import org.readium.downloads.android.AndroidDownloadManager -import org.readium.downloads.android.AndroidDownloadManagerProvider import org.readium.r2.lcp.LcpService import org.readium.r2.navigator.preferences.FontFamily import org.readium.r2.shared.ExperimentalReadiumApi @@ -24,6 +22,8 @@ import org.readium.r2.shared.resource.DirectoryContainerFactory import org.readium.r2.shared.resource.FileResourceFactory import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.archive.channel.ChannelZipArchiveFactory +import org.readium.r2.shared.util.downloads.android.AndroidDownloadManager +import org.readium.r2.shared.util.downloads.android.AndroidDownloadManagerProvider import org.readium.r2.shared.util.http.DefaultHttpClient import org.readium.r2.shared.util.http.HttpResourceFactory import org.readium.r2.shared.util.mediatype.FormatRegistry diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt index 69e67ee57e..82985a2ec3 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt @@ -23,8 +23,6 @@ import kotlinx.coroutines.MainScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.readium.downloads.DownloadManager -import org.readium.downloads.DownloadManagerProvider import org.readium.r2.lcp.LcpException import org.readium.r2.lcp.LcpPublicationRetriever import org.readium.r2.lcp.LcpService @@ -37,6 +35,8 @@ import org.readium.r2.shared.publication.protection.ContentProtectionSchemeRetri import org.readium.r2.shared.publication.services.cover import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.Url +import org.readium.r2.shared.util.downloads.DownloadManager +import org.readium.r2.shared.util.downloads.DownloadManagerProvider import org.readium.r2.shared.util.getOrElse import org.readium.r2.shared.util.mediatype.FormatRegistry import org.readium.r2.shared.util.mediatype.MediaType diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt index b34dd05561..e5088d08d0 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt @@ -11,12 +11,12 @@ import java.net.URL import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch -import org.readium.downloads.DownloadManager -import org.readium.downloads.DownloadManagerProvider import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.publication.opds.images import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.Url +import org.readium.r2.shared.util.downloads.DownloadManager +import org.readium.r2.shared.util.downloads.DownloadManagerProvider import org.readium.r2.shared.util.getOrElse import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.testapp.data.DownloadRepository From 265a4ea2a4f6ea7de3d90ab99f66d5b348141826 Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Fri, 1 Sep 2023 16:25:28 +0200 Subject: [PATCH 16/35] Add allowDownloadsOverMetered --- .../shared/util/downloads/android/AndroidDownloadManager.kt | 1 + .../util/downloads/android/AndroidDownloadManagerProvider.kt | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt index a8d47c9205..91632cb5e3 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt @@ -29,6 +29,7 @@ public class AndroidDownloadManager internal constructor( private val destStorage: Storage, private val dirType: String, private val refreshRate: Hz, + private val allowDownloadsOverMetered: Boolean, private val listener: DownloadManager.Listener ) : DownloadManager { diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManagerProvider.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManagerProvider.kt index 8aac51ebfe..147b61dd0f 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManagerProvider.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManagerProvider.kt @@ -16,7 +16,8 @@ import org.readium.r2.shared.util.downloads.DownloadManagerProvider public class AndroidDownloadManagerProvider( private val context: Context, private val destStorage: AndroidDownloadManager.Storage = AndroidDownloadManager.Storage.App, - private val refreshRate: Hz = 0.1.hz + private val refreshRate: Hz = 0.1.hz, + private val allowDownloadsOverMetered: Boolean = true ) : DownloadManagerProvider { override fun createDownloadManager( @@ -29,6 +30,7 @@ public class AndroidDownloadManagerProvider( destStorage, Environment.DIRECTORY_DOWNLOADS, refreshRate, + allowDownloadsOverMetered, listener ) } From 5e1589415d763db0432833a33ffb18b956421eff Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Sat, 2 Sep 2023 12:45:22 +0200 Subject: [PATCH 17/35] Refactor downlaod repositories --- readium/lcp/build.gradle.kts | 1 - .../readium/r2/lcp/LcpDownloadsRepository.kt | 61 +++++++ .../readium/r2/lcp/LcpPublicationRetriever.kt | 150 +++++------------- readium/shared/build.gradle.kts | 1 - .../android/AndroidDownloadManager.kt | 11 +- .../downloads/android/DownloadsRepository.kt | 61 +++---- 6 files changed, 127 insertions(+), 158 deletions(-) create mode 100644 readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt diff --git a/readium/lcp/build.gradle.kts b/readium/lcp/build.gradle.kts index d70daa2204..6dc38bd2ec 100644 --- a/readium/lcp/build.gradle.kts +++ b/readium/lcp/build.gradle.kts @@ -72,7 +72,6 @@ dependencies { implementation(libs.bundles.room) ksp(libs.androidx.room.compiler) - implementation(libs.androidx.datastore.preferences) // Tests testImplementation(libs.junit) diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt new file mode 100644 index 0000000000..fe9d605d91 --- /dev/null +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt @@ -0,0 +1,61 @@ +/* + * 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 org.json.JSONObject + +internal class LcpDownloadsRepository( + context: Context +) { + private val storageDir: File = + File(context.noBackupFilesDir, LcpDownloadsRepository::class.qualifiedName!!) + .also { if (!it.exists()) it.mkdirs() } + + private val storageFile: File = + File(storageDir, "licenses.json") + .also { if (!it.exists()) { it.writeText("{}", Charsets.UTF_8) } } + + private val snapshot: MutableMap = + storageFile.readText(Charsets.UTF_8).toData().toMutableMap() + + fun addDownload(id: String, license: JSONObject) { + snapshot[id] = license + storageFile.writeText(snapshot.toJson(), Charsets.UTF_8) + } + + fun removeDownload(id: String) { + snapshot.remove(id) + storageFile.writeText(snapshot.toJson(), Charsets.UTF_8) + } + + fun retrieveLicense(id: String): JSONObject? { + return snapshot[id] + } + + private fun Map.toJson(): String { + val jsonObject = JSONObject() + for ((id, license) in this.entries) { + jsonObject.put(id, license) + } + return jsonObject.toString() + } + + private fun String.toData(): Map { + val jsonObject = JSONObject(this) + val names = jsonObject.keys().iterator().toList() + return names.associateWith { jsonObject.getJSONObject(it) } + } + + private fun Iterator.toList(): List = + LinkedList().apply { + while (hasNext()) + this += next() + }.toMutableList() +} diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index b0fc73bb49..db18717bf4 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -1,20 +1,13 @@ +/* + * 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 androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore import java.io.File -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import org.json.JSONArray -import org.json.JSONObject import org.readium.r2.lcp.license.container.createLicenseContainer import org.readium.r2.lcp.license.model.LicenseDocument import org.readium.r2.shared.extensions.tryOrLog @@ -22,19 +15,12 @@ import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.downloads.DownloadManager import org.readium.r2.shared.util.downloads.DownloadManagerProvider -import org.readium.r2.shared.util.getOrElse import org.readium.r2.shared.util.mediatype.FormatRegistry import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.mediatype.MediaTypeRetriever -private val Context.dataStore: DataStore by preferencesDataStore( - name = "readium-lcp-licenses" -) - -private val licensesKey: Preferences.Key = stringPreferencesKey("licenses") - public class LcpPublicationRetriever( - private val context: Context, + context: Context, private val listener: Listener, downloadManagerProvider: DownloadManagerProvider, private val mediaTypeRetriever: MediaTypeRetriever @@ -64,26 +50,38 @@ public class LcpPublicationRetriever( private inner class DownloadListener : DownloadManager.Listener { - private val coroutineScope: CoroutineScope = - MainScope() - override fun onDownloadCompleted( requestId: DownloadManager.RequestId, file: File ) { - coroutineScope.launch { - val lcpRequestId = RequestId(requestId.value) - val acquisition = onDownloadCompletedImpl( - requestId.value, - file - ).getOrElse { - tryOrLog { file.delete() } - listener.onAcquisitionFailed(lcpRequestId, LcpException.wrap(it)) - return@launch - } - - listener.onAcquisitionCompleted(lcpRequestId, acquisition) + val lcpRequestId = RequestId(requestId.value) + + val license = LicenseDocument(downloadsRepository.retrieveLicense(requestId.value)!!) + downloadsRepository.removeDownload(requestId.value) + + val link = license.link(LicenseDocument.Rel.Publication)!! + + val mediaType = mediaTypeRetriever.retrieve(mediaType = link.type) + ?: MediaType.EPUB + + try { + // Saves the License Document into the downloaded publication + val container = createLicenseContainer(file, mediaType) + container.write(license) + } catch (e: Exception) { + tryOrLog { file.delete() } + listener.onAcquisitionFailed(lcpRequestId, LcpException.wrap(e)) + return } + + val acquiredPublication = LcpService.AcquiredPublication( + localFile = file, + suggestedFilename = "${license.id}.${formatRegistry.fileExtension(mediaType) ?: "epub"}", + mediaType = mediaType, + licenseDocument = license + ) + + listener.onAcquisitionCompleted(lcpRequestId, acquiredPublication) } override fun onDownloadProgressed( @@ -112,15 +110,14 @@ public class LcpPublicationRetriever( private val downloadManager: DownloadManager = downloadManagerProvider.createDownloadManager( DownloadListener(), - "org.readium.lcp.LcpPublicationRetriever" + LcpPublicationRetriever::class.qualifiedName!! ) private val formatRegistry: FormatRegistry = FormatRegistry() - private val licenses: Flow> = - context.dataStore.data - .map { data -> data.licenses } + private val downloadsRepository: LcpDownloadsRepository = + LcpDownloadsRepository(context) public suspend fun retrieve( license: ByteArray, @@ -158,6 +155,7 @@ public class LcpPublicationRetriever( public suspend fun cancel(requestId: RequestId) { downloadManager.cancel(DownloadManager.RequestId(requestId.value)) + downloadsRepository.removeDownload(requestId.value) } private suspend fun fetchPublication( @@ -178,77 +176,7 @@ public class LcpPublicationRetriever( ) ) - persistLicense(requestId.value, license.json) - + downloadsRepository.addDownload(requestId.value, license.json) return RequestId(requestId.value) } - - private suspend fun onDownloadCompletedImpl( - id: String, - file: File - ): Try { - val licenses = licenses.first() - val license = LicenseDocument(licenses[id]!!) - removeLicense(id) - - val link = license.link(LicenseDocument.Rel.Publication)!! - - val mediaType = mediaTypeRetriever.retrieve(mediaType = link.type) - ?: MediaType.EPUB - - try { - // Saves the License Document into the downloaded publication - val container = createLicenseContainer(file, mediaType) - container.write(license) - } catch (e: Exception) { - return Try.failure(e) - } - - val acquiredPublication = LcpService.AcquiredPublication( - localFile = file, - suggestedFilename = "${license.id}.${formatRegistry.fileExtension(mediaType) ?: "epub"}", - mediaType = mediaType, - licenseDocument = license - ) - - return Try.success(acquiredPublication) - } - - private suspend fun persistLicense(id: String, license: JSONObject) { - context.dataStore.edit { data -> - val newEntry = id to license - val licenses = data.licenses + newEntry - data[licensesKey] = licenses.toJson() - } - } - - private suspend fun removeLicense(id: String) { - context.dataStore.edit { data -> - val licenses = data.licenses - id - data[licensesKey] = licenses.toJson() - } - } - - private val Preferences.licenses: Map - get() = get(licensesKey)?.toLicenses().orEmpty() - - private fun licenseToJson(id: String, license: JSONObject): JSONObject = - JSONObject() - .put("id", id) - .put("license", license) - - private fun jsonToLicense(jsonObject: JSONObject): Pair = - jsonObject.getString("id") to jsonObject.getJSONObject("license") - - private fun Map.toJson(): String { - val jsonObjects = map { licenseToJson(it.key, it.value) } - val array = JSONArray(jsonObjects) - return array.toString() - } - - private fun String.toLicenses(): Map { - val array = JSONArray(this) - val objects = (0 until array.length()).map { array.getJSONObject(it) } - return objects.associate { jsonToLicense(it) } - } } diff --git a/readium/shared/build.gradle.kts b/readium/shared/build.gradle.kts index ee140a1783..8282274403 100644 --- a/readium/shared/build.gradle.kts +++ b/readium/shared/build.gradle.kts @@ -56,7 +56,6 @@ apply(from = "$rootDir/scripts/publish-module.gradle") dependencies { implementation(libs.androidx.appcompat) implementation(libs.androidx.browser) - implementation(libs.androidx.datastore.preferences) implementation(libs.timber) implementation(libs.joda.time) implementation(libs.kotlin.reflect) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt index 91632cb5e3..07b184af8b 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt @@ -52,7 +52,7 @@ public class AndroidDownloadManager internal constructor( init { coroutineScope.launch { - if (downloadsRepository.hasDownloadsOngoing()) { + if (downloadsRepository.hasDownloads()) { startObservingProgress() } } @@ -84,7 +84,7 @@ public class AndroidDownloadManager internal constructor( val longId = requestId.value.toLong() downloadManager.remove() downloadsRepository.removeId(name, longId) - if (!downloadsRepository.hasDownloadsOngoing()) { + if (!downloadsRepository.hasDownloads()) { stopObservingProgress() } } @@ -102,8 +102,7 @@ public class AndroidDownloadManager internal constructor( .setHeaders(headers) .setTitle(title) .apply { description?.let { setDescription(it) } } - .setAllowedOverMetered(true) - .setAllowedOverRoaming(true) + .setAllowedOverMetered(allowDownloadsOverMetered) private fun SystemDownloadManager.Request.setHeaders( headers: Map> @@ -163,7 +162,7 @@ public class AndroidDownloadManager internal constructor( listener.onDownloadFailed(id, mapErrorCode(facade.reason!!)) downloadManager.remove(facade.id) downloadsRepository.removeId(name, facade.id) - if (!downloadsRepository.hasDownloadsOngoing()) { + if (!downloadsRepository.hasDownloads()) { stopObservingProgress() } } @@ -180,7 +179,7 @@ public class AndroidDownloadManager internal constructor( } downloadManager.remove(facade.id) downloadsRepository.removeId(name, facade.id) - if (!downloadsRepository.hasDownloadsOngoing()) { + if (!downloadsRepository.hasDownloads()) { stopObservingProgress() } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadsRepository.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadsRepository.kt index f0a3f816f1..271944270a 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadsRepository.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadsRepository.kt @@ -7,59 +7,42 @@ package org.readium.r2.shared.util.downloads.android import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore +import java.io.File import java.util.LinkedList -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map import org.json.JSONArray import org.json.JSONObject -private val Context.dataStore: DataStore by preferencesDataStore( - name = "readium-downloads-android" -) - -private val downloadIdsKey: Preferences.Key = stringPreferencesKey("downloadIds") - internal class DownloadsRepository( - private val context: Context + context: Context ) { - private val downloadIds: Flow>> = - context.dataStore.data - .map { data -> data.ids } + private val storageDir: File = + File(context.noBackupFilesDir, DownloadsRepository::class.qualifiedName!!) + .also { if (!it.exists()) it.mkdirs() } - suspend fun addId(name: String, id: Long) { - context.dataStore.edit { data -> - val current = data.ids - val currentThisName = current[name].orEmpty() - val newEntryThisName = name to (currentThisName + id) - data[downloadIdsKey] = (current + newEntryThisName).toJson() - } - } + private val storageFile: File = + File(storageDir, "downloads.json") + .also { if (!it.exists()) { it.writeText("{}", Charsets.UTF_8) } } - suspend fun removeId(name: String, id: Long) { - context.dataStore.edit { data -> - val current = data.ids - val currentThisName = current[name].orEmpty() - val newEntryThisName = name to (currentThisName - id) - data[downloadIdsKey] = (current + newEntryThisName).toJson() - } + private var snapshot: MutableMap> = + storageFile.readText(Charsets.UTF_8).toData().toMutableMap() + + fun addId(name: String, id: Long) { + snapshot[name] = snapshot[name].orEmpty() + id + storageFile.writeText(snapshot.toJson(), Charsets.UTF_8) } - suspend fun idsForName(name: String): List { - return downloadIds.first()[name].orEmpty() + fun removeId(name: String, id: Long) { + snapshot[name] = snapshot[name].orEmpty() - id + storageFile.writeText(snapshot.toJson(), Charsets.UTF_8) } - suspend fun hasDownloadsOngoing(): Boolean = - downloadIds.first().values.flatten().isNotEmpty() + fun idsForName(name: String): List { + return snapshot[name].orEmpty() + } - private val Preferences.ids: Map> - get() = get(downloadIdsKey)?.toData().orEmpty() + fun hasDownloads(): Boolean = + snapshot.values.flatten().isNotEmpty() private fun Map>.toJson(): String { val jsonObject = JSONObject() From fd9af69cc8e5e34d783a670d4fb7c0ed410b0787 Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Sat, 2 Sep 2023 14:11:39 +0200 Subject: [PATCH 18/35] Move downloadManagerProvider to the constructor level in LcpService --- .../java/org/readium/r2/lcp/LcpService.kt | 42 ++++--- .../readium/r2/lcp/service/LicensesService.kt | 9 +- .../foreground/ForegroundDownloadManager.kt | 111 ++++++++++++++++++ .../ForegroundDownloadManagerProvider.kt | 23 ++++ .../java/org/readium/r2/testapp/Readium.kt | 3 +- .../readium/r2/testapp/domain/Bookshelf.kt | 3 +- 6 files changed, 169 insertions(+), 22 deletions(-) create mode 100644 readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt create mode 100644 readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManagerProvider.kt 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 9a9c3fac83..aa9cf626dc 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 @@ -13,8 +13,6 @@ import android.content.Context import java.io.File import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.readium.r2.lcp.auth.LcpDialogAuthentication import org.readium.r2.lcp.license.model.LicenseDocument @@ -55,9 +53,18 @@ public interface LcpService { * Acquires a protected publication from a standalone LCPL's bytes. * * You can cancel the on-going acquisition by cancelling its parent coroutine context. - * + * @Deprecated( + "Use a LcpPublicationRetriever instead.", + ReplaceWith("publicationRetriever()"), + level = DeprecationLevel.ERROR + ) * @param onProgress Callback to follow the acquisition progress from 0.0 to 1.0. */ + @Deprecated( + "Use a LcpPublicationRetriever instead.", + ReplaceWith("publicationRetriever()"), + level = DeprecationLevel.ERROR + ) public suspend fun acquirePublication(lcpl: ByteArray, onProgress: (Double) -> Unit = {}): Try /** @@ -67,14 +74,15 @@ public interface LcpService { * * @param onProgress Callback to follow the acquisition progress from 0.0 to 1.0. */ + @Deprecated( + "Use a LcpPublicationRetriever instead.", + ReplaceWith("publicationRetriever()"), + level = DeprecationLevel.ERROR + ) public suspend fun acquirePublication(lcpl: File, onProgress: (Double) -> Unit = {}): Try = withContext( Dispatchers.IO ) { - try { - acquirePublication(lcpl.readBytes(), onProgress) - } catch (e: Exception) { - Try.failure(LcpException.wrap(e)) - } + throw NotImplementedError() } /** @@ -120,10 +128,12 @@ public interface LcpService { * Creates a [LcpPublicationRetriever] instance which can be used to acquire a protected * publication from standalone LCPL's bytes. * - * @param listener listener to implement to be notified about the status of the download. + * You should use only one instance of [LcpPublicationRetriever] in your app. If you don't, + * behaviour is undefined. + * + * @param listener listener to implement to be notified about the status of the downloads. */ public fun publicationRetriever( - downloadManagerProvider: DownloadManagerProvider, listener: LcpPublicationRetriever.Listener ): LcpPublicationRetriever @@ -169,7 +179,8 @@ public interface LcpService { public operator fun invoke( context: Context, assetRetriever: AssetRetriever, - mediaTypeRetriever: MediaTypeRetriever + mediaTypeRetriever: MediaTypeRetriever, + downloadManagerProvider: DownloadManagerProvider ): LcpService? { if (!LcpClient.isAvailable()) { return null @@ -195,7 +206,8 @@ public interface LcpService { passphrases = passphrases, context = context, assetRetriever = assetRetriever, - mediaTypeRetriever = mediaTypeRetriever + mediaTypeRetriever = mediaTypeRetriever, + downloadManagerProvider = downloadManagerProvider ) } @@ -219,11 +231,7 @@ public interface LcpService { authentication: LcpAuthenticating?, completion: (AcquiredPublication?, LcpException?) -> Unit ) { - GlobalScope.launch { - acquirePublication(lcpl) - .onSuccess { completion(it, null) } - .onFailure { completion(null, it) } - } + throw NotImplementedError() } @Deprecated( 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 d48beba62a..980179bf6f 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 @@ -49,7 +49,8 @@ internal class LicensesService( private val passphrases: PassphrasesService, private val context: Context, private val assetRetriever: AssetRetriever, - private val mediaTypeRetriever: MediaTypeRetriever + private val mediaTypeRetriever: MediaTypeRetriever, + private val downloadManagerProvider: DownloadManagerProvider ) : LcpService, CoroutineScope by MainScope() { override suspend fun isLcpProtected(file: File): Boolean { @@ -75,7 +76,6 @@ internal class LicensesService( LcpContentProtection(this, authentication, assetRetriever) override fun publicationRetriever( - downloadManagerProvider: DownloadManagerProvider, listener: LcpPublicationRetriever.Listener ): LcpPublicationRetriever { return LcpPublicationRetriever( @@ -86,6 +86,11 @@ internal class LicensesService( ) } + @Deprecated( + "Use a LcpPublicationRetriever instead.", + ReplaceWith("publicationRetriever()"), + level = DeprecationLevel.ERROR + ) override suspend fun acquirePublication(lcpl: ByteArray, onProgress: (Double) -> Unit): Try = try { val licenseDocument = LicenseDocument(lcpl) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt new file mode 100644 index 0000000000..2cf328908f --- /dev/null +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt @@ -0,0 +1,111 @@ +/* + * 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.downloads.foreground + +import java.io.File +import java.util.UUID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.readium.r2.shared.util.ThrowableError +import org.readium.r2.shared.util.Try +import org.readium.r2.shared.util.downloads.DownloadManager +import org.readium.r2.shared.util.http.HttpClient +import org.readium.r2.shared.util.http.HttpException +import org.readium.r2.shared.util.http.HttpRequest + +public class ForegroundDownloadManager( + private val httpClient: HttpClient, + private val listener: DownloadManager.Listener +) : DownloadManager { + + private val coroutineScope: CoroutineScope = + MainScope() + + private val jobs: MutableMap = + mutableMapOf() + + override suspend fun submit(request: DownloadManager.Request): DownloadManager.RequestId { + val requestId = DownloadManager.RequestId(UUID.randomUUID().toString()) + jobs[requestId] = coroutineScope.launch { doRequest(request, requestId) } + return requestId + } + + private suspend fun doRequest(request: DownloadManager.Request, id: DownloadManager.RequestId) { + val response = httpClient.fetch( + HttpRequest( + url = request.url.toString(), + headers = request.headers.mapValues { it.value.joinToString(",") } + ) + ) + + val dottedExtension = request.url.extension + ?.let { ".$it" } + .orEmpty() + + when (response) { + is Try.Success -> { + withContext(Dispatchers.IO) { + try { + val dest = File.createTempFile( + UUID.randomUUID().toString(), + dottedExtension + ) + dest.writeBytes(response.value.body) + } catch (e: Exception) { + val error = DownloadManager.Error.FileError(ThrowableError(e)) + listener.onDownloadFailed(id, error) + } + } + } + is Try.Failure -> { + val error = mapError(response.value) + listener.onDownloadFailed(id, error) + } + } + } + + private fun mapError(httpException: HttpException): DownloadManager.Error { + val httpError = ThrowableError(httpException) + return when (httpException.kind) { + HttpException.Kind.MalformedRequest -> + DownloadManager.Error.Unknown(httpError) + HttpException.Kind.MalformedResponse -> + DownloadManager.Error.HttpData(httpError) + HttpException.Kind.Timeout -> + DownloadManager.Error.Unreachable(httpError) + HttpException.Kind.BadRequest -> + DownloadManager.Error.Unknown(httpError) + HttpException.Kind.Unauthorized -> + DownloadManager.Error.Forbidden(httpError) + HttpException.Kind.Forbidden -> + DownloadManager.Error.Forbidden(httpError) + HttpException.Kind.NotFound -> + DownloadManager.Error.NotFound(httpError) + HttpException.Kind.ClientError -> + DownloadManager.Error.HttpData(httpError) + HttpException.Kind.ServerError -> + DownloadManager.Error.Server(httpError) + HttpException.Kind.Offline -> + DownloadManager.Error.Unreachable(httpError) + HttpException.Kind.Cancelled -> + DownloadManager.Error.Unknown(httpError) + HttpException.Kind.Other -> + DownloadManager.Error.Unknown(httpError) + } + } + + override suspend fun cancel(requestId: DownloadManager.RequestId) { + jobs.remove(requestId)?.cancel() + } + + override suspend fun close() { + } +} diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManagerProvider.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManagerProvider.kt new file mode 100644 index 0000000000..704abd6511 --- /dev/null +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManagerProvider.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.downloads.foreground + +import org.readium.r2.shared.util.downloads.DownloadManager +import org.readium.r2.shared.util.downloads.DownloadManagerProvider +import org.readium.r2.shared.util.http.HttpClient + +public class ForegroundDownloadManagerProvider( + private val httpClient: HttpClient +) : DownloadManagerProvider { + + override fun createDownloadManager( + listener: DownloadManager.Listener, + name: String + ): DownloadManager { + return ForegroundDownloadManager(httpClient, listener) + } +} diff --git a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt index 94ee79994c..8caef34284 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt @@ -80,7 +80,8 @@ class Readium(context: Context) { val lcpService = LcpService( context, assetRetriever, - mediaTypeRetriever + mediaTypeRetriever, + downloadManagerProvider )?.let { Try.success(it) } ?: Try.failure(UserException("liblcp is missing on the classpath")) diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt index 82985a2ec3..50c0a156f6 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt @@ -62,7 +62,7 @@ class Bookshelf( private val assetRetriever: AssetRetriever, private val protectionRetriever: ContentProtectionSchemeRetriever, private val formatRegistry: FormatRegistry, - private val downloadManagerProvider: DownloadManagerProvider + downloadManagerProvider: DownloadManagerProvider ) { sealed class ImportError( content: Content, @@ -162,7 +162,6 @@ class Bookshelf( private val lcpPublicationRetriever = lcpService.map { it.publicationRetriever( - downloadManagerProvider, LcpRetrieverListener() ) } From 70f6dd1dc39779922a1892ae643f68940b77ffb3 Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Mon, 4 Sep 2023 13:02:00 +0200 Subject: [PATCH 19/35] Refactor download manager --- .../readium/r2/lcp/LcpDownloadsRepository.kt | 4 + .../readium/r2/lcp/LcpPublicationRetriever.kt | 106 ++++-- .../java/org/readium/r2/lcp/LcpService.kt | 14 +- .../readium/r2/lcp/service/LicensesService.kt | 11 +- .../shared/util/downloads/DownloadManager.kt | 8 +- .../util/downloads/DownloadManagerProvider.kt | 28 -- .../android/AndroidDownloadManager.kt | 146 +++++--- .../android/AndroidDownloadManagerProvider.kt | 37 -- .../downloads/android/DownloadsRepository.kt | 74 ---- .../foreground/ForegroundDownloadManager.kt | 31 +- .../ForegroundDownloadManagerProvider.kt | 23 -- .../org/readium/r2/testapp/Application.kt | 8 +- .../org/readium/r2/testapp/MainViewModel.kt | 2 +- .../java/org/readium/r2/testapp/Readium.kt | 9 +- .../testapp/bookshelf/BookshelfViewModel.kt | 2 +- .../r2/testapp/data/DownloadRepository.kt | 39 +- .../r2/testapp/data/db/DownloadsDao.kt | 20 +- .../readium/r2/testapp/data/model/Download.kt | 11 +- .../readium/r2/testapp/domain/Bookshelf.kt | 346 +++--------------- .../readium/r2/testapp/domain/CoverStorage.kt | 61 +++ .../readium/r2/testapp/domain/ImportError.kt | 69 ++++ .../r2/testapp/domain/OpdsDownloader.kt | 117 ------ .../r2/testapp/domain/PublicationRetriever.kt | 252 +++++++++++++ 23 files changed, 697 insertions(+), 721 deletions(-) delete mode 100644 readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManagerProvider.kt delete mode 100644 readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManagerProvider.kt delete mode 100644 readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadsRepository.kt delete mode 100644 readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManagerProvider.kt create mode 100644 test-app/src/main/java/org/readium/r2/testapp/domain/CoverStorage.kt create mode 100644 test-app/src/main/java/org/readium/r2/testapp/domain/ImportError.kt delete mode 100644 test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt create mode 100644 test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt index fe9d605d91..720ef981e9 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt @@ -25,6 +25,10 @@ internal class LcpDownloadsRepository( private val snapshot: MutableMap = storageFile.readText(Charsets.UTF_8).toData().toMutableMap() + fun getIds(): List { + return snapshot.keys.toList() + } + fun addDownload(id: String, license: JSONObject) { snapshot[id] = license storageFile.writeText(snapshot.toJson(), Charsets.UTF_8) diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index db18717bf4..56ee0fa46c 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -14,15 +14,13 @@ import org.readium.r2.shared.extensions.tryOrLog import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.downloads.DownloadManager -import org.readium.r2.shared.util.downloads.DownloadManagerProvider import org.readium.r2.shared.util.mediatype.FormatRegistry import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.mediatype.MediaTypeRetriever public class LcpPublicationRetriever( context: Context, - private val listener: Listener, - downloadManagerProvider: DownloadManagerProvider, + private val downloadManager: DownloadManager, private val mediaTypeRetriever: MediaTypeRetriever ) { @@ -55,8 +53,21 @@ public class LcpPublicationRetriever( file: File ) { val lcpRequestId = RequestId(requestId.value) - - val license = LicenseDocument(downloadsRepository.retrieveLicense(requestId.value)!!) + val listenersForId = listeners[lcpRequestId].orEmpty() + + val license = downloadsRepository.retrieveLicense(requestId.value) + ?.let { LicenseDocument(it) } + ?: run { + listenersForId.forEach { + it.onAcquisitionFailed( + lcpRequestId, + LcpException.wrap( + Exception("Couldn't retrieve license from local storage.") + ) + ) + } + return + } downloadsRepository.removeDownload(requestId.value) val link = license.link(LicenseDocument.Rel.Publication)!! @@ -70,7 +81,9 @@ public class LcpPublicationRetriever( container.write(license) } catch (e: Exception) { tryOrLog { file.delete() } - listener.onAcquisitionFailed(lcpRequestId, LcpException.wrap(e)) + listenersForId.forEach { + it.onAcquisitionFailed(lcpRequestId, LcpException.wrap(e)) + } return } @@ -81,7 +94,10 @@ public class LcpPublicationRetriever( licenseDocument = license ) - listener.onAcquisitionCompleted(lcpRequestId, acquiredPublication) + listenersForId.forEach { + it.onAcquisitionCompleted(lcpRequestId, acquiredPublication) + } + listeners.remove(lcpRequestId) } override fun onDownloadProgressed( @@ -89,40 +105,61 @@ public class LcpPublicationRetriever( downloaded: Long, expected: Long? ) { - listener.onAcquisitionProgressed( - RequestId(requestId.value), - downloaded, - expected - ) + val lcpRequestId = RequestId(requestId.value) + val listenersForId = listeners[lcpRequestId].orEmpty() + + listenersForId.forEach { + it.onAcquisitionProgressed( + lcpRequestId, + downloaded, + expected + ) + } } override fun onDownloadFailed( requestId: DownloadManager.RequestId, error: DownloadManager.Error ) { - listener.onAcquisitionFailed( - RequestId(requestId.value), - LcpException.Network(Exception(error.message)) - ) + val lcpRequestId = RequestId(requestId.value) + val listenersForId = listeners[lcpRequestId].orEmpty() + + listenersForId.forEach { + it.onAcquisitionFailed( + lcpRequestId, + LcpException.Network(Exception(error.message)) + ) + } + + listeners.remove(lcpRequestId) } } - private val downloadManager: DownloadManager = - downloadManagerProvider.createDownloadManager( - DownloadListener(), - LcpPublicationRetriever::class.qualifiedName!! - ) - private val formatRegistry: FormatRegistry = FormatRegistry() private val downloadsRepository: LcpDownloadsRepository = LcpDownloadsRepository(context) - public suspend fun retrieve( + private val downloadListener: DownloadManager.Listener = + DownloadListener() + + private val listeners: MutableMap> = + mutableMapOf() + + public fun register( + requestId: RequestId, + listener: Listener + ) { + listeners.getOrPut(requestId) { mutableListOf() }.add(listener) + downloadManager.register(DownloadManager.RequestId(requestId.value), downloadListener) + } + + public fun retrieve( license: ByteArray, downloadTitle: String, - downloadDescription: String? = null + downloadDescription: String? = null, + listener: Listener ): Try { return try { val licenseDocument = LicenseDocument(license) @@ -131,34 +168,32 @@ public class LcpPublicationRetriever( downloadTitle, downloadDescription ) + register(requestId, listener) Try.success(requestId) } catch (e: Exception) { Try.failure(LcpException.wrap(e)) } } - public suspend fun retrieve( + public fun retrieve( license: File, downloadTitle: String, - downloadDescription: String + downloadDescription: String, + listener: Listener ): Try { return try { - retrieve(license.readBytes(), downloadTitle, downloadDescription) + retrieve(license.readBytes(), downloadTitle, downloadDescription, listener) } catch (e: Exception) { Try.failure(LcpException.wrap(e)) } } - public suspend fun close() { - downloadManager.close() - } - - public suspend fun cancel(requestId: RequestId) { + public fun cancel(requestId: RequestId) { downloadManager.cancel(DownloadManager.RequestId(requestId.value)) downloadsRepository.removeDownload(requestId.value) } - private suspend fun fetchPublication( + private fun fetchPublication( license: LicenseDocument, downloadTitle: String, downloadDescription: String? @@ -168,12 +203,13 @@ public class LcpPublicationRetriever( ?: throw LcpException.Parsing.Url(rel = LicenseDocument.Rel.Publication.value) val requestId = downloadManager.submit( - DownloadManager.Request( + request = DownloadManager.Request( url = Url(url), title = downloadTitle, description = downloadDescription, headers = emptyMap() - ) + ), + listener = downloadListener ) downloadsRepository.addDownload(requestId.value, license.json) 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 aa9cf626dc..a52f8f8c53 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 @@ -30,7 +30,7 @@ import org.readium.r2.shared.asset.Asset import org.readium.r2.shared.asset.AssetRetriever import org.readium.r2.shared.publication.protection.ContentProtection import org.readium.r2.shared.util.Try -import org.readium.r2.shared.util.downloads.DownloadManagerProvider +import org.readium.r2.shared.util.downloads.DownloadManager import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.mediatype.MediaTypeRetriever @@ -128,14 +128,8 @@ public interface LcpService { * Creates a [LcpPublicationRetriever] instance which can be used to acquire a protected * publication from standalone LCPL's bytes. * - * You should use only one instance of [LcpPublicationRetriever] in your app. If you don't, - * behaviour is undefined. - * - * @param listener listener to implement to be notified about the status of the downloads. */ - public fun publicationRetriever( - listener: LcpPublicationRetriever.Listener - ): LcpPublicationRetriever + public fun publicationRetriever(): LcpPublicationRetriever /** * Creates a [ContentProtection] instance which can be used with a Streamer to unlock @@ -180,7 +174,7 @@ public interface LcpService { context: Context, assetRetriever: AssetRetriever, mediaTypeRetriever: MediaTypeRetriever, - downloadManagerProvider: DownloadManagerProvider + downloadManager: DownloadManager ): LcpService? { if (!LcpClient.isAvailable()) { return null @@ -207,7 +201,7 @@ public interface LcpService { context = context, assetRetriever = assetRetriever, mediaTypeRetriever = mediaTypeRetriever, - downloadManagerProvider = downloadManagerProvider + downloadManager = downloadManager ) } 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 980179bf6f..ddaabadabd 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 @@ -35,7 +35,7 @@ import org.readium.r2.shared.asset.AssetRetriever import org.readium.r2.shared.extensions.tryOr import org.readium.r2.shared.publication.protection.ContentProtection import org.readium.r2.shared.util.Try -import org.readium.r2.shared.util.downloads.DownloadManagerProvider +import org.readium.r2.shared.util.downloads.DownloadManager import org.readium.r2.shared.util.mediatype.FormatRegistry import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.mediatype.MediaTypeRetriever @@ -50,7 +50,7 @@ internal class LicensesService( private val context: Context, private val assetRetriever: AssetRetriever, private val mediaTypeRetriever: MediaTypeRetriever, - private val downloadManagerProvider: DownloadManagerProvider + private val downloadManager: DownloadManager ) : LcpService, CoroutineScope by MainScope() { override suspend fun isLcpProtected(file: File): Boolean { @@ -75,13 +75,10 @@ internal class LicensesService( ): ContentProtection = LcpContentProtection(this, authentication, assetRetriever) - override fun publicationRetriever( - listener: LcpPublicationRetriever.Listener - ): LcpPublicationRetriever { + override fun publicationRetriever(): LcpPublicationRetriever { return LcpPublicationRetriever( context, - listener, - downloadManagerProvider, + downloadManager, mediaTypeRetriever ) } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt index 44e189a3e0..270da7bb47 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt @@ -80,9 +80,11 @@ public interface DownloadManager { public fun onDownloadFailed(requestId: RequestId, error: Error) } - public suspend fun submit(request: Request): RequestId + public fun submit(request: Request, listener: Listener): RequestId - public suspend fun cancel(requestId: RequestId) + public fun register(requestId: RequestId, listener: Listener) - public suspend fun close() + public fun cancel(requestId: RequestId) + + public fun close() } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManagerProvider.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManagerProvider.kt deleted file mode 100644 index 38e00dae8c..0000000000 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManagerProvider.kt +++ /dev/null @@ -1,28 +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.downloads - -/** - * To be implemented by custom implementations of [DownloadManager]. - * - * Downloads can keep going on the background and the listener be called at any time. - * Naming [DownloadManager]s is useful to retrieve the downloads they own and - * associated data after app restarted. - */ -public interface DownloadManagerProvider { - - /** - * Creates a [DownloadManager]. - * - * @param listener listener to implement to observe the status of downloads - * @param name name of the download manager - */ - public fun createDownloadManager( - listener: DownloadManager.Listener, - name: String = "default" - ): DownloadManager -} diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt index 07b184af8b..c39282bde5 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt @@ -10,6 +10,7 @@ import android.app.DownloadManager as SystemDownloadManager import android.content.Context import android.database.Cursor import android.net.Uri +import android.os.Environment import java.io.File import java.util.UUID import kotlin.time.Duration.Companion.seconds @@ -20,19 +21,31 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.readium.r2.shared.units.Hz +import org.readium.r2.shared.units.hz import org.readium.r2.shared.util.downloads.DownloadManager import org.readium.r2.shared.util.toUri public class AndroidDownloadManager internal constructor( private val context: Context, - private val name: String, private val destStorage: Storage, private val dirType: String, private val refreshRate: Hz, - private val allowDownloadsOverMetered: Boolean, - private val listener: DownloadManager.Listener + private val allowDownloadsOverMetered: Boolean ) : DownloadManager { + public constructor( + context: Context, + destStorage: Storage = Storage.App, + refreshRate: Hz = 0.1.hz, + allowDownloadsOverMetered: Boolean = true + ) : this( + context = context, + destStorage = destStorage, + dirType = Environment.DIRECTORY_DOWNLOADS, + refreshRate = refreshRate, + allowDownloadsOverMetered = allowDownloadsOverMetered + ) + public enum class Storage { App, Shared; @@ -44,22 +57,28 @@ public class AndroidDownloadManager internal constructor( private val downloadManager: SystemDownloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as SystemDownloadManager - private val downloadsRepository: DownloadsRepository = - DownloadsRepository(context) - private var observeProgressJob: Job? = null - init { - coroutineScope.launch { - if (downloadsRepository.hasDownloads()) { - startObservingProgress() - } + private val listeners: MutableMap> = + mutableMapOf() + + public override fun register( + requestId: DownloadManager.RequestId, + listener: DownloadManager.Listener + ) { + listeners.getOrPut(requestId) { mutableListOf() }.add(listener) + + if (observeProgressJob == null) { + maybeStartObservingProgress() } } - public override suspend fun submit(request: DownloadManager.Request): DownloadManager.RequestId { - startObservingProgress() + public override fun submit( + request: DownloadManager.Request, + listener: DownloadManager.Listener + ): DownloadManager.RequestId { + maybeStartObservingProgress() val androidRequest = createRequest( uri = request.url.toUri(), @@ -69,8 +88,9 @@ public class AndroidDownloadManager internal constructor( description = request.description ) val downloadId = downloadManager.enqueue(androidRequest) - downloadsRepository.addId(name, downloadId) - return DownloadManager.RequestId(downloadId.toString()) + val requestId = DownloadManager.RequestId(downloadId.toString()) + register(requestId, listener) + return requestId } private fun generateFileName(extension: String?): String { @@ -80,12 +100,12 @@ public class AndroidDownloadManager internal constructor( return "${UUID.randomUUID()}$dottedExtension}" } - public override suspend fun cancel(requestId: DownloadManager.RequestId) { + public override fun cancel(requestId: DownloadManager.RequestId) { val longId = requestId.value.toLong() - downloadManager.remove() - downloadsRepository.removeId(name, longId) - if (!downloadsRepository.hasDownloads()) { - stopObservingProgress() + downloadManager.remove(longId) + listeners[requestId]?.clear() + if (!listeners.any { it.value.isNotEmpty() }) { + maybeStopObservingProgress() } } @@ -128,64 +148,75 @@ public class AndroidDownloadManager internal constructor( return this } - private fun startObservingProgress() { - if (observeProgressJob != null) { + private fun maybeStartObservingProgress() { + if (observeProgressJob != null || listeners.all { it.value.isEmpty() }) { return } observeProgressJob = coroutineScope.launch { while (true) { - val ids = downloadsRepository.idsForName(name) val cursor = downloadManager.query(SystemDownloadManager.Query()) - notify(cursor, ids) + notify(cursor) delay((1.0 / refreshRate.value).seconds) } } } - private fun stopObservingProgress() { - observeProgressJob?.cancel() - observeProgressJob = null + private fun maybeStopObservingProgress() { + if (listeners.all { it.value.isEmpty() }) { + observeProgressJob?.cancel() + observeProgressJob = null + } } - private suspend fun notify(cursor: Cursor, ids: List) = cursor.use { + private fun notify(cursor: Cursor) = cursor.use { while (cursor.moveToNext()) { val facade = DownloadCursorFacade(cursor) val id = DownloadManager.RequestId(facade.id.toString()) - - if (facade.id !in ids) { - continue + val listenersForId = listeners[id].orEmpty() + if (listenersForId.isNotEmpty()) { + notifyDownload(id, facade, listenersForId) } + } + } - when (facade.status) { - SystemDownloadManager.STATUS_FAILED -> { - listener.onDownloadFailed(id, mapErrorCode(facade.reason!!)) - downloadManager.remove(facade.id) - downloadsRepository.removeId(name, facade.id) - if (!downloadsRepository.hasDownloads()) { - stopObservingProgress() - } + private fun notifyDownload( + id: DownloadManager.RequestId, + facade: DownloadCursorFacade, + listenersForId: List + ) { + when (facade.status) { + SystemDownloadManager.STATUS_FAILED -> { + listenersForId.forEach { + it.onDownloadFailed(id, mapErrorCode(facade.reason!!)) } - SystemDownloadManager.STATUS_PAUSED -> {} - SystemDownloadManager.STATUS_PENDING -> {} - SystemDownloadManager.STATUS_SUCCESSFUL -> { - val destUri = Uri.parse(facade.localUri!!) - val destFile = File(destUri.path!!) - val newDest = File(destFile.parent, generateFileName(destFile.extension)) - if (destFile.renameTo(newDest)) { - listener.onDownloadCompleted(id, newDest) - } else { - listener.onDownloadFailed(id, DownloadManager.Error.FileError()) + downloadManager.remove(facade.id) + listeners.remove(id) + maybeStopObservingProgress() + } + SystemDownloadManager.STATUS_PAUSED -> {} + SystemDownloadManager.STATUS_PENDING -> {} + SystemDownloadManager.STATUS_SUCCESSFUL -> { + val destUri = Uri.parse(facade.localUri!!) + val destFile = File(destUri.path!!) + val newDest = File(destFile.parent, generateFileName(destFile.extension)) + if (destFile.renameTo(newDest)) { + listenersForId.forEach { + it.onDownloadCompleted(id, newDest) } - downloadManager.remove(facade.id) - downloadsRepository.removeId(name, facade.id) - if (!downloadsRepository.hasDownloads()) { - stopObservingProgress() + } else { + listenersForId.forEach { + it.onDownloadFailed(id, DownloadManager.Error.FileError()) } } - SystemDownloadManager.STATUS_RUNNING -> { - val expected = facade.expected - listener.onDownloadProgressed(id, facade.downloadedSoFar, expected) + downloadManager.remove(facade.id) + listeners.remove(id) + maybeStopObservingProgress() + } + SystemDownloadManager.STATUS_RUNNING -> { + val expected = facade.expected + listenersForId.forEach { + it.onDownloadProgressed(id, facade.downloadedSoFar, expected) } } } @@ -221,7 +252,8 @@ public class AndroidDownloadManager internal constructor( DownloadManager.Error.Unknown() } - public override suspend fun close() { + public override fun close() { + listeners.clear() coroutineScope.cancel() } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManagerProvider.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManagerProvider.kt deleted file mode 100644 index 147b61dd0f..0000000000 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManagerProvider.kt +++ /dev/null @@ -1,37 +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.downloads.android - -import android.content.Context -import android.os.Environment -import org.readium.r2.shared.units.Hz -import org.readium.r2.shared.units.hz -import org.readium.r2.shared.util.downloads.DownloadManager -import org.readium.r2.shared.util.downloads.DownloadManagerProvider - -public class AndroidDownloadManagerProvider( - private val context: Context, - private val destStorage: AndroidDownloadManager.Storage = AndroidDownloadManager.Storage.App, - private val refreshRate: Hz = 0.1.hz, - private val allowDownloadsOverMetered: Boolean = true -) : DownloadManagerProvider { - - override fun createDownloadManager( - listener: DownloadManager.Listener, - name: String - ): DownloadManager { - return AndroidDownloadManager( - context, - name, - destStorage, - Environment.DIRECTORY_DOWNLOADS, - refreshRate, - allowDownloadsOverMetered, - listener - ) - } -} diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadsRepository.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadsRepository.kt deleted file mode 100644 index 271944270a..0000000000 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadsRepository.kt +++ /dev/null @@ -1,74 +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.downloads.android - -import android.content.Context -import java.io.File -import java.util.LinkedList -import org.json.JSONArray -import org.json.JSONObject - -internal class DownloadsRepository( - context: Context -) { - - private val storageDir: File = - File(context.noBackupFilesDir, DownloadsRepository::class.qualifiedName!!) - .also { if (!it.exists()) it.mkdirs() } - - private val storageFile: File = - File(storageDir, "downloads.json") - .also { if (!it.exists()) { it.writeText("{}", Charsets.UTF_8) } } - - private var snapshot: MutableMap> = - storageFile.readText(Charsets.UTF_8).toData().toMutableMap() - - fun addId(name: String, id: Long) { - snapshot[name] = snapshot[name].orEmpty() + id - storageFile.writeText(snapshot.toJson(), Charsets.UTF_8) - } - - fun removeId(name: String, id: Long) { - snapshot[name] = snapshot[name].orEmpty() - id - storageFile.writeText(snapshot.toJson(), Charsets.UTF_8) - } - - fun idsForName(name: String): List { - return snapshot[name].orEmpty() - } - - fun hasDownloads(): Boolean = - snapshot.values.flatten().isNotEmpty() - - private fun Map>.toJson(): String { - val jsonObject = JSONObject() - for ((name, ids) in this.entries) { - jsonObject.put(name, JSONArray(ids)) - } - return jsonObject.toString() - } - - private fun String.toData(): Map> { - val jsonObject = JSONObject(this) - val names = jsonObject.keys().iterator().toList() - return names.associateWith { jsonToIds(jsonObject.getJSONArray(it)) } - } - - private fun jsonToIds(jsonArray: JSONArray): List { - val list = mutableListOf() - for (i in 0 until jsonArray.length()) { - list.add(jsonArray.getLong(i)) - } - return list - } - - private fun Iterator.toList(): List = - LinkedList().apply { - while (hasNext()) - this += next() - }.toMutableList() -} diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt index 2cf328908f..97c7ee01ad 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt @@ -22,8 +22,7 @@ import org.readium.r2.shared.util.http.HttpException import org.readium.r2.shared.util.http.HttpRequest public class ForegroundDownloadManager( - private val httpClient: HttpClient, - private val listener: DownloadManager.Listener + private val httpClient: HttpClient ) : DownloadManager { private val coroutineScope: CoroutineScope = @@ -32,8 +31,15 @@ public class ForegroundDownloadManager( private val jobs: MutableMap = mutableMapOf() - override suspend fun submit(request: DownloadManager.Request): DownloadManager.RequestId { + private val listeners: MutableMap> = + mutableMapOf() + + public override fun submit( + request: DownloadManager.Request, + listener: DownloadManager.Listener + ): DownloadManager.RequestId { val requestId = DownloadManager.RequestId(UUID.randomUUID().toString()) + listeners.getOrPut(requestId) { mutableListOf() }.add(listener) jobs[requestId] = coroutineScope.launch { doRequest(request, requestId) } return requestId } @@ -50,6 +56,8 @@ public class ForegroundDownloadManager( ?.let { ".$it" } .orEmpty() + val listenersForId = listeners[id].orEmpty() + when (response) { is Try.Success -> { withContext(Dispatchers.IO) { @@ -61,15 +69,17 @@ public class ForegroundDownloadManager( dest.writeBytes(response.value.body) } catch (e: Exception) { val error = DownloadManager.Error.FileError(ThrowableError(e)) - listener.onDownloadFailed(id, error) + listenersForId.forEach { it.onDownloadFailed(id, error) } } } } is Try.Failure -> { val error = mapError(response.value) - listener.onDownloadFailed(id, error) + listenersForId.forEach { it.onDownloadFailed(id, error) } } } + + listeners.remove(id) } private fun mapError(httpException: HttpException): DownloadManager.Error { @@ -102,10 +112,17 @@ public class ForegroundDownloadManager( } } - override suspend fun cancel(requestId: DownloadManager.RequestId) { + public override fun cancel(requestId: DownloadManager.RequestId) { jobs.remove(requestId)?.cancel() + listeners.remove(requestId) + } + + public override fun register( + requestId: DownloadManager.RequestId, + listener: DownloadManager.Listener + ) { } - override suspend fun close() { + public override fun close() { } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManagerProvider.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManagerProvider.kt deleted file mode 100644 index 704abd6511..0000000000 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManagerProvider.kt +++ /dev/null @@ -1,23 +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.downloads.foreground - -import org.readium.r2.shared.util.downloads.DownloadManager -import org.readium.r2.shared.util.downloads.DownloadManagerProvider -import org.readium.r2.shared.util.http.HttpClient - -public class ForegroundDownloadManagerProvider( - private val httpClient: HttpClient -) : DownloadManagerProvider { - - override fun createDownloadManager( - listener: DownloadManager.Listener, - name: String - ): DownloadManager { - return ForegroundDownloadManager(httpClient, listener) - } -} diff --git a/test-app/src/main/java/org/readium/r2/testapp/Application.kt b/test-app/src/main/java/org/readium/r2/testapp/Application.kt index 3120552b77..f657d505ed 100755 --- a/test-app/src/main/java/org/readium/r2/testapp/Application.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Application.kt @@ -22,6 +22,7 @@ import org.readium.r2.testapp.data.BookRepository import org.readium.r2.testapp.data.DownloadRepository import org.readium.r2.testapp.data.db.AppDatabase import org.readium.r2.testapp.domain.Bookshelf +import org.readium.r2.testapp.domain.CoverStorage import org.readium.r2.testapp.reader.ReaderRepository import timber.log.Timber @@ -70,18 +71,21 @@ class Application : android.app.Application() { AppDatabase.getDatabase(this).downloadsDao() .let { dao -> DownloadRepository(dao) } + val coverStorage = CoverStorage(storageDir) + bookshelf = Bookshelf( applicationContext, bookRepository, downloadRepository, storageDir, - readium.lcpService, + coverStorage, readium.publicationFactory, readium.assetRetriever, readium.protectionRetriever, readium.formatRegistry, - readium.downloadManagerProvider + readium.lcpService, + readium.downloadManager ) readerRepository = diff --git a/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt index c74b906a88..b0a3c0214e 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt @@ -35,7 +35,7 @@ class MainViewModel( } fun copyPublicationToAppStorage(uri: Uri) = viewModelScope.launch { - app.bookshelf.copyPublicationToAppStorage(uri) + app.bookshelf.importPublicationToAppStorage(uri) } private fun sendImportFeedback(event: Bookshelf.Event) { diff --git a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt index 8caef34284..aa9038c0b0 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt @@ -8,10 +8,10 @@ package org.readium.r2.testapp import android.content.Context import org.readium.adapters.pdfium.document.PdfiumDocumentFactory +import org.readium.r2.lcp.LcpException import org.readium.r2.lcp.LcpService import org.readium.r2.navigator.preferences.FontFamily import org.readium.r2.shared.ExperimentalReadiumApi -import org.readium.r2.shared.UserException import org.readium.r2.shared.asset.AssetRetriever import org.readium.r2.shared.publication.protection.ContentProtectionSchemeRetriever import org.readium.r2.shared.resource.CompositeArchiveFactory @@ -23,7 +23,6 @@ import org.readium.r2.shared.resource.FileResourceFactory import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.archive.channel.ChannelZipArchiveFactory import org.readium.r2.shared.util.downloads.android.AndroidDownloadManager -import org.readium.r2.shared.util.downloads.android.AndroidDownloadManagerProvider import org.readium.r2.shared.util.http.DefaultHttpClient import org.readium.r2.shared.util.http.HttpResourceFactory import org.readium.r2.shared.util.mediatype.FormatRegistry @@ -68,7 +67,7 @@ class Readium(context: Context) { context.contentResolver ) - val downloadManagerProvider = AndroidDownloadManagerProvider( + val downloadManager = AndroidDownloadManager( context = context, destStorage = AndroidDownloadManager.Storage.App ) @@ -81,9 +80,9 @@ class Readium(context: Context) { context, assetRetriever, mediaTypeRetriever, - downloadManagerProvider + downloadManager )?.let { Try.success(it) } - ?: Try.failure(UserException("liblcp is missing on the classpath")) + ?: Try.failure(LcpException.Unknown(Exception("liblcp is missing on the classpath"))) private val contentProtections = listOfNotNull( lcpService.getOrNull()?.contentProtection() diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt index ea4e9320db..ae483e33d0 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt @@ -33,7 +33,7 @@ class BookshelfViewModel(application: Application) : AndroidViewModel(applicatio fun copyPublicationToAppStorage(uri: Uri) { viewModelScope.launch { - app.bookshelf.copyPublicationToAppStorage(uri) + app.bookshelf.importPublicationToAppStorage(uri) } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/data/DownloadRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/data/DownloadRepository.kt index d2f03b3fec..4c720eae59 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/data/DownloadRepository.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/DownloadRepository.kt @@ -13,27 +13,52 @@ class DownloadRepository( private val downloadsDao: DownloadsDao ) { + suspend fun getLcpDownloads(): List { + return downloadsDao.getLcpDownloads() + } + + suspend fun getOpdsDownloads(): List { + return downloadsDao.getOpdsDownloads() + } + + suspend fun insertLcpDownload( + id: String, + cover: String? + ) { + downloadsDao.insert( + Download(id = id, type = Download.TYPE_LCP, extra = cover) + ) + } + suspend fun insertOpdsDownload( - manager: String, id: String, cover: String? ) { downloadsDao.insert( - Download(manager = manager, id = id, extra = cover) + Download(id = id, type = Download.TYPE_OPDS, extra = cover) ) } + suspend fun getLcpDownloadCover( + id: String + ): String? { + return downloadsDao.get(id, Download.TYPE_LCP)!!.extra + } suspend fun getOpdsDownloadCover( - manager: String, id: String ): String? { - return downloadsDao.get(manager, id)!!.extra + return downloadsDao.get(id, Download.TYPE_OPDS)!!.extra + } + + suspend fun removeLcpDownload( + id: String + ) { + downloadsDao.delete(id, Download.TYPE_LCP) } - suspend fun removeDownload( - manager: String, + suspend fun removeOpdsDownload( id: String ) { - downloadsDao.delete(manager, id) + downloadsDao.delete(id, Download.TYPE_OPDS) } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadsDao.kt b/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadsDao.kt index e2e4d13ec7..31f85a2a37 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadsDao.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadsDao.kt @@ -19,13 +19,25 @@ interface DownloadsDao { @Query( "DELETE FROM " + Download.TABLE_NAME + - " WHERE " + Download.ID + " = :id" + " AND " + Download.MANAGER + " = :manager" + " WHERE " + Download.ID + " = :id AND " + Download.TYPE + " = :type" ) - suspend fun delete(manager: String, id: String) + suspend fun delete(id: String, type: String) @Query( "SELECT * FROM " + Download.TABLE_NAME + - " WHERE " + Download.ID + " = :id" + " AND " + Download.MANAGER + " = :manager" + " WHERE " + Download.ID + " = :id AND " + Download.TYPE + " = :type" ) - suspend fun get(manager: String, id: String): Download? + suspend fun get(id: String, type: String): Download? + + @Query( + "SELECT * FROM " + Download.TABLE_NAME + + " WHERE " + Download.TYPE + " = '" + Download.TYPE_OPDS + "'" + ) + suspend fun getOpdsDownloads(): List + + @Query( + "SELECT * FROM " + Download.TABLE_NAME + + " WHERE " + Download.TYPE + " = '" + Download.TYPE_LCP + "'" + ) + suspend fun getLcpDownloads(): List } diff --git a/test-app/src/main/java/org/readium/r2/testapp/data/model/Download.kt b/test-app/src/main/java/org/readium/r2/testapp/data/model/Download.kt index 240aa632c1..0e592a9205 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/data/model/Download.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/model/Download.kt @@ -9,12 +9,12 @@ package org.readium.r2.testapp.data.model import androidx.room.ColumnInfo import androidx.room.Entity -@Entity(tableName = Download.TABLE_NAME, primaryKeys = [Download.MANAGER, Download.ID]) +@Entity(tableName = Download.TABLE_NAME, primaryKeys = [Download.ID, Download.TYPE]) data class Download( - @ColumnInfo(name = MANAGER) - val manager: String, @ColumnInfo(name = ID) val id: String, + @ColumnInfo(name = TYPE) + val type: String, @ColumnInfo(name = EXTRA) val extra: String? = null, @ColumnInfo(name = CREATION_DATE, defaultValue = "CURRENT_TIMESTAMP") @@ -24,8 +24,11 @@ data class Download( companion object { const val TABLE_NAME = "downloads" const val CREATION_DATE = "creation_date" - const val MANAGER = "manager" const val ID = "id" + const val TYPE = "TYPE" const val EXTRA = "cover" + + const val TYPE_OPDS = "opds" + const val TYPE_LCP = "lcp" } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt index 50c0a156f6..7e453f1edf 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt @@ -7,49 +7,28 @@ package org.readium.r2.testapp.domain import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.net.Uri -import androidx.annotation.StringRes import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.net.HttpURLConnection -import java.net.URL -import java.util.UUID import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.readium.r2.lcp.LcpException -import org.readium.r2.lcp.LcpPublicationRetriever import org.readium.r2.lcp.LcpService -import org.readium.r2.shared.UserException -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.publication.Publication import org.readium.r2.shared.publication.protection.ContentProtectionSchemeRetriever -import org.readium.r2.shared.publication.services.cover import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.downloads.DownloadManager -import org.readium.r2.shared.util.downloads.DownloadManagerProvider import org.readium.r2.shared.util.getOrElse import org.readium.r2.shared.util.mediatype.FormatRegistry -import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.toUrl import org.readium.r2.streamer.PublicationFactory -import org.readium.r2.testapp.R import org.readium.r2.testapp.data.BookRepository import org.readium.r2.testapp.data.DownloadRepository import org.readium.r2.testapp.data.model.Book import org.readium.r2.testapp.utils.extensions.copyToTempFile -import org.readium.r2.testapp.utils.extensions.moveTo import org.readium.r2.testapp.utils.tryOrLog -import org.readium.r2.testapp.utils.tryOrNull import timber.log.Timber class Bookshelf( @@ -57,67 +36,14 @@ class Bookshelf( private val bookRepository: BookRepository, downloadRepository: DownloadRepository, private val storageDir: File, - lcpService: Try, + private val coverStorage: CoverStorage, private val publicationFactory: PublicationFactory, private val assetRetriever: AssetRetriever, private val protectionRetriever: ContentProtectionSchemeRetriever, - private val formatRegistry: FormatRegistry, - downloadManagerProvider: DownloadManagerProvider + formatRegistry: FormatRegistry, + lcpService: Try, + downloadManager: DownloadManager ) { - sealed class ImportError( - content: Content, - cause: Exception? - ) : UserException(content, cause) { - - constructor(@StringRes userMessageId: Int) : - this(Content(userMessageId), null) - - constructor(cause: UserException) : - this(Content(cause), cause) - - class LcpAcquisitionFailed( - override val cause: UserException - ) : ImportError(cause) - - class PublicationError( - override val cause: UserException - ) : ImportError(cause) { - - companion object { - - operator fun invoke( - error: AssetRetriever.Error - ): ImportError = PublicationError( - org.readium.r2.testapp.domain.PublicationError( - error - ) - ) - - operator fun invoke( - error: Publication.OpeningException - ): ImportError = PublicationError( - org.readium.r2.testapp.domain.PublicationError( - error - ) - ) - } - } - - class ImportBookFailed( - override val cause: Throwable - ) : ImportError(R.string.import_publication_unexpected_io_exception) - - class DownloadFailed( - val error: DownloadManager.Error - ) : ImportError(R.string.import_publication_download_failed) - - class OpdsError( - override val cause: Throwable - ) : ImportError(R.string.import_publication_no_acquisition) - - class ImportDatabaseFailed : - ImportError(R.string.import_publication_unable_add_pub_database) - } sealed class Event { data object ImportPublicationSuccess : @@ -131,239 +57,91 @@ class Bookshelf( private val coroutineScope: CoroutineScope = MainScope() - private val coverDir: File = - File(storageDir, "covers/") - .apply { if (!exists()) mkdirs() } + val channel: Channel = + Channel(Channel.BUFFERED) - private val opdsDownloader: OpdsDownloader = - OpdsDownloader( + private val publicationRetriever: PublicationRetriever = + PublicationRetriever( + storageDir, + assetRetriever, + formatRegistry, downloadRepository, - downloadManagerProvider, - OpdsDownloaderListener() - ) - - private inner class OpdsDownloaderListener : OpdsDownloader.Listener { - override fun onDownloadCompleted(publication: File, cover: String?) { - coroutineScope.launch { - addLocalBook(publication, cover) - } - } - - override fun onDownloadFailed(error: DownloadManager.Error) { - coroutineScope.launch { - channel.send( - Event.ImportPublicationError( - ImportError.DownloadFailed(error) - ) - ) - } - } - } - - private val lcpPublicationRetriever = lcpService.map { - it.publicationRetriever( - LcpRetrieverListener() + downloadManager, + lcpService.map { it.publicationRetriever() }, + PublicationRetrieverListener() ) - } - private inner class LcpRetrieverListener : LcpPublicationRetriever.Listener { - override fun onAcquisitionCompleted( - requestId: LcpPublicationRetriever.RequestId, - acquiredPublication: LcpService.AcquiredPublication - ) { + private inner class PublicationRetrieverListener : PublicationRetriever.Listener { + override fun onImportSucceeded(publication: File, coverUrl: String?) { coroutineScope.launch { - addLocalBook(acquiredPublication.localFile) + val url = publication.toUrl() + addBookFeedback(url, coverUrl) } } - override fun onAcquisitionProgressed( - requestId: LcpPublicationRetriever.RequestId, - downloaded: Long, - expected: Long? - ) { - } - - override fun onAcquisitionFailed( - requestId: LcpPublicationRetriever.RequestId, - error: LcpException - ) { + override fun onImportError(error: ImportError) { coroutineScope.launch { - channel.send( - Event.ImportPublicationError( - ImportError.LcpAcquisitionFailed(error) - ) - ) + channel.send(Event.ImportPublicationError(error)) } } } - val channel: Channel = - Channel(Channel.BUFFERED) - - suspend fun copyPublicationToAppStorage( + suspend fun importPublicationToAppStorage( contentUri: Uri ) { val tempFile = contentUri.copyToTempFile(context, storageDir) .getOrElse { - channel.send( - Event.ImportPublicationError(ImportError.ImportBookFailed(it)) - ) + channel.send(Event.ImportPublicationError(ImportError.ImportBookFailed(it))) return } - addLocalBook(tempFile) + publicationRetriever.importFromAppStorage(tempFile) } suspend fun downloadPublicationFromOpds( publication: Publication ) { - opdsDownloader.download(publication) - .getOrElse { - channel.send( - Event.ImportPublicationError( - ImportError.OpdsError(it) - ) - ) - } + publicationRetriever.downloadFromOpds(publication) } suspend fun addPublicationFromTheWeb( url: Url ) { - val asset = assetRetriever.retrieve(url) - ?: run { - channel.send(mediaTypeNotSupportedEvent()) - return - } - - addBook(url, asset) - .onSuccess { channel.send(Event.ImportPublicationSuccess) } - .onFailure { channel.send(Event.ImportPublicationError(it)) } + addBookFeedback(url) } suspend fun addPublicationFromSharedStorage( - url: Url, - coverUrl: String? = null - ) { - val asset = assetRetriever.retrieve(url) - ?: run { - channel.send(mediaTypeNotSupportedEvent()) - return - } - - addBook(url, asset, coverUrl) - .onSuccess { channel.send(Event.ImportPublicationSuccess) } - .onFailure { channel.send(Event.ImportPublicationError(it)) } - } - - suspend fun addLocalBook( - tempFile: File, - coverUrl: String? = null + url: Url ) { - val sourceAsset = assetRetriever.retrieve(tempFile) - ?: run { - channel.send(mediaTypeNotSupportedEvent()) - return - } - - if ( - sourceAsset is Asset.Resource && - sourceAsset.mediaType.matches(MediaType.LCP_LICENSE_DOCUMENT) - ) { - acquireLcpPublication(sourceAsset) - return - } - - val fileExtension = formatRegistry.fileExtension(sourceAsset.mediaType) ?: "epub" - val fileName = "${UUID.randomUUID()}.$fileExtension" - val libraryFile = File(storageDir, fileName) - - try { - tempFile.moveTo(libraryFile) - } catch (e: Exception) { - Timber.d(e) - tryOrNull { libraryFile.delete() } - channel.send( - Event.ImportPublicationError( - ImportError.ImportBookFailed(e) - ) - ) - return - } - - addActualLocalBook( - libraryFile, - sourceAsset.mediaType, - sourceAsset.assetType, - coverUrl - ).onSuccess { - channel.send(Event.ImportPublicationSuccess) - }.onFailure { - tryOrNull { libraryFile.delete() } - channel.send(Event.ImportPublicationError(it)) - } + addBookFeedback(url) } - private fun mediaTypeNotSupportedEvent(): Event.ImportPublicationError = - Event.ImportPublicationError( - ImportError.PublicationError( - PublicationError.UnsupportedPublication( - Publication.OpeningException.UnsupportedAsset( - "Unsupported media type" - ) + private fun mediaTypeNotSupportedError(): ImportError = + ImportError.PublicationError( + PublicationError.UnsupportedPublication( + Publication.OpeningException.UnsupportedAsset( + "Unsupported media type" ) ) ) - private suspend fun acquireLcpPublication(licenceAsset: Asset.Resource) { - val lcpRetriever = lcpPublicationRetriever - .getOrElse { - channel.send( - Event.ImportPublicationError( - ImportError.LcpAcquisitionFailed(it) - ) - ) - return - } - - val license = licenceAsset.resource.read() - .getOrElse { - channel.send( - Event.ImportPublicationError( - ImportError.LcpAcquisitionFailed(it) - ) - ) - return - } - - lcpRetriever.retrieve(license, "LCP Publication", "Downloading") - } - - private suspend fun addActualLocalBook( - libraryFile: File, - mediaType: MediaType, - assetType: AssetType, - coverUrl: String? - ): Try { - val libraryUrl = libraryFile.toUrl() - val libraryAsset = assetRetriever.retrieve( - libraryUrl, - mediaType, - assetType - ).getOrElse { return Try.failure(ImportError.PublicationError(it)) } - - return addBook( - libraryUrl, - libraryAsset, - coverUrl - ) + private suspend fun addBookFeedback( + url: Url, + coverUrl: String? = null + ) { + addBook(url, coverUrl) + .onSuccess { channel.send(Event.ImportPublicationSuccess) } + .onFailure { channel.send(Event.ImportPublicationError(it)) } } private suspend fun addBook( url: Url, - asset: Asset, coverUrl: String? = null ): Try { + val asset = + assetRetriever.retrieve(url) + ?: return Try.failure(mediaTypeNotSupportedError()) + val drmScheme = protectionRetriever.retrieve(asset) @@ -372,15 +150,11 @@ class Bookshelf( contentProtectionScheme = drmScheme, allowUserInteraction = false ).onSuccess { publication -> - val coverBitmap: Bitmap? = coverUrl - ?.let { getBitmapFromURL(it) } - ?: publication.cover() val coverFile = - try { - storeCover(coverBitmap) - } catch (e: Exception) { - return Try.failure(ImportError.ImportBookFailed(e)) - } + coverStorage.storeCover(publication, coverUrl) + .getOrElse { + return Try.failure(ImportError.ImportBookFailed(it)) + } val id = bookRepository.insertBook( url.toString(), @@ -405,32 +179,6 @@ class Bookshelf( return Try.success(Unit) } - private suspend fun storeCover(cover: Bitmap?): File = - withContext(Dispatchers.IO) { - val coverImageFile = File(coverDir, "${UUID.randomUUID()}.png") - val resized = cover?.let { Bitmap.createScaledBitmap(it, 120, 200, true) } - val fos = FileOutputStream(coverImageFile) - resized?.compress(Bitmap.CompressFormat.PNG, 80, fos) - fos.flush() - fos.close() - coverImageFile - } - - private suspend fun getBitmapFromURL(src: String): Bitmap? = - withContext(Dispatchers.IO) { - try { - val url = URL(src) - val connection = url.openConnection() as HttpURLConnection - connection.doInput = true - connection.connect() - val input = connection.inputStream - BitmapFactory.decodeStream(input) - } catch (e: IOException) { - e.printStackTrace() - null - } - } - suspend fun deleteBook(book: Book) { val id = book.id!! bookRepository.deleteBook(id) diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/CoverStorage.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/CoverStorage.kt new file mode 100644 index 0000000000..e3d2f45c75 --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/CoverStorage.kt @@ -0,0 +1,61 @@ +package org.readium.r2.testapp.domain + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.readium.r2.shared.publication.Publication +import org.readium.r2.shared.publication.services.cover +import org.readium.r2.shared.util.Try + +class CoverStorage( + appStorageDir: File +) { + + private val coverDir: File = + File(appStorageDir, "covers/") + .apply { if (!exists()) mkdirs() } + + suspend fun storeCover(publication: Publication, overrideUrl: String?): Try { + val coverBitmap: Bitmap? = overrideUrl + ?.let { getBitmapFromURL(it) } + ?: publication.cover() + return try { + Try.success(storeCover(coverBitmap)) + } catch (e: Exception) { + Try.failure(e) + } + } + + private suspend fun storeCover(cover: Bitmap?): File = + withContext(Dispatchers.IO) { + val coverImageFile = File(coverDir, "${UUID.randomUUID()}.png") + val resized = cover?.let { Bitmap.createScaledBitmap(it, 120, 200, true) } + val fos = FileOutputStream(coverImageFile) + resized?.compress(Bitmap.CompressFormat.PNG, 80, fos) + fos.flush() + fos.close() + coverImageFile + } + + private suspend fun getBitmapFromURL(src: String): Bitmap? = + withContext(Dispatchers.IO) { + try { + val url = URL(src) + val connection = url.openConnection() as HttpURLConnection + connection.doInput = true + connection.connect() + val input = connection.inputStream + BitmapFactory.decodeStream(input) + } catch (e: IOException) { + e.printStackTrace() + null + } + } +} diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/ImportError.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/ImportError.kt new file mode 100644 index 0000000000..3838469ff7 --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/ImportError.kt @@ -0,0 +1,69 @@ +/* + * 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.testapp.domain + +import androidx.annotation.StringRes +import org.readium.r2.shared.UserException +import org.readium.r2.shared.asset.AssetRetriever +import org.readium.r2.shared.publication.Publication +import org.readium.r2.shared.util.downloads.DownloadManager +import org.readium.r2.testapp.R + +sealed class ImportError( + content: Content, + cause: Exception? +) : UserException(content, cause) { + + constructor(@StringRes userMessageId: Int) : + this(Content(userMessageId), null) + + constructor(cause: UserException) : + this(Content(cause), cause) + + class LcpAcquisitionFailed( + override val cause: UserException + ) : ImportError(cause) + + class PublicationError( + override val cause: UserException + ) : ImportError(cause) { + + companion object { + + operator fun invoke( + error: AssetRetriever.Error + ): ImportError = PublicationError( + org.readium.r2.testapp.domain.PublicationError( + error + ) + ) + + operator fun invoke( + error: Publication.OpeningException + ): ImportError = PublicationError( + org.readium.r2.testapp.domain.PublicationError( + error + ) + ) + } + } + + class ImportBookFailed( + override val cause: Throwable + ) : ImportError(R.string.import_publication_unexpected_io_exception) + + class DownloadFailed( + val error: DownloadManager.Error + ) : ImportError(R.string.import_publication_download_failed) + + class OpdsError( + override val cause: Throwable + ) : ImportError(R.string.import_publication_no_acquisition) + + class ImportDatabaseFailed : + ImportError(R.string.import_publication_unable_add_pub_database) +} diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt deleted file mode 100644 index e5088d08d0..0000000000 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/OpdsDownloader.kt +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright 2021 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.testapp.domain - -import java.io.File -import java.net.URL -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.launch -import org.readium.r2.shared.publication.Publication -import org.readium.r2.shared.publication.opds.images -import org.readium.r2.shared.util.Try -import org.readium.r2.shared.util.Url -import org.readium.r2.shared.util.downloads.DownloadManager -import org.readium.r2.shared.util.downloads.DownloadManagerProvider -import org.readium.r2.shared.util.getOrElse -import org.readium.r2.shared.util.mediatype.MediaType -import org.readium.r2.testapp.data.DownloadRepository - -class OpdsDownloader( - private val downloadRepository: DownloadRepository, - private val downloadManagerProvider: DownloadManagerProvider, - private val listener: Listener -) { - - interface Listener { - - fun onDownloadCompleted(publication: File, cover: String?) - - fun onDownloadFailed(error: DownloadManager.Error) - } - - private val coroutineScope: CoroutineScope = - MainScope() - - private val managerName: String = - "opds-downloader" - - private val downloadManager = downloadManagerProvider.createDownloadManager( - listener = DownloadListener(), - name = managerName - ) - - private inner class DownloadListener : DownloadManager.Listener { - override fun onDownloadCompleted(requestId: DownloadManager.RequestId, file: File) { - coroutineScope.launch { - val cover = downloadRepository.getOpdsDownloadCover(managerName, requestId.value) - downloadRepository.removeDownload(managerName, requestId.value) - listener.onDownloadCompleted(file, cover) - } - } - - override fun onDownloadProgressed( - requestId: DownloadManager.RequestId, - downloaded: Long, - expected: Long? - ) { - } - - override fun onDownloadFailed( - requestId: DownloadManager.RequestId, - error: DownloadManager.Error - ) { - listener.onDownloadFailed(error) - } - } - - fun download(publication: Publication): Try { - val publicationUrl = getDownloadURL(publication) - .getOrElse { return Try.failure(it) } - .toString() - - val coverUrl = publication - .images.firstOrNull()?.href - - coroutineScope.launch { - downloadAsync(publication.metadata.title, publicationUrl, coverUrl) - } - - return Try.success(Unit) - } - - private suspend fun downloadAsync( - publicationTitle: String?, - publicationUrl: String, - coverUrl: String? - ) { - val requestId = downloadManager.submit( - DownloadManager.Request( - Url(publicationUrl)!!, - title = publicationTitle ?: "Untitled publication", - description = "Downloading", - headers = emptyMap() - ) - ) - downloadRepository.insertOpdsDownload( - manager = managerName, - id = requestId.value, - cover = coverUrl - ) - } - - private fun getDownloadURL(publication: Publication): Try = - publication.links - .firstOrNull { it.mediaType?.isPublication == true || it.mediaType == MediaType.LCP_LICENSE_DOCUMENT } - ?.let { - try { - Try.success(URL(it.href)) - } catch (e: Exception) { - Try.failure(e) - } - } ?: Try.failure(Exception("No supported link to acquire publication.")) -} diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt new file mode 100644 index 0000000000..e98ad9be63 --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt @@ -0,0 +1,252 @@ +/* + * Copyright 2021 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.testapp.domain + +import java.io.File +import java.net.URL +import java.util.UUID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import org.readium.r2.lcp.LcpException +import org.readium.r2.lcp.LcpPublicationRetriever +import org.readium.r2.lcp.LcpService +import org.readium.r2.shared.asset.Asset +import org.readium.r2.shared.asset.AssetRetriever +import org.readium.r2.shared.publication.Publication +import org.readium.r2.shared.publication.opds.images +import org.readium.r2.shared.util.Try +import org.readium.r2.shared.util.Url +import org.readium.r2.shared.util.downloads.DownloadManager +import org.readium.r2.shared.util.getOrElse +import org.readium.r2.shared.util.mediatype.FormatRegistry +import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.testapp.data.DownloadRepository +import org.readium.r2.testapp.utils.extensions.moveTo +import org.readium.r2.testapp.utils.tryOrNull +import timber.log.Timber + +class PublicationRetriever( + private val storageDir: File, + private val assetRetriever: AssetRetriever, + private val formatRegistry: FormatRegistry, + private val downloadRepository: DownloadRepository, + private val downloadManager: DownloadManager, + private val lcpPublicationRetriever: Try, + private val listener: Listener +) { + + interface Listener { + + fun onImportSucceeded(publication: File, coverUrl: String?) + + fun onImportError(error: ImportError) + } + + private val coroutineScope: CoroutineScope = + MainScope() + + private inner class DownloadListener : DownloadManager.Listener { + override fun onDownloadCompleted(requestId: DownloadManager.RequestId, file: File) { + coroutineScope.launch { + val coverUrl = downloadRepository.getOpdsDownloadCover(requestId.value) + downloadRepository.removeOpdsDownload(requestId.value) + importFromAppStorage(file, coverUrl) + } + } + + override fun onDownloadProgressed( + requestId: DownloadManager.RequestId, + downloaded: Long, + expected: Long? + ) { + } + + override fun onDownloadFailed( + requestId: DownloadManager.RequestId, + error: DownloadManager.Error + ) { + coroutineScope.launch { + downloadRepository.removeOpdsDownload(requestId.value) + listener.onImportError(ImportError.DownloadFailed(error)) + } + } + } + + private inner class LcpRetrieverListener : LcpPublicationRetriever.Listener { + override fun onAcquisitionCompleted( + requestId: LcpPublicationRetriever.RequestId, + acquiredPublication: LcpService.AcquiredPublication + ) { + coroutineScope.launch { + val coverUrl = downloadRepository.getLcpDownloadCover(requestId.value) + downloadRepository.removeLcpDownload(requestId.value) + importFromAppStorage(acquiredPublication.localFile, coverUrl) + } + } + + override fun onAcquisitionProgressed( + requestId: LcpPublicationRetriever.RequestId, + downloaded: Long, + expected: Long? + ) { + } + + override fun onAcquisitionFailed( + requestId: LcpPublicationRetriever.RequestId, + error: LcpException + ) { + coroutineScope.launch { + downloadRepository.removeLcpDownload(requestId.value) + listener.onImportError(ImportError.LcpAcquisitionFailed(error)) + } + } + } + + private val downloadListener: DownloadListener = + DownloadListener() + + private val lcpRetrieverListener: LcpRetrieverListener = + LcpRetrieverListener() + + init { + coroutineScope.launch { + for (download in downloadRepository.getOpdsDownloads()) { + downloadManager.register( + DownloadManager.RequestId(download.id), + downloadListener + ) + } + + lcpPublicationRetriever.map { publicationRetriever -> + for (download in downloadRepository.getLcpDownloads()) { + publicationRetriever.register( + LcpPublicationRetriever.RequestId(download.id), + lcpRetrieverListener + ) + } + } + } + } + + fun downloadFromOpds(publication: Publication) { + val publicationUrl = getDownloadURL(publication) + .getOrElse { + listener.onImportError(ImportError.OpdsError(it)) + return + }.toString() + + val coverUrl = publication + .images.firstOrNull()?.href + + coroutineScope.launch { + downloadAsync(publication.metadata.title, publicationUrl, coverUrl) + } + } + + private suspend fun downloadAsync( + publicationTitle: String?, + publicationUrl: String, + coverUrl: String? + ) { + val requestId = downloadManager.submit( + request = DownloadManager.Request( + Url(publicationUrl)!!, + title = publicationTitle ?: "Untitled publication", + description = "Downloading", + headers = emptyMap() + ), + listener = downloadListener + ) + downloadRepository.insertOpdsDownload( + id = requestId.value, + cover = coverUrl + ) + } + + private fun getDownloadURL(publication: Publication): Try = + publication.links + .firstOrNull { it.mediaType?.isPublication == true || it.mediaType == MediaType.LCP_LICENSE_DOCUMENT } + ?.let { + try { + Try.success(URL(it.href)) + } catch (e: Exception) { + Try.failure(e) + } + } ?: Try.failure(Exception("No supported link to acquire publication.")) + + suspend fun importFromAppStorage( + tempFile: File, + coverUrl: String? = null + ) { + val sourceAsset = assetRetriever.retrieve(tempFile) + ?: run { + listener.onImportError(mediaTypeNotSupportedError()) + return + } + + if ( + sourceAsset is Asset.Resource && + sourceAsset.mediaType.matches(MediaType.LCP_LICENSE_DOCUMENT) + ) { + acquireLcpPublication(sourceAsset, coverUrl) + .getOrElse { + listener.onImportError(ImportError.ImportBookFailed(it)) + return + } + return + } + + val fileExtension = formatRegistry.fileExtension(sourceAsset.mediaType) ?: "epub" + val fileName = "${UUID.randomUUID()}.$fileExtension" + val libraryFile = File(storageDir, fileName) + + try { + tempFile.moveTo(libraryFile) + } catch (e: Exception) { + Timber.d(e) + tryOrNull { libraryFile.delete() } + listener.onImportError(ImportError.ImportBookFailed(e)) + return + } + + listener.onImportSucceeded(libraryFile, coverUrl) + } + + private fun mediaTypeNotSupportedError(): ImportError.PublicationError = + ImportError.PublicationError( + PublicationError.UnsupportedPublication( + Publication.OpeningException.UnsupportedAsset( + "Unsupported media type" + ) + ) + ) + + private suspend fun acquireLcpPublication( + licenceAsset: Asset.Resource, + coverUrl: String? + ): Try { + val lcpRetriever = lcpPublicationRetriever + .getOrElse { return Try.failure(it) } + + val license = licenceAsset.resource.read() + .getOrElse { return Try.failure(it) } + + val requestId = lcpRetriever.retrieve( + license, + "Fulfilling Lcp publication", + null, + lcpRetrieverListener + ).getOrElse { + return Try.failure(it) + } + + downloadRepository.insertLcpDownload(requestId.value, coverUrl) + + return Try.success(Unit) + } +} From d93b2bc713043b712a625fdf00915b1ec266255c Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Mon, 4 Sep 2023 20:12:07 +0200 Subject: [PATCH 20/35] Various changes --- .../readium/r2/lcp/LcpPublicationRetriever.kt | 44 ++---- .../r2/lcp/license/model/LicenseDocument.kt | 7 + .../org/readium/r2/testapp/MainActivity.kt | 2 +- .../org/readium/r2/testapp/MainViewModel.kt | 4 +- .../r2/testapp/bookshelf/BookshelfFragment.kt | 6 +- .../testapp/bookshelf/BookshelfViewModel.kt | 19 +-- .../r2/testapp/catalogs/CatalogViewModel.kt | 2 +- .../r2/testapp/data/DownloadRepository.kt | 2 +- .../r2/testapp/data/db/DownloadsDao.kt | 2 +- .../readium/r2/testapp/domain/Bookshelf.kt | 63 ++++---- .../readium/r2/testapp/domain/ImportError.kt | 13 +- .../r2/testapp/domain/PublicationError.kt | 6 +- .../r2/testapp/domain/PublicationRetriever.kt | 148 ++++++++++-------- .../r2/testapp/reader/ReaderRepository.kt | 6 +- test-app/src/main/res/values/strings.xml | 2 +- 15 files changed, 151 insertions(+), 175 deletions(-) diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index 56ee0fa46c..d143861bb2 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -11,7 +11,6 @@ import java.io.File import org.readium.r2.lcp.license.container.createLicenseContainer import org.readium.r2.lcp.license.model.LicenseDocument import org.readium.r2.shared.extensions.tryOrLog -import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.downloads.DownloadManager import org.readium.r2.shared.util.mediatype.FormatRegistry @@ -156,36 +155,18 @@ public class LcpPublicationRetriever( } public fun retrieve( - license: ByteArray, + license: LicenseDocument, downloadTitle: String, downloadDescription: String? = null, listener: Listener - ): Try { - return try { - val licenseDocument = LicenseDocument(license) - val requestId = fetchPublication( - licenseDocument, - downloadTitle, - downloadDescription - ) - register(requestId, listener) - Try.success(requestId) - } catch (e: Exception) { - Try.failure(LcpException.wrap(e)) - } - } - - public fun retrieve( - license: File, - downloadTitle: String, - downloadDescription: String, - listener: Listener - ): Try { - return try { - retrieve(license.readBytes(), downloadTitle, downloadDescription, listener) - } catch (e: Exception) { - Try.failure(LcpException.wrap(e)) - } + ): RequestId { + val requestId = fetchPublication( + license, + downloadTitle, + downloadDescription + ) + register(requestId, listener) + return requestId } public fun cancel(requestId: RequestId) { @@ -198,13 +179,12 @@ public class LcpPublicationRetriever( downloadTitle: String, downloadDescription: String? ): RequestId { - val link = license.link(LicenseDocument.Rel.Publication) - val url = link?.url - ?: throw LcpException.Parsing.Url(rel = LicenseDocument.Rel.Publication.value) + val link = license.link(LicenseDocument.Rel.Publication)!! + val url = Url(link.url) val requestId = downloadManager.submit( request = DownloadManager.Request( - url = Url(url), + url = url, title = downloadTitle, description = downloadDescription, headers = emptyMap() diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/LicenseDocument.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/LicenseDocument.kt index 397ac5a540..efc0db377e 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/LicenseDocument.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/LicenseDocument.kt @@ -70,6 +70,13 @@ public class LicenseDocument internal constructor(public val json: JSONObject) { if (link(Rel.Hint) == null || link(Rel.Publication) == null) { throw LcpException.Parsing.LicenseDocument } + + // Check that the acquisition link has a valid URL. + try { + link(Rel.Publication)!!.url + } catch (e: Exception) { + throw LcpException.Parsing.Url(rel = LicenseDocument.Rel.Publication.value) + } } public constructor(data: ByteArray) : this( diff --git a/test-app/src/main/java/org/readium/r2/testapp/MainActivity.kt b/test-app/src/main/java/org/readium/r2/testapp/MainActivity.kt index 04464b96ef..f82908b690 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/MainActivity.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/MainActivity.kt @@ -27,7 +27,7 @@ class MainActivity : AppCompatActivity() { setContentView(R.layout.activity_main) intent.data?.let { - viewModel.copyPublicationToAppStorage(it) + viewModel.importPublicationFromStorage(it) } val navView: BottomNavigationView = findViewById(R.id.nav_view) diff --git a/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt index b0a3c0214e..c534a90d9e 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt @@ -33,9 +33,9 @@ class MainViewModel( .onEach { sendImportFeedback(it) } .launchIn(viewModelScope) } - fun copyPublicationToAppStorage(uri: Uri) = + fun importPublicationFromStorage(uri: Uri) = viewModelScope.launch { - app.bookshelf.importPublicationToAppStorage(uri) + app.bookshelf.importPublicationFromStorage(uri) } private fun sendImportFeedback(event: Bookshelf.Event) { diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfFragment.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfFragment.kt index 332d1809ca..02b8c2b4c5 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfFragment.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfFragment.kt @@ -67,7 +67,7 @@ class BookshelfFragment : Fragment() { appStoragePickerLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> uri?.let { - bookshelfViewModel.copyPublicationToAppStorage(it) + bookshelfViewModel.importPublicationFromStorage(it) } } @@ -76,7 +76,7 @@ class BookshelfFragment : Fragment() { uri?.let { val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) - bookshelfViewModel.addPublicationFromSharedStorage(it) + bookshelfViewModel.addPublicationFromStorage(it) } } @@ -132,7 +132,7 @@ class BookshelfFragment : Fragment() { return@setPositiveButton } - bookshelfViewModel.addPublicationFromTheWeb(url) + bookshelfViewModel.addPublicationFromWeb(url) } .show() } diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt index ae483e33d0..0ea35d3fc2 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfViewModel.kt @@ -14,6 +14,7 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import org.readium.r2.shared.util.Url +import org.readium.r2.shared.util.toUrl import org.readium.r2.testapp.data.model.Book import org.readium.r2.testapp.reader.ReaderActivityContract import org.readium.r2.testapp.utils.EventChannel @@ -31,22 +32,16 @@ class BookshelfViewModel(application: Application) : AndroidViewModel(applicatio app.bookshelf.deleteBook(book) } - fun copyPublicationToAppStorage(uri: Uri) { - viewModelScope.launch { - app.bookshelf.importPublicationToAppStorage(uri) - } + fun importPublicationFromStorage(uri: Uri) { + app.bookshelf.importPublicationFromStorage(uri) } - fun addPublicationFromSharedStorage(uri: Uri) { - viewModelScope.launch { - app.bookshelf.addPublicationFromSharedStorage(Url(uri.toString())!!) - } + fun addPublicationFromStorage(uri: Uri) { + app.bookshelf.addPublicationFromStorage(uri.toUrl()!!) } - fun addPublicationFromTheWeb(url: Url) { - viewModelScope.launch { - app.bookshelf.addPublicationFromTheWeb(url) - } + fun addPublicationFromWeb(url: Url) { + app.bookshelf.addPublicationFromWeb(url) } fun openPublication( diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt index 95a867208e..3d9c271563 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt @@ -53,7 +53,7 @@ class CatalogViewModel(application: Application) : AndroidViewModel(application) } fun downloadPublication(publication: Publication) = viewModelScope.launch { - app.bookshelf.downloadPublicationFromOpds(publication) + app.bookshelf.importPublicationFromOpds(publication) } sealed class Event { diff --git a/test-app/src/main/java/org/readium/r2/testapp/data/DownloadRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/data/DownloadRepository.kt index 4c720eae59..703955ce24 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/data/DownloadRepository.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/DownloadRepository.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 Readium Foundation. All rights reserved. + * 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. */ diff --git a/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadsDao.kt b/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadsDao.kt index 31f85a2a37..00bc705c8d 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadsDao.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadsDao.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 Readium Foundation. All rights reserved. + * 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. */ diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt index 7e453f1edf..5ab374902d 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch +import org.readium.r2.lcp.LcpException import org.readium.r2.lcp.LcpService import org.readium.r2.shared.asset.AssetRetriever import org.readium.r2.shared.publication.Publication @@ -27,23 +28,24 @@ import org.readium.r2.streamer.PublicationFactory import org.readium.r2.testapp.data.BookRepository import org.readium.r2.testapp.data.DownloadRepository import org.readium.r2.testapp.data.model.Book -import org.readium.r2.testapp.utils.extensions.copyToTempFile import org.readium.r2.testapp.utils.tryOrLog import timber.log.Timber class Bookshelf( - private val context: Context, + context: Context, private val bookRepository: BookRepository, downloadRepository: DownloadRepository, - private val storageDir: File, + storageDir: File, private val coverStorage: CoverStorage, private val publicationFactory: PublicationFactory, private val assetRetriever: AssetRetriever, private val protectionRetriever: ContentProtectionSchemeRetriever, formatRegistry: FormatRegistry, - lcpService: Try, + lcpService: Try, downloadManager: DownloadManager ) { + val channel: Channel = + Channel(Channel.UNLIMITED) sealed class Event { data object ImportPublicationSuccess : @@ -57,11 +59,9 @@ class Bookshelf( private val coroutineScope: CoroutineScope = MainScope() - val channel: Channel = - Channel(Channel.BUFFERED) - private val publicationRetriever: PublicationRetriever = PublicationRetriever( + context, storageDir, assetRetriever, formatRegistry, @@ -72,59 +72,48 @@ class Bookshelf( ) private inner class PublicationRetrieverListener : PublicationRetriever.Listener { - override fun onImportSucceeded(publication: File, coverUrl: String?) { + override fun onSuccess(publication: File, coverUrl: String?) { coroutineScope.launch { val url = publication.toUrl() addBookFeedback(url, coverUrl) } } - override fun onImportError(error: ImportError) { + override fun onError(error: ImportError) { coroutineScope.launch { channel.send(Event.ImportPublicationError(error)) } } } - suspend fun importPublicationToAppStorage( - contentUri: Uri + fun importPublicationFromStorage( + uri: Uri ) { - val tempFile = contentUri.copyToTempFile(context, storageDir) - .getOrElse { - channel.send(Event.ImportPublicationError(ImportError.ImportBookFailed(it))) - return - } - - publicationRetriever.importFromAppStorage(tempFile) + publicationRetriever.retrieveFromStorage(uri) } - suspend fun downloadPublicationFromOpds( + fun importPublicationFromOpds( publication: Publication ) { - publicationRetriever.downloadFromOpds(publication) + publicationRetriever.retrieveFromOpds(publication) } - suspend fun addPublicationFromTheWeb( + fun addPublicationFromWeb( url: Url ) { - addBookFeedback(url) + coroutineScope.launch { + addBookFeedback(url) + } } - suspend fun addPublicationFromSharedStorage( + fun addPublicationFromStorage( url: Url ) { - addBookFeedback(url) + coroutineScope.launch { + addBookFeedback(url) + } } - private fun mediaTypeNotSupportedError(): ImportError = - ImportError.PublicationError( - PublicationError.UnsupportedPublication( - Publication.OpeningException.UnsupportedAsset( - "Unsupported media type" - ) - ) - ) - private suspend fun addBookFeedback( url: Url, coverUrl: String? = null @@ -140,7 +129,9 @@ class Bookshelf( ): Try { val asset = assetRetriever.retrieve(url) - ?: return Try.failure(mediaTypeNotSupportedError()) + ?: return Try.failure( + ImportError.PublicationError(PublicationError.UnsupportedAsset()) + ) val drmScheme = protectionRetriever.retrieve(asset) @@ -153,7 +144,7 @@ class Bookshelf( val coverFile = coverStorage.storeCover(publication, coverUrl) .getOrElse { - return Try.failure(ImportError.ImportBookFailed(it)) + return Try.failure(ImportError.StorageError(it)) } val id = bookRepository.insertBook( @@ -166,7 +157,7 @@ class Bookshelf( ) if (id == -1L) { coverFile.delete() - return Try.failure(ImportError.ImportDatabaseFailed()) + return Try.failure(ImportError.DatabaseError()) } } .onFailure { diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/ImportError.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/ImportError.kt index 3838469ff7..137eafe7a7 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/ImportError.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/ImportError.kt @@ -8,7 +8,6 @@ package org.readium.r2.testapp.domain import androidx.annotation.StringRes import org.readium.r2.shared.UserException -import org.readium.r2.shared.asset.AssetRetriever import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.util.downloads.DownloadManager import org.readium.r2.testapp.R @@ -34,14 +33,6 @@ sealed class ImportError( companion object { - operator fun invoke( - error: AssetRetriever.Error - ): ImportError = PublicationError( - org.readium.r2.testapp.domain.PublicationError( - error - ) - ) - operator fun invoke( error: Publication.OpeningException ): ImportError = PublicationError( @@ -52,7 +43,7 @@ sealed class ImportError( } } - class ImportBookFailed( + class StorageError( override val cause: Throwable ) : ImportError(R.string.import_publication_unexpected_io_exception) @@ -64,6 +55,6 @@ sealed class ImportError( override val cause: Throwable ) : ImportError(R.string.import_publication_no_acquisition) - class ImportDatabaseFailed : + class DatabaseError : ImportError(R.string.import_publication_unable_add_pub_database) } diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationError.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationError.kt index 32d32bec2e..264f5f2aa7 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationError.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationError.kt @@ -25,7 +25,7 @@ sealed class PublicationError(@StringRes userMessageId: Int) : UserException(use R.string.publication_error_scheme_not_supported ) - class UnsupportedPublication(val error: Error? = null) : PublicationError( + class UnsupportedAsset(val error: Error? = null) : PublicationError( R.string.publication_error_unsupported_asset ) @@ -62,13 +62,13 @@ sealed class PublicationError(@StringRes userMessageId: Int) : UserException(use is Publication.OpeningException.Unexpected -> Unexpected(error) is Publication.OpeningException.UnsupportedAsset -> - SchemeNotSupported(error) + UnsupportedAsset(error) } operator fun invoke(error: AssetRetriever.Error): PublicationError = when (error) { is AssetRetriever.Error.ArchiveFormatNotSupported -> - UnsupportedPublication(error) + UnsupportedAsset(error) is AssetRetriever.Error.Forbidden -> Forbidden(error) is AssetRetriever.Error.NotFound -> diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt index e98ad9be63..75efb3a99e 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt @@ -1,13 +1,14 @@ /* - * Copyright 2021 Readium Foundation. All rights reserved. + * 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.testapp.domain +import android.content.Context +import android.net.Uri import java.io.File -import java.net.URL import java.util.UUID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope @@ -15,6 +16,7 @@ import kotlinx.coroutines.launch import org.readium.r2.lcp.LcpException import org.readium.r2.lcp.LcpPublicationRetriever import org.readium.r2.lcp.LcpService +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.publication.Publication @@ -26,25 +28,27 @@ import org.readium.r2.shared.util.getOrElse import org.readium.r2.shared.util.mediatype.FormatRegistry import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.testapp.data.DownloadRepository +import org.readium.r2.testapp.utils.extensions.copyToTempFile import org.readium.r2.testapp.utils.extensions.moveTo import org.readium.r2.testapp.utils.tryOrNull import timber.log.Timber class PublicationRetriever( + private val context: Context, private val storageDir: File, private val assetRetriever: AssetRetriever, private val formatRegistry: FormatRegistry, private val downloadRepository: DownloadRepository, private val downloadManager: DownloadManager, - private val lcpPublicationRetriever: Try, + private val lcpPublicationRetriever: Try, private val listener: Listener ) { interface Listener { - fun onImportSucceeded(publication: File, coverUrl: String?) + fun onSuccess(publication: File, coverUrl: String?) - fun onImportError(error: ImportError) + fun onError(error: ImportError) } private val coroutineScope: CoroutineScope = @@ -55,7 +59,7 @@ class PublicationRetriever( coroutineScope.launch { val coverUrl = downloadRepository.getOpdsDownloadCover(requestId.value) downloadRepository.removeOpdsDownload(requestId.value) - importFromAppStorage(file, coverUrl) + retrieveFromStorage(file, coverUrl) } } @@ -72,7 +76,7 @@ class PublicationRetriever( ) { coroutineScope.launch { downloadRepository.removeOpdsDownload(requestId.value) - listener.onImportError(ImportError.DownloadFailed(error)) + listener.onError(ImportError.DownloadFailed(error)) } } } @@ -85,7 +89,7 @@ class PublicationRetriever( coroutineScope.launch { val coverUrl = downloadRepository.getLcpDownloadCover(requestId.value) downloadRepository.removeLcpDownload(requestId.value) - importFromAppStorage(acquiredPublication.localFile, coverUrl) + retrieveFromStorage(acquiredPublication.localFile, coverUrl) } } @@ -102,7 +106,7 @@ class PublicationRetriever( ) { coroutineScope.launch { downloadRepository.removeLcpDownload(requestId.value) - listener.onImportError(ImportError.LcpAcquisitionFailed(error)) + listener.onError(ImportError.LcpAcquisitionFailed(error)) } } } @@ -133,59 +137,66 @@ class PublicationRetriever( } } - fun downloadFromOpds(publication: Publication) { - val publicationUrl = getDownloadURL(publication) - .getOrElse { - listener.onImportError(ImportError.OpdsError(it)) - return - }.toString() - - val coverUrl = publication - .images.firstOrNull()?.href - + fun retrieveFromStorage( + uri: Uri + ) { coroutineScope.launch { - downloadAsync(publication.metadata.title, publicationUrl, coverUrl) + val tempFile = uri.copyToTempFile(context, storageDir) + .getOrElse { + listener.onError(ImportError.StorageError(it)) + return@launch + } + + retrieveFromStorage(tempFile) } } - private suspend fun downloadAsync( - publicationTitle: String?, - publicationUrl: String, - coverUrl: String? - ) { - val requestId = downloadManager.submit( - request = DownloadManager.Request( - Url(publicationUrl)!!, - title = publicationTitle ?: "Untitled publication", - description = "Downloading", - headers = emptyMap() - ), - listener = downloadListener - ) - downloadRepository.insertOpdsDownload( - id = requestId.value, - cover = coverUrl - ) + fun retrieveFromOpds(publication: Publication) { + coroutineScope.launch { + val publicationUrl = publication.acquisitionUrl() + .getOrElse { + listener.onError(ImportError.OpdsError(it)) + return@launch + }.toString() + + val coverUrl = publication + .images.firstOrNull()?.href + + val requestId = downloadManager.submit( + request = DownloadManager.Request( + Url(publicationUrl)!!, + title = publication.metadata.title ?: "Untitled publication", + description = "Downloading", + headers = emptyMap() + ), + listener = downloadListener + ) + downloadRepository.insertOpdsDownload( + id = requestId.value, + cover = coverUrl + ) + } } - private fun getDownloadURL(publication: Publication): Try = - publication.links + private fun Publication.acquisitionUrl(): Try { + val acquisitionLink = links .firstOrNull { it.mediaType?.isPublication == true || it.mediaType == MediaType.LCP_LICENSE_DOCUMENT } - ?.let { - try { - Try.success(URL(it.href)) - } catch (e: Exception) { - Try.failure(e) - } - } ?: Try.failure(Exception("No supported link to acquire publication.")) + ?: return Try.failure(Exception("No supported link to acquire publication.")) + + return Url(acquisitionLink.href) + ?.let { Try.success(it) } + ?: Try.failure(Exception("Invalid acquisition url.")) + } - suspend fun importFromAppStorage( + private suspend fun retrieveFromStorage( tempFile: File, coverUrl: String? = null ) { val sourceAsset = assetRetriever.retrieve(tempFile) ?: run { - listener.onImportError(mediaTypeNotSupportedError()) + listener.onError( + ImportError.PublicationError(PublicationError.UnsupportedAsset()) + ) return } @@ -193,9 +204,9 @@ class PublicationRetriever( sourceAsset is Asset.Resource && sourceAsset.mediaType.matches(MediaType.LCP_LICENSE_DOCUMENT) ) { - acquireLcpPublication(sourceAsset, coverUrl) + acquireLcpPublication(sourceAsset, tempFile, coverUrl) .getOrElse { - listener.onImportError(ImportError.ImportBookFailed(it)) + listener.onError(ImportError.StorageError(it)) return } return @@ -210,40 +221,41 @@ class PublicationRetriever( } catch (e: Exception) { Timber.d(e) tryOrNull { libraryFile.delete() } - listener.onImportError(ImportError.ImportBookFailed(e)) + listener.onError(ImportError.StorageError(e)) return } - listener.onImportSucceeded(libraryFile, coverUrl) + listener.onSuccess(libraryFile, coverUrl) } - private fun mediaTypeNotSupportedError(): ImportError.PublicationError = - ImportError.PublicationError( - PublicationError.UnsupportedPublication( - Publication.OpeningException.UnsupportedAsset( - "Unsupported media type" - ) - ) - ) - private suspend fun acquireLcpPublication( licenceAsset: Asset.Resource, + licenceFile: File, coverUrl: String? - ): Try { + ): Try { val lcpRetriever = lcpPublicationRetriever - .getOrElse { return Try.failure(it) } + .getOrElse { return Try.failure(ImportError.LcpAcquisitionFailed(it)) } val license = licenceAsset.resource.read() - .getOrElse { return Try.failure(it) } + .getOrElse { return Try.failure(ImportError.StorageError(it)) } + .let { + try { + LicenseDocument(it) + } catch (e: LcpException) { + return Try.failure( + ImportError.PublicationError(ImportError.LcpAcquisitionFailed(e)) + ) + } + } + + tryOrNull { licenceFile.delete() } val requestId = lcpRetriever.retrieve( license, "Fulfilling Lcp publication", null, lcpRetrieverListener - ).getOrElse { - return Try.failure(it) - } + ) downloadRepository.insertLcpDownload(requestId.value, coverUrl) diff --git a/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt index e53136038b..30cf0cc83d 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/reader/ReaderRepository.kt @@ -135,7 +135,7 @@ class ReaderRepository( openImage(bookId, publication, initialLocator) else -> Try.failure( - OpeningError.PublicationError(PublicationError.UnsupportedPublication()) + OpeningError.PublicationError(PublicationError.UnsupportedAsset()) ) } @@ -155,14 +155,14 @@ class ReaderRepository( publication, ExoPlayerEngineProvider(application) ) ?: return Try.failure( - OpeningError.PublicationError(PublicationError.UnsupportedPublication()) + OpeningError.PublicationError(PublicationError.UnsupportedAsset()) ) val navigator = navigatorFactory.createNavigator( initialLocator, initialPreferences ) ?: return Try.failure( - OpeningError.PublicationError(PublicationError.UnsupportedPublication()) + OpeningError.PublicationError(PublicationError.UnsupportedAsset()) ) mediaServiceFacade.openSession(bookId, navigator) diff --git a/test-app/src/main/res/values/strings.xml b/test-app/src/main/res/values/strings.xml index 5a31044843..8d2a7eb769 100644 --- a/test-app/src/main/res/values/strings.xml +++ b/test-app/src/main/res/values/strings.xml @@ -100,7 +100,7 @@ Publication added to your library Unable to add publication to the database - Publication format is not supported + Asset format is not supported Publication has not been found. Publication is temporarily unavailable. Provided credentials were incorrect From d559407cd557a365a0cc548c104768ffe0c64c99 Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Mon, 4 Sep 2023 20:28:23 +0200 Subject: [PATCH 21/35] Small fix --- test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt | 2 +- test-app/src/main/res/values/arrays.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt index c534a90d9e..81e0a5ced0 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/MainViewModel.kt @@ -26,7 +26,7 @@ class MainViewModel( getApplication() val channel: EventChannel = - EventChannel(Channel(Channel.BUFFERED), viewModelScope) + EventChannel(Channel(Channel.UNLIMITED), viewModelScope) init { app.bookshelf.channel.receiveAsFlow() diff --git a/test-app/src/main/res/values/arrays.xml b/test-app/src/main/res/values/arrays.xml index 8cb4f5dc9a..aeb66d5a55 100644 --- a/test-app/src/main/res/values/arrays.xml +++ b/test-app/src/main/res/values/arrays.xml @@ -1,7 +1,7 @@ - Copy to app storage + Import to app storage Read from shared storage Stream from the Web From a143413354ec154340f6588f5ece7967f5f774f2 Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Sun, 10 Sep 2023 17:12:18 +0200 Subject: [PATCH 22/35] Fix ForegroundDownloadManager --- .../util/downloads/foreground/ForegroundDownloadManager.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt index 97c7ee01ad..f7dfef3b95 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt @@ -39,7 +39,7 @@ public class ForegroundDownloadManager( listener: DownloadManager.Listener ): DownloadManager.RequestId { val requestId = DownloadManager.RequestId(UUID.randomUUID().toString()) - listeners.getOrPut(requestId) { mutableListOf() }.add(listener) + register(requestId, listener) jobs[requestId] = coroutineScope.launch { doRequest(request, requestId) } return requestId } @@ -121,6 +121,7 @@ public class ForegroundDownloadManager( requestId: DownloadManager.RequestId, listener: DownloadManager.Listener ) { + listeners.getOrPut(requestId) { mutableListOf() }.add(listener) } public override fun close() { From 7fd23737ad7a7b25913794fe5be70c5b7f35a92e Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Sun, 10 Sep 2023 17:34:42 +0200 Subject: [PATCH 23/35] Refine cancellation --- .../readium/r2/lcp/LcpPublicationRetriever.kt | 13 +++++++++++++ .../r2/shared/util/downloads/DownloadManager.kt | 2 ++ .../downloads/android/AndroidDownloadManager.kt | 16 +++++++++++++++- .../foreground/ForegroundDownloadManager.kt | 2 ++ .../r2/testapp/domain/PublicationRetriever.kt | 14 ++++++++++++++ 5 files changed, 46 insertions(+), 1 deletion(-) diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index d143861bb2..813a094beb 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -43,6 +43,10 @@ public class LcpPublicationRetriever( requestId: RequestId, error: LcpException ) + + public fun onAcquisitionCancelled( + requestId: RequestId + ) } private inner class DownloadListener : DownloadManager.Listener { @@ -132,6 +136,15 @@ public class LcpPublicationRetriever( listeners.remove(lcpRequestId) } + + override fun onDownloadCancelled(requestId: DownloadManager.RequestId) { + val lcpRequestId = RequestId(requestId.value) + val listenersForId = listeners[lcpRequestId].orEmpty() + listenersForId.forEach { + it.onAcquisitionCancelled(lcpRequestId) + } + listeners.remove(lcpRequestId) + } } private val formatRegistry: FormatRegistry = diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt index 270da7bb47..a003803236 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt @@ -78,6 +78,8 @@ public interface DownloadManager { public fun onDownloadProgressed(requestId: RequestId, downloaded: Long, expected: Long?) public fun onDownloadFailed(requestId: RequestId, error: Error) + + public fun onDownloadCancelled(requestId: RequestId) } public fun submit(request: Request, listener: Listener): RequestId diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt index c39282bde5..47bee577bd 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt @@ -103,7 +103,9 @@ public class AndroidDownloadManager internal constructor( public override fun cancel(requestId: DownloadManager.RequestId) { val longId = requestId.value.toLong() downloadManager.remove(longId) - listeners[requestId]?.clear() + val listenersForId = listeners[requestId].orEmpty() + listenersForId.forEach { it.onDownloadCancelled(requestId) } + listeners.remove(requestId) if (!listeners.any { it.value.isNotEmpty() }) { maybeStopObservingProgress() } @@ -170,6 +172,9 @@ public class AndroidDownloadManager internal constructor( } private fun notify(cursor: Cursor) = cursor.use { + val knownDownloads = mutableSetOf() + + // Notify about known downloads while (cursor.moveToNext()) { val facade = DownloadCursorFacade(cursor) val id = DownloadManager.RequestId(facade.id.toString()) @@ -177,7 +182,16 @@ public class AndroidDownloadManager internal constructor( if (listenersForId.isNotEmpty()) { notifyDownload(id, facade, listenersForId) } + knownDownloads.add(id) + } + + // Missing downloads have been cancelled. + val unknownDownloads = listeners - knownDownloads + unknownDownloads.forEach { entry -> + entry.value.forEach { it.onDownloadCancelled(entry.key) } + listeners.remove(entry.key) } + maybeStopObservingProgress() } private fun notifyDownload( diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt index f7dfef3b95..d63c2c9b2c 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt @@ -114,6 +114,8 @@ public class ForegroundDownloadManager( public override fun cancel(requestId: DownloadManager.RequestId) { jobs.remove(requestId)?.cancel() + val listenersForId = listeners[requestId].orEmpty() + listenersForId.forEach { it.onDownloadCancelled(requestId) } listeners.remove(requestId) } diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt index 75efb3a99e..28051611e2 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt @@ -79,6 +79,13 @@ class PublicationRetriever( listener.onError(ImportError.DownloadFailed(error)) } } + + override fun onDownloadCancelled(requestId: DownloadManager.RequestId) { + coroutineScope.launch { + Timber.v("Download ${requestId.value} has been cancelled.") + downloadRepository.removeOpdsDownload(requestId.value) + } + } } private inner class LcpRetrieverListener : LcpPublicationRetriever.Listener { @@ -109,6 +116,13 @@ class PublicationRetriever( listener.onError(ImportError.LcpAcquisitionFailed(error)) } } + + override fun onAcquisitionCancelled(requestId: LcpPublicationRetriever.RequestId) { + coroutineScope.launch { + Timber.v("Acquisition ${requestId.value} has been cancelled.") + downloadRepository.removeLcpDownload(requestId.value) + } + } } private val downloadListener: DownloadListener = From d2e6c600fe8a67859896bab627543d7d6b8cc527 Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Sun, 10 Sep 2023 17:57:59 +0200 Subject: [PATCH 24/35] Add documentation --- .../readium/r2/lcp/LcpPublicationRetriever.kt | 3 ++ .../shared/util/downloads/DownloadManager.kt | 30 +++++++++++++++++++ .../android/AndroidDownloadManager.kt | 20 +++++++++++++ .../foreground/ForegroundDownloadManager.kt | 5 ++++ 4 files changed, 58 insertions(+) diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index 813a094beb..934124113c 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -17,6 +17,9 @@ import org.readium.r2.shared.util.mediatype.FormatRegistry import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.mediatype.MediaTypeRetriever +/** + * Util to acquire a protected publication from standalone LCPL's bytes. + */ public class LcpPublicationRetriever( context: Context, private val downloadManager: DownloadManager, diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt index a003803236..7be1b30239 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt @@ -9,6 +9,9 @@ package org.readium.r2.shared.util.downloads import java.io.File import org.readium.r2.shared.util.Url +/** + * Retrieves files through Http with various features depending on implementations. + */ public interface DownloadManager { public data class Request( @@ -73,20 +76,47 @@ public interface DownloadManager { public interface Listener { + /** + * Download with id [requestId] successfully completed and is available at [file]. + */ public fun onDownloadCompleted(requestId: RequestId, file: File) + /** + * [downloaded] / [expected] bytes have been downloaded for request with id [requestId]. + */ public fun onDownloadProgressed(requestId: RequestId, downloaded: Long, expected: Long?) + /** + * Download with id [requestId] failed with [error]. + */ public fun onDownloadFailed(requestId: RequestId, error: Error) + /** + * Download with id [requestId] has been cancelled. + */ public fun onDownloadCancelled(requestId: RequestId) } + /** + * Submits a new request to this [DownloadManager]. [listener] will automatically be registered. + */ public fun submit(request: Request, listener: Listener): RequestId + /** + * Registers a listener for the download with [requestId]. + * + * If your [DownloadManager] supports background downloading, this should typically be used + * when you get back a new instance after the app restarted. + */ public fun register(requestId: RequestId, listener: Listener) + /** + * Cancels the download with [requestId]. + */ public fun cancel(requestId: RequestId) + /** + * Releases any in-memory resource associated with this [DownloadManager]. + */ public fun close() } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt index 47bee577bd..3f29e9a9e0 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt @@ -25,6 +25,9 @@ import org.readium.r2.shared.units.hz import org.readium.r2.shared.util.downloads.DownloadManager import org.readium.r2.shared.util.toUri +/** + * A [DownloadManager] implementation using the Android download service. + */ public class AndroidDownloadManager internal constructor( private val context: Context, private val destStorage: Storage, @@ -33,6 +36,16 @@ public class AndroidDownloadManager internal constructor( private val allowDownloadsOverMetered: Boolean ) : DownloadManager { + /** + * Creates a new instance of [AndroidDownloadManager]. + * + * @param context Android context + * @param destStorage Location where downloads should be stored + * @param refreshRate Frequency with which download status will be checked and + * listeners notified + * @param allowDownloadsOverMetered If downloads must be paused when only metered connexions + * are available + */ public constructor( context: Context, destStorage: Storage = Storage.App, @@ -47,7 +60,14 @@ public class AndroidDownloadManager internal constructor( ) public enum class Storage { + /** + * App internal storage. + */ App, + + /** + * Shared storage, accessible by users. + */ Shared; } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt index d63c2c9b2c..69a4dbca80 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt @@ -21,6 +21,11 @@ import org.readium.r2.shared.util.http.HttpClient import org.readium.r2.shared.util.http.HttpException import org.readium.r2.shared.util.http.HttpRequest +/** + * A [DownloadManager] implementation using a [HttpClient]. + * + * If the app is killed, downloads will stop and you won't be able to resume them later. + */ public class ForegroundDownloadManager( private val httpClient: HttpClient ) : DownloadManager { From eaa37504a70a5ad01a8ae8a1a967d2260521fff8 Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Thu, 14 Sep 2023 14:43:19 +0200 Subject: [PATCH 25/35] Various changes --- .../readium/r2/lcp/LcpDownloadsRepository.kt | 72 ++++++++++---- .../readium/r2/lcp/LcpPublicationRetriever.kt | 99 ++++++++++--------- .../r2/lcp/license/model/LicenseDocument.kt | 3 + .../foreground/ForegroundDownloadManager.kt | 1 + .../readium/r2/testapp/domain/Bookshelf.kt | 6 ++ 5 files changed, 120 insertions(+), 61 deletions(-) diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt index 720ef981e9..36ad8ef594 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt @@ -9,38 +9,76 @@ package org.readium.r2.lcp import android.content.Context import java.io.File import java.util.LinkedList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.async +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import kotlinx.coroutines.withContext import org.json.JSONObject internal class LcpDownloadsRepository( context: Context ) { - private val storageDir: File = - File(context.noBackupFilesDir, LcpDownloadsRepository::class.qualifiedName!!) - .also { if (!it.exists()) it.mkdirs() } + private val coroutineScope: CoroutineScope = + MainScope() - private val storageFile: File = - File(storageDir, "licenses.json") - .also { if (!it.exists()) { it.writeText("{}", Charsets.UTF_8) } } + private val storageDir: Deferred = + coroutineScope.async { + withContext(Dispatchers.IO) { + File(context.noBackupFilesDir, LcpDownloadsRepository::class.qualifiedName!!) + .also { if (!it.exists()) it.mkdirs() } + } + } - private val snapshot: MutableMap = - storageFile.readText(Charsets.UTF_8).toData().toMutableMap() + private val storageFile: Deferred = + coroutineScope.async { + withContext(Dispatchers.IO) { + File(storageDir.await(), "licenses.json") + .also { if (!it.exists()) { it.writeText("{}", Charsets.UTF_8) } } + } + } - fun getIds(): List { - return snapshot.keys.toList() - } + private val snapshot: Deferred> = + coroutineScope.async { + readSnapshot().toMutableMap() + } fun addDownload(id: String, license: JSONObject) { - snapshot[id] = license - storageFile.writeText(snapshot.toJson(), Charsets.UTF_8) + coroutineScope.launch { + val snapshotCompleted = snapshot.await() + snapshotCompleted[id] = license + writeSnapshot(snapshotCompleted) + } } fun removeDownload(id: String) { - snapshot.remove(id) - storageFile.writeText(snapshot.toJson(), Charsets.UTF_8) + coroutineScope.launch { + val snapshotCompleted = snapshot.await() + snapshotCompleted.remove(id) + writeSnapshot(snapshotCompleted) + } } - fun retrieveLicense(id: String): JSONObject? { - return snapshot[id] + suspend fun retrieveLicense(id: String): JSONObject? { + coroutineScope.coroutineContext.job.children.forEach { it.join() } + return snapshot.await()[id] + } + + private suspend fun readSnapshot(): Map { + return withContext(Dispatchers.IO) { + storageFile.await().readText(Charsets.UTF_8).toData().toMutableMap() + } + } + + private suspend fun writeSnapshot(snapshot: Map) { + val storageFileCompleted = storageFile.await() + withContext(Dispatchers.IO) { + storageFileCompleted.writeText(snapshot.toJson(), Charsets.UTF_8) + } } private fun Map.toJson(): String { diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index 934124113c..111612eafe 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -8,6 +8,9 @@ package org.readium.r2.lcp import android.content.Context import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch import org.readium.r2.lcp.license.container.createLicenseContainer import org.readium.r2.lcp.license.model.LicenseDocument import org.readium.r2.shared.extensions.tryOrLog @@ -26,6 +29,9 @@ public class LcpPublicationRetriever( private val mediaTypeRetriever: MediaTypeRetriever ) { + private val coroutineScope: CoroutineScope = + MainScope() + @JvmInline public value class RequestId(public val value: String) @@ -58,52 +64,54 @@ public class LcpPublicationRetriever( requestId: DownloadManager.RequestId, file: File ) { - val lcpRequestId = RequestId(requestId.value) - val listenersForId = listeners[lcpRequestId].orEmpty() + coroutineScope.launch { + val lcpRequestId = RequestId(requestId.value) + val listenersForId = checkNotNull(listeners[lcpRequestId]) + + val license = downloadsRepository.retrieveLicense(requestId.value) + ?.let { LicenseDocument(it) } + ?: run { + listenersForId.forEach { + it.onAcquisitionFailed( + lcpRequestId, + LcpException.wrap( + Exception("Couldn't retrieve license from local storage.") + ) + ) + } + return@launch + } + downloadsRepository.removeDownload(requestId.value) - val license = downloadsRepository.retrieveLicense(requestId.value) - ?.let { LicenseDocument(it) } - ?: run { + val mediaType = mediaTypeRetriever.retrieve( + mediaType = license.publicationLink.type + ) + ?: MediaType.EPUB + + try { + // Saves the License Document into the downloaded publication + val container = createLicenseContainer(file, mediaType) + container.write(license) + } catch (e: Exception) { + tryOrLog { file.delete() } listenersForId.forEach { - it.onAcquisitionFailed( - lcpRequestId, - LcpException.wrap( - Exception("Couldn't retrieve license from local storage.") - ) - ) + it.onAcquisitionFailed(lcpRequestId, LcpException.wrap(e)) } - return + return@launch } - downloadsRepository.removeDownload(requestId.value) - - val link = license.link(LicenseDocument.Rel.Publication)!! - val mediaType = mediaTypeRetriever.retrieve(mediaType = link.type) - ?: MediaType.EPUB + val acquiredPublication = LcpService.AcquiredPublication( + localFile = file, + suggestedFilename = "${license.id}.${formatRegistry.fileExtension(mediaType) ?: "epub"}", + mediaType = mediaType, + licenseDocument = license + ) - try { - // Saves the License Document into the downloaded publication - val container = createLicenseContainer(file, mediaType) - container.write(license) - } catch (e: Exception) { - tryOrLog { file.delete() } listenersForId.forEach { - it.onAcquisitionFailed(lcpRequestId, LcpException.wrap(e)) + it.onAcquisitionCompleted(lcpRequestId, acquiredPublication) } - return - } - - val acquiredPublication = LcpService.AcquiredPublication( - localFile = file, - suggestedFilename = "${license.id}.${formatRegistry.fileExtension(mediaType) ?: "epub"}", - mediaType = mediaType, - licenseDocument = license - ) - - listenersForId.forEach { - it.onAcquisitionCompleted(lcpRequestId, acquiredPublication) + listeners.remove(lcpRequestId) } - listeners.remove(lcpRequestId) } override fun onDownloadProgressed( @@ -112,7 +120,7 @@ public class LcpPublicationRetriever( expected: Long? ) { val lcpRequestId = RequestId(requestId.value) - val listenersForId = listeners[lcpRequestId].orEmpty() + val listenersForId = checkNotNull(listeners[lcpRequestId]) listenersForId.forEach { it.onAcquisitionProgressed( @@ -128,7 +136,9 @@ public class LcpPublicationRetriever( error: DownloadManager.Error ) { val lcpRequestId = RequestId(requestId.value) - val listenersForId = listeners[lcpRequestId].orEmpty() + val listenersForId = checkNotNull(listeners[lcpRequestId]) + + downloadsRepository.removeDownload(requestId.value) listenersForId.forEach { it.onAcquisitionFailed( @@ -142,7 +152,7 @@ public class LcpPublicationRetriever( override fun onDownloadCancelled(requestId: DownloadManager.RequestId) { val lcpRequestId = RequestId(requestId.value) - val listenersForId = listeners[lcpRequestId].orEmpty() + val listenersForId = checkNotNull(listeners[lcpRequestId]) listenersForId.forEach { it.onAcquisitionCancelled(lcpRequestId) } @@ -166,8 +176,10 @@ public class LcpPublicationRetriever( requestId: RequestId, listener: Listener ) { - listeners.getOrPut(requestId) { mutableListOf() }.add(listener) - downloadManager.register(DownloadManager.RequestId(requestId.value), downloadListener) + listeners.getOrPut(requestId) { + downloadManager.register(DownloadManager.RequestId(requestId.value), downloadListener) + mutableListOf() + }.add(listener) } public fun retrieve( @@ -195,8 +207,7 @@ public class LcpPublicationRetriever( downloadTitle: String, downloadDescription: String? ): RequestId { - val link = license.link(LicenseDocument.Rel.Publication)!! - val url = Url(link.url) + val url = Url(license.publicationLink.url) val requestId = downloadManager.submit( request = DownloadManager.Request( diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/LicenseDocument.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/LicenseDocument.kt index efc0db377e..7df6487d0b 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/LicenseDocument.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/model/LicenseDocument.kt @@ -102,6 +102,9 @@ public class LicenseDocument internal constructor(public val json: JSONObject) { } } + public val publicationLink: Link + get() = link(Rel.Publication)!! + public fun link(rel: Rel, type: MediaType? = null): Link? = links.firstWithRel(rel.value, type) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt index 69a4dbca80..b158efe11a 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt @@ -132,5 +132,6 @@ public class ForegroundDownloadManager( } public override fun close() { + jobs.forEach { cancel(it.key) } } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt index 5ab374902d..0e511af3b8 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt @@ -31,6 +31,12 @@ import org.readium.r2.testapp.data.model.Book import org.readium.r2.testapp.utils.tryOrLog import timber.log.Timber +/** + * The [Bookshelf] supports two different processes: + * - directly _adding_ the url to a remote asset or an asset from shared storage to the database + * - _importing_ an asset, that is downloading or copying the publication the asset points to to the app storage + * before adding it to the database + */ class Bookshelf( context: Context, private val bookRepository: BookRepository, From 840455c5025da1e6ad72db940f4b08caa2cca764 Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Thu, 14 Sep 2023 15:09:38 +0200 Subject: [PATCH 26/35] Improve concurrency in LcpDownloadsRepository --- .../main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt index 36ad8ef594..ca06632f3b 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt @@ -64,8 +64,11 @@ internal class LcpDownloadsRepository( } suspend fun retrieveLicense(id: String): JSONObject? { - coroutineScope.coroutineContext.job.children.forEach { it.join() } - return snapshot.await()[id] + val snapshot = snapshot.await() + while (coroutineScope.coroutineContext.job.children.toList().isNotEmpty()) { + coroutineScope.coroutineContext.job.children.forEach { it.join() } + } + return snapshot[id] } private suspend fun readSnapshot(): Map { From 605577947cf8d2cac9e4fe7f4223624e26888257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Thu, 14 Sep 2023 19:12:25 +0200 Subject: [PATCH 27/35] Documentation and formatting --- .../readium/r2/lcp/LcpPublicationRetriever.kt | 171 +++++++++++------- .../java/org/readium/r2/lcp/LcpService.kt | 15 +- .../readium/r2/shared/util/MapCompanion.kt | 4 + .../shared/util/downloads/DownloadManager.kt | 34 +++- 4 files changed, 137 insertions(+), 87 deletions(-) diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index 111612eafe..ccc26b80dc 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -21,7 +21,7 @@ import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.mediatype.MediaTypeRetriever /** - * Util to acquire a protected publication from standalone LCPL's bytes. + * Utility to acquire a protected publication from an LCP License Document. */ public class LcpPublicationRetriever( context: Context, @@ -29,35 +29,135 @@ public class LcpPublicationRetriever( private val mediaTypeRetriever: MediaTypeRetriever ) { - private val coroutineScope: CoroutineScope = - MainScope() - @JvmInline public value class RequestId(public val value: String) public interface Listener { + /** + * Called when the publication has been successfully acquired. + */ public fun onAcquisitionCompleted( requestId: RequestId, acquiredPublication: LcpService.AcquiredPublication ) + /** + * The acquisition with ID [requestId] has downloaded [downloaded] out of [expected] bytes. + */ public fun onAcquisitionProgressed( requestId: RequestId, downloaded: Long, expected: Long? ) + /** + * The acquisition with ID [requestId] has failed with the given [error]. + */ public fun onAcquisitionFailed( requestId: RequestId, error: LcpException ) + /** + * The acquisition with ID [requestId] has been cancelled. + */ public fun onAcquisitionCancelled( requestId: RequestId ) } + /** + * Submits a new request to acquire the publication protected with the given [license]. + * + * The given [listener] will automatically be registered. + * + * Returns the ID of the acquisition request, which can be used to cancel it. + */ + public fun retrieve( + license: LicenseDocument, + downloadTitle: String, + downloadDescription: String? = null, + listener: Listener + ): RequestId { + val requestId = fetchPublication( + license, + downloadTitle, + downloadDescription + ) + register(requestId, listener) + return requestId + } + + /** + * Registers a listener for the acquisition with the given [requestId]. + * + * If the [downloadManager] provided during construction supports background downloading, this + * should typically be used when you get create a new instance after the app restarted. + */ + public fun register( + requestId: RequestId, + listener: Listener + ) { + listeners.getOrPut(requestId) { + downloadManager.register(DownloadManager.RequestId(requestId.value), downloadListener) + mutableListOf() + }.add(listener) + } + + /** + * Cancels the acquisition with the given [requestId]. + */ + public fun cancel(requestId: RequestId) { + downloadManager.cancel(DownloadManager.RequestId(requestId.value)) + downloadsRepository.removeDownload(requestId.value) + } + + /** + * Releases any in-memory resource associated with this [LcpPublicationRetriever]. + * + * If the pending acquisitions cannot continue in the background, they will be cancelled. + */ + public fun close() { + downloadManager.close() + } + + private val coroutineScope: CoroutineScope = + MainScope() + + private val formatRegistry: FormatRegistry = + FormatRegistry() + + private val downloadsRepository: LcpDownloadsRepository = + LcpDownloadsRepository(context) + + private val downloadListener: DownloadManager.Listener = + DownloadListener() + + private val listeners: MutableMap> = + mutableMapOf() + + private fun fetchPublication( + license: LicenseDocument, + downloadTitle: String, + downloadDescription: String? + ): RequestId { + val url = Url(license.publicationLink.url) + + val requestId = downloadManager.submit( + request = DownloadManager.Request( + url = url, + title = downloadTitle, + description = downloadDescription, + headers = emptyMap() + ), + listener = downloadListener + ) + + downloadsRepository.addDownload(requestId.value, license.json) + return RequestId(requestId.value) + } + private inner class DownloadListener : DownloadManager.Listener { override fun onDownloadCompleted( @@ -159,67 +259,4 @@ public class LcpPublicationRetriever( listeners.remove(lcpRequestId) } } - - private val formatRegistry: FormatRegistry = - FormatRegistry() - - private val downloadsRepository: LcpDownloadsRepository = - LcpDownloadsRepository(context) - - private val downloadListener: DownloadManager.Listener = - DownloadListener() - - private val listeners: MutableMap> = - mutableMapOf() - - public fun register( - requestId: RequestId, - listener: Listener - ) { - listeners.getOrPut(requestId) { - downloadManager.register(DownloadManager.RequestId(requestId.value), downloadListener) - mutableListOf() - }.add(listener) - } - - public fun retrieve( - license: LicenseDocument, - downloadTitle: String, - downloadDescription: String? = null, - listener: Listener - ): RequestId { - val requestId = fetchPublication( - license, - downloadTitle, - downloadDescription - ) - register(requestId, listener) - return requestId - } - - public fun cancel(requestId: RequestId) { - downloadManager.cancel(DownloadManager.RequestId(requestId.value)) - downloadsRepository.removeDownload(requestId.value) - } - - private fun fetchPublication( - license: LicenseDocument, - downloadTitle: String, - downloadDescription: String? - ): RequestId { - val url = Url(license.publicationLink.url) - - val requestId = downloadManager.submit( - request = DownloadManager.Request( - url = url, - title = downloadTitle, - description = downloadDescription, - headers = emptyMap() - ), - listener = downloadListener - ) - - downloadsRepository.addDownload(requestId.value, license.json) - return RequestId(requestId.value) - } } 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 a52f8f8c53..4669a239d2 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 @@ -53,11 +53,7 @@ public interface LcpService { * Acquires a protected publication from a standalone LCPL's bytes. * * You can cancel the on-going acquisition by cancelling its parent coroutine context. - * @Deprecated( - "Use a LcpPublicationRetriever instead.", - ReplaceWith("publicationRetriever()"), - level = DeprecationLevel.ERROR - ) + * * @param onProgress Callback to follow the acquisition progress from 0.0 to 1.0. */ @Deprecated( @@ -125,9 +121,8 @@ public interface LcpService { ): Try /** - * Creates a [LcpPublicationRetriever] instance which can be used to acquire a protected - * publication from standalone LCPL's bytes. - * + * Creates an [LcpPublicationRetriever] instance which can be used to acquire a protected + * publication from an LCP License Document. */ public fun publicationRetriever(): LcpPublicationRetriever @@ -215,8 +210,8 @@ public interface LcpService { } @Deprecated( - "Use `acquirePublication()` with coroutines instead", - ReplaceWith("acquirePublication(lcpl)"), + "Use a LcpPublicationRetriever instead.", + ReplaceWith("publicationRetriever()"), level = DeprecationLevel.ERROR ) @DelicateCoroutinesApi diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/MapCompanion.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/MapCompanion.kt index 5d0e13d260..65adee3494 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/MapCompanion.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/MapCompanion.kt @@ -9,6 +9,8 @@ package org.readium.r2.shared.util +import org.readium.r2.shared.InternalReadiumApi + /** * Encapsulates a [Map] into a more limited query API. * @@ -24,6 +26,7 @@ package org.readium.r2.shared.util * val layout: Layout? = Layout("reflowable") * ``` */ +@InternalReadiumApi public open class MapCompanion(protected val map: Map) { public constructor(elements: Array, keySelector: (E) -> K) : @@ -60,6 +63,7 @@ public open class MapCompanion(protected val map: Map) { /** * Extends a [MapCompanion] by adding a [default] value as a fallback. */ +@InternalReadiumApi public open class MapWithDefaultCompanion(map: Map, public val default: E) : MapCompanion( map ) { diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt index 7be1b30239..1d3d2baee8 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt @@ -8,9 +8,17 @@ package org.readium.r2.shared.util.downloads import java.io.File import org.readium.r2.shared.util.Url +import org.readium.r2.shared.util.downloads.android.AndroidDownloadManager +import org.readium.r2.shared.util.downloads.foreground.ForegroundDownloadManager /** - * Retrieves files through Http with various features depending on implementations. + * Manages a set of concurrent files downloaded through HTTP. + * + * Choose the implementation that best fits your needs: + * - [AndroidDownloadManager] for downloading files in the background with the Android system + * service, even if the app is stopped. + * - [ForegroundDownloadManager] for a simpler implementation based on HttpClient which cancels + * the on-going download when the app is closed. */ public interface DownloadManager { @@ -77,46 +85,52 @@ public interface DownloadManager { public interface Listener { /** - * Download with id [requestId] successfully completed and is available at [file]. + * The download with ID [requestId] has been successfully completed and is now available at + * [file]. */ public fun onDownloadCompleted(requestId: RequestId, file: File) /** - * [downloaded] / [expected] bytes have been downloaded for request with id [requestId]. + * The request with ID [requestId] has downloaded [downloaded] out of [expected] bytes. */ public fun onDownloadProgressed(requestId: RequestId, downloaded: Long, expected: Long?) /** - * Download with id [requestId] failed with [error]. + * The download with ID [requestId] failed due to [error]. */ public fun onDownloadFailed(requestId: RequestId, error: Error) /** - * Download with id [requestId] has been cancelled. + * The download with ID [requestId] has been cancelled. */ public fun onDownloadCancelled(requestId: RequestId) } /** - * Submits a new request to this [DownloadManager]. [listener] will automatically be registered. + * Submits a new request to this [DownloadManager]. The given [listener] will automatically be + * registered. + * + * Returns the ID of the download request, which can be used to cancel it. */ public fun submit(request: Request, listener: Listener): RequestId /** - * Registers a listener for the download with [requestId]. + * Registers a listener for the download with the given [requestId]. * - * If your [DownloadManager] supports background downloading, this should typically be used - * when you get back a new instance after the app restarted. + * If your [DownloadManager] supports background downloading, this should typically be used when + * you get create a new instance after the app restarted. */ public fun register(requestId: RequestId, listener: Listener) /** - * Cancels the download with [requestId]. + * Cancels the download with the given [requestId]. */ public fun cancel(requestId: RequestId) /** * Releases any in-memory resource associated with this [DownloadManager]. + * + * If the pending downloads cannot continue in the background, they will be cancelled. */ public fun close() } From 16110a4bd57b2abe241190a791813e140a97cdcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Fri, 15 Sep 2023 09:50:06 +0200 Subject: [PATCH 28/35] Refactor the test app --- .../org/readium/r2/testapp/Application.kt | 55 ++- .../r2/testapp/data/DownloadRepository.kt | 45 +- .../readium/r2/testapp/data/db/AppDatabase.kt | 5 +- .../r2/testapp/data/db/DownloadsDao.kt | 14 +- .../readium/r2/testapp/data/model/Download.kt | 34 +- .../readium/r2/testapp/domain/Bookshelf.kt | 29 +- .../r2/testapp/domain/PublicationRetriever.kt | 400 +++++++++++------- 7 files changed, 335 insertions(+), 247 deletions(-) diff --git a/test-app/src/main/java/org/readium/r2/testapp/Application.kt b/test-app/src/main/java/org/readium/r2/testapp/Application.kt index f657d505ed..da946cf38f 100755 --- a/test-app/src/main/java/org/readium/r2/testapp/Application.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Application.kt @@ -21,8 +21,13 @@ import org.readium.r2.testapp.BuildConfig.DEBUG import org.readium.r2.testapp.data.BookRepository import org.readium.r2.testapp.data.DownloadRepository import org.readium.r2.testapp.data.db.AppDatabase +import org.readium.r2.testapp.data.model.Download import org.readium.r2.testapp.domain.Bookshelf import org.readium.r2.testapp.domain.CoverStorage +import org.readium.r2.testapp.domain.LcpPublicationRetriever +import org.readium.r2.testapp.domain.LocalPublicationRetriever +import org.readium.r2.testapp.domain.OpdsPublicationRetriever +import org.readium.r2.testapp.domain.PublicationRetriever import org.readium.r2.testapp.reader.ReaderRepository import timber.log.Timber @@ -36,9 +41,6 @@ class Application : android.app.Application() { lateinit var bookRepository: BookRepository private set - lateinit var downloadRepository: DownloadRepository - private set - lateinit var bookshelf: Bookshelf private set @@ -60,32 +62,41 @@ class Application : android.app.Application() { storageDir = computeStorageDir() - /* - * Initializing repositories - */ - bookRepository = - AppDatabase.getDatabase(this).booksDao() - .let { dao -> BookRepository(dao) } - - downloadRepository = - AppDatabase.getDatabase(this).downloadsDao() - .let { dao -> DownloadRepository(dao) } - - val coverStorage = CoverStorage(storageDir) + val database = AppDatabase.getDatabase(this) + + bookRepository = BookRepository(database.booksDao()) + + val publicationRetriever = PublicationRetriever( + localPublicationRetriever = LocalPublicationRetriever( + context = applicationContext, + storageDir = storageDir, + assetRetriever = readium.assetRetriever, + formatRegistry = readium.formatRegistry, + lcpPublicationRetriever = + readium.lcpService.getOrNull()?.publicationRetriever()?.let { retriever -> + LcpPublicationRetriever( + downloadRepository = DownloadRepository( + Download.Type.LCP, + database.downloadsDao() + ), + lcpPublicationRetriever = retriever + ) + } + ), + opdsPublicationRetriever = OpdsPublicationRetriever( + downloadManager = readium.downloadManager, + downloadRepository = DownloadRepository(Download.Type.OPDS, database.downloadsDao()) + ) + ) bookshelf = Bookshelf( - applicationContext, bookRepository, - downloadRepository, - storageDir, - coverStorage, + CoverStorage(storageDir), readium.publicationFactory, readium.assetRetriever, readium.protectionRetriever, - readium.formatRegistry, - readium.lcpService, - readium.downloadManager + publicationRetriever ) readerRepository = diff --git a/test-app/src/main/java/org/readium/r2/testapp/data/DownloadRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/data/DownloadRepository.kt index 703955ce24..1879315f36 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/data/DownloadRepository.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/DownloadRepository.kt @@ -10,55 +10,28 @@ import org.readium.r2.testapp.data.db.DownloadsDao import org.readium.r2.testapp.data.model.Download class DownloadRepository( + private val type: Download.Type, private val downloadsDao: DownloadsDao ) { - suspend fun getLcpDownloads(): List { - return downloadsDao.getLcpDownloads() - } - - suspend fun getOpdsDownloads(): List { - return downloadsDao.getOpdsDownloads() - } - - suspend fun insertLcpDownload( - id: String, - cover: String? - ) { - downloadsDao.insert( - Download(id = id, type = Download.TYPE_LCP, extra = cover) - ) - } + suspend fun all(): List = + downloadsDao.getDownloads(type) - suspend fun insertOpdsDownload( + suspend fun insert( id: String, cover: String? ) { downloadsDao.insert( - Download(id = id, type = Download.TYPE_OPDS, extra = cover) + Download(id = id, type = type, cover = cover) ) } - suspend fun getLcpDownloadCover( - id: String - ): String? { - return downloadsDao.get(id, Download.TYPE_LCP)!!.extra - } - suspend fun getOpdsDownloadCover( - id: String - ): String? { - return downloadsDao.get(id, Download.TYPE_OPDS)!!.extra - } - - suspend fun removeLcpDownload( + suspend fun remove( id: String ) { - downloadsDao.delete(id, Download.TYPE_LCP) + downloadsDao.delete(id, type) } - suspend fun removeOpdsDownload( - id: String - ) { - downloadsDao.delete(id, Download.TYPE_OPDS) - } + suspend fun getCover(id: String): String? = + downloadsDao.get(id, type)?.cover } diff --git a/test-app/src/main/java/org/readium/r2/testapp/data/db/AppDatabase.kt b/test-app/src/main/java/org/readium/r2/testapp/data/db/AppDatabase.kt index 5b5d387065..4f19e64f87 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/data/db/AppDatabase.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/db/AppDatabase.kt @@ -22,7 +22,10 @@ import org.readium.r2.testapp.data.model.Highlight version = 1, exportSchema = false ) -@TypeConverters(HighlightConverters::class) +@TypeConverters( + HighlightConverters::class, + Download.Type.Converter::class +) abstract class AppDatabase : RoomDatabase() { abstract fun booksDao(): BooksDao diff --git a/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadsDao.kt b/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadsDao.kt index 00bc705c8d..c8ed9aba9e 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadsDao.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/db/DownloadsDao.kt @@ -21,23 +21,17 @@ interface DownloadsDao { "DELETE FROM " + Download.TABLE_NAME + " WHERE " + Download.ID + " = :id AND " + Download.TYPE + " = :type" ) - suspend fun delete(id: String, type: String) + suspend fun delete(id: String, type: Download.Type) @Query( "SELECT * FROM " + Download.TABLE_NAME + " WHERE " + Download.ID + " = :id AND " + Download.TYPE + " = :type" ) - suspend fun get(id: String, type: String): Download? + suspend fun get(id: String, type: Download.Type): Download? @Query( "SELECT * FROM " + Download.TABLE_NAME + - " WHERE " + Download.TYPE + " = '" + Download.TYPE_OPDS + "'" + " WHERE " + Download.TYPE + " = :type" ) - suspend fun getOpdsDownloads(): List - - @Query( - "SELECT * FROM " + Download.TABLE_NAME + - " WHERE " + Download.TYPE + " = '" + Download.TYPE_LCP + "'" - ) - suspend fun getLcpDownloads(): List + suspend fun getDownloads(type: Download.Type): List } diff --git a/test-app/src/main/java/org/readium/r2/testapp/data/model/Download.kt b/test-app/src/main/java/org/readium/r2/testapp/data/model/Download.kt index 0e592a9205..ad2e6296ea 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/data/model/Download.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/data/model/Download.kt @@ -8,27 +8,43 @@ package org.readium.r2.testapp.data.model import androidx.room.ColumnInfo import androidx.room.Entity +import androidx.room.TypeConverter +/** + * Represents an on-going publication download, either from an OPDS catalog or an LCP acquisition. + * + * The download [id] is unique relative to its [type] (OPDS or LCP). + */ @Entity(tableName = Download.TABLE_NAME, primaryKeys = [Download.ID, Download.TYPE]) data class Download( + @ColumnInfo(name = TYPE) + val type: Type, @ColumnInfo(name = ID) val id: String, - @ColumnInfo(name = TYPE) - val type: String, - @ColumnInfo(name = EXTRA) - val extra: String? = null, + @ColumnInfo(name = COVER) + val cover: String? = null, @ColumnInfo(name = CREATION_DATE, defaultValue = "CURRENT_TIMESTAMP") val creation: Long? = null ) { + enum class Type(val value: String) { + OPDS("opds"), LCP("lcp"); + + class Converter { + private val values = values().associateBy(Type::value) + + @TypeConverter + fun fromString(value: String?): Type = values[value]!! + + @TypeConverter + fun toString(type: Type): String = type.value + } + } companion object { const val TABLE_NAME = "downloads" const val CREATION_DATE = "creation_date" const val ID = "id" - const val TYPE = "TYPE" - const val EXTRA = "cover" - - const val TYPE_OPDS = "opds" - const val TYPE_LCP = "lcp" + const val TYPE = "type" + const val COVER = "cover" } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt index 0e511af3b8..9d50aeb942 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt @@ -6,27 +6,21 @@ package org.readium.r2.testapp.domain -import android.content.Context import android.net.Uri import java.io.File import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch -import org.readium.r2.lcp.LcpException -import org.readium.r2.lcp.LcpService import org.readium.r2.shared.asset.AssetRetriever import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.publication.protection.ContentProtectionSchemeRetriever import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.Url -import org.readium.r2.shared.util.downloads.DownloadManager import org.readium.r2.shared.util.getOrElse -import org.readium.r2.shared.util.mediatype.FormatRegistry import org.readium.r2.shared.util.toUrl import org.readium.r2.streamer.PublicationFactory import org.readium.r2.testapp.data.BookRepository -import org.readium.r2.testapp.data.DownloadRepository import org.readium.r2.testapp.data.model.Book import org.readium.r2.testapp.utils.tryOrLog import timber.log.Timber @@ -38,21 +32,20 @@ import timber.log.Timber * before adding it to the database */ class Bookshelf( - context: Context, private val bookRepository: BookRepository, - downloadRepository: DownloadRepository, - storageDir: File, private val coverStorage: CoverStorage, private val publicationFactory: PublicationFactory, private val assetRetriever: AssetRetriever, private val protectionRetriever: ContentProtectionSchemeRetriever, - formatRegistry: FormatRegistry, - lcpService: Try, - downloadManager: DownloadManager + private val publicationRetriever: PublicationRetriever ) { val channel: Channel = Channel(Channel.UNLIMITED) + init { + publicationRetriever.listener = PublicationRetrieverListener() + } + sealed class Event { data object ImportPublicationSuccess : Event() @@ -65,18 +58,6 @@ class Bookshelf( private val coroutineScope: CoroutineScope = MainScope() - private val publicationRetriever: PublicationRetriever = - PublicationRetriever( - context, - storageDir, - assetRetriever, - formatRegistry, - downloadRepository, - downloadManager, - lcpService.map { it.publicationRetriever() }, - PublicationRetrieverListener() - ) - private inner class PublicationRetrieverListener : PublicationRetriever.Listener { override fun onSuccess(publication: File, coverUrl: String?) { coroutineScope.launch { diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt index 28051611e2..f4d856a535 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt @@ -14,7 +14,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import org.readium.r2.lcp.LcpException -import org.readium.r2.lcp.LcpPublicationRetriever +import org.readium.r2.lcp.LcpPublicationRetriever as ReadiumLcpPublicationRetriever import org.readium.r2.lcp.LcpService import org.readium.r2.lcp.license.model.LicenseDocument import org.readium.r2.shared.asset.Asset @@ -33,17 +33,18 @@ import org.readium.r2.testapp.utils.extensions.moveTo import org.readium.r2.testapp.utils.tryOrNull import timber.log.Timber +/** + * Retrieves a publication from a remote or local source and import it into the bookshelf storage. + * + * If the source file is a LCP license document, the protected publication will be downloaded. + */ class PublicationRetriever( - private val context: Context, - private val storageDir: File, - private val assetRetriever: AssetRetriever, - private val formatRegistry: FormatRegistry, - private val downloadRepository: DownloadRepository, - private val downloadManager: DownloadManager, - private val lcpPublicationRetriever: Try, - private val listener: Listener + private val localPublicationRetriever: LocalPublicationRetriever, + private val opdsPublicationRetriever: OpdsPublicationRetriever ) { + lateinit var listener: Listener + interface Listener { fun onSuccess(publication: File, coverUrl: String?) @@ -51,141 +52,187 @@ class PublicationRetriever( fun onError(error: ImportError) } - private val coroutineScope: CoroutineScope = - MainScope() + init { + localPublicationRetriever.listener = object : Listener { + override fun onSuccess(publication: File, coverUrl: String?) { + listener.onSuccess(publication, coverUrl) + } - private inner class DownloadListener : DownloadManager.Listener { - override fun onDownloadCompleted(requestId: DownloadManager.RequestId, file: File) { - coroutineScope.launch { - val coverUrl = downloadRepository.getOpdsDownloadCover(requestId.value) - downloadRepository.removeOpdsDownload(requestId.value) - retrieveFromStorage(file, coverUrl) + override fun onError(error: ImportError) { + listener.onError(error) } } - override fun onDownloadProgressed( - requestId: DownloadManager.RequestId, - downloaded: Long, - expected: Long? - ) { - } + opdsPublicationRetriever.listener = object : Listener { + override fun onSuccess(publication: File, coverUrl: String?) { + localPublicationRetriever.retrieve(publication, coverUrl) + } - override fun onDownloadFailed( - requestId: DownloadManager.RequestId, - error: DownloadManager.Error - ) { - coroutineScope.launch { - downloadRepository.removeOpdsDownload(requestId.value) - listener.onError(ImportError.DownloadFailed(error)) + override fun onError(error: ImportError) { + listener.onError(error) } } + } - override fun onDownloadCancelled(requestId: DownloadManager.RequestId) { - coroutineScope.launch { - Timber.v("Download ${requestId.value} has been cancelled.") - downloadRepository.removeOpdsDownload(requestId.value) - } + fun retrieveFromStorage(uri: Uri) { + localPublicationRetriever.retrieve(uri) + } + + fun retrieveFromOpds(publication: Publication) { + opdsPublicationRetriever.retrieve(publication) + } +} + +/** + * Retrieves a publication from a file (publication or LCP license document) stored on the device. + */ +class LocalPublicationRetriever( + private val context: Context, + private val storageDir: File, + private val assetRetriever: AssetRetriever, + private val formatRegistry: FormatRegistry, + private val lcpPublicationRetriever: LcpPublicationRetriever? +) { + + lateinit var listener: PublicationRetriever.Listener + + private val coroutineScope: CoroutineScope = + MainScope() + + init { + lcpPublicationRetriever?.listener = LcpListener() + } + + /** + * Retrieves the publication from the given local [uri]. + */ + fun retrieve(uri: Uri) { + coroutineScope.launch { + val tempFile = uri.copyToTempFile(context, storageDir) + .getOrElse { + listener.onError(ImportError.StorageError(it)) + return@launch + } + + retrieveFromStorage(tempFile) } } - private inner class LcpRetrieverListener : LcpPublicationRetriever.Listener { - override fun onAcquisitionCompleted( - requestId: LcpPublicationRetriever.RequestId, - acquiredPublication: LcpService.AcquiredPublication + /** + * Retrieves the publication stored at the given [tempFile]. + */ + fun retrieve( + tempFile: File, + coverUrl: String? = null + ) { + coroutineScope.launch { + retrieveFromStorage(tempFile, coverUrl) + } + } + + private suspend fun retrieveFromStorage( + tempFile: File, + coverUrl: String? = null + ) { + val sourceAsset = assetRetriever.retrieve(tempFile) + ?: run { + listener.onError( + ImportError.PublicationError(PublicationError.UnsupportedAsset()) + ) + return + } + + if ( + sourceAsset is Asset.Resource && + sourceAsset.mediaType.matches(MediaType.LCP_LICENSE_DOCUMENT) ) { - coroutineScope.launch { - val coverUrl = downloadRepository.getLcpDownloadCover(requestId.value) - downloadRepository.removeLcpDownload(requestId.value) - retrieveFromStorage(acquiredPublication.localFile, coverUrl) + if (lcpPublicationRetriever == null) { + listener.onError( + ImportError.PublicationError(PublicationError.UnsupportedAsset()) + ) + } else { + lcpPublicationRetriever.retrieve(sourceAsset, tempFile, coverUrl) } + return } - override fun onAcquisitionProgressed( - requestId: LcpPublicationRetriever.RequestId, - downloaded: Long, - expected: Long? - ) { + val fileExtension = formatRegistry.fileExtension(sourceAsset.mediaType) ?: "epub" + val fileName = "${UUID.randomUUID()}.$fileExtension" + val libraryFile = File(storageDir, fileName) + + try { + tempFile.moveTo(libraryFile) + } catch (e: Exception) { + Timber.d(e) + tryOrNull { libraryFile.delete() } + listener.onError(ImportError.StorageError(e)) + return } - override fun onAcquisitionFailed( - requestId: LcpPublicationRetriever.RequestId, - error: LcpException - ) { + listener.onSuccess(libraryFile, coverUrl) + } + + private inner class LcpListener : PublicationRetriever.Listener { + override fun onSuccess(publication: File, coverUrl: String?) { coroutineScope.launch { - downloadRepository.removeLcpDownload(requestId.value) - listener.onError(ImportError.LcpAcquisitionFailed(error)) + retrieve(publication, coverUrl) } } - override fun onAcquisitionCancelled(requestId: LcpPublicationRetriever.RequestId) { - coroutineScope.launch { - Timber.v("Acquisition ${requestId.value} has been cancelled.") - downloadRepository.removeLcpDownload(requestId.value) - } + override fun onError(error: ImportError) { + listener.onError(error) } } +} - private val downloadListener: DownloadListener = - DownloadListener() +/** + * Retrieves a publication from an OPDS entry. + */ +class OpdsPublicationRetriever( + private val downloadManager: DownloadManager, + private val downloadRepository: DownloadRepository +) { - private val lcpRetrieverListener: LcpRetrieverListener = - LcpRetrieverListener() + lateinit var listener: PublicationRetriever.Listener + + private val coroutineScope: CoroutineScope = + MainScope() init { coroutineScope.launch { - for (download in downloadRepository.getOpdsDownloads()) { + for (download in downloadRepository.all()) { downloadManager.register( DownloadManager.RequestId(download.id), downloadListener ) } - - lcpPublicationRetriever.map { publicationRetriever -> - for (download in downloadRepository.getLcpDownloads()) { - publicationRetriever.register( - LcpPublicationRetriever.RequestId(download.id), - lcpRetrieverListener - ) - } - } } } - fun retrieveFromStorage( - uri: Uri - ) { - coroutineScope.launch { - val tempFile = uri.copyToTempFile(context, storageDir) - .getOrElse { - listener.onError(ImportError.StorageError(it)) - return@launch - } - - retrieveFromStorage(tempFile) - } - } - - fun retrieveFromOpds(publication: Publication) { + /** + * Retrieves the file of the given OPDS [publication]. + */ + fun retrieve(publication: Publication) { coroutineScope.launch { val publicationUrl = publication.acquisitionUrl() .getOrElse { listener.onError(ImportError.OpdsError(it)) return@launch - }.toString() + } val coverUrl = publication .images.firstOrNull()?.href val requestId = downloadManager.submit( request = DownloadManager.Request( - Url(publicationUrl)!!, + publicationUrl, title = publication.metadata.title ?: "Untitled publication", description = "Downloading", headers = emptyMap() ), listener = downloadListener ) - downloadRepository.insertOpdsDownload( + downloadRepository.insert( id = requestId.value, cover = coverUrl ) @@ -202,77 +249,140 @@ class PublicationRetriever( ?: Try.failure(Exception("Invalid acquisition url.")) } - private suspend fun retrieveFromStorage( - tempFile: File, - coverUrl: String? = null - ) { - val sourceAsset = assetRetriever.retrieve(tempFile) - ?: run { - listener.onError( - ImportError.PublicationError(PublicationError.UnsupportedAsset()) - ) - return + private val downloadListener: DownloadListener = + DownloadListener() + + private inner class DownloadListener : DownloadManager.Listener { + override fun onDownloadCompleted(requestId: DownloadManager.RequestId, file: File) { + coroutineScope.launch { + val coverUrl = downloadRepository.getCover(requestId.value) + downloadRepository.remove(requestId.value) + listener.onSuccess(file, coverUrl) } + } - if ( - sourceAsset is Asset.Resource && - sourceAsset.mediaType.matches(MediaType.LCP_LICENSE_DOCUMENT) + override fun onDownloadProgressed( + requestId: DownloadManager.RequestId, + downloaded: Long, + expected: Long? ) { - acquireLcpPublication(sourceAsset, tempFile, coverUrl) - .getOrElse { - listener.onError(ImportError.StorageError(it)) - return - } - return } - val fileExtension = formatRegistry.fileExtension(sourceAsset.mediaType) ?: "epub" - val fileName = "${UUID.randomUUID()}.$fileExtension" - val libraryFile = File(storageDir, fileName) + override fun onDownloadFailed( + requestId: DownloadManager.RequestId, + error: DownloadManager.Error + ) { + coroutineScope.launch { + downloadRepository.remove(requestId.value) + listener.onError(ImportError.DownloadFailed(error)) + } + } - try { - tempFile.moveTo(libraryFile) - } catch (e: Exception) { - Timber.d(e) - tryOrNull { libraryFile.delete() } - listener.onError(ImportError.StorageError(e)) - return + override fun onDownloadCancelled(requestId: DownloadManager.RequestId) { + coroutineScope.launch { + Timber.v("Download ${requestId.value} has been cancelled.") + downloadRepository.remove(requestId.value) + } } + } +} - listener.onSuccess(libraryFile, coverUrl) +/** + * Retrieves a publication from an LCP license document. + */ +class LcpPublicationRetriever( + private val downloadRepository: DownloadRepository, + private val lcpPublicationRetriever: ReadiumLcpPublicationRetriever +) { + lateinit var listener: PublicationRetriever.Listener + + private val coroutineScope: CoroutineScope = + MainScope() + + init { + coroutineScope.launch { + for (download in downloadRepository.all()) { + lcpPublicationRetriever.register( + ReadiumLcpPublicationRetriever.RequestId(download.id), + lcpRetrieverListener + ) + } + } } - private suspend fun acquireLcpPublication( + /** + * Retrieves a publication protected with the given license. + */ + fun retrieve( licenceAsset: Asset.Resource, licenceFile: File, coverUrl: String? - ): Try { - val lcpRetriever = lcpPublicationRetriever - .getOrElse { return Try.failure(ImportError.LcpAcquisitionFailed(it)) } - - val license = licenceAsset.resource.read() - .getOrElse { return Try.failure(ImportError.StorageError(it)) } - .let { - try { - LicenseDocument(it) - } catch (e: LcpException) { - return Try.failure( - ImportError.PublicationError(ImportError.LcpAcquisitionFailed(e)) - ) + ) { + coroutineScope.launch { + val license = licenceAsset.resource.read() + .getOrElse { + listener.onError(ImportError.StorageError(it)) + return@launch + } + .let { + try { + LicenseDocument(it) + } catch (e: LcpException) { + listener.onError(ImportError.LcpAcquisitionFailed(e)) + return@launch + } } - } - tryOrNull { licenceFile.delete() } + tryOrNull { licenceFile.delete() } - val requestId = lcpRetriever.retrieve( - license, - "Fulfilling Lcp publication", - null, - lcpRetrieverListener - ) + val requestId = lcpPublicationRetriever.retrieve( + license, + "Fulfilling Lcp publication", + null, + lcpRetrieverListener + ) - downloadRepository.insertLcpDownload(requestId.value, coverUrl) + downloadRepository.insert(requestId.value, coverUrl) + } + } + + private val lcpRetrieverListener: LcpRetrieverListener = + LcpRetrieverListener() + + private inner class LcpRetrieverListener : ReadiumLcpPublicationRetriever.Listener { + override fun onAcquisitionCompleted( + requestId: ReadiumLcpPublicationRetriever.RequestId, + acquiredPublication: LcpService.AcquiredPublication + ) { + coroutineScope.launch { + val coverUrl = downloadRepository.getCover(requestId.value) + downloadRepository.remove(requestId.value) + listener.onSuccess(acquiredPublication.localFile, coverUrl) + } + } + + override fun onAcquisitionProgressed( + requestId: ReadiumLcpPublicationRetriever.RequestId, + downloaded: Long, + expected: Long? + ) { + } - return Try.success(Unit) + override fun onAcquisitionFailed( + requestId: ReadiumLcpPublicationRetriever.RequestId, + error: LcpException + ) { + coroutineScope.launch { + downloadRepository.remove(requestId.value) + listener.onError(ImportError.LcpAcquisitionFailed(error)) + } + } + + override fun onAcquisitionCancelled(requestId: ReadiumLcpPublicationRetriever.RequestId) { + coroutineScope.launch { + Timber.v("Acquisition ${requestId.value} has been cancelled.") + downloadRepository.remove(requestId.value) + } + } } } From a0cfa4c9be6c127501ba25cf9dfb9d2a69a524d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Fri, 15 Sep 2023 11:41:21 +0200 Subject: [PATCH 29/35] Adjust LCP downloads --- .../readium/r2/lcp/LcpDownloadsRepository.kt | 27 +++----- .../readium/r2/lcp/LcpPublicationRetriever.kt | 29 ++++++-- .../readium/r2/shared/util/CoroutineQueue.kt | 69 +++++++++++++++++++ .../android/AndroidDownloadManager.kt | 2 +- 4 files changed, 104 insertions(+), 23 deletions(-) create mode 100644 readium/shared/src/main/java/org/readium/r2/shared/util/CoroutineQueue.kt diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt index ca06632f3b..3d32f9dd06 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt @@ -9,25 +9,21 @@ package org.readium.r2.lcp import android.content.Context import java.io.File import java.util.LinkedList -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.MainScope import kotlinx.coroutines.async -import kotlinx.coroutines.job import kotlinx.coroutines.launch -import kotlinx.coroutines.plus import kotlinx.coroutines.withContext import org.json.JSONObject +import org.readium.r2.shared.util.CoroutineQueue internal class LcpDownloadsRepository( context: Context ) { - private val coroutineScope: CoroutineScope = - MainScope() + private val queue = CoroutineQueue() private val storageDir: Deferred = - coroutineScope.async { + queue.scope.async { withContext(Dispatchers.IO) { File(context.noBackupFilesDir, LcpDownloadsRepository::class.qualifiedName!!) .also { if (!it.exists()) it.mkdirs() } @@ -35,7 +31,7 @@ internal class LcpDownloadsRepository( } private val storageFile: Deferred = - coroutineScope.async { + queue.scope.async { withContext(Dispatchers.IO) { File(storageDir.await(), "licenses.json") .also { if (!it.exists()) { it.writeText("{}", Charsets.UTF_8) } } @@ -43,12 +39,12 @@ internal class LcpDownloadsRepository( } private val snapshot: Deferred> = - coroutineScope.async { + queue.scope.async { readSnapshot().toMutableMap() } fun addDownload(id: String, license: JSONObject) { - coroutineScope.launch { + queue.scope.launch { val snapshotCompleted = snapshot.await() snapshotCompleted[id] = license writeSnapshot(snapshotCompleted) @@ -56,20 +52,17 @@ internal class LcpDownloadsRepository( } fun removeDownload(id: String) { - coroutineScope.launch { + queue.launch { val snapshotCompleted = snapshot.await() snapshotCompleted.remove(id) writeSnapshot(snapshotCompleted) } } - suspend fun retrieveLicense(id: String): JSONObject? { - val snapshot = snapshot.await() - while (coroutineScope.coroutineContext.job.children.toList().isNotEmpty()) { - coroutineScope.coroutineContext.job.children.forEach { it.join() } + suspend fun retrieveLicense(id: String): JSONObject? = + queue.await { + snapshot.await()[id] } - return snapshot[id] - } private suspend fun readSnapshot(): Map { return withContext(Dispatchers.IO) { diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index ccc26b80dc..d7d19e3a43 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -85,7 +85,7 @@ public class LcpPublicationRetriever( downloadTitle, downloadDescription ) - register(requestId, listener) + addListener(requestId, listener) return requestId } @@ -99,10 +99,16 @@ public class LcpPublicationRetriever( requestId: RequestId, listener: Listener ) { - listeners.getOrPut(requestId) { - downloadManager.register(DownloadManager.RequestId(requestId.value), downloadListener) - mutableListOf() - }.add(listener) + addListener( + requestId, + listener, + onFirstListenerAdded = { + downloadManager.register( + DownloadManager.RequestId(requestId.value), + downloadListener + ) + } + ) } /** @@ -137,6 +143,19 @@ public class LcpPublicationRetriever( private val listeners: MutableMap> = mutableMapOf() + private fun addListener( + requestId: RequestId, + listener: Listener, + onFirstListenerAdded: () -> Unit = {} + ) { + listeners + .getOrPut(requestId) { + onFirstListenerAdded() + mutableListOf() + } + .add(listener) + } + private fun fetchPublication( license: LicenseDocument, downloadTitle: String, diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/CoroutineQueue.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/CoroutineQueue.kt new file mode 100644 index 0000000000..55c149803e --- /dev/null +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/CoroutineQueue.kt @@ -0,0 +1,69 @@ +/* + * 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 + +import kotlin.coroutines.resume +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import org.readium.r2.shared.InternalReadiumApi + +/** + * Executes coroutines in a sequential order (FIFO). + */ +@InternalReadiumApi +public class CoroutineQueue( + public val scope: CoroutineScope = MainScope() +) { + init { + scope.launch { + for (task in tasks) { + task() + } + } + } + + /** + * Launches a coroutine in the queue. + */ + public fun launch(task: suspend () -> Unit) { + tasks.trySendBlocking(Task(task)).getOrThrow() + } + + /** + * Launches a coroutine in the queue, and waits for its result. + */ + public suspend fun await(task: suspend () -> T): T = + suspendCancellableCoroutine { cont -> + tasks.trySendBlocking(Task(task, cont)).getOrThrow() + } + + /** + * Cancels all the coroutines in the queue. + */ + public fun cancel(cause: CancellationException? = null) { + scope.cancel(cause) + } + + private val tasks: Channel> = Channel(Channel.UNLIMITED) + + private class Task( + val task: suspend () -> T, + val continuation: CancellableContinuation? = null + ) { + suspend operator fun invoke() { + val result = task() + continuation?.resume(result) + } + } +} diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt index 3f29e9a9e0..de4513158a 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt @@ -248,7 +248,7 @@ public class AndroidDownloadManager internal constructor( maybeStopObservingProgress() } SystemDownloadManager.STATUS_RUNNING -> { - val expected = facade.expected + val expected = facade.expected?.takeIf { it > 0 } listenersForId.forEach { it.onDownloadProgressed(id, facade.downloadedSoFar, expected) } From 97211c7c4378eb64c38624d8b03fa89fc49544d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Fri, 15 Sep 2023 12:37:55 +0200 Subject: [PATCH 30/35] Fix `ForegroundDownloadManager` and progress report --- .../readium/r2/lcp/LcpPublicationRetriever.kt | 11 +- .../shared/util/downloads/DownloadManager.kt | 3 +- .../android/AndroidDownloadManager.kt | 2 +- .../foreground/ForegroundDownloadManager.kt | 147 ++++++++++++------ .../readium/r2/testapp/domain/Bookshelf.kt | 5 + .../r2/testapp/domain/PublicationRetriever.kt | 28 +++- .../r2/testapp/utils/extensions/Number.kt | 9 ++ 7 files changed, 150 insertions(+), 55 deletions(-) create mode 100644 test-app/src/main/java/org/readium/r2/testapp/utils/extensions/Number.kt diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index d7d19e3a43..94c3bb01d1 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -181,7 +181,8 @@ public class LcpPublicationRetriever( override fun onDownloadCompleted( requestId: DownloadManager.RequestId, - file: File + file: File, + mediaType: MediaType? ) { coroutineScope.launch { val lcpRequestId = RequestId(requestId.value) @@ -202,14 +203,14 @@ public class LcpPublicationRetriever( } downloadsRepository.removeDownload(requestId.value) - val mediaType = mediaTypeRetriever.retrieve( + val mt = mediaTypeRetriever.retrieve( mediaType = license.publicationLink.type ) ?: MediaType.EPUB try { // Saves the License Document into the downloaded publication - val container = createLicenseContainer(file, mediaType) + val container = createLicenseContainer(file, mt) container.write(license) } catch (e: Exception) { tryOrLog { file.delete() } @@ -221,8 +222,8 @@ public class LcpPublicationRetriever( val acquiredPublication = LcpService.AcquiredPublication( localFile = file, - suggestedFilename = "${license.id}.${formatRegistry.fileExtension(mediaType) ?: "epub"}", - mediaType = mediaType, + suggestedFilename = "${license.id}.${formatRegistry.fileExtension(mt) ?: "epub"}", + mediaType = mt, licenseDocument = license ) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt index 1d3d2baee8..aa29a19c3f 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt @@ -10,6 +10,7 @@ import java.io.File import org.readium.r2.shared.util.Url import org.readium.r2.shared.util.downloads.android.AndroidDownloadManager import org.readium.r2.shared.util.downloads.foreground.ForegroundDownloadManager +import org.readium.r2.shared.util.mediatype.MediaType /** * Manages a set of concurrent files downloaded through HTTP. @@ -88,7 +89,7 @@ public interface DownloadManager { * The download with ID [requestId] has been successfully completed and is now available at * [file]. */ - public fun onDownloadCompleted(requestId: RequestId, file: File) + public fun onDownloadCompleted(requestId: RequestId, file: File, mediaType: MediaType?) /** * The request with ID [requestId] has downloaded [downloaded] out of [expected] bytes. diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt index de4513158a..125e160956 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt @@ -236,7 +236,7 @@ public class AndroidDownloadManager internal constructor( val newDest = File(destFile.parent, generateFileName(destFile.extension)) if (destFile.renameTo(newDest)) { listenersForId.forEach { - it.onDownloadCompleted(id, newDest) + it.onDownloadCompleted(id, newDest, mediaType = null) } } else { listenersForId.forEach { diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt index b158efe11a..3589b6f329 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt @@ -7,19 +7,25 @@ package org.readium.r2.shared.util.downloads.foreground import java.io.File +import java.io.FileOutputStream import java.util.UUID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.MainScope +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.readium.r2.shared.extensions.tryOrLog import org.readium.r2.shared.util.ThrowableError import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.downloads.DownloadManager +import org.readium.r2.shared.util.flatMap import org.readium.r2.shared.util.http.HttpClient import org.readium.r2.shared.util.http.HttpException import org.readium.r2.shared.util.http.HttpRequest +import org.readium.r2.shared.util.http.HttpResponse +import org.readium.r2.shared.util.http.HttpTry /** * A [DownloadManager] implementation using a [HttpClient]. @@ -27,7 +33,8 @@ import org.readium.r2.shared.util.http.HttpRequest * If the app is killed, downloads will stop and you won't be able to resume them later. */ public class ForegroundDownloadManager( - private val httpClient: HttpClient + private val httpClient: HttpClient, + private val bufferLength: Int = 1024 * 8 ) : DownloadManager { private val coroutineScope: CoroutineScope = @@ -50,88 +57,136 @@ public class ForegroundDownloadManager( } private suspend fun doRequest(request: DownloadManager.Request, id: DownloadManager.RequestId) { - val response = httpClient.fetch( - HttpRequest( - url = request.url.toString(), - headers = request.headers.mapValues { it.value.joinToString(",") } + val destination = withContext(Dispatchers.IO) { + File.createTempFile(UUID.randomUUID().toString(), null) + } + + httpClient + .download( + request = HttpRequest( + url = request.url.toString(), + headers = request.headers.mapValues { it.value.joinToString(",") } + ), + destination = destination, + onProgress = { downloaded, expected -> + forEachListener(id) { + onDownloadProgressed(id, downloaded = downloaded, expected = expected) + } + } ) - ) + .onSuccess { response -> + forEachListener(id) { + onDownloadCompleted( + id, + file = destination, + mediaType = response.mediaType + ) + } + } + .onFailure { error -> + forEachListener(id) { + onDownloadFailed(id, mapError(error)) + } + } - val dottedExtension = request.url.extension - ?.let { ".$it" } - .orEmpty() + listeners.remove(id) + } - val listenersForId = listeners[id].orEmpty() + private fun forEachListener( + id: DownloadManager.RequestId, + task: DownloadManager.Listener.() -> Unit + ) { + listeners[id].orEmpty().forEach { + tryOrLog { it.task() } + } + } - when (response) { - is Try.Success -> { + public override fun cancel(requestId: DownloadManager.RequestId) { + jobs.remove(requestId)?.cancel() + forEachListener(requestId) { onDownloadCancelled(requestId) } + listeners.remove(requestId) + } + + public override fun register( + requestId: DownloadManager.RequestId, + listener: DownloadManager.Listener + ) { + listeners.getOrPut(requestId) { mutableListOf() }.add(listener) + } + + public override fun close() { + jobs.forEach { cancel(it.key) } + } + + private suspend fun HttpClient.download( + request: HttpRequest, + destination: File, + onProgress: (downloaded: Long, expected: Long?) -> Unit + ): HttpTry = + try { + stream(request).flatMap { res -> withContext(Dispatchers.IO) { - try { - val dest = File.createTempFile( - UUID.randomUUID().toString(), - dottedExtension - ) - dest.writeBytes(response.value.body) - } catch (e: Exception) { - val error = DownloadManager.Error.FileError(ThrowableError(e)) - listenersForId.forEach { it.onDownloadFailed(id, error) } + val expected = res.response.contentLength?.takeIf { it > 0 } + + res.body.use { input -> + FileOutputStream(destination).use { output -> + val buf = ByteArray(bufferLength) + var n: Int + var downloadedBytes = 0L + while (-1 != input.read(buf).also { n = it }) { + ensureActive() + downloadedBytes += n + output.write(buf, 0, n) + onProgress(downloadedBytes, expected) + } + } } + + Try.success(res.response) } } - is Try.Failure -> { - val error = mapError(response.value) - listenersForId.forEach { it.onDownloadFailed(id, error) } - } + } catch (e: Exception) { + Try.failure(HttpException.wrap(e)) } - listeners.remove(id) - } - private fun mapError(httpException: HttpException): DownloadManager.Error { val httpError = ThrowableError(httpException) return when (httpException.kind) { HttpException.Kind.MalformedRequest -> DownloadManager.Error.Unknown(httpError) + HttpException.Kind.MalformedResponse -> DownloadManager.Error.HttpData(httpError) + HttpException.Kind.Timeout -> DownloadManager.Error.Unreachable(httpError) + HttpException.Kind.BadRequest -> DownloadManager.Error.Unknown(httpError) + HttpException.Kind.Unauthorized -> DownloadManager.Error.Forbidden(httpError) + HttpException.Kind.Forbidden -> DownloadManager.Error.Forbidden(httpError) + HttpException.Kind.NotFound -> DownloadManager.Error.NotFound(httpError) + HttpException.Kind.ClientError -> DownloadManager.Error.HttpData(httpError) + HttpException.Kind.ServerError -> DownloadManager.Error.Server(httpError) + HttpException.Kind.Offline -> DownloadManager.Error.Unreachable(httpError) + HttpException.Kind.Cancelled -> DownloadManager.Error.Unknown(httpError) + HttpException.Kind.Other -> DownloadManager.Error.Unknown(httpError) } } - - public override fun cancel(requestId: DownloadManager.RequestId) { - jobs.remove(requestId)?.cancel() - val listenersForId = listeners[requestId].orEmpty() - listenersForId.forEach { it.onDownloadCancelled(requestId) } - listeners.remove(requestId) - } - - public override fun register( - requestId: DownloadManager.RequestId, - listener: DownloadManager.Listener - ) { - listeners.getOrPut(requestId) { mutableListOf() }.add(listener) - } - - public override fun close() { - jobs.forEach { cancel(it.key) } - } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt index 9d50aeb942..63eb69810b 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt @@ -22,6 +22,7 @@ import org.readium.r2.shared.util.toUrl import org.readium.r2.streamer.PublicationFactory import org.readium.r2.testapp.data.BookRepository import org.readium.r2.testapp.data.model.Book +import org.readium.r2.testapp.utils.extensions.formatPercentage import org.readium.r2.testapp.utils.tryOrLog import timber.log.Timber @@ -66,6 +67,10 @@ class Bookshelf( } } + override fun onProgressed(progress: Double) { + Timber.e("Downloaded ${progress.formatPercentage()}") + } + override fun onError(error: ImportError) { coroutineScope.launch { channel.send(Event.ImportPublicationError(error)) diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt index f4d856a535..7b57898f00 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt @@ -48,7 +48,7 @@ class PublicationRetriever( interface Listener { fun onSuccess(publication: File, coverUrl: String?) - + fun onProgressed(progress: Double) fun onError(error: ImportError) } @@ -58,6 +58,10 @@ class PublicationRetriever( listener.onSuccess(publication, coverUrl) } + override fun onProgressed(progress: Double) { + listener.onProgressed(progress) + } + override fun onError(error: ImportError) { listener.onError(error) } @@ -68,6 +72,10 @@ class PublicationRetriever( localPublicationRetriever.retrieve(publication, coverUrl) } + override fun onProgressed(progress: Double) { + listener.onProgressed(progress) + } + override fun onError(error: ImportError) { listener.onError(error) } @@ -179,6 +187,10 @@ class LocalPublicationRetriever( } } + override fun onProgressed(progress: Double) { + listener.onProgressed(progress) + } + override fun onError(error: ImportError) { listener.onError(error) } @@ -253,7 +265,11 @@ class OpdsPublicationRetriever( DownloadListener() private inner class DownloadListener : DownloadManager.Listener { - override fun onDownloadCompleted(requestId: DownloadManager.RequestId, file: File) { + override fun onDownloadCompleted( + requestId: DownloadManager.RequestId, + file: File, + mediaType: MediaType? + ) { coroutineScope.launch { val coverUrl = downloadRepository.getCover(requestId.value) downloadRepository.remove(requestId.value) @@ -266,6 +282,10 @@ class OpdsPublicationRetriever( downloaded: Long, expected: Long? ) { + coroutineScope.launch { + val progression = expected?.let { downloaded.toDouble() / expected } ?: return@launch + listener.onProgressed(progression) + } } override fun onDownloadFailed( @@ -366,6 +386,10 @@ class LcpPublicationRetriever( downloaded: Long, expected: Long? ) { + coroutineScope.launch { + val progression = expected?.let { downloaded.toDouble() / expected } ?: return@launch + listener.onProgressed(progression) + } } override fun onAcquisitionFailed( diff --git a/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/Number.kt b/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/Number.kt new file mode 100644 index 0000000000..467f90c359 --- /dev/null +++ b/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/Number.kt @@ -0,0 +1,9 @@ +package org.readium.r2.testapp.utils.extensions + +import java.text.NumberFormat + +fun Number.formatPercentage(maximumFractionDigits: Int = 0): String { + val format = NumberFormat.getPercentInstance() + format.maximumFractionDigits = maximumFractionDigits + return format.format(this) +} From a86e0bd50d7dd7e73b891c99abbf706153e25782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mickae=CC=88l=20Menu?= Date: Fri, 15 Sep 2023 12:45:09 +0200 Subject: [PATCH 31/35] Fix PublicationRetriever listeners --- .../org/readium/r2/testapp/Application.kt | 58 +++++++++++-------- .../readium/r2/testapp/domain/Bookshelf.kt | 6 +- .../r2/testapp/domain/PublicationRetriever.kt | 28 ++++----- 3 files changed, 53 insertions(+), 39 deletions(-) diff --git a/test-app/src/main/java/org/readium/r2/testapp/Application.kt b/test-app/src/main/java/org/readium/r2/testapp/Application.kt index da946cf38f..12bae720d0 100755 --- a/test-app/src/main/java/org/readium/r2/testapp/Application.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Application.kt @@ -66,29 +66,6 @@ class Application : android.app.Application() { bookRepository = BookRepository(database.booksDao()) - val publicationRetriever = PublicationRetriever( - localPublicationRetriever = LocalPublicationRetriever( - context = applicationContext, - storageDir = storageDir, - assetRetriever = readium.assetRetriever, - formatRegistry = readium.formatRegistry, - lcpPublicationRetriever = - readium.lcpService.getOrNull()?.publicationRetriever()?.let { retriever -> - LcpPublicationRetriever( - downloadRepository = DownloadRepository( - Download.Type.LCP, - database.downloadsDao() - ), - lcpPublicationRetriever = retriever - ) - } - ), - opdsPublicationRetriever = OpdsPublicationRetriever( - downloadManager = readium.downloadManager, - downloadRepository = DownloadRepository(Download.Type.OPDS, database.downloadsDao()) - ) - ) - bookshelf = Bookshelf( bookRepository, @@ -96,7 +73,40 @@ class Application : android.app.Application() { readium.publicationFactory, readium.assetRetriever, readium.protectionRetriever, - publicationRetriever + createPublicationRetriever = { listener -> + PublicationRetriever( + listener = listener, + createLocalPublicationRetriever = { localListener -> + LocalPublicationRetriever( + listener = localListener, + context = applicationContext, + storageDir = storageDir, + assetRetriever = readium.assetRetriever, + formatRegistry = readium.formatRegistry, + createLcpPublicationRetriever = { lcpListener -> + readium.lcpService.getOrNull()?.publicationRetriever() + ?.let { retriever -> + LcpPublicationRetriever( + listener = lcpListener, + downloadRepository = DownloadRepository( + Download.Type.LCP, + database.downloadsDao() + ), + lcpPublicationRetriever = retriever + ) + } + } + ) + }, + createOpdsPublicationRetriever = { opdsListener -> + OpdsPublicationRetriever( + listener = opdsListener, + downloadManager = readium.downloadManager, + downloadRepository = DownloadRepository(Download.Type.OPDS, database.downloadsDao()) + ) + } + ) + } ) readerRepository = diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt index 63eb69810b..003c1adb68 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/Bookshelf.kt @@ -38,13 +38,15 @@ class Bookshelf( private val publicationFactory: PublicationFactory, private val assetRetriever: AssetRetriever, private val protectionRetriever: ContentProtectionSchemeRetriever, - private val publicationRetriever: PublicationRetriever + createPublicationRetriever: (PublicationRetriever.Listener) -> PublicationRetriever ) { val channel: Channel = Channel(Channel.UNLIMITED) + private val publicationRetriever: PublicationRetriever + init { - publicationRetriever.listener = PublicationRetrieverListener() + publicationRetriever = createPublicationRetriever(PublicationRetrieverListener()) } sealed class Event { diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt index 7b57898f00..63a637e080 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt @@ -39,11 +39,13 @@ import timber.log.Timber * If the source file is a LCP license document, the protected publication will be downloaded. */ class PublicationRetriever( - private val localPublicationRetriever: LocalPublicationRetriever, - private val opdsPublicationRetriever: OpdsPublicationRetriever + private val listener: Listener, + createLocalPublicationRetriever: (Listener) -> LocalPublicationRetriever, + createOpdsPublicationRetriever: (Listener) -> OpdsPublicationRetriever ) { - lateinit var listener: Listener + private val localPublicationRetriever: LocalPublicationRetriever + private val opdsPublicationRetriever: OpdsPublicationRetriever interface Listener { @@ -53,7 +55,7 @@ class PublicationRetriever( } init { - localPublicationRetriever.listener = object : Listener { + localPublicationRetriever = createLocalPublicationRetriever(object : Listener { override fun onSuccess(publication: File, coverUrl: String?) { listener.onSuccess(publication, coverUrl) } @@ -65,9 +67,9 @@ class PublicationRetriever( override fun onError(error: ImportError) { listener.onError(error) } - } + }) - opdsPublicationRetriever.listener = object : Listener { + opdsPublicationRetriever = createOpdsPublicationRetriever(object : Listener { override fun onSuccess(publication: File, coverUrl: String?) { localPublicationRetriever.retrieve(publication, coverUrl) } @@ -79,7 +81,7 @@ class PublicationRetriever( override fun onError(error: ImportError) { listener.onError(error) } - } + }) } fun retrieveFromStorage(uri: Uri) { @@ -95,20 +97,21 @@ class PublicationRetriever( * Retrieves a publication from a file (publication or LCP license document) stored on the device. */ class LocalPublicationRetriever( + private val listener: PublicationRetriever.Listener, private val context: Context, private val storageDir: File, private val assetRetriever: AssetRetriever, private val formatRegistry: FormatRegistry, - private val lcpPublicationRetriever: LcpPublicationRetriever? + createLcpPublicationRetriever: (PublicationRetriever.Listener) -> LcpPublicationRetriever? ) { - lateinit var listener: PublicationRetriever.Listener + private val lcpPublicationRetriever: LcpPublicationRetriever? private val coroutineScope: CoroutineScope = MainScope() init { - lcpPublicationRetriever?.listener = LcpListener() + lcpPublicationRetriever = createLcpPublicationRetriever(LcpListener()) } /** @@ -201,12 +204,11 @@ class LocalPublicationRetriever( * Retrieves a publication from an OPDS entry. */ class OpdsPublicationRetriever( + private val listener: PublicationRetriever.Listener, private val downloadManager: DownloadManager, private val downloadRepository: DownloadRepository ) { - lateinit var listener: PublicationRetriever.Listener - private val coroutineScope: CoroutineScope = MainScope() @@ -311,10 +313,10 @@ class OpdsPublicationRetriever( * Retrieves a publication from an LCP license document. */ class LcpPublicationRetriever( + private val listener: PublicationRetriever.Listener, private val downloadRepository: DownloadRepository, private val lcpPublicationRetriever: ReadiumLcpPublicationRetriever ) { - lateinit var listener: PublicationRetriever.Listener private val coroutineScope: CoroutineScope = MainScope() From fab5af06e9c043ce1a9a2fd4f32e9cf08ccad4de Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Fri, 15 Sep 2023 13:34:28 +0200 Subject: [PATCH 32/35] Small fixes --- .../main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt | 2 +- .../org/readium/r2/shared/util/downloads/DownloadManager.kt | 2 +- .../shared/util/downloads/android/AndroidDownloadManager.kt | 5 ++--- .../r2/shared/util/downloads/android/DownloadCursorFacade.kt | 1 + test-app/src/main/java/org/readium/r2/testapp/Application.kt | 5 ++++- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index 94c3bb01d1..9d48b298fc 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -93,7 +93,7 @@ public class LcpPublicationRetriever( * Registers a listener for the acquisition with the given [requestId]. * * If the [downloadManager] provided during construction supports background downloading, this - * should typically be used when you get create a new instance after the app restarted. + * should typically be used when you create a new instance after the app restarted. */ public fun register( requestId: RequestId, diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt index aa29a19c3f..3636fe9950 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt @@ -119,7 +119,7 @@ public interface DownloadManager { * Registers a listener for the download with the given [requestId]. * * If your [DownloadManager] supports background downloading, this should typically be used when - * you get create a new instance after the app restarted. + * you create a new instance after the app restarted. */ public fun register(requestId: RequestId, listener: Listener) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt index 125e160956..50bfa358e6 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt @@ -49,7 +49,7 @@ public class AndroidDownloadManager internal constructor( public constructor( context: Context, destStorage: Storage = Storage.App, - refreshRate: Hz = 0.1.hz, + refreshRate: Hz = 60.0.hz, allowDownloadsOverMetered: Boolean = true ) : this( context = context, @@ -248,9 +248,8 @@ public class AndroidDownloadManager internal constructor( maybeStopObservingProgress() } SystemDownloadManager.STATUS_RUNNING -> { - val expected = facade.expected?.takeIf { it > 0 } listenersForId.forEach { - it.onDownloadProgressed(id, facade.downloadedSoFar, expected) + it.onDownloadProgressed(id, facade.downloadedSoFar, facade.expected) } } } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadCursorFacade.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadCursorFacade.kt index 1ce7424a8a..88abb4de22 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadCursorFacade.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadCursorFacade.kt @@ -34,6 +34,7 @@ internal class DownloadCursorFacade( .also { require(it != -1) } .takeUnless { cursor.isNull(it) } ?.let { cursor.getLong(it) } + ?.takeUnless { it == -1L } val downloadedSoFar: Long = cursor .getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) diff --git a/test-app/src/main/java/org/readium/r2/testapp/Application.kt b/test-app/src/main/java/org/readium/r2/testapp/Application.kt index 12bae720d0..0b050f14fa 100755 --- a/test-app/src/main/java/org/readium/r2/testapp/Application.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Application.kt @@ -102,7 +102,10 @@ class Application : android.app.Application() { OpdsPublicationRetriever( listener = opdsListener, downloadManager = readium.downloadManager, - downloadRepository = DownloadRepository(Download.Type.OPDS, database.downloadsDao()) + downloadRepository = DownloadRepository( + Download.Type.OPDS, + database.downloadsDao() + ) ) } ) From e39526521afab3be39787506287d498d5284c7ae Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Fri, 15 Sep 2023 14:56:34 +0200 Subject: [PATCH 33/35] Introduce Download data class --- .../readium/r2/lcp/LcpPublicationRetriever.kt | 18 ++++---- .../shared/util/downloads/DownloadManager.kt | 10 +++-- .../android/AndroidDownloadManager.kt | 43 ++++++++++++++----- .../downloads/android/DownloadCursorFacade.kt | 6 +++ .../foreground/ForegroundDownloadManager.kt | 6 ++- .../java/org/readium/r2/testapp/Readium.kt | 1 + .../r2/testapp/domain/PublicationRetriever.kt | 6 +-- 7 files changed, 62 insertions(+), 28 deletions(-) diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index 9d48b298fc..f9909aabba 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -7,7 +7,6 @@ package org.readium.r2.lcp import android.content.Context -import java.io.File import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch @@ -181,8 +180,7 @@ public class LcpPublicationRetriever( override fun onDownloadCompleted( requestId: DownloadManager.RequestId, - file: File, - mediaType: MediaType? + download: DownloadManager.Download ) { coroutineScope.launch { val lcpRequestId = RequestId(requestId.value) @@ -204,16 +202,18 @@ public class LcpPublicationRetriever( downloadsRepository.removeDownload(requestId.value) val mt = mediaTypeRetriever.retrieve( - mediaType = license.publicationLink.type - ) - ?: MediaType.EPUB + mediaTypes = listOfNotNull( + license.publicationLink.type, + download.mediaType.toString() + ) + ) ?: MediaType.EPUB try { // Saves the License Document into the downloaded publication - val container = createLicenseContainer(file, mt) + val container = createLicenseContainer(download.file, mt) container.write(license) } catch (e: Exception) { - tryOrLog { file.delete() } + tryOrLog { download.file.delete() } listenersForId.forEach { it.onAcquisitionFailed(lcpRequestId, LcpException.wrap(e)) } @@ -221,7 +221,7 @@ public class LcpPublicationRetriever( } val acquiredPublication = LcpService.AcquiredPublication( - localFile = file, + localFile = download.file, suggestedFilename = "${license.id}.${formatRegistry.fileExtension(mt) ?: "epub"}", mediaType = mt, licenseDocument = license diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt index 3636fe9950..9b00ed7b72 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt @@ -30,6 +30,11 @@ public interface DownloadManager { val headers: Map> = emptyMap() ) + public data class Download( + val file: File, + val mediaType: MediaType + ) + @JvmInline public value class RequestId(public val value: String) @@ -86,10 +91,9 @@ public interface DownloadManager { public interface Listener { /** - * The download with ID [requestId] has been successfully completed and is now available at - * [file]. + * The download with ID [requestId] has been successfully completed. */ - public fun onDownloadCompleted(requestId: RequestId, file: File, mediaType: MediaType?) + public fun onDownloadCompleted(requestId: RequestId, download: Download) /** * The request with ID [requestId] has downloaded [downloaded] out of [expected] bytes. diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt index 50bfa358e6..aa87bc0e13 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt @@ -20,16 +20,22 @@ import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.readium.r2.shared.resource.FileResource import org.readium.r2.shared.units.Hz import org.readium.r2.shared.units.hz +import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.downloads.DownloadManager +import org.readium.r2.shared.util.getOrElse +import org.readium.r2.shared.util.mediatype.MediaTypeRetriever import org.readium.r2.shared.util.toUri +import org.readium.r2.shared.util.use /** * A [DownloadManager] implementation using the Android download service. */ public class AndroidDownloadManager internal constructor( private val context: Context, + private val mediaTypeRetriever: MediaTypeRetriever, private val destStorage: Storage, private val dirType: String, private val refreshRate: Hz, @@ -48,11 +54,13 @@ public class AndroidDownloadManager internal constructor( */ public constructor( context: Context, + mediaTypeRetriever: MediaTypeRetriever, destStorage: Storage = Storage.App, refreshRate: Hz = 60.0.hz, allowDownloadsOverMetered: Boolean = true ) : this( context = context, + mediaTypeRetriever = mediaTypeRetriever, destStorage = destStorage, dirType = Environment.DIRECTORY_DOWNLOADS, refreshRate = refreshRate, @@ -231,17 +239,13 @@ public class AndroidDownloadManager internal constructor( SystemDownloadManager.STATUS_PAUSED -> {} SystemDownloadManager.STATUS_PENDING -> {} SystemDownloadManager.STATUS_SUCCESSFUL -> { - val destUri = Uri.parse(facade.localUri!!) - val destFile = File(destUri.path!!) - val newDest = File(destFile.parent, generateFileName(destFile.extension)) - if (destFile.renameTo(newDest)) { - listenersForId.forEach { - it.onDownloadCompleted(id, newDest, mediaType = null) - } - } else { - listenersForId.forEach { - it.onDownloadFailed(id, DownloadManager.Error.FileError()) - } + coroutineScope.launch { + prepareResult(Uri.parse(facade.localUri)!!) + .onSuccess { download -> + listenersForId.forEach { it.onDownloadCompleted(id, download) } + }.onFailure { error -> + listenersForId.forEach { it.onDownloadFailed(id, error) } + } } downloadManager.remove(facade.id) listeners.remove(id) @@ -255,6 +259,23 @@ public class AndroidDownloadManager internal constructor( } } + private suspend fun prepareResult(destUri: Uri): Try { + val destFile = File(destUri.path!!) + val mediaType = FileResource(destFile, mediaTypeRetriever).use { + it.mediaType().getOrElse { return Try.failure(DownloadManager.Error.FileError()) } + } + val newDest = File(destFile.parent, generateFileName(destFile.extension)) + return if (destFile.renameTo(newDest)) { + val download = DownloadManager.Download( + file = newDest, + mediaType = mediaType + ) + Try.success(download) + } else { + Try.failure(DownloadManager.Error.FileError()) + } + } + private fun mapErrorCode(code: Int): DownloadManager.Error = when (code) { 401, 403 -> diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadCursorFacade.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadCursorFacade.kt index 88abb4de22..9096032cba 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadCursorFacade.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/DownloadCursorFacade.kt @@ -41,6 +41,12 @@ internal class DownloadCursorFacade( .also { require(it != -1) } .let { cursor.getLong(it) } + val mediaType: String? = cursor + .getColumnIndex(DownloadManager.COLUMN_MEDIA_TYPE) + .also { require(it != -1) } + .takeUnless { cursor.isNull(it) } + ?.let { cursor.getString(it) } + val reason: Int? = cursor .getColumnIndex(DownloadManager.COLUMN_REASON) .also { require(it != -1) } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt index 3589b6f329..dd1c493f0f 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/foreground/ForegroundDownloadManager.kt @@ -78,8 +78,10 @@ public class ForegroundDownloadManager( forEachListener(id) { onDownloadCompleted( id, - file = destination, - mediaType = response.mediaType + DownloadManager.Download( + file = destination, + mediaType = response.mediaType + ) ) } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt index aa9038c0b0..d3f5741111 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/Readium.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Readium.kt @@ -69,6 +69,7 @@ class Readium(context: Context) { val downloadManager = AndroidDownloadManager( context = context, + mediaTypeRetriever = mediaTypeRetriever, destStorage = AndroidDownloadManager.Storage.App ) diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt index 63a637e080..54e2aca6f9 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt @@ -28,6 +28,7 @@ import org.readium.r2.shared.util.getOrElse import org.readium.r2.shared.util.mediatype.FormatRegistry import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.testapp.data.DownloadRepository +import org.readium.r2.testapp.data.model.Download import org.readium.r2.testapp.utils.extensions.copyToTempFile import org.readium.r2.testapp.utils.extensions.moveTo import org.readium.r2.testapp.utils.tryOrNull @@ -269,13 +270,12 @@ class OpdsPublicationRetriever( private inner class DownloadListener : DownloadManager.Listener { override fun onDownloadCompleted( requestId: DownloadManager.RequestId, - file: File, - mediaType: MediaType? + download: DownloadManager.Download ) { coroutineScope.launch { val coverUrl = downloadRepository.getCover(requestId.value) downloadRepository.remove(requestId.value) - listener.onSuccess(file, coverUrl) + listener.onSuccess(download.file, coverUrl) } } From 9e141727a6243fa6f988415f0ca64025cc043576 Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Fri, 15 Sep 2023 15:09:53 +0200 Subject: [PATCH 34/35] Remove notifications --- .../org/readium/r2/lcp/LcpPublicationRetriever.kt | 14 ++------------ readium/shared/src/main/AndroidManifest.xml | 6 +++++- .../r2/shared/util/downloads/DownloadManager.kt | 2 -- .../downloads/android/AndroidDownloadManager.kt | 12 +++--------- .../r2/testapp/domain/PublicationRetriever.kt | 5 ----- 5 files changed, 10 insertions(+), 29 deletions(-) diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index f9909aabba..eebbe697f2 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -75,15 +75,9 @@ public class LcpPublicationRetriever( */ public fun retrieve( license: LicenseDocument, - downloadTitle: String, - downloadDescription: String? = null, listener: Listener ): RequestId { - val requestId = fetchPublication( - license, - downloadTitle, - downloadDescription - ) + val requestId = fetchPublication(license) addListener(requestId, listener) return requestId } @@ -156,17 +150,13 @@ public class LcpPublicationRetriever( } private fun fetchPublication( - license: LicenseDocument, - downloadTitle: String, - downloadDescription: String? + license: LicenseDocument ): RequestId { val url = Url(license.publicationLink.url) val requestId = downloadManager.submit( request = DownloadManager.Request( url = url, - title = downloadTitle, - description = downloadDescription, headers = emptyMap() ), listener = downloadListener diff --git a/readium/shared/src/main/AndroidManifest.xml b/readium/shared/src/main/AndroidManifest.xml index eb475b4918..bd3ac19bfe 100644 --- a/readium/shared/src/main/AndroidManifest.xml +++ b/readium/shared/src/main/AndroidManifest.xml @@ -4,4 +4,8 @@ available in the top-level LICENSE file of the project. --> - + + + + + diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt index 9b00ed7b72..2db34a68e7 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/DownloadManager.kt @@ -25,8 +25,6 @@ public interface DownloadManager { public data class Request( val url: Url, - val title: String, - val description: String? = null, val headers: Map> = emptyMap() ) diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt index aa87bc0e13..dccaf3e31d 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt @@ -111,9 +111,7 @@ public class AndroidDownloadManager internal constructor( val androidRequest = createRequest( uri = request.url.toUri(), filename = generateFileName(extension = request.url.extension), - headers = request.headers, - title = request.title, - description = request.description + headers = request.headers ) val downloadId = downloadManager.enqueue(androidRequest) val requestId = DownloadManager.RequestId(downloadId.toString()) @@ -142,16 +140,12 @@ public class AndroidDownloadManager internal constructor( private fun createRequest( uri: Uri, filename: String, - headers: Map>, - title: String, - description: String? + headers: Map> ): SystemDownloadManager.Request = SystemDownloadManager.Request(uri) - .setNotificationVisibility(SystemDownloadManager.Request.VISIBILITY_VISIBLE) + .setNotificationVisibility(SystemDownloadManager.Request.VISIBILITY_HIDDEN) .setDestination(filename) .setHeaders(headers) - .setTitle(title) - .apply { description?.let { setDescription(it) } } .setAllowedOverMetered(allowDownloadsOverMetered) private fun SystemDownloadManager.Request.setHeaders( diff --git a/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt b/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt index 54e2aca6f9..54cd1eab98 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/domain/PublicationRetriever.kt @@ -28,7 +28,6 @@ import org.readium.r2.shared.util.getOrElse import org.readium.r2.shared.util.mediatype.FormatRegistry import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.testapp.data.DownloadRepository -import org.readium.r2.testapp.data.model.Download import org.readium.r2.testapp.utils.extensions.copyToTempFile import org.readium.r2.testapp.utils.extensions.moveTo import org.readium.r2.testapp.utils.tryOrNull @@ -241,8 +240,6 @@ class OpdsPublicationRetriever( val requestId = downloadManager.submit( request = DownloadManager.Request( publicationUrl, - title = publication.metadata.title ?: "Untitled publication", - description = "Downloading", headers = emptyMap() ), listener = downloadListener @@ -359,8 +356,6 @@ class LcpPublicationRetriever( val requestId = lcpPublicationRetriever.retrieve( license, - "Fulfilling Lcp publication", - null, lcpRetrieverListener ) From 52fe84a92627bf1c209209f8309fc83ce8dd9a0a Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Fri, 15 Sep 2023 15:26:00 +0200 Subject: [PATCH 35/35] Move permission --- readium/shared/src/main/AndroidManifest.xml | 6 +----- .../shared/util/downloads/android/AndroidDownloadManager.kt | 4 ++++ test-app/src/main/AndroidManifest.xml | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/readium/shared/src/main/AndroidManifest.xml b/readium/shared/src/main/AndroidManifest.xml index bd3ac19bfe..eb475b4918 100644 --- a/readium/shared/src/main/AndroidManifest.xml +++ b/readium/shared/src/main/AndroidManifest.xml @@ -4,8 +4,4 @@ available in the top-level LICENSE file of the project. --> - - - - - + diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt index dccaf3e31d..d50dfa33e7 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/downloads/android/AndroidDownloadManager.kt @@ -45,6 +45,10 @@ public class AndroidDownloadManager internal constructor( /** * Creates a new instance of [AndroidDownloadManager]. * + * Because of discrepancies across different devices, default notifications are disabled. + * If you want to use this [DownloadManager], you will need permission + * android.permission.DOWNLOAD_WITHOUT_NOTIFICATION. + * * @param context Android context * @param destStorage Location where downloads should be stored * @param refreshRate Frequency with which download status will be checked and diff --git a/test-app/src/main/AndroidManifest.xml b/test-app/src/main/AndroidManifest.xml index 5865b76456..157f4461e2 100644 --- a/test-app/src/main/AndroidManifest.xml +++ b/test-app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ +