diff --git a/app/src/main/java/ua/acclorite/book_story/data/model/file/CachedFileCompat.kt b/app/src/main/java/ua/acclorite/book_story/data/model/file/CachedFileCompat.kt index cc2a817d..be9dcb35 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/model/file/CachedFileCompat.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/model/file/CachedFileCompat.kt @@ -9,8 +9,6 @@ package ua.acclorite.book_story.data.model.file import android.content.Context import android.net.Uri import android.provider.DocumentsContract -import com.anggrayudi.storage.file.DocumentFileCompat -import com.anggrayudi.storage.file.getAbsolutePath object CachedFileCompat { 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( name: String? = null, path: String? = null, diff --git a/app/src/main/java/ua/acclorite/book_story/data/repository/BookRepositoryImpl.kt b/app/src/main/java/ua/acclorite/book_story/data/repository/BookRepositoryImpl.kt index cc973a36..814d8f82 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/repository/BookRepositoryImpl.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/repository/BookRepositoryImpl.kt @@ -9,8 +9,10 @@ package ua.acclorite.book_story.data.repository import ua.acclorite.book_story.core.CoverImage 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.file.FileMapper import ua.acclorite.book_story.data.parser.FileParser 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.reader.ReaderText import ua.acclorite.book_story.domain.repository.BookRepository @@ -22,6 +24,7 @@ import javax.inject.Singleton class BookRepositoryImpl @Inject constructor( private val database: BookDao, private val bookMapper: BookMapper, + private val fileMapper: FileMapper, private val fileParser: FileParser, private val textParser: TextParser, private val fileProvider: FileProvider @@ -44,6 +47,12 @@ class BookRepositoryImpl @Inject constructor( .mapCatching { textParser.parse(it) } } + override suspend fun getFileFromBook(bookId: Int): Result { + return getBook(bookId) + .mapCatching { fileProvider.getFileFromBook(it).getOrThrow() } + .mapCatching { fileMapper.toFile(it) } + } + override suspend fun addBook(book: Book): Result = runCatching { database.insertBook(bookMapper.toBookEntity(book)) } diff --git a/app/src/main/java/ua/acclorite/book_story/data/service/FileProviderImpl.kt b/app/src/main/java/ua/acclorite/book_story/data/service/FileProviderImpl.kt index 70940e37..99c45a3a 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/service/FileProviderImpl.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/service/FileProviderImpl.kt @@ -18,21 +18,23 @@ class FileProviderImpl @Inject constructor( ) : FileProvider { override fun getFileFromBook(book: Book): Result = runCatching { - CachedFileCompat.fromFullPath( - context = application, - path = book.filePath, - builder = CachedFileCompat.build( - name = book.filePath.substringAfterLast(java.io.File.separator), - path = book.filePath, - isDirectory = false + application.contentResolver.persistedUriPermissions.forEach { storage -> + val storageFile = CachedFileCompat.fromUri( + application, + storage.uri ) - ).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> = runCatching { diff --git a/app/src/main/java/ua/acclorite/book_story/domain/repository/BookRepository.kt b/app/src/main/java/ua/acclorite/book_story/domain/repository/BookRepository.kt index f5cf5d3f..ac84bb8a 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/repository/BookRepository.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/repository/BookRepository.kt @@ -7,6 +7,7 @@ package ua.acclorite.book_story.domain.repository 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.reader.ReaderText @@ -23,6 +24,10 @@ interface BookRepository { bookId: Int ): Result> + suspend fun getFileFromBook( + bookId: Int + ): Result + suspend fun addBook( book: Book ): Result diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/book/GetFileFromBookUseCase.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/book/GetFileFromBookUseCase.kt new file mode 100644 index 00000000..bb044596 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/book/GetFileFromBookUseCase.kt @@ -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 + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/book_info/BookInfoModel.kt b/app/src/main/java/ua/acclorite/book_story/presentation/book_info/BookInfoModel.kt index 553054d8..297572fa 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/book_info/BookInfoModel.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/book_info/BookInfoModel.kt @@ -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.DeleteBookUseCase 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.UpdateBookUseCase import ua.acclorite.book_story.domain.use_case.book.UpdateCoverImageUseCase @@ -38,6 +39,7 @@ class BookInfoModel @Inject constructor( private val updateCoverImageUseCase: UpdateCoverImageUseCase, private val updateBookUseCase: UpdateBookUseCase, private val getBookUseCase: GetBookUseCase, + private val getFileFromBookUseCase: GetFileFromBookUseCase, private val deleteBookUseCase: DeleteBookUseCase, private val canResetCoverImageUseCase: CanResetCoverImageUseCase, private val resetCoverImageUseCase: ResetCoverImageUseCase @@ -302,6 +304,13 @@ class BookInfoModel @Inject constructor( event.context.getString(R.string.path_changed) .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.OnCheckCoverReset) + + val file = getFileFromBookUseCase(bookId) + _state.update { + it.copy( + file = file + ) + } } } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/book_info/BookInfoScreen.kt b/app/src/main/java/ua/acclorite/book_story/presentation/book_info/BookInfoScreen.kt index cc05b54e..9db57325 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/book_info/BookInfoScreen.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/book_info/BookInfoScreen.kt @@ -75,6 +75,7 @@ data class BookInfoScreen(val bookId: Int) : Screen, Parcelable { if (state.value.book.id == bookId) { BookInfoContent( book = state.value.book, + file = state.value.file, categories = settingsState.value.categories, bottomSheet = state.value.bottomSheet, dialog = state.value.dialog, diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/book_info/BookInfoState.kt b/app/src/main/java/ua/acclorite/book_story/presentation/book_info/BookInfoState.kt index 7d1437fd..e11b7c00 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/book_info/BookInfoState.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/book_info/BookInfoState.kt @@ -9,11 +9,13 @@ package ua.acclorite.book_story.presentation.book_info import androidx.compose.runtime.Immutable import ua.acclorite.book_story.core.BottomSheet 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 @Immutable data class BookInfoState( val book: Book = Book.default, + val file: File? = null, val canResetCover: Boolean = false, diff --git a/app/src/main/java/ua/acclorite/book_story/ui/book_info/BookInfoBottomSheet.kt b/app/src/main/java/ua/acclorite/book_story/ui/book_info/BookInfoBottomSheet.kt index 926748e5..12c52d13 100644 --- a/app/src/main/java/ua/acclorite/book_story/ui/book_info/BookInfoBottomSheet.kt +++ b/app/src/main/java/ua/acclorite/book_story/ui/book_info/BookInfoBottomSheet.kt @@ -8,6 +8,7 @@ package ua.acclorite.book_story.ui.book_info import androidx.compose.runtime.Composable 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.presentation.book_info.BookInfoEvent import ua.acclorite.book_story.presentation.book_info.BookInfoScreen @@ -16,6 +17,7 @@ import ua.acclorite.book_story.presentation.book_info.BookInfoScreen fun BookInfoBottomSheet( bottomSheet: BottomSheet?, book: Book, + file: File?, canResetCover: Boolean, showPathDialog: (BookInfoEvent.OnShowPathDialog) -> Unit, changeCover: (BookInfoEvent.OnChangeCover) -> Unit, @@ -40,6 +42,7 @@ fun BookInfoBottomSheet( BookInfoScreen.DETAILS_BOTTOM_SHEET -> { BookInfoDetailsBottomSheet( book = book, + file = file, showPathDialog = showPathDialog, dismissBottomSheet = dismissBottomSheet ) diff --git a/app/src/main/java/ua/acclorite/book_story/ui/book_info/BookInfoContent.kt b/app/src/main/java/ua/acclorite/book_story/ui/book_info/BookInfoContent.kt index 8ff07ba5..b61e1154 100644 --- a/app/src/main/java/ua/acclorite/book_story/ui/book_info/BookInfoContent.kt +++ b/app/src/main/java/ua/acclorite/book_story/ui/book_info/BookInfoContent.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import ua.acclorite.book_story.core.BottomSheet 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.Category import ua.acclorite.book_story.presentation.book_info.BookInfoEvent @@ -17,6 +18,7 @@ import ua.acclorite.book_story.presentation.book_info.BookInfoEvent @Composable fun BookInfoContent( book: Book, + file: File?, categories: List, bottomSheet: BottomSheet?, dialog: Dialog?, @@ -64,6 +66,7 @@ fun BookInfoContent( BookInfoBottomSheet( bottomSheet = bottomSheet, book = book, + file = file, showPathDialog = showPathDialog, canResetCover = canResetCover, changeCover = changeCover, diff --git a/app/src/main/java/ua/acclorite/book_story/ui/book_info/BookInfoDetailsBottomSheet.kt b/app/src/main/java/ua/acclorite/book_story/ui/book_info/BookInfoDetailsBottomSheet.kt index e9e65e5c..4e55e637 100644 --- a/app/src/main/java/ua/acclorite/book_story/ui/book_info/BookInfoDetailsBottomSheet.kt +++ b/app/src/main/java/ua/acclorite/book_story/ui/book_info/BookInfoDetailsBottomSheet.kt @@ -14,12 +14,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import ua.acclorite.book_story.R 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.presentation.book_info.BookInfoEvent import ua.acclorite.book_story.ui.common.components.common.LazyColumnWithScrollbar @@ -32,20 +31,16 @@ import java.util.Locale @Composable fun BookInfoDetailsBottomSheet( book: Book, + file: File?, showPathDialog: (BookInfoEvent.OnShowPathDialog) -> Unit, dismissBottomSheet: (BookInfoEvent.OnDismissBottomSheet) -> Unit ) { val pattern = remember { SimpleDateFormat("HH:mm dd MMM yyyy", Locale.getDefault()) } val lastOpened = remember(book.lastOpened) { pattern.format(Date(book.lastOpened ?: 0)) } - val context = LocalContext.current - val cachedFile = remember(book.filePath) { - CachedFileCompat.fromFullPath(context, book.filePath) - } - - val fileSize = remember(cachedFile) { - if (cachedFile != null && cachedFile.canAccess()) { - val sizeBytes = cachedFile.size + val fileSize = remember(file) { + if (file != null) { + val sizeBytes = file.size val sizeKB = sizeBytes / 1024f val sizeMB = sizeKB / 1024f when { @@ -53,17 +48,25 @@ fun BookInfoDetailsBottomSheet( sizeKB > 0f -> "%.2f KB".format(sizeKB) else -> "" } - } else { - "" - } + } else "" } - val fileExists = remember(cachedFile) { - cachedFile.let { - it != null && it.canAccess() && !it.isDirectory && ExtensionsData.fileExtensions - .any { ext -> - it.name.endsWith(ext, ignoreCase = true) + val fileExists = remember(file) { + file.let { file -> + if (file == null) return@let false + if (file.isDirectory) return@let false + if ( + ExtensionsData.fileExtensions.none { ext -> + file.name.endsWith( + ext, + ignoreCase = true + ) } + ) { + return@let false + } + + return@let true } } diff --git a/app/src/main/java/ua/acclorite/book_story/ui/book_info/BookInfoDetailsBottomSheetItem.kt b/app/src/main/java/ua/acclorite/book_story/ui/book_info/BookInfoDetailsBottomSheetItem.kt index 78b605b7..432e8c0c 100644 --- a/app/src/main/java/ua/acclorite/book_story/ui/book_info/BookInfoDetailsBottomSheetItem.kt +++ b/app/src/main/java/ua/acclorite/book_story/ui/book_info/BookInfoDetailsBottomSheetItem.kt @@ -8,13 +8,13 @@ package ua.acclorite.book_story.ui.book_info import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.EditNote -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField 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.ui.common.components.common.IconButton import ua.acclorite.book_story.ui.common.components.common.StyledText +import ua.acclorite.book_story.ui.theme.ExpandingTransition @Composable fun BookInfoDetailsBottomSheetItem( @@ -34,46 +35,52 @@ fun BookInfoDetailsBottomSheetItem( errorMessage: String? = null, onEdit: () -> Unit = {} ) { - Row( + Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically ) { - OutlinedTextField( - modifier = Modifier - .weight(1f) - .focusable(false), - value = text, - onValueChange = {}, - readOnly = true, - isError = showError, - supportingText = if (!showError || errorMessage.isNullOrBlank()) null else { - { - StyledText( - text = errorMessage, - style = LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.error - ) - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + modifier = Modifier + .weight(1f) + .focusable(false), + value = text, + onValueChange = {}, + readOnly = true, + isError = showError, + label = { + StyledText(label) } - }, - label = { - StyledText(label) - } - ) + ) - if (editable) { - IconButton( - icon = Icons.Default.EditNote, - contentDescription = R.string.edit_content_desc, - disableOnClick = false, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) { - onEdit() + if (editable) { + IconButton( + icon = Icons.Default.EditNote, + contentDescription = R.string.edit_content_desc, + disableOnClick = false, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) { + 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 + ) + ) + } } } \ No newline at end of file