Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
8b4401c
Add DownloadManager module
qnga Aug 16, 2023
4cf2f22
Fix multiple DownloadManagers
qnga Aug 18, 2023
7feeb87
Move import stuff to BookImporter
qnga Aug 18, 2023
be4ad76
Refactor importation code
qnga Aug 18, 2023
1dd3abb
Merge branch 'v3' into feature/download-manager
qnga Aug 23, 2023
a1ba25c
Make it work
qnga Aug 24, 2023
9b6d1fc
Small changes
qnga Aug 25, 2023
1dc6836
Reorganization
qnga Aug 25, 2023
d6034aa
Introduce DownloadRepository
qnga Aug 25, 2023
8a5f1cf
Centralize import feedback
qnga Aug 25, 2023
687aeec
Various changes
qnga Aug 25, 2023
a4a879b
Various changes
qnga Aug 25, 2023
09848c9
Merge branch 'v3' into feature/download-manager
qnga Aug 25, 2023
e715b5d
Small changess
qnga Aug 31, 2023
490849c
More changes
qnga Sep 1, 2023
ab5ef5b
A few more changes
qnga Sep 1, 2023
57413ec
Move to shared
qnga Sep 1, 2023
265a4ea
Add allowDownloadsOverMetered
qnga Sep 1, 2023
5e15894
Refactor downlaod repositories
qnga Sep 2, 2023
fd9af69
Move downloadManagerProvider to the constructor level in LcpService
qnga Sep 2, 2023
70f6dd1
Refactor download manager
qnga Sep 4, 2023
d93b2bc
Various changes
qnga Sep 4, 2023
d559407
Small fix
qnga Sep 4, 2023
93dbc65
Merge branch 'v3' of github.com:readium/kotlin-toolkit into feature/d…
qnga Sep 4, 2023
a143413
Fix ForegroundDownloadManager
qnga Sep 10, 2023
7fd2373
Refine cancellation
qnga Sep 10, 2023
d2e6c60
Add documentation
qnga Sep 10, 2023
eaa3750
Various changes
qnga Sep 14, 2023
840455c
Improve concurrency in LcpDownloadsRepository
qnga Sep 14, 2023
6055779
Documentation and formatting
mickael-menu Sep 14, 2023
16110a4
Refactor the test app
mickael-menu Sep 15, 2023
a0cfa4c
Adjust LCP downloads
mickael-menu Sep 15, 2023
97211c7
Fix `ForegroundDownloadManager` and progress report
mickael-menu Sep 15, 2023
a86e0bd
Fix PublicationRetriever listeners
mickael-menu Sep 15, 2023
fab5af0
Small fixes
qnga Sep 15, 2023
e395265
Introduce Download data class
qnga Sep 15, 2023
9e14172
Remove notifications
qnga Sep 15, 2023
52fe84a
Move permission
qnga Sep 15, 2023
d76998c
Merge branch 'v3' into feature/download-manager
mickael-menu Sep 15, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Copyright 2023 Readium Foundation. All rights reserved.
* Use of this source code is governed by the BSD-style license
* available in the top-level LICENSE file of the project.
*/

package org.readium.r2.lcp

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

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

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

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

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

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

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

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

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

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

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

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

private fun <T> Iterator<T>.toList(): List<T> =
LinkedList<T>().apply {
while (hasNext())
this += next()
}.toMutableList()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
/*
* 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 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
import org.readium.r2.shared.util.Url
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

/**
* Utility to acquire a protected publication from an LCP License Document.
*/
public class LcpPublicationRetriever(
context: Context,
private val downloadManager: DownloadManager,
private val mediaTypeRetriever: MediaTypeRetriever
) {

@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,
listener: Listener
): RequestId {
val requestId = fetchPublication(license)
addListener(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 create a new instance after the app restarted.
*/
public fun register(
requestId: RequestId,
listener: Listener
) {
addListener(
requestId,
listener,
onFirstListenerAdded = {
downloadManager.register(
DownloadManager.RequestId(requestId.value),
downloadListener
)
}
)
}

/**
* 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<RequestId, MutableList<Listener>> =
mutableMapOf()

private fun addListener(
requestId: RequestId,
listener: Listener,
onFirstListenerAdded: () -> Unit = {}
) {
listeners
.getOrPut(requestId) {
onFirstListenerAdded()
mutableListOf()
}
.add(listener)
}

private fun fetchPublication(
license: LicenseDocument
): RequestId {
val url = Url(license.publicationLink.url)

val requestId = downloadManager.submit(
request = DownloadManager.Request(
url = url,
headers = emptyMap()
),
listener = downloadListener
)

downloadsRepository.addDownload(requestId.value, license.json)
return RequestId(requestId.value)
}

private inner class DownloadListener : DownloadManager.Listener {

override fun onDownloadCompleted(
requestId: DownloadManager.RequestId,
download: DownloadManager.Download
) {
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 mt = mediaTypeRetriever.retrieve(
mediaTypes = listOfNotNull(
license.publicationLink.type,
download.mediaType.toString()
)
) ?: MediaType.EPUB

try {
// Saves the License Document into the downloaded publication
val container = createLicenseContainer(download.file, mt)
container.write(license)
} catch (e: Exception) {
tryOrLog { download.file.delete() }
listenersForId.forEach {
it.onAcquisitionFailed(lcpRequestId, LcpException.wrap(e))
}
return@launch
}

val acquiredPublication = LcpService.AcquiredPublication(
localFile = download.file,
suggestedFilename = "${license.id}.${formatRegistry.fileExtension(mt) ?: "epub"}",
mediaType = mt,
licenseDocument = license
)

listenersForId.forEach {
it.onAcquisitionCompleted(lcpRequestId, acquiredPublication)
}
listeners.remove(lcpRequestId)
}
}

override fun onDownloadProgressed(
requestId: DownloadManager.RequestId,
downloaded: Long,
expected: Long?
) {
val lcpRequestId = RequestId(requestId.value)
val listenersForId = checkNotNull(listeners[lcpRequestId])

listenersForId.forEach {
it.onAcquisitionProgressed(
lcpRequestId,
downloaded,
expected
)
}
}

override fun onDownloadFailed(
requestId: DownloadManager.RequestId,
error: DownloadManager.Error
) {
val lcpRequestId = RequestId(requestId.value)
val listenersForId = checkNotNull(listeners[lcpRequestId])

downloadsRepository.removeDownload(requestId.value)

listenersForId.forEach {
it.onAcquisitionFailed(
lcpRequestId,
LcpException.Network(Exception(error.message))
)
}

listeners.remove(lcpRequestId)
}

override fun onDownloadCancelled(requestId: DownloadManager.RequestId) {
val lcpRequestId = RequestId(requestId.value)
val listenersForId = checkNotNull(listeners[lcpRequestId])
listenersForId.forEach {
it.onAcquisitionCancelled(lcpRequestId)
}
listeners.remove(lcpRequestId)
}
}
}
Loading