fix: cannot open books

Resolves: #221
This commit is contained in:
Acclorite 2025-06-09 12:58:54 +03:00
parent dd7998c600
commit 57a7225545
No known key found for this signature in database
GPG key ID: 6E54C611F6EE8593
12 changed files with 148 additions and 121 deletions

View file

@ -9,8 +9,6 @@ package ua.acclorite.book_story.data.model.file
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.DocumentsContract import android.provider.DocumentsContract
import com.anggrayudi.storage.file.DocumentFileCompat
import com.anggrayudi.storage.file.getAbsolutePath
object CachedFileCompat { object CachedFileCompat {
fun fromUri(context: Context, uri: Uri, builder: CachedFileBuilder? = null): CachedFile { fun fromUri(context: Context, uri: Uri, builder: CachedFileBuilder? = null): CachedFile {
@ -32,61 +30,6 @@ object CachedFileCompat {
) )
} }
fun fromFullPath(
context: Context,
path: String,
builder: CachedFileBuilder? = null
): CachedFile? {
val uri = try {
val storageId = DocumentFileCompat.getStorageId(context, path)
if (storageId.isBlank()) throw NullPointerException("Could not get storageId.")
val basePath = DocumentFileCompat.getBasePath(context, path)
if (basePath.isBlank()) throw NullPointerException("Could not get basePath.")
val parentUri = context.contentResolver.persistedUriPermissions.find {
try {
val persistedUri = DocumentFileCompat.fromUri(context, it.uri)
val persistedUriPath = persistedUri?.getAbsolutePath(context)
return@find !(persistedUri == null ||
!persistedUri.canRead() ||
persistedUriPath.isNullOrBlank() ||
!path.startsWith(persistedUriPath, ignoreCase = true))
} catch (e: Exception) {
e.printStackTrace()
return@find false
}
}?.uri
if (parentUri == null) throw NullPointerException("Could not get parentUri.")
DocumentsContract.buildDocumentUriUsingTree(parentUri, "$storageId:$basePath")
} catch (e: Exception) {
e.printStackTrace()
return null
}
val cachedFile = CachedFile(
context = context,
uri = when {
DocumentsContract.isDocumentUri(context, uri) -> uri
DocumentsContract.isTreeUri(uri) -> {
DocumentsContract.buildDocumentUriUsingTree(
uri,
DocumentsContract.getTreeDocumentId(uri)
)
}
else -> uri
},
builder = builder
)
if (!cachedFile.canAccess()) return null
return cachedFile
}
fun build( fun build(
name: String? = null, name: String? = null,
path: String? = null, path: String? = null,

View file

@ -9,8 +9,10 @@ package ua.acclorite.book_story.data.repository
import ua.acclorite.book_story.core.CoverImage import ua.acclorite.book_story.core.CoverImage
import ua.acclorite.book_story.data.local.room.BookDao import ua.acclorite.book_story.data.local.room.BookDao
import ua.acclorite.book_story.data.mapper.book.BookMapper import ua.acclorite.book_story.data.mapper.book.BookMapper
import ua.acclorite.book_story.data.mapper.file.FileMapper
import ua.acclorite.book_story.data.parser.FileParser import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.data.parser.TextParser import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.model.file.File
import ua.acclorite.book_story.domain.model.library.Book import ua.acclorite.book_story.domain.model.library.Book
import ua.acclorite.book_story.domain.model.reader.ReaderText import ua.acclorite.book_story.domain.model.reader.ReaderText
import ua.acclorite.book_story.domain.repository.BookRepository import ua.acclorite.book_story.domain.repository.BookRepository
@ -22,6 +24,7 @@ import javax.inject.Singleton
class BookRepositoryImpl @Inject constructor( class BookRepositoryImpl @Inject constructor(
private val database: BookDao, private val database: BookDao,
private val bookMapper: BookMapper, private val bookMapper: BookMapper,
private val fileMapper: FileMapper,
private val fileParser: FileParser, private val fileParser: FileParser,
private val textParser: TextParser, private val textParser: TextParser,
private val fileProvider: FileProvider private val fileProvider: FileProvider
@ -44,6 +47,12 @@ class BookRepositoryImpl @Inject constructor(
.mapCatching { textParser.parse(it) } .mapCatching { textParser.parse(it) }
} }
override suspend fun getFileFromBook(bookId: Int): Result<File> {
return getBook(bookId)
.mapCatching { fileProvider.getFileFromBook(it).getOrThrow() }
.mapCatching { fileMapper.toFile(it) }
}
override suspend fun addBook(book: Book): Result<Unit> = runCatching { override suspend fun addBook(book: Book): Result<Unit> = runCatching {
database.insertBook(bookMapper.toBookEntity(book)) database.insertBook(bookMapper.toBookEntity(book))
} }

View file

@ -18,21 +18,23 @@ class FileProviderImpl @Inject constructor(
) : FileProvider { ) : FileProvider {
override fun getFileFromBook(book: Book): Result<CachedFile> = runCatching { override fun getFileFromBook(book: Book): Result<CachedFile> = runCatching {
CachedFileCompat.fromFullPath( application.contentResolver.persistedUriPermissions.forEach { storage ->
context = application, val storageFile = CachedFileCompat.fromUri(
path = book.filePath, application,
builder = CachedFileCompat.build( storage.uri
name = book.filePath.substringAfterLast(java.io.File.separator),
path = book.filePath,
isDirectory = false
) )
).let { file ->
if (file == null || !file.canAccess()) {
throw NoSuchElementException("Could not load CachedFile.")
}
file if (!storageFile.isDirectory) return@forEach
if (!book.filePath.startsWith(storageFile.path, ignoreCase = true)) return@forEach
storageFile.walk().forEach { file ->
if (book.filePath.equals(file.path, ignoreCase = true)) {
return@runCatching file
}
}
} }
throw NoSuchElementException("Could not find file from book.")
} }
override fun getStorageFiles(): Result<List<CachedFile>> = runCatching { override fun getStorageFiles(): Result<List<CachedFile>> = runCatching {

View file

@ -7,6 +7,7 @@
package ua.acclorite.book_story.domain.repository package ua.acclorite.book_story.domain.repository
import ua.acclorite.book_story.core.CoverImage import ua.acclorite.book_story.core.CoverImage
import ua.acclorite.book_story.domain.model.file.File
import ua.acclorite.book_story.domain.model.library.Book import ua.acclorite.book_story.domain.model.library.Book
import ua.acclorite.book_story.domain.model.reader.ReaderText import ua.acclorite.book_story.domain.model.reader.ReaderText
@ -23,6 +24,10 @@ interface BookRepository {
bookId: Int bookId: Int
): Result<List<ReaderText>> ): Result<List<ReaderText>>
suspend fun getFileFromBook(
bookId: Int
): Result<File>
suspend fun addBook( suspend fun addBook(
book: Book book: Book
): Result<Unit> ): Result<Unit>

View file

@ -0,0 +1,33 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.domain.use_case.book
import ua.acclorite.book_story.core.log.logE
import ua.acclorite.book_story.core.log.logI
import ua.acclorite.book_story.domain.model.file.File
import ua.acclorite.book_story.domain.repository.BookRepository
import javax.inject.Inject
class GetFileFromBookUseCase @Inject constructor(
private val bookRepository: BookRepository
) {
suspend operator fun invoke(bookId: Int): File? {
logI("Getting file from book [$bookId].")
return bookRepository.getFileFromBook(bookId).fold(
onSuccess = {
logI("Successfully got file from book [$bookId].")
it
},
onFailure = {
logE("Could not get file from book [$bookId] with error: ${it.message}")
null
}
)
}
}

View file

@ -24,6 +24,7 @@ import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.use_case.book.CanResetCoverImageUseCase import ua.acclorite.book_story.domain.use_case.book.CanResetCoverImageUseCase
import ua.acclorite.book_story.domain.use_case.book.DeleteBookUseCase import ua.acclorite.book_story.domain.use_case.book.DeleteBookUseCase
import ua.acclorite.book_story.domain.use_case.book.GetBookUseCase import ua.acclorite.book_story.domain.use_case.book.GetBookUseCase
import ua.acclorite.book_story.domain.use_case.book.GetFileFromBookUseCase
import ua.acclorite.book_story.domain.use_case.book.ResetCoverImageUseCase import ua.acclorite.book_story.domain.use_case.book.ResetCoverImageUseCase
import ua.acclorite.book_story.domain.use_case.book.UpdateBookUseCase import ua.acclorite.book_story.domain.use_case.book.UpdateBookUseCase
import ua.acclorite.book_story.domain.use_case.book.UpdateCoverImageUseCase import ua.acclorite.book_story.domain.use_case.book.UpdateCoverImageUseCase
@ -38,6 +39,7 @@ class BookInfoModel @Inject constructor(
private val updateCoverImageUseCase: UpdateCoverImageUseCase, private val updateCoverImageUseCase: UpdateCoverImageUseCase,
private val updateBookUseCase: UpdateBookUseCase, private val updateBookUseCase: UpdateBookUseCase,
private val getBookUseCase: GetBookUseCase, private val getBookUseCase: GetBookUseCase,
private val getFileFromBookUseCase: GetFileFromBookUseCase,
private val deleteBookUseCase: DeleteBookUseCase, private val deleteBookUseCase: DeleteBookUseCase,
private val canResetCoverImageUseCase: CanResetCoverImageUseCase, private val canResetCoverImageUseCase: CanResetCoverImageUseCase,
private val resetCoverImageUseCase: ResetCoverImageUseCase private val resetCoverImageUseCase: ResetCoverImageUseCase
@ -302,6 +304,13 @@ class BookInfoModel @Inject constructor(
event.context.getString(R.string.path_changed) event.context.getString(R.string.path_changed)
.showToast(context = event.context) .showToast(context = event.context)
} }
val file = getFileFromBookUseCase(_state.value.book.id)
_state.update {
it.copy(
file = file
)
}
} }
} }
@ -408,6 +417,13 @@ class BookInfoModel @Inject constructor(
onEvent(BookInfoEvent.OnShowPathDialog) onEvent(BookInfoEvent.OnShowPathDialog)
} }
onEvent(BookInfoEvent.OnCheckCoverReset) onEvent(BookInfoEvent.OnCheckCoverReset)
val file = getFileFromBookUseCase(bookId)
_state.update {
it.copy(
file = file
)
}
} }
} }

