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..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 @@ -23,9 +26,6 @@ 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 +40,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 +88,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 +115,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