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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions app/src/main/java/com/readarea/core/BookLoader.kt
Original file line number Diff line number Diff line change
Expand Up @@ -227,19 +227,23 @@ object ImageUtil {

object BookLoader {

private fun metaZip(context: Context, uri: Uri, keep: (String, Long) -> Boolean): MemoryZipAccess {
private fun metaZip(context: Context, uri: Uri, maxEntry: Int = MemoryZipAccess.MAX_ENTRY, keep: (String, Long) -> Boolean): MemoryZipAccess {
val input = if (uri.scheme == "file") File(uri.path!!).inputStream() else context.contentResolver.openInputStream(uri) ?: throw FileNotFoundException("Unable to open file")
return input.use { MemoryZipAccess(it, keep) }
return input.use { MemoryZipAccess(it, maxEntry, keep) }
}

/** Reads one image from a book for its cover; high-resolution covers can be larger than other entries read for details. */
private fun coverEntry(context: Context, uri: Uri, ref: String): ByteArray? =
metaZip(context, uri, Limits.ENTRY) { name, _ -> name.equals(ref, true) || PathUtil.decode(name).equals(ref, true) }.read(ref)

private fun isImage(name: String): Boolean {
val l = name.lowercase()
return l.endsWith(".jpg") || l.endsWith(".jpeg") || l.endsWith(".png") || l.endsWith(".webp") || l.endsWith(".gif") || l.endsWith(".bmp")
}

private fun withCover(context: Context, uri: Uri, zip: ZipAccess, parsed: ParsedBook): ReflowableBook {
val ref = parsed.meta.coverRef
val bytes = ref?.let { r -> zip.read(r) ?: metaZip(context, uri) { name, _ -> name.equals(r, true) || PathUtil.decode(name).equals(r, true) }.read(r) }
val bytes = ref?.let { r -> zip.read(r) ?: coverEntry(context, uri, r) }
return ReflowableBook(ParsedBook(parsed.meta, emptyList(), emptyList(), ResourceProvider { p -> if (p == ref) bytes else null }))
}

Expand All @@ -255,7 +259,7 @@ object BookLoader {
val docZip = metaZip(context, uri) { name, _ -> name.equals(doc, true) || PathUtil.decode(name).equals(doc, true) }
EpubParser(docZip).firstImageIn(doc)
}
val coverBytes = coverRef?.let { ref -> zip.read(ref) ?: metaZip(context, uri) { name, _ -> name.equals(ref, true) || PathUtil.decode(name).equals(ref, true) }.read(ref) }
val coverBytes = coverRef?.let { ref -> zip.read(ref) ?: coverEntry(context, uri, ref) }
val meta = (if (parsed.meta.title.isBlank()) parsed.meta.copy(title = title) else parsed.meta).copy(coverRef = coverRef)
ReflowableBook(ParsedBook(meta, emptyList(), emptyList(), ResourceProvider { p -> if (p == coverRef) coverBytes else null }))
}
Expand All @@ -270,7 +274,7 @@ object BookLoader {
BookFormat.CBZ -> {
val names = metaZip(context, uri) { _, _ -> false }.entries
val best = names.filter { isImage(it) && !it.lowercase().startsWith("__macosx") }.minWithOrNull { a, b -> CbzSource.naturalCompare(a, b) }
val cover = best?.let { b -> metaZip(context, uri) { name, _ -> name == b }.read(b) }
val cover = best?.let { b -> metaZip(context, uri, Limits.ENTRY) { name, _ -> name == b }.read(b) }
ReflowableBook(ParsedBook(BookMeta(title = title, coverRef = best), emptyList(), emptyList(), ResourceProvider { p -> if (p == best) cover else null }))
}
else -> null
Expand Down
79 changes: 72 additions & 7 deletions app/src/main/java/com/readarea/data/LibraryRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ class LibraryRepository(
private val _scan = MutableStateFlow(ScanState())
val scan: StateFlow<ScanState> = _scan.asStateFlow()

private val _lostFolders = MutableStateFlow<List<String>>(emptyList())

/** Library folders Android no longer lets ReadArea read, whose books are set aside until it's granted again. */
val lostFolders: StateFlow<List<String>> = _lostFolders.asStateFlow()

val books = db.books().observeAll()
val collections = db.collections().observe()
val highlights = db.notes().allHighlights()
Expand Down Expand Up @@ -98,7 +103,17 @@ class LibraryRepository(
val s = settings.appNow()
if (!s.reopenLastBook || s.resumeBookId <= 0) return null
val b = db.books().get(s.resumeBookId) ?: return null
return if (b.missing) null else b.id
// Starting on an error page instead of the library helps nobody: only reopen a book that can be read.
return if (b.missing || !canRead(b.uri)) null else b.id
}

/** Whether a book's file can be opened right now, checked without reading it. */
private suspend fun canRead(uri: String): Boolean = withContext(Dispatchers.IO) {
val u = uri.toUri()
runCatching {
if (u.scheme == "file") File(u.path!!).canRead()
else context.contentResolver.openFileDescriptor(u, "r")?.use { true } ?: false
}.getOrDefault(false)
}

suspend fun adoptGrantedAccess(): Boolean {
Expand All @@ -121,9 +136,19 @@ class LibraryRepository(
val ignored = app.ignored.toHashSet()
_scan.value = ScanState(running = true, phase = ScanPhase.FOLDERS)
var found = 0
val lost = ArrayList<String>()
for (folder in folders) {
val tree = folder.toUri()
val files = runCatching { walkTree(tree) }.getOrElse { emptyList() }
val walked = runCatching { walkTree(tree) }
if (walked.exceptionOrNull() is SecurityException) {
// Android took back access to this folder (a reinstall restored from backup, or access withdrawn):
// its books can't be opened, so they are set aside until the folder is chosen again.
lost.add(folder)
val stranded = db.books().all().filter { it.folderUri == folder && !it.missing }.map { it.id }
setMissing(stranded, true)
continue
}
val files = walked.getOrElse { emptyList() }
found += files.size
_scan.value = _scan.value.copy(found = found)
val all = db.books().all()
Expand All @@ -150,7 +175,16 @@ class LibraryRepository(
val gone = existing.values.filter { it.uri !in seen && !it.missing }.map { it.id }
if (gone.isNotEmpty() && files.isNotEmpty()) db.books().setMissing(gone, true)
}
if (app.deviceScan && DeviceStorage.hasAccess(context)) found += scanDevice(found, ignored)
_lostFolders.value = lost
if (DeviceStorage.hasAccess(context)) {
if (app.deviceScan) found += scanDevice(found, ignored)
} else {
// Books found on the device can only be read with access to all files; without it they'd fail when opened.
val stranded = db.books().all().filter { it.folderUri == DeviceStorage.DEVICE && !it.missing }.map { it.id }
setMissing(stranded, true)
}
checkImportedAccess()
forgetLostCovers()
_scan.value = ScanState(running = true, found = found, phase = ScanPhase.DETAILS)
metaMutex.withLock { loadPendingMetadata() }
_scan.value = ScanState(running = false, found = found)
Expand Down Expand Up @@ -191,6 +225,32 @@ class LibraryRepository(
return found.size
}

/**
* Books added one by one keep a permission per file, which Android can take back (a reinstall, or its limit on
* how many it keeps). Those that can't be opened are set aside, and come back once they can be.
*/
private suspend fun checkImportedAccess() = withContext(Dispatchers.IO) {
val held = context.contentResolver.persistedUriPermissions.filter { it.isReadPermission }.map { it.uri.toString() }.toHashSet()
val imported = db.books().all().filter { it.folderUri == null && it.uri.toUri().scheme == "content" }
val lost = ArrayList<Long>()
val back = ArrayList<Long>()
for (b in imported) {
val readable = b.uri in held || canRead(b.uri)
if (!readable && !b.missing) lost.add(b.id) else if (readable && b.missing) back.add(b.id)
}
setMissing(lost, true)
setMissing(back, false)
}

/** Covers live in app storage, which a restore from backup doesn't bring back: read the ones that are gone again. */
private suspend fun forgetLostCovers() = withContext(Dispatchers.IO) {
val gone = db.books().all().filter { b -> b.coverPath?.let { !File(it).exists() } == true }.map { it.id }
gone.chunked(500).forEach { db.books().forgetCovers(it) }
}

/** Android's SQLite takes at most 999 values in one statement, and these lists can be a whole library. */
private suspend fun setMissing(ids: List<Long>, missing: Boolean) = ids.chunked(500).forEach { db.books().setMissing(it, missing) }

private data class FoundFile(val uri: String, val name: String, val size: Long, val modified: Long, val format: BookFormat)

private fun walkTree(tree: Uri): List<FoundFile> {
Expand Down Expand Up @@ -357,13 +417,18 @@ class LibraryRepository(
} finally {
opened.close()
}
}.getOrElse { book.copy(metaLoaded = true) }
}
// A file that can't be opened right now (not reachable, or access not granted yet) is tried again on the next
// scan; only one that opened but couldn't be read is settled without details.
val error = updated.exceptionOrNull()
if (error is java.io.FileNotFoundException || error is SecurityException) return
val details = updated.getOrElse { book.copy(metaLoaded = true) }
val current = db.books().get(book.id) ?: return
db.books().update(
current.copy(
title = updated.title, author = updated.author, description = updated.description, language = updated.language,
series = updated.series, seriesIndex = updated.seriesIndex, publisher = updated.publisher, coverPath = updated.coverPath,
metaLoaded = true, pageCount = updated.pageCount,
title = details.title, author = details.author, description = details.description, language = details.language,
series = details.series, seriesIndex = details.seriesIndex, publisher = details.publisher, coverPath = details.coverPath,
metaLoaded = true, pageCount = details.pageCount,
),
)
}
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/com/readarea/data/db/Daos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ interface BookDao {
@Query("UPDATE books SET missing = :missing WHERE id IN (:ids)")
suspend fun setMissing(ids: List<Long>, missing: Boolean)

/** Forgets covers whose image is gone, so their details are read again. */
@Query("UPDATE books SET coverPath = NULL, metaLoaded = 0 WHERE id IN (:ids)")
suspend fun forgetCovers(ids: List<Long>)

@Query("DELETE FROM books WHERE id IN (:ids)")
suspend fun delete(ids: List<Long>)

Expand Down
7 changes: 5 additions & 2 deletions app/src/main/java/com/readarea/ui/LibraryViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,14 @@ class LibraryViewModel(application: Application) : AndroidViewModel(application)

suspend fun resumeTarget(): Long? = repo.resumeTarget()

suspend fun shouldOfferDeviceScan(): Boolean {
/** Whether to offer finding books on the device: the first time, or when it's on but Android took back the access it needs. */
suspend fun shouldOfferDeviceScan(hasAccess: Boolean): Boolean {
val s = settingsRepo.appNow()
return !s.askedDeviceScan && !s.deviceScan
return (!s.askedDeviceScan && !s.deviceScan) || (s.deviceScan && !hasAccess)
}

val lostFolders = repo.lostFolders

fun importFiles(uris: List<Uri>, onDone: (List<Long>) -> Unit = {}) {
viewModelScope.launch { onDone(repo.importFiles(uris)) }
}
Expand Down
29 changes: 28 additions & 1 deletion app/src/main/java/com/readarea/ui/ReadAreaRoot.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffo
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
Expand Down Expand Up @@ -108,14 +110,31 @@ fun ReadAreaRoot(vm: LibraryViewModel) {
if (granted) vm.setDeviceScan(true) else vm.markDeviceScanAsked()
}
LaunchedEffect(Unit) {
if (vm.shouldOfferDeviceScan()) {
if (vm.shouldOfferDeviceScan(DeviceStorage.hasAccess(context))) {
if (DeviceStorage.hasAccess(context)) vm.setDeviceScan(true) else findDialog = true
}
}
LifecycleResumeEffect(Unit) {
vm.adoptGrantedAccess()
onPauseOrDispose {}
}
val lostFolders by vm.lostFolders.collectAsStateWithLifecycle()
var dismissedFolders by rememberSaveable { mutableStateOf(listOf<String>()) }
lostFolders.firstOrNull { it !in dismissedFolders }?.let { folder ->
AlertDialog(
onDismissRequest = { dismissedFolders = dismissedFolders + folder },
icon = { Icon(Icons.Rounded.TravelExplore, null) },
title = { Text(stringResource(R.string.folder_access_lost_title)) },
text = { Text(stringResource(R.string.folder_access_lost_body, folderLabel(context, folder))) },
confirmButton = {
Button(onClick = {
dismissedFolders = dismissedFolders + folder
runCatching { folderPicker.launch(folder.toUri()) }
}) { Text(stringResource(R.string.choose_folder_again)) }
},
dismissButton = { TextButton(onClick = { dismissedFolders = dismissedFolders + folder }) { Text(stringResource(R.string.not_now)) } },
)
}
if (findDialog) {
AlertDialog(
onDismissRequest = { findDialog = false; vm.markDeviceScanAsked() },
Expand Down Expand Up @@ -191,3 +210,11 @@ fun ReadAreaRoot(vm: LibraryViewModel) {
}
details?.let { id -> BookDetailsSheet(vm, actions, id) { details = null } }
}

/** A library folder as people know it, e.g. "Internal storage › Books", from its tree address. */
private fun folderLabel(context: android.content.Context, uri: String): String = runCatching {
val id = android.provider.DocumentsContract.getTreeDocumentId(uri.toUri())
val path = id.substringAfter(':', id)
val root = id.substringBefore(':')
(if (root.equals("primary", true)) context.getString(R.string.internal_storage) else root) + if (path.isNotEmpty()) " › " + path.replace("/", " › ") else ""
}.getOrElse { Uri.decode(uri) }
3 changes: 3 additions & 0 deletions app/src/main/res/values-ar/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -444,4 +444,7 @@
<item quantity="many">تم تحديد %d عنصرًا</item>
<item quantity="other">تم تحديد %d عنصر</item>
</plurals>
<string name="choose_folder_again">اختيار المجلد</string>
<string name="folder_access_lost_body">لم يعد بإمكان ReadArea فتح الكتب في «%s». اختر ذلك المجلد مجددًا لاستعادتها.</string>
<string name="folder_access_lost_title">هل تسمح بالوصول مجددًا؟</string>
</resources>
3 changes: 3 additions & 0 deletions app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -404,4 +404,7 @@
<item quantity="one">%d ausgewählt</item>
<item quantity="other">%d ausgewählt</item>
</plurals>
<string name="choose_folder_again">Ordner wählen</string>
<string name="folder_access_lost_body">ReadArea kann die Bücher in „%s“ nicht mehr öffnen. Wähle diesen Ordner erneut aus, um sie zurückzuholen.</string>
<string name="folder_access_lost_title">Zugriff erneut erlauben?</string>
</resources>
3 changes: 3 additions & 0 deletions app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -414,4 +414,7 @@
<item quantity="many">%d seleccionados</item>
<item quantity="other">%d seleccionados</item>
</plurals>
<string name="choose_folder_again">Elegir carpeta</string>
<string name="folder_access_lost_body">ReadArea ya no puede abrir los libros de «%s». Vuelve a elegir esa carpeta para recuperarlos.</string>
<string name="folder_access_lost_title">¿Permitir el acceso de nuevo?</string>
</resources>
3 changes: 3 additions & 0 deletions app/src/main/res/values-fr/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -414,4 +414,7 @@
<item quantity="many">%d sélectionnés</item>
<item quantity="other">%d sélectionnés</item>
</plurals>
<string name="choose_folder_again">Choisir le dossier</string>
<string name="folder_access_lost_body">ReadArea ne peut plus ouvrir les livres de « %s ». Choisissez de nouveau ce dossier pour les retrouver.</string>
<string name="folder_access_lost_title">Autoriser l\'accès à nouveau ?</string>
</resources>
3 changes: 3 additions & 0 deletions app/src/main/res/values-hi/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -404,4 +404,7 @@
<item quantity="one">%d चुनी गई</item>
<item quantity="other">%d चुनी गईं</item>
</plurals>
<string name="choose_folder_again">फ़ोल्डर चुनें</string>
<string name="folder_access_lost_body">ReadArea अब “%s” में मौजूद किताबें नहीं खोल सकता। उन्हें वापस पाने के लिए वह फ़ोल्डर फिर से चुनें।</string>
<string name="folder_access_lost_title">फिर से एक्सेस की अनुमति दें?</string>
</resources>
3 changes: 3 additions & 0 deletions app/src/main/res/values-in/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -394,4 +394,7 @@
<plurals name="selected_count">
<item quantity="other">%d dipilih</item>
</plurals>
<string name="choose_folder_again">Pilih folder</string>
<string name="folder_access_lost_body">ReadArea tidak bisa lagi membuka buku di “%s”. Pilih folder itu lagi untuk mengembalikannya.</string>
<string name="folder_access_lost_title">Izinkan akses lagi?</string>
</resources>
3 changes: 3 additions & 0 deletions app/src/main/res/values-it/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -414,4 +414,7 @@
<item quantity="many">%d selezionati</item>
<item quantity="other">%d selezionati</item>
</plurals>
<string name="choose_folder_again">Scegli cartella</string>
<string name="folder_access_lost_body">ReadArea non può più aprire i libri in “%s”. Scegli di nuovo quella cartella per riaverli.</string>
<string name="folder_access_lost_title">Consentire di nuovo l\'accesso?</string>
</resources>
3 changes: 3 additions & 0 deletions app/src/main/res/values-ja/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -394,4 +394,7 @@
<plurals name="selected_count">
<item quantity="other">%d件選択</item>
</plurals>
<string name="choose_folder_again">フォルダを選択</string>
<string name="folder_access_lost_body">ReadArea は「%s」の本を開けなくなりました。そのフォルダをもう一度選ぶと元に戻ります。</string>
<string name="folder_access_lost_title">もう一度アクセスを許可しますか?</string>
</resources>
3 changes: 3 additions & 0 deletions app/src/main/res/values-ko/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -394,4 +394,7 @@
<plurals name="selected_count">
<item quantity="other">%d개 선택됨</item>
</plurals>
<string name="choose_folder_again">폴더 선택</string>
<string name="folder_access_lost_body">ReadArea에서 더 이상 “%s”의 책을 열 수 없습니다. 해당 폴더를 다시 선택하면 책이 돌아옵니다.</string>
<string name="folder_access_lost_title">다시 접근을 허용할까요?</string>
</resources>
3 changes: 3 additions & 0 deletions app/src/main/res/values-nl/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -404,4 +404,7 @@
<item quantity="one">%d geselecteerd</item>
<item quantity="other">%d geselecteerd</item>
</plurals>
<string name="choose_folder_again">Map kiezen</string>
<string name="folder_access_lost_body">ReadArea kan de boeken in ‘%s’ niet meer openen. Kies die map opnieuw om ze terug te halen.</string>
<string name="folder_access_lost_title">Opnieuw toegang geven?</string>
</resources>
3 changes: 3 additions & 0 deletions app/src/main/res/values-pl/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -424,4 +424,7 @@
<item quantity="many">Zaznaczono %d</item>
<item quantity="other">Zaznaczono %d</item>
</plurals>
<string name="choose_folder_again">Wybierz folder</string>
<string name="folder_access_lost_body">ReadArea nie może już otworzyć książek w „%s”. Wybierz ten folder ponownie, aby je odzyskać.</string>
<string name="folder_access_lost_title">Zezwolić ponownie na dostęp?</string>
</resources>
3 changes: 3 additions & 0 deletions app/src/main/res/values-pt-rBR/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -414,4 +414,7 @@
<item quantity="many">%d selecionados</item>
<item quantity="other">%d selecionados</item>
</plurals>
<string name="choose_folder_again">Escolher pasta</string>
<string name="folder_access_lost_body">O ReadArea não consegue mais abrir os livros em “%s”. Escolha essa pasta de novo para recuperá-los.</string>
<string name="folder_access_lost_title">Permitir o acesso de novo?</string>
</resources>
Loading
Loading