View file

@ -75,6 +75,7 @@ data class BookInfoScreen(val bookId: Int) : Screen, Parcelable {
if (state.value.book.id == bookId) { if (state.value.book.id == bookId) {
BookInfoContent( BookInfoContent(
book = state.value.book, book = state.value.book,
file = state.value.file,
categories = settingsState.value.categories, categories = settingsState.value.categories,
bottomSheet = state.value.bottomSheet, bottomSheet = state.value.bottomSheet,
dialog = state.value.dialog, dialog = state.value.dialog,

View file

@ -9,11 +9,13 @@ package ua.acclorite.book_story.presentation.book_info
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.core.BottomSheet import ua.acclorite.book_story.core.BottomSheet
import ua.acclorite.book_story.core.Dialog import ua.acclorite.book_story.core.Dialog
import ua.acclorite.book_story.domain.model.file.File
import ua.acclorite.book_story.domain.model.library.Book import ua.acclorite.book_story.domain.model.library.Book
@Immutable @Immutable
data class BookInfoState( data class BookInfoState(
val book: Book = Book.default, val book: Book = Book.default,
val file: File? = null,
val canResetCover: Boolean = false, val canResetCover: Boolean = false,

View file

@ -8,6 +8,7 @@ package ua.acclorite.book_story.ui.book_info
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import ua.acclorite.book_story.core.BottomSheet import ua.acclorite.book_story.core.BottomSheet
import ua.acclorite.book_story.domain.model.file.File
import ua.acclorite.book_story.domain.model.library.Book import ua.acclorite.book_story.domain.model.library.Book
import ua.acclorite.book_story.presentation.book_info.BookInfoEvent import ua.acclorite.book_story.presentation.book_info.BookInfoEvent
import ua.acclorite.book_story.presentation.book_info.BookInfoScreen import ua.acclorite.book_story.presentation.book_info.BookInfoScreen
@ -16,6 +17,7 @@ import ua.acclorite.book_story.presentation.book_info.BookInfoScreen
fun BookInfoBottomSheet( fun BookInfoBottomSheet(
bottomSheet: BottomSheet?, bottomSheet: BottomSheet?,
book: Book, book: Book,
file: File?,
canResetCover: Boolean, canResetCover: Boolean,
showPathDialog: (BookInfoEvent.OnShowPathDialog) -> Unit, showPathDialog: (BookInfoEvent.OnShowPathDialog) -> Unit,
changeCover: (BookInfoEvent.OnChangeCover) -> Unit, changeCover: (BookInfoEvent.OnChangeCover) -> Unit,
@ -40,6 +42,7 @@ fun BookInfoBottomSheet(
BookInfoScreen.DETAILS_BOTTOM_SHEET -> { BookInfoScreen.DETAILS_BOTTOM_SHEET -> {
BookInfoDetailsBottomSheet( BookInfoDetailsBottomSheet(
book = book, book = book,
file = file,
showPathDialog = showPathDialog, showPathDialog = showPathDialog,
dismissBottomSheet = dismissBottomSheet dismissBottomSheet = dismissBottomSheet
) )

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import ua.acclorite.book_story.core.BottomSheet import ua.acclorite.book_story.core.BottomSheet
import ua.acclorite.book_story.core.Dialog import ua.acclorite.book_story.core.Dialog
import ua.acclorite.book_story.domain.model.file.File
import ua.acclorite.book_story.domain.model.library.Book import ua.acclorite.book_story.domain.model.library.Book
import ua.acclorite.book_story.domain.model.library.Category import ua.acclorite.book_story.domain.model.library.Category
import ua.acclorite.book_story.presentation.book_info.BookInfoEvent import ua.acclorite.book_story.presentation.book_info.BookInfoEvent
@ -17,6 +18,7 @@ import ua.acclorite.book_story.presentation.book_info.BookInfoEvent
@Composable @Composable
fun BookInfoContent( fun BookInfoContent(
book: Book, book: Book,
file: File?,
categories: List<Category>, categories: List<Category>,
bottomSheet: BottomSheet?, bottomSheet: BottomSheet?,
dialog: Dialog?, dialog: Dialog?,
@ -64,6 +66,7 @@ fun BookInfoContent(
BookInfoBottomSheet( BookInfoBottomSheet(
bottomSheet = bottomSheet, bottomSheet = bottomSheet,
book = book, book = book,
file = file,
showPathDialog = showPathDialog, showPathDialog = showPathDialog,
canResetCover = canResetCover, canResetCover = canResetCover,
changeCover = changeCover, changeCover = changeCover,

View file

@ -14,12 +14,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.core.data.ExtensionsData import ua.acclorite.book_story.core.data.ExtensionsData
import ua.acclorite.book_story.data.model.file.CachedFileCompat import ua.acclorite.book_story.domain.model.file.File
import ua.acclorite.book_story.domain.model.library.Book import ua.acclorite.book_story.domain.model.library.Book
import ua.acclorite.book_story.presentation.book_info.BookInfoEvent import ua.acclorite.book_story.presentation.book_info.BookInfoEvent
import ua.acclorite.book_story.ui.common.components.common.LazyColumnWithScrollbar import ua.acclorite.book_story.ui.common.components.common.LazyColumnWithScrollbar
@ -32,20 +31,16 @@ import java.util.Locale
@Composable @Composable
fun BookInfoDetailsBottomSheet( fun BookInfoDetailsBottomSheet(
book: Book, book: Book,
file: File?,
showPathDialog: (BookInfoEvent.OnShowPathDialog) -> Unit, showPathDialog: (BookInfoEvent.OnShowPathDialog) -> Unit,
dismissBottomSheet: (BookInfoEvent.OnDismissBottomSheet) -> Unit dismissBottomSheet: (BookInfoEvent.OnDismissBottomSheet) -> Unit
) { ) {
val pattern = remember { SimpleDateFormat("HH:mm dd MMM yyyy", Locale.getDefault()) } val pattern = remember { SimpleDateFormat("HH:mm dd MMM yyyy", Locale.getDefault()) }
val lastOpened = remember(book.lastOpened) { pattern.format(Date(book.lastOpened ?: 0)) } val lastOpened = remember(book.lastOpened) { pattern.format(Date(book.lastOpened ?: 0)) }
val context = LocalContext.current val fileSize = remember(file) {
val cachedFile = remember(book.filePath) { if (file != null) {
CachedFileCompat.fromFullPath(context, book.filePath) val sizeBytes = file.size
}
val fileSize = remember(cachedFile) {
if (cachedFile != null && cachedFile.canAccess()) {
val sizeBytes = cachedFile.size
val sizeKB = sizeBytes / 1024f val sizeKB = sizeBytes / 1024f
val sizeMB = sizeKB / 1024f val sizeMB = sizeKB / 1024f
when { when {
@ -53,17 +48,25 @@ fun BookInfoDetailsBottomSheet(
sizeKB > 0f -> "%.2f KB".format(sizeKB) sizeKB > 0f -> "%.2f KB".format(sizeKB)
else -> "" else -> ""
} }
} else { } else ""
""
}
} }
val fileExists = remember(cachedFile) { val fileExists = remember(file) {
cachedFile.let { file.let { file ->
it != null && it.canAccess() && !it.isDirectory && ExtensionsData.fileExtensions if (file == null) return@let false
.any { ext -> if (file.isDirectory) return@let false
it.name.endsWith(ext, ignoreCase = true) if (
ExtensionsData.fileExtensions.none { ext ->
file.name.endsWith(
ext,
ignoreCase = true
)
} }
) {
return@let false
}
return@let true
} }
} }

View file

@ -8,13 +8,13 @@ package ua.acclorite.book_story.ui.book_info
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.EditNote import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.ui.common.components.common.IconButton import ua.acclorite.book_story.ui.common.components.common.IconButton
import ua.acclorite.book_story.ui.common.components.common.StyledText import ua.acclorite.book_story.ui.common.components.common.StyledText
import ua.acclorite.book_story.ui.theme.ExpandingTransition
@Composable @Composable
fun BookInfoDetailsBottomSheetItem( fun BookInfoDetailsBottomSheetItem(
@ -34,46 +35,52 @@ fun BookInfoDetailsBottomSheetItem(
errorMessage: String? = null, errorMessage: String? = null,
onEdit: () -> Unit = {} onEdit: () -> Unit = {}
) { ) {
Row( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
OutlinedTextField( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.weight(1f) horizontalArrangement = Arrangement.spacedBy(8.dp),
.focusable(false), verticalAlignment = Alignment.CenterVertically
value = text, ) {
onValueChange = {}, OutlinedTextField(
readOnly = true, modifier = Modifier
isError = showError, .weight(1f)
supportingText = if (!showError || errorMessage.isNullOrBlank()) null else { .focusable(false),
{ value = text,
StyledText( onValueChange = {},
text = errorMessage, readOnly = true,
style = LocalTextStyle.current.copy( isError = showError,
color = MaterialTheme.colorScheme.error label = {
) StyledText(label)
)
} }
}, )
label = {
StyledText(label)
}
)
if (editable) { if (editable) {
IconButton( IconButton(
icon = Icons.Default.EditNote, icon = Icons.Default.EditNote,
contentDescription = R.string.edit_content_desc, contentDescription = R.string.edit_content_desc,
disableOnClick = false, disableOnClick = false,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) { ) {
onEdit() onEdit()
}
} }
} }
ExpandingTransition(showError && !errorMessage.isNullOrBlank()) {
StyledText(
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 4.dp),
text = errorMessage!!,
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.error
)
)
}
} }
} }