🛠️ Add logging and reformat data layer

* Reformatted BookRepository, added logs
* Improved code readability, added comments
This commit is contained in:
Acclorite 2024-09-17 23:17:41 +03:00
parent 94c49c20c8
commit 100926ce14
25 changed files with 544 additions and 375 deletions

View file

@ -1,11 +1,10 @@
package ua.acclorite.book_story.data.parser
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.util.CoverImage
import ua.acclorite.book_story.domain.model.BookWithCover
import java.io.File
interface FileParser {
suspend fun parse(file: File): Pair<Book, CoverImage?>?
suspend fun parse(file: File): BookWithCover?
}

View file

@ -7,8 +7,7 @@ import ua.acclorite.book_story.data.parser.htm.HtmFileParser
import ua.acclorite.book_story.data.parser.html.HtmlFileParser
import ua.acclorite.book_story.data.parser.pdf.PdfFileParser
import ua.acclorite.book_story.data.parser.txt.TxtFileParser
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.util.CoverImage
import ua.acclorite.book_story.domain.model.BookWithCover
import java.io.File
import javax.inject.Inject
@ -22,7 +21,7 @@ class FileParserImpl @Inject constructor(
private val htmlFileParser: HtmlFileParser,
private val htmFileParser: HtmFileParser,
) : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
override suspend fun parse(file: File): BookWithCover? {
if (!file.exists()) {
Log.e(FILE_PARSER, "File does not exist.")
return null

View file

@ -8,8 +8,8 @@ import org.jsoup.Jsoup
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.BookWithCover
import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.util.CoverImage
import ua.acclorite.book_story.domain.util.UIText
import java.io.File
import java.util.zip.ZipFile
@ -17,9 +17,9 @@ import javax.inject.Inject
class EpubFileParser @Inject constructor() : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
override suspend fun parse(file: File): BookWithCover? {
return try {
var book: Pair<Book, CoverImage?>? = null
var book: BookWithCover? = null
withContext(Dispatchers.IO) {
ZipFile(file).use { zip ->
@ -71,19 +71,22 @@ class EpubFileParser @Inject constructor() : FileParser {
.firstOrNull()?.attr("href")
}
book = Book(
title = title,
author = author,
description = description,
textPath = "",
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
coverImage = null
) to extractCoverImageBitmap(file, coverImage)
book = BookWithCover(
book = Book(
title = title,
author = author,
description = description,
textPath = "",
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
coverImage = null
),
coverImage = extractCoverImageBitmap(file, coverImage)
)
}
}
book

View file

@ -7,8 +7,8 @@ import org.w3c.dom.Element
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.BookWithCover
import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.util.CoverImage
import ua.acclorite.book_story.domain.util.UIText
import java.io.File
import javax.inject.Inject
@ -16,7 +16,7 @@ import javax.xml.parsers.DocumentBuilderFactory
class Fb2FileParser @Inject constructor() : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
override suspend fun parse(file: File): BookWithCover? {
return try {
val factory = DocumentBuilderFactory.newInstance()
val builder = factory.newDocumentBuilder()
@ -50,19 +50,22 @@ class Fb2FileParser @Inject constructor() : FileParser {
val descriptionFromFile = extractElementContent(document, "annotation")
Book(
title = title,
author = author,
description = descriptionFromFile,
textPath = "",
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
BookWithCover(
book = Book(
title = title,
author = author,
description = descriptionFromFile,
textPath = "",
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
coverImage = null
),
coverImage = null
) to null
)
} catch (e: Exception) {
e.printStackTrace()
null

View file

@ -4,15 +4,15 @@ import org.jsoup.Jsoup
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.BookWithCover
import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.util.CoverImage
import ua.acclorite.book_story.domain.util.UIText
import java.io.File
import javax.inject.Inject
class HtmFileParser @Inject constructor() : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
override suspend fun parse(file: File): BookWithCover? {
return try {
val document = Jsoup.parse(file)
@ -22,19 +22,22 @@ class HtmFileParser @Inject constructor() : FileParser {
}
}
Book(
title = title,
author = UIText.StringResource(R.string.unknown_author),
description = null,
textPath = "",
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
BookWithCover(
book = Book(
title = title,
author = UIText.StringResource(R.string.unknown_author),
description = null,
textPath = "",
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
coverImage = null
),
coverImage = null
) to null
)
} catch (e: Exception) {
e.printStackTrace()
null

View file

@ -4,15 +4,15 @@ import org.jsoup.Jsoup
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.BookWithCover
import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.util.CoverImage
import ua.acclorite.book_story.domain.util.UIText
import java.io.File
import javax.inject.Inject
class HtmlFileParser @Inject constructor() : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
override suspend fun parse(file: File): BookWithCover? {
return try {
val document = Jsoup.parse(file)
@ -22,19 +22,22 @@ class HtmlFileParser @Inject constructor() : FileParser {
}
}
Book(
title = title,
author = UIText.StringResource(R.string.unknown_author),
description = null,
textPath = "",
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
BookWithCover(
book = Book(
title = title,
author = UIText.StringResource(R.string.unknown_author),
description = null,
textPath = "",
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
coverImage = null
),
coverImage = null
) to null
)
} catch (e: Exception) {
e.printStackTrace()
null

View file

@ -6,15 +6,15 @@ import com.tom_roush.pdfbox.pdmodel.PDDocument
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.BookWithCover
import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.util.CoverImage
import ua.acclorite.book_story.domain.util.UIText
import java.io.File
import javax.inject.Inject
class PdfFileParser @Inject constructor(private val application: Application) : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
override suspend fun parse(file: File): BookWithCover? {
return try {
PDFBoxResourceLoader.init(application)
@ -29,19 +29,22 @@ class PdfFileParser @Inject constructor(private val application: Application) :
document.close()
Book(
title = title,
author = author,
description = description,
textPath = "",
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
BookWithCover(
book = Book(
title = title,
author = author,
description = description,
textPath = "",
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
coverImage = null
),
coverImage = null
) to null
)
} catch (e: Exception) {
e.printStackTrace()
null

View file

@ -3,32 +3,35 @@ package ua.acclorite.book_story.data.parser.txt
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.BookWithCover
import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.util.CoverImage
import ua.acclorite.book_story.domain.util.UIText
import java.io.File
import javax.inject.Inject
class TxtFileParser @Inject constructor() : FileParser {
override suspend fun parse(file: File): Pair<Book, CoverImage?>? {
override suspend fun parse(file: File): BookWithCover? {
return try {
val title = file.nameWithoutExtension.trim()
val author = UIText.StringResource(R.string.unknown_author)
Book(
title = title,
author = author,
description = null,
textPath = "",
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
BookWithCover(
book = Book(
title = title,
author = author,
description = null,
textPath = "",
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
coverImage = null
),
coverImage = null
) to null
)
} catch (e: Exception) {
e.printStackTrace()
null

View file

@ -9,13 +9,9 @@ import android.provider.MediaStore
import android.util.Log
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.local.data_store.DataStore
@ -30,6 +26,8 @@ import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.data.remote.GithubAPI
import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.BookWithText
import ua.acclorite.book_story.domain.model.BookWithTextAndCover
import ua.acclorite.book_story.domain.model.ColorPreset
import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.model.NullableBook
@ -51,6 +49,16 @@ import javax.inject.Singleton
private const val GET_BOOK_FROM_FILE = "GET BOOK FROM FILE, REPOSITORY"
private const val GET_TEXT = "GET TEXT, REPOSITORY"
private const val GET_BOOKS = "GET BOOKS, REPOSITORY"
private const val GET_BOOKS_BY_ID = "GET BOOKS, REPOSITORY"
private const val INSERT_BOOK = "INSERT BOOK, REPOSITORY"
private const val UPDATE_BOOK = "UPDATE BOOK, REPOSITORY"
private const val DELETE_BOOKS = "DELETE BOOKS, REPOSITORY"
private const val CAN_RESET_COVER = "CAN RESET COVER, REPOSITORY"
private const val RESET_COVER = "RESET COVER, REPOSITORY"
private const val GET_ALL_SETTINGS = "GET ALL SETTINGS, REPOSITORY"
private const val GET_FILES_FROM_DEVICE = "GET FILES FROM DEVICE, REPOSITORY"
private const val CHECK_FOR_UPDATES = "CHECK FOR UPDATES, REPOSITORY"
@Suppress("DEPRECATION")
@Singleton
@ -67,13 +75,18 @@ class BookRepositoryImpl @Inject constructor(
private val colorPresetMapper: ColorPresetMapper,
private val fileParser: FileParser,
private val textParser: TextParser
private val textParser: TextParser,
) : BookRepository {
/**
* Get all books matching [query] from database.
* Empty [query] equals to all books.
*/
override suspend fun getBooks(query: String): List<Book> {
Log.i(GET_BOOKS, "Searching for books with query: \"$query\".")
val books = database.searchBooks(query)
Log.i(GET_BOOKS, "Found ${books.size} books.")
return books.map { entity ->
val book = bookMapper.toBook(entity)
val lastHistory = database.getLatestHistoryForBook(
@ -86,7 +99,11 @@ class BookRepositoryImpl @Inject constructor(
}
}
/**
* Get all books that match given [ids].
*/
override suspend fun getBooksById(ids: List<Int>): List<Book> {
Log.i(GET_BOOKS_BY_ID, "Getting books with ids: $ids.")
val books = database.findBooksById(ids)
return books.map { entity ->
@ -128,26 +145,33 @@ class BookRepositoryImpl @Inject constructor(
return lines
}
/**
* Inserts book in database.
* Creates covers and books folders, which contain book's text and cover.
*/
override suspend fun insertBook(
book: Book,
coverImage: CoverImage?,
text: List<String>
bookWithTextAndCover: BookWithTextAndCover
): Boolean {
Log.i(INSERT_BOOK, "Inserting ${bookWithTextAndCover.book.title}.")
val filesDir = application.filesDir
val coversDir = File(filesDir, "covers")
val booksDir = File(filesDir, "books")
if (!coversDir.exists()) {
Log.i(INSERT_BOOK, "Created covers folder.")
coversDir.mkdirs()
}
if (!booksDir.exists()) {
Log.i(INSERT_BOOK, "Created books folder.")
booksDir.mkdirs()
}
var coverUri = ""
val textUri: String
if (text.isEmpty()) {
if (bookWithTextAndCover.text.isEmpty()) {
Log.e(INSERT_BOOK, "Text is empty.")
return false
}
@ -157,18 +181,19 @@ class BookRepositoryImpl @Inject constructor(
withContext(Dispatchers.IO) {
FileOutputStream(textPath).use { stream ->
text.forEach { line ->
bookWithTextAndCover.text.forEach { line ->
stream.write(line.toByteArray())
stream.write(System.lineSeparator().toByteArray())
}
}
}
} catch (e: Exception) {
Log.e(INSERT_BOOK, "Could not write text.")
e.printStackTrace()
return false
}
if (coverImage != null) {
if (bookWithTextAndCover.coverImage != null) {
try {
coverUri = "${UUID.randomUUID()}.webp"
val cover = File(coversDir, coverUri)
@ -176,23 +201,25 @@ class BookRepositoryImpl @Inject constructor(
withContext(Dispatchers.IO) {
FileOutputStream(cover).use { stream ->
if (
!coverImage.copy(Bitmap.Config.RGB_565, false).compress(
Bitmap.CompressFormat.WEBP,
20,
stream
)
!bookWithTextAndCover.coverImage.copy(Bitmap.Config.RGB_565, false)
.compress(
Bitmap.CompressFormat.WEBP,
20,
stream
)
) {
throw Exception("Couldn't save cover image")
}
}
}
} catch (e: Exception) {
Log.e(INSERT_BOOK, "Could not save cover.")
coverUri = ""
e.printStackTrace()
}
}
val updatedBook = book.copy(
val updatedBook = bookWithTextAndCover.book.copy(
textPath = "$booksDir/$textUri",
coverImage = if (coverUri.isNotBlank()) {
Uri.fromFile(File("$coversDir/$coverUri"))
@ -201,37 +228,48 @@ class BookRepositoryImpl @Inject constructor(
val bookToInsert = bookMapper.toBookEntity(updatedBook)
database.insertBooks(listOf(bookToInsert))
Log.i(INSERT_BOOK, "Successfully inserted book.")
return true
}
override suspend fun updateBooks(books: List<Book>) {
/**
* Update book without text or cover image.
*/
override suspend fun updateBook(book: Book) {
// without text and cover image
val entity = database.findBookById(book.id)
database.updateBooks(
books.map {
val book = database.findBookById(it.id)
listOf(
bookMapper.toBookEntity(
it.copy(
textPath = book.textPath,
coverImage = if (book.image != null) Uri.parse(book.image) else null
book.copy(
textPath = entity.textPath,
coverImage = if (entity.image != null) Uri.parse(entity.image) else null
)
)
}
)
)
}
override suspend fun updateBookWithText(book: Book, text: List<String>): Boolean {
/**
* Update book with text. Deletes old text file and replaces it with new.
*/
override suspend fun updateBookWithText(bookWithText: BookWithText): Boolean {
Log.i(UPDATE_BOOK, "Updating book with text: ${bookWithText.book.title}.")
// without cover image
val filesDir = application.filesDir
val booksDir = File(filesDir, "books")
if (!booksDir.exists()) {
Log.i(UPDATE_BOOK, "Created books folder.")
booksDir.mkdirs()
}
val textUri: String
val bookEntity = database.findBookById(book.id)
val bookEntity = database.findBookById(bookWithText.book.id)
if (text.isEmpty()) {
if (bookWithText.text.isEmpty()) {
Log.e(UPDATE_BOOK, "Text is empty.")
return false
}
@ -241,33 +279,35 @@ class BookRepositoryImpl @Inject constructor(
withContext(Dispatchers.IO) {
FileOutputStream(textPath).use { stream ->
text.forEach { line ->
bookWithText.text.forEach { line ->
stream.write(line.toByteArray())
stream.write(System.lineSeparator().toByteArray())
}
}
}
} catch (e: Exception) {
Log.e(UPDATE_BOOK, "Could not update text.")
e.printStackTrace()
return false
}
if (book.textPath.isNotBlank()) {
if (bookWithText.book.textPath.isNotBlank()) {
try {
val fileToDelete = File(
book.textPath
bookWithText.book.textPath
)
if (fileToDelete.exists()) {
fileToDelete.delete()
}
} catch (e: Exception) {
Log.e(UPDATE_BOOK, "Failed to delete old text.")
e.printStackTrace()
}
}
val updatedBook = bookMapper.toBookEntity(
book.copy(
bookWithText.book.copy(
textPath = "$booksDir/$textUri",
coverImage = if (bookEntity.image != null) Uri.parse(bookEntity.image) else null
)
@ -276,13 +316,19 @@ class BookRepositoryImpl @Inject constructor(
database.updateBooks(
listOf(updatedBook)
)
Log.i(UPDATE_BOOK, "Successfully updated book.")
return true
}
/**
* Update cover image of the book. Deletes old cover and replaces with new.
*/
override suspend fun updateCoverImageOfBook(
bookWithOldCover: Book,
newCoverImage: CoverImage?
) {
Log.i(UPDATE_BOOK, "Updating cover image: ${bookWithOldCover.title}.")
// without text
val book = database.findBookById(bookWithOldCover.id)
var uri: String? = null
@ -291,6 +337,7 @@ class BookRepositoryImpl @Inject constructor(
val coversDir = File(filesDir, "covers")
if (!coversDir.exists()) {
Log.i(UPDATE_BOOK, "Created covers folder.")
coversDir.mkdirs()
}
@ -313,6 +360,7 @@ class BookRepositoryImpl @Inject constructor(
}
}
} catch (e: Exception) {
Log.e(UPDATE_BOOK, "Could not save new cover.")
e.printStackTrace()
return
}
@ -328,6 +376,7 @@ class BookRepositoryImpl @Inject constructor(
fileToDelete.delete()
}
} catch (e: Exception) {
Log.e(UPDATE_BOOK, "Could not delete old cover.")
e.printStackTrace()
}
}
@ -350,17 +399,26 @@ class BookRepositoryImpl @Inject constructor(
)
)
)
Log.i(UPDATE_BOOK, "Successfully updated cover image.")
}
/**
* Delete books.
* Also deletes cover image and text from internal storage.
*/
override suspend fun deleteBooks(books: List<Book>) {
Log.i(DELETE_BOOKS, "Deleting books.")
val filesDir = application.filesDir
val coversDir = File(filesDir, "covers")
val booksDir = File(filesDir, "books")
if (!coversDir.exists()) {
Log.i(DELETE_BOOKS, "Created covers folder.")
coversDir.mkdirs()
}
if (!booksDir.exists()) {
Log.i(DELETE_BOOKS, "Created books folder.")
booksDir.mkdirs()
}
@ -378,6 +436,7 @@ class BookRepositoryImpl @Inject constructor(
fileToDelete.delete()
}
} catch (e: Exception) {
Log.e(DELETE_BOOKS, "Could not delete cover image.")
e.printStackTrace()
}
}
@ -390,6 +449,7 @@ class BookRepositoryImpl @Inject constructor(
fileToDelete.delete()
}
} catch (e: Exception) {
Log.e(DELETE_BOOKS, "Could not delete text.")
e.printStackTrace()
}
}
@ -397,15 +457,21 @@ class BookRepositoryImpl @Inject constructor(
book
}
)
Log.i(DELETE_BOOKS, "Successfully deleted books.")
}
/**
* @return Whether can reset cover image (restore default).
*/
override suspend fun canResetCover(bookId: Int): Boolean {
val book = database.findBookById(bookId)
val defaultCoverUncompressed = fileParser.parse(File(book.filePath))?.second
val defaultCoverUncompressed = fileParser.parse(File(book.filePath))?.coverImage
?: return false
if (book.image == null) {
Log.i(CAN_RESET_COVER, "Can reset cover image. (current is null)")
return true
}
@ -424,6 +490,7 @@ class BookRepositoryImpl @Inject constructor(
Uri.parse(book.image)
)
} catch (e: Exception) {
Log.i(CAN_RESET_COVER, "Can reset cover image. (could not get current)")
e.printStackTrace()
return true
}
@ -431,30 +498,45 @@ class BookRepositoryImpl @Inject constructor(
return !defaultCover.sameAs(currentCover)
}
/**
* Reset cover image to default.
* If there is no default cover, returns false.
*/
override suspend fun resetCoverImage(bookId: Int): Boolean {
if (!canResetCover(bookId)) {
Log.w(RESET_COVER, "Cannot reset cover image.")
return false
}
val book = database.findBookById(bookId)
val defaultCover = fileParser.parse(File(book.filePath))?.second
val defaultCover = fileParser.parse(File(book.filePath))?.coverImage
?: return false
updateCoverImageOfBook(bookMapper.toBook(book), defaultCover)
Log.i(RESET_COVER, "Successfully reset cover image.")
return true
}
/**
* Puts DataStore constant to [DataStore].
*/
override suspend fun <T> putDataToDataStore(key: Preferences.Key<T>, value: T) {
dataStore.putData(key, value)
}
override suspend fun getAllSettings(scope: CoroutineScope): MainState {
/**
* Gets all settings from DataStore and returns [MainState].
*/
override suspend fun getAllSettings(): MainState {
Log.i(GET_ALL_SETTINGS, "Getting all settings.")
val result = CompletableDeferred<MainState>()
scope.launch {
withContext(Dispatchers.Default) {
val keys = dataStore.getAllData()
val data = mutableMapOf<String, Any>()
Log.i(GET_ALL_SETTINGS, "Got ${keys?.size} settings keys.")
val jobs = keys?.map { key ->
async {
val nullableData = dataStore.getNullableData(key)
@ -474,7 +556,13 @@ class BookRepositoryImpl @Inject constructor(
return result.await()
}
/**
* Get all matching files from device.
* Filters by [query] and sorts out not supported file formats and already added files.
*/
override suspend fun getFilesFromDevice(query: String): List<SelectableFile> {
Log.i(GET_FILES_FROM_DEVICE, "Getting files from device by query: \"$query\".")
val existingBooks = database
.searchBooks("")
.map { bookMapper.toBook(it) }
@ -564,9 +652,11 @@ class BookRepositoryImpl @Inject constructor(
(Environment.getExternalStorageState() != Environment.MEDIA_MOUNTED &&
Environment.getExternalStorageState() != Environment.MEDIA_MOUNTED_READ_ONLY)
) {
Log.e(GET_FILES_FROM_DEVICE, "Could not correctly get root directory.")
return emptyList()
}
Log.i(GET_FILES_FROM_DEVICE, "Successfully got all matching files.")
return rootDirectory.getAllFiles()
}
@ -576,74 +666,106 @@ class BookRepositoryImpl @Inject constructor(
override suspend fun getBookFromFile(file: File): NullableBook {
val parsedBook = fileParser.parse(file)
if (parsedBook == null) {
Log.w(GET_BOOK_FROM_FILE, "Parsed book(${file.name}) is null.")
Log.e(GET_BOOK_FROM_FILE, "Parsed file(${file.name}) is null.")
return NullableBook.Null(
file.name,
UIText.StringResource(R.string.error_wrong_file_format)
UIText.StringResource(R.string.error_something_went_wrong)
)
}
val parsedText = textParser.parse(file)
if (parsedText is Resource.Error) {
Log.w(GET_BOOK_FROM_FILE, "Parsed text(${file.name}) has error.")
Log.e(GET_BOOK_FROM_FILE, "Parsed text(${file.name}) has error.")
return NullableBook.Null(
file.name,
parsedText.message
)
}
Log.i(GET_BOOK_FROM_FILE, "Successfully got book from file.")
return NullableBook.NotNull(
book = parsedBook.first.copy(
chapters = parsedText.data!!.map { it.chapter }.run {
if (this.size == 1) return@run emptyList()
this
}
),
coverImage = parsedBook.second,
text = parsedText.data.map {
it.text
}.flatten()
bookWithTextAndCover = BookWithTextAndCover(
book = parsedBook.book.copy(
chapters = parsedText.data!!.map { it.chapter }.run {
if (this.size == 1) return@run emptyList()
this
}
),
coverImage = parsedBook.coverImage,
text = parsedText.data.map {
it.text
}.flatten()
)
)
}
override suspend fun insertHistory(history: List<History>) {
database.insertHistory(history.map { historyMapper.toHistoryEntity(it) })
/**
* Insert history in database.
*/
override suspend fun insertHistory(history: History) {
database.insertHistory(
listOf(
historyMapper.toHistoryEntity(
history
)
)
)
}
override suspend fun getHistory(): Flow<Resource<List<History>>> {
return flow {
val history = database.getHistory()
emit(
Resource.Success(
data = history.map {
historyMapper.toHistory(
it
)
}
)
/**
* Get all history from database.
*/
override suspend fun getHistory(): List<History> {
return database.getHistory().map {
historyMapper.toHistory(
it
)
}
}
/**
* Get latest history of the matching [bookId].
*/
override suspend fun getLatestBookHistory(bookId: Int): History? {
val history = database.getLatestHistoryForBook(bookId)
return history?.let { historyMapper.toHistory(it) }
}
/**
* Delete whole history.
*/
override suspend fun deleteWholeHistory() {
database.deleteWholeHistory()
}
/**
* Delete all history of the matching [bookId].
*/
override suspend fun deleteBookHistory(bookId: Int) {
database.deleteBookHistory(bookId)
}
override suspend fun deleteHistory(history: List<History>) {
database.deleteHistory(history.map { historyMapper.toHistoryEntity(it) })
/**
* Delete specific history item.
*/
override suspend fun deleteHistory(history: History) {
database.deleteHistory(
listOf(
historyMapper.toHistoryEntity(
history
)
)
)
}
/**
* Check for updates from GitHub.
*
* @param postNotification Whether notification should be send.
*/
override suspend fun checkForUpdates(postNotification: Boolean): LatestReleaseInfo? {
Log.i(CHECK_FOR_UPDATES, "Checking for updates. Post notification: $postNotification")
return withContext(Dispatchers.IO) {
try {
val result = githubAPI.getLatestRelease()
@ -652,6 +774,7 @@ class BookRepositoryImpl @Inject constructor(
val currentVersion = application.getString(R.string.app_version)
if (version != currentVersion && postNotification) {
Log.i(CHECK_FOR_UPDATES, "Posting notification.")
updatesNotificationService.postNotification(
result
)
@ -659,12 +782,16 @@ class BookRepositoryImpl @Inject constructor(
result
} catch (e: Exception) {
Log.e(CHECK_FOR_UPDATES, "Could not get latest release information.")
e.printStackTrace()
null
}
}
}
/**
* Update color preset.
*/
override suspend fun updateColorPreset(colorPreset: ColorPreset) {
database.updateColorPreset(
colorPresetMapper.toColorPresetEntity(
@ -675,6 +802,9 @@ class BookRepositoryImpl @Inject constructor(
)
}
/**
* Select color preset. Only one can be selected at time.
*/
override suspend fun selectColorPreset(colorPreset: ColorPreset) {
database.getColorPresets().map {
it.copy(
@ -685,12 +815,20 @@ class BookRepositoryImpl @Inject constructor(
}
}
/**
* Get all color presets.
* Sorted by order (either manual or newest ones at the end).
*/
override suspend fun getColorPresets(): List<ColorPreset> {
return database.getColorPresets()
.sortedBy { it.order }
.map { colorPresetMapper.toColorPreset(it) }
}
/**
* Reorder color presets.
* Changes the order of the color presets.
*/
override suspend fun reorderColorPresets(orderedColorPresets: List<ColorPreset>) {
database.deleteColorPresets()
@ -701,6 +839,9 @@ class BookRepositoryImpl @Inject constructor(
}
}
/**
* Delete color preset.
*/
override suspend fun deleteColorPreset(colorPreset: ColorPreset) {
database.deleteColorPreset(
colorPresetMapper.toColorPresetEntity(
@ -709,6 +850,11 @@ class BookRepositoryImpl @Inject constructor(
)
}
/**
* Create or delete favorite directory if already exists.
*
* @param path Path to directory.
*/
override suspend fun updateFavoriteDirectory(path: String) {
if (database.favoriteDirectoryExits(path)) {
database.deleteFavoriteDirectory(
@ -722,18 +868,3 @@ class BookRepositoryImpl @Inject constructor(
)
}
}

View file

@ -2,6 +2,7 @@ package ua.acclorite.book_story.domain.model
import android.net.Uri
import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.util.CoverImage
import ua.acclorite.book_story.domain.util.UIText
@Immutable
@ -24,3 +25,46 @@ data class Book(
val lastOpened: Long?,
val category: Category,
)
@Immutable
data class BookWithText(
val book: Book,
val text: List<String>
)
@Immutable
data class BookWithCover(
val book: Book,
val coverImage: CoverImage?
)
@Immutable
data class BookWithTextAndCover(
val book: Book,
val coverImage: CoverImage?,
val text: List<String>
)
@Immutable
sealed class NullableBook(
val bookWithTextAndCover: BookWithTextAndCover?,
val fileName: String?,
val message: UIText?
) {
class NotNull(
bookWithTextAndCover: BookWithTextAndCover
) : NullableBook(
bookWithTextAndCover = bookWithTextAndCover,
fileName = null,
message = null
)
class Null(
fileName: String,
message: UIText?
) : NullableBook(
bookWithTextAndCover = null,
fileName = fileName,
message = message
)
}

View file

@ -1,31 +0,0 @@
package ua.acclorite.book_story.domain.model
import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.util.CoverImage
import ua.acclorite.book_story.domain.util.UIText
@Immutable
sealed class NullableBook(
val book: Book?,
val coverImage: CoverImage? = null,
val text: List<String> = emptyList(),
val fileName: String?,
val message: UIText?
) {
class NotNull(
book: Book,
coverImage: CoverImage?,
text: List<String>
) : NullableBook(
book = book,
text = text,
coverImage = coverImage,
fileName = null,
message = null
)
class Null(
fileName: String,
message: UIText?
) : NullableBook(null, text = emptyList(), fileName = fileName, message = message)
}

View file

@ -1,21 +1,21 @@
package ua.acclorite.book_story.domain.repository
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.BookWithText
import ua.acclorite.book_story.domain.model.BookWithTextAndCover
import ua.acclorite.book_story.domain.model.ColorPreset
import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.domain.model.SelectableFile
import ua.acclorite.book_story.domain.util.CoverImage
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.presentation.data.MainState
import java.io.File
interface BookRepository {
/* ------ Books ------------------------------ */
suspend fun getBooks(
query: String
): List<Book>
@ -29,18 +29,15 @@ interface BookRepository {
): List<String>
suspend fun insertBook(
book: Book,
coverImage: CoverImage?,
text: List<String>
bookWithTextAndCover: BookWithTextAndCover
): Boolean
suspend fun updateBooks(
books: List<Book>
suspend fun updateBook(
book: Book
)
suspend fun updateBookWithText(
book: Book,
text: List<String>
bookWithText: BookWithText
): Boolean
suspend fun updateCoverImageOfBook(
@ -56,49 +53,85 @@ interface BookRepository {
bookId: Int
): Boolean
suspend fun resetCoverImage(bookId: Int): Boolean
suspend fun resetCoverImage(
bookId: Int
): Boolean
/* - - - - - - - - - - - - - - - - - - - - - - */
/* ------ DataStore -------------------------- */
suspend fun <T> putDataToDataStore(
key: Preferences.Key<T>,
value: T
)
suspend fun getAllSettings(scope: CoroutineScope): MainState
suspend fun getFilesFromDevice(query: String = ""): List<SelectableFile>
suspend fun getBookFromFile(file: File): NullableBook
suspend fun getAllSettings(): MainState
/* - - - - - - - - - - - - - - - - - - - - - - */
suspend fun insertHistory(history: List<History>)
/* ------ File System ------------------------ */
suspend fun getFilesFromDevice(
query: String = ""
): List<SelectableFile>
suspend fun getHistory(): Flow<Resource<List<History>>>
suspend fun getBookFromFile(
file: File
): NullableBook
/* - - - - - - - - - - - - - - - - - - - - - - */
suspend fun getLatestBookHistory(bookId: Int): History?
/* ------ History ---------------------------- */
suspend fun insertHistory(
history: History
)
suspend fun getHistory(): List<History>
suspend fun getLatestBookHistory(
bookId: Int
): History?
suspend fun deleteWholeHistory()
suspend fun deleteBookHistory(bookId: Int)
suspend fun deleteHistory(
history: List<History>
suspend fun deleteBookHistory(
bookId: Int
)
suspend fun checkForUpdates(postNotification: Boolean): LatestReleaseInfo?
suspend fun deleteHistory(
history: History
)
/* - - - - - - - - - - - - - - - - - - - - - - */
suspend fun updateColorPreset(colorPreset: ColorPreset)
/* ------ API (GitHub) ----------------------- */
suspend fun checkForUpdates(
postNotification: Boolean
): LatestReleaseInfo?
/* - - - - - - - - - - - - - - - - - - - - - - */
suspend fun selectColorPreset(colorPreset: ColorPreset)
/* ------ Color Presets ---------------------- */
suspend fun updateColorPreset(
colorPreset: ColorPreset
)
suspend fun selectColorPreset(
colorPreset: ColorPreset
)
suspend fun getColorPresets(): List<ColorPreset>
suspend fun reorderColorPresets(orderedColorPresets: List<ColorPreset>)
suspend fun reorderColorPresets(
orderedColorPresets: List<ColorPreset>
)
suspend fun deleteColorPreset(colorPreset: ColorPreset)
suspend fun deleteColorPreset(
colorPreset: ColorPreset
)
/* - - - - - - - - - - - - - - - - - - - - - - */
/* ------ Favorite Directories --------------- */
suspend fun updateFavoriteDirectory(path: String)
/* - - - - - - - - - - - - - - - - - - - - - - */
}

View file

@ -6,7 +6,7 @@ import javax.inject.Inject
class DeleteHistory @Inject constructor(private val repository: BookRepository) {
suspend fun execute(history: List<History>) {
suspend fun execute(history: History) {
repository.deleteHistory(history)
}
}

View file

@ -1,6 +1,5 @@
package ua.acclorite.book_story.domain.use_case
import kotlinx.coroutines.CoroutineScope
import ua.acclorite.book_story.domain.repository.BookRepository
import ua.acclorite.book_story.presentation.data.MainState
import javax.inject.Inject
@ -9,7 +8,7 @@ class GetAllSettings @Inject constructor(
private val repository: BookRepository
) {
suspend fun execute(scope: CoroutineScope): MainState {
return repository.getAllSettings(scope)
suspend fun execute(): MainState {
return repository.getAllSettings()
}
}

View file

@ -1,14 +1,12 @@
package ua.acclorite.book_story.domain.use_case
import kotlinx.coroutines.flow.Flow
import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.repository.BookRepository
import ua.acclorite.book_story.domain.util.Resource
import javax.inject.Inject
class GetHistory @Inject constructor(private val repository: BookRepository) {
suspend fun execute(): Flow<Resource<List<History>>> {
suspend fun execute(): List<History> {
return repository.getHistory()
}
}

View file

@ -1,16 +1,13 @@
package ua.acclorite.book_story.domain.use_case
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.BookWithTextAndCover
import ua.acclorite.book_story.domain.repository.BookRepository
import ua.acclorite.book_story.domain.util.CoverImage
import javax.inject.Inject
class InsertBook @Inject constructor(private val repository: BookRepository) {
suspend fun execute(
book: Book,
coverImage: CoverImage?,
text: List<String>
bookWithTextAndCover: BookWithTextAndCover
): Boolean {
return repository.insertBook(book, coverImage, text)
return repository.insertBook(bookWithTextAndCover)
}
}

View file

@ -5,7 +5,7 @@ import ua.acclorite.book_story.domain.repository.BookRepository
import javax.inject.Inject
class InsertHistory @Inject constructor(private val repository: BookRepository) {
suspend fun execute(history: List<History>) {
suspend fun execute(history: History) {
repository.insertHistory(history)
}
}

View file

@ -7,6 +7,6 @@ import javax.inject.Inject
class UpdateBook @Inject constructor(private val repository: BookRepository) {
suspend fun execute(book: Book) {
repository.updateBooks(listOf(book))
repository.updateBook(book)
}
}

View file

@ -1,12 +1,12 @@
package ua.acclorite.book_story.domain.use_case
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.BookWithText
import ua.acclorite.book_story.domain.repository.BookRepository
import javax.inject.Inject
class UpdateBookWithText @Inject constructor(private val repository: BookRepository) {
suspend fun execute(book: Book, text: List<String>): Boolean {
return repository.updateBookWithText(book, text)
suspend fun execute(bookWithText: BookWithText): Boolean {
return repository.updateBookWithText(bookWithText)
}
}

View file

@ -468,7 +468,7 @@ class MainViewModel @Inject constructor(
settingsViewModel: SettingsViewModel,
) {
viewModelScope.launch(Dispatchers.Main) {
val settings = getAllSettings.execute(viewModelScope)
val settings = getAllSettings.execute()
// All additional execution
changeLanguage.execute(settings.language)

View file

@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.BookWithText
import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.model.NullableBook
@ -531,7 +532,7 @@ class BookInfoViewModel @Inject constructor(
var textUpdated = false
val updatedText = updatedBook.text
val updatedText = updatedBook.bookWithTextAndCover!!.text
val text = getText.execute(book.textPath)
if (updatedText != text) {
@ -560,7 +561,7 @@ class BookInfoViewModel @Inject constructor(
yield()
onEvent(
BookInfoEvent.OnShowConfirmUpdateDialog(
updatedText = updatedBook.text,
updatedText = updatedBook.bookWithTextAndCover.text,
)
)
@ -629,8 +630,10 @@ class BookInfoViewModel @Inject constructor(
val updatedText = _state.value.updatedText ?: return@launch
val isSuccess = updateBookWithText.execute(
book = _state.value.book,
text = updatedText
BookWithText(
book = _state.value.book,
text = updatedText
)
)
if (!isSuccess) {
@ -732,12 +735,10 @@ class BookInfoViewModel @Inject constructor(
onEvent(BookInfoEvent.OnCancelUpdate)
_state.value.book.id.let {
insertHistory.execute(
listOf(
History(
bookId = it,
book = null,
time = Date().time
)
History(
bookId = it,
book = null,
time = Date().time
)
)
}

View file

@ -46,14 +46,14 @@ fun BrowseAddingDialogItem(result: Pair<NullableBook, Selected>, onClick: (Boole
Modifier.weight(1f)
) {
Text(
text = result.first.book!!.title,
text = result.first.bookWithTextAndCover!!.book.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = result.first.book!!.author.asString(),
text = result.first.bookWithTextAndCover!!.book.author.asString(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,

View file

@ -467,12 +467,11 @@ class BrowseViewModel @Inject constructor(
return@launch
}
val failed = booksToInsert.any {
!insertBook.execute(
it.book!!,
it.coverImage,
it.text
)
var failed = false
booksToInsert.forEach {
if (!insertBook.execute(it.bookWithTextAndCover!!)) {
failed = true
}
}
if (failed) {

View file

@ -20,7 +20,6 @@ import ua.acclorite.book_story.domain.use_case.DeleteWholeHistory
import ua.acclorite.book_story.domain.use_case.GetBooksById
import ua.acclorite.book_story.domain.use_case.GetHistory
import ua.acclorite.book_story.domain.use_case.InsertHistory
import ua.acclorite.book_story.domain.util.Resource
import ua.acclorite.book_story.presentation.core.navigation.Screen
import ua.acclorite.book_story.presentation.core.util.BaseViewModel
import java.text.SimpleDateFormat
@ -119,9 +118,7 @@ class HistoryViewModel @Inject constructor(
is HistoryEvent.OnDeleteHistoryElement -> {
viewModelScope.launch(Dispatchers.IO) {
deleteHistory.execute(
listOf(event.historyToDelete)
)
deleteHistory.execute(event.historyToDelete)
_state.update {
it.copy(
@ -154,9 +151,7 @@ class HistoryViewModel @Inject constructor(
when (snackbar) {
SnackbarResult.Dismissed -> Unit
SnackbarResult.ActionPerformed -> {
insertHistory.execute(
listOf(event.historyToDelete)
)
insertHistory.execute(event.historyToDelete)
event.refreshList()
_state.update {
it.copy(
@ -256,12 +251,10 @@ class HistoryViewModel @Inject constructor(
viewModelScope.launch {
event.book.id.let {
insertHistory.execute(
listOf(
History(
bookId = it,
book = null,
time = Date().time
)
History(
bookId = it,
book = null,
time = Date().time
)
)
}
@ -300,91 +293,82 @@ class HistoryViewModel @Inject constructor(
return maxElementsById.filterNotNull()
}
getHistory.execute().collect { result ->
when (result) {
is Resource.Success -> {
val historyWithoutBook =
result.data?.sortedByDescending { it.time } ?: emptyList()
val historyWithoutBooks = getHistory.execute()
.sortedByDescending { it.time }
if (historyWithoutBook.isEmpty()) {
_state.update {
it.copy(
history = emptyList(),
isLoading = false
)
}
return@collect
}
val books = getBooksById.execute(
historyWithoutBook.map { it.bookId }.distinct()
)
if (books.isEmpty()) {
_state.update {
it.copy(
history = emptyList(),
isLoading = false
)
}
return@collect
}
val history = historyWithoutBook.map {
val book = books.find { book -> book.id == it.bookId }!!
it.copy(
book = book
)
}
val groupedHistory = mutableListOf<GroupedHistory>()
history
.filter {
val book = books.find { book -> book.id == it.bookId }!!
book.title.lowercase().trim().contains(query.lowercase().trim())
}.groupBy { item ->
val calendar = Calendar.getInstance().apply {
timeInMillis = item.time
}
val now = Calendar.getInstance()
when {
isSameDay(calendar, now) -> "today"
isSameDay(
calendar,
now.apply {
add(
Calendar.DAY_OF_YEAR,
-1
)
}) -> "yesterday"
else -> SimpleDateFormat(
"dd.MM.yy",
Locale.getDefault()
).format(item.time)
}
}.forEach { (key, value) ->
groupedHistory.add(
GroupedHistory(
key,
filterMaxElementsById(value)
)
)
}
_state.update {
it.copy(
history = groupedHistory,
isLoading = false
)
}
}
is Resource.Error -> Unit
if (historyWithoutBooks.isEmpty()) {
_state.update {
it.copy(
history = emptyList(),
isLoading = false
)
}
return
}
val books = getBooksById.execute(
historyWithoutBooks.map { it.bookId }.distinct()
)
if (books.isEmpty()) {
_state.update {
it.copy(
history = emptyList(),
isLoading = false
)
}
return
}
val history = historyWithoutBooks.map {
val book = books.find { book -> book.id == it.bookId }!!
it.copy(
book = book
)
}
val groupedHistory = mutableListOf<GroupedHistory>()
history
.filter {
val book = books.find { book -> book.id == it.bookId }!!
book.title.lowercase().trim().contains(query.lowercase().trim())
}.groupBy { item ->
val calendar = Calendar.getInstance().apply {
timeInMillis = item.time
}
val now = Calendar.getInstance()
when {
isSameDay(calendar, now) -> "today"
isSameDay(
calendar,
now.apply {
add(
Calendar.DAY_OF_YEAR,
-1
)
}) -> "yesterday"
else -> SimpleDateFormat(
"dd.MM.yy",
Locale.getDefault()
).format(item.time)
}
}.forEach { (key, value) ->
groupedHistory.add(
GroupedHistory(
key,
filterMaxElementsById(value)
)
)
}
_state.update {
it.copy(
history = groupedHistory,
isLoading = false
)
}
}
}

View file

@ -328,12 +328,10 @@ class LibraryViewModel @Inject constructor(
viewModelScope.launch {
event.book.id.let {
insertHistory.execute(
listOf(
History(
bookId = it,
book = null,
time = Date().time
)
History(
bookId = it,
book = null,
time = Date().time
)
)
}