diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6a678bedc2..d3f2d38580 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -192,8 +192,6 @@ dependencies { // Disk implementation(libs.disklrucache) - implementation(libs.unifile) - implementation(libs.bundles.archive) // HTML parser implementation(libs.jsoup) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 00cf2d61bd..432fc49a4c 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -98,9 +98,6 @@ -keep public enum nl.adaptivity.xmlutil.EventType { *; } ##---------------End: proguard configuration for kotlinx.serialization ---------- -# Apache Commons Compress --keep class * extends org.apache.commons.compress.archivers.zip.ZipExtraField { (); } - # Firebase -keep class com.google.firebase.installations.** { *; } -keep interface com.google.firebase.installations.** { *; } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt index a5f5fec7e6..01af0cc1e6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt @@ -47,6 +47,7 @@ import rx.Subscription import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers import uy.kohesive.injekt.injectLazy +import yokai.core.archive.ZipWriter import yokai.core.metadata.COMIC_INFO_FILE import yokai.core.metadata.ComicInfo import yokai.core.metadata.getComicInfo @@ -628,25 +629,9 @@ class Downloader( tmpDir: UniFile, ) { val zip = mangaDir.createFile("$dirname.cbz$TMP_DIR_SUFFIX") ?: return - ZipOutputStream(BufferedOutputStream(zip.openOutputStream())).use { zipOut -> - zipOut.setMethod(ZipEntry.STORED) - - tmpDir.listFiles()?.forEach { img -> - img.openInputStream().use { input -> - val data = input.readBytes() - val size = img.length() - val entry = ZipEntry(img.name).apply { - val crc = CRC32().apply { - update(data) - } - setCrc(crc.value) - - compressedSize = size - setSize(size) - } - zipOut.putNextEntry(entry) - zipOut.write(data) - } + ZipWriter(context, zip).use { writer -> + tmpDir.listFiles()?.forEach { file -> + writer.write(file) } } zip.renameTo("$dirname.cbz") diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt index 5ac2eaf88e..42e6d9e610 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt @@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.source import android.content.Context import androidx.core.net.toFile import co.touchlab.kermit.Logger -import com.github.junrar.Archive import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.R import yokai.i18n.MR @@ -17,12 +16,12 @@ import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.util.chapter.ChapterRecognition import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.storage.EpubFile +import eu.kanade.tachiyomi.util.storage.fillChapterMetadata +import eu.kanade.tachiyomi.util.storage.fillMangaMetadata import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.e import eu.kanade.tachiyomi.util.system.extension import eu.kanade.tachiyomi.util.system.nameWithoutExtension -import eu.kanade.tachiyomi.util.system.openReadOnlyChannel -import eu.kanade.tachiyomi.util.system.toZipFile import eu.kanade.tachiyomi.util.system.withIOContext import eu.kanade.tachiyomi.util.system.writeText import kotlinx.coroutines.async @@ -35,6 +34,7 @@ import nl.adaptivity.xmlutil.serialization.XML import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy +import yokai.core.archive.archiveReader import yokai.core.metadata.COMIC_INFO_FILE import yokai.core.metadata.ComicInfo import yokai.core.metadata.copyFromComicInfo @@ -187,7 +187,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour val chapter = chapters.last() val format = getFormat(chapter) if (format is Format.Epub) { - EpubFile(format.file.openReadOnlyChannel(context)).use { epub -> + EpubFile(format.file.archiveReader(context)).use { epub -> epub.fillMangaMetadata(manga) } } @@ -338,10 +338,10 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour } private fun getFormat(file: UniFile) = with(file) { + val supportedArchives = listOf("zip", "cbz", "rar", "cbr", "7z", "cb7", "tar", "cbt") when { isDirectory -> Format.Directory(this) - extension.equals("zip", true) || extension.equals("cbz", true) -> Format.Zip(this) - extension.equals("rar", true) || extension.equals("cbr", true) -> Format.Rar(this) + supportedArchives.contains(extension?.lowercase()) -> Format.Archive(this) extension.equals("epub", true) -> Format.Epub(this) else -> throw Exception(context.getString(MR.strings.local_invalid_format)) } @@ -357,31 +357,22 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour entry?.let { updateCover(manga, it.openInputStream()) } } - is Format.Zip -> { - format.file.openReadOnlyChannel(context).toZipFile().use { zip -> - val entry = zip.entries.toList() - .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } - .find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } + is Format.Archive -> { + format.file.archiveReader(context).use { reader -> + val entry = reader.useEntries { entries -> + entries.toList() + .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } + .find { it.isFile && ImageUtil.isImage(it.name) { reader.getInputStream(it.name)!! } } + } - entry?.let { updateCover(manga, zip.getInputStream(it)) } - } - } - is Format.Rar -> { - Archive(format.file.openInputStream()).use { archive -> - val entry = archive.fileHeaders - .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } - .find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } } - - entry?.let { updateCover(manga, archive.getInputStream(it)) } + entry?.let { updateCover(manga, reader.getInputStream(it.name)!!) } } } is Format.Epub -> { - EpubFile(format.file.openReadOnlyChannel(context)).use { epub -> - val entry = epub.getImagesFromPages() - .firstOrNull() - ?.let { epub.getEntry(it) } + EpubFile(format.file.archiveReader(context)).use { epub -> + val entry = epub.getImagesFromPages().firstOrNull() - entry?.let { updateCover(manga, epub.getInputStream(it)) } + entry?.let { updateCover(manga, epub.getInputStream(it)!!) } } } } @@ -401,25 +392,19 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour true } is Format.Epub -> { - EpubFile(format.file.openReadOnlyChannel(context)).use { epub -> + EpubFile(format.file.archiveReader(context)).use { epub -> epub.fillChapterMetadata(chapter) } true } - is Format.Rar -> Archive(format.file.openInputStream()).use { archive -> - val entry = archive.fileHeaders - .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } - .find { !it.isDirectory && it.fileName == COMIC_INFO_FILE } ?: return false + is Format.Archive -> format.file.archiveReader(context).use { reader -> + val entry = reader.useEntries { entries -> + entries.toList() + .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } + .find { it.isFile && it.name == COMIC_INFO_FILE } + } ?: return false - updateMetadata(chapter, manga, archive.getInputStream(entry)) - true - } - is Format.Zip -> format.file.openReadOnlyChannel(context).toZipFile().use { zip -> - val entry = zip.entries.toList() - .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } - .find { !it.isDirectory && it.name == COMIC_INFO_FILE } ?: return false - - updateMetadata(chapter, manga, zip.getInputStream(entry)) + updateMetadata(chapter, manga, reader.getInputStream(entry.name)!!) true } } @@ -442,8 +427,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour sealed class Format { data class Directory(val file: UniFile) : Format() - data class Zip(val file: UniFile) : Format() - data class Rar(val file: UniFile) : Format() + data class Archive(val file: UniFile) : Format() data class Epub(val file: UniFile) : Format() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ArchivePageLoader.kt similarity index 52% rename from app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ArchivePageLoader.kt index 2459c6e59d..67689cb60e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ArchivePageLoader.kt @@ -4,41 +4,35 @@ import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.system.ImageUtil -import eu.kanade.tachiyomi.util.system.toZipFile -import java.nio.channels.SeekableByteChannel +import yokai.core.archive.ArchiveReader /** * Loader used to load a chapter from a .zip or .cbz file. */ -class ZipPageLoader(channel: SeekableByteChannel) : PageLoader() { - - /** - * The zip file to load pages from. - */ - private val zip = channel.toZipFile() +internal class ArchivePageLoader(private val reader: ArchiveReader) : PageLoader() { /** * Recycles this loader and the open zip. */ override fun recycle() { super.recycle() - zip.close() + reader.close() } /** * Returns the pages found on this zip archive ordered with a natural comparator. */ override suspend fun getPages(): List { - return zip.entries.asSequence() - .filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } - .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } - .mapIndexed { i, entry -> - ReaderPage(i).apply { - stream = { zip.getInputStream(entry) } - status = Page.State.READY + return reader.useEntries { entries -> + entries.filter { it.isFile && ImageUtil.isImage(it.name) { reader.getInputStream(it.name)!! } } + .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } + .mapIndexed { i, entry -> + ReaderPage(i).apply { + stream = { reader.getInputStream(entry.name)!! } + status = Page.State.READY + } } - } - .toList() + }.toList() } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt index 944258916c..009bb5ce1c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt @@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.ui.reader.loader import android.content.Context import co.touchlab.kermit.Logger -import com.github.junrar.exception.UnsupportedRarV5Exception import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadProvider @@ -10,8 +9,8 @@ import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter -import eu.kanade.tachiyomi.util.system.openReadOnlyChannel import eu.kanade.tachiyomi.util.system.withIOContext +import yokai.core.archive.archiveReader import yokai.i18n.MR import yokai.util.lang.getString @@ -82,13 +81,8 @@ class ChapterLoader( source is LocalSource -> source.getFormat(chapter.chapter).let { format -> when (format) { is LocalSource.Format.Directory -> DirectoryPageLoader(format.file) - is LocalSource.Format.Zip -> ZipPageLoader(format.file.openReadOnlyChannel(context)) - is LocalSource.Format.Rar -> try { - RarPageLoader(format.file.openInputStream()) - } catch (e: UnsupportedRarV5Exception) { - error(context.getString(MR.strings.loader_rar5_error)) - } - is LocalSource.Format.Epub -> EpubPageLoader(format.file.openReadOnlyChannel(context)) + is LocalSource.Format.Archive -> ArchivePageLoader(format.file.archiveReader(context)) + is LocalSource.Format.Epub -> EpubPageLoader(format.file.archiveReader(context)) } } else -> error(context.getString(MR.strings.source_not_installed)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt index 91ba2cb0c1..418cba9e4b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt @@ -10,10 +10,8 @@ import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage -import eu.kanade.tachiyomi.util.system.openReadOnlyChannel -import eu.kanade.tachiyomi.util.system.toTempFile import uy.kohesive.injekt.injectLazy -import java.io.File +import yokai.core.archive.archiveReader /** * Loader used to load a chapter from the downloaded chapters. @@ -29,11 +27,11 @@ class DownloadPageLoader( // Needed to open input streams private val context: Application by injectLazy() - private var zipPageLoader: ZipPageLoader? = null + private var archivePageLoader: ArchivePageLoader? = null override fun recycle() { super.recycle() - zipPageLoader?.recycle() + archivePageLoader?.recycle() } /** @@ -50,7 +48,7 @@ class DownloadPageLoader( } private suspend fun getPagesFromArchive(chapterPath: UniFile): List { - val loader = ZipPageLoader(chapterPath.openReadOnlyChannel(context)).also { zipPageLoader = it } + val loader = ArchivePageLoader(chapterPath.archiveReader(context)).also { archivePageLoader = it } return loader.getPages() } @@ -66,6 +64,6 @@ class DownloadPageLoader( } override suspend fun loadPage(page: ReaderPage) { - zipPageLoader?.loadPage(page) + archivePageLoader?.loadPage(page) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt index de45da9e87..2f8331c3db 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt @@ -3,17 +3,18 @@ package eu.kanade.tachiyomi.ui.reader.loader import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.util.storage.EpubFile +import yokai.core.archive.ArchiveReader import java.nio.channels.SeekableByteChannel /** * Loader used to load a chapter from a .epub file. */ -class EpubPageLoader(channel: SeekableByteChannel) : PageLoader() { +class EpubPageLoader(reader: ArchiveReader) : PageLoader() { /** * The epub file. */ - private val epub = EpubFile(channel) + private val epub = EpubFile(reader) /** * Recycles this loader and the open zip. @@ -29,7 +30,7 @@ class EpubPageLoader(channel: SeekableByteChannel) : PageLoader() { override suspend fun getPages(): List { return epub.getImagesFromPages() .mapIndexed { i, path -> - val streamFn = { epub.getInputStream(epub.getEntry(path)!!) } + val streamFn = { epub.getInputStream(path)!! } ReaderPage(i).apply { stream = streamFn status = Page.State.READY diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt deleted file mode 100644 index 7fa553bbe5..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt +++ /dev/null @@ -1,79 +0,0 @@ -package eu.kanade.tachiyomi.ui.reader.loader - -import com.github.junrar.Archive -import com.github.junrar.rarfile.FileHeader -import eu.kanade.tachiyomi.source.model.Page -import eu.kanade.tachiyomi.ui.reader.model.ReaderPage -import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder -import eu.kanade.tachiyomi.util.system.ImageUtil -import java.io.File -import java.io.InputStream -import java.io.PipedInputStream -import java.io.PipedOutputStream -import java.util.concurrent.Executors - -/** - * Loader used to load a chapter from a .rar or .cbr file. - */ -class RarPageLoader(inputStream: InputStream) : PageLoader() { - - /** - * The rar archive to load pages from. - */ - private val archive = Archive(inputStream) - - /** - * Pool for copying compressed files to an input stream. - */ - private val pool = Executors.newFixedThreadPool(1) - - /** - * Recycles this loader and the open archive. - */ - override fun recycle() { - super.recycle() - archive.close() - pool.shutdown() - } - - /** - * Returns an RxJava Single containing the pages found on this rar archive ordered with a natural - * comparator. - */ - override suspend fun getPages(): List { - return archive.fileHeaders.asSequence() - .filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } } - .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } - .mapIndexed { i, header -> - ReaderPage(i).apply { - stream = { getStream(header) } - status = Page.State.READY - } - } - .toList() - } - - /** - * No additional action required to load the page - */ - override suspend fun loadPage(page: ReaderPage) { - check(!isRecycled) - } - - /** - * Returns an input stream for the given [header]. - */ - private fun getStream(header: FileHeader): InputStream { - val pipeIn = PipedInputStream() - val pipeOut = PipedOutputStream(pipeIn) - pool.execute { - try { - pipeOut.use { - archive.extractFile(header, it) - } - } catch (e: Exception) { - } - } - return pipeIn - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt b/app/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt index 75e572627e..ff256aa6da 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt @@ -2,216 +2,58 @@ package eu.kanade.tachiyomi.util.storage import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga -import eu.kanade.tachiyomi.util.system.toZipFile -import org.apache.commons.compress.archivers.zip.ZipArchiveEntry -import org.apache.commons.compress.archivers.zip.ZipFile -import org.jsoup.Jsoup -import org.jsoup.nodes.Document -import java.io.Closeable -import java.io.File -import java.io.InputStream -import java.nio.channels.SeekableByteChannel import java.text.ParseException import java.text.SimpleDateFormat -import java.util.Locale +import java.util.* /** - * Wrapper over ZipFile to load files in epub format. + * Fills manga metadata using this epub file's metadata. */ -class EpubFile(channel: SeekableByteChannel) : Closeable { +fun EpubFile.fillMangaMetadata(manga: SManga) { + val ref = getPackageHref() + val doc = getPackageDocument(ref) - /** - * Zip file of this epub. - */ - private val zip = channel.toZipFile() + val creator = doc.getElementsByTag("dc:creator").first() + val description = doc.getElementsByTag("dc:description").first() - /** - * Path separator used by this epub. - */ - private val pathSeparator = getPathSeparator() + manga.author = creator?.text() + manga.description = description?.text() +} - /** - * Closes the underlying zip file. - */ - override fun close() { - zip.close() +/** + * Fills chapter metadata using this epub file's metadata. + */ +fun EpubFile.fillChapterMetadata(chapter: SChapter) { + val ref = getPackageHref() + val doc = getPackageDocument(ref) + + val title = doc.getElementsByTag("dc:title").first() + val publisher = doc.getElementsByTag("dc:publisher").first() + val creator = doc.getElementsByTag("dc:creator").first() + var date = doc.getElementsByTag("dc:date").first() + if (date == null) { + date = doc.select("meta[property=dcterms:modified]").first() } - /** - * Returns an input stream for reading the contents of the specified zip file entry. - */ - fun getInputStream(entry: ZipArchiveEntry): InputStream { - return zip.getInputStream(entry) + if (title != null) { + chapter.name = title.text() } - /** - * Returns the zip file entry for the specified name, or null if not found. - */ - fun getEntry(name: String): ZipArchiveEntry? { - return zip.getEntry(name) + if (publisher != null) { + chapter.scanlator = publisher.text() + } else if (creator != null) { + chapter.scanlator = creator.text() } - /** - * Fills manga metadata using this epub file's metadata. - */ - fun fillMangaMetadata(manga: SManga) { - val ref = getPackageHref() - val doc = getPackageDocument(ref) - - val creator = doc.getElementsByTag("dc:creator").first() - val description = doc.getElementsByTag("dc:description").first() - - manga.author = creator?.text() - manga.description = description?.text() - } - - /** - * Fills chapter metadata using this epub file's metadata. - */ - fun fillChapterMetadata(chapter: SChapter) { - val ref = getPackageHref() - val doc = getPackageDocument(ref) - - val title = doc.getElementsByTag("dc:title").first() - val publisher = doc.getElementsByTag("dc:publisher").first() - val creator = doc.getElementsByTag("dc:creator").first() - var date = doc.getElementsByTag("dc:date").first() - if (date == null) { - date = doc.select("meta[property=dcterms:modified]").first() - } - - if (title != null) { - chapter.name = title.text() - } - - if (publisher != null) { - chapter.scanlator = publisher.text() - } else if (creator != null) { - chapter.scanlator = creator.text() - } - - if (date != null) { - val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()) - try { - val parsedDate = dateFormat.parse(date.text()) - if (parsedDate != null) { - chapter.date_upload = parsedDate.time - } - } catch (e: ParseException) { - // Empty + if (date != null) { + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()) + try { + val parsedDate = dateFormat.parse(date.text()) + if (parsedDate != null) { + chapter.date_upload = parsedDate.time } - } - } - - /** - * Returns the path of all the images found in the epub file. - */ - fun getImagesFromPages(): List { - val ref = getPackageHref() - val doc = getPackageDocument(ref) - val pages = getPagesFromDocument(doc) - return getImagesFromPages(pages, ref) - } - - /** - * Returns the path to the package document. - */ - private fun getPackageHref(): String { - val meta = zip.getEntry(resolveZipPath("META-INF", "container.xml")) - if (meta != null) { - val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") } - val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path") - if (path != null) { - return path - } - } - return resolveZipPath("OEBPS", "content.opf") - } - - /** - * Returns the package document where all the files are listed. - */ - private fun getPackageDocument(ref: String): Document { - val entry = zip.getEntry(ref) - return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") } - } - - /** - * Returns all the pages from the epub. - */ - private fun getPagesFromDocument(document: Document): List { - val pages = document.select("manifest > item") - .filter { node -> "application/xhtml+xml" == node.attr("media-type") } - .associateBy { it.attr("id") } - - val spine = document.select("spine > itemref").map { it.attr("idref") } - return spine.mapNotNull { pages[it] }.map { it.attr("href") } - } - - /** - * Returns all the images contained in every page from the epub. - */ - private fun getImagesFromPages(pages: List, packageHref: String): List { - val result = mutableListOf() - val basePath = getParentDirectory(packageHref) - pages.forEach { page -> - val entryPath = resolveZipPath(basePath, page) - val entry = zip.getEntry(entryPath) - val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") } - val imageBasePath = getParentDirectory(entryPath) - - document.allElements.forEach { - if (it.tagName() == "img") { - result.add(resolveZipPath(imageBasePath, it.attr("src"))) - } else if (it.tagName() == "image") { - result.add(resolveZipPath(imageBasePath, it.attr("xlink:href"))) - } - } - } - - return result - } - - /** - * Returns the path separator used by the epub file. - */ - private fun getPathSeparator(): String { - val meta = zip.getEntry("META-INF\\container.xml") - return if (meta != null) { - "\\" - } else { - "/" - } - } - - /** - * Resolves a zip path from base and relative components and a path separator. - */ - private fun resolveZipPath(basePath: String, relativePath: String): String { - if (relativePath.startsWith(pathSeparator)) { - // Path is absolute, so return as-is. - return relativePath - } - - var fixedBasePath = basePath.replace(pathSeparator, File.separator) - if (!fixedBasePath.startsWith(File.separator)) { - fixedBasePath = "${File.separator}$fixedBasePath" - } - - val fixedRelativePath = relativePath.replace(pathSeparator, File.separator) - val resolvedPath = File(fixedBasePath, fixedRelativePath).canonicalPath - return resolvedPath.replace(File.separator, pathSeparator).substring(1) - } - - /** - * Gets the parent directory of a path. - */ - private fun getParentDirectory(path: String): String { - val separatorIndex = path.lastIndexOf(pathSeparator) - return if (separatorIndex >= 0) { - path.substring(0, separatorIndex) - } else { - "" + } catch (e: ParseException) { + // Empty } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/SeekableByteChannel.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/SeekableByteChannel.kt deleted file mode 100644 index ab2f8757fc..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/SeekableByteChannel.kt +++ /dev/null @@ -1,8 +0,0 @@ -package eu.kanade.tachiyomi.util.system - -import org.apache.commons.compress.archivers.zip.ZipFile -import java.nio.channels.SeekableByteChannel - -fun SeekableByteChannel.toZipFile(): ZipFile { - return ZipFile.Builder().setSeekableByteChannel(this).get() -} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 9650f05229..ae501bbafd 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -23,6 +23,8 @@ kotlin { api(kotlinx.coroutines.core) api(kotlinx.serialization.json) api(kotlinx.serialization.json.okio) + + implementation(libs.jsoup) } } val androidMain by getting { @@ -39,6 +41,10 @@ kotlin { api(androidx.preference) implementation(libs.quickjs.android) + + api(libs.unifile) + + implementation(libs.libarchive) } } } diff --git a/core/src/androidMain/kotlin/eu/kanade/tachiyomi/util/storage/EpubFile.kt b/core/src/androidMain/kotlin/eu/kanade/tachiyomi/util/storage/EpubFile.kt new file mode 100644 index 0000000000..db17a49d7b --- /dev/null +++ b/core/src/androidMain/kotlin/eu/kanade/tachiyomi/util/storage/EpubFile.kt @@ -0,0 +1,141 @@ +package eu.kanade.tachiyomi.util.storage + +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import yokai.core.archive.ArchiveReader +import java.io.Closeable +import java.io.File +import java.io.InputStream +import java.nio.channels.SeekableByteChannel +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale + +/** + * Wrapper over ZipFile to load files in epub format. + */ +class EpubFile(private val reader: ArchiveReader) : Closeable by reader { + + /** + * Path separator used by this epub. + */ + private val pathSeparator = getPathSeparator() + + /** + * Returns an input stream for reading the contents of the specified zip file entry. + */ + fun getInputStream(entryName: String): InputStream? { + return reader.getInputStream(entryName) + } + + /** + * Returns the path of all the images found in the epub file. + */ + fun getImagesFromPages(): List { + val ref = getPackageHref() + val doc = getPackageDocument(ref) + val pages = getPagesFromDocument(doc) + return getImagesFromPages(pages, ref) + } + + /** + * Returns the path to the package document. + */ + fun getPackageHref(): String { + val meta = getInputStream(resolveZipPath("META-INF", "container.xml")) + if (meta != null) { + val metaDoc = meta.use { Jsoup.parse(it, null, "") } + val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path") + if (path != null) { + return path + } + } + return resolveZipPath("OEBPS", "content.opf") + } + + /** + * Returns the package document where all the files are listed. + */ + fun getPackageDocument(ref: String): Document { + return getInputStream(ref)!!.use { Jsoup.parse(it, null, "") } + } + + /** + * Returns all the pages from the epub. + */ + private fun getPagesFromDocument(document: Document): List { + val pages = document.select("manifest > item") + .filter { node -> "application/xhtml+xml" == node.attr("media-type") } + .associateBy { it.attr("id") } + + val spine = document.select("spine > itemref").map { it.attr("idref") } + return spine.mapNotNull { pages[it] }.map { it.attr("href") } + } + + /** + * Returns all the images contained in every page from the epub. + */ + private fun getImagesFromPages(pages: List, packageHref: String): List { + val result = mutableListOf() + val basePath = getParentDirectory(packageHref) + pages.forEach { page -> + val entryPath = resolveZipPath(basePath, page) + val document = getInputStream(entryPath)!!.use { Jsoup.parse(it, null, "") } + val imageBasePath = getParentDirectory(entryPath) + + document.allElements.forEach { + if (it.tagName() == "img") { + result.add(resolveZipPath(imageBasePath, it.attr("src"))) + } else if (it.tagName() == "image") { + result.add(resolveZipPath(imageBasePath, it.attr("xlink:href"))) + } + } + } + + return result + } + + /** + * Returns the path separator used by the epub file. + */ + private fun getPathSeparator(): String { + val meta = getInputStream("META-INF\\container.xml") + return if (meta != null) { + meta.close() + "\\" + } else { + "/" + } + } + + /** + * Resolves a zip path from base and relative components and a path separator. + */ + private fun resolveZipPath(basePath: String, relativePath: String): String { + if (relativePath.startsWith(pathSeparator)) { + // Path is absolute, so return as-is. + return relativePath + } + + var fixedBasePath = basePath.replace(pathSeparator, File.separator) + if (!fixedBasePath.startsWith(File.separator)) { + fixedBasePath = "${File.separator}$fixedBasePath" + } + + val fixedRelativePath = relativePath.replace(pathSeparator, File.separator) + val resolvedPath = File(fixedBasePath, fixedRelativePath).canonicalPath + return resolvedPath.replace(File.separator, pathSeparator).substring(1) + } + + /** + * Gets the parent directory of a path. + */ + private fun getParentDirectory(path: String): String { + val separatorIndex = path.lastIndexOf(pathSeparator) + return if (separatorIndex >= 0) { + path.substring(0, separatorIndex) + } else { + "" + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/UniFileExtensions.kt b/core/src/androidMain/kotlin/eu/kanade/tachiyomi/util/system/UniFileExtensions.kt similarity index 83% rename from app/src/main/java/eu/kanade/tachiyomi/util/system/UniFileExtensions.kt rename to core/src/androidMain/kotlin/eu/kanade/tachiyomi/util/system/UniFileExtensions.kt index 473bbbfac3..4c8cc9975c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/UniFileExtensions.kt +++ b/core/src/androidMain/kotlin/eu/kanade/tachiyomi/util/system/UniFileExtensions.kt @@ -15,6 +15,9 @@ val UniFile.nameWithoutExtension: String? val UniFile.extension: String? get() = name?.replace("${nameWithoutExtension.orEmpty()}.", "") +val UniFile.displayablePath: String + get() = filePath ?: uri.toString() + fun UniFile.toTempFile(context: Context): File { val inputStream = context.contentResolver.openInputStream(uri)!! val tempFile = @@ -47,6 +50,5 @@ fun UniFile.writeText(string: String, onComplete: () -> Unit = {}) { } } -fun UniFile.openReadOnlyChannel(context: Context): SeekableByteChannel { - return ParcelFileDescriptor.AutoCloseInputStream(context.contentResolver.openFileDescriptor(uri, "r")).channel -} +fun UniFile.openFileDescriptor(context: Context, mode: String): ParcelFileDescriptor = + context.contentResolver.openFileDescriptor(uri, mode) ?: error("Failed to open file descriptor: $displayablePath") diff --git a/core/src/androidMain/kotlin/yokai/core/archive/AndroidArchiveInputStream.kt b/core/src/androidMain/kotlin/yokai/core/archive/AndroidArchiveInputStream.kt new file mode 100644 index 0000000000..bf08b6974d --- /dev/null +++ b/core/src/androidMain/kotlin/yokai/core/archive/AndroidArchiveInputStream.kt @@ -0,0 +1,51 @@ +package yokai.core.archive + +import me.zhanghai.android.libarchive.Archive +import me.zhanghai.android.libarchive.ArchiveEntry +import me.zhanghai.android.libarchive.ArchiveException +import java.nio.ByteBuffer + +class AndroidArchiveInputStream(buffer: Long, size: Long) : ArchiveInputStream() { + private val archive = Archive.readNew() + + init { + try { + Archive.setCharset(archive, Charsets.UTF_8.name().toByteArray()) + Archive.readSupportFilterAll(archive) + Archive.readSupportFormatAll(archive) + Archive.readOpenMemoryUnsafe(archive, buffer, size) + } catch (e: ArchiveException) { + close() + throw e + } + } + + private val oneByteBuffer = ByteBuffer.allocateDirect(1) + + override fun read(): Int { + read(oneByteBuffer) + return if (oneByteBuffer.hasRemaining()) oneByteBuffer.get().toUByte().toInt() else -1 + } + + override fun read(b: ByteArray, off: Int, len: Int): Int { + val buffer = ByteBuffer.wrap(b, off, len) + read(buffer) + return if (buffer.hasRemaining()) buffer.remaining() else -1 + } + + private fun read(buffer: ByteBuffer) { + buffer.clear() + Archive.readData(archive, buffer) + buffer.flip() + } + + override fun close() { + Archive.readFree(archive) + } + + fun getNextEntry() = Archive.readNextHeader(archive).takeUnless { it == 0L }?.let { entry -> + val name = ArchiveEntry.pathnameUtf8(entry) ?: ArchiveEntry.pathname(entry)?.decodeToString() ?: return null + val isFile = ArchiveEntry.filetype(entry) == ArchiveEntry.AE_IFREG + ArchiveEntry(name, isFile) + } +} diff --git a/core/src/androidMain/kotlin/yokai/core/archive/AndroidArchiveReader.kt b/core/src/androidMain/kotlin/yokai/core/archive/AndroidArchiveReader.kt new file mode 100644 index 0000000000..8309e9b1a6 --- /dev/null +++ b/core/src/androidMain/kotlin/yokai/core/archive/AndroidArchiveReader.kt @@ -0,0 +1,42 @@ +package yokai.core.archive + +import android.content.Context +import android.os.ParcelFileDescriptor +import android.system.Os +import android.system.OsConstants +import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.util.system.openFileDescriptor +import me.zhanghai.android.libarchive.ArchiveException +import java.io.InputStream + +class AndroidArchiveReader(pfd: ParcelFileDescriptor) : ArchiveReader { + val size = pfd.statSize + val address = Os.mmap(0, size, OsConstants.PROT_READ, OsConstants.MAP_PRIVATE, pfd.fileDescriptor, 0) + + override fun useEntries(block: (Sequence) -> T): T = + AndroidArchiveInputStream(address, size).use { block(generateSequence { it.getNextEntry() }) } + + override fun getInputStream(entryName: String): InputStream? { + val archive = AndroidArchiveInputStream(address, size) + try { + while (true) { + val entry = archive.getNextEntry() ?: break + if (entry.name == entryName) { + return archive + } + } + } catch (e: ArchiveException) { + archive.close() + throw e + } + archive.close() + return null + } + + override fun close() { + Os.munmap(address, size) + } +} + +fun UniFile.archiveReader(context: Context): ArchiveReader = + openFileDescriptor(context, "r").use { AndroidArchiveReader(it) } diff --git a/core/src/androidMain/kotlin/yokai/core/archive/ZipWriter.kt b/core/src/androidMain/kotlin/yokai/core/archive/ZipWriter.kt new file mode 100644 index 0000000000..dd105245d6 --- /dev/null +++ b/core/src/androidMain/kotlin/yokai/core/archive/ZipWriter.kt @@ -0,0 +1,74 @@ +package yokai.core.archive + +import android.content.Context +import android.system.Os +import android.system.StructStat +import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.util.system.openFileDescriptor +import me.zhanghai.android.libarchive.Archive +import me.zhanghai.android.libarchive.ArchiveEntry +import me.zhanghai.android.libarchive.ArchiveException +import java.io.Closeable +import java.nio.ByteBuffer + +class ZipWriter(val context: Context, file: UniFile) : Closeable { + private val pfd = file.openFileDescriptor(context, "wt") + private val archive = Archive.writeNew() + private val entry = ArchiveEntry.new2(archive) + private val buffer = ByteBuffer.allocateDirect(8192) + + init { + try { + Archive.setCharset(archive, Charsets.UTF_8.name().toByteArray()) + Archive.writeSetFormatZip(archive) + Archive.writeZipSetCompressionStore(archive) + Archive.writeOpenFd(archive, pfd.fd) + } catch (e: ArchiveException) { + close() + throw e + } + } + + fun write(file: UniFile) { + file.openFileDescriptor(context, "r").use { + val fd = it.fileDescriptor + ArchiveEntry.clear(entry) + ArchiveEntry.setPathnameUtf8(entry, file.name) + val stat = Os.fstat(fd) + ArchiveEntry.setStat(entry, stat.toArchiveStat()) + Archive.writeHeader(archive, entry) + while (true) { + buffer.clear() + Os.read(fd, buffer) + if (buffer.position() == 0) break + buffer.flip() + Archive.writeData(archive, buffer) + } + Archive.writeFinishEntry(archive) + } + } + + override fun close() { + ArchiveEntry.free(entry) + Archive.writeFree(archive) + pfd.close() + } +} + +private fun StructStat.toArchiveStat() = ArchiveEntry.StructStat().apply { + stDev = st_dev + stMode = st_mode + stNlink = st_nlink.toInt() + stUid = st_uid + stGid = st_gid + stRdev = st_rdev + stSize = st_size + stBlksize = st_blksize + stBlocks = st_blocks + stAtim = timespec(st_atime) + stMtim = timespec(st_mtime) + stCtim = timespec(st_ctime) + stIno = st_ino +} + +private fun timespec(tvSec: Long) = ArchiveEntry.StructTimespec().also { it.tvSec = tvSec } diff --git a/core/src/commonMain/kotlin/yokai/core/archive/ArchiveEntry.kt b/core/src/commonMain/kotlin/yokai/core/archive/ArchiveEntry.kt new file mode 100644 index 0000000000..b9de51c610 --- /dev/null +++ b/core/src/commonMain/kotlin/yokai/core/archive/ArchiveEntry.kt @@ -0,0 +1,6 @@ +package yokai.core.archive + +class ArchiveEntry( + val name: String, + val isFile: Boolean, +) diff --git a/core/src/commonMain/kotlin/yokai/core/archive/ArchiveInputStream.kt b/core/src/commonMain/kotlin/yokai/core/archive/ArchiveInputStream.kt new file mode 100644 index 0000000000..6a9cd0185b --- /dev/null +++ b/core/src/commonMain/kotlin/yokai/core/archive/ArchiveInputStream.kt @@ -0,0 +1,6 @@ +package yokai.core.archive + +import java.io.InputStream + +// TODO: Use Okio's Source +abstract class ArchiveInputStream : InputStream() diff --git a/core/src/commonMain/kotlin/yokai/core/archive/ArchiveReader.kt b/core/src/commonMain/kotlin/yokai/core/archive/ArchiveReader.kt new file mode 100644 index 0000000000..00d646f3a7 --- /dev/null +++ b/core/src/commonMain/kotlin/yokai/core/archive/ArchiveReader.kt @@ -0,0 +1,9 @@ +package yokai.core.archive + +import java.io.Closeable +import java.io.InputStream + +interface ArchiveReader : Closeable { + fun useEntries(block: (Sequence) -> T): T + fun getInputStream(entryName: String): InputStream? +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5d413728f9..b6ae6db4e6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,7 +20,6 @@ coil3 = { module = "io.coil-kt.coil3:coil", version.ref = "coil3" } coil3-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil3" } coil3-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil3" } coil3-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil3" } -common-compress = { module = "org.apache.commons:commons-compress", version = "1.26.0" } compose-theme-adapter3 = { module = "com.google.accompanist:accompanist-themeadapter-material3", version = "0.33.2-alpha" } conductor = { module = "com.bluelinelabs:conductor", version = "4.0.0-preview-4" } conductor-support-preference = { module = "com.github.tachiyomiorg:conductor-support-preference", version = "3.0.0" } @@ -45,6 +44,8 @@ injekt-core = { module = "com.github.inorichi.injekt:injekt-core", version = "65 kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } +libarchive = { module = "me.zhanghai.android.libarchive:library", version = "1.1.0" } + material = { module = "com.google.android.material:material", version = "1.12.0" } material-design-dimens = { module = "com.dmitrymalkovich.android:material-design-dimens", version = "1.4" } markwon = { module = "io.noties.markwon:core", version = "4.6.2" } @@ -57,7 +58,6 @@ jsoup = { module = "org.jsoup:jsoup", version = "1.16.1" } junit-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" } junit-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" } junit-android = { module = "androidx.test.ext:junit", version = "1.1.5" } -junrar = { module = "com.github.junrar:junrar", version = "7.5.5" } loading-button = { module = "br.com.simplepass:loading-button-android", version = "2.2.0" } mockk = { module = "io.mockk:mockk", version = "1.13.11" } @@ -103,7 +103,6 @@ kotlinter = { id = "org.jmailen.kotlinter", version = "4.1.1" } gradle-versions = { id = "com.github.ben-manes.versions", version = "0.42.0" } [bundles] -archive = [ "common-compress", "junrar" ] db = [ "sqldelight-coroutines" ] db-android = [ "sqldelight-android-driver", "sqldelight-android-paging" ] coil = [ "coil3", "coil3-svg", "coil3-gif", "coil3-okhttp" ] diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index 7abfabfe97..84e1f263ad 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -84,7 +84,6 @@ Order by No chapters found No pages found - RARv5 format is not supported Remove all downloads? No chapters to delete By source\'s order