🚀 Storage Access Framework

* Per-folder storage access
* Easily revoke per-folder access
* No storage permission
* Privacy focus (Milestone #5)

Resolves: #38
This commit is contained in:
Acclorite 2025-03-02 13:09:10 +02:00
parent beb118f234
commit 8cbe14b0e0
82 changed files with 1424 additions and 1588 deletions

View file

@ -1,12 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Check device for books -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<!-- Removing DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION, which is not used -->
<uses-permission
android:name="ua.acclorite.book_story.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION"
@ -19,11 +13,9 @@
android:icon="@mipmap/app_icon"
android:label="@string/app_name"
android:largeHeap="true"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/app_icon_round"
android:supportsRtl="true"
android:theme="@style/Splash"
android:usesCleartextTraffic="false"
tools:ignore="DataExtractionRules"
tools:targetApi="tiramisu">

View file

@ -30,7 +30,8 @@ class DocumentParser @Inject constructor(
*
* @return Parsed text line by line with Markdown(all lines are not blank).
*/
suspend fun Document.parseDocument(
suspend fun parseDocument(
document: Document,
zipFile: ZipFile? = null,
imageEntries: List<ZipEntry>? = null,
includeChapter: Boolean = true
@ -40,148 +41,150 @@ class DocumentParser @Inject constructor(
val readerText = mutableListOf<ReaderText>()
var chapterAdded = false
body().apply {
// Remove manual line breaks from all <p>, <a>
select("p").forEach { element ->
yield()
element.html(element.html().replace(Regex("\\n+"), " "))
element.append("\n")
}
select("a").forEach { element ->
yield()
element.html(element.html().replace(Regex("\\n+"), ""))
}
// Remove <head>'s title
select("title").remove()
// Markdown
select("hr").append("\n---\n")
select("b").append("**").prepend("**")
select("h1").append("**").prepend("**")
select("h2").append("**").prepend("**")
select("h3").append("**").prepend("**")
select("strong").append("**").prepend("**")
select("em").append("_").prepend("_")
select("a").forEach { element ->
var link = element.attr("href")
if (!link.startsWith("http") || element.wholeText().isBlank()) return@forEach
if (link.startsWith("http://")) {
link = link.replace("http://", "https://")
document.selectFirst("body")
.run { if (this == null) document.body() else this }
.apply {
// Remove manual line breaks from all <p>, <a>
select("p").forEach { element ->
yield()
element.html(element.html().replace(Regex("\\n+"), " "))
element.append("\n")
}
select("a").forEach { element ->
yield()
element.html(element.html().replace(Regex("\\n+"), ""))
}
element.prepend("[")
element.append("]($link)")
}
// Remove <head>'s title
select("title").remove()
// Image (<img>)
select("img").forEach { element ->
val src = element.attr("src")
.trim()
.substringAfterLast(File.separator)
.lowercase()
.takeIf {
it.containsVisibleText() && imageEntries?.any { image ->
it == image.name.substringAfterLast(File.separator).lowercase()
} == true
} ?: return@forEach
// Markdown
select("hr").append("\n---\n")
select("b").append("**").prepend("**")
select("h1").append("**").prepend("**")
select("h2").append("**").prepend("**")
select("h3").append("**").prepend("**")
select("strong").append("**").prepend("**")
select("em").append("_").prepend("_")
select("a").forEach { element ->
var link = element.attr("href")
if (!link.startsWith("http") || element.wholeText().isBlank()) return@forEach
val alt = element.attr("alt").trim().takeIf {
it.clearMarkdown().containsVisibleText()
} ?: src.substringBeforeLast(".")
element.append("\n[[$src|$alt]]\n")
}
// Image (<image>)
select("image").forEach { element ->
val src = element.attr("xlink:href")
.trim()
.substringAfterLast(File.separator)
.lowercase()
.takeIf {
it.containsVisibleText() && imageEntries?.any { image ->
it == image.name.substringAfterLast(File.separator).lowercase()
} == true
} ?: return@forEach
val alt = src.substringBeforeLast(".")
element.append("\n[[$src|$alt]]\n")
}
}.wholeText().lines().forEach { line ->
yield()
val formattedLine = line.replace(
Regex("""\*\*\*\s*(.*?)\s*\*\*\*"""), "_**$1**_"
).replace(
Regex("""\*\*\s*(.*?)\s*\*\*"""), "**$1**"
).replace(
Regex("""_\s*(.*?)\s*_"""), "_$1_"
).trim()
val imageRegex = Regex("""\[\[(.*?)\|(.*?)]]""")
if (line.containsVisibleText()) {
when {
imageRegex.matches(line) -> {
val trimmedLine = line.removeSurrounding("[[", "]]")
val src = trimmedLine.substringBefore("|")
val alt = "_${trimmedLine.substringAfter("|")}_"
val image = try {
val imageEntry = imageEntries?.find { image ->
src == image.name.substringAfterLast(File.separator).lowercase()
} ?: return@forEach
zipFile?.getImage(imageEntry)?.asImageBitmap()
} catch (e: Exception) {
e.printStackTrace()
null
} ?: return@forEach
image.prepareToDraw()
readerText.add( // Adding image
ReaderText.Image(
imageBitmap = image
)
)
readerText.add( // Adding alternative text (caption) for image
ReaderText.Text(
markdownParser.parse(alt)
)
)
if (link.startsWith("http://")) {
link = link.replace("http://", "https://")
}
line == "---" || line == "***" -> readerText.add(ReaderText.Separator)
element.prepend("[")
element.append("]($link)")
}
else -> {
if (
!chapterAdded &&
formattedLine.clearAllMarkdown().containsVisibleText() &&
includeChapter
) {
readerText.add(
0, ReaderText.Chapter(
title = formattedLine.clearAllMarkdown(),
nested = false
// Image (<img>)
select("img").forEach { element ->
val src = element.attr("src")
.trim()
.substringAfterLast(File.separator)
.lowercase()
.takeIf {
it.containsVisibleText() && imageEntries?.any { image ->
it == image.name.substringAfterLast(File.separator).lowercase()
} == true
} ?: return@forEach
val alt = element.attr("alt").trim().takeIf {
it.clearMarkdown().containsVisibleText()
} ?: src.substringBeforeLast(".")
element.append("\n[[$src|$alt]]\n")
}
// Image (<image>)
select("image").forEach { element ->
val src = element.attr("xlink:href")
.trim()
.substringAfterLast(File.separator)
.lowercase()
.takeIf {
it.containsVisibleText() && imageEntries?.any { image ->
it == image.name.substringAfterLast(File.separator).lowercase()
} == true
} ?: return@forEach
val alt = src.substringBeforeLast(".")
element.append("\n[[$src|$alt]]\n")
}
}.wholeText().lines().forEach { line ->
yield()
val formattedLine = line.replace(
Regex("""\*\*\*\s*(.*?)\s*\*\*\*"""), "_**$1**_"
).replace(
Regex("""\*\*\s*(.*?)\s*\*\*"""), "**$1**"
).replace(
Regex("""_\s*(.*?)\s*_"""), "_$1_"
).trim()
val imageRegex = Regex("""\[\[(.*?)\|(.*?)]]""")
if (line.containsVisibleText()) {
when {
imageRegex.matches(line) -> {
val trimmedLine = line.removeSurrounding("[[", "]]")
val src = trimmedLine.substringBefore("|")
val alt = "_${trimmedLine.substringAfter("|")}_"
val image = try {
val imageEntry = imageEntries?.find { image ->
src == image.name.substringAfterLast(File.separator).lowercase()
} ?: return@forEach
zipFile?.getImage(imageEntry)?.asImageBitmap()
} catch (e: Exception) {
e.printStackTrace()
null
} ?: return@forEach
image.prepareToDraw()
readerText.add( // Adding image
ReaderText.Image(
imageBitmap = image
)
)
chapterAdded = true
} else if (
formattedLine.clearMarkdown().containsVisibleText()
) {
readerText.add(
readerText.add( // Adding alternative text (caption) for image
ReaderText.Text(
line = markdownParser.parse(formattedLine)
markdownParser.parse(alt)
)
)
}
line == "---" || line == "***" -> readerText.add(ReaderText.Separator)
else -> {
if (
!chapterAdded &&
formattedLine.clearAllMarkdown().containsVisibleText() &&
includeChapter
) {
readerText.add(
0, ReaderText.Chapter(
title = formattedLine.clearAllMarkdown(),
nested = false
)
)
chapterAdded = true
} else if (
formattedLine.clearMarkdown().containsVisibleText()
) {
readerText.add(
ReaderText.Text(
line = markdownParser.parse(formattedLine)
)
)
}
}
}
}
}
}
yield()

View file

@ -6,11 +6,11 @@
package ua.acclorite.book_story.data.parser
import ua.acclorite.book_story.domain.file.CachedFile
import ua.acclorite.book_story.domain.library.book.BookWithCover
import java.io.File
interface FileParser {
suspend fun parse(file: File): BookWithCover?
suspend fun parse(cachedFile: CachedFile): BookWithCover?
}

View file

@ -12,8 +12,8 @@ import ua.acclorite.book_story.data.parser.fb2.Fb2FileParser
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.file.CachedFile
import ua.acclorite.book_story.domain.library.book.BookWithCover
import java.io.File
import javax.inject.Inject
private const val FILE_PARSER = "File Parser"
@ -25,40 +25,41 @@ class FileParserImpl @Inject constructor(
private val fb2FileParser: Fb2FileParser,
private val htmlFileParser: HtmlFileParser,
) : FileParser {
override suspend fun parse(file: File): BookWithCover? {
if (!file.exists()) {
Log.e(FILE_PARSER, "File does not exist.")
override suspend fun parse(cachedFile: CachedFile): BookWithCover? {
if (!cachedFile.canAccess()) {
Log.e(FILE_PARSER, "File does not exist or no read access is granted.")
return null
}
val fileFormat = ".${file.extension}".lowercase().trim()
val fileFormat = ".${cachedFile.name.substringAfterLast(".")}".lowercase().trim()
return when (fileFormat) {
".pdf" -> {
pdfFileParser.parse(file)
pdfFileParser.parse(cachedFile)
}
".epub" -> {
epubFileParser.parse(file)
epubFileParser.parse(cachedFile)
}
".txt" -> {
txtFileParser.parse(file)
txtFileParser.parse(cachedFile)
}
".fb2" -> {
fb2FileParser.parse(file)
fb2FileParser.parse(cachedFile)
}
".html" -> {
htmlFileParser.parse(file)
htmlFileParser.parse(cachedFile)
}
".htm" -> {
htmlFileParser.parse(file)
htmlFileParser.parse(cachedFile)
}
".md" -> {
txtFileParser.parse(file)
txtFileParser.parse(cachedFile)
}
else -> {
@ -67,12 +68,4 @@ class FileParserImpl @Inject constructor(
}
}
}
}
}

View file

@ -6,9 +6,10 @@
package ua.acclorite.book_story.data.parser
import ua.acclorite.book_story.domain.file.CachedFile
import ua.acclorite.book_story.domain.reader.ReaderText
import java.io.File
interface TextParser {
suspend fun parse(file: File): List<ReaderText>
suspend fun parse(cachedFile: CachedFile): List<ReaderText>
}

View file

@ -10,12 +10,11 @@ import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ua.acclorite.book_story.data.parser.epub.EpubTextParser
import ua.acclorite.book_story.data.parser.fb2.Fb2TextParser
import ua.acclorite.book_story.data.parser.html.HtmlTextParser
import ua.acclorite.book_story.data.parser.pdf.PdfTextParser
import ua.acclorite.book_story.data.parser.txt.TxtTextParser
import ua.acclorite.book_story.domain.file.CachedFile
import ua.acclorite.book_story.domain.reader.ReaderText
import java.io.File
import javax.inject.Inject
private const val TEXT_PARSER = "Text Parser"
@ -24,47 +23,47 @@ class TextParserImpl @Inject constructor(
// Markdown parser (Markdown)
private val txtTextParser: TxtTextParser,
private val pdfTextParser: PdfTextParser,
private val fb2TextParser: Fb2TextParser,
// Document parser (HTML+Markdown)
private val epubTextParser: EpubTextParser,
private val htmlTextParser: HtmlTextParser
) : TextParser {
override suspend fun parse(file: File): List<ReaderText> {
if (!file.exists()) {
Log.e(TEXT_PARSER, "File does not exist.")
override suspend fun parse(cachedFile: CachedFile): List<ReaderText> {
if (!cachedFile.canAccess()) {
Log.e(TEXT_PARSER, "File does not exist or no read access is granted.")
return emptyList()
}
val fileFormat = ".${file.extension}".lowercase().trim()
val fileFormat = ".${cachedFile.name.substringAfterLast(".")}".lowercase().trim()
return withContext(Dispatchers.IO) {
when (fileFormat) {
".pdf" -> {
pdfTextParser.parse(file)
pdfTextParser.parse(cachedFile)
}
".epub" -> {
epubTextParser.parse(file)
epubTextParser.parse(cachedFile)
}
".txt" -> {
txtTextParser.parse(file)
txtTextParser.parse(cachedFile)
}
".fb2" -> {
fb2TextParser.parse(file)
htmlTextParser.parse(cachedFile)
}
".html" -> {
htmlTextParser.parse(file)
htmlTextParser.parse(cachedFile)
}
".htm" -> {
htmlTextParser.parse(file)
htmlTextParser.parse(cachedFile)
}
".md" -> {
htmlTextParser.parse(file)
htmlTextParser.parse(cachedFile)
}
else -> {

View file

@ -13,6 +13,7 @@ import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.file.CachedFile
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.library.book.BookWithCover
import ua.acclorite.book_story.domain.library.category.Category
@ -23,12 +24,15 @@ import javax.inject.Inject
class EpubFileParser @Inject constructor() : FileParser {
override suspend fun parse(file: File): BookWithCover? {
override suspend fun parse(cachedFile: CachedFile): BookWithCover? {
return try {
var book: BookWithCover? = null
val rawFile = cachedFile.rawFile
if (rawFile == null || !rawFile.exists() || !rawFile.canRead()) return null
withContext(Dispatchers.IO) {
ZipFile(file).use { zip ->
ZipFile(rawFile).use { zip ->
val opfEntry = zip.entries().asSequence().find { entry ->
entry.name.endsWith(".opf", ignoreCase = true)
} ?: return@withContext
@ -41,7 +45,7 @@ class EpubFileParser @Inject constructor() : FileParser {
val title = document.select("metadata > dc|title").text().trim().run {
ifBlank {
file.nameWithoutExtension.trim()
cachedFile.name.substringBeforeLast(".").trim()
}
}
@ -85,12 +89,12 @@ class EpubFileParser @Inject constructor() : FileParser {
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
filePath = cachedFile.path,
lastOpened = null,
category = Category.entries[0],
coverImage = null
),
coverImage = extractCoverImageBitmap(file, coverImage)
coverImage = extractCoverImageBitmap(rawFile, coverImage)
)
}
}

View file

@ -19,6 +19,7 @@ import kotlinx.coroutines.yield
import org.jsoup.Jsoup
import ua.acclorite.book_story.data.parser.DocumentParser
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.file.CachedFile
import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.presentation.core.constants.Constants
import ua.acclorite.book_story.presentation.core.constants.provideImageExtensions
@ -40,15 +41,18 @@ class EpubTextParser @Inject constructor(
private val documentParser: DocumentParser
) : TextParser {
override suspend fun parse(file: File): List<ReaderText> {
Log.i(EPUB_TAG, "Started EPUB parsing: ${file.name}.")
override suspend fun parse(cachedFile: CachedFile): List<ReaderText> {
Log.i(EPUB_TAG, "Started EPUB parsing: ${cachedFile.name}.")
return try {
yield()
var readerText = listOf<ReaderText>()
val rawFile = cachedFile.rawFile
if (rawFile == null || !rawFile.exists() || !rawFile.canRead()) return emptyList()
withContext(Dispatchers.IO) {
ZipFile(file).use { zip ->
ZipFile(rawFile).use { zip ->
val tocEntry = zip.entries().toList().find { entry ->
entry.name.endsWith(".ncx", ignoreCase = true)
}
@ -163,13 +167,12 @@ class EpubTextParser @Inject constructor(
) {
// Getting all text
val content = zip.getInputStream(entry).bufferedReader().use { it.readText() }
var readerText = documentParser.run {
Jsoup.parse(content).parseDocument(
zipFile = zip,
imageEntries = imageEntries,
includeChapter = false
)
}.toMutableList()
var readerText = documentParser.parseDocument(
document = Jsoup.parse(content),
zipFile = zip,
imageEntries = imageEntries,
includeChapter = false
).toMutableList()
// Adding chapter title from TOC if found
getChapterTitleFromToc(

View file

@ -6,65 +6,55 @@
package ua.acclorite.book_story.data.parser.fb2
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.w3c.dom.Document
import org.w3c.dom.Element
import org.jsoup.Jsoup
import org.jsoup.parser.Parser
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.file.CachedFile
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.library.book.BookWithCover
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.ui.UIText
import java.io.File
import javax.inject.Inject
import javax.xml.parsers.DocumentBuilderFactory
class Fb2FileParser @Inject constructor() : FileParser {
override suspend fun parse(file: File): BookWithCover? {
override suspend fun parse(cachedFile: CachedFile): BookWithCover? {
return try {
val factory = DocumentBuilderFactory.newInstance()
val builder = factory.newDocumentBuilder()
val document = withContext(Dispatchers.IO) {
builder.parse(file)
val document = cachedFile.openInputStream()?.use {
Jsoup.parse(it, null, "", Parser.xmlParser())
}
val titleFromFile = extractElementContent(document, "book-title")
val title = titleFromFile ?: file.nameWithoutExtension.trim()
val authorFirstName = extractElementContent(document, "first-name")
val authorLastName = extractElementContent(document, "last-name")
val authorFromFile = StringBuilder()
if (authorFirstName != null) {
authorFromFile.append(
"$authorFirstName "
)
}
if (authorLastName != null) {
authorFromFile.append(
authorLastName
)
val title = document?.selectFirst("book-title")?.text()?.trim().run {
if (isNullOrBlank()) {
return@run cachedFile.name.substringBeforeLast(".").trim()
}
this
}
val author = if (authorFromFile.isNotBlank()) {
UIText.StringValue(authorFromFile.toString().trim())
} else {
UIText.StringResource(R.string.unknown_author)
val author = document?.selectFirst("author")?.text()?.trim().run {
if (isNullOrBlank()) {
return@run UIText.StringResource(R.string.unknown_author)
}
UIText.StringValue(this.trim())
}
val descriptionFromFile = extractElementContent(document, "annotation")
val description = document?.selectFirst("annotation")?.text()?.trim().run {
if (isNullOrBlank()) {
return@run null
}
this
}
BookWithCover(
book = Book(
title = title,
author = author,
description = descriptionFromFile,
description = description,
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
filePath = cachedFile.path,
lastOpened = null,
category = Category.entries[0],
coverImage = null
@ -76,13 +66,4 @@ class Fb2FileParser @Inject constructor() : FileParser {
null
}
}
private fun extractElementContent(document: Document, tagName: String): String? {
val nodeList = document.getElementsByTagName(tagName)
if (nodeList.length > 0) {
val element = nodeList.item(0) as Element
return element.textContent.trim()
}
return null
}
}

View file

@ -1,168 +0,0 @@
/*
* 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.data.parser.fb2
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.w3c.dom.Element
import org.w3c.dom.NodeList
import ua.acclorite.book_story.data.parser.MarkdownParser
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.presentation.core.util.clearAllMarkdown
import java.io.File
import javax.inject.Inject
import javax.xml.parsers.DocumentBuilderFactory
private const val FB2_TAG = "FB2 Parser"
class Fb2TextParser @Inject constructor(
private val markdownParser: MarkdownParser
) : TextParser {
override suspend fun parse(file: File): List<ReaderText> {
Log.i(FB2_TAG, "Started FB2 parsing: ${file.name}.")
return try {
val factory = DocumentBuilderFactory.newInstance()
val builder = factory.newDocumentBuilder()
val document = withContext(Dispatchers.IO) {
builder.parse(file)
}
val readerText = mutableListOf<ReaderText>()
val bodyNodes = document.getElementsByTagName("body")
if (bodyNodes.length == 0) {
return emptyList()
}
yield()
val unformattedLines = mutableListOf<String>()
val bodyNode = bodyNodes.item(0) as Element
val paragraphNodes = bodyNode.getElementsByTagName("p")
for (element in paragraphNodes.asList()) {
yield()
if (element.textContent.isBlank()) {
continue
}
unformattedLines.add(
element.textContent.trim()
)
}
yield()
val lines = mutableListOf<String>()
unformattedLines.forEachIndexed { index, string ->
try {
yield()
val line = string.trim()
if (index == 0) {
lines.add(line)
return@forEachIndexed
}
if (line.all { it.isDigit() }) {
return@forEachIndexed
}
if (line.first().isLowerCase()) {
val currentLine = lines[lines.lastIndex]
if (currentLine.last() == '-') {
if (currentLine[currentLine.lastIndex - 1].isLowerCase()) {
lines[lines.lastIndex] = currentLine.dropLast(1) + line
return@forEachIndexed
}
}
lines[lines.lastIndex] += " $line"
return@forEachIndexed
}
if (line.first().isUpperCase() || line.first().isDigit()) {
lines.add(line)
return@forEachIndexed
}
if (line.first().isLetter()) {
lines[lines.lastIndex] += " $line"
return@forEachIndexed
}
} catch (e: Exception) {
e.printStackTrace()
return@forEachIndexed
}
}
yield()
var chapterAdded = false
lines.forEach { line ->
yield()
if (line.isNotBlank()) {
when (line) {
"***", "---" -> readerText.add(
ReaderText.Separator
)
else -> {
if (!chapterAdded && line.clearAllMarkdown().isNotBlank()) {
readerText.add(
0, ReaderText.Chapter(
title = line.clearAllMarkdown(),
nested = false
)
)
chapterAdded = true
} else readerText.add(
ReaderText.Text(
line = markdownParser.parse(line)
)
)
}
}
}
}
yield()
if (
readerText.filterIsInstance<ReaderText.Text>().isEmpty() ||
readerText.filterIsInstance<ReaderText.Chapter>().isEmpty()
) {
Log.e(FB2_TAG, "Could not extract text from FB2.")
return emptyList()
}
Log.i(FB2_TAG, "Successfully finished FB2 parsing.")
readerText
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
}
private fun NodeList.asList(): List<Element> {
val list = mutableListOf<Element>()
for (i in 0 until this.length) {
list.add(this.item(i) as Element)
}
return list
}
}

View file

@ -7,25 +7,29 @@
package ua.acclorite.book_story.data.parser.html
import org.jsoup.Jsoup
import org.jsoup.parser.Parser
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.file.CachedFile
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.library.book.BookWithCover
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.ui.UIText
import java.io.File
import javax.inject.Inject
class HtmlFileParser @Inject constructor() : FileParser {
override suspend fun parse(file: File): BookWithCover? {
override suspend fun parse(cachedFile: CachedFile): BookWithCover? {
return try {
val document = Jsoup.parse(file)
val document = cachedFile.openInputStream()?.use {
Jsoup.parse(it, null, "", Parser.htmlParser())
}
val title = document.select("head > title").text().trim().run {
ifBlank {
file.nameWithoutExtension.trim()
val title = document?.select("head > title")?.text()?.trim().run {
if (isNullOrBlank()) {
return@run cachedFile.name.substringBeforeLast(".").trim()
}
return@run this
}
BookWithCover(
@ -36,7 +40,7 @@ class HtmlFileParser @Inject constructor() : FileParser {
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
filePath = cachedFile.path,
lastOpened = null,
category = Category.entries[0],
coverImage = null

View file

@ -9,10 +9,11 @@ package ua.acclorite.book_story.data.parser.html
import android.util.Log
import kotlinx.coroutines.yield
import org.jsoup.Jsoup
import org.jsoup.parser.Parser
import ua.acclorite.book_story.data.parser.DocumentParser
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.file.CachedFile
import ua.acclorite.book_story.domain.reader.ReaderText
import java.io.File
import javax.inject.Inject
private const val HTML_TAG = "HTML Parser"
@ -21,17 +22,18 @@ class HtmlTextParser @Inject constructor(
private val documentParser: DocumentParser
) : TextParser {
override suspend fun parse(file: File): List<ReaderText> {
Log.i(HTML_TAG, "Started HTML parsing: ${file.name}.")
override suspend fun parse(cachedFile: CachedFile): List<ReaderText> {
Log.i(HTML_TAG, "Started HTML parsing: ${cachedFile.name}.")
return try {
val readerText = documentParser.run {
Jsoup.parse(file).parseDocument()
val readerText = cachedFile.openInputStream()?.use { stream ->
documentParser.parseDocument(Jsoup.parse(stream, null, "", Parser.htmlParser()))
}
yield()
if (
readerText.isNullOrEmpty() ||
readerText.filterIsInstance<ReaderText.Text>().isEmpty() ||
readerText.filterIsInstance<ReaderText.Chapter>().isEmpty()
) {

View file

@ -11,24 +11,24 @@ import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
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.file.CachedFile
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.library.book.BookWithCover
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.ui.UIText
import java.io.File
import javax.inject.Inject
class PdfFileParser @Inject constructor(
private val application: Application
) : FileParser {
override suspend fun parse(file: File): BookWithCover? {
override suspend fun parse(cachedFile: CachedFile): BookWithCover? {
return try {
PDFBoxResourceLoader.init(application)
val document = PDDocument.load(cachedFile.openInputStream())
val document = PDDocument.load(file)
val title = document.documentInformation.title ?: file.nameWithoutExtension.trim()
val title = document.documentInformation.title
?: cachedFile.name.substringBeforeLast(".").trim()
val author = document.documentInformation.author.run {
if (isNullOrBlank()) UIText.StringResource(R.string.unknown_author)
else UIText.StringValue(this)
@ -45,7 +45,7 @@ class PdfFileParser @Inject constructor(
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
filePath = cachedFile.path,
lastOpened = null,
category = Category.entries[0],
coverImage = null

View file

@ -14,9 +14,9 @@ import com.tom_roush.pdfbox.text.PDFTextStripper
import kotlinx.coroutines.yield
import ua.acclorite.book_story.data.parser.MarkdownParser
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.file.CachedFile
import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.presentation.core.util.clearAllMarkdown
import java.io.File
import javax.inject.Inject
private const val PDF_TAG = "PDF Parser"
@ -26,8 +26,8 @@ class PdfTextParser @Inject constructor(
private val application: Application
) : TextParser {
override suspend fun parse(file: File): List<ReaderText> {
Log.i(PDF_TAG, "Started PDF parsing: ${file.name}.")
override suspend fun parse(cachedFile: CachedFile): List<ReaderText> {
Log.i(PDF_TAG, "Started PDF parsing: ${cachedFile.name}.")
return try {
yield()
@ -41,7 +41,7 @@ class PdfTextParser @Inject constructor(
val pdfStripper = PDFTextStripper()
pdfStripper.paragraphStart = "</br>"
PDDocument.load(file).use {
PDDocument.load(cachedFile.openInputStream()).use {
oldText = pdfStripper.getText(it)
.replace("\r", "")
}
@ -160,20 +160,4 @@ class PdfTextParser @Inject constructor(
emptyList()
}
}
}
}

View file

@ -8,18 +8,18 @@ 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.file.CachedFile
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.library.book.BookWithCover
import ua.acclorite.book_story.domain.library.category.Category
import ua.acclorite.book_story.domain.ui.UIText
import java.io.File
import javax.inject.Inject
class TxtFileParser @Inject constructor() : FileParser {
override suspend fun parse(file: File): BookWithCover? {
override suspend fun parse(cachedFile: CachedFile): BookWithCover? {
return try {
val title = file.nameWithoutExtension.trim()
val title = cachedFile.name.substringBeforeLast(".").trim()
val author = UIText.StringResource(R.string.unknown_author)
BookWithCover(
@ -30,7 +30,7 @@ class TxtFileParser @Inject constructor() : FileParser {
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = file.path,
filePath = cachedFile.path,
lastOpened = null,
category = Category.entries[0],
coverImage = null

View file

@ -12,11 +12,9 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import ua.acclorite.book_story.data.parser.MarkdownParser
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.file.CachedFile
import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.presentation.core.util.clearAllMarkdown
import java.io.BufferedReader
import java.io.File
import java.io.FileReader
import javax.inject.Inject
private const val TXT_TAG = "TXT Parser"
@ -25,35 +23,37 @@ class TxtTextParser @Inject constructor(
private val markdownParser: MarkdownParser
) : TextParser {
override suspend fun parse(file: File): List<ReaderText> {
Log.i(TXT_TAG, "Started TXT parsing: ${file.name}.")
override suspend fun parse(cachedFile: CachedFile): List<ReaderText> {
Log.i(TXT_TAG, "Started TXT parsing: ${cachedFile.name}.")
return try {
val readerText = mutableListOf<ReaderText>()
var chapterAdded = false
withContext(Dispatchers.IO) {
BufferedReader(FileReader(file)).forEachLine { line ->
if (line.isNotBlank()) {
when (line) {
"***", "---" -> readerText.add(
ReaderText.Separator
)
cachedFile.openInputStream()?.bufferedReader()?.use { reader ->
reader.forEachLine { line ->
if (line.isNotBlank()) {
when (line) {
"***", "---" -> readerText.add(
ReaderText.Separator
)
else -> {
if (!chapterAdded && line.clearAllMarkdown().isNotBlank()) {
readerText.add(
0, ReaderText.Chapter(
title = line.clearAllMarkdown(),
nested = false
else -> {
if (!chapterAdded && line.clearAllMarkdown().isNotBlank()) {
readerText.add(
0, ReaderText.Chapter(
title = line.clearAllMarkdown(),
nested = false
)
)
chapterAdded = true
} else readerText.add(
ReaderText.Text(
line = markdownParser.parse(line)
)
)
chapterAdded = true
} else readerText.add(
ReaderText.Text(
line = markdownParser.parse(line)
)
)
}
}
}
}

View file

@ -18,11 +18,13 @@ import ua.acclorite.book_story.data.local.room.BookDao
import ua.acclorite.book_story.data.mapper.book.BookMapper
import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.file.CachedFileCompat
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.domain.library.book.BookWithCover
import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.domain.repository.BookRepository
import ua.acclorite.book_story.domain.util.CoverImage
import java.io.BufferedOutputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
@ -98,14 +100,22 @@ class BookRepositoryImpl @Inject constructor(
if (bookId == -1) return emptyList()
val book = database.findBookById(bookId)
val file = File(book.filePath)
val cachedFile = CachedFileCompat.fromFullPath(
context = application,
path = book.filePath,
builder = CachedFileCompat.build(
name = book.filePath.substringAfterLast(File.separator),
path = book.filePath,
isDirectory = false
)
)
if (!file.exists()) {
if (cachedFile == null || !cachedFile.canAccess()) {
Log.e(GET_TEXT, "File [$bookId] does not exist")
return emptyList()
}
val readerText = textParser.parse(file)
val readerText = textParser.parse(cachedFile)
if (
readerText.filterIsInstance<ReaderText.Text>().isEmpty() ||
@ -125,7 +135,7 @@ class BookRepositoryImpl @Inject constructor(
*/
override suspend fun insertBook(
bookWithCover: BookWithCover
): Boolean {
) {
Log.i(INSERT_BOOK, "Inserting ${bookWithCover.book.title}.")
val filesDir = application.filesDir
@ -144,17 +154,14 @@ class BookRepositoryImpl @Inject constructor(
val cover = File(coversDir, coverUri)
withContext(Dispatchers.IO) {
FileOutputStream(cover).use { stream ->
if (
!bookWithCover.coverImage.copy(Bitmap.Config.RGB_565, false)
.compress(
Bitmap.CompressFormat.WEBP,
20,
stream
)
) {
throw Exception("Couldn't save cover image")
}
BufferedOutputStream(FileOutputStream(cover)).use { output ->
bookWithCover.coverImage
.copy(Bitmap.Config.RGB_565, false)
.compress(Bitmap.CompressFormat.WEBP, 20, output)
.let { success ->
if (success) return@let
throw Exception("Couldn't save cover image")
}
}
}
} catch (e: Exception) {
@ -173,7 +180,6 @@ class BookRepositoryImpl @Inject constructor(
val bookToInsert = bookMapper.toBookEntity(updatedBook)
database.insertBook(bookToInsert)
Log.i(INSERT_BOOK, "Successfully inserted book.")
return true
}
/**
@ -218,16 +224,14 @@ class BookRepositoryImpl @Inject constructor(
val cover = File(coversDir, uri)
withContext(Dispatchers.IO) {
FileOutputStream(cover).use { stream ->
if (
!newCoverImage.copy(Bitmap.Config.RGB_565, false).compress(
Bitmap.CompressFormat.WEBP,
20,
stream
)
) {
throw Exception("Couldn't save cover image")
}
BufferedOutputStream(FileOutputStream(cover)).use { output ->
newCoverImage
.copy(Bitmap.Config.RGB_565, false)
.compress(Bitmap.CompressFormat.WEBP, 20, output)
.let { success ->
if (success) return@let
throw Exception("Couldn't save cover image")
}
}
}
} catch (e: Exception) {
@ -319,7 +323,21 @@ class BookRepositoryImpl @Inject constructor(
override suspend fun canResetCover(bookId: Int): Boolean {
val book = database.findBookById(bookId)
val defaultCoverUncompressed = fileParser.parse(File(book.filePath))?.coverImage
val cachedFile = CachedFileCompat.fromFullPath(
application,
book.filePath,
builder = CachedFileCompat.build(
name = book.filePath.substringAfterLast(File.separator),
path = book.filePath,
isDirectory = false
)
)
if (cachedFile == null || !cachedFile.canAccess()) {
return false
}
val defaultCoverUncompressed = fileParser.parse(cachedFile)?.coverImage
?: return false
if (book.image == null) {
@ -361,7 +379,21 @@ class BookRepositoryImpl @Inject constructor(
}
val book = database.findBookById(bookId)
val defaultCover = fileParser.parse(File(book.filePath))?.coverImage
val cachedFile = CachedFileCompat.fromFullPath(
application,
book.filePath,
builder = CachedFileCompat.build(
name = book.filePath.substringAfterLast(File.separator),
path = book.filePath,
isDirectory = false
)
)
if (cachedFile == null || !cachedFile.canAccess()) {
return false
}
val defaultCover = fileParser.parse(cachedFile)?.coverImage
?: return false
updateCoverImageOfBook(bookMapper.toBook(book), defaultCover)

View file

@ -7,21 +7,13 @@
package ua.acclorite.book_story.data.repository
import android.app.Application
import android.content.Context.STORAGE_SERVICE
import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.local.room.BookDao
import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.browse.SelectableFile
import ua.acclorite.book_story.domain.file.CachedFile
import ua.acclorite.book_story.domain.file.CachedFileCompat
import ua.acclorite.book_story.domain.library.book.NullableBook
import ua.acclorite.book_story.domain.library.book.NullableBook.NotNull
import ua.acclorite.book_story.domain.library.book.NullableBook.Null
@ -29,16 +21,16 @@ import ua.acclorite.book_story.domain.repository.FileSystemRepository
import ua.acclorite.book_story.domain.ui.UIText
import ua.acclorite.book_story.presentation.core.constants.Constants
import ua.acclorite.book_story.presentation.core.constants.provideExtensions
import java.io.File
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
private const val GET_BOOK_FROM_FILE = "BOOK FROM FILE, REPO"
private const val GET_FILES_FROM_DEVICE = "FILES FROM DEVICE, REPO"
private const val GET_FILES = "FILES, REPO"
/**
* File System repository.
* Manages all File System([java.io.File]) related work.
* Manages all File System related work.
*/
@Singleton
class FileSystemRepositoryImpl @Inject constructor(
@ -48,23 +40,21 @@ class FileSystemRepositoryImpl @Inject constructor(
) : FileSystemRepository {
/**
* Get all matching files from device.
* Gets 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\".")
override suspend fun getFiles(query: String): List<SelectableFile> {
Log.i(GET_FILES, "Getting files from device, query: \"$query\".")
val existingPaths = database
.searchBooks("")
.map { it.filePath.trim() }
.map { it.filePath }
val supportedExtensions = Constants.provideExtensions()
/**
* Verify that a file is valid and can be shown correctly.
* Verify that [CachedFile] is valid and can be shown correctly.
*/
fun File.isValid(): Boolean {
if (!exists() || !canRead() || !isFile) return false
fun CachedFile.isValid(): Boolean {
// First: Ensuring supported extension
supportedExtensions.any { ext ->
name.endsWith(ext, ignoreCase = true)
@ -72,104 +62,89 @@ class FileSystemRepositoryImpl @Inject constructor(
// Second: Ensuring query to match
if (query.isNotBlank()) {
name.contains(query.trim(), ignoreCase = true)
.let { if (!it) return false }
name.contains(query.trim(), ignoreCase = true).let {
if (!it) return false
}
}
// Third: Ensuring that a file is not added already
existingPaths.none { existingPath ->
existingPath.equals(path.trim(), ignoreCase = true)
existingPath.equals(path, ignoreCase = true)
}.let { if (!it) return false }
return true
}
/**
* Get all verified files from directory (root).
*/
suspend fun File.getFilesFromDirectory(): List<SelectableFile> {
return withContext(Dispatchers.IO) {
val semaphore = Semaphore(5)
walk()
.map { file ->
async {
semaphore.withPermit {
if (!file.isValid()) return@async null
SelectableFile(
name = file.name,
path = file.path,
size = file.length(),
lastModified = file.lastModified(),
selected = false
)
}
}
fun CachedFile.getSelectableFilesFromStorage(): List<SelectableFile> {
val selectableFiles = mutableListOf<SelectableFile>()
walk { file ->
if (!file.isValid()) return@walk
selectableFiles.add(
SelectableFile(
data = file,
selected = false
)
)
}
return selectableFiles
}
fun getAllStorages(): List<CachedFile> {
return application.contentResolver.persistedUriPermissions.mapNotNull { permission ->
val storage = CachedFileCompat.fromUri(
application,
permission.uri,
builder = CachedFileCompat.build(
name = UUID.randomUUID().toString(),
size = 0,
lastModified = 0
)
)
if (!storage.isDirectory) return@mapNotNull null
storage
}.let { storages ->
storages.filter { storage ->
storages.none {
it.path != storage.path && storage.path.startsWith(
it.path,
ignoreCase = true
)
}
.toList()
.awaitAll()
.filterNotNull()
}
}
}
/**
* Get all storages (including SD).
*/
fun getAllStorageDirectories(): List<File> {
val storageDirectories = mutableListOf<File>()
return try {
val storages = getAllStorages()
val files = mutableListOf<SelectableFile>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val storageManager = application.getSystemService(STORAGE_SERVICE) as StorageManager
val storageVolumes = storageManager.storageVolumes.mapNotNull { it.directory }
for (volume in storageVolumes) {
if (volume.exists() && volume.canRead() && volume.isDirectory) {
storageDirectories.add(volume)
}
}
} else {
val internalStorage = Environment.getExternalStorageDirectory()
if (internalStorage.exists() && internalStorage.canRead() && internalStorage.isDirectory) {
storageDirectories.add(internalStorage)
}
val storageVolumes = File("/storage")
if (storageVolumes.exists() && storageVolumes.canRead() && storageVolumes.isDirectory) {
storageVolumes.listFiles()?.forEach { volume ->
if (
volume.exists() &&
volume.canRead() &&
volume.isDirectory &&
volume != internalStorage
) {
storageDirectories.add(volume)
}
}
}
for (storage in storages) {
files.addAll(storage.getSelectableFilesFromStorage())
}
return storageDirectories
Log.i(GET_FILES, "Successfully got all matching files.")
files
} catch (e: Exception) {
e.printStackTrace()
Log.e(GET_FILES, "Couldn't get all matching files.")
emptyList()
}
val storageDirectories = getAllStorageDirectories()
val files = mutableListOf<SelectableFile>()
for (volume in storageDirectories) {
files.addAll(volume.getFilesFromDirectory())
}
Log.i(GET_FILES_FROM_DEVICE, "Successfully got all matching files.")
return files
}
/**
* Gets book from given file. If error happened, returns [NullableBook.Null].
*/
override suspend fun getBookFromFile(file: File): NullableBook {
val parsedBook = fileParser.parse(file)
override suspend fun getBookFromFile(cachedFile: CachedFile): NullableBook {
val parsedBook = fileParser.parse(cachedFile)
if (parsedBook == null) {
Log.e(GET_BOOK_FROM_FILE, "Parsed file(${file.name}) is null.")
Log.e(GET_BOOK_FROM_FILE, "Parsed file(${cachedFile.name}) is null.")
return Null(
file.name,
cachedFile.name,
UIText.StringResource(R.string.error_something_went_wrong)
)
}

View file

@ -6,99 +6,47 @@
package ua.acclorite.book_story.data.repository
import android.annotation.SuppressLint
import android.app.Application
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import android.util.Log
import androidx.activity.ComponentActivity
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.shouldShowRationale
import kotlinx.coroutines.delay
import kotlinx.coroutines.yield
import ua.acclorite.book_story.domain.repository.PermissionRepository
import ua.acclorite.book_story.presentation.core.util.launchActivity
import javax.inject.Inject
import javax.inject.Singleton
private const val STORAGE_PERMISSION = "STORAGE PERM, REPO"
private const val GRANT_URI = "GRANT URI PERM, REPO"
private const val RELEASE_URI = "RELEASE URI PERM, REPO"
@Singleton
class PermissionRepositoryImpl @Inject constructor() : PermissionRepository {
class PermissionRepositoryImpl @Inject constructor(
private val application: Application
) : PermissionRepository {
@SuppressLint("InlinedApi")
@OptIn(ExperimentalPermissionsApi::class)
override suspend fun grantStoragePermission(
activity: ComponentActivity,
storagePermissionState: PermissionState
): Boolean {
Log.i(STORAGE_PERMISSION, "Requested storage permission")
val legacyStoragePermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.R
override suspend fun grantPersistableUriPermission(uri: Uri) {
Log.i(GRANT_URI, "Granting persistable uri permission to \"${uri.path}\" URI.")
val permissionGranted = if (legacyStoragePermission) {
storagePermissionState.status.isGranted
} else Environment.isExternalStorageManager()
if (permissionGranted) {
Log.i(STORAGE_PERMISSION, "Granted: Storage Permission is already granted")
return true
try {
application.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
} catch (e: Exception) {
e.printStackTrace()
Log.e(GRANT_URI, "Could not grant URI permission.")
}
}
when (legacyStoragePermission) {
true -> {
if (!storagePermissionState.status.shouldShowRationale) {
storagePermissionState.launchPermissionRequest()
} else {
val uri = Uri.parse("package:${activity.packageName}")
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, uri)
override suspend fun releasePersistableUriPermission(uri: Uri) {
Log.i(RELEASE_URI, "Releasing persistable uri permission from \"${uri.path}\" URI.")
intent.launchActivity(activity) {
Log.e(
STORAGE_PERMISSION,
"Could not launch \"ACTION_APPLICATION_DETAILS_SETTINGS\" activity"
)
return false
}
}
}
false -> {
val uri = Uri.parse("package:${activity.packageName}")
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, uri)
intent.launchActivity(activity) {
Log.e(
STORAGE_PERMISSION,
"Could not launch \"ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION\" activity"
)
return false
}
}
try {
application.contentResolver.releasePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
} catch (e: Exception) {
e.printStackTrace()
Log.w(RELEASE_URI, "No granted URI permission found.")
}
for (i in 1..30) {
val granted = if (legacyStoragePermission) {
storagePermissionState.status.isGranted
} else Environment.isExternalStorageManager()
if (!granted) {
delay(1000)
yield()
continue
}
yield()
Log.i(STORAGE_PERMISSION, "Successfully granted")
return true
break
}
Log.e(STORAGE_PERMISSION, "Not granted: Timeout")
return false
}
}

View file

@ -7,12 +7,11 @@
package ua.acclorite.book_story.domain.browse
import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.domain.file.CachedFile
import ua.acclorite.book_story.domain.util.Selected
@Immutable
data class SelectableFile(
val name: String,
val path: String,
val size: Long,
val lastModified: Long,
val selected: Boolean
val data: CachedFile,
val selected: Selected
)

View file

@ -0,0 +1,311 @@
/*
* 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.file
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
import androidx.compose.runtime.Immutable
import com.anggrayudi.storage.file.DocumentFileCompat
import com.anggrayudi.storage.file.getAbsolutePath
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.util.UUID
/**
* Cached File.
* Faster than [androidx.documentfile.provider.DocumentFile].
* Saves all it's variables after initialized.
*/
@Suppress("unused")
@Immutable
class CachedFile(
private val context: Context,
val uri: Uri,
private val builder: CachedFileBuilder? = null
) {
@Immutable
private data class QueryParams(
val name: String,
val size: Long,
val lastModified: Long,
val isDirectory: Boolean
)
private val queryParams by lazy {
getFileQueryParams()
}
val path: String by lazy { builder?.path ?: getFilePath() }
val rawFile: File? by lazy { storeInCache() }
val name: String get() = builder?.name ?: queryParams.name
val size: Long get() = builder?.size ?: queryParams.size
val lastModified: Long get() = builder?.lastModified ?: queryParams.lastModified
val isDirectory: Boolean get() = builder?.isDirectory ?: queryParams.isDirectory
fun canAccess(): Boolean {
return try {
context.contentResolver.query(uri, null, null, null, null)?.let {
it.close()
return true
}
throw Exception("Could not access URI: $uri")
} catch (e: Exception) {
false
}
}
fun openInputStream(): InputStream? {
return try {
context.contentResolver.openInputStream(uri)
?: throw Exception("Failed to open InputStream for URI: $uri")
} catch (e: Exception) {
e.printStackTrace()
null
}
}
fun listFiles(forEach: ((CachedFile) -> Unit)? = null): List<CachedFile> {
if (!isDirectory || !canAccess()) return emptyList()
val cachedFiles = mutableListOf<CachedFile>()
val nameColumn = DocumentsContract.Document.COLUMN_DISPLAY_NAME
val uriColumn = DocumentsContract.Document.COLUMN_DOCUMENT_ID
val sizeColumn = DocumentsContract.Document.COLUMN_SIZE
val lastModifiedColumn = DocumentsContract.Document.COLUMN_LAST_MODIFIED
val isDirectoryColumn = DocumentsContract.Document.COLUMN_MIME_TYPE
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(
uri,
DocumentsContract.getDocumentId(uri)
)
context.contentResolver.query(
childrenUri,
arrayOf(
nameColumn,
uriColumn,
sizeColumn,
lastModifiedColumn,
isDirectoryColumn
),
null, null, null
)?.use { cursor ->
if (cursor.count == 0) {
return emptyList()
}
try {
val nameIndex = cursor.getColumnIndexOrThrow(nameColumn)
val uriIndex = cursor.getColumnIndexOrThrow(uriColumn)
val sizeIndex = cursor.getColumnIndexOrThrow(sizeColumn)
val lastModifiedIndex = cursor.getColumnIndexOrThrow(lastModifiedColumn)
val isDirectoryIndex = cursor.getColumnIndexOrThrow(isDirectoryColumn)
while (cursor.moveToNext()) {
val nameQuery = cursor.getString(nameIndex)
val pathQuery = "$path/$nameQuery"
val uriQuery = DocumentsContract.buildDocumentUriUsingTree(
uri,
cursor.getString(uriIndex)
)
val sizeQuery = cursor.getLong(sizeIndex)
val lastModifiedQuery = cursor.getLong(lastModifiedIndex)
val isDirectoryQuery = cursor.getString(isDirectoryIndex) ==
DocumentsContract.Document.MIME_TYPE_DIR
val queryFile = CachedFileCompat.fromUri(
context = context,
uri = uriQuery,
builder = CachedFileCompat.build(
name = nameQuery,
path = pathQuery,
size = sizeQuery,
lastModified = lastModifiedQuery,
isDirectory = isDirectoryQuery
)
)
forEach?.invoke(queryFile)
cachedFiles.add(queryFile)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
return cachedFiles
}
fun walk(
includeDirectories: Boolean = false,
forEach: ((CachedFile) -> Unit)? = null
): List<CachedFile> {
val cachedFiles = mutableListOf<CachedFile>()
listFiles {
when (it.isDirectory) {
false -> {
forEach?.invoke(it)
cachedFiles.add(it)
}
true -> {
if (includeDirectories) cachedFiles.add(it)
cachedFiles.addAll(it.walk(includeDirectories, forEach))
}
}
}
return cachedFiles
}
/**
* Copies the file to [context].cacheDir and provides [java.io.File].
*
* @return null if the file is directory or failed
*/
private fun storeInCache(): File? {
if (isDirectory) return null
val cacheDir = context.cacheDir
val fileName = path.replace("_", "-").replace("/", "_")
val cacheFile = File(cacheDir, fileName)
val tempFile = File(cacheDir, "$fileName.tmp")
try {
context.contentResolver.openInputStream(uri)?.use { input ->
BufferedOutputStream(FileOutputStream(tempFile)).use { output ->
input.copyTo(output)
}
} ?: throw IllegalStateException("Failed to open InputStream.")
if (!tempFile.renameTo(cacheFile)) {
throw IllegalStateException("Failed to rename .tmp file.")
}
} catch (e: Exception) {
e.printStackTrace()
tempFile.delete()
return null
}
return cacheFile
}
private fun getFileQueryParams(): QueryParams {
val nameColumn = DocumentsContract.Document.COLUMN_DISPLAY_NAME
val sizeColumn = DocumentsContract.Document.COLUMN_SIZE
val lastModifiedColumn = DocumentsContract.Document.COLUMN_LAST_MODIFIED
val isDirectoryColumn = DocumentsContract.Document.COLUMN_MIME_TYPE
val projection = mutableListOf<String>().apply {
if (builder?.name == null) add(nameColumn)
if (builder?.size == null) add(sizeColumn)
if (builder?.lastModified == null) add(lastModifiedColumn)
if (builder?.isDirectory == null) add(isDirectoryColumn)
}
if (projection.isEmpty() && builder != null) {
return QueryParams(
name = builder.name!!,
size = builder.size!!,
lastModified = builder.lastModified!!,
isDirectory = builder.isDirectory!!
)
}
context.contentResolver.query(
uri,
projection.toTypedArray(),
null, null, null
)?.use { cursor ->
try {
if (cursor.moveToFirst()) {
val queryResult = mutableMapOf<String, Any?>()
projection.forEach { column ->
when (column) {
nameColumn -> {
if (builder?.name == null) {
queryResult[column] = cursor.getString(
cursor.getColumnIndexOrThrow(column)
)
}
}
sizeColumn -> {
if (builder?.size == null) {
queryResult[column] = cursor.getLong(
cursor.getColumnIndexOrThrow(column)
)
}
}
lastModifiedColumn -> {
if (builder?.lastModified == null) {
queryResult[column] = cursor.getLong(
cursor.getColumnIndexOrThrow(column)
)
}
}
isDirectoryColumn -> {
if (builder?.isDirectory == null) {
queryResult[column] = cursor.getString(
cursor.getColumnIndexOrThrow(column)
)
}
}
}
}
val nameQuery = queryResult.getOrElse(nameColumn) {
builder?.name
} as String
val sizeQuery = queryResult.getOrElse(sizeColumn) {
builder?.size
} as Long
val lastModifiedQuery = queryResult.getOrElse(lastModifiedColumn) {
builder?.lastModified
} as Long
val isDirectoryQuery = when (queryResult[isDirectoryColumn]) {
DocumentsContract.Document.MIME_TYPE_DIR -> true
null -> builder?.isDirectory!!
else -> false
}
return QueryParams(
name = nameQuery,
size = sizeQuery,
lastModified = lastModifiedQuery,
isDirectory = isDirectoryQuery
)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
return QueryParams(
name = "unknown_${UUID.randomUUID()}",
size = 0,
lastModified = 0,
isDirectory = false
)
}
private fun getFilePath(): String {
val tempFile = DocumentFileCompat.fromUri(context, uri)
return tempFile?.getAbsolutePath(context)?.trimEnd('/') ?: ""
}
}

View file

@ -0,0 +1,18 @@
/*
* 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.file
import androidx.compose.runtime.Immutable
@Immutable
data class CachedFileBuilder(
val name: String?,
val path: String?,
val size: Long? = null,
val lastModified: Long? = null,
val isDirectory: Boolean? = null
)

View file

@ -0,0 +1,109 @@
/*
* 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.file
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
import com.anggrayudi.storage.file.DocumentFileCompat
object CachedFileCompat {
fun fromUri(context: Context, uri: Uri, builder: CachedFileBuilder? = null): CachedFile {
return CachedFile(
context = context,
uri = when {
DocumentsContract.isDocumentUri(context, uri) -> uri
DocumentsContract.isTreeUri(uri) -> {
DocumentsContract.buildDocumentUriUsingTree(
uri,
DocumentsContract.getTreeDocumentId(uri)
)
}
else -> uri
},
builder = builder
)
}
fun fromFullPath(
context: Context,
path: String,
builder: CachedFileBuilder? = null
): CachedFile? {
val uri = try {
val fullPathUri = DocumentFileCompat.fromFullPath(context, path)?.uri
if (fullPathUri == null) throw NullPointerException("Could not get URI from full path.")
fullPathUri
} catch (e: Exception) {
e.printStackTrace()
try {
val parentUri = DocumentFileCompat.getAccessibleRootDocumentFile(
context = context,
fullPath = path
)?.uri
if (parentUri == null) throw NullPointerException("Could not get parent URI.")
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 documentUri = DocumentsContract.buildDocumentUriUsingTree(
parentUri,
"$storageId:$basePath"
)
if (documentUri == null) throw NullPointerException("Could not get document URI.")
documentUri
} 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,
size: Long? = null,
lastModified: Long? = null,
isDirectory: Boolean? = null
): CachedFileBuilder {
return CachedFileBuilder(
name = name,
path = path,
size = size,
lastModified = lastModified,
isDirectory = isDirectory
)
}
}

View file

@ -27,7 +27,7 @@ interface BookRepository {
suspend fun insertBook(
bookWithCover: BookWithCover
): Boolean
)
suspend fun updateBook(
book: Book

View file

@ -7,16 +7,16 @@
package ua.acclorite.book_story.domain.repository
import ua.acclorite.book_story.domain.browse.SelectableFile
import ua.acclorite.book_story.domain.file.CachedFile
import ua.acclorite.book_story.domain.library.book.NullableBook
import java.io.File
interface FileSystemRepository {
suspend fun getFilesFromDevice(
suspend fun getFiles(
query: String = ""
): List<SelectableFile>
suspend fun getBookFromFile(
file: File
cachedFile: CachedFile
): NullableBook
}

View file

@ -6,15 +6,15 @@
package ua.acclorite.book_story.domain.repository
import androidx.activity.ComponentActivity
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import android.net.Uri
interface PermissionRepository {
@OptIn(ExperimentalPermissionsApi::class)
suspend fun grantStoragePermission(
activity: ComponentActivity,
storagePermissionState: PermissionState
): Boolean
suspend fun grantPersistableUriPermission(
uri: Uri
)
suspend fun releasePersistableUriPermission(
uri: Uri
)
}

View file

@ -14,7 +14,7 @@ class InsertBook @Inject constructor(
private val repository: BookRepository
) {
suspend fun execute(bookWithCover: BookWithCover): Boolean {
return repository.insertBook(bookWithCover)
suspend fun execute(bookWithCover: BookWithCover) {
repository.insertBook(bookWithCover)
}
}

View file

@ -6,16 +6,16 @@
package ua.acclorite.book_story.domain.use_case.file_system
import ua.acclorite.book_story.domain.file.CachedFile
import ua.acclorite.book_story.domain.library.book.NullableBook
import ua.acclorite.book_story.domain.repository.FileSystemRepository
import java.io.File
import javax.inject.Inject
class GetBookFromFile @Inject constructor(
private val repository: FileSystemRepository
) {
suspend fun execute(file: File): NullableBook {
return repository.getBookFromFile(file)
suspend fun execute(cachedFile: CachedFile): NullableBook {
return repository.getBookFromFile(cachedFile)
}
}

View file

@ -10,11 +10,11 @@ import ua.acclorite.book_story.domain.browse.SelectableFile
import ua.acclorite.book_story.domain.repository.FileSystemRepository
import javax.inject.Inject
class GetFilesFromDevice @Inject constructor(
class GetFiles @Inject constructor(
private val repository: FileSystemRepository
) {
suspend fun execute(query: String): List<SelectableFile> {
return repository.getFilesFromDevice(query)
return repository.getFiles(query)
}
}

View file

@ -0,0 +1,20 @@
/*
* 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.permission
import android.net.Uri
import ua.acclorite.book_story.domain.repository.PermissionRepository
import javax.inject.Inject
class GrantPersistableUriPermission @Inject constructor(
private val repository: PermissionRepository
) {
suspend fun execute(uri: Uri) {
repository.grantPersistableUriPermission(uri)
}
}

View file

@ -1,29 +0,0 @@
/*
* 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.permission
import androidx.activity.ComponentActivity
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import ua.acclorite.book_story.domain.repository.PermissionRepository
import javax.inject.Inject
class GrantStoragePermission @Inject constructor(
private val repository: PermissionRepository
) {
@OptIn(ExperimentalPermissionsApi::class)
suspend fun execute(
activity: ComponentActivity,
storagePermissionState: PermissionState
): Boolean {
return repository.grantStoragePermission(
activity = activity,
storagePermissionState = storagePermissionState
)
}
}

View file

@ -0,0 +1,20 @@
/*
* 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.permission
import android.net.Uri
import ua.acclorite.book_story.domain.repository.PermissionRepository
import javax.inject.Inject
class ReleasePersistableUriPermission @Inject constructor(
private val repository: PermissionRepository
) {
suspend fun execute(uri: Uri) {
repository.releasePersistableUriPermission(uri)
}
}

View file

@ -14,9 +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.domain.file.CachedFileCompat
import ua.acclorite.book_story.domain.library.book.Book
import ua.acclorite.book_story.presentation.core.components.common.LazyColumnWithScrollbar
import ua.acclorite.book_story.presentation.core.components.modal_bottom_sheet.ModalBottomSheet
@ -24,7 +26,6 @@ import ua.acclorite.book_story.presentation.core.constants.Constants
import ua.acclorite.book_story.presentation.core.constants.provideExtensions
import ua.acclorite.book_story.presentation.settings.components.SettingsSubcategoryTitle
import ua.acclorite.book_story.ui.book_info.BookInfoEvent
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@ -38,10 +39,14 @@ fun BookInfoDetailsBottomSheet(
val pattern = remember { SimpleDateFormat("HH:mm dd MMM yyyy", Locale.getDefault()) }
val lastOpened = remember(book.lastOpened) { pattern.format(Date(book.lastOpened ?: 0)) }
val fileSize = remember(book.filePath) {
val file = File(book.filePath)
if (file.exists()) {
val sizeBytes = file.length()
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 sizeKB = sizeBytes / 1024f
val sizeMB = sizeKB / 1024f
when {
@ -54,11 +59,12 @@ fun BookInfoDetailsBottomSheet(
}
}
val fileExists = remember(book.filePath) {
File(book.filePath).let {
it.exists() && it.isFile && Constants.provideExtensions().any { ext ->
it.name.endsWith(ext, ignoreCase = true)
}
val fileExists = remember(cachedFile) {
cachedFile.let {
it != null && it.canAccess() && !it.isDirectory && Constants.provideExtensions()
.any { ext ->
it.name.endsWith(ext, ignoreCase = true)
}
}
}

View file

@ -12,8 +12,6 @@ import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.ui.focus.FocusRequester
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import ua.acclorite.book_story.domain.browse.BrowseLayout
import ua.acclorite.book_story.domain.browse.SelectableFile
import ua.acclorite.book_story.domain.library.book.SelectableNullableBook
@ -22,13 +20,12 @@ import ua.acclorite.book_story.domain.util.Dialog
import ua.acclorite.book_story.ui.browse.BrowseEvent
import ua.acclorite.book_story.ui.main.MainEvent
@OptIn(ExperimentalMaterialApi::class, ExperimentalPermissionsApi::class)
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun BrowseContent(
files: List<SelectableFile>,
selectedBooksAddDialog: List<SelectableNullableBook>,
refreshState: PullRefreshState,
storagePermissionState: PermissionState,
loadingAddDialog: Boolean,
dialog: Dialog?,
bottomSheet: BottomSheet?,
@ -45,7 +42,6 @@ fun BrowseContent(
selectedItemsCount: Int,
isRefreshing: Boolean,
isLoading: Boolean,
isError: Boolean,
dialogHidden: Boolean,
filesEmpty: Boolean,
showSearch: Boolean,
@ -58,25 +54,19 @@ fun BrowseContent(
clearSelectedFiles: (BrowseEvent.OnClearSelectedFiles) -> Unit,
selectFiles: (BrowseEvent.OnSelectFiles) -> Unit,
selectFile: (BrowseEvent.OnSelectFile) -> Unit,
permissionCheck: (BrowseEvent.OnPermissionCheck) -> Unit,
dismissBottomSheet: (BrowseEvent.OnDismissBottomSheet) -> Unit,
showFilterBottomSheet: (BrowseEvent.OnShowFilterBottomSheet) -> Unit,
actionPermissionDialog: (BrowseEvent.OnActionPermissionDialog) -> Unit,
dismissPermissionDialog: (BrowseEvent.OnDismissPermissionDialog) -> Unit,
showAddDialog: (BrowseEvent.OnShowAddDialog) -> Unit,
dismissAddDialog: (BrowseEvent.OnDismissAddDialog) -> Unit,
actionAddDialog: (BrowseEvent.OnActionAddDialog) -> Unit,
selectAddDialog: (BrowseEvent.OnSelectAddDialog) -> Unit,
changePinnedPaths: (MainEvent.OnChangeBrowsePinnedPaths) -> Unit,
navigateToLibrary: () -> Unit,
navigateToHelp: () -> Unit,
navigateToBrowseSettings: () -> Unit,
) {
BrowseDialog(
dialog = dialog,
storagePermissionState = storagePermissionState,
loadingAddDialog = loadingAddDialog,
actionPermissionDialog = actionPermissionDialog,
dismissPermissionDialog = dismissPermissionDialog,
actionAddDialog = actionAddDialog,
dismissAddDialog = dismissAddDialog,
selectedBooksAddDialog = selectedBooksAddDialog,
@ -114,16 +104,13 @@ fun BrowseContent(
requestFocus = requestFocus,
clearSelectedFiles = clearSelectedFiles,
selectFiles = selectFiles,
storagePermissionState = storagePermissionState,
isLoading = isLoading,
isError = isError,
filesEmpty = filesEmpty,
permissionCheck = permissionCheck,
selectFile = selectFile,
showFilterBottomSheet = showFilterBottomSheet,
showAddDialog = showAddDialog,
changePinnedPaths = changePinnedPaths,
navigateToHelp = navigateToHelp
navigateToBrowseSettings = navigateToBrowseSettings
)
BrowseBackHandler(

View file

@ -7,37 +7,22 @@
package ua.acclorite.book_story.presentation.browse
import androidx.compose.runtime.Composable
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import ua.acclorite.book_story.domain.library.book.SelectableNullableBook
import ua.acclorite.book_story.domain.util.Dialog
import ua.acclorite.book_story.ui.browse.BrowseEvent
import ua.acclorite.book_story.ui.browse.BrowseScreen
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun BrowseDialog(
dialog: Dialog?,
storagePermissionState: PermissionState,
loadingAddDialog: Boolean,
selectedBooksAddDialog: List<SelectableNullableBook>,
dismissAddDialog: (BrowseEvent.OnDismissAddDialog) -> Unit,
actionAddDialog: (BrowseEvent.OnActionAddDialog) -> Unit,
selectAddDialog: (BrowseEvent.OnSelectAddDialog) -> Unit,
actionPermissionDialog: (BrowseEvent.OnActionPermissionDialog) -> Unit,
dismissPermissionDialog: (BrowseEvent.OnDismissPermissionDialog) -> Unit,
navigateToLibrary: () -> Unit
) {
when (dialog) {
BrowseScreen.PERMISSION_DIALOG -> {
BrowsePermissionDialog(
storagePermissionState = storagePermissionState,
actionPermissionDialog = actionPermissionDialog,
dismissPermissionDialog = dismissPermissionDialog
)
}
BrowseScreen.ADD_DIALOG -> {
BrowseAddDialog(
loadingAddDialog = loadingAddDialog,

View file

@ -12,52 +12,23 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.core.components.common.AnimatedVisibility
import ua.acclorite.book_story.presentation.core.components.placeholder.EmptyPlaceholder
import ua.acclorite.book_story.presentation.core.components.placeholder.ErrorPlaceholder
import ua.acclorite.book_story.ui.browse.BrowseEvent
import ua.acclorite.book_story.ui.theme.Transitions
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun BoxScope.BrowseEmptyPlaceholder(
filesEmpty: Boolean,
dialogHidden: Boolean,
isError: Boolean,
isLoading: Boolean,
isRefreshing: Boolean,
storagePermissionState: PermissionState,
permissionCheck: (BrowseEvent.OnPermissionCheck) -> Unit,
navigateToHelp: () -> Unit
navigateToBrowseSettings: () -> Unit
) {
AnimatedVisibility(
visible = isError,
modifier = Modifier.align(Alignment.Center),
enter = Transitions.DefaultTransitionIn,
exit = Transitions.NoExitAnimation
) {
ErrorPlaceholder(
modifier = Modifier.align(Alignment.Center),
errorMessage = stringResource(id = R.string.error_permission),
icon = painterResource(id = R.drawable.error),
actionTitle = stringResource(id = R.string.grant_permission)
) {
permissionCheck(
BrowseEvent.OnPermissionCheck(
storagePermissionState = storagePermissionState
)
)
}
}
AnimatedVisibility(
visible = !isLoading
&& dialogHidden
&& filesEmpty
&& !isError
&& !isRefreshing,
modifier = Modifier.align(Alignment.Center),
enter = Transitions.DefaultTransitionIn,
@ -66,9 +37,9 @@ fun BoxScope.BrowseEmptyPlaceholder(
EmptyPlaceholder(
message = stringResource(id = R.string.browse_empty),
icon = painterResource(id = R.drawable.empty_browse),
actionTitle = stringResource(id = R.string.get_help),
actionTitle = stringResource(id = R.string.set_up_scanning),
action = {
navigateToHelp()
navigateToBrowseSettings()
}
)
}

View file

@ -40,10 +40,10 @@ import java.util.Locale
fun BrowseGridFileItem(file: SelectableFile, hasSelectedItems: Boolean) {
val lastModified = rememberSaveable {
SimpleDateFormat("dd MMM yyyy", Locale.getDefault())
.format(Date(file.lastModified))
.format(Date(file.data.lastModified))
}
val sizeBytes = rememberSaveable { file.size }
val sizeBytes = rememberSaveable { file.data.size }
val fileSizeKB = rememberSaveable {
if (sizeBytes > 0) sizeBytes.toDouble() / 1024.0 else 0.0
}
@ -94,7 +94,7 @@ fun BrowseGridFileItem(file: SelectableFile, hasSelectedItems: Boolean) {
Spacer(modifier = Modifier.height(8.dp))
StyledText(
text = file.name,
text = file.data.name,
style = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,

View file

@ -50,7 +50,7 @@ fun BrowseGridLayout(
items(
group.files,
key = { it.path }
key = { it.data.path }
) { selectableFile ->
Box(Modifier.animateItem()) {
itemContent(selectableFile, group.files)

View file

@ -29,7 +29,7 @@ fun BrowseLayout(
) {
val groupedFiles = remember(files, pinnedPaths) {
files.groupBy { file ->
file.path.substringBeforeLast(File.separator)
file.data.path.substringBeforeLast(File.separator)
}.map { (header, files) ->
GroupedFiles(
header = header,

View file

@ -41,10 +41,10 @@ import java.util.Locale
fun RowScope.BrowseListFileItem(file: SelectableFile) {
val lastModified = rememberSaveable {
SimpleDateFormat("HH:mm dd MMM yyyy", Locale.getDefault())
.format(Date(file.lastModified))
.format(Date(file.data.lastModified))
}
val sizeBytes = rememberSaveable { file.size }
val sizeBytes = rememberSaveable { file.data.size }
val fileSizeKB = rememberSaveable {
if (sizeBytes > 0) sizeBytes.toDouble() / 1024.0 else 0.0
}
@ -83,7 +83,7 @@ fun RowScope.BrowseListFileItem(file: SelectableFile) {
Spacer(modifier = Modifier.width(12.dp))
Column(verticalArrangement = Arrangement.Center) {
StyledText(
text = file.name,
text = file.data.name,
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface,

View file

@ -44,7 +44,7 @@ fun BrowseListLayout(
items(
group.files,
key = { it.path }
key = { it.data.path }
) { selectableFile ->
Box(Modifier.animateItem()) {
itemContent(selectableFile, group.files)

View file

@ -1,52 +0,0 @@
/*
* 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.presentation.browse
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SdStorage
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.core.components.dialog.Dialog
import ua.acclorite.book_story.presentation.core.util.LocalActivity
import ua.acclorite.book_story.ui.browse.BrowseEvent
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun BrowsePermissionDialog(
storagePermissionState: PermissionState,
actionPermissionDialog: (BrowseEvent.OnActionPermissionDialog) -> Unit,
dismissPermissionDialog: (BrowseEvent.OnDismissPermissionDialog) -> Unit
) {
val activity = LocalActivity.current
Dialog(
title = stringResource(id = R.string.storage_permission),
icon = Icons.Outlined.SdStorage,
description = stringResource(id = R.string.storage_permission_description),
actionEnabled = true,
disableOnClick = false,
onDismiss = {
dismissPermissionDialog(
BrowseEvent.OnDismissPermissionDialog(
storagePermissionState = storagePermissionState
)
)
},
onAction = {
actionPermissionDialog(
BrowseEvent.OnActionPermissionDialog(
activity = activity,
storagePermissionState = storagePermissionState
)
)
},
withContent = false
)
}

View file

@ -19,20 +19,17 @@ import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import ua.acclorite.book_story.domain.browse.BrowseLayout
import ua.acclorite.book_story.domain.browse.SelectableFile
import ua.acclorite.book_story.ui.browse.BrowseEvent
import ua.acclorite.book_story.ui.main.MainEvent
import ua.acclorite.book_story.ui.theme.DefaultTransition
@OptIn(ExperimentalMaterialApi::class, ExperimentalPermissionsApi::class)
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun BrowseScaffold(
files: List<SelectableFile>,
refreshState: PullRefreshState,
storagePermissionState: PermissionState,
listState: LazyListState,
gridState: LazyGridState,
layout: BrowseLayout,
@ -46,7 +43,6 @@ fun BrowseScaffold(
selectedItemsCount: Int,
isRefreshing: Boolean,
isLoading: Boolean,
isError: Boolean,
dialogHidden: Boolean,
filesEmpty: Boolean,
showSearch: Boolean,
@ -59,11 +55,10 @@ fun BrowseScaffold(
clearSelectedFiles: (BrowseEvent.OnClearSelectedFiles) -> Unit,
selectFiles: (BrowseEvent.OnSelectFiles) -> Unit,
selectFile: (BrowseEvent.OnSelectFile) -> Unit,
permissionCheck: (BrowseEvent.OnPermissionCheck) -> Unit,
showFilterBottomSheet: (BrowseEvent.OnShowFilterBottomSheet) -> Unit,
showAddDialog: (BrowseEvent.OnShowAddDialog) -> Unit,
changePinnedPaths: (MainEvent.OnChangeBrowsePinnedPaths) -> Unit,
navigateToHelp: () -> Unit,
navigateToBrowseSettings: () -> Unit,
) {
Scaffold(
modifier = Modifier
@ -149,12 +144,9 @@ fun BrowseScaffold(
BrowseEmptyPlaceholder(
filesEmpty = filesEmpty,
dialogHidden = dialogHidden,
isError = isError,
isLoading = isLoading,
isRefreshing = isRefreshing,
storagePermissionState = storagePermissionState,
permissionCheck = permissionCheck,
navigateToHelp = navigateToHelp
navigateToBrowseSettings = navigateToBrowseSettings
)
BrowseRefreshIndicator(

View file

@ -37,8 +37,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -68,7 +69,9 @@ fun Dialog(
val imeInsets = WindowInsets.ime.asPaddingValues().let {
it.calculateTopPadding() + it.calculateBottomPadding()
}.value
val screenHeightDp = LocalConfiguration.current.screenHeightDp
val screenHeightDp = with(LocalDensity.current) {
LocalWindowInfo.current.containerSize.height.toDp().value
}
val maxHeight = animateDpAsState(
(screenHeightDp - imeInsets - 32).dp,

View file

@ -11,9 +11,11 @@ package ua.acclorite.book_story.presentation.settings.browse
import androidx.compose.foundation.lazy.LazyListScope
import ua.acclorite.book_story.presentation.settings.browse.filter.BrowseFilterSubcategory
import ua.acclorite.book_story.presentation.settings.browse.general.BrowseGeneralSubcategory
import ua.acclorite.book_story.presentation.settings.browse.scan.BrowseScanSubcategory
import ua.acclorite.book_story.presentation.settings.browse.sort.BrowseSortSubcategory
fun LazyListScope.BrowseSettingsCategory() {
BrowseScanSubcategory()
BrowseGeneralSubcategory()
BrowseFilterSubcategory()
BrowseSortSubcategory(

View file

@ -0,0 +1,41 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
@file:Suppress("FunctionName")
package ua.acclorite.book_story.presentation.settings.browse.scan
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.settings.browse.scan.components.BrowseScanOption
import ua.acclorite.book_story.presentation.settings.browse.scan.components.BrowseScanOptionNote
import ua.acclorite.book_story.presentation.settings.components.SettingsSubcategory
fun LazyListScope.BrowseScanSubcategory(
titleColor: @Composable () -> Color = { MaterialTheme.colorScheme.primary },
title: @Composable () -> String = { stringResource(id = R.string.scan_browse_settings) },
showTitle: Boolean = true,
showDivider: Boolean = true
) {
SettingsSubcategory(
titleColor = titleColor,
title = title,
showTitle = showTitle,
showDivider = showDivider
) {
item {
BrowseScanOption()
}
item {
BrowseScanOptionNote()
}
}
}

View file

@ -0,0 +1,203 @@
/*
* 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.presentation.settings.browse.scan.components
import android.content.Context
import android.content.UriPermission
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
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.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.anggrayudi.storage.file.DocumentFileCompat
import com.anggrayudi.storage.file.getBasePath
import com.anggrayudi.storage.file.getRootPath
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.core.components.common.IconButton
import ua.acclorite.book_story.presentation.core.components.common.StyledText
import ua.acclorite.book_story.presentation.core.util.noRippleClickable
import ua.acclorite.book_story.ui.browse.BrowseScreen
import ua.acclorite.book_story.ui.settings.SettingsEvent
import ua.acclorite.book_story.ui.settings.SettingsModel
import ua.acclorite.book_story.ui.theme.dynamicListItemColor
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun BrowseScanOption() {
val settingsModel = hiltViewModel<SettingsModel>()
val context = LocalContext.current
fun getPersistedUriPermissions(): List<UriPermission> {
return context.contentResolver?.persistedUriPermissions.let { permissions ->
if (permissions.isNullOrEmpty()) return@let emptyList()
permissions.sortedBy { it.uri.path?.lowercase() }
}
}
val persistedUriPermissions = remember {
mutableStateListOf<UriPermission>().apply {
clear()
addAll(getPersistedUriPermissions())
}
}
val persistedUriIntent = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree()
) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
settingsModel.onEvent(
SettingsEvent.OnGrantPersistableUriPermission(
uri = uri
)
)
persistedUriPermissions.clear()
persistedUriPermissions.addAll(getPersistedUriPermissions())
BrowseScreen.refreshListChannel.trySend(Unit)
}
Column(
modifier = Modifier
.fillMaxWidth()
.animateContentSize()
) {
persistedUriPermissions.forEachIndexed { index, permission ->
BrowseScanFolderItem(
index = index,
permission = permission,
context = context,
releasePersistableUriPermission = {
settingsModel.onEvent(
SettingsEvent.OnReleasePersistableUriPermission(
uri = permission.uri
)
)
persistedUriPermissions.clear()
persistedUriPermissions.addAll(getPersistedUriPermissions())
BrowseScreen.refreshListChannel.trySend(Unit)
}
)
}
}
BrowseScanAction(
requestPersistableUriPermission = {
persistedUriIntent.launch(null)
}
)
}
@Composable
private fun BrowseScanFolderItem(
index: Int,
permission: UriPermission,
context: Context,
releasePersistableUriPermission: () -> Unit
) {
val permissionFile = DocumentFileCompat.fromUri(context, permission.uri) ?: return
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 18.dp,
vertical = 8.dp
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(18.dp)
) {
Icon(
imageVector = Icons.Outlined.Folder,
contentDescription = null,
modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.dynamicListItemColor(index))
.padding(11.dp)
.size(22.dp),
tint = MaterialTheme.colorScheme.onSurface
)
Column(
modifier = Modifier.weight(1f)
) {
StyledText(
text = permissionFile.getBasePath(context),
style = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface
)
)
StyledText(
text = permissionFile.getRootPath(context),
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
)
}
IconButton(
modifier = Modifier.size(24.dp),
icon = Icons.Outlined.Clear,
contentDescription = R.string.remove_content_desc,
disableOnClick = false,
color = MaterialTheme.colorScheme.onSurface
) {
releasePersistableUriPermission()
}
}
}
@Composable
private fun BrowseScanAction(
requestPersistableUriPermission: () -> Unit
) {
Row(
modifier = Modifier
.padding(horizontal = 18.dp, vertical = 18.dp)
.noRippleClickable {
requestPersistableUriPermission()
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = Icons.Default.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary
)
StyledText(
text = stringResource(id = R.string.add_folder),
style = MaterialTheme.typography.labelLarge.copy(
color = MaterialTheme.colorScheme.secondary
)
)
}
}

View file

@ -0,0 +1,21 @@
/*
* 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.presentation.settings.browse.scan.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.settings.components.SettingsSubcategoryNote
@Composable
fun BrowseScanOptionNote() {
SettingsSubcategoryNote(
text = stringResource(
id = R.string.browse_scan_option_note
)
)
}

View file

@ -0,0 +1,55 @@
/*
* 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.presentation.settings.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.outlined.Info
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.presentation.core.components.common.StyledText
@Composable
fun SettingsSubcategoryNote(
text: String,
horizontalPadding: Dp = 18.dp,
verticalPadding: Dp = 8.dp
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = horizontalPadding,
vertical = verticalPadding
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = Icons.Outlined.Info,
contentDescription = stringResource(id = R.string.note_content_desc),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
StyledText(
modifier = Modifier.fillMaxWidth(),
text = text,
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
)
}
}

View file

@ -10,24 +10,17 @@ import androidx.compose.foundation.background
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import ua.acclorite.book_story.domain.navigator.StackEvent
import ua.acclorite.book_story.domain.ui.ButtonItem
import ua.acclorite.book_story.ui.main.MainEvent
import ua.acclorite.book_story.ui.start.StartEvent
import ua.acclorite.book_story.ui.start.StartScreen
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun StartContent(
currentPage: Int,
stackEvent: StackEvent,
storagePermissionGranted: Boolean,
storagePermissionState: PermissionState,
languages: List<ButtonItem>,
changeLanguage: (MainEvent.OnChangeLanguage) -> Unit,
storagePermissionRequest: (StartEvent.OnStoragePermissionRequest) -> Unit,
navigateForward: () -> Unit,
navigateBack: () -> Unit,
navigateToBrowse: () -> Unit,
@ -46,11 +39,8 @@ fun StartContent(
StartSettings(
currentPage = currentPage,
stackEvent = stackEvent,
storagePermissionGranted = storagePermissionGranted,
storagePermissionState = storagePermissionState,
languages = languages,
changeLanguage = changeLanguage,
storagePermissionRequest = storagePermissionRequest,
navigateForward = navigateForward
)
}

View file

@ -7,39 +7,27 @@
package ua.acclorite.book_story.presentation.start
import androidx.compose.runtime.Composable
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import ua.acclorite.book_story.domain.navigator.StackEvent
import ua.acclorite.book_story.domain.ui.ButtonItem
import ua.acclorite.book_story.presentation.core.util.LocalActivity
import ua.acclorite.book_story.ui.main.MainEvent
import ua.acclorite.book_story.ui.start.StartEvent
import ua.acclorite.book_story.ui.start.StartScreen
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun StartSettings(
currentPage: Int,
stackEvent: StackEvent,
storagePermissionGranted: Boolean,
storagePermissionState: PermissionState,
languages: List<ButtonItem>,
changeLanguage: (MainEvent.OnChangeLanguage) -> Unit,
storagePermissionRequest: (StartEvent.OnStoragePermissionRequest) -> Unit,
navigateForward: () -> Unit
) {
val activity = LocalActivity.current
StartSettingsScaffold(
currentPage = currentPage,
storagePermissionGranted = storagePermissionGranted,
navigateForward = navigateForward
) {
StartContentTransition(
targetValue = when (currentPage) {
0 -> StartScreen.GENERAL_SETTINGS
1 -> StartScreen.APPEARANCE_SETTINGS
else -> StartScreen.PERMISSION_SETTINGS
else -> StartScreen.SCAN_SETTINGS
},
stackEvent = stackEvent
) { page ->
@ -56,13 +44,8 @@ fun StartSettings(
StartSettingsLayoutAppearance()
}
StartScreen.PERMISSION_SETTINGS -> {
StartSettingsLayoutPermissions(
activity = activity,
storagePermissionGranted = storagePermissionGranted,
storagePermissionState = storagePermissionState,
storagePermissionRequest = storagePermissionRequest
)
StartScreen.SCAN_SETTINGS -> {
StartSettingsLayoutScan()
}
}
}

View file

@ -22,8 +22,6 @@ import ua.acclorite.book_story.presentation.core.components.common.StyledText
@Composable
fun StartSettingsBottomBar(
currentPage: Int,
storagePermissionGranted: Boolean,
navigateForward: () -> Unit
) {
Column {
@ -34,8 +32,7 @@ fun StartSettingsBottomBar(
.padding(bottom = 8.dp)
.padding(horizontal = 18.dp)
.fillMaxWidth(),
onClick = { navigateForward() },
enabled = storagePermissionGranted || currentPage != 2
onClick = { navigateForward() }
) {
StyledText(text = stringResource(id = R.string.next))
}

View file

@ -1,68 +0,0 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
@file:Suppress("FunctionName")
package ua.acclorite.book_story.presentation.start
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.settings.components.SettingsSubcategoryTitle
import ua.acclorite.book_story.ui.start.StartEvent
@OptIn(ExperimentalPermissionsApi::class)
fun LazyListScope.StartSettingsLayoutPermissions(
activity: ComponentActivity,
storagePermissionGranted: Boolean,
storagePermissionState: PermissionState,
storagePermissionRequest: (StartEvent.OnStoragePermissionRequest) -> Unit,
) {
item {
Spacer(modifier = Modifier.height(16.dp))
}
item {
SettingsSubcategoryTitle(
modifier = Modifier,
title = stringResource(id = R.string.start_permissions_preferences),
color = MaterialTheme.colorScheme.primary
)
}
item {
Spacer(modifier = Modifier.height(8.dp))
}
item {
StartSettingsLayoutPermissionsItem(
title = stringResource(id = R.string.start_permissions_storage),
description = stringResource(id = R.string.start_permissions_storage_desc),
isOptional = false,
isGranted = storagePermissionGranted,
onGrantClick = {
storagePermissionRequest(
StartEvent.OnStoragePermissionRequest(
activity = activity,
storagePermissionState = storagePermissionState
)
)
}
)
}
item {
Spacer(modifier = Modifier.height(8.dp))
}
}

View file

@ -1,100 +0,0 @@
/*
* 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.presentation.start
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.core.components.common.StyledText
@Composable
fun StartSettingsLayoutPermissionsItem(
title: String,
description: String,
isOptional: Boolean,
isGranted: Boolean,
onGrantClick: () -> Unit
) {
Row(
modifier = Modifier
.padding(horizontal = 18.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.Center) {
Row(verticalAlignment = Alignment.CenterVertically) {
StyledText(
text = title,
style = MaterialTheme.typography.titleMedium.copy(
color = MaterialTheme.colorScheme.onSurface
),
maxLines = 1
)
if (!isOptional) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Default.Star,
contentDescription = stringResource(id = R.string.required_content_desc),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(15.dp)
)
}
}
StyledText(
text = description,
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
)
}
Spacer(modifier = Modifier.width(8.dp))
if (!isGranted) {
if (!isOptional) {
Button(
onClick = onGrantClick,
contentPadding = ButtonDefaults.TextButtonContentPadding
) {
StyledText(
text = stringResource(id = R.string.grant),
style = MaterialTheme.typography.labelLarge
)
}
} else {
TextButton(onClick = onGrantClick) {
StyledText(text = stringResource(id = R.string.grant))
}
}
} else {
StyledText(
text = stringResource(id = R.string.start_permissions_granted),
modifier = Modifier.padding(ButtonDefaults.TextButtonContentPadding),
style = MaterialTheme.typography.labelLarge.copy(
color = MaterialTheme.colorScheme.primary.copy(0.7f)
)
)
}
}
}

View file

@ -0,0 +1,45 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
@file:Suppress("FunctionName")
package ua.acclorite.book_story.presentation.start
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.settings.browse.scan.components.BrowseScanOption
import ua.acclorite.book_story.presentation.settings.components.SettingsSubcategoryTitle
fun LazyListScope.StartSettingsLayoutScan() {
item {
Spacer(modifier = Modifier.height(16.dp))
}
item {
SettingsSubcategoryTitle(
title = stringResource(id = R.string.start_scan_preferences),
color = MaterialTheme.colorScheme.primary
)
}
item {
Spacer(modifier = Modifier.height(8.dp))
}
item {
BrowseScanOption()
}
item {
Spacer(modifier = Modifier.height(8.dp))
}
}

View file

@ -29,8 +29,6 @@ import ua.acclorite.book_story.presentation.core.components.common.StyledText
@Composable
fun StartSettingsScaffold(
currentPage: Int,
storagePermissionGranted: Boolean,
navigateForward: () -> Unit,
content: @Composable () -> Unit
) {
@ -39,8 +37,6 @@ fun StartSettingsScaffold(
containerColor = MaterialTheme.colorScheme.surface,
bottomBar = {
StartSettingsBottomBar(
currentPage = currentPage,
storagePermissionGranted = storagePermissionGranted,
navigateForward = navigateForward
)
}

View file

@ -4,16 +4,11 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
@file:OptIn(ExperimentalPermissionsApi::class)
package ua.acclorite.book_story.ui.browse
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Immutable
import androidx.compose.ui.focus.FocusRequester
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import ua.acclorite.book_story.domain.browse.SelectableFile
import ua.acclorite.book_story.domain.library.book.SelectableNullableBook
@ -54,19 +49,6 @@ sealed class BrowseEvent {
data object OnDismissBottomSheet : BrowseEvent()
data class OnPermissionCheck(
val storagePermissionState: PermissionState
) : BrowseEvent()
data class OnActionPermissionDialog(
val activity: ComponentActivity,
val storagePermissionState: PermissionState
) : BrowseEvent()
data class OnDismissPermissionDialog(
val storagePermissionState: PermissionState
) : BrowseEvent()
data object OnShowAddDialog : BrowseEvent()
data object OnDismissAddDialog : BrowseEvent()

View file

@ -6,12 +6,8 @@
package ua.acclorite.book_story.ui.browse
import android.os.Build
import android.os.Environment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -32,18 +28,15 @@ import ua.acclorite.book_story.domain.library.book.NullableBook
import ua.acclorite.book_story.domain.library.book.SelectableNullableBook
import ua.acclorite.book_story.domain.use_case.book.InsertBook
import ua.acclorite.book_story.domain.use_case.file_system.GetBookFromFile
import ua.acclorite.book_story.domain.use_case.file_system.GetFilesFromDevice
import ua.acclorite.book_story.domain.use_case.permission.GrantStoragePermission
import ua.acclorite.book_story.domain.use_case.file_system.GetFiles
import ua.acclorite.book_story.presentation.core.util.showToast
import ua.acclorite.book_story.ui.library.LibraryScreen
import java.io.File
import javax.inject.Inject
import kotlin.collections.map
@HiltViewModel
class BrowseModel @Inject constructor(
private val grantStoragePermission: GrantStoragePermission,
private val getFilesFromDevice: GetFilesFromDevice,
private val getFiles: GetFiles,
private val getBookFromFile: GetBookFromFile,
private val insertBook: InsertBook
) : ViewModel() {
@ -79,10 +72,8 @@ class BrowseModel @Inject constructor(
private var refreshJob: Job? = null
private var changeSearchQueryJob: Job? = null
private var storagePermissionJob: Job? = null
private var getAddDialogBooksJob: Job? = null
@OptIn(ExperimentalPermissionsApi::class)
fun onEvent(event: BrowseEvent) {
when (event) {
is BrowseEvent.OnRefreshList -> {
@ -191,11 +182,11 @@ class BrowseModel @Inject constructor(
val editedList = _state.value.files.map { file ->
if (
event.files.any {
file.path.startsWith(it.path)
file.data.path.startsWith(it.data.path)
} && event.includedFileFormats.run {
if (isEmpty()) return@run true
any {
file.path.endsWith(it, ignoreCase = true)
file.data.path.endsWith(it, ignoreCase = true)
}
}
) {
@ -225,7 +216,7 @@ class BrowseModel @Inject constructor(
is BrowseEvent.OnSelectFile -> {
viewModelScope.launch(Dispatchers.IO) {
val editedList = _state.value.files.map { file ->
if (event.file.path == file.path) {
if (event.file.data.path == file.data.path) {
file.copy(
selected = !file.selected
)
@ -271,79 +262,6 @@ class BrowseModel @Inject constructor(
}
}
is BrowseEvent.OnPermissionCheck -> {
viewModelScope.launch(Dispatchers.IO) {
val legacyPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.R
val isPermissionGranted = if (!legacyPermission) {
Environment.isExternalStorageManager()
} else event.storagePermissionState.status.isGranted
if (isPermissionGranted) {
return@launch
}
_state.update {
it.copy(
dialog = BrowseScreen.PERMISSION_DIALOG,
isError = false
)
}
}
}
is BrowseEvent.OnActionPermissionDialog -> {
storagePermissionJob?.cancel()
storagePermissionJob = viewModelScope.launch(Dispatchers.IO) {
grantStoragePermission.execute(
activity = event.activity,
storagePermissionState = event.storagePermissionState
).apply {
if (this) {
_state.update {
it.copy(
dialog = null,
isError = false
)
}
onEvent(
BrowseEvent.OnRefreshList(
loading = true,
hideSearch = false
)
)
}
}
}
}
is BrowseEvent.OnDismissPermissionDialog -> {
viewModelScope.launch {
val legacyPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.R
val permissionGranted = if (!legacyPermission) {
Environment.isExternalStorageManager()
} else event.storagePermissionState.status.isGranted
storagePermissionJob?.cancel()
_state.update {
it.copy(
isError = !permissionGranted,
dialog = null
)
}
if (permissionGranted) {
viewModelScope.launch(Dispatchers.IO) {
onEvent(
BrowseEvent.OnRefreshList(
loading = true,
hideSearch = false
)
)
}
}
}
}
is BrowseEvent.OnShowAddDialog -> {
viewModelScope.launch {
_state.update {
@ -374,10 +292,9 @@ class BrowseModel @Inject constructor(
}
return@launch
}
.map { File(it.path) }
.forEach {
yield()
books.add(getBookFromFile.execute(it))
books.add(getBookFromFile.execute(it.data))
}
yield()
@ -417,35 +334,8 @@ class BrowseModel @Inject constructor(
return@mapNotNull null
}.ifEmpty { return@launch }
var failed = false
for (book in booksToInsert) {
if (!insertBook.execute(book.bookWithCover!!)) {
failed = true
break
}
}
if (failed) {
_state.update {
it.copy(
dialog = null
)
}
onEvent(
BrowseEvent.OnRefreshList(
loading = false,
hideSearch = false
)
)
onEvent(BrowseEvent.OnClearSelectedFiles)
withContext(Dispatchers.Main) {
event.context
.getString(R.string.error_something_went_wrong)
.showToast(context = event.context)
}
return@launch
insertBook.execute(book.bookWithCover!!)
}
LibraryScreen.refreshListChannel.trySend(0)
@ -512,7 +402,7 @@ class BrowseModel @Inject constructor(
private suspend fun getFilesFromDownloads(
query: String = if (_state.value.showSearch) _state.value.searchQuery else ""
) {
getFilesFromDevice.execute(query).apply {
getFiles.execute(query).apply {
yield()
_state.update {
it.copy(
@ -525,13 +415,6 @@ class BrowseModel @Inject constructor(
}
}
fun resetScreen() {
viewModelScope.launch {
storagePermissionJob?.cancel()
_state.update { it.copy(isError = false) }
}
}
fun filterList(
files: List<SelectableFile>,
sortOrderDescending: Boolean,
@ -555,7 +438,7 @@ class BrowseModel @Inject constructor(
return filter { file ->
includedFilterItems.any {
file.path.endsWith(
file.data.path.endsWith(
it, ignoreCase = true
)
}
@ -568,19 +451,19 @@ class BrowseModel @Inject constructor(
compareByWithOrder<SelectableFile> {
when (sortOrder) {
BrowseSortOrder.NAME -> {
it.name.trim()
it.data.name.trim()
}
BrowseSortOrder.FILE_FORMAT -> {
it.path.substringAfterLast(".").lowercase().trimEnd()
it.data.path.substringAfterLast(".").lowercase().trimEnd()
}
BrowseSortOrder.FILE_SIZE -> {
it.size
it.data.size
}
else -> {
it.lastModified
it.data.lastModified
}
}
}

View file

@ -6,7 +6,6 @@
package ua.acclorite.book_story.ui.browse
import android.Manifest
import android.os.Parcelable
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.rememberLazyListState
@ -20,8 +19,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.focus.FocusRequester
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
@ -31,16 +28,13 @@ import ua.acclorite.book_story.domain.browse.BrowseLayout
import ua.acclorite.book_story.domain.navigator.Screen
import ua.acclorite.book_story.presentation.browse.BrowseContent
import ua.acclorite.book_story.presentation.navigator.LocalNavigator
import ua.acclorite.book_story.ui.help.HelpScreen
import ua.acclorite.book_story.ui.library.LibraryScreen
import ua.acclorite.book_story.ui.main.MainModel
import ua.acclorite.book_story.ui.settings.BrowseSettingsScreen
@Parcelize
object BrowseScreen : Screen, Parcelable {
@IgnoredOnParcel
const val PERMISSION_DIALOG = "permission_dialog"
@IgnoredOnParcel
const val ADD_DIALOG = "add_dialog"
@ -65,7 +59,7 @@ object BrowseScreen : Screen, Parcelable {
@IgnoredOnParcel
private var initialGridOffset = 0
@OptIn(ExperimentalMaterialApi::class, ExperimentalPermissionsApi::class)
@OptIn(ExperimentalMaterialApi::class)
@Composable
override fun Content() {
val navigator = LocalNavigator.current
@ -78,9 +72,6 @@ object BrowseScreen : Screen, Parcelable {
val listState = rememberLazyListState(initialListIndex, initialListOffset)
val gridState = rememberLazyGridState(initialGridIndex, initialGridOffset)
val storagePermissionState = rememberPermissionState(
permission = Manifest.permission.READ_EXTERNAL_STORAGE
)
val focusRequester = remember { FocusRequester() }
val refreshState = rememberPullRefreshState(
refreshing = state.value.isRefreshing,
@ -105,14 +96,6 @@ object BrowseScreen : Screen, Parcelable {
}
}
LaunchedEffect(Unit) {
screenModel.onEvent(
BrowseEvent.OnPermissionCheck(
storagePermissionState = storagePermissionState
)
)
}
LaunchedEffect(Unit) {
resetScrollPositionCompositionChannel.receiveAsFlow().collectLatest {
initialListIndex = 0
@ -124,8 +107,6 @@ object BrowseScreen : Screen, Parcelable {
DisposableEffect(Unit) {
onDispose {
screenModel.resetScreen()
initialListIndex = 0
initialListOffset = 0
initialGridIndex = 0
@ -149,7 +130,6 @@ object BrowseScreen : Screen, Parcelable {
files = files.value,
selectedBooksAddDialog = state.value.selectedBooksAddDialog,
refreshState = refreshState,
storagePermissionState = storagePermissionState,
loadingAddDialog = state.value.loadingAddDialog,
dialog = state.value.dialog,
bottomSheet = state.value.bottomSheet,
@ -166,7 +146,6 @@ object BrowseScreen : Screen, Parcelable {
selectedItemsCount = state.value.selectedItemsCount,
isRefreshing = state.value.isRefreshing,
isLoading = state.value.isLoading,
isError = state.value.isError,
dialogHidden = state.value.dialog == null,
filesEmpty = files.value.isEmpty(),
showSearch = state.value.showSearch,
@ -179,11 +158,8 @@ object BrowseScreen : Screen, Parcelable {
clearSelectedFiles = screenModel::onEvent,
selectFiles = screenModel::onEvent,
selectFile = screenModel::onEvent,
permissionCheck = screenModel::onEvent,
showFilterBottomSheet = screenModel::onEvent,
dismissBottomSheet = screenModel::onEvent,
actionPermissionDialog = screenModel::onEvent,
dismissPermissionDialog = screenModel::onEvent,
showAddDialog = screenModel::onEvent,
dismissAddDialog = screenModel::onEvent,
selectAddDialog = screenModel::onEvent,
@ -192,8 +168,8 @@ object BrowseScreen : Screen, Parcelable {
navigateToLibrary = {
navigator.push(LibraryScreen, saveInBackStack = false)
},
navigateToHelp = {
navigator.push(HelpScreen(fromStart = false))
navigateToBrowseSettings = {
navigator.push(BrowseSettingsScreen)
},
)
}

View file

@ -18,7 +18,6 @@ data class BrowseState(
val isLoading: Boolean = true,
val isRefreshing: Boolean = false,
val isError: Boolean = false,
val selectedItemsCount: Int = 0,
val hasSelectedItems: Boolean = false,

View file

@ -169,4 +169,9 @@ class MainActivity : AppCompatActivity() {
}
}
}
override fun onDestroy() {
cacheDir.deleteRecursively()
super.onDestroy()
}
}

View file

@ -4,18 +4,24 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
@file:OptIn(ExperimentalPermissionsApi::class)
package ua.acclorite.book_story.ui.settings
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import ua.acclorite.book_story.domain.util.ID
@Immutable
sealed class SettingsEvent {
data class OnGrantPersistableUriPermission(
val uri: Uri
) : SettingsEvent()
data class OnReleasePersistableUriPermission(
val uri: Uri
) : SettingsEvent()
data class OnSelectColorPreset(
val id: ID
) : SettingsEvent()

View file

@ -9,7 +9,6 @@ package ua.acclorite.book_story.ui.settings
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -27,6 +26,8 @@ import ua.acclorite.book_story.domain.use_case.color_preset.GetColorPresets
import ua.acclorite.book_story.domain.use_case.color_preset.ReorderColorPresets
import ua.acclorite.book_story.domain.use_case.color_preset.SelectColorPreset
import ua.acclorite.book_story.domain.use_case.color_preset.UpdateColorPreset
import ua.acclorite.book_story.domain.use_case.permission.GrantPersistableUriPermission
import ua.acclorite.book_story.domain.use_case.permission.ReleasePersistableUriPermission
import ua.acclorite.book_story.presentation.core.constants.Constants
import ua.acclorite.book_story.presentation.core.constants.provideDefaultColorPreset
import ua.acclorite.book_story.presentation.core.util.showToast
@ -39,7 +40,9 @@ class SettingsModel @Inject constructor(
private val updateColorPreset: UpdateColorPreset,
private val selectColorPreset: SelectColorPreset,
private val reorderColorPresets: ReorderColorPresets,
private val deleteColorPreset: DeleteColorPreset
private val deleteColorPreset: DeleteColorPreset,
private val grantPersistableUriPermission: GrantPersistableUriPermission,
private val releasePersistableUriPermission: ReleasePersistableUriPermission
) : ViewModel() {
private val mutex = Mutex()
@ -92,9 +95,24 @@ class SettingsModel @Inject constructor(
}
}
@OptIn(ExperimentalPermissionsApi::class)
fun onEvent(event: SettingsEvent) {
when (event) {
is SettingsEvent.OnGrantPersistableUriPermission -> {
viewModelScope.launch {
grantPersistableUriPermission.execute(
event.uri
)
}
}
is SettingsEvent.OnReleasePersistableUriPermission -> {
viewModelScope.launch {
releasePersistableUriPermission.execute(
event.uri
)
}
}
is SettingsEvent.OnSelectColorPreset -> {
viewModelScope.launch {
cancelColorPresetJobs()

View file

@ -1,26 +0,0 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
@file:OptIn(ExperimentalPermissionsApi::class)
package ua.acclorite.book_story.ui.start
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Immutable
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
@Immutable
sealed class StartEvent {
data class OnCheckPermissions(
val storagePermissionState: PermissionState
) : StartEvent()
data class OnStoragePermissionRequest(
val activity: ComponentActivity,
val storagePermissionState: PermissionState
) : StartEvent()
}

View file

@ -1,92 +0,0 @@
/*
* 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.ui.start
import android.os.Build
import android.os.Environment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.yield
import ua.acclorite.book_story.domain.use_case.permission.GrantStoragePermission
import javax.inject.Inject
@HiltViewModel
class StartModel @Inject constructor(
private val grantStoragePermission: GrantStoragePermission
) : ViewModel() {
private val mutex = Mutex()
private val _state = MutableStateFlow(StartState())
val state = _state.asStateFlow()
private var storagePermissionJob: Job? = null
@OptIn(ExperimentalPermissionsApi::class)
fun onEvent(event: StartEvent) {
when (event) {
is StartEvent.OnCheckPermissions -> {
viewModelScope.launch(Dispatchers.IO) {
val legacyStoragePermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.R
val storagePermissionGranted = if (!legacyStoragePermission) {
Environment.isExternalStorageManager()
} else event.storagePermissionState.status.isGranted
_state.update {
it.copy(
storagePermissionGranted = storagePermissionGranted
)
}
}
}
is StartEvent.OnStoragePermissionRequest -> {
storagePermissionJob?.cancel()
storagePermissionJob = viewModelScope.launch(Dispatchers.IO) {
grantStoragePermission.execute(
activity = event.activity,
storagePermissionState = event.storagePermissionState
).apply {
if (this) {
_state.update {
it.copy(
storagePermissionGranted = true
)
}
}
}
}
}
}
}
fun resetScreen() {
viewModelScope.launch {
_state.update {
storagePermissionJob?.cancel()
StartState()
}
}
}
private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {
mutex.withLock {
yield()
this.value = function(this.value)
}
}
}

View file

@ -6,21 +6,16 @@
package ua.acclorite.book_story.ui.start
import android.Manifest
import android.annotation.SuppressLint
import android.os.Parcelable
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.text.TextStyle
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import ua.acclorite.book_story.domain.navigator.Screen
@ -49,27 +44,21 @@ object StartScreen : Screen, Parcelable {
const val APPEARANCE_SETTINGS = "appearance_settings"
@IgnoredOnParcel
const val PERMISSION_SETTINGS = "permission_settings"
const val SCAN_SETTINGS = "scan_settings"
@IgnoredOnParcel
const val DONE = "done"
@SuppressLint("InlinedApi")
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
val navigator = LocalNavigator.current
val screenModel = hiltViewModel<StartModel>()
val mainModel = hiltViewModel<MainModel>()
val state = screenModel.state.collectAsStateWithLifecycle()
val mainState = mainModel.state.collectAsStateWithLifecycle()
val activity = LocalActivity.current
val storagePermissionState = rememberPermissionState(
permission = Manifest.permission.READ_EXTERNAL_STORAGE
)
val currentPage = remember { mutableIntStateOf(0) }
val stackEvent = remember { mutableStateOf(StackEvent.Default) }
@ -85,27 +74,13 @@ object StartScreen : Screen, Parcelable {
}.sortedBy { it.title }
}
LaunchedEffect(Unit) {
screenModel.onEvent(
StartEvent.OnCheckPermissions(
storagePermissionState = storagePermissionState
)
)
}
DisposableEffect(Unit) {
onDispose { screenModel.resetScreen() }
}
StartContent(
currentPage = currentPage.intValue,
stackEvent = stackEvent.value,
storagePermissionGranted = state.value.storagePermissionGranted,
storagePermissionState = storagePermissionState,
languages = languages,
changeLanguage = mainModel::onEvent,
storagePermissionRequest = screenModel::onEvent,
navigateForward = {
if ((currentPage.intValue + 1 == 3) && !state.value.storagePermissionGranted) {
if (currentPage.intValue + 1 == 4) {
return@StartContent
}

View file

@ -1,14 +0,0 @@
/*
* 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.ui.start
import androidx.compose.runtime.Immutable
@Immutable
data class StartState(
val storagePermissionGranted: Boolean = false
)

View file

@ -9,14 +9,12 @@
<resources>
<string name="history_screen">التاريخ</string>
<string name="add_books_description">يمكنك اختيار أي الكُتب تريد إضافتها. يمكنك تعديل العنوان و صورة غلاف الكتاب لاحقا. عملية التحميل يمكن أن تستغرق حتى بضع دقائق.</string>
<string name="storage_permission_description">نحتاج إذن التخزين لفحص جهازك من أجل الكُتب. من دون هذا الإذن، لن تكون قادرا على إضافة كتاب.</string>
<string name="move_book_description">اختر الفئة التي تريد نقل هذا الكتاب إليها. سيتم نقل هذا الكتاب إلى الفئة المُحدّدة.</string>
<string name="library_screen">المكتبة</string>
<string name="browse_screen">تصفح</string>
<string name="settings_screen">الإعدادات</string>
<string name="about_screen">عن التطبيق</string>
<string name="help_screen">مساعدة</string>
<string name="storage_permission">منح الإذن؟</string>
<string name="add_books">إضافة الكُتب؟</string>
<string name="move_books">نقل الكُتب؟</string>
<string name="delete_books">حذف الكُتب؟</string>
@ -36,9 +34,6 @@
<string name="history_empty">دع آخر كتاب قرأته يفتح صفحات قصة جديدة عظيمة من رحلة قرائتك</string>
<string name="error_could_not_reset_cover">غير قادر على إعادة ضبط صورة الغلاف.</string>
<string name="cancel">إلغاء</string>
<string name="grant">امنح</string>
<string name="grant_permission">امنح الإذن</string>
<string name="get_help">أحتاج المساعدة</string>
<string name="add_book">أضف كتابا˝</string>
<string name="never">أبدا˝</string>
<string name="undo">تراجع</string>
@ -51,7 +46,6 @@
<string name="language_option">لغة التطبيق</string>
<string name="dark_theme_option">سمة داكنة</string>
<string name="help_desc_how_to_manage_history_2">التاريخ</string>
<string name="error_permission">نحتاج إذن التخزين لفحص جهازك من أجل الكُتب</string>
<string name="error_no_description">لا يوجد وصف.</string>
<string name="browse_empty">قم بتوسيع مجموعة كتب مكتبتك باستخدام تنزيلاتك</string>
<string name="selected_items_count_query">حُددت %1$s</string>
@ -301,7 +295,6 @@
<string name="start_welcome_desc">إلى عالم حيث الكلمات تسحر وتعلم وتقود إلى مغامرات لا تصدق! دعنا نرتب بعض الأمور!</string>
<string name="start_welcome">مرحبًا بك في Book\'s Story!</string>
<string name="delete_books_content_desc">احذف الكُتب المُحدّدة</string>
<string name="start_permissions_storage_desc">مطلوب مسح جهازك والعثور على الكُتب</string>
<string name="delete_color_preset_content_desc">احذف إعداد اللون المسبق</string>
<string name="create_color_preset_content_desc">أنشئ إعداد لون مسبق جديد</string>
<string name="shuffle_color_preset_content_desc">اخلط الألوان</string>
@ -333,7 +326,6 @@
<string name="go_back_content_desc">ارجع</string>
<string name="open_reader_settings_content_desc">إعدادات القارئ</string>
<string name="cutout_padding_option_desc">تطبيق الهامش على منطقة القطع</string>
<string name="required_content_desc">مطلوب</string>
<string name="error_content_desc">خطأ</string>
<string name="cover_image_not_found_content_desc">لم يتم العثور غلاف صورة</string>
<string name="more_content_desc">المزيد</string>
@ -341,11 +333,8 @@
<string name="path_changed">نجح تغيير مسار الملف.</string>
<string name="revert_content_desc">تراجع</string>
<string name="arrow_content_desc">سهم</string>
<string name="start_permissions_granted">منحت</string>
<string name="start_permissions_storage">إذن التخزين</string>
<string name="yesterday">أمس</string>
<string name="sort_order_content_desc">ترتيب الفرز</string>
<string name="start_permissions_preferences">الأذونات</string>
<string name="today">اليوم</string>
<string name="checkbox_content_desc">مربّع تأشير</string>
<string name="github_profile_content_desc">ملف تعريف GitHub</string>

View file

@ -61,7 +61,6 @@
<string name="help_translate_option">Pomozte překládat</string>
<string name="filter_tab">Filtr</string>
<string name="reader_tab">Čtečka</string>
<string name="get_help">Potřebuji pomoc</string>
<string name="browse_layout_list">List</string>
<string name="absolute_dark_option">Úplně tmavé</string>
<string name="delete_book">Smazat knihu?</string>
@ -80,7 +79,6 @@
<string name="browse_screen">Procházet</string>
<string name="error_no_share_app">Žádná aplikace pro sdílení nebyla nalezena.</string>
<string name="help_desc_how_to_add_books_2">Procházet</string>
<string name="grant_permission">Povolit oprávnění</string>
<string name="browse_grid_size_option">Velikost mřížky</string>
<string name="delete_books">Smazat knihy?</string>
<string name="font_style_normal">Normální</string>
@ -137,11 +135,9 @@
<string name="about_screen">O aplikaci</string>
<string name="add_books">Přidat knihy?</string>
<string name="move_book">Přesunout knihu?</string>
<string name="storage_permission_description">Potřebujeme povolení, abychom mohli skenovat knihy na Vašem úložišti. Bez povolení si nebudete moci přidat jakoukoli knihu.</string>
<string name="delete_book_description">Smaže to tuto knihu z databáze. Nesmaže to na vašem zařízení.</string>
<string name="error_no_description">Žádný popis.</string>
<string name="error_something_went_wrong">Něco se pokazilo.</string>
<string name="grant">Povolit</string>
<string name="add_book">Přidat knihu</string>
<string name="copy">Kopírovat</string>
<string name="translate">Přeložit</string>
@ -195,7 +191,6 @@
<string name="custom_screen_brightness_option">Vlastní jas obrazovky</string>
<string name="horizontal_gesture_sensitivity_option">Citlivost</string>
<string name="help_desc_how_to_customize_app_2">Nastavení</string>
<string name="start_permissions_storage_desc">Vyžadováno, abychom mohli skenoval Vaše úložiště a hledat knihy</string>
<string name="start_language_preferences">Preference jazyka</string>
<string name="start_theme_preferences">Preference témat</string>
<string name="history_content_desc">Historie čtení</string>
@ -203,7 +198,6 @@
<string name="cover_image_content_desc">Obálka</string>
<string name="delete_books_content_desc">Smazat vybrané knihy</string>
<string name="browse_content_desc">Přidat knihy</string>
<string name="start_permissions_granted">Povoleno</string>
<string name="move_books_content_desc">Přesunout vybrané knihy</string>
<string name="start_welcome">Vítejte v Book\'s Story!</string>
<string name="help_title_how_to_move_or_delete_books">Jak přesunu nebo smažu knihy?</string>
@ -219,14 +213,11 @@
<string name="go_back_content_desc">Jít zpět</string>
<string name="continue_reading_content_desc">Pokračovat ve čtení</string>
<string name="library_content_desc">Vaše knihovna</string>
<string name="required_content_desc">Vyžadován</string>
<string name="open_reader_settings_content_desc">Nastavení čtečky</string>
<string name="delete_whole_history_content_desc">Smazat celou historii</string>
<string name="color_preset_placeholder">Pojmenujete nastavení…</string>
<string name="help_desc_how_to_move_or_delete_books_2">Knihovna</string>
<string name="start_welcome_desc">Ve světe, kde slova okouzlují, učí a vedou k neuvěřitelným dobrodružstvím! Pojďme nastavit pár věcí!</string>
<string name="start_permissions_storage">Oprávnění úložiště</string>
<string name="start_permissions_preferences">Oprávnění</string>
<string name="yellow2_theme">Makemake</string>
<string name="start_done">A je to!</string>
<string name="today">Dnes</string>

View file

@ -28,9 +28,6 @@
<string name="selected_items_count_query">%1$s ausgewählt</string>
<string name="color_preset_selected_query">Ausgewählt «%1$s»</string>
<string name="cancel">Abbrechen</string>
<string name="grant">Gewähren</string>
<string name="grant_permission">Erteilen der Berechtigung</string>
<string name="get_help">Ich brauche Hilfe</string>
<string name="add_book">Buch hinzufügen</string>
<string name="move">Verschieben</string>
<string name="never">Niemals</string>
@ -120,9 +117,6 @@
<string name="help_desc_how_to_read_book_2">Bibliothek</string>
<string name="help_title_how_to_customize_reader">Wie passe ich den Reader an?</string>
<string name="help_desc_how_to_customize_reader_1">Beginnen Sie mit dem Lesen des Buches (siehe: Wie lese ich ein Buch?), und bringen Sie dann die oberen und unteren Balken zum Vorschein, indem Sie irgendwo auf den Bildschirm klicken. Klicken Sie dann auf das Zahnradsymbol in der oberen rechten Ecke. Dadurch werden alle Reader-Einstellungen angezeigt. Der zweite Weg ist, zu den</string>
<string name="start_permissions_granted">Gewährt</string>
<string name="start_permissions_storage">Speicherberechtigung</string>
<string name="start_permissions_storage_desc">Erforderlich, um Ihr Gerät zu scannen und Bücher zu finden</string>
<string name="start_done">Alles ist erledigt!</string>
<string name="start_done_desc">Sie haben die Grundeinstellungen der App erfolgreich abgeschlossen. Möchten Sie anhand des Hilfebildschirms erfahren, wie Sie die App verwenden?</string>
<string name="today">Heute</string>
@ -147,12 +141,9 @@
<string name="app_icon_content_desc">App-Symbol</string>
<string name="help_desc_how_to_add_books_3">Wenn Sie die heruntergeladenen Bücher nicht sehen, versuchen Sie, die Liste zu aktualisieren, indem Sie sie nach unten ziehen. Stellen Sie sicher, dass Ihre Bücher das unterstützte Dateiformat haben. Klicken Sie dann auf das Buch, um es auszuwählen, oder halten Sie gedrückt, um seinen Speicherort anzuzeigen. Nachdem Sie alle gewünschten Bücher ausgewählt haben, klicken Sie auf das Häkchensymbol in der oberen rechten Ecke, warten Sie, bis alle Bücher geladen sind, und klicken Sie auf «Hinzufügen». Jetzt sollten Sie alle Bücher sehen, die Sie in der Liste hinzugefügt haben</string>
<string name="help_screen">Hilfe</string>
<string name="storage_permission">Berechtigung erteilen?</string>
<string name="add_books">Bücher hinzufügen?</string>
<string name="storage_permission_description">Wir benötigen eine Speicherberechtigung, um Ihr Gerät nach Büchern zu durchsuchen. Ohne diese Berechtigung können Sie kein Buch hinzufügen.</string>
<string name="delete_books_description">Es werden alle ausgewählten Bücher (%1$s) aus der Datenbank gelöscht. Ausgewählte Bücher werden nicht von Ihrem Gerät gelöscht.</string>
<string name="delete_history_description">Dadurch wird der gesamte Verlauf aus der Datenbank gelöscht. Nach dem Löschen des gesamten Verlaufs wird die Reihenfolge der Bücher zurückgesetzt.</string>
<string name="error_permission">Wir benötigen eine Speicherberechtigung, um Ihr Gerät nach Büchern zu durchsuchen</string>
<string name="error_something_went_wrong">Etwas ist schief gelaufen.</string>
<string name="error_no_translator">Keine Übersetzungsapp gefunden.</string>
<string name="error_no_browser">Keine Browserapp gefunden.</string>
@ -214,13 +205,11 @@
<string name="start_welcome_desc">In eine Welt, in der Worte verzaubern, lehren und zu unglaublichen Abenteuern führen! Lassen Sie uns ein paar Dinge vorbereiten!</string>
<string name="start_language_preferences">Spracheinstellungen</string>
<string name="start_theme_preferences">Themeneinstellungen</string>
<string name="start_permissions_preferences">Berechtigungen</string>
<string name="delete_history_element_content_desc">Lösche Verlaufselement</string>
<string name="library_content_desc">Deine Bibliothek</string>
<string name="history_content_desc">Leseverlauf</string>
<string name="browse_content_desc">Bücher hinzufügen</string>
<string name="arrow_content_desc">Pfeil</string>
<string name="required_content_desc">Vorausgesetzt</string>
<string name="reset_start_content_desc">Startanleitung zurücksetzen</string>
<string name="open_in_web_content_desc">Im Internet öffnen</string>
<string name="delete_color_preset_content_desc">Farbvoreinstellung löschen</string>

View file

@ -13,7 +13,6 @@
<string name="settings_screen">Ajustes</string>
<string name="about_screen">Acerca de la aplicación</string>
<string name="help_screen">Ayuda</string>
<string name="storage_permission">¿Conceder permiso?</string>
<string name="add_books">¿Añadir libros?</string>
<string name="move_books">¿Mover libros?</string>
<string name="delete_books">¿Borrar libros?</string>
@ -23,15 +22,12 @@
<string name="move_books_description">Elija la categoría a la que desea mover todos los libros seleccionados (%1$s). Todos los libros seleccionados serán trasladados a la categoría seleccionada.</string>
<string name="move_book_description">Elija la categoría a la que quiere mover este libro. Este libro será trasladado a la categoría seleccionada.</string>
<string name="delete_history_description">Eliminará todo el historial de la base de datos. Después de borrar todo el historial, el orden de los libros se restablecerá.</string>
<string name="error_permission">Necesitamos permiso de almacenamiento para escanear su dispositivo en busca de libros</string>
<string name="error_no_description">Sin descripción.</string>
<string name="error_something_went_wrong">Algo salió mal.</string>
<string name="error_no_browser">No se encontró ninguna aplicación de navegador.</string>
<string name="error_closed_source">Fuente cerrada</string>
<string name="selected_items_count_query">%1$s seleccionado</string>
<string name="cancel">Cancelar</string>
<string name="grant">Conceder</string>
<string name="get_help">Necesito ayuda</string>
<string name="add_book">Añadir un libro</string>
<string name="move">Mover</string>
<string name="change_cover">Cambiar portada</string>
@ -75,11 +71,9 @@
<string name="help_desc_how_to_customize_app_3">Puedes hacerlo desde las pantallas de Biblioteca, Historial y Explorar haciendo clic en tres puntos en la esquina superior derecha y seleccionando «Ajustes» desde el menú desplegable. A continuación, verá todos los ajustes disponibles para esta aplicación. ¡No dudes en experimentar con ellos!</string>
<string name="start_language_preferences">Configuración del idioma</string>
<string name="start_theme_preferences">Preferencias de tema</string>
<string name="start_permissions_preferences">Permisos</string>
<string name="start_welcome">¡Bienvenid@ a Book\'s Story!</string>
<string name="drag_content_desc">Arrastrar</string>
<string name="delete">Borrar</string>
<string name="storage_permission_description">Necesitamos permiso de almacenamiento para escanear su dispositivo en busca de libros. Sin este permino, no podrá añadir libros.</string>
<string name="delete_books_description">Eliminará todos los libros seleccionados(%1$s) de la base de datos. No eliminará los libros seleccionados de su dispositivo.</string>
<string name="add_books_description">Puede escoger qué libros desea añadir. Puede editar el título y la portada del libro más adelante. El proceso de carga puede durar varios minutos.</string>
<string name="delete_book_description">Eliminará este libro de la base de datos. No eliminará este libro de su dispositivo.</string>
@ -90,7 +84,6 @@
<string name="library_empty">Lea sus libros favoritos y ordénelos en categorías</string>
<string name="history_empty">Deje que su último libro leído abra las páginas de una nueva gran historia en su viaje de lectura</string>
<string name="translate">Traducir</string>
<string name="grant_permission">Conceder permiso</string>
<string name="never">Nunca</string>
<string name="copy">Copiar</string>
<string name="done">Hecho</string>
@ -179,11 +172,8 @@
<string name="help_desc_how_to_read_book_2">Biblioteca</string>
<string name="help_title_how_to_use_double_click_translation">¿Cómo utilizo la traducción de doble clic?</string>
<string name="start_welcome_desc">¡Hacia un mundo donde las palabras cautivan, enseñan y conducen a increíbles aventuras! ¡Vamos a preparar algunas cosas!</string>
<string name="start_permissions_granted">Concedido</string>
<string name="start_done">¡Todo está listo!</string>
<string name="start_permissions_storage">Permiso de almacenamiento</string>
<string name="help_desc_how_to_use_double_click_translation_1">En primer lugar, vaya a los ajustes del lector (ver: ¿Cómo personalizo el lector?), luego encienda el ajuste correspondiente. Después de eso, comienza a leer el libro (ver: ¿Cómo leo un libro?). Ahora si hace doble clic en el párrafo deseado, el traductor externo se abrirá con el texto del párrafo.</string>
<string name="start_permissions_storage_desc">Necesario para escanear tu dispositivo y encontrar los libros</string>
<string name="help_desc_how_to_customize_reader_2">Ajustes</string>
<string name="help_desc_how_to_manage_history_1">Ir al</string>
<string name="help_desc_how_to_manage_history_2">Historial</string>
@ -212,7 +202,6 @@
<string name="delete_history_element_content_desc">Eliminar elemento del historial</string>
<string name="library_content_desc">Tu biblioteca</string>
<string name="arrow_content_desc">Flecha</string>
<string name="required_content_desc">Necesario</string>
<string name="open_in_web_content_desc">Abrir en la web</string>
<string name="shuffle_color_preset_content_desc">Colores aleatorios</string>
<string name="open_reader_settings_content_desc">Ajustes del lector</string>

View file

@ -13,7 +13,6 @@
<string name="help_screen">Aide</string>
<string name="delete_books">Supprimer des livres ?</string>
<string name="move_books">Déplacer des livres ?</string>
<string name="storage_permission">Autoriser ?</string>
<string name="delete_book">Supprimer le livre ?</string>
<string name="move_book">Déplacer le livre ?</string>
<string name="delete_history">Effacer l\'historique de lecture ?</string>
@ -21,7 +20,6 @@
<string name="delete_books_description">Cela supprimera tous les livres sélectionnés (%1$s) de la base de données. Ceci ne supprimera pas les livres sélectionnés de votre appareil.</string>
<string name="delete_book_description">Cela supprimera ce livre de la base de données. Ceci ne le supprimera pas de votre appareil.</string>
<string name="move_book_description">Choisissez la catégorie dans laquelle vous souhaitez déplacer ce livre. Ce dernier sera déplacé vers la catégorie choisie.</string>
<string name="error_permission">Nous avons besoin de l\'autorisation de stockage pour scanner votre appareil à la recherche de livres</string>
<string name="error_no_translator">Aucune application de traduction n\'a été trouvée.</string>
<string name="error_no_dictionary">Aucune application de dictionnaire n\'a été trouvée.</string>
<string name="error_no_share_app">Aucune application pour partager n\'a été trouvée.</string>
@ -178,10 +176,6 @@
<string name="start_welcome_desc">Pour un monde où les mots charment, enseignent et mènent à des aventures incroyables! Mettons les choses au point!</string>
<string name="start_language_preferences">Préférences de langue</string>
<string name="start_theme_preferences">Préférences de thèmes</string>
<string name="start_permissions_preferences">Permissions</string>
<string name="start_permissions_granted">Autorisé</string>
<string name="start_permissions_storage">Permission de Stockage</string>
<string name="start_permissions_storage_desc">Nécessaire pour scanner votre appareil et trouver des livres</string>
<string name="start_done">Tout est prêt!</string>
<string name="yesterday">Hier</string>
<string name="continue_reading_content_desc">Poursuivre la lecture</string>
@ -206,7 +200,6 @@
<string name="library_content_desc">Votre librairie</string>
<string name="browse_content_desc">Ajouter les livres</string>
<string name="arrow_content_desc">Flèche</string>
<string name="required_content_desc">Requis</string>
<string name="reset_start_content_desc">Guide de réinitialisation</string>
<string name="open_in_web_content_desc">Ouvrir en ligne</string>
<string name="delete_color_preset_content_desc">Supprimer le préréglage de couleur</string>
@ -214,7 +207,6 @@
<string name="tryzub_content_desc">Tryzub</string>
<string name="github_profile_content_desc">Profile Github</string>
<string name="drag_content_desc">Glisser</string>
<string name="storage_permission_description">Nous avons besoin de l\'autorisation de stockage pour scanner votre appareil et trouver des livres. Sans cette autorisation, vous ne pourrez pas ajouter de livre.</string>
<string name="move_books_description">Choisissez la catégorie dans laquelle vous souhaitez déplacer tous les livres sélectionnés (%1$s). Tous les livres sélectionnés seront déplacés vers la catégorie choisie.</string>
<string name="add_books">Ajouter des livres ?</string>
<string name="error_no_description">Pas de description.</string>
@ -224,9 +216,6 @@
<string name="error_could_not_get_text">Le contenu du livre est vide. Veuillez vous assurer que le contenu du livre n\'est pas vide et de le mettre à jour.</string>
<string name="history_empty">Laissez le dernier livre que vous avez lu ouvrir les pages d\'une grande et nouvelle histoire dans votre aventure de lecture</string>
<string name="cancel">Annuler</string>
<string name="grant_permission">Accorder la permission</string>
<string name="grant">Autoriser</string>
<string name="get_help">J\'ai besoin d\'aide</string>
<string name="add_book">Ajouter un livre</string>
<string name="color_tab">Couleurs</string>
<string name="general_settings">Paramètres généraux</string>

View file

@ -11,7 +11,6 @@
<string name="about_screen">ऐप के बारे में जानकारी</string>
<string name="default_string">डिफ़ॉल्ट</string>
<string name="help_screen">सहायता</string>
<string name="storage_permission">अनुमति दें?</string>
<string name="add_books">किताबें शामिल करें ?</string>
<string name="move_books">किताबें स्थानांतरित करें?</string>
<string name="delete_book">किताब हटाएं?</string>
@ -25,9 +24,6 @@
<string name="selected_items_count_query">%1$s चुना हुआ है</string>
<string name="color_preset_selected_query">«%1$s» चुना गया है</string>
<string name="cancel">रद्द करें</string>
<string name="grant_permission">अनुमति दें</string>
<string name="grant">अनुदान</string>
<string name="get_help">मुझे सहायता की आवश्यकता है</string>
<string name="add_book">एक किताब जोड़ें</string>
<string name="never">कभी नहीं</string>
<string name="undo">पूर्ववत् करें</string>
@ -81,12 +77,10 @@
<string name="system_reader_settings">सिस्टम</string>
<string name="filter_browse_settings">फ़िल्टर</string>
<string name="add_books_description">आप चुन सकते हैं कि आप कौन सी किताबें जोड़ना चाहते हैं। आप बाद में किताब का शीर्षक और कवर छवि संपादित कर सकते हैं। लोडिंग प्रक्रिया में कई मिनट लग सकते हैं।</string>
<string name="storage_permission_description">हमें आपकी डिवाइस को पुस्तकों के लिए स्कैन करने के लिए संग्रहण अनुमति की आवश्यकता है। इस अनुमति के बिना, आप कोई पुस्तक नहीं डाल सकेंगे ।</string>
<string name="delete_books_description">यह डेटाबेस से सभी चयनित पुस्तकों(%1$s) को हटा देगा। यह आपके डिवाइस से चयनित पुस्तकों को नहीं हटाएगा।</string>
<string name="delete_book_description">यह इस पुस्तक को डेटाबेस से हटा देगा. यह आपकी डिवाइस से इस पुस्तक को नहीं हटाएगा।</string>
<string name="move_book_description">वह श्रेणी चुनें जिसमें आप इस पुस्तक को ले जाना चाहते हैं। यह पुस्तक चयनित श्रेणी में ले जाई जाएगी।</string>
<string name="delete_history_description">यह डेटाबेस से पूरा इतिहास मिटा देगा। पूरा इतिहास मिटाने के बाद, किताबों का क्रम फिर से सेट हो जाएगा।</string>
<string name="error_permission">हमें किताबों के लिए आपके डिवाइस को स्कैन करने के लिए भंडारण अनुमति की आवश्यकता है</string>
<string name="error_no_description">कोई वर्णन नहीं।</string>
<string name="error_no_dictionary">कोई शब्दकोश ऐप नहीं मिला ।</string>
<string name="error_no_browser">कोई ब्राउज़र ऐप नहीं मिला ।</string>

View file

@ -13,7 +13,6 @@
<string name="move">Sposta</string>
<string name="undo">Annulla</string>
<string name="translate">Traduci</string>
<string name="grant_permission">Concedi permesso</string>
<string name="browse_settings">Esplora</string>
<string name="delete_cover">Elimina immagine di copertina</string>
<string name="text_reader_settings">Testo</string>
@ -52,18 +51,14 @@
<string name="theme_appearance_settings">Preferenze tema</string>
<string name="go_back_content_desc">Indietro</string>
<string name="paragraph_height_option">Spaziatura dei paragrafi</string>
<string name="storage_permission">Concedi permesso?</string>
<string name="font_style_italic">Corsivo</string>
<string name="error_no_translator">Nessuna app traduttore trovata.</string>
<string name="start_permissions_preferences">Permessi</string>
<string name="history_content_desc">Cronologia lettura</string>
<string name="required_content_desc">Richiesto</string>
<string name="app_version_option">Versione app</string>
<string name="books_moved">Tutti i libri selezionati sono stati spostati correttamente.</string>
<string name="error_no_browser">Nessuna app browser trovata.</string>
<string name="delete_history">Cancellare la cronologia di lettura?</string>
<string name="error_no_dictionary">Nessuna app dizionario trovata.</string>
<string name="storage_permission_description">Abbiamo bisogno del permesso di archiviazione per la scansione del Tuo Dispositivo per i libri. Senza questo permesso, non sarai in grado di aggiungere i libri.</string>
<string name="done">Fatto</string>
<string name="share">Condividi</string>
<string name="yes_go_to_help">Sì, vai ad Aiuto</string>
@ -98,7 +93,6 @@
<string name="delete_books_description">Eliminerà tutti i libri selezionati (%1$s) dal database. Non eliminerà i libri selezionati dal tuo dispositivo.</string>
<string name="error_no_description">Nessuna descrizione.</string>
<string name="error_something_went_wrong">Qualcosa è andato storto.</string>
<string name="error_permission">Abbiamo bisogno del permesso di archiviazione per cercare i libri nel tuo dispositivo</string>
<string name="library_empty">Leggi i tuoi libri preferiti e ordinali per categoria</string>
<string name="sort_tab">Ordina</string>
<string name="color_tab">Colori</string>
@ -124,7 +118,6 @@
<string name="delete_books">Eliminare libri?</string>
<string name="copy">Copia</string>
<string name="general_tab">Generale</string>
<string name="grant">Concedi</string>
<string name="help_translate_option">Aiuta a tradurre</string>
<string name="yellow_theme">Venere</string>
<string name="dictionary">Dizionario</string>
@ -132,7 +125,6 @@
<string name="theme_contrast_option">Contrasto tema</string>
<string name="search_content_desc">Cerca</string>
<string name="green_theme">Terra</string>
<string name="get_help">Ho bisogno di aiuto</string>
<string name="theme_contrast_high">Alto</string>
<string name="author">Autore</string>
<string name="browse_layout_list">Lista</string>
@ -287,9 +279,6 @@
<string name="filter_content_desc">Filtro</string>
<string name="help_desc_how_to_customize_reader_3">e seleziona la categoria Lettore per personalizzare le impostazioni generali oppure la categoria Aspetto per personalizzare le impostazioni Colori.</string>
<string name="help_title_how_to_use_double_click_translation">Come si usa la traduzione con doppio clic?</string>
<string name="start_permissions_granted">Concesso</string>
<string name="start_permissions_storage">Autorizzazione di archiviazione</string>
<string name="start_permissions_storage_desc">Necessario per scansionare il tuo dispositivo e trovare i libri</string>
<string name="start_done_desc">Hai completato con successo le impostazioni di base dell\'app. Vuoi imparare a usare l\'app leggendo la schermata di Aiuto?</string>
<string name="add_files_content_desc">Aggiungi file</string>
<string name="library_content_desc">La tua libreria</string>

View file

@ -33,7 +33,6 @@
<string name="library_screen">Biblioteka</string>
<string name="browse_screen">Przeglądaj</string>
<string name="cancel">Anuluj</string>
<string name="grant">Udziel</string>
<string name="delete">Usuń</string>
<string name="sort_browse_settings">Sortuj</string>
<string name="red_color">Czerwony</string>
@ -58,7 +57,6 @@
<string name="planning_tab">Planowanie</string>
<string name="fast_color_preset_change_option">Szybka zmiana ustawienia koloru</string>
<string name="about_screen">O aplikacji</string>
<string name="storage_permission">Przyznać dostęp?</string>
<string name="add_books">Dodać książki?</string>
<string name="delete_books">Usunąć książki?</string>
<string name="delete_book">Usunąć książkę?</string>
@ -67,7 +65,6 @@
<string name="move_books_description">Wybierz kategorię, do której chcesz przenieść wszystkie wybrane książki (%1$s). Wszystkie wybrane książki zostaną przeniesione do wybranej kategorii.</string>
<string name="delete_book_description">To spowoduje usunięcie tej książki z bazy danych, ale nie usunie jej z twojego urządzenia.</string>
<string name="delete_history_description">To spowoduje usuniecie całej historii z bazy danych. Po usunięciu całej historii kolejność książek zostanie zresetowana.</string>
<string name="error_permission">Potrzebujemy uprawnień do przechowywania, aby przeskanować Twoje urządzenie w poszukiwaniu książek</string>
<string name="error_no_description">Brak opisu.</string>
<string name="error_something_went_wrong">Coś poszło nie tak.</string>
<string name="error_no_dictionary">Nie znaleziono aplikacji słownikowej.</string>
@ -129,15 +126,12 @@
<string name="dropped_tab">Porzucone</string>
<string name="browse_layout_option">Tryb wyświetlania</string>
<string name="add_books_description">Możesz wybrać książki, które chcesz dodać. Możesz później edytować tytuł i obraz okładki książki. Proces ładowania może potrwać do kilku minut.</string>
<string name="storage_permission_description">Potrzebujemy uprawnień do przechowywania, aby przeskanować Twoje urządzenie w poszukiwaniu książek. Bez tego pozwolenia nie będziesz mógł dodać książki.</string>
<string name="error_no_browser">Nie znaleziono aplikacji przeglądarki.</string>
<string name="purple_theme">Jowisz</string>
<string name="pink_theme">Saturn</string>
<string name="yellow_theme">Wenus</string>
<string name="aqua_theme">Uran</string>
<string name="reader_tab">Czytelnik</string>
<string name="grant_permission">Udziel zgody</string>
<string name="get_help">Potrzebuję pomocy</string>
<string name="add_book">Dodaj książkę</string>
<string name="translate">Przetłumacz</string>
<string name="appearance_settings_desc">Motyw, kolory</string>
@ -182,7 +176,6 @@
<string name="credits_contribution">Wnieśli wkład</string>
<string name="yesterday">Wczoraj</string>
<string name="selected_content_desc">Wybrane</string>
<string name="start_permissions_storage_desc">Wymagane do przeskanowania urządzenia i znalezienia książek</string>
<string name="reset_start_content_desc">Zresetuj przewodnik startu</string>
<string name="theme_contrast_high">Wysoki</string>
<string name="cover_image_changed">Obraz okładki został pomyślnie zmieniony.</string>
@ -209,7 +202,6 @@
<string name="help_desc_how_to_use_double_click_translation_1">Najpierw przejdź do ustawień czytnika (patrz: Jak mogę dostosować czytnik?), A następnie włącz odpowiednie ustawienie. Następnie zacznij czytać książkę (patrz: Jak mogę przeczytać książkę?). Teraz, jeśli klikniesz dwukrotnie żądany akapit, zewnętrzny tłumacz zostanie otwarty z tekstem akapitu.</string>
<string name="help_title_how_to_create_color_presets">Jak mogę tworzyć i modyfikować ustawienia kolorów?</string>
<string name="start_language_preferences">Preferencje języka</string>
<string name="start_permissions_storage">Uprawnienie do przechowywania</string>
<string name="today">Dzisiaj</string>
<string name="go_back_content_desc">Wróć</string>
<string name="cover_image_content_desc">Obraz okładki</string>
@ -219,7 +211,6 @@
<string name="delete_history_element_content_desc">Usuń element historii</string>
<string name="library_content_desc">Twoja biblioteka</string>
<string name="arrow_content_desc">Strzałka</string>
<string name="required_content_desc">Wymagane</string>
<string name="open_in_web_content_desc">Otwórz w sieci</string>
<string name="drag_content_desc">Przeciągnij</string>
<string name="help_desc_how_to_move_or_delete_books_3">Przytrzymaj obraz okładki pierwszej książki, aż zostanie wybrany, teraz możesz po prostu kliknąć wszystkie obrazy okładki książek, które chcesz wybrać. Możesz także wybrać książki w wielu kategoriach. Po wybraniu wszystkich żądanych książek możesz kliknąć ikonę strzałki, aby przenieść oraz ikonę kosza, aby usunąć książki.</string>
@ -227,8 +218,6 @@
<string name="help_desc_how_to_create_color_presets_1">Przejdź do ustawień wyglądu (patrz: Jak mogę dostosować czytnik?). Możesz zmodyfikować wszystkie ustawienia koloru w polu poniżej « Ustawienie koloru » w podkategorii « Kolory ». Aby utworzyć nowe ustawienie kolorów, kliknij przycisk ikony « Dodaj ». Istnieje wiele sposobów modyfikowania ustawienia koloru: klikając pole tekstowe, możesz zmienić nazwę ustawienia koloru, przesuwając suwaki, możesz zmienić kolory ustawionego koloru, klikając przycisk ikony « Losowo pomieszaj », możesz wymieszać wszystkie kolory. Jeśli chcesz usunąć ustawienie koloru, kliknij przycisk ikony « Usuń ».</string>
<string name="help_desc_how_to_use_color_presets_1">Przejdź do ustawień wyglądu (patrz: Jak mogę dostosować czytnik?). Możesz wybrać, który kolor ma być używany w ustawieniu « Ustawienia kolorów ». Kliknij żądane ustawienie koloru, aby go wybrać, spowoduje to również zmianę kolorów w programie Reader. Jeśli chcesz zmienić kolejność ustawienia koloru, wybierz go i przytrzymaj przycisk ikony « Uchwyt », a następnie przesuń go do żądanej pozycji. Możesz szybko zmienić ustawienia kolorów, włączając odpowiednie ustawienie w podkategorii « Kolory » ustawienia wyglądu, a następnie przesuwając palcem w lewo lub w prawo w programie Reader, aby szybko zmienić ustawienie kolorów.</string>
<string name="start_theme_preferences">Ustawienia wyglądu</string>
<string name="start_permissions_preferences">Uprawnienia</string>
<string name="start_permissions_granted">Przyznane</string>
<string name="start_done_desc">Pomyślnie wykonałeś podstawowe ustawienie aplikacji. Czy chcesz dowiedzieć się, jak korzystać z aplikacji, czytając Pomoc?</string>
<string name="file_icon_content_desc">Plik</string>
<string name="select_all_files_content_desc">Wybierz wszystkie pliki</string>

View file

@ -24,7 +24,6 @@
<string name="history_screen">Histórico</string>
<string name="browse_screen">Explorar</string>
<string name="fullscreen_option">Tela cheia</string>
<string name="grant">Permitir</string>
<string name="done">Feito</string>
<string name="dark_theme_off">Desabilitado</string>
<string name="delete_books">Apagar livros?</string>
@ -56,7 +55,6 @@
<string name="misc_reader_settings">Miscelânea</string>
<string name="pure_dark_off">Desabilitado</string>
<string name="add_books">Adicionar livros?</string>
<string name="storage_permission_description">Precisamos de permissão de acesso ao armazenamento para procurar livros no seu dispositivo. Sem essa permissão, você não será capaz de adicionar um livro.</string>
<string name="delete">Apagar</string>
<string name="undo">Desfazer</string>
<string name="sort_tab">Organizar</string>
@ -69,7 +67,6 @@
<string name="dark_theme_follow_system">Sistema</string>
<string name="theme_contrast_standard">Padrão</string>
<string name="font_style_normal">Normal</string>
<string name="storage_permission">Dar permissão?</string>
<string name="delete_history">Apagar histórico de leitura?</string>
<string name="author">Autor</string>
<string name="blue_color">Azul</string>
@ -106,16 +103,13 @@
<string name="file_icon_content_desc">Arquivo</string>
<string name="error_content_desc">Erro</string>
<string name="search_content_desc">Pesquisar</string>
<string name="start_permissions_preferences">Permissões</string>
<string name="copied">Copiado</string>
<string name="required_content_desc">Requerido</string>
<string name="alignment_justify">Justificar</string>
<string name="move_books_description">Escolha a categoria da qual você quer mover todos os livros selecionados(%1$s). Todos os livros selecionados serão movidos para essa categoria.</string>
<string name="delete_books_description">Isto irá apagar todos os livros selecionados(%1$s) da base de dados. Isso não irá apagar os livros do seu dispositivo.</string>
<string name="alignment_center">Centro</string>
<string name="help_desc_how_to_customize_reader_2">Configurações</string>
<string name="revert_content_desc">Reverter</string>
<string name="start_permissions_granted">Permitir</string>
<string name="help_desc_how_to_customize_app_2">Configurações</string>
<string name="browse_sort_order_name">Alfabeticamente</string>
<string name="selected_content_desc">Selecionado</string>
@ -160,7 +154,6 @@
<string name="browse_grid_size_per_row">por linha</string>
<string name="already_read_tab">Lido</string>
<string name="browse_grid_size_option">Tamanho da grade</string>
<string name="grant_permission">Dar permissão</string>
<string name="error_no_description">Sem descrição.</string>
<string name="selected_items_count_query">%1$s selecionado</string>
<string name="error_closed_source">Código fechado</string>
@ -183,7 +176,6 @@
<string name="continue_reading_content_desc">Continuar a ler</string>
<string name="go_back_content_desc">Voltar</string>
<string name="start_language_preferences">Prefêrencias linguísticas</string>
<string name="start_permissions_storage">Permissão de armazenamento</string>
<string name="whats_new_option">O que há de novo</string>
<string name="help_translate_option">Ajude a traduzir</string>
<string name="start_done">Está tudo feito!</string>
@ -200,7 +192,6 @@
<string name="github_profile_content_desc">Perfil do Github</string>
<string name="screen_orientation_option">Orientação da tela</string>
<string name="error_something_went_wrong">Algo deu errado.</string>
<string name="get_help">Preciso d eajuda</string>
<string name="change_cover">Trocar imagem da capa</string>
<string name="reader_settings_desc">Fonte, texto, tradutor</string>
<string name="browse_settings_desc">Geral, filtro, ordem</string>
@ -223,7 +214,6 @@
<string name="horizontal_gesture_sensitivity_option">Sensibilidade</string>
<string name="horizontal_gesture_off">Desativar</string>
<string name="app_version_option_desc_1">Book\'s Story v%1$s</string>
<string name="error_permission">Precisamos de permissão de armazenamento para escanear seu dispositivo em busca de livros</string>
<string name="error_no_browser">Nenhum aplicativo de navegador encontrado.</string>
<string name="error_no_share_app">Nenhum aplicativo para compartilhar encontrado.</string>
<string name="error_no_dictionary">Nenhum aplicativo de dicionário encontrado.</string>
@ -306,7 +296,6 @@
<string name="purple_gray_theme">Ceres</string>
<string name="book_deleted">Esse livro foi apagado com sucesso.</string>
<string name="help_desc_how_to_read_book_1">Ir para</string>
<string name="start_permissions_storage_desc">Necessário para escanear seu dispositivo e encontrar livros</string>
<string name="help_desc_how_to_add_books_1">Ir para</string>
<string name="reset_start_content_desc">Redefinir guia de introdução</string>
<string name="progress_bar_font_size_option">Tamanho da fonte</string>

View file

@ -19,9 +19,6 @@
<string name="color_preset_query">முன்னமைக்கப்பட்ட %1$s</string>
<string name="color_preset_selected_query">தேர்ந்தெடுக்கப்பட்ட «%1$s»</string>
<string name="cancel">ரத்துசெய்</string>
<string name="grant">மானியம்</string>
<string name="grant_permission">இசைவு வழங்கவும்</string>
<string name="get_help">எனக்கு உதவி தேவை</string>
<string name="delete">நீக்கு</string>
<string name="next">அடுத்தது</string>
<string name="yes_go_to_help">ஆம், உதவிக்குச் செல்லுங்கள்</string>
@ -83,7 +80,6 @@
<string name="screen_orientation_locked_landscape">பூட்டப்பட்ட நிலப்பரப்பு</string>
<string name="unknown">தெரியவில்லை</string>
<string name="help_desc_how_to_read_book_1">செல்லுங்கள்</string>
<string name="start_permissions_storage">சேமிப்பக இசைவு</string>
<string name="app_version_option">பயன்பாட்டு பதிப்பு</string>
<string name="licenses_option">திறந்த மூல உரிமங்கள்</string>
<string name="help_desc_how_to_manage_history_2">வரலாறு</string>
@ -97,13 +93,11 @@
<string name="credits_fonts">எழுத்துருக்கள்</string>
<string name="history_empty">நீங்கள் படித்த உங்கள் கடைசி நூல் உங்கள் வாசிப்பு பயணத்தின் புதிய சிறந்த கதையின் பக்கங்களைத் திறக்கட்டும்</string>
<string name="share">பங்கு</string>
<string name="start_permissions_preferences">அனுமதிகள்</string>
<string name="never">ஒருபோதும்</string>
<string name="browse_layout_option">காட்சி முறை</string>
<string name="browse_sort_order_file_format">கோப்பு வடிவம்</string>
<string name="default_string">இயல்புநிலை</string>
<string name="delete_history">வாசிப்பு வரலாற்றை நீக்கவா?</string>
<string name="error_permission">புத்தகங்களுக்காக உங்கள் சாதனத்தை ச்கேன் செய்ய எங்களுக்கு சேமிப்பக இசைவு தேவை</string>
<string name="error_no_description">விளக்கம் இல்லை.</string>
<string name="selected_items_count_query">%1$s தேர்ந்தெடுக்கப்பட்டன</string>
<string name="undo">செயல்தவிர்</string>
@ -161,7 +155,6 @@
<string name="today">இன்று</string>
<string name="error_content_desc">பிழை</string>
<string name="browse_content_desc">புத்தகங்களைச் சேர்க்கவும்</string>
<string name="required_content_desc">தேவை</string>
<string name="open_in_web_content_desc">வலையில் திறந்திருக்கும்</string>
<string name="delete_color_preset_content_desc">வண்ண முன்னமைவை நீக்கு</string>
<string name="create_color_preset_content_desc">புதிய வண்ண முன்னமைவை உருவாக்கவும்</string>
@ -179,12 +172,10 @@
<string name="settings_screen">அமைப்புகள்</string>
<string name="about_screen">பயன்பாடு பற்றி</string>
<string name="help_screen">உதவி</string>
<string name="storage_permission">இசைவு வழங்கவா?</string>
<string name="move_books">புத்தகங்களை நகர்த்தவா?</string>
<string name="delete_books">புத்தகங்களை நீக்கவா?</string>
<string name="delete_book">புத்தகத்தை நீக்கவா?</string>
<string name="move_book">புத்தகத்தை நகர்த்தவா?</string>
<string name="storage_permission_description">புத்தகங்களுக்காக உங்கள் சாதனத்தை ச்கேன் செய்ய எங்களுக்கு சேமிப்பக இசைவு தேவை. இந்த அனுமதியின்றி, நீங்கள் ஒரு புத்தகத்தைச் சேர்க்க முடியாது.</string>
<string name="add_books_description">நீங்கள் எந்த புத்தகங்களைச் சேர்க்க விரும்புகிறீர்கள் என்பதை நீங்கள் தேர்வு செய்யலாம். புத்தகத்தின் தலைப்பு மற்றும் படத்தை பின்னர் திருத்தலாம். ஏற்றுதல் செயல்முறை பல நிமிடங்கள் வரை ஆகலாம்.</string>
<string name="move_books_description">தேர்ந்தெடுக்கப்பட்ட அனைத்து புத்தகங்களையும் (%1$s) நகர்த்த விரும்பும் வகையைத் தேர்வுசெய்க. தேர்ந்தெடுக்கப்பட்ட அனைத்து புத்தகங்களும் தேர்ந்தெடுக்கப்பட்ட வகைக்கு மாற்றப்படும்.</string>
<string name="delete_books_description">இது தரவுத்தளத்திலிருந்து தேர்ந்தெடுக்கப்பட்ட அனைத்து புத்தகங்களையும் (%1$s) நீக்கிவிடும். இது உங்கள் சாதனத்திலிருந்து தேர்ந்தெடுக்கப்பட்ட புத்தகங்களை நீக்காது.</string>
@ -290,8 +281,6 @@
<string name="help_desc_how_to_create_color_presets_1">தோற்ற அமைப்புகளுக்குச் செல்லுங்கள் (பார்க்க: வாசகரை எவ்வாறு தனிப்பயனாக்குவது?). «வண்ண முன்னமைவு» கீழ் «வண்ணங்கள்» துணைப்பிரிவு ஆகியவற்றின் கீழ் பெட்டியில் உள்ள அனைத்து வண்ண முன்னமைவின் அமைப்புகளையும் நீங்கள் மாற்றலாம். புதிய வண்ண முன்னமைவை உருவாக்க, «சேர்» ஐகான்-பொன்ட்டனைக் சொடுக்கு செய்க. ஒரு வண்ண முன்னமைவை மாற்ற பல வழிகள் உள்ளன: உரை புலத்தைக் சொடுக்கு செய்வதன் மூலம் நீங்கள் வண்ண முன்னமைவின் பெயரை மாற்றலாம், ச்லைடர்களை நகர்த்துவதன் மூலம் வண்ண முன்னமைவின் வண்ணங்களை மாற்றலாம், «சஃபிள்» ஐகான்-பொத்தானைக் சொடுக்கு செய்வதன் மூலம் நீங்கள் அனைத்தையும் கலக்கலாம் நிறங்கள். நீங்கள் வண்ண முன்னமைவை நீக்க விரும்பினால், «நீக்கு» ஐகான்-பொத்தானைக் சொடுக்கு செய்க.</string>
<string name="help_desc_how_to_use_perception_expander_1">புலனுணர்வு விரிவாக்கம் உங்களுக்கு விரைவாக படிக்க உதவக்கூடும். முதலில், அதை வாசகர் அமைப்புகளில் இயக்கவும் (பார்க்க: வாசகரை எவ்வாறு தனிப்பயனாக்குவது?). இயக்கப்பட்டதும், வாசகரின் இருபுறமும் இரண்டு செங்குத்து கோடுகள் தோன்றும். உங்கள் வாசிப்பு வேகத்தை மேலும் மேம்படுத்த, மையத்தைச் சுற்றி குறைந்த இடத்தை உருவாக்க திணிப்பை சரிசெய்யவும். இதைப் பயன்படுத்த, உங்கள் புற பார்வை பக்கங்களில் உரையை எடுக்க அனுமதிக்கும் போது திரையின் மையத்தில் கவனம் செலுத்துங்கள். நீங்கள் படிக்கும்போது, ஒவ்வொரு வரியுக்கும் பிறகு பக்கங்களை நேரடியாகப் பார்க்காமல் மையத்தில் உங்கள் கவனத்தை பராமரிக்கவும்.</string>
<string name="start_theme_preferences">கருப்பொருள் விருப்பத்தேர்வுகள்</string>
<string name="start_permissions_granted">வழங்கப்பட்டது</string>
<string name="start_permissions_storage_desc">உங்கள் சாதனத்தை ச்கேன் செய்து புத்தகங்களைக் கண்டுபிடிக்க வேண்டும்</string>
<string name="start_done_desc">பயன்பாட்டின் அடிப்படை அமைப்பை நீங்கள் வெற்றிகரமாக முடித்துவிட்டீர்கள். உதவித் திரையைப் படிப்பதன் மூலம் பயன்பாட்டை எவ்வாறு பயன்படுத்துவது என்பதை அறிய விரும்புகிறீர்களா?</string>
<string name="yesterday">நேற்று</string>
<string name="continue_reading_content_desc">தொடர்ந்து படிக்கவும்</string>

View file

@ -17,7 +17,6 @@
<string name="help_screen">Yardım</string>
<!-- Dialog Labels -->
<string name="storage_permission">İzin verilsin mi?</string>
<string name="add_books">Kitaplar eklensin mi?</string>
<string name="move_books">Kitaplar taşınsın mı?</string>
<string name="delete_books">Kitaplar silinsin mi?</string>
@ -27,10 +26,6 @@
<string name="edit">Düzenle</string>
<!-- Dialog Descriptions -->
<string name="storage_permission_description">
Cihazınızı kitaplar için tarayabilmemiz adına depolama iznine ihtiyacımız var.
Bu izin olmadan kitap ekleyemezsiniz.
</string>
<string name="add_books_description">
Eklemek istediğiniz kitapları seçebilirsiniz.
Kitabın başlığını ve kapak resmini daha sonra düzenleyebilirsiniz.
@ -57,9 +52,6 @@
Geçmişi sildikten sonra kitapların sıralaması sıfırlanacaktır.
</string>
<!-- Errors -->
<string name="error_permission">
Cihazınızı kitaplar için tarayabilmemiz adına depolama iznine ihtiyacımız var
</string>
<string name="error_no_description">
ıklama yok.
</string>
@ -99,9 +91,6 @@
<!-- Action Texts -->
<string name="cancel">İptal</string>
<string name="grant">Ver</string>
<string name="grant_permission">İzin ver</string>
<string name="get_help">Yardıma ihtiyacım var</string>
<string name="add_book">Kitap ekle</string>
<string name="move">Taşı</string>
<string name="delete">Sil</string>
@ -539,10 +528,6 @@
</string>
<string name="start_language_preferences">Dil tercihleri</string>
<string name="start_theme_preferences">Tema tercihleri</string>
<string name="start_permissions_preferences">İzinler</string>
<string name="start_permissions_granted">Verildi</string>
<string name="start_permissions_storage">Depolama izni</string>
<string name="start_permissions_storage_desc">Cihazınızı tarayıp kitapları bulmak için gereklidir</string>
<string name="start_done">Her şey tamam!</string>
<string name="start_done_desc">
Uygulamanın temel ayarlarını başarıyla tamamladınız.
@ -579,7 +564,6 @@
<string name="history_content_desc">Okuma geçmişi</string>
<string name="browse_content_desc">Kitap ekle</string>
<string name="arrow_content_desc">Ok</string>
<string name="required_content_desc">Gerekli</string>
<string name="reset_start_content_desc">Başlangıç rehberini sıfırla</string>
<string name="open_in_web_content_desc">Web\'de aç</string>
<string name="delete_color_preset_content_desc">Renk ön ayarını sil</string>

View file

@ -17,7 +17,6 @@
<string name="help_screen">Довідка</string>
<!-- Dialog Labels -->
<string name="storage_permission">Надати Дозвіл?</string>
<string name="add_books">Додати книги?</string>
<string name="move_books">Перемістити книги?</string>
<string name="delete_books">Видалити книги?</string>
@ -27,15 +26,14 @@
<string name="edit">Редагувати</string>
<!-- Dialog Descriptions -->
<string name="storage_permission_description">Нам потрібен дозвіл на сховище, щоб просканувати Ваш Девайс на наявність книг. Без цього дозволу ви не зможете додати книгу.</string>
<string name="add_books_description">Ви можете вибрати книги, які хочете додати. Ви зможете відредагувати Назву та Обкладинку книги пізніше. Процес завантаження може зайняти декілька хвилин.</string>
<string name="move_books_description">Виберіть категорію, в яку ви хочете перемістити всі обрані книги(%1$s). Всі обрані книги будуть переміщені в обрану категорію.</string>
<string name="delete_books_description">Це видалить всі обрані книги(%1$s) з бази даних. Це не видалить обрані книги з вашого пристрою.</string>
<string name="delete_book_description">Це видалить цю книгу з бази даних. Це не видалить цю книгу з вашого пристрою.</string>
<string name="move_book_description">Виберіть категорію, в яку ви хочете перемістити цю книгу. Ця книга буде переміщена в обрану категорію.</string>
<string name="delete_history_description">Це видалить всю історію з бази даних. Після видалення всієї історії порядок книг буде скинутий.</string>
<!-- Errors -->
<string name="error_permission">Нам потрібен дозвіл на сховище, щоб просканувати Ваш Девайс на наявність книг</string>
<string name="error_no_description">Немає опису.</string>
<string name="error_something_went_wrong">Який жах! Щось пішло не так.</string>
<string name="error_no_translator">Не знайдено жодного перекладача.</string>
@ -43,12 +41,12 @@
<string name="error_no_browser">Не знайдено жодного браузера.</string>
<string name="error_no_share_app">Не знайдено застосунка щоб поділитися.</string>
<string name="error_could_not_get_text">
Файл порожній, пошкоджений або відсутній.
Перевірте файл або змініть шлях і спробуйте ще раз.
Файл порожній, відсутній або недоступний.
Перевірте файл, налаштування сканування або змініть шлях і спробуйте ще раз.
</string>
<string name="error_could_not_reset_cover">Не вдалося скинути обкладинку.</string>
<string name="error_closed_source">Закритий код</string>
<string name="error_no_file">Файла не існує</string>
<string name="error_no_file">Файла не існує або він недоступний</string>
<!-- Is Empty messages -->
<string name="search_field_empty">Пошук…</string>
@ -63,9 +61,6 @@
<!-- Action Texts -->
<string name="cancel">Відмінити</string>
<string name="grant">Надати</string>
<string name="grant_permission">Надати дозвіл</string>
<string name="get_help">Мені потрібна допомога</string>
<string name="add_book">Додати книгу</string>
<string name="move">Перемістити</string>
<string name="delete">Видалити</string>
@ -82,6 +77,8 @@
<string name="share">Поділитися</string>
<string name="ok">ОК</string>
<string name="change_path">Змінити шлях</string>
<string name="add_folder">Додати теку</string>
<string name="set_up_scanning">Налаштувати сканування</string>
<!-- Book -->
<string name="unknown_author">Невідомий</string>
@ -128,10 +125,18 @@
<string name="progress_reader_settings">Прогрес</string>
<string name="theme_appearance_settings">Налаштування теми</string>
<string name="colors_appearance_settings">Кольори</string>
<string name="scan_browse_settings">Сканування</string>
<string name="general_browse_settings">Головні</string>
<string name="filter_browse_settings">Фільтр</string>
<string name="sort_browse_settings">Сотрування</string>
<!-- Settings notes -->
<string name="browse_scan_option_note">
Ці теки використовуються для сканування та отримання файлів у Огляді.
Видалення теки тут не видалить фактичні файли чи книги з Бібліотеки.
Щоб відкликати доступ, натисніть відповідну кнопку.
</string>
<!-- Settings options -->
<!-- General -->
<string name="language_option">Мова застосунка</string>
@ -407,10 +412,7 @@
<string name="start_welcome_desc">У світ, де слова чарують, вчать та ведуть у неймовірні пригоди! Давайте дещо налаштуємо!</string>
<string name="start_language_preferences">Налаштування мови</string>
<string name="start_theme_preferences">Налаштування теми</string>
<string name="start_permissions_preferences">Дозволи</string>
<string name="start_permissions_granted">Надано</string>
<string name="start_permissions_storage">Дозвіл на сховище</string>
<string name="start_permissions_storage_desc">Потрібний, щоб просканувати ваш пристрій та знайти книги</string>
<string name="start_scan_preferences">Налаштування сканування</string>
<string name="start_done">Все готово!</string>
<string name="start_done_desc">Ви успішно закінчили базове налаштовування застосунку. Бажаєте дізнатися, як користуватися застосунком, прочитавши екран Довідки?</string>
@ -444,7 +446,6 @@
<string name="history_content_desc">Історія читання</string>
<string name="browse_content_desc">Додавайте книги</string>
<string name="arrow_content_desc">Стрілка</string>
<string name="required_content_desc">Необхідно</string>
<string name="reset_start_content_desc">Скинути початковий посібник</string>
<string name="open_in_web_content_desc">Відкрити в браузері</string>
<string name="delete_color_preset_content_desc">Видалити пресет кольорів</string>
@ -464,5 +465,7 @@
<string name="edit_content_desc">Редагувати поле</string>
<string name="expand_content_desc">Розгорнути</string>
<string name="collapse_content_desc">Згорнути</string>
<string name="remove_content_desc">Прибрати</string>
<string name="note_content_desc">Примітка</string>
</resources>

View file

@ -21,14 +21,11 @@
<string name="never">永不</string>
<string name="color_preset_query">预设 %1$s</string>
<string name="cancel">取消</string>
<string name="error_permission">我们需要存储权限以扫描您的设备上的书</string>
<string name="library_screen">书库</string>
<string name="history_screen">历史</string>
<string name="browse_screen">浏览</string>
<string name="about_screen">关于 app</string>
<string name="storage_permission">授予权限?</string>
<string name="move_book">移动书籍?</string>
<string name="storage_permission_description">我们需要存储权限以扫描您的设备上的书。没有此权限,您将无法添加书。</string>
<string name="delete_books_description">所有已选择的书(%1$s将会从数据库中删除但不会从您的设备上删除。</string>
<string name="delete_book_description">这本书将会从数据库中删除,但不会从您的设备上删除。</string>
<string name="move_book_description">选择你想将这本书移动到的分类。这本书将会被移动到指定的分类。</string>
@ -44,8 +41,6 @@
<string name="library_empty">阅读您喜欢的书籍并对其进行分类</string>
<string name="history_empty">让您读完的最后一本书为您打开阅读之旅的新篇章</string>
<string name="color_preset_selected_query">已选择 «%1$s»</string>
<string name="grant">授予</string>
<string name="grant_permission">授予权限</string>
<string name="undo">撤销</string>
<string name="next">下一个</string>
<string name="unknown_author">未知</string>
@ -55,7 +50,6 @@
<string name="already_read_tab">已阅读</string>
<string name="add_books">添加书籍?</string>
<string name="error_no_browser">未发现浏览器应用。</string>
<string name="get_help">我需要帮助</string>
<string name="add_book">添加一本书</string>
<string name="move">移动</string>
<string name="delete">删除</string>
@ -260,11 +254,8 @@
<string name="yesterday">昨天</string>
<string name="continue_reading_content_desc">继续阅读</string>
<string name="cover_image_content_desc">封面图片</string>
<string name="start_permissions_preferences">权限</string>
<string name="start_done">设置完成!</string>
<string name="error_content_desc">错误</string>
<string name="start_permissions_granted">已授权</string>
<string name="start_permissions_storage">存储权限</string>
<string name="start_done_desc">你已经成功完成了应用基本设置。要参阅帮助以获取更多使用技巧吗?</string>
<string name="cover_image_not_found_content_desc">未找到封面图片</string>
<string name="start_language_preferences">语言偏好</string>
@ -293,7 +284,6 @@
<string name="history_content_desc">阅读历史</string>
<string name="browse_content_desc">添加书籍</string>
<string name="arrow_content_desc">箭头</string>
<string name="required_content_desc">必填</string>
<string name="reset_start_content_desc">重置引导页面</string>
<string name="delete_books_content_desc">删除所选书籍</string>
<string name="revert_content_desc">撤销</string>
@ -301,7 +291,6 @@
<string name="checkbox_content_desc">复选框</string>
<string name="add_files_content_desc">添加文件</string>
<string name="select_all_files_content_desc">选择所有文件</string>
<string name="start_permissions_storage_desc">需要扫描您的设备来查找书籍</string>
<string name="delete_whole_history_content_desc">删除全部历史记录</string>
<string name="open_in_web_content_desc">在网页中打开</string>
<string name="create_color_preset_content_desc">创建新颜色预设</string>

View file

@ -19,7 +19,6 @@
<string name="help_screen">Help</string>
<!-- Dialog Labels -->
<string name="storage_permission">Grant Permission?</string>
<string name="add_books">Add books?</string>
<string name="move_books">Move books?</string>
<string name="delete_books">Delete books?</string>
@ -29,10 +28,6 @@
<string name="edit">Edit</string>
<!-- Dialog Descriptions -->
<string name="storage_permission_description">
We need storage permission to scan Your Device for books.
Without this permission, you will not be able to add a book.
</string>
<string name="add_books_description">
You can choose which books you want to add.
You can edit Title and Cover image of the book later.
@ -60,26 +55,19 @@
</string>
<!-- Errors -->
<string name="error_permission">
We need storage permission to scan Your Device for books
</string>
<string name="error_no_description">
No description.
</string>
<string name="error_something_went_wrong">
Something went wrong.
</string>
<string name="error_no_description">No description.</string>
<string name="error_something_went_wrong">Something went wrong.</string>
<string name="error_no_translator">No translator app found.</string>
<string name="error_no_dictionary">No dictionary app found.</string>
<string name="error_no_browser">No browser app found.</string>
<string name="error_no_share_app">No app to share found.</string>
<string name="error_could_not_get_text">
File is empty, corrupted, or missing.
Please verify the file or update the path and try again.
File is empty, missing, or inaccessible.
Verify the file, scan settings or change the path, then try again.
</string>
<string name="error_could_not_reset_cover">Could not reset cover image.</string>
<string name="error_closed_source">Closed source</string>
<string name="error_no_file">File does not exist</string>
<string name="error_no_file">File does not exist or inaccessible</string>
<!-- Is Empty messages -->
<string name="search_field_empty">
@ -102,9 +90,6 @@
<!-- Action Texts -->
<string name="cancel">Cancel</string>
<string name="grant">Grant</string>
<string name="grant_permission">Grant permission</string>
<string name="get_help">I need help</string>
<string name="add_book">Add a book</string>
<string name="move">Move</string>
<string name="delete">Delete</string>
@ -121,6 +106,8 @@
<string name="share">Share</string>
<string name="ok">OK</string>
<string name="change_path">Change path</string>
<string name="add_folder">Add folder</string>
<string name="set_up_scanning">Set up scanning</string>
<!-- Book -->
<string name="unknown_author">Unknown</string>
@ -167,10 +154,18 @@
<string name="progress_reader_settings">Progress</string>
<string name="theme_appearance_settings">Theme preferences</string>
<string name="colors_appearance_settings">Colors</string>
<string name="scan_browse_settings">Scan</string>
<string name="general_browse_settings">General</string>
<string name="filter_browse_settings">Filter</string>
<string name="sort_browse_settings">Sort</string>
<!-- Settings notes -->
<string name="browse_scan_option_note">
These folders are used to scan and retrieve files in the Browse.
Removing a folder here will not delete actual files or books from the Library.
To revoke access, click the corresponding button.
</string>
<!-- Settings options -->
<!-- General -->
<string name="language_option">App language</string>
@ -545,10 +540,7 @@
</string>
<string name="start_language_preferences">Language preferences</string>
<string name="start_theme_preferences">Theme preferences</string>
<string name="start_permissions_preferences">Permissions</string>
<string name="start_permissions_granted">Granted</string>
<string name="start_permissions_storage">Storage permission</string>
<string name="start_permissions_storage_desc">Required to scan your device and find books</string>
<string name="start_scan_preferences">Scan preferences</string>
<string name="start_done">Everything\'s done!</string>
<string name="start_done_desc">
You\'re successfully done basic setting of the app.
@ -585,7 +577,6 @@
<string name="history_content_desc">Reading history</string>
<string name="browse_content_desc">Add books</string>
<string name="arrow_content_desc">Arrow</string>
<string name="required_content_desc">Required</string>
<string name="reset_start_content_desc">Reset start guide</string>
<string name="open_in_web_content_desc">Open in web</string>
<string name="delete_color_preset_content_desc">Delete color preset</string>
@ -608,5 +599,7 @@
<string name="edit_content_desc">Edit field</string>
<string name="expand_content_desc">Expand</string>
<string name="collapse_content_desc">Collapse</string>
<string name="remove_content_desc">Remove</string>
<string name="note_content_desc">Note</string>
</resources>