From 51dab2d220d322063d59e22944718097228fe90b Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Wed, 14 Dec 2022 09:21:42 +0100 Subject: [PATCH 1/2] Mutualize publication import code and add support for LCPL in OPDS catalogs --- .../org/readium/r2/testapp/Application.kt | 13 +- .../org/readium/r2/testapp/MainActivity.kt | 2 +- .../org/readium/r2/testapp/MediaService.kt | 9 +- .../r2/testapp/bookshelf/BookRepository.kt | 191 ++++++++++++++++-- .../r2/testapp/bookshelf/BookshelfFragment.kt | 26 +-- .../testapp/bookshelf/BookshelfViewModel.kt | 186 ++++------------- .../r2/testapp/catalogs/CatalogViewModel.kt | 100 +++------ .../readium/r2/testapp/opds/OPDSDownloader.kt | 112 ---------- .../r2/testapp/utils/extensions/File.kt | 68 +++++++ .../r2/testapp/utils/extensions/Uri.kt | 19 +- test-app/src/main/res/values/strings.xml | 2 +- 11 files changed, 347 insertions(+), 381 deletions(-) delete mode 100644 test-app/src/main/java/org/readium/r2/testapp/opds/OPDSDownloader.kt diff --git a/test-app/src/main/java/org/readium/r2/testapp/Application.kt b/test-app/src/main/java/org/readium/r2/testapp/Application.kt index 1a2cbc4559..b037837565 100755 --- a/test-app/src/main/java/org/readium/r2/testapp/Application.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Application.kt @@ -15,17 +15,14 @@ import com.google.android.material.color.DynamicColors import java.io.File import java.util.* import kotlinx.coroutines.* -import org.readium.r2.testapp.BuildConfig.DEBUG import org.readium.r2.testapp.bookshelf.BookRepository +import org.readium.r2.testapp.BuildConfig.DEBUG import org.readium.r2.testapp.db.BookDatabase import org.readium.r2.testapp.reader.ReaderRepository import timber.log.Timber class Application : android.app.Application() { - val Context.navigatorPreferences: DataStore - by preferencesDataStore(name = "navigator-preferences") - lateinit var readium: Readium private set @@ -40,6 +37,9 @@ class Application : android.app.Application() { private val coroutineScope: CoroutineScope = MainScope() + private val Context.navigatorPreferences: DataStore + by preferencesDataStore(name = "navigator-preferences") + private val mediaServiceBinder: CompletableDeferred = CompletableDeferred() @@ -85,7 +85,7 @@ class Application : android.app.Application() { */ bookRepository = BookDatabase.getDatabase(this).booksDao() - .let { BookRepository(it) } + .let { BookRepository(this, it, storageDir, readium.lcpService, readium.streamer) } readerRepository = coroutineScope.async { @@ -112,6 +112,3 @@ class Application : android.app.Application() { ) } } - -val Context.resolver: ContentResolver - get() = applicationContext.contentResolver diff --git a/test-app/src/main/java/org/readium/r2/testapp/MainActivity.kt b/test-app/src/main/java/org/readium/r2/testapp/MainActivity.kt index 1ff23abb7d..4f01d3c307 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/MainActivity.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/MainActivity.kt @@ -27,7 +27,7 @@ class MainActivity : AppCompatActivity() { setContentView(R.layout.activity_main) intent.data?.let { - viewModel.importPublicationFromUri(it) + viewModel.addPublicationFromUri(it) } val navView: BottomNavigationView = findViewById(R.id.nav_view) diff --git a/test-app/src/main/java/org/readium/r2/testapp/MediaService.kt b/test-app/src/main/java/org/readium/r2/testapp/MediaService.kt index 77937e72d7..ba121a94c1 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/MediaService.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/MediaService.kt @@ -21,8 +21,6 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.sample import org.readium.navigator.media2.ExperimentalMedia2 import org.readium.navigator.media2.MediaNavigator -import org.readium.r2.testapp.bookshelf.BookRepository -import org.readium.r2.testapp.db.BookDatabase import org.readium.r2.testapp.reader.ReaderActivityContract import org.readium.r2.testapp.utils.LifecycleMediaSessionService import timber.log.Timber @@ -35,9 +33,8 @@ class MediaService : LifecycleMediaSessionService() { */ inner class Binder : android.os.Binder() { - private val books by lazy { - BookRepository(BookDatabase.getDatabase(this@MediaService).booksDao()) - } + private val app: Application + get() = application as Application private var saveLocationJob: Job? = null @@ -69,7 +66,7 @@ class MediaService : LifecycleMediaSessionService() { */ saveLocationJob = navigator.currentLocator .sample(3000) - .onEach { locator -> books.saveProgression(locator, bookId) } + .onEach { locator -> app.bookRepository.saveProgression(locator, bookId) } .launchIn(lifecycleScope) } diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt index e9cebb458a..b9c64e07f6 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookRepository.kt @@ -6,41 +6,55 @@ package org.readium.r2.testapp.bookshelf +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri import androidx.annotation.ColorInt import androidx.lifecycle.LiveData +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.util.* +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext import org.joda.time.DateTime +import org.readium.r2.lcp.LcpService +import org.readium.r2.shared.extensions.mediaType +import org.readium.r2.shared.extensions.tryOrNull import org.readium.r2.shared.publication.Locator import org.readium.r2.shared.publication.Publication +import org.readium.r2.shared.publication.asset.FileAsset import org.readium.r2.shared.publication.indexOfFirstWithHref +import org.readium.r2.shared.publication.services.cover +import org.readium.r2.shared.util.Try +import org.readium.r2.shared.util.flatMap import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.streamer.Streamer import org.readium.r2.testapp.db.BooksDao import org.readium.r2.testapp.domain.model.Book import org.readium.r2.testapp.domain.model.Bookmark import org.readium.r2.testapp.domain.model.Highlight import org.readium.r2.testapp.utils.extensions.authorName - -class BookRepository(private val booksDao: BooksDao) { +import org.readium.r2.testapp.utils.extensions.copyToTempFile +import org.readium.r2.testapp.utils.extensions.moveTo +import timber.log.Timber + +class BookRepository( + private val context: Context, + private val booksDao: BooksDao, + private val storageDir: File, + private val lcpService: Try, + private val streamer: Streamer +) { fun books(): LiveData> = booksDao.getAllBooks() suspend fun get(id: Long) = booksDao.get(id) - suspend fun insertBook(href: String, mediaType: MediaType, publication: Publication): Long { - val book = Book( - creation = DateTime().toDate().time, - title = publication.metadata.title, - author = publication.metadata.authorName, - href = href, - identifier = publication.metadata.identifier ?: "", - type = mediaType.toString(), - progression = "{}" - ) - return booksDao.insertBook(book) - } - - suspend fun deleteBook(id: Long) = booksDao.deleteBook(id) - suspend fun saveProgression(locator: Locator, bookId: Long) = booksDao.saveProgression(locator.toJSON().toString(), bookId) @@ -90,4 +104,147 @@ class BookRepository(private val booksDao: BooksDao) { suspend fun updateHighlightStyle(id: Long, style: Highlight.Style, @ColorInt tint: Int) { booksDao.updateHighlightStyle(id, style, tint) } + + private suspend fun insertBookIntoDatabase( + href: String, + mediaType: MediaType, + publication: Publication + ): Long { + val book = Book( + creation = DateTime().toDate().time, + title = publication.metadata.title, + author = publication.metadata.authorName, + href = href, + identifier = publication.metadata.identifier ?: "", + type = mediaType.toString(), + progression = "{}" + ) + return booksDao.insertBook(book) + } + + private suspend fun deleteBookFromDatabase(id: Long) = + booksDao.deleteBook(id) + + sealed class ImportException( + message: String? = null, + cause: Throwable? = null + ) : Exception(message, cause) { + + class LcpAcquisitionFailed( + cause: Throwable + ) : ImportException(cause = cause) + + object IOException : ImportException() + + object ImportDatabaseFailed : ImportException() + + class UnableToOpenPublication( + val exception: Publication.OpeningException + ) : ImportException(cause = exception) + } + + suspend fun addBook( + contentUri: Uri + ): Try = + contentUri.copyToTempFile(context, storageDir) + .mapFailure { ImportException.IOException } + .map { addBook(it) } + + suspend fun addBook( + tempFile: File, + coverUrl: String? = null + ): Try { + val sourceMediaType = tempFile.mediaType() + val publicationAsset: FileAsset = + if (sourceMediaType != MediaType.LCP_LICENSE_DOCUMENT) + FileAsset(tempFile, sourceMediaType) + else { + lcpService + .flatMap { it.acquirePublication(tempFile) } + .fold( + { + val mediaType = + MediaType.of(fileExtension = File(it.suggestedFilename).extension) + FileAsset(it.localFile, mediaType) + }, + { + tryOrNull { tempFile.delete() } + return Try.failure(ImportException.LcpAcquisitionFailed(it)) + } + ) + } + + val mediaType = publicationAsset.mediaType() + val fileName = "${UUID.randomUUID()}.${mediaType.fileExtension}" + val libraryAsset = FileAsset(File(storageDir, fileName), mediaType) + + try { + publicationAsset.file.moveTo(libraryAsset.file) + } catch (e: Exception) { + Timber.d(e) + tryOrNull { publicationAsset.file.delete() } + return Try.failure(ImportException.IOException) + } + + streamer.open(libraryAsset, allowUserInteraction = false) + .onSuccess { publication -> + val id = insertBookIntoDatabase( + libraryAsset.file.path, + libraryAsset.mediaType(), + publication + ) + if (id == -1L) + return Try.failure(ImportException.ImportDatabaseFailed) + + val cover: Bitmap? = coverUrl + ?.let { getBitmapFromURL(it) } + ?: publication.cover() + storeCoverImage(cover, id.toString()) + Try.success(Unit) + } + .onFailure { + tryOrNull { libraryAsset.file.delete() } + Timber.d(it) + return Try.failure(ImportException.UnableToOpenPublication(it)) + } + + return Try.success(Unit) + } + + private suspend fun storeCoverImage(cover: Bitmap?, imageName: String) = + withContext(Dispatchers.IO) { + // TODO Figure out where to store these cover images + val coverImageDir = File(storageDir, "covers/") + if (!coverImageDir.exists()) { + coverImageDir.mkdirs() + } + val coverImageFile = File(storageDir, "covers/$imageName.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() + } + + 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) { + book.id?.let { deleteBookFromDatabase(it) } + tryOrNull { File(book.href).delete() } + tryOrNull { File(storageDir, "covers/${book.id}.png").delete() } + } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfFragment.kt b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfFragment.kt index 76795fa061..6e136d6dbe 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfFragment.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/bookshelf/BookshelfFragment.kt @@ -54,7 +54,7 @@ class BookshelfFragment : Fragment() { bookshelfViewModel.channel.receive(viewLifecycleOwner) { handleEvent(it) } bookshelfAdapter = BookshelfAdapter( - onBookClick = { book -> book.id?.let { bookshelfViewModel.openBook(it, requireActivity()) } }, + onBookClick = { book -> book.id?.let { bookshelfViewModel.openPublication(it, requireActivity()) } }, onBookLongClick = { book -> confirmDeleteBook(book) } ) @@ -62,13 +62,13 @@ class BookshelfFragment : Fragment() { registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> uri?.let { binding.bookshelfProgressBar.visibility = View.VISIBLE - bookshelfViewModel.importPublicationFromUri(it) + bookshelfViewModel.addPublicationFromUri(it) } } readerLauncher = registerForActivityResult(ReaderActivityContract()) { input -> - input?.let { tryOrLog { bookshelfViewModel.closeBook(input.bookId) } } + input?.let { tryOrLog { bookshelfViewModel.closePublication(input.bookId) } } } binding.bookshelfBookList.apply { @@ -117,7 +117,7 @@ class BookshelfFragment : Fragment() { val url = urlEditText.text.toString() val uri = Uri.parse(url) binding.bookshelfProgressBar.visibility = View.VISIBLE - bookshelfViewModel.importPublicationFromUri(uri) + bookshelfViewModel.addPublicationFromUri(uri) urlDialog.dismiss() } } @@ -133,19 +133,19 @@ class BookshelfFragment : Fragment() { private fun handleEvent(event: BookshelfViewModel.Event) { val message = when (event) { - is BookshelfViewModel.Event.ImportPublicationFailed -> { - "Error: " + event.errorMessage + is BookshelfViewModel.Event.ImportPublicationSuccess -> + getString(R.string.import_publication_success) + + is BookshelfViewModel.Event.ImportPublicationError -> { + event.errorMessage } - is BookshelfViewModel.Event.UnableToMovePublication -> - getString(R.string.unable_to_move_pub) - is BookshelfViewModel.Event.ImportPublicationSuccess -> getString(R.string.import_publication_success) - is BookshelfViewModel.Event.ImportDatabaseFailed -> - getString(R.string.unable_add_pub_database) - is BookshelfViewModel.Event.OpenBookError -> { + + is BookshelfViewModel.Event.OpenPublicationError -> { val detail = event.errorMessage ?: "Unable to open publication. An unexpected error occurred." "Error: $detail" } + is BookshelfViewModel.Event.LaunchReader -> { readerLauncher.launch(event.arguments) null @@ -175,7 +175,7 @@ class BookshelfFragment : Fragment() { } private fun deleteBook(book: Book) { - bookshelfViewModel.deleteBook(book) + bookshelfViewModel.deletePublication(book) } private fun confirmDeleteBook(book: Book) { 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 a84262ed34..676869ab44 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 @@ -9,70 +9,38 @@ package org.readium.r2.testapp.bookshelf import android.app.Activity import android.app.Application import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.net.Uri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.net.HttpURLConnection -import java.net.URL -import java.util.* -import kotlin.time.ExperimentalTime import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.readium.r2.shared.UserException -import org.readium.r2.shared.extensions.mediaType -import org.readium.r2.shared.extensions.tryOrNull -import org.readium.r2.shared.publication.Publication -import org.readium.r2.shared.publication.asset.FileAsset -import org.readium.r2.shared.publication.services.cover -import org.readium.r2.shared.util.flatMap -import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.testapp.BuildConfig +import org.readium.r2.testapp.R import org.readium.r2.testapp.domain.model.Book import org.readium.r2.testapp.reader.ReaderActivityContract import org.readium.r2.testapp.reader.ReaderRepository import org.readium.r2.testapp.utils.EventChannel import org.readium.r2.testapp.utils.extensions.copyToTempFile -import org.readium.r2.testapp.utils.extensions.moveTo -import timber.log.Timber class BookshelfViewModel(application: Application) : AndroidViewModel(application) { - val channel = EventChannel(Channel(Channel.BUFFERED), viewModelScope) - val books = app.bookRepository.books() - - private val app get() = getApplication() + private val app get() = + getApplication() private val preferences = application.getSharedPreferences("org.readium.r2.settings", Context.MODE_PRIVATE) + val channel = EventChannel(Channel(Channel.BUFFERED), viewModelScope) + val books = app.bookRepository.books() + init { copySamplesFromAssetsToStorage() } - fun deleteBook(book: Book) = viewModelScope.launch { - book.id?.let { app.bookRepository.deleteBook(it) } - tryOrNull { File(book.href).delete() } - tryOrNull { File(app.storageDir, "covers/${book.id}.png").delete() } - } - - private suspend fun addPublicationToDatabase( - href: String, - mediaType: MediaType, - publication: Publication - ): Long { - val id = app.bookRepository.insertBook(href, mediaType, publication) - storeCoverImage(publication, id.toString()) - return id - } - - fun copySamplesFromAssetsToStorage() = viewModelScope.launch(Dispatchers.IO) { + private fun copySamplesFromAssetsToStorage() = viewModelScope.launch(Dispatchers.IO) { withContext(Dispatchers.IO) { if (!preferences.contains("samples")) { val dir = app.storageDir @@ -84,7 +52,7 @@ class BookshelfViewModel(application: Application) : AndroidViewModel(applicatio val file = app.assets.open("Samples/$element").copyToTempFile(app.storageDir) if (file != null) - importPublication(file) + app.bookRepository.addBook(file) else if (BuildConfig.DEBUG) error("Unable to load sample into the library") } @@ -93,72 +61,34 @@ class BookshelfViewModel(application: Application) : AndroidViewModel(applicatio } } - fun importPublicationFromUri( - uri: Uri - ) = viewModelScope.launch { - uri.copyToTempFile(app, app.storageDir) - ?.let { - importPublication(it) - } - } - - private suspend fun importPublication( - sourceFile: File - ) { - val sourceMediaType = sourceFile.mediaType() - val publicationAsset: FileAsset = - if (sourceMediaType != MediaType.LCP_LICENSE_DOCUMENT) - FileAsset(sourceFile, sourceMediaType) - else { - app.readium.lcpService - .flatMap { it.acquirePublication(sourceFile) } - .fold( - { - val mediaType = - MediaType.of(fileExtension = File(it.suggestedFilename).extension) - FileAsset(it.localFile, mediaType) - }, - { - tryOrNull { sourceFile.delete() } - Timber.d(it) - channel.send(Event.ImportPublicationFailed(it.message)) - return - } - ) - } - - val mediaType = publicationAsset.mediaType() - val fileName = "${UUID.randomUUID()}.${mediaType.fileExtension}" - val libraryAsset = FileAsset(File(app.storageDir, fileName), mediaType) - - try { - publicationAsset.file.moveTo(libraryAsset.file) - } catch (e: Exception) { - Timber.d(e) - tryOrNull { publicationAsset.file.delete() } - channel.send(Event.UnableToMovePublication) - return + fun deletePublication(book: Book) = + viewModelScope.launch { + app.bookRepository.deleteBook(book) } - app.readium.streamer.open(libraryAsset, allowUserInteraction = false) - .onSuccess { - addPublicationToDatabase(libraryAsset.file.path, libraryAsset.mediaType(), it).let { id -> - - if (id != -1L) - channel.send(Event.ImportPublicationSuccess) - else - channel.send(Event.ImportDatabaseFailed) + fun addPublicationFromUri(uri: Uri) = + viewModelScope.launch { + app.bookRepository + .addBook(uri) + .onFailure { exception -> + val errorMessage = when (exception) { + is BookRepository.ImportException.UnableToOpenPublication -> + exception.exception.getUserMessage(app) + BookRepository.ImportException.ImportDatabaseFailed -> + app.getString(R.string.unable_add_pub_database) + is BookRepository.ImportException.LcpAcquisitionFailed -> + "Error: " + exception.message + BookRepository.ImportException.IOException -> + app.getString(R.string.unexpected_io_exception) + } + channel.send(Event.ImportPublicationError(errorMessage)) } - } - .onFailure { - tryOrNull { libraryAsset.file.delete() } - Timber.d(it) - channel.send(Event.ImportPublicationFailed(it.getUserMessage(app))) - } - } + .onSuccess { + channel.send(Event.ImportPublicationSuccess) + } + } - @OptIn(ExperimentalTime::class) - fun openBook( + fun openPublication( bookId: Long, activity: Activity ) = viewModelScope.launch { @@ -172,7 +102,7 @@ class BookshelfViewModel(application: Application) : AndroidViewModel(applicatio is UserException -> exception.getUserMessage(app) else -> exception.message } - channel.send(Event.OpenBookError(message)) + channel.send(Event.OpenPublicationError(message)) } .onSuccess { val arguments = ReaderActivityContract.Arguments(bookId) @@ -180,55 +110,25 @@ class BookshelfViewModel(application: Application) : AndroidViewModel(applicatio } } - fun closeBook(bookId: Long) = viewModelScope.launch { + fun closePublication(bookId: Long) = viewModelScope.launch { val readerRepository = app.readerRepository.await() readerRepository.close(bookId) } - private fun storeCoverImage(publication: Publication, imageName: String) = - viewModelScope.launch(Dispatchers.IO) { - // TODO Figure out where to store these cover images - val coverImageDir = File(app.storageDir, "covers/") - if (!coverImageDir.exists()) { - coverImageDir.mkdirs() - } - val coverImageFile = File(app.storageDir, "covers/$imageName.png") - - val bitmap: Bitmap? = publication.cover() - - val resized = bitmap?.let { Bitmap.createScaledBitmap(it, 120, 200, true) } - val fos = FileOutputStream(coverImageFile) - resized?.compress(Bitmap.CompressFormat.PNG, 80, fos) - fos.flush() - fos.close() - } - - private fun getBitmapFromURL(src: String): Bitmap? { - return 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 - } - } - sealed class Event { - class ImportPublicationFailed(val errorMessage: String?) : Event() - - object UnableToMovePublication : Event() - object ImportPublicationSuccess : Event() - object ImportDatabaseFailed : Event() + class ImportPublicationError( + val errorMessage: String + ) : Event() - class OpenBookError(val errorMessage: String?) : Event() + class OpenPublicationError( + val errorMessage: String? + ) : Event() - class LaunchReader(val arguments: ReaderActivityContract.Arguments) : Event() + class LaunchReader( + val arguments: ReaderActivityContract.Arguments + ) : Event() } } diff --git a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt index 6eb72fae42..d231a42f06 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/catalogs/CatalogViewModel.kt @@ -6,18 +6,14 @@ package org.readium.r2.testapp.catalogs -import android.graphics.Bitmap -import android.graphics.BitmapFactory +import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.net.HttpURLConnection import java.net.MalformedURLException import java.net.URL -import kotlinx.coroutines.Dispatchers +import java.util.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import org.readium.r2.opds.OPDS1Parser @@ -25,24 +21,20 @@ import org.readium.r2.opds.OPDS2Parser import org.readium.r2.shared.opds.ParseData import org.readium.r2.shared.publication.Publication import org.readium.r2.shared.publication.opds.images -import org.readium.r2.shared.publication.services.cover import org.readium.r2.shared.util.Try +import org.readium.r2.shared.util.flatMap import org.readium.r2.shared.util.http.HttpRequest import org.readium.r2.shared.util.mediatype.MediaType -import org.readium.r2.testapp.Application -import org.readium.r2.testapp.bookshelf.BookRepository -import org.readium.r2.testapp.db.BookDatabase import org.readium.r2.testapp.domain.model.Catalog -import org.readium.r2.testapp.opds.OPDSDownloader import org.readium.r2.testapp.utils.EventChannel +import org.readium.r2.testapp.utils.extensions.downloadTo import timber.log.Timber -class CatalogViewModel(application: android.app.Application) : AndroidViewModel(application) { +class CatalogViewModel(application: Application) : AndroidViewModel(application) { + + private val app get() = + getApplication() - private val bookDao = BookDatabase.getDatabase(application).booksDao() - private val bookRepository = BookRepository(bookDao) - private var opdsDownloader = OPDSDownloader(application.applicationContext) - private var storageDir = (application as Application).storageDir val detailChannel = EventChannel(Channel(Channel.BUFFERED), viewModelScope) val eventChannel = EventChannel(Channel(Channel.BUFFERED), viewModelScope) val parseData = MutableLiveData() @@ -71,68 +63,32 @@ class CatalogViewModel(application: android.app.Application) : AndroidViewModel( } fun downloadPublication(publication: Publication) = viewModelScope.launch { - val downloadUrl = getDownloadURL(publication) - val publicationUrl = opdsDownloader.publicationUrl(downloadUrl.toString()) - publicationUrl.onSuccess { - val id = addPublicationToDatabase(it.first, MediaType.EPUB, publication) - if (id != -1L) { + val filename = UUID.randomUUID().toString() + val dest = File(app.storageDir, filename) + + getDownloadURL(publication) + .flatMap { url -> + url.downloadTo(dest) + }.flatMap { + val opdsCover = publication.images.firstOrNull()?.href + app.bookRepository.addBook(dest, opdsCover) + }.onSuccess { detailChannel.send(Event.DetailEvent.ImportPublicationSuccess) - } else { - detailChannel.send(Event.DetailEvent.ImportPublicationFailed) - } - } - .onFailure { + }.onFailure { detailChannel.send(Event.DetailEvent.ImportPublicationFailed) } } - private fun getDownloadURL(publication: Publication): URL? = + private fun getDownloadURL(publication: Publication): Try = publication.links - .firstOrNull { it.mediaType.isPublication } - ?.let { URL(it.href) } - - private suspend fun addPublicationToDatabase( - href: String, - mediaType: MediaType, - publication: Publication - ): Long { - val id = bookRepository.insertBook(href, mediaType, publication) - storeCoverImage(publication, id.toString()) - return id - } - - private fun storeCoverImage(publication: Publication, imageName: String) = - viewModelScope.launch(Dispatchers.IO) { - // TODO Figure out where to store these cover images - val coverImageDir = File(storageDir, "covers/") - if (!coverImageDir.exists()) { - coverImageDir.mkdirs() - } - val coverImageFile = File(storageDir, "covers/$imageName.png") - - val bitmap: Bitmap? = - publication.cover() ?: getBitmapFromURL(publication.images.first().href) - - val resized = bitmap?.let { Bitmap.createScaledBitmap(it, 120, 200, true) } - val fos = FileOutputStream(coverImageFile) - resized?.compress(Bitmap.CompressFormat.PNG, 80, fos) - fos.flush() - fos.close() - } - - private fun getBitmapFromURL(src: String): Bitmap? { - return 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 - } - } + .firstOrNull { it.mediaType.isPublication || it.mediaType == MediaType.LCP_LICENSE_DOCUMENT } + ?.let { + try { + Try.success(URL(it.href)) + } catch (e: Exception) { + Try.failure(e) + } + } ?: Try.failure(Exception("No supported link to acquire publication.")) sealed class Event { diff --git a/test-app/src/main/java/org/readium/r2/testapp/opds/OPDSDownloader.kt b/test-app/src/main/java/org/readium/r2/testapp/opds/OPDSDownloader.kt deleted file mode 100644 index b542a621a1..0000000000 --- a/test-app/src/main/java/org/readium/r2/testapp/opds/OPDSDownloader.kt +++ /dev/null @@ -1,112 +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.opds - -import android.content.Context -import java.io.File -import java.io.FileOutputStream -import java.util.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.withContext -import org.readium.r2.shared.util.Try -import org.readium.r2.shared.util.flatMap -import org.readium.r2.shared.util.http.* -import org.readium.r2.shared.util.mediatype.MediaType -import org.readium.r2.testapp.BuildConfig.DEBUG -import timber.log.Timber - -class OPDSDownloader(context: Context) { - - private val useExternalFileDir = useExternalDir(context) - - private val rootDir: String = if (useExternalFileDir) { - context.getExternalFilesDir(null)?.path + "/" - } else { - context.filesDir.path + "/" - } - - private fun useExternalDir(context: Context): Boolean { - val properties = Properties() - val inputStream = context.assets.open("configs/config.properties") - properties.load(inputStream) - return properties.getProperty("useExternalFileDir", "false")!!.toBoolean() - } - - suspend fun publicationUrl( - url: String - ): Try, Exception> { - val fileName = UUID.randomUUID().toString() - if (DEBUG) Timber.i("download url %s", url) - return DefaultHttpClient().download(HttpRequest(url), File(rootDir, fileName)) - .flatMap { - try { - if (DEBUG) Timber.i("response url %s", it.url) - if (DEBUG) Timber.i("download destination %s %s %s", "%s%s", rootDir, fileName) - if (url == it.url) { - Try.success(Pair(rootDir + fileName, fileName)) - } else { - redirectedDownload(it.url, fileName) - } - } catch (e: Exception) { - Try.failure(e) - } - } - } - - private suspend fun redirectedDownload( - responseUrl: String, - fileName: String - ): Try, Exception> { - return DefaultHttpClient().download(HttpRequest(responseUrl), File(rootDir, fileName)) - .flatMap { - if (DEBUG) Timber.i("response url %s", it.url) - if (DEBUG) Timber.i("download destination %s %s %s", "%s%s", rootDir, fileName) - try { - Try.success(Pair(rootDir + fileName, fileName)) - } catch (e: Exception) { - Try.failure(e) - } - } - } - - private suspend fun HttpClient.download( - request: HttpRequest, - destination: File, - ): HttpTry = - try { - stream(request).flatMap { res -> - withContext(Dispatchers.IO) { - res.body.use { input -> - FileOutputStream(destination).use { output -> - val buf = ByteArray(1024 * 8) - var n: Int - var downloadedBytes = 0 - while (-1 != input.read(buf).also { n = it }) { - ensureActive() - downloadedBytes += n - output.write(buf, 0, n) - } - } - } - var response = res.response - if (response.mediaType.matches(MediaType.BINARY)) { - response = response.copy( - mediaType = MediaType.ofFile(destination) ?: response.mediaType - ) - } - Try.success(response) - } - } - } catch (e: Exception) { - Try.failure(HttpException.wrap(e)) - } -} diff --git a/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/File.kt b/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/File.kt index f18527ebc6..81368e780e 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/File.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/utils/extensions/File.kt @@ -11,9 +11,18 @@ package org.readium.r2.testapp.utils.extensions import java.io.File import java.io.FileFilter +import java.io.FileOutputStream import java.io.IOException +import java.net.URL import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext +import org.readium.r2.shared.util.Try +import org.readium.r2.shared.util.flatMap +import org.readium.r2.shared.util.http.* +import org.readium.r2.shared.util.mediatype.MediaType +import org.readium.r2.testapp.BuildConfig +import timber.log.Timber suspend fun File.moveTo(target: File) = withContext(Dispatchers.IO) { if (!this@moveTo.renameTo(target)) @@ -28,3 +37,62 @@ fun File.listFilesSafely(filter: FileFilter? = null): List { val array: Array? = if (filter == null) listFiles() else listFiles(filter) return array?.toList() ?: emptyList() } + +suspend fun URL.downloadTo( + dest: File, + maxRedirections: Int = 2 +): Try { + if (maxRedirections == 0) { + return Try.Failure(Exception("Too many HTTP redirections.")) + } + + val urlString = toString() + + if (BuildConfig.DEBUG) Timber.i("download url $urlString") + return DefaultHttpClient().download(HttpRequest(toString()), dest) + .flatMap { + try { + if (BuildConfig.DEBUG) Timber.i("response url ${it.url}") + if (BuildConfig.DEBUG) Timber.i("download destination ${dest.path}") + if (urlString == it.url) { + Try.success(Unit) + } else { + URL(it.url).downloadTo(dest, maxRedirections - 1) + } + } catch (e: Exception) { + Try.failure(e) + } + } +} + +private suspend fun HttpClient.download( + request: HttpRequest, + destination: File, +): HttpTry = + try { + stream(request).flatMap { res -> + withContext(Dispatchers.IO) { + res.body.use { input -> + FileOutputStream(destination).use { output -> + val buf = ByteArray(1024 * 8) + var n: Int + var downloadedBytes = 0 + while (-1 != input.read(buf).also { n = it }) { + ensureActive() + downloadedBytes += n + output.write(buf, 0, n) + } + } + } + var response = res.response + if (response.mediaType.matches(MediaType.BINARY)) { + response = response.copy( + mediaType = MediaType.ofFile(destination) ?: response.mediaType + ) + } + Try.success(response) + } + } + } catch (e: Exception) { + Try.failure(HttpException.wrap(e)) + } 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 fb9a1d94dc..573701b231 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 @@ -10,14 +10,17 @@ import android.content.Context import android.net.Uri import java.io.File import java.util.* -import org.readium.r2.shared.extensions.tryOrNull +import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.mediatype.MediaType import org.readium.r2.testapp.utils.ContentResolverUtil -suspend fun Uri.copyToTempFile(context: Context, dir: File): File? = tryOrNull { - val filename = UUID.randomUUID().toString() - val mediaType = MediaType.ofUri(this, context.contentResolver) - val file = File(dir, "$filename.${mediaType?.fileExtension ?: "tmp"}") - ContentResolverUtil.getContentInputStream(context, this, file) - file -} +suspend fun Uri.copyToTempFile(context: Context, dir: File): Try = + try { + val filename = UUID.randomUUID().toString() + val mediaType = MediaType.ofUri(this, context.contentResolver) + val file = File(dir, "$filename.${mediaType?.fileExtension ?: "tmp"}") + ContentResolverUtil.getContentInputStream(context, this, file) + Try.success(file) + } catch (e: Exception) { + Try.failure(e) + } diff --git a/test-app/src/main/res/values/strings.xml b/test-app/src/main/res/values/strings.xml index a91829dada..6f18a55ec8 100644 --- a/test-app/src/main/res/values/strings.xml +++ b/test-app/src/main/res/values/strings.xml @@ -169,7 +169,7 @@ End of chapter - Unable to move publication into the library + Unable to add publication due to an unexpected error on your device Publication added to your library Unable to add publication to the database Failed parsing Catalog From a43c13750bcd3906036ae73dbfdc638e809670af Mon Sep 17 00:00:00 2001 From: Quentin Gliosca Date: Wed, 14 Dec 2022 11:56:23 +0100 Subject: [PATCH 2/2] Lint --- .../src/main/java/org/readium/r2/testapp/Application.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test-app/src/main/java/org/readium/r2/testapp/Application.kt b/test-app/src/main/java/org/readium/r2/testapp/Application.kt index b037837565..576ee32e52 100755 --- a/test-app/src/main/java/org/readium/r2/testapp/Application.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/Application.kt @@ -6,7 +6,10 @@ package org.readium.r2.testapp -import android.content.* +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection import android.os.IBinder import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences @@ -15,8 +18,8 @@ import com.google.android.material.color.DynamicColors import java.io.File import java.util.* import kotlinx.coroutines.* -import org.readium.r2.testapp.bookshelf.BookRepository import org.readium.r2.testapp.BuildConfig.DEBUG +import org.readium.r2.testapp.bookshelf.BookRepository import org.readium.r2.testapp.db.BookDatabase import org.readium.r2.testapp.reader.ReaderRepository import timber.log.Timber