Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions test-app/src/main/java/org/readium/r2/testapp/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,9 +26,6 @@ import timber.log.Timber

class Application : android.app.Application() {

val Context.navigatorPreferences: DataStore<Preferences>
by preferencesDataStore(name = "navigator-preferences")

lateinit var readium: Readium
private set

Expand All @@ -40,6 +40,9 @@ class Application : android.app.Application() {
private val coroutineScope: CoroutineScope =
MainScope()

private val Context.navigatorPreferences: DataStore<Preferences>
by preferencesDataStore(name = "navigator-preferences")

private val mediaServiceBinder: CompletableDeferred<MediaService.Binder> =
CompletableDeferred()

Expand Down Expand Up @@ -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 {
Expand All @@ -112,6 +115,3 @@ class Application : android.app.Application() {
)
}
}

val Context.resolver: ContentResolver
get() = applicationContext.contentResolver
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 3 additions & 6 deletions test-app/src/main/java/org/readium/r2/testapp/MediaService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<LcpService, Exception>,
private val streamer: Streamer
) {

fun books(): LiveData<List<Book>> = 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)

Expand Down Expand Up @@ -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<Unit, ImportException> =
contentUri.copyToTempFile(context, storageDir)
.mapFailure { ImportException.IOException }
.map { addBook(it) }

suspend fun addBook(
tempFile: File,
coverUrl: String? = null
): Try<Unit, ImportException> {
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() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,21 +54,21 @@ 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) }
)

documentPickerLauncher =
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 {
Expand Down Expand Up @@ -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()
}
}
Expand All @@ -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
Expand Down Expand Up @@ -175,7 +175,7 @@ class BookshelfFragment : Fragment() {
}

private fun deleteBook(book: Book) {
bookshelfViewModel.deleteBook(book)
bookshelfViewModel.deletePublication(book)
}

private fun confirmDeleteBook(book: Book) {
Expand Down
Loading