-
Notifications
You must be signed in to change notification settings - Fork 109
Add a download manager #381
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+2,507
−711
Merged
Changes from all commits
Commits
Show all changes
39 commits
Select commit
Hold shift + click to select a range
8b4401c
Add DownloadManager module
qnga 4cf2f22
Fix multiple DownloadManagers
qnga 7feeb87
Move import stuff to BookImporter
qnga be4ad76
Refactor importation code
qnga 1dd3abb
Merge branch 'v3' into feature/download-manager
qnga a1ba25c
Make it work
qnga 9b6d1fc
Small changes
qnga 1dc6836
Reorganization
qnga d6034aa
Introduce DownloadRepository
qnga 8a5f1cf
Centralize import feedback
qnga 687aeec
Various changes
qnga a4a879b
Various changes
qnga 09848c9
Merge branch 'v3' into feature/download-manager
qnga e715b5d
Small changess
qnga 490849c
More changes
qnga ab5ef5b
A few more changes
qnga 57413ec
Move to shared
qnga 265a4ea
Add allowDownloadsOverMetered
qnga 5e15894
Refactor downlaod repositories
qnga fd9af69
Move downloadManagerProvider to the constructor level in LcpService
qnga 70f6dd1
Refactor download manager
qnga d93b2bc
Various changes
qnga d559407
Small fix
qnga 93dbc65
Merge branch 'v3' of github.com:readium/kotlin-toolkit into feature/d…
qnga a143413
Fix ForegroundDownloadManager
qnga 7fd2373
Refine cancellation
qnga d2e6c60
Add documentation
qnga eaa3750
Various changes
qnga 840455c
Improve concurrency in LcpDownloadsRepository
qnga 6055779
Documentation and formatting
mickael-menu 16110a4
Refactor the test app
mickael-menu a0cfa4c
Adjust LCP downloads
mickael-menu 97211c7
Fix `ForegroundDownloadManager` and progress report
mickael-menu a86e0bd
Fix PublicationRetriever listeners
mickael-menu fab5af0
Small fixes
qnga e395265
Introduce Download data class
qnga 9e14172
Remove notifications
qnga 52fe84a
Move permission
qnga d76998c
Merge branch 'v3' into feature/download-manager
mickael-menu File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
99 changes: 99 additions & 0 deletions
99
readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} |
272 changes: 272 additions & 0 deletions
272
readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.