Remove uses of File#listFiles()

pull/449/head
Isira Seneviratne 3 years ago
parent df05211561
commit 9f7ec888a6

@ -1,7 +1,8 @@
package org.koitharu.kotatsu.bookmarks.domain package org.koitharu.kotatsu.bookmarks.domain
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.local.data.ImageFileFilter import org.koitharu.kotatsu.local.data.hasImageExtension
import org.koitharu.kotatsu.local.data.isImageExtension
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import java.util.Date import java.util.Date
@ -17,11 +18,8 @@ data class Bookmark(
val percent: Float, val percent: Float,
) : ListModel { ) : ListModel {
val directImageUrl: String?
get() = if (isImageUrlDirect()) imageUrl else null
val imageLoadData: Any val imageLoadData: Any
get() = if (isImageUrlDirect()) imageUrl else toMangaPage() get() = if (hasImageExtension(imageUrl)) imageUrl else toMangaPage()
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {
return other is Bookmark && return other is Bookmark &&
@ -36,9 +34,4 @@ data class Bookmark(
preview = null, preview = null,
source = manga.source, source = manga.source,
) )
private fun isImageUrlDirect(): Boolean {
val extension = imageUrl.substringAfterLast('.')
return extension.isNotEmpty() && ImageFileFilter().isExtensionValid(extension)
}
} }

@ -7,14 +7,11 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.annotation.WorkerThread
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import java.io.File import java.io.File
import java.io.FileFilter
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
@ -46,8 +43,6 @@ fun File.getStorageName(context: Context): String = runCatching {
} }
}.getOrNull() ?: context.getString(R.string.other_storage) }.getOrNull() ?: context.getString(R.string.other_storage)
fun Uri.toFileOrNull() = if (scheme == "file") path?.let(::File) else null
suspend fun File.deleteAwait() = withContext(Dispatchers.IO) { suspend fun File.deleteAwait() = withContext(Dispatchers.IO) {
delete() || deleteRecursively() delete() || deleteRecursively()
} }
@ -66,33 +61,3 @@ fun ContentResolver.resolveName(uri: Uri): String? {
} }
return fallback return fallback
} }
suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) {
computeSizeInternal(this)
}
@WorkerThread
private fun computeSizeInternal(file: File): Long {
if (file.isDirectory) {
val files = file.listFiles() ?: return 0L
return files.sumOf { computeSizeInternal(it) }
} else {
return file.length()
}
}
fun File.listFilesRecursive(filter: FileFilter? = null): Sequence<File> = sequence {
listFilesRecursiveImpl(this@listFilesRecursive, filter)
}
private suspend fun SequenceScope<File>.listFilesRecursiveImpl(root: File, filter: FileFilter?) {
val ss = root.list() ?: return
for (s in ss) {
val f = File(root, s)
if (f.isDirectory) {
listFilesRecursiveImpl(f, filter)
} else if (filter == null || filter.accept(f)) {
yield(f)
}
}
}

@ -0,0 +1,18 @@
package org.koitharu.kotatsu.core.util.ext
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import java.nio.file.Path
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.Path
import kotlin.io.path.fileSize
import kotlin.io.path.walk
@OptIn(ExperimentalPathApi::class)
suspend fun Path.computeSize(): Long = runInterruptible(Dispatchers.IO) {
// Directories are not included by default
walk().sumOf { it.fileSize() }
}
fun Uri.toPathOrNull() = if (scheme == "file") path?.let { Path(it) } else null

