mirror of
https://github.com/null2264/yokai.git
synced 2025-06-21 10:44:42 +00:00
refactor: Use libarchive for Archive support
Co-authored-by: Ahmad Ansori Palembani <palembani@gmail.com>
This commit is contained in:
parent
c0bf67eabd
commit
17465f2719
22 changed files with 433 additions and 392 deletions
|
@ -192,8 +192,6 @@ dependencies {
|
||||||
|
|
||||||
// Disk
|
// Disk
|
||||||
implementation(libs.disklrucache)
|
implementation(libs.disklrucache)
|
||||||
implementation(libs.unifile)
|
|
||||||
implementation(libs.bundles.archive)
|
|
||||||
|
|
||||||
// HTML parser
|
// HTML parser
|
||||||
implementation(libs.jsoup)
|
implementation(libs.jsoup)
|
||||||
|
|
3
app/proguard-rules.pro
vendored
3
app/proguard-rules.pro
vendored
|
@ -98,9 +98,6 @@
|
||||||
-keep public enum nl.adaptivity.xmlutil.EventType { *; }
|
-keep public enum nl.adaptivity.xmlutil.EventType { *; }
|
||||||
##---------------End: proguard configuration for kotlinx.serialization ----------
|
##---------------End: proguard configuration for kotlinx.serialization ----------
|
||||||
|
|
||||||
# Apache Commons Compress
|
|
||||||
-keep class * extends org.apache.commons.compress.archivers.zip.ZipExtraField { <init>(); }
|
|
||||||
|
|
||||||
# Firebase
|
# Firebase
|
||||||
-keep class com.google.firebase.installations.** { *; }
|
-keep class com.google.firebase.installations.** { *; }
|
||||||
-keep interface com.google.firebase.installations.** { *; }
|
-keep interface com.google.firebase.installations.** { *; }
|
||||||
|
|
|
@ -47,6 +47,7 @@ import rx.Subscription
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
import rx.android.schedulers.AndroidSchedulers
|
||||||
import rx.schedulers.Schedulers
|
import rx.schedulers.Schedulers
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import yokai.core.archive.ZipWriter
|
||||||
import yokai.core.metadata.COMIC_INFO_FILE
|
import yokai.core.metadata.COMIC_INFO_FILE
|
||||||
import yokai.core.metadata.ComicInfo
|
import yokai.core.metadata.ComicInfo
|
||||||
import yokai.core.metadata.getComicInfo
|
import yokai.core.metadata.getComicInfo
|
||||||
|
@ -628,25 +629,9 @@ class Downloader(
|
||||||
tmpDir: UniFile,
|
tmpDir: UniFile,
|
||||||
) {
|
) {
|
||||||
val zip = mangaDir.createFile("$dirname.cbz$TMP_DIR_SUFFIX") ?: return
|
val zip = mangaDir.createFile("$dirname.cbz$TMP_DIR_SUFFIX") ?: return
|
||||||
ZipOutputStream(BufferedOutputStream(zip.openOutputStream())).use { zipOut ->
|
ZipWriter(context, zip).use { writer ->
|
||||||
zipOut.setMethod(ZipEntry.STORED)
|
tmpDir.listFiles()?.forEach { file ->
|
||||||
|
writer.write(file)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
zip.renameTo("$dirname.cbz")
|
zip.renameTo("$dirname.cbz")
|
||||||
|
|
|
@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.source
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.net.toFile
|
import androidx.core.net.toFile
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import com.github.junrar.Archive
|
|
||||||
import com.hippo.unifile.UniFile
|
import com.hippo.unifile.UniFile
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import yokai.i18n.MR
|
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.chapter.ChapterRecognition
|
||||||
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
|
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
|
||||||
import eu.kanade.tachiyomi.util.storage.EpubFile
|
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.ImageUtil
|
||||||
import eu.kanade.tachiyomi.util.system.e
|
import eu.kanade.tachiyomi.util.system.e
|
||||||
import eu.kanade.tachiyomi.util.system.extension
|
import eu.kanade.tachiyomi.util.system.extension
|
||||||
import eu.kanade.tachiyomi.util.system.nameWithoutExtension
|
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.withIOContext
|
||||||
import eu.kanade.tachiyomi.util.system.writeText
|
import eu.kanade.tachiyomi.util.system.writeText
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
@ -35,6 +34,7 @@ import nl.adaptivity.xmlutil.serialization.XML
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import yokai.core.archive.archiveReader
|
||||||
import yokai.core.metadata.COMIC_INFO_FILE
|
import yokai.core.metadata.COMIC_INFO_FILE
|
||||||
import yokai.core.metadata.ComicInfo
|
import yokai.core.metadata.ComicInfo
|
||||||
import yokai.core.metadata.copyFromComicInfo
|
import yokai.core.metadata.copyFromComicInfo
|
||||||
|
@ -187,7 +187,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
||||||
val chapter = chapters.last()
|
val chapter = chapters.last()
|
||||||
val format = getFormat(chapter)
|
val format = getFormat(chapter)
|
||||||
if (format is Format.Epub) {
|
if (format is Format.Epub) {
|
||||||
EpubFile(format.file.openReadOnlyChannel(context)).use { epub ->
|
EpubFile(format.file.archiveReader(context)).use { epub ->
|
||||||
epub.fillMangaMetadata(manga)
|
epub.fillMangaMetadata(manga)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -338,10 +338,10 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getFormat(file: UniFile) = with(file) {
|
private fun getFormat(file: UniFile) = with(file) {
|
||||||
|
val supportedArchives = listOf("zip", "cbz", "rar", "cbr", "7z", "cb7", "tar", "cbt")
|
||||||
when {
|
when {
|
||||||
isDirectory -> Format.Directory(this)
|
isDirectory -> Format.Directory(this)
|
||||||
extension.equals("zip", true) || extension.equals("cbz", true) -> Format.Zip(this)
|
supportedArchives.contains(extension?.lowercase()) -> Format.Archive(this)
|
||||||
extension.equals("rar", true) || extension.equals("cbr", true) -> Format.Rar(this)
|
|
||||||
extension.equals("epub", true) -> Format.Epub(this)
|
extension.equals("epub", true) -> Format.Epub(this)
|
||||||
else -> throw Exception(context.getString(MR.strings.local_invalid_format))
|
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()) }
|
entry?.let { updateCover(manga, it.openInputStream()) }
|
||||||
}
|
}
|
||||||
is Format.Zip -> {
|
is Format.Archive -> {
|
||||||
format.file.openReadOnlyChannel(context).toZipFile().use { zip ->
|
format.file.archiveReader(context).use { reader ->
|
||||||
val entry = zip.entries.toList()
|
val entry = reader.useEntries { entries ->
|
||||||
|
entries.toList()
|
||||||
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
|
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
|
||||||
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
|
.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 -> {
|
is Format.Epub -> {
|
||||||
EpubFile(format.file.openReadOnlyChannel(context)).use { epub ->
|
EpubFile(format.file.archiveReader(context)).use { epub ->
|
||||||
val entry = epub.getImagesFromPages()
|
val entry = epub.getImagesFromPages().firstOrNull()
|
||||||
.firstOrNull()
|
|
||||||
?.let { epub.getEntry(it) }
|
|
||||||
|
|
||||||
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
|
true
|
||||||
}
|
}
|
||||||
is Format.Epub -> {
|
is Format.Epub -> {
|
||||||
EpubFile(format.file.openReadOnlyChannel(context)).use { epub ->
|
EpubFile(format.file.archiveReader(context)).use { epub ->
|
||||||
epub.fillChapterMetadata(chapter)
|
epub.fillChapterMetadata(chapter)
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
is Format.Rar -> Archive(format.file.openInputStream()).use { archive ->
|
is Format.Archive -> format.file.archiveReader(context).use { reader ->
|
||||||
val entry = archive.fileHeaders
|
val entry = reader.useEntries { entries ->
|
||||||
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
|
entries.toList()
|
||||||
.find { !it.isDirectory && it.fileName == 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) }
|
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
|
||||||
.find { !it.isDirectory && it.name == COMIC_INFO_FILE } ?: return false
|
.find { it.isFile && it.name == COMIC_INFO_FILE }
|
||||||
|
} ?: return false
|
||||||
|
|
||||||
updateMetadata(chapter, manga, zip.getInputStream(entry))
|
updateMetadata(chapter, manga, reader.getInputStream(entry.name)!!)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -442,8 +427,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
||||||
|
|
||||||
sealed class Format {
|
sealed class Format {
|
||||||
data class Directory(val file: UniFile) : Format()
|
data class Directory(val file: UniFile) : Format()
|
||||||
data class Zip(val file: UniFile) : Format()
|
data class Archive(val file: UniFile) : Format()
|
||||||
data class Rar(val file: UniFile) : Format()
|
|
||||||
data class Epub(val file: UniFile) : Format()
|
data class Epub(val file: UniFile) : Format()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,41 +4,35 @@ import eu.kanade.tachiyomi.source.model.Page
|
||||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||||
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
|
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
|
||||||
import eu.kanade.tachiyomi.util.system.ImageUtil
|
import eu.kanade.tachiyomi.util.system.ImageUtil
|
||||||
import eu.kanade.tachiyomi.util.system.toZipFile
|
import yokai.core.archive.ArchiveReader
|
||||||
import java.nio.channels.SeekableByteChannel
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loader used to load a chapter from a .zip or .cbz file.
|
* Loader used to load a chapter from a .zip or .cbz file.
|
||||||
*/
|
*/
|
||||||
class ZipPageLoader(channel: SeekableByteChannel) : PageLoader() {
|
internal class ArchivePageLoader(private val reader: ArchiveReader) : PageLoader() {
|
||||||
|
|
||||||
/**
|
|
||||||
* The zip file to load pages from.
|
|
||||||
*/
|
|
||||||
private val zip = channel.toZipFile()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recycles this loader and the open zip.
|
* Recycles this loader and the open zip.
|
||||||
*/
|
*/
|
||||||
override fun recycle() {
|
override fun recycle() {
|
||||||
super.recycle()
|
super.recycle()
|
||||||
zip.close()
|
reader.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the pages found on this zip archive ordered with a natural comparator.
|
* Returns the pages found on this zip archive ordered with a natural comparator.
|
||||||
*/
|
*/
|
||||||
override suspend fun getPages(): List<ReaderPage> {
|
override suspend fun getPages(): List<ReaderPage> {
|
||||||
return zip.entries.asSequence()
|
return reader.useEntries { entries ->
|
||||||
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
|
entries.filter { it.isFile && ImageUtil.isImage(it.name) { reader.getInputStream(it.name)!! } }
|
||||||
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
|
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
|
||||||
.mapIndexed { i, entry ->
|
.mapIndexed { i, entry ->
|
||||||
ReaderPage(i).apply {
|
ReaderPage(i).apply {
|
||||||
stream = { zip.getInputStream(entry) }
|
stream = { reader.getInputStream(entry.name)!! }
|
||||||
status = Page.State.READY
|
status = Page.State.READY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toList()
|
}.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
|
@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.ui.reader.loader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import com.github.junrar.exception.UnsupportedRarV5Exception
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadProvider
|
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.Source
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||||
import eu.kanade.tachiyomi.util.system.openReadOnlyChannel
|
|
||||||
import eu.kanade.tachiyomi.util.system.withIOContext
|
import eu.kanade.tachiyomi.util.system.withIOContext
|
||||||
|
import yokai.core.archive.archiveReader
|
||||||
import yokai.i18n.MR
|
import yokai.i18n.MR
|
||||||
import yokai.util.lang.getString
|
import yokai.util.lang.getString
|
||||||
|
|
||||||
|
@ -82,13 +81,8 @@ class ChapterLoader(
|
||||||
source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
|
source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
|
||||||
when (format) {
|
when (format) {
|
||||||
is LocalSource.Format.Directory -> DirectoryPageLoader(format.file)
|
is LocalSource.Format.Directory -> DirectoryPageLoader(format.file)
|
||||||
is LocalSource.Format.Zip -> ZipPageLoader(format.file.openReadOnlyChannel(context))
|
is LocalSource.Format.Archive -> ArchivePageLoader(format.file.archiveReader(context))
|
||||||
is LocalSource.Format.Rar -> try {
|
is LocalSource.Format.Epub -> EpubPageLoader(format.file.archiveReader(context))
|
||||||
RarPageLoader(format.file.openInputStream())
|
|
||||||
} catch (e: UnsupportedRarV5Exception) {
|
|
||||||
error(context.getString(MR.strings.loader_rar5_error))
|
|
||||||
}
|
|
||||||
is LocalSource.Format.Epub -> EpubPageLoader(format.file.openReadOnlyChannel(context))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> error(context.getString(MR.strings.source_not_installed))
|
else -> error(context.getString(MR.strings.source_not_installed))
|
||||||
|
|
|
@ -10,10 +10,8 @@ import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
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 uy.kohesive.injekt.injectLazy
|
||||||
import java.io.File
|
import yokai.core.archive.archiveReader
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loader used to load a chapter from the downloaded chapters.
|
* Loader used to load a chapter from the downloaded chapters.
|
||||||
|
@ -29,11 +27,11 @@ class DownloadPageLoader(
|
||||||
// Needed to open input streams
|
// Needed to open input streams
|
||||||
private val context: Application by injectLazy()
|
private val context: Application by injectLazy()
|
||||||
|
|
||||||
private var zipPageLoader: ZipPageLoader? = null
|
private var archivePageLoader: ArchivePageLoader? = null
|
||||||
|
|
||||||
override fun recycle() {
|
override fun recycle() {
|
||||||
super.recycle()
|
super.recycle()
|
||||||
zipPageLoader?.recycle()
|
archivePageLoader?.recycle()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -50,7 +48,7 @@ class DownloadPageLoader(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getPagesFromArchive(chapterPath: UniFile): List<ReaderPage> {
|
private suspend fun getPagesFromArchive(chapterPath: UniFile): List<ReaderPage> {
|
||||||
val loader = ZipPageLoader(chapterPath.openReadOnlyChannel(context)).also { zipPageLoader = it }
|
val loader = ArchivePageLoader(chapterPath.archiveReader(context)).also { archivePageLoader = it }
|
||||||
return loader.getPages()
|
return loader.getPages()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -66,6 +64,6 @@ class DownloadPageLoader(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun loadPage(page: ReaderPage) {
|
override suspend fun loadPage(page: ReaderPage) {
|
||||||
zipPageLoader?.loadPage(page)
|
archivePageLoader?.loadPage(page)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,17 +3,18 @@ package eu.kanade.tachiyomi.ui.reader.loader
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||||
import eu.kanade.tachiyomi.util.storage.EpubFile
|
import eu.kanade.tachiyomi.util.storage.EpubFile
|
||||||
|
import yokai.core.archive.ArchiveReader
|
||||||
import java.nio.channels.SeekableByteChannel
|
import java.nio.channels.SeekableByteChannel
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loader used to load a chapter from a .epub file.
|
* Loader used to load a chapter from a .epub file.
|
||||||
*/
|
*/
|
||||||
class EpubPageLoader(channel: SeekableByteChannel) : PageLoader() {
|
class EpubPageLoader(reader: ArchiveReader) : PageLoader() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The epub file.
|
* The epub file.
|
||||||
*/
|
*/
|
||||||
private val epub = EpubFile(channel)
|
private val epub = EpubFile(reader)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recycles this loader and the open zip.
|
* Recycles this loader and the open zip.
|
||||||
|
@ -29,7 +30,7 @@ class EpubPageLoader(channel: SeekableByteChannel) : PageLoader() {
|
||||||
override suspend fun getPages(): List<ReaderPage> {
|
override suspend fun getPages(): List<ReaderPage> {
|
||||||
return epub.getImagesFromPages()
|
return epub.getImagesFromPages()
|
||||||
.mapIndexed { i, path ->
|
.mapIndexed { i, path ->
|
||||||
val streamFn = { epub.getInputStream(epub.getEntry(path)!!) }
|
val streamFn = { epub.getInputStream(path)!! }
|
||||||
ReaderPage(i).apply {
|
ReaderPage(i).apply {
|
||||||
stream = streamFn
|
stream = streamFn
|
||||||
status = Page.State.READY
|
status = Page.State.READY
|
||||||
|
|
|
@ -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<ReaderPage> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,59 +2,14 @@ package eu.kanade.tachiyomi.util.storage
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
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.ParseException
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Locale
|
import java.util.*
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrapper over ZipFile to load files in epub format.
|
|
||||||
*/
|
|
||||||
class EpubFile(channel: SeekableByteChannel) : Closeable {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Zip file of this epub.
|
|
||||||
*/
|
|
||||||
private val zip = channel.toZipFile()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Path separator used by this epub.
|
|
||||||
*/
|
|
||||||
private val pathSeparator = getPathSeparator()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Closes the underlying zip file.
|
|
||||||
*/
|
|
||||||
override fun close() {
|
|
||||||
zip.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an input stream for reading the contents of the specified zip file entry.
|
|
||||||
*/
|
|
||||||
fun getInputStream(entry: ZipArchiveEntry): InputStream {
|
|
||||||
return zip.getInputStream(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the zip file entry for the specified name, or null if not found.
|
|
||||||
*/
|
|
||||||
fun getEntry(name: String): ZipArchiveEntry? {
|
|
||||||
return zip.getEntry(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fills manga metadata using this epub file's metadata.
|
* Fills manga metadata using this epub file's metadata.
|
||||||
*/
|
*/
|
||||||
fun fillMangaMetadata(manga: SManga) {
|
fun EpubFile.fillMangaMetadata(manga: SManga) {
|
||||||
val ref = getPackageHref()
|
val ref = getPackageHref()
|
||||||
val doc = getPackageDocument(ref)
|
val doc = getPackageDocument(ref)
|
||||||
|
|
||||||
|
@ -68,7 +23,7 @@ class EpubFile(channel: SeekableByteChannel) : Closeable {
|
||||||
/**
|
/**
|
||||||
* Fills chapter metadata using this epub file's metadata.
|
* Fills chapter metadata using this epub file's metadata.
|
||||||
*/
|
*/
|
||||||
fun fillChapterMetadata(chapter: SChapter) {
|
fun EpubFile.fillChapterMetadata(chapter: SChapter) {
|
||||||
val ref = getPackageHref()
|
val ref = getPackageHref()
|
||||||
val doc = getPackageDocument(ref)
|
val doc = getPackageDocument(ref)
|
||||||
|
|
||||||
|
@ -102,116 +57,3 @@ class EpubFile(channel: SeekableByteChannel) : Closeable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the path of all the images found in the epub file.
|
|
||||||
*/
|
|
||||||
fun getImagesFromPages(): List<String> {
|
|
||||||
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<String> {
|
|
||||||
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<String>, packageHref: String): List<String> {
|
|
||||||
val result = mutableListOf<String>()
|
|
||||||
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 {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
|
|
@ -23,6 +23,8 @@ kotlin {
|
||||||
api(kotlinx.coroutines.core)
|
api(kotlinx.coroutines.core)
|
||||||
api(kotlinx.serialization.json)
|
api(kotlinx.serialization.json)
|
||||||
api(kotlinx.serialization.json.okio)
|
api(kotlinx.serialization.json.okio)
|
||||||
|
|
||||||
|
implementation(libs.jsoup)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val androidMain by getting {
|
val androidMain by getting {
|
||||||
|
@ -39,6 +41,10 @@ kotlin {
|
||||||
api(androidx.preference)
|
api(androidx.preference)
|
||||||
|
|
||||||
implementation(libs.quickjs.android)
|
implementation(libs.quickjs.android)
|
||||||
|
|
||||||
|
api(libs.unifile)
|
||||||
|
|
||||||
|
implementation(libs.libarchive)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<String>, packageHref: String): List<String> {
|
||||||
|
val result = mutableListOf<String>()
|
||||||
|
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 {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,6 +15,9 @@ val UniFile.nameWithoutExtension: String?
|
||||||
val UniFile.extension: String?
|
val UniFile.extension: String?
|
||||||
get() = name?.replace("${nameWithoutExtension.orEmpty()}.", "")
|
get() = name?.replace("${nameWithoutExtension.orEmpty()}.", "")
|
||||||
|
|
||||||
|
val UniFile.displayablePath: String
|
||||||
|
get() = filePath ?: uri.toString()
|
||||||
|
|
||||||
fun UniFile.toTempFile(context: Context): File {
|
fun UniFile.toTempFile(context: Context): File {
|
||||||
val inputStream = context.contentResolver.openInputStream(uri)!!
|
val inputStream = context.contentResolver.openInputStream(uri)!!
|
||||||
val tempFile =
|
val tempFile =
|
||||||
|
@ -47,6 +50,5 @@ fun UniFile.writeText(string: String, onComplete: () -> Unit = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun UniFile.openReadOnlyChannel(context: Context): SeekableByteChannel {
|
fun UniFile.openFileDescriptor(context: Context, mode: String): ParcelFileDescriptor =
|
||||||
return ParcelFileDescriptor.AutoCloseInputStream(context.contentResolver.openFileDescriptor(uri, "r")).channel
|
context.contentResolver.openFileDescriptor(uri, mode) ?: error("Failed to open file descriptor: $displayablePath")
|
||||||
}
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 <T> useEntries(block: (Sequence<ArchiveEntry>) -> 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) }
|
74
core/src/androidMain/kotlin/yokai/core/archive/ZipWriter.kt
Normal file
74
core/src/androidMain/kotlin/yokai/core/archive/ZipWriter.kt
Normal file
|
@ -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 }
|
|
@ -0,0 +1,6 @@
|
||||||
|
package yokai.core.archive
|
||||||
|
|
||||||
|
class ArchiveEntry(
|
||||||
|
val name: String,
|
||||||
|
val isFile: Boolean,
|
||||||
|
)
|
|
@ -0,0 +1,6 @@
|
||||||
|
package yokai.core.archive
|
||||||
|
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
// TODO: Use Okio's Source
|
||||||
|
abstract class ArchiveInputStream : InputStream()
|
|
@ -0,0 +1,9 @@
|
||||||
|
package yokai.core.archive
|
||||||
|
|
||||||
|
import java.io.Closeable
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
interface ArchiveReader : Closeable {
|
||||||
|
fun <T> useEntries(block: (Sequence<ArchiveEntry>) -> T): T
|
||||||
|
fun getInputStream(entryName: String): InputStream?
|
||||||
|
}
|
|
@ -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-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil3" }
|
||||||
coil3-gif = { module = "io.coil-kt.coil3:coil-gif", 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" }
|
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" }
|
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 = { module = "com.bluelinelabs:conductor", version = "4.0.0-preview-4" }
|
||||||
conductor-support-preference = { module = "com.github.tachiyomiorg:conductor-support-preference", version = "3.0.0" }
|
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" }
|
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 = { module = "com.google.android.material:material", version = "1.12.0" }
|
||||||
material-design-dimens = { module = "com.dmitrymalkovich.android:material-design-dimens", version = "1.4" }
|
material-design-dimens = { module = "com.dmitrymalkovich.android:material-design-dimens", version = "1.4" }
|
||||||
markwon = { module = "io.noties.markwon:core", version = "4.6.2" }
|
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-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" }
|
||||||
junit-api = { module = "org.junit.jupiter:junit-jupiter-api", 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" }
|
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" }
|
loading-button = { module = "br.com.simplepass:loading-button-android", version = "2.2.0" }
|
||||||
mockk = { module = "io.mockk:mockk", version = "1.13.11" }
|
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" }
|
gradle-versions = { id = "com.github.ben-manes.versions", version = "0.42.0" }
|
||||||
|
|
||||||
[bundles]
|
[bundles]
|
||||||
archive = [ "common-compress", "junrar" ]
|
|
||||||
db = [ "sqldelight-coroutines" ]
|
db = [ "sqldelight-coroutines" ]
|
||||||
db-android = [ "sqldelight-android-driver", "sqldelight-android-paging" ]
|
db-android = [ "sqldelight-android-driver", "sqldelight-android-paging" ]
|
||||||
coil = [ "coil3", "coil3-svg", "coil3-gif", "coil3-okhttp" ]
|
coil = [ "coil3", "coil3-svg", "coil3-gif", "coil3-okhttp" ]
|
||||||
|
|
|
@ -84,7 +84,6 @@
|
||||||
<string name="order_by">Order by</string>
|
<string name="order_by">Order by</string>
|
||||||
<string name="no_chapters_error">No chapters found</string>
|
<string name="no_chapters_error">No chapters found</string>
|
||||||
<string name="no_pages_found">No pages found</string>
|
<string name="no_pages_found">No pages found</string>
|
||||||
<string name="loader_rar5_error">RARv5 format is not supported</string>
|
|
||||||
<string name="remove_all_downloads">Remove all downloads?</string>
|
<string name="remove_all_downloads">Remove all downloads?</string>
|
||||||
<string name="no_chapters_to_delete">No chapters to delete</string>
|
<string name="no_chapters_to_delete">No chapters to delete</string>
|
||||||
<string name="by_source_order">By source\'s order</string>
|
<string name="by_source_order">By source\'s order</string>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue