diff --git a/app/src/main/java/com/readarea/core/BookLoader.kt b/app/src/main/java/com/readarea/core/BookLoader.kt index e71134e..119c4c4 100644 --- a/app/src/main/java/com/readarea/core/BookLoader.kt +++ b/app/src/main/java/com/readarea/core/BookLoader.kt @@ -227,11 +227,15 @@ 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") @@ -239,7 +243,7 @@ object BookLoader { 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 })) } @@ -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 })) } @@ -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 diff --git a/app/src/main/java/com/readarea/data/LibraryRepository.kt b/app/src/main/java/com/readarea/data/LibraryRepository.kt index 3659d72..a70ff48 100644 --- a/app/src/main/java/com/readarea/data/LibraryRepository.kt +++ b/app/src/main/java/com/readarea/data/LibraryRepository.kt @@ -47,6 +47,11 @@ class LibraryRepository( private val _scan = MutableStateFlow(ScanState()) val scan: StateFlow = _scan.asStateFlow() + private val _lostFolders = MutableStateFlow>(emptyList()) + + /** Library folders Android no longer lets ReadArea read, whose books are set aside until it's granted again. */ + val lostFolders: StateFlow> = _lostFolders.asStateFlow() + val books = db.books().observeAll() val collections = db.collections().observe() val highlights = db.notes().allHighlights() @@ -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 { @@ -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() 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() @@ -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) @@ -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() + val back = ArrayList() + 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, 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 { @@ -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, ), ) } diff --git a/app/src/main/java/com/readarea/data/db/Daos.kt b/app/src/main/java/com/readarea/data/db/Daos.kt index d901a39..7a4fec1 100644 --- a/app/src/main/java/com/readarea/data/db/Daos.kt +++ b/app/src/main/java/com/readarea/data/db/Daos.kt @@ -60,6 +60,10 @@ interface BookDao { @Query("UPDATE books SET missing = :missing WHERE id IN (:ids)") suspend fun setMissing(ids: List, 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) + @Query("DELETE FROM books WHERE id IN (:ids)") suspend fun delete(ids: List) diff --git a/app/src/main/java/com/readarea/ui/LibraryViewModel.kt b/app/src/main/java/com/readarea/ui/LibraryViewModel.kt index 9b2a01e..ebce195 100644 --- a/app/src/main/java/com/readarea/ui/LibraryViewModel.kt +++ b/app/src/main/java/com/readarea/ui/LibraryViewModel.kt @@ -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, onDone: (List) -> Unit = {}) { viewModelScope.launch { onDone(repo.importFiles(uris)) } } diff --git a/app/src/main/java/com/readarea/ui/ReadAreaRoot.kt b/app/src/main/java/com/readarea/ui/ReadAreaRoot.kt index e75d978..1cb84e9 100644 --- a/app/src/main/java/com/readarea/ui/ReadAreaRoot.kt +++ b/app/src/main/java/com/readarea/ui/ReadAreaRoot.kt @@ -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 @@ -108,7 +110,7 @@ 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 } } @@ -116,6 +118,23 @@ fun ReadAreaRoot(vm: LibraryViewModel) { vm.adoptGrantedAccess() onPauseOrDispose {} } + val lostFolders by vm.lostFolders.collectAsStateWithLifecycle() + var dismissedFolders by rememberSaveable { mutableStateOf(listOf()) } + 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() }, @@ -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) } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index be0628d..1ce3809 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -444,4 +444,7 @@ تم تحديد %d عنصرًا تم تحديد %d عنصر + اختيار المجلد + لم يعد بإمكان ReadArea فتح الكتب في «%s». اختر ذلك المجلد مجددًا لاستعادتها. + هل تسمح بالوصول مجددًا؟ diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 5fcb6c8..4773f5c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -404,4 +404,7 @@ %d ausgewählt %d ausgewählt + Ordner wählen + ReadArea kann die Bücher in „%s“ nicht mehr öffnen. Wähle diesen Ordner erneut aus, um sie zurückzuholen. + Zugriff erneut erlauben? diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5be1ccf..e1a0f8c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -414,4 +414,7 @@ %d seleccionados %d seleccionados + Elegir carpeta + ReadArea ya no puede abrir los libros de «%s». Vuelve a elegir esa carpeta para recuperarlos. + ¿Permitir el acceso de nuevo? diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index cd2f177..ea97dd2 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -414,4 +414,7 @@ %d sélectionnés %d sélectionnés + Choisir le dossier + ReadArea ne peut plus ouvrir les livres de « %s ». Choisissez de nouveau ce dossier pour les retrouver. + Autoriser l\'accès à nouveau ? diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index aefb2ae..fd6e534 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -404,4 +404,7 @@ %d चुनी गई %d चुनी गईं + फ़ोल्डर चुनें + ReadArea अब “%s” में मौजूद किताबें नहीं खोल सकता। उन्हें वापस पाने के लिए वह फ़ोल्डर फिर से चुनें। + फिर से एक्सेस की अनुमति दें? diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 84b653d..0f4ce62 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -394,4 +394,7 @@ %d dipilih + Pilih folder + ReadArea tidak bisa lagi membuka buku di “%s”. Pilih folder itu lagi untuk mengembalikannya. + Izinkan akses lagi? diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 15ec8c7..c1607a3 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -414,4 +414,7 @@ %d selezionati %d selezionati + Scegli cartella + ReadArea non può più aprire i libri in “%s”. Scegli di nuovo quella cartella per riaverli. + Consentire di nuovo l\'accesso? diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 0dab17f..e51ba39 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -394,4 +394,7 @@ %d件選択 + フォルダを選択 + ReadArea は「%s」の本を開けなくなりました。そのフォルダをもう一度選ぶと元に戻ります。 + もう一度アクセスを許可しますか? diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index f32286e..1fba6c7 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -394,4 +394,7 @@ %d개 선택됨 + 폴더 선택 + ReadArea에서 더 이상 “%s”의 책을 열 수 없습니다. 해당 폴더를 다시 선택하면 책이 돌아옵니다. + 다시 접근을 허용할까요? diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 5a866ee..53a65c4 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -404,4 +404,7 @@ %d geselecteerd %d geselecteerd + Map kiezen + ReadArea kan de boeken in ‘%s’ niet meer openen. Kies die map opnieuw om ze terug te halen. + Opnieuw toegang geven? diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index de6f274..2004483 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -424,4 +424,7 @@ Zaznaczono %d Zaznaczono %d + Wybierz folder + ReadArea nie może już otworzyć książek w „%s”. Wybierz ten folder ponownie, aby je odzyskać. + Zezwolić ponownie na dostęp? diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index cd530b9..e6d6242 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -414,4 +414,7 @@ %d selecionados %d selecionados + Escolher pasta + O ReadArea não consegue mais abrir os livros em “%s”. Escolha essa pasta de novo para recuperá-los. + Permitir o acesso de novo? diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 3991dac..489739a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -424,4 +424,7 @@ Выбрано: %d Выбрано: %d + Выбрать папку + ReadArea больше не может открыть книги в «%s». Выберите эту папку ещё раз, чтобы вернуть их. + Снова разрешить доступ? diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 6c22639..b00e5a3 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -404,4 +404,7 @@ %d seçildi %d seçildi + Klasör seç + ReadArea artık “%s” içindeki kitapları açamıyor. Onları geri getirmek için o klasörü yeniden seçin. + Erişime yeniden izin verilsin mi? diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index dd552d6..f1f42fd 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -424,4 +424,7 @@ Вибрано: %d Вибрано: %d + Вибрати теку + ReadArea більше не може відкрити книжки в «%s». Виберіть цю теку ще раз, щоб повернути їх. + Знову дозволити доступ? diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5280646..09f4039 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -394,4 +394,7 @@ 已选择 %d 项 + 选择文件夹 + ReadArea 已无法打开“%s”中的书。重新选择该文件夹即可找回这些书。 + 要再次允许访问吗? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 01ffdd4..b5e0b13 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -406,4 +406,7 @@ %d selected %d selected + Choose folder + ReadArea can no longer open the books in “%s”. Choose that folder again to bring them back. + Allow access again? diff --git a/app/src/test/java/com/readarea/qa/LibraryBehaviourTest.kt b/app/src/test/java/com/readarea/qa/LibraryBehaviourTest.kt index f7c9117..122acba 100644 --- a/app/src/test/java/com/readarea/qa/LibraryBehaviourTest.kt +++ b/app/src/test/java/com/readarea/qa/LibraryBehaviourTest.kt @@ -59,6 +59,9 @@ class LibraryBehaviourTest { @Before fun setUp() { TestIsolation.reset() + // The books below were found on the device, which takes access to all files; without it they'd be set aside. + val ops = app.getSystemService(android.app.AppOpsManager::class.java) + shadowOf(ops).setMode("android:manage_external_storage", android.os.Process.myUid(), app.packageName, android.app.AppOpsManager.MODE_ALLOWED) runBlocking { app.settings.updateApp { AppSettings(askedDeviceScan = true, reopenLastBook = false) } app.settings.updateReader { ReaderSettings() } diff --git a/app/src/test/java/com/readarea/qa/LibraryFlowTest.kt b/app/src/test/java/com/readarea/qa/LibraryFlowTest.kt index 4ed62fe..9bd0b4f 100644 --- a/app/src/test/java/com/readarea/qa/LibraryFlowTest.kt +++ b/app/src/test/java/com/readarea/qa/LibraryFlowTest.kt @@ -194,4 +194,68 @@ class LibraryFlowTest { } assertFalse(runBlocking { app.settings.app.first() }.deviceScan) } + + private fun coveredEpub(title: String) = TestBooks.epub( + chapters = 1, paragraphs = 3, withCover = true, title = title, + coverImage = com.readarea.ui.CoverArt.make(title, "Ann", 0xFF2E4A6B.toInt(), 0xFF101A26.toInt(), 0xFFFFFFFF.toInt(), 0), + ) + + private fun book(id: Long) = runBlocking { app.database.books().get(id) }!! + + @Test + fun aBookThatCantBeReadYetGetsItsCoverLater() { + // Not readable at first (moved, or access not granted yet): its details must be tried again, not given up on. + val file = File(root, "Novels/later.epub") + val id = runBlocking { app.database.books().insert(com.readarea.data.db.BookEntity(uri = android.net.Uri.fromFile(file).toString(), fileName = file.name, format = "EPUB", title = "later")) } + runBlocking { app.library.loadMetadata(book(id)) } + assertFalse("an unreadable file is not settled without a cover", book(id).metaLoaded) + put("Novels/later.epub", coveredEpub("Later Novel")) + runBlocking { app.library.loadMetadata(book(id)) } + assertTrue(book(id).metaLoaded) + assertTrue("the cover is read once the file can be", book(id).coverPath?.let { File(it).exists() } == true) + assertEquals("Later Novel", book(id).title) + } + + @Test + fun coversLostFromAppStorageAreReadAgain() { + // What a restore from backup leaves: the library, but none of the cover images kept in app storage. + val file = put("Novels/restored.epub", coveredEpub("Restored Novel")) + val id = runBlocking { + app.database.books().insert( + com.readarea.data.db.BookEntity( + uri = android.net.Uri.fromFile(file).toString(), fileName = file.name, format = "EPUB", title = "Restored Novel", + metaLoaded = true, coverPath = File(app.filesDir, "covers/cover_gone.jpg").absolutePath, + ), + ) + } + runBlocking { app.library.scanAll() } + assertTrue("the cover is made again", book(id).coverPath?.let { File(it).exists() } == true) + } + + @Test + fun deviceBooksAreSetAsideWhileAccessIsGoneAndComeBack() { + grantAllFiles() + put("Books/kept.epub", coveredEpub("Kept")) + runBlocking { + app.settings.updateApp { it.copy(deviceScan = true) } + app.library.scanAll() + } + assertTrue("kept.epub" in names()) + denyAllFiles() + runBlocking { app.library.scanAll() } + assertFalse("a book that can't be opened without access isn't offered", "kept.epub" in names()) + grantAllFiles() + runBlocking { app.library.scanAll() } + assertTrue("it comes back with access", "kept.epub" in names()) + } + + @Test + fun anUnreadableLastBookIsNotReopenedAtStart() { + val file = File(root, "Novels/last.epub") + val id = runBlocking { app.database.books().insert(com.readarea.data.db.BookEntity(uri = android.net.Uri.fromFile(file).toString(), fileName = file.name, format = "EPUB", title = "Last")) } + runBlocking { app.settings.updateApp { it.copy(reopenLastBook = true, resumeBookId = id) } } + assertEquals("the library opens instead of an error", null, runBlocking { app.library.resumeTarget() }) + put("Novels/last.epub", coveredEpub("Last")) + assertEquals(id, runBlocking { app.library.resumeTarget() }) + } } diff --git a/core/src/main/kotlin/com/readarea/core/format/TextDecoder.kt b/core/src/main/kotlin/com/readarea/core/format/TextDecoder.kt index 8255ac7..03e1a5d 100644 --- a/core/src/main/kotlin/com/readarea/core/format/TextDecoder.kt +++ b/core/src/main/kotlin/com/readarea/core/format/TextDecoder.kt @@ -144,7 +144,8 @@ class FileZipAccess(file: File) : ZipAccess { } } -class MemoryZipAccess(input: java.io.InputStream, keep: (String, Long) -> Boolean) : ZipAccess { +/** Reads the entries of a zip stream that [keep] asks for, each up to [maxEntry] bytes, into memory. */ +class MemoryZipAccess(input: java.io.InputStream, private val maxEntry: Int = MAX_ENTRY, keep: (String, Long) -> Boolean) : ZipAccess { private val data = LinkedHashMap() private val index = HashMap() override val entries: List @@ -167,9 +168,9 @@ class MemoryZipAccess(input: java.io.InputStream, keep: (String, Long) -> Boolea size += n total += n if (total > Limits.ARCHIVE) throw BookParseException(ParseError.TOO_LARGE, "Archive expands beyond the limit") - if (size <= MAX_ENTRY) out.write(buf, 0, n) + if (size <= maxEntry) out.write(buf, 0, n) } - if (size <= MAX_ENTRY) { + if (size <= maxEntry) { data[e.name] = out.toByteArray() index[e.name.lowercase()] = e.name }