@ -4,11 +4,15 @@ import androidx.annotation.WorkerThread
import androidx.collection.ArraySet import androidx.collection.ArraySet
import okio.Closeable import okio.Closeable
import java.io.File import java.io.File
import java.io.FileInputStream import java.nio.file.Path
import java.util.zip.Deflater import java.util.zip.Deflater
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
import kotlin.io.path.forEachDirectoryEntry
import kotlin.io.path.inputStream
import kotlin.io.path.isDirectory
import kotlin.io.path.name
class ZipOutput( class ZipOutput(
val file: File, val file: File,
@ -23,7 +27,7 @@ class ZipOutput(
@WorkerThread @WorkerThread
fun put(name: String, file: File): Boolean { fun put(name: String, file: File): Boolean {
return output.appendFile(file, name) return output.appendFile(file.toPath(), name)
} }
@WorkerThread @WorkerThread
@ -78,8 +82,8 @@ class ZipOutput(
} }
@WorkerThread @WorkerThread
private fun ZipOutputStream.appendFile(fileToZip: File, name: String): Boolean { private fun ZipOutputStream.appendFile(fileToZip: Path, name: String): Boolean {
if (fileToZip.isDirectory) { if (fileToZip.isDirectory()) {
val entry = if (name.endsWith("/")) { val entry = if (name.endsWith("/")) {
ZipEntry(name) ZipEntry(name)
} else { } else {
@ -90,11 +94,11 @@ class ZipOutput(
} }
putNextEntry(entry) putNextEntry(entry)
closeEntry() closeEntry()
fileToZip.listFiles()?.forEach { childFile -> fileToZip.forEachDirectoryEntry {
appendFile(childFile, "$name/${childFile.name}") appendFile(it, "$name/${it.name}")
} }
} else { } else {
FileInputStream(fileToZip).use { fis -> fileToZip.inputStream().use { fis ->
if (!entryNames.add(name)) { if (!entryNames.add(name)) {
return false return false
} }

@ -44,7 +44,7 @@ import org.koitharu.kotatsu.core.util.ext.combine
import org.koitharu.kotatsu.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.core.util.ext.toPathOrNull
import org.koitharu.kotatsu.details.domain.BranchComparator import org.koitharu.kotatsu.details.domain.BranchComparator
import org.koitharu.kotatsu.details.domain.DetailsInteractor import org.koitharu.kotatsu.details.domain.DetailsInteractor
import org.koitharu.kotatsu.details.domain.DoubleMangaLoadUseCase import org.koitharu.kotatsu.details.domain.DoubleMangaLoadUseCase
@ -135,13 +135,7 @@ class DetailsViewModel @Inject constructor(
val localSize = doubleManga val localSize = doubleManga
.map { .map {
val local = it?.local it?.local?.url?.toUri()?.toPathOrNull()?.computeSize() ?: 0L
if (local != null) {
val file = local.url.toUri().toFileOrNull()
file?.computeSize() ?: 0L
} else {
0L
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(), 0) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(), 0)
val description = manga val description = manga

@ -1,31 +1,13 @@
package org.koitharu.kotatsu.local.data package org.koitharu.kotatsu.local.data
import android.net.Uri import android.net.Uri
import java.io.File
import java.io.FileFilter
import java.io.FilenameFilter
import java.util.Locale
class CbzFilter : FileFilter, FilenameFilter { fun isCbzExtension(string: String): Boolean {
return string.equals("cbz", ignoreCase = true) || string.equals("zip", ignoreCase = true)
override fun accept(dir: File, name: String): Boolean { }
return isFileSupported(name)
}
override fun accept(pathname: File?): Boolean {
return isFileSupported(pathname?.name ?: return false)
}
companion object {
fun isFileSupported(name: String): Boolean {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
return ext == "cbz" || ext == "zip"
}
fun isUriSupported(uri: Uri): Boolean { fun hasCbzExtension(name: String): Boolean {
val scheme = uri.scheme?.lowercase(Locale.ROOT) return isCbzExtension(name.substringAfterLast('.', ""))
return scheme != null && scheme == "cbz" || scheme == "zip"
}
}
} }
fun hasCbzExtension(uri: Uri) = uri.scheme?.let { isCbzExtension(it) } ?: false

@ -1,29 +1,16 @@
package org.koitharu.kotatsu.local.data package org.koitharu.kotatsu.local.data
import java.io.File
import java.io.FileFilter
import java.io.FilenameFilter
import java.util.Locale
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
class ImageFileFilter : FilenameFilter, FileFilter { fun isImageExtension(string: String): Boolean {
return string.equals("png", ignoreCase = true) || string.equals("jpg", ignoreCase = true)
override fun accept(dir: File, name: String): Boolean { || string.equals("jpeg", ignoreCase = true) || string.equals("webp", ignoreCase = true)
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) }
return isExtensionValid(ext)
}
override fun accept(pathname: File?): Boolean {
val ext = pathname?.extension?.lowercase(Locale.ROOT) ?: return false
return isExtensionValid(ext)
}
fun accept(entry: ZipEntry): Boolean { fun hasImageExtension(name: String): Boolean {
val ext = entry.name.substringAfterLast('.', "").lowercase(Locale.ROOT) return isImageExtension(name.substringAfterLast('.', ""))
return isExtensionValid(ext) }
}
fun isExtensionValid(ext: String): Boolean { fun hasImageExtension(entry: ZipEntry): Boolean {
return ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "webp" return hasImageExtension(entry.name)
}
} }

@ -14,6 +14,7 @@ import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.CompositeMutex import org.koitharu.kotatsu.core.util.CompositeMutex
import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
@ -32,6 +33,9 @@ import java.io.File
import java.util.EnumSet import java.util.EnumSet
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.io.path.deleteExisting
import kotlin.io.path.forEachDirectoryEntry
import kotlin.io.path.useDirectoryEntries
private const val MAX_PARALLELISM = 4 private const val MAX_PARALLELISM = 4
@ -57,10 +61,7 @@ class LocalMangaRepository @Inject constructor(
if (offset > 0) { if (offset > 0) {
return emptyList() return emptyList()
} }
val list = getRawList() val list = getRawList().filter { query.isEmpty() || it.isMatchesQuery(query) }
if (query.isNotEmpty()) {
list.retainAll { x -> x.isMatchesQuery(query) }
}
return list.unwrap() return list.unwrap()
} }
@ -69,18 +70,17 @@ class LocalMangaRepository @Inject constructor(
return emptyList() return emptyList()
} }
val list = getRawList() val list = getRawList()
if (!tags.isNullOrEmpty()) { .filter { tags.isNullOrEmpty() || it.containsTags(tags) }
list.retainAll { x -> x.containsTags(tags) } .apply {
} when (sortOrder) {
when (sortOrder) { SortOrder.ALPHABETICAL -> sortedWith(compareBy(AlphanumComparator()) { x -> x.manga.title })
SortOrder.ALPHABETICAL -> list.sortWith(compareBy(org.koitharu.kotatsu.core.util.AlphanumComparator()) { x -> x.manga.title }) SortOrder.RATING -> sortedByDescending { it.manga.rating }
SortOrder.RATING -> list.sortByDescending { it.manga.rating } SortOrder.NEWEST, SortOrder.UPDATED -> sortedByDescending { it.createdAt }
SortOrder.NEWEST, else -> {
SortOrder.UPDATED, }
-> list.sortByDescending { it.createdAt } }
}
else -> Unit
}
return list.unwrap() return list.unwrap()
} }
@ -127,21 +127,19 @@ class LocalMangaRepository @Inject constructor(
} }
suspend fun findSavedManga(remoteManga: Manga): LocalManga? { suspend fun findSavedManga(remoteManga: Manga): LocalManga? {
val files = getAllFiles()
if (files.isEmpty()) {
return null
}
return channelFlow { return channelFlow {
for (file in files) { storageManager.getReadableDirs().forEach { dir ->
launch { dir.forEachDirectoryEntry { entry ->
val mangaInput = LocalMangaInput.of(file) launch {
runCatchingCancellable { val mangaInput = LocalMangaInput.of(entry.toFile())
val mangaInfo = mangaInput.getMangaInfo() runCatchingCancellable {
if (mangaInfo != null && mangaInfo.id == remoteManga.id) { val mangaInfo = mangaInput.getMangaInfo()
send(mangaInput) if (mangaInfo != null && mangaInfo.id == remoteManga.id) {
send(mangaInput)
}
}.onFailure {
it.printStackTraceDebug()
} }
}.onFailure {
it.printStackTraceDebug()
} }
} }
} }
@ -171,10 +169,10 @@ class LocalMangaRepository @Inject constructor(
} }
val dirs = storageManager.getWriteableDirs() val dirs = storageManager.getWriteableDirs()
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
dirs.flatMap { dir -> dirs.forEach { dir ->
dir.listFiles(TempFileFilter())?.toList().orEmpty() dir.toPath().forEachDirectoryEntry("*.tmp") {
}.forEach { file -> it.deleteExisting()
file.deleteRecursively() }
} }
} }
return true return true
@ -188,20 +186,21 @@ class LocalMangaRepository @Inject constructor(
locks.unlock(id) locks.unlock(id)
} }
private suspend fun getRawList(): ArrayList<LocalManga> { private suspend fun getRawList(): List<LocalManga> {
val files = getAllFiles()
return coroutineScope { return coroutineScope {
val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM) val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
files.map { file -> storageManager.getReadableDirs().flatMap { dir ->
async(dispatcher) { dir.useDirectoryEntries { entries ->
runCatchingCancellable { LocalMangaInput.ofOrNull(file)?.getManga() }.getOrNull() entries.map {
async(dispatcher) {
runCatchingCancellable { LocalMangaInput.ofOrNull(it)?.getManga() }.getOrNull()
}
}.toList() // Using toList() allows the directory stream to be closed.
} }
}.awaitAll() }
}.filterNotNullTo(ArrayList(files.size)) .awaitAll()
} .filterNotNull()
}
private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir ->
dir.listFiles()?.toList().orEmpty()
} }
private fun Collection<LocalManga>.unwrap(): List<Manga> = map { it.manga } private fun Collection<LocalManga>.unwrap(): List<Manga> = map { it.manga }

@ -17,10 +17,11 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.getStorageName import org.koitharu.kotatsu.core.util.ext.getStorageName
import org.koitharu.kotatsu.core.util.ext.resolveFile import org.koitharu.kotatsu.core.util.ext.resolveFile
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import java.io.File import java.io.File
import java.nio.file.Path
import javax.inject.Inject import javax.inject.Inject
import kotlin.io.path.isReadable
private const val DIR_NAME = "manga" private const val DIR_NAME = "manga"
private const val NOMEDIA = ".nomedia" private const val NOMEDIA = ".nomedia"
@ -46,15 +47,15 @@ class LocalStorageManager @Inject constructor(
} }
suspend fun computeCacheSize(cache: CacheDir) = withContext(Dispatchers.IO) { suspend fun computeCacheSize(cache: CacheDir) = withContext(Dispatchers.IO) {
getCacheDirs(cache.dir).sumOf { it.computeSize() } getCacheDirs(cache.dir).sumOf { it.toPath().computeSize() }
} }
suspend fun computeCacheSize() = withContext(Dispatchers.IO) { suspend fun computeCacheSize() = withContext(Dispatchers.IO) {
getCacheDirs().sumOf { it.computeSize() } getCacheDirs().sumOf { it.toPath().computeSize() }
} }
suspend fun computeStorageSize() = withContext(Dispatchers.IO) { suspend fun computeStorageSize() = withContext(Dispatchers.IO) {
getAvailableStorageDirs().sumOf { it.computeSize() } getAvailableStorageDirs().sumOf { it.toPath().computeSize() }
} }
suspend fun computeAvailableSize() = runInterruptible(Dispatchers.IO) { suspend fun computeAvailableSize() = runInterruptible(Dispatchers.IO) {
@ -65,19 +66,20 @@ class LocalStorageManager @Inject constructor(
getCacheDirs(cache.dir).forEach { it.deleteRecursively() } getCacheDirs(cache.dir).forEach { it.deleteRecursively() }
} }
suspend fun getReadableDirs(): List<File> = runInterruptible(Dispatchers.IO) { suspend fun getReadableDirs(): List<Path> = runInterruptible(Dispatchers.IO) {
getConfiguredStorageDirs() getConfiguredStorageDirs()
.map { it.toPath() }
.filter { it.isReadable() } .filter { it.isReadable() }
} }
suspend fun getWriteableDirs(): List<File> = runInterruptible(Dispatchers.IO) { suspend fun getWriteableDirs(): List<File> = runInterruptible(Dispatchers.IO) {
getConfiguredStorageDirs() getConfiguredStorageDirs()
.filter { it.isWriteable() } .filter { it.isWritable() }
} }
suspend fun getDefaultWriteableDir(): File? = runInterruptible(Dispatchers.IO) { suspend fun getDefaultWriteableDir(): File? = runInterruptible(Dispatchers.IO) {
val preferredDir = settings.mangaStorageDir?.takeIf { it.isWriteable() } val preferredDir = settings.mangaStorageDir?.takeIf { it.isWritable() }
preferredDir ?: getFallbackStorageDir()?.takeIf { it.isWriteable() } preferredDir ?: getFallbackStorageDir()?.takeIf { it.isWritable() }
} }
suspend fun getApplicationStorageDirs(): Set<File> = runInterruptible(Dispatchers.IO) { suspend fun getApplicationStorageDirs(): Set<File> = runInterruptible(Dispatchers.IO) {
@ -146,7 +148,7 @@ class LocalStorageManager @Inject constructor(
} }
@WorkerThread @WorkerThread
private fun getCacheDirs(): MutableSet<File> { private fun getCacheDirs(): Set<File> {
val result = LinkedHashSet<File>() val result = LinkedHashSet<File>()
result += context.cacheDir result += context.cacheDir
context.externalCacheDirs.filterNotNullTo(result) context.externalCacheDirs.filterNotNullTo(result)
@ -163,11 +165,7 @@ class LocalStorageManager @Inject constructor(
} }
} }
private fun File.isReadable() = runCatching { private fun File.isWritable() = runCatching {
canRead()
}.getOrDefault(false)
private fun File.isWriteable() = runCatching {
canWrite() canWrite()
}.getOrDefault(false) }.getOrDefault(false)
} }

@ -4,6 +4,7 @@ import androidx.annotation.WorkerThread
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@ -126,8 +127,7 @@ class MangaIndex(source: String?) {
item.put("id", id) item.put("id", id)
list.add(item) list.add(item)
} }
val comparator = org.koitharu.kotatsu.core.util.AlphanumComparator() list.sortWith(compareBy(AlphanumComparator()) { it.getString("name") })
list.sortWith(compareBy(comparator) { it.getString("name") })
val newJo = JSONObject() val newJo = JSONObject()
list.forEachIndexed { i, obj -> list.forEachIndexed { i, obj ->
obj.put("number", i + 1) obj.put("number", i + 1)

@ -1,11 +0,0 @@
package org.koitharu.kotatsu.local.data
import java.io.File
import java.io.FilenameFilter
class TempFileFilter : FilenameFilter {
override fun accept(dir: File, name: String): Boolean {
return name.endsWith(".tmp", ignoreCase = true)
}
}

@ -15,9 +15,9 @@ import okio.source
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.util.ext.resolveName import org.koitharu.kotatsu.core.util.ext.resolveName
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.data.hasCbzExtension
import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import java.io.File import java.io.File
@ -46,7 +46,7 @@ class SingleMangaImporter @Inject constructor(
private suspend fun importFile(uri: Uri): LocalManga = withContext(Dispatchers.IO) { private suspend fun importFile(uri: Uri): LocalManga = withContext(Dispatchers.IO) {
val contentResolver = storageManager.contentResolver val contentResolver = storageManager.contentResolver
val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri") val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri")
if (!CbzFilter.isFileSupported(name)) { if (!hasCbzExtension(name)) {
throw UnsupportedFileException("Unsupported file on $uri") throw UnsupportedFileException("Unsupported file on $uri")
} }
val dest = File(getOutputDir(), name) val dest = File(getOutputDir(), name)

@ -4,12 +4,13 @@ import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.util.ext.listFilesRecursive import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.longHashCode import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.core.util.ext.toListSorted import org.koitharu.kotatsu.core.util.ext.toListSorted
import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.ImageFileFilter
import org.koitharu.kotatsu.local.data.MangaIndex import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.local.data.hasImageExtension
import org.koitharu.kotatsu.local.data.isCbzExtension
import org.koitharu.kotatsu.local.data.isImageExtension
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@ -18,7 +19,14 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toCamelCase import org.koitharu.kotatsu.parsers.util.toCamelCase
import java.io.File import java.io.File
import java.nio.file.Path
import java.util.zip.ZipFile import java.util.zip.ZipFile
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.extension
import kotlin.io.path.getLastModifiedTime
import kotlin.io.path.name
import kotlin.io.path.nameWithoutExtension
import kotlin.io.path.walk
/** /**
* Manga {Folder} * Manga {Folder}
@ -61,7 +69,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
name = f.nameWithoutExtension.toHumanReadable(), name = f.nameWithoutExtension.toHumanReadable(),
number = i + 1, number = i + 1,
source = MangaSource.LOCAL, source = MangaSource.LOCAL,
uploadDate = f.lastModified(), uploadDate = f.getLastModifiedTime().toMillis(),
url = f.toUri().toString(), url = f.toUri().toString(),
scanlator = null, scanlator = null,
branch = null, branch = null,
@ -84,11 +92,13 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
index?.getMangaInfo() index?.getMangaInfo()
} }
@OptIn(ExperimentalPathApi::class)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
val file = chapter.url.toUri().toFile() val file = chapter.url.toUri().toFile()
if (file.isDirectory) { if (file.isDirectory) {
file.listFilesRecursive(ImageFileFilter()) file.toPath().walk()
.toListSorted(compareBy(org.koitharu.kotatsu.core.util.AlphanumComparator()) { x -> x.name }) .filter { isImageExtension(it.extension) }
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
.map { .map {
val pageUri = it.toUri().toString() val pageUri = it.toUri().toString()
MangaPage( MangaPage(
@ -104,7 +114,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
.asSequence() .asSequence()
.filter { x -> !x.isDirectory } .filter { x -> !x.isDirectory }
.map { it.name } .map { it.name }
.toListSorted(org.koitharu.kotatsu.core.util.AlphanumComparator()) .toListSorted(AlphanumComparator())
.map { .map {
val pageUri = zipUri(file, it) val pageUri = zipUri(file, it)
MangaPage( MangaPage(
@ -120,20 +130,25 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
private fun String.toHumanReadable() = replace("_", " ").toCamelCase() private fun String.toHumanReadable() = replace("_", " ").toCamelCase()
private fun getChaptersFiles(): List<File> = root.listFilesRecursive(CbzFilter()) @OptIn(ExperimentalPathApi::class)
.toListSorted(compareBy(org.koitharu.kotatsu.core.util.AlphanumComparator()) { x -> x.name }) private fun getChaptersFiles(): List<Path> = root.toPath().walk()
.filter { isCbzExtension(it.extension) }
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
@OptIn(ExperimentalPathApi::class)
private fun findFirstImageEntry(): String? { private fun findFirstImageEntry(): String? {
val filter = ImageFileFilter() val rootPath = root.toPath()
root.listFilesRecursive(filter).firstOrNull()?.let { return rootPath.walk()
return it.toUri().toString() .filter { isImageExtension(it.extension) }
} .firstOrNull()?.toUri()?.toString()
val cbz = root.listFilesRecursive(CbzFilter()).firstOrNull() ?: return null ?: run {
return ZipFile(cbz).use { zip -> val cbz = rootPath.walk().filter { isCbzExtension(it.extension) }.firstOrNull()?.toFile() ?: return null
zip.entries().asSequence() return ZipFile(cbz).use { zip ->
.firstOrNull { x -> !x.isDirectory && filter.accept(x) } zip.entries().asSequence()
?.let { entry -> zipUri(cbz, entry.name) } .firstOrNull { x -> !x.isDirectory && hasImageExtension(x) }
} ?.let { entry -> zipUri(cbz, entry.name) }
}
}
} }
private fun fileUri(base: File, name: String): String { private fun fileUri(base: File, name: String): String {

@ -2,13 +2,16 @@ package org.koitharu.kotatsu.local.data.input
import android.net.Uri import android.net.Uri
import androidx.core.net.toFile import androidx.core.net.toFile
import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.isCbzExtension
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import java.io.File import java.io.File
import java.nio.file.Path
import kotlin.io.path.extension
import kotlin.io.path.isDirectory
sealed class LocalMangaInput( sealed class LocalMangaInput(
protected val root: File, protected val root: File,
@ -31,9 +34,9 @@ sealed class LocalMangaInput(
else -> LocalMangaZipInput(file) else -> LocalMangaZipInput(file)
} }
fun ofOrNull(file: File): LocalMangaInput? = when { fun ofOrNull(path: Path): LocalMangaInput? = when {
file.isDirectory -> LocalMangaDirInput(file) path.isDirectory() -> LocalMangaDirInput(path.toFile())
CbzFilter.isFileSupported(file.name) -> LocalMangaZipInput(file) isCbzExtension(path.extension) -> LocalMangaZipInput(path.toFile())
else -> null else -> null
} }

@ -7,6 +7,7 @@ import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.longHashCode import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.core.util.ext.readText import org.koitharu.kotatsu.core.util.ext.readText
import org.koitharu.kotatsu.core.util.ext.toListSorted import org.koitharu.kotatsu.core.util.ext.toListSorted
@ -70,7 +71,7 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
publicUrl = fileUri, publicUrl = fileUri,
source = MangaSource.LOCAL, source = MangaSource.LOCAL,
coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()), coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()),
chapters = chapters.sortedWith(org.koitharu.kotatsu.core.util.AlphanumComparator()) chapters = chapters.sortedWith(AlphanumComparator())
.mapIndexed { i, s -> .mapIndexed { i, s ->
MangaChapter( MangaChapter(
id = "$i$s".longHashCode(), id = "$i$s".longHashCode(),
@ -125,7 +126,7 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
} }
} }
entries entries
.toListSorted(compareBy(org.koitharu.kotatsu.core.util.AlphanumComparator()) { x -> x.name }) .toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
.map { x -> .map { x ->
val entryUri = zipUri(file, x.name) val entryUri = zipUri(file, x.name)
MangaPage( MangaPage(
@ -141,7 +142,7 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
private fun findFirstImageEntry(entries: Enumeration<out ZipEntry>): ZipEntry? { private fun findFirstImageEntry(entries: Enumeration<out ZipEntry>): ZipEntry? {
val list = entries.toList() val list = entries.toList()
.filterNot { it.isDirectory } .filterNot { it.isDirectory }
.sortedWith(compareBy(org.koitharu.kotatsu.core.util.AlphanumComparator()) { x -> x.name }) .sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
val map = MimeTypeMap.getSingleton() val map = MimeTypeMap.getSingleton()
return list.firstOrNull { return list.firstOrNull {
map.getMimeTypeFromExtension(it.name.substringAfterLast('.')) map.getMimeTypeFromExtension(it.name.substringAfterLast('.'))

@ -41,8 +41,8 @@ import org.koitharu.kotatsu.core.util.ext.ramAvailable
import org.koitharu.kotatsu.core.util.ext.withProgress import org.koitharu.kotatsu.core.util.ext.withProgress
import org.koitharu.kotatsu.core.util.progress.ProgressDeferred import org.koitharu.kotatsu.core.util.progress.ProgressDeferred
import org.koitharu.kotatsu.core.zip.ZipPool import org.koitharu.kotatsu.core.zip.ZipPool
import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.hasCbzExtension
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
@ -198,7 +198,7 @@ class PageLoader @Inject constructor(
val pageUrl = getPageUrl(page) val pageUrl = getPageUrl(page)
check(pageUrl.isNotBlank()) { "Cannot obtain full image url" } check(pageUrl.isNotBlank()) { "Cannot obtain full image url" }
val uri = Uri.parse(pageUrl) val uri = Uri.parse(pageUrl)
return if (CbzFilter.isUriSupported(uri)) { return if (hasCbzExtension(uri)) {
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
zipPool[uri] zipPool[uri]
}.use { }.use {

@ -19,8 +19,8 @@ import okio.source
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.hasCbzExtension
import org.koitharu.kotatsu.local.data.util.withExtraCloseable import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.mimeType import org.koitharu.kotatsu.parsers.util.mimeType
@ -56,7 +56,7 @@ class MangaPageFetcher(
private suspend fun loadPage(pageUrl: String): SourceResult { private suspend fun loadPage(pageUrl: String): SourceResult {
val uri = pageUrl.toUri() val uri = pageUrl.toUri()
return if (CbzFilter.isUriSupported(uri)) { return if (hasCbzExtension(uri)) {
val zip = runInterruptible(Dispatchers.IO) { ZipFile(uri.schemeSpecificPart) } val zip = runInterruptible(Dispatchers.IO) { ZipFile(uri.schemeSpecificPart) }
val entry = runInterruptible(Dispatchers.IO) { zip.getEntry(uri.fragment) } val entry = runInterruptible(Dispatchers.IO) { zip.getEntry(uri.fragment) }
return SourceResult( return SourceResult(

Loading…
Cancel
Save