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) + } +}