diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/content/ContentResource.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/content/ContentResource.kt index eb78f1091d..15064a8195 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/content/ContentResource.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/content/ContentResource.kt @@ -13,17 +13,21 @@ import android.net.Uri import android.provider.MediaStore import java.io.FileNotFoundException import java.io.IOException -import java.io.InputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.readium.r2.shared.InternalReadiumApi -import org.readium.r2.shared.extensions.* +import org.readium.r2.shared.extensions.coerceFirstNonNegative +import org.readium.r2.shared.extensions.queryProjection +import org.readium.r2.shared.extensions.readFully +import org.readium.r2.shared.extensions.requireLengthFitInt import org.readium.r2.shared.util.AbsoluteUrl import org.readium.r2.shared.util.DebugError import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.data.ReadError import org.readium.r2.shared.util.flatMap +import org.readium.r2.shared.util.getOrElse +import org.readium.r2.shared.util.io.CountingInputStream import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.shared.util.resource.Resource import org.readium.r2.shared.util.resource.filename @@ -45,9 +49,12 @@ public class ContentResource( private lateinit var _properties: Try + private var stream: CountingInputStream? = null + override val sourceUrl: AbsoluteUrl? = uri.toUrl() as? AbsoluteUrl override fun close() { + stream?.close() } override suspend fun properties(): Try { @@ -95,22 +102,12 @@ public class ContentResource( } private suspend fun readFully(): Try = - withStream { it.readFully() } + withStream(fromIndex = 0) { it.readFully() } private suspend fun readRange(range: LongRange): Try = - withStream { + withStream(fromIndex = range.first) { withContext(Dispatchers.IO) { - var skipped: Long = 0 - - while (skipped != range.first) { - skipped += it.skip(range.first - skipped) - if (skipped == 0L) { - throw IOException("Could not skip InputStream to read ranges from $uri.") - } - } - - val length = range.last - range.first + 1 - it.read(length) + it.readRange(range) } } @@ -134,16 +131,37 @@ public class ContentResource( return _length } - private suspend fun withStream(block: suspend (InputStream) -> T): Try { + private suspend fun withStream( + fromIndex: Long, + block: suspend (CountingInputStream) -> T + ): Try { + val stream = stream(fromIndex) + .getOrElse { return Try.failure(it) } + return Try.catching { - val stream = contentResolver.openInputStream(uri) + block(stream) + } + } + + private fun stream(fromIndex: Long): Try { + // Reuse the current stream if it didn't exceed the requested index. + stream + ?.takeIf { it.count <= fromIndex } + ?.let { return Try.success(it) } + + stream?.close() + + val contentStream = + contentResolver.openInputStream(uri) ?: return Try.failure( ReadError.Access( ContentResolverError.NotAvailable() ) ) - stream.use { block(stream) } - } + + stream = CountingInputStream(contentStream) + + return Try.success(stream!!) } private inline fun Try.Companion.catching(closure: () -> T): Try = diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/io/CountingInputStream.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/io/CountingInputStream.kt index 7712eabd4e..284ada89fa 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/io/CountingInputStream.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/io/CountingInputStream.kt @@ -75,7 +75,16 @@ public class CountingInputStream( return ByteArray(0) } - skip(range.first - count) + val toSkip = range.first - count + var skipped: Long = 0 + + while (skipped != toSkip) { + skipped += skip(toSkip - skipped) + if (skipped == 0L) { + throw IOException("Could not skip InputStream to read ranges.") + } + } + val length = range.last - range.first + 1 return read(length) } diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/zip/StreamingZipArchiveProvider.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/zip/StreamingZipArchiveProvider.kt index b390d50665..9a002af05d 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/zip/StreamingZipArchiveProvider.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/zip/StreamingZipArchiveProvider.kt @@ -79,7 +79,13 @@ internal class StreamingZipArchiveProvider { val datasourceChannel = ReadableChannelAdapter(readable, wrapError) val channel = wrapBaseChannel(datasourceChannel) val zipFile = ZipFile(channel, true) - StreamingZipContainer(zipFile, sourceUrl) + val sourceScheme = (readable as? Resource)?.sourceUrl?.scheme + val cacheEntryMaxSize = + when { + sourceScheme?.isContent ?: false -> 5242880 + else -> 0 + } + StreamingZipContainer(zipFile, sourceUrl, cacheEntryMaxSize) } internal suspend fun openFile(file: File): Container = withContext(Dispatchers.IO) { diff --git a/readium/shared/src/main/java/org/readium/r2/shared/util/zip/StreamingZipContainer.kt b/readium/shared/src/main/java/org/readium/r2/shared/util/zip/StreamingZipContainer.kt index a93262b63d..7dfde7d00a 100644 --- a/readium/shared/src/main/java/org/readium/r2/shared/util/zip/StreamingZipContainer.kt +++ b/readium/shared/src/main/java/org/readium/r2/shared/util/zip/StreamingZipContainer.kt @@ -39,7 +39,8 @@ import org.readium.r2.shared.util.zip.compress.archivers.zip.ZipFile internal class StreamingZipContainer( private val zipFile: ZipFile, - override val sourceUrl: AbsoluteUrl? + override val sourceUrl: AbsoluteUrl?, + private val cacheEntryMaxSize: Int = 0 ) : Container { private inner class Entry( @@ -47,6 +48,9 @@ internal class StreamingZipContainer( private val entry: ZipArchiveEntry ) : Resource { + private var cache: ByteArray? = + null + override val sourceUrl: AbsoluteUrl? get() = null override suspend fun properties(): ReadTry = @@ -102,8 +106,25 @@ internal class StreamingZipContainer( it.readFully() } - private fun readRange(range: LongRange): ByteArray = - stream(range.first).readRange(range) + private suspend fun readRange(range: LongRange): ByteArray = + when { + cache != null -> { + // If the entry is cached, its size fit into an Int. + val rangeSize = (range.last - range.first + 1).toInt() + cache!!.copyInto( + ByteArray(rangeSize), + startIndex = range.first.toInt(), + endIndex = range.last.toInt() + 1 + ) + } + + entry.size in 0 until cacheEntryMaxSize -> { + cache = readFully() + readRange(range) + } + else -> + stream(range.first).readRange(range) + } /** * Reading an entry in chunks (e.g. from the HTTP server) can be really slow if the entry 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 acf1889776..e9a7261e83 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 @@ -63,6 +63,14 @@ class Bookshelf( } } + fun importPublicationFromHttp( + url: AbsoluteUrl + ) { + coroutineScope.launch { + addBookFeedback(publicationRetriever.retrieveFromHttp(url)) + } + } + fun importPublicationFromOpds( publication: Publication ) { 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 09aa6fe553..68cb158783 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 @@ -9,6 +9,7 @@ package org.readium.r2.testapp.domain import org.readium.r2.lcp.LcpError import org.readium.r2.shared.util.DebugError import org.readium.r2.shared.util.Error +import org.readium.r2.shared.util.content.ContentResolverError import org.readium.r2.shared.util.file.FileSystemError import org.readium.r2.shared.util.http.HttpError import org.readium.r2.testapp.R @@ -36,6 +37,10 @@ sealed class ImportError( override val cause: FileSystemError ) : ImportError(cause) + class ContentResolver( + override val cause: ContentResolverError + ) : ImportError(cause) + class Download( override val cause: HttpError ) : ImportError(cause) @@ -57,6 +62,7 @@ sealed class ImportError( is Opds -> UserError(R.string.import_publication_no_acquisition, cause = this) is Publication -> cause.toUserError() is FileSystem -> cause.toUserError() + is ContentResolver -> cause.toUserError() is InconsistentState -> UserError( R.string.import_publication_inconsistent_state, cause = this 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 f92f973db4..bf67be424f 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 @@ -41,10 +41,10 @@ import timber.log.Timber class PublicationRetriever( context: Context, private val assetRetriever: AssetRetriever, - httpClient: HttpClient, + private val httpClient: HttpClient, lcpService: LcpService?, private val bookshelfDir: File, - tempDir: File + private val tempDir: File ) { data class Result( val publication: File, @@ -109,6 +109,46 @@ class PublicationRetriever( ) } + suspend fun retrieveFromHttp( + url: AbsoluteUrl + ): Try { + val request = HttpRequest( + url, + headers = emptyMap() + ) + + val tempFile = when (val result = httpClient.stream(request)) { + is Try.Failure -> + return Try.failure(ImportError.Download(result.value)) + is Try.Success -> { + result.value.body + .copyToNewFile(tempDir) + .getOrElse { return Try.failure(ImportError.FileSystem(it)) } + } + } + + val localResult = localPublicationRetriever + .retrieve(tempFile) + .getOrElse { + tryOrLog { tempFile.delete() } + return Try.failure(it) + } + + val finalResult = moveToBookshelfDir( + localResult.tempFile, + localResult.format, + localResult.coverUrl + ) + .getOrElse { + tryOrLog { localResult.tempFile.delete() } + return Try.failure(it) + } + + return Try.success( + Result(finalResult.publication, finalResult.format, finalResult.coverUrl) + ) + } + private suspend fun moveToBookshelfDir( tempFile: File, format: Format?, @@ -167,7 +207,7 @@ private class LocalPublicationRetriever( ): Try { val tempFile = uri.copyToTempFile(context, tempDir) .getOrElse { - return Try.failure(ImportError.FileSystem(FileSystemError.IO(it))) + return Try.failure(ImportError.ContentResolver(it)) } return retrieveFromStorage(tempFile, coverUrl = null) .onFailure { tryOrLog { tempFile.delete() } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/utils/ContentResolverUtil.kt b/test-app/src/main/java/org/readium/r2/testapp/utils/ContentResolverUtil.kt deleted file mode 100644 index d28327a409..0000000000 --- a/test-app/src/main/java/org/readium/r2/testapp/utils/ContentResolverUtil.kt +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Module: r2-testapp-kotlin - * Developers: Aferdita Muriqi, Clément Baumann - * - * Copyright (c) 2018. European Digital Reading Lab. All rights reserved. - * Licensed to the Readium Foundation under one or more contributor license agreements. - * Use of this source code is governed by a BSD-style license which is detailed in the - * LICENSE file present in the project repository where this source code is maintained. - */ - -package org.readium.r2.testapp.utils - -import android.content.ContentUris -import android.content.Context -import android.net.Uri -import android.provider.DocumentsContract -import android.provider.MediaStore -import android.text.TextUtils -import java.io.File -import java.io.FileNotFoundException -import java.io.InputStream -import java.net.URL -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -object ContentResolverUtil { - - suspend fun getContentInputStream(context: Context, uri: Uri, publicationFile: File) { - withContext(Dispatchers.IO) { - try { - val path = getRealPath(context, uri) - if (path != null) { - File(path).copyTo(publicationFile) - } else { - val input = URL(uri.toString()).openStream() - input.toFile(publicationFile) - } - } catch (e: Exception) { - val input = getInputStream(context, uri) - input?.let { - input.toFile(publicationFile) - } - } - } - } - - private fun getInputStream(context: Context, uri: Uri): InputStream? { - return try { - context.contentResolver.openInputStream(uri) - } catch (e: FileNotFoundException) { - e.printStackTrace() - null - } - } - - private fun getRealPath(context: Context, uri: Uri): String? { - // DocumentProvider - if (DocumentsContract.isDocumentUri(context, uri)) { - // ExternalStorageProvider - if (isExternalStorageDocument(uri)) { - val docId = DocumentsContract.getDocumentId(uri) - val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - val type = split[0] - if ("primary".equals(type, ignoreCase = true)) { - return context.getExternalFilesDir(null).toString() + "/" + split[1] - } - // TODO handle non-primary volumes - } else if (isDownloadsDocument(uri)) { - val id = DocumentsContract.getDocumentId(uri) - if (!TextUtils.isEmpty(id)) { - if (id.startsWith("raw:")) { - return id.replaceFirst("raw:".toRegex(), "") - } - return try { - val contentUri = ContentUris.withAppendedId( - Uri.parse("content://downloads/public_downloads"), - java.lang.Long.valueOf(id) - ) - getDataColumn(context, contentUri, null, null) - } catch (e: NumberFormatException) { - null - } - } - } else if (isMediaDocument(uri)) { - val docId = DocumentsContract.getDocumentId(uri) - val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - val type = split[0] - - var contentUri: Uri? = null - when (type) { - "image" -> contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI - "video" -> contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI - "audio" -> contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI - } - - val selection = "_id=?" - val selectionArgs = arrayOf(split[1]) - - return getDataColumn(context, contentUri, selection, selectionArgs) - } - } else if ("content".equals(uri.scheme!!, ignoreCase = true)) { - // Return the remote address - return getDataColumn(context, uri, null, null) - } else if ("file".equals(uri.scheme!!, ignoreCase = true)) { - return uri.path - } - - return null - } - - /** - * Get the value of the data column for this Uri. This is useful for - * MediaStore Uris, and other file-based ContentProviders. - * - * @param context The context. - * @param uri The Uri to query. - * @param selection (Optional) Filter used in the query. - * @param selectionArgs (Optional) Selection arguments used in the query. - * @return The value of the _data column, which is typically a file path. - */ - private fun getDataColumn( - context: Context, - uri: Uri?, - selection: String?, - selectionArgs: Array? - ): String? { - val column = "_data" - val projection = arrayOf(column) - context.contentResolver.query(uri!!, projection, selection, selectionArgs, null).use { cursor -> - cursor?.let { - if (cursor.moveToFirst()) { - val index = cursor.getColumnIndexOrThrow(column) - return cursor.getString(index) - } - } - } - return null - } - - /** - * @param uri The Uri to check. - * @return Whether the Uri authority is ExternalStorageProvider. - */ - private fun isExternalStorageDocument(uri: Uri): Boolean { - return "com.android.externalstorage.documents" == uri.authority - } - - /** - * @param uri The Uri to check. - * @return Whether the Uri authority is DownloadsProvider. - */ - private fun isDownloadsDocument(uri: Uri): Boolean { - return "com.android.providers.downloads.documents" == uri.authority - } - - /** - * @param uri The Uri to check. - * @return Whether the Uri authority is MediaProvider. - */ - private fun isMediaDocument(uri: Uri): Boolean { - return "com.android.providers.media.documents" == uri.authority - } -} diff --git a/test-app/src/main/java/org/readium/r2/testapp/utils/ImportActivity.kt b/test-app/src/main/java/org/readium/r2/testapp/utils/ImportActivity.kt index 6b327930eb..f66b292c28 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/utils/ImportActivity.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/utils/ImportActivity.kt @@ -11,10 +11,12 @@ package org.readium.r2.testapp.utils import android.app.Activity +import android.content.ContentResolver import android.content.Intent import android.net.Uri import android.os.Bundle import androidx.core.content.IntentCompat +import org.readium.r2.shared.util.toAbsoluteUrl import org.readium.r2.testapp.Application import org.readium.r2.testapp.MainActivity import timber.log.Timber @@ -42,7 +44,19 @@ class ImportActivity : Activity() { } val app = application as Application - app.bookshelf.importPublicationFromStorage(uri) + when { + uri.scheme == ContentResolver.SCHEME_CONTENT -> { + app.bookshelf.importPublicationFromStorage(uri) + } + else -> { + val url = uri.toAbsoluteUrl() + ?: run { + Timber.d("Uri is not an Url.") + return + } + app.bookshelf.importPublicationFromHttp(url) + } + } } private fun uriFromIntent(intent: Intent): Uri? = diff --git a/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/Uri.kt b/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/Uri.kt index b9636881cf..8601d2abca 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/Uri.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/Uri.kt @@ -11,21 +11,33 @@ import android.content.Context import android.net.Uri import android.provider.MediaStore import java.io.File -import java.util.* +import java.io.FileNotFoundException +import java.io.IOException +import java.util.UUID import org.readium.r2.shared.util.Try -import org.readium.r2.testapp.utils.ContentResolverUtil +import org.readium.r2.shared.util.content.ContentResolverError +import org.readium.r2.testapp.utils.toFile import org.readium.r2.testapp.utils.tryOrNull -suspend fun Uri.copyToTempFile(context: Context, dir: File): Try = +suspend fun Uri.copyToTempFile(context: Context, dir: File): Try { + val filename = UUID.randomUUID().toString() + val file = File(dir, "$filename.${extension(context)}") + + val inputStream = try { + context.contentResolver.openInputStream(this) + } catch (e: FileNotFoundException) { + return Try.failure(ContentResolverError.FileNotFound(e)) + } ?: return Try.failure(ContentResolverError.NotAvailable()) + try { - val filename = UUID.randomUUID().toString() - val file = File(dir, "$filename.${extension(context)}") - ContentResolverUtil.getContentInputStream(context, this, file) - Try.success(file) - } catch (e: Exception) { - Try.failure(e) + inputStream.toFile(file) + } catch (e: IOException) { + return Try.failure(ContentResolverError.IO(e)) } + return Try.success(file) +} + private fun Uri.extension(context: Context): String? { if (scheme == ContentResolver.SCHEME_CONTENT) { tryOrNull {