Move Local Source to separate module (#9152)

* Move Local Source to separate module

* Review changes
This commit is contained in:
Andreas 2023-02-26 22:16:49 +01:00 committed by GitHub
parent 2368c50ebb
commit f27dc19b37
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 523 additions and 314 deletions

View File

@ -140,7 +140,9 @@ android {
dependencies {
implementation(project(":i18n"))
implementation(project(":core"))
implementation(project(":core-metadata"))
implementation(project(":source-api"))
implementation(project(":source-local"))
implementation(project(":data"))
implementation(project(":domain"))
implementation(project(":presentation-core"))
@ -200,7 +202,7 @@ dependencies {
// TLS 1.3 support for Android < 10
implementation(libs.conscrypt.android)
// Data serialization (JSON, protobuf)
// Data serialization (JSON, protobuf, xml)
implementation(kotlinx.bundles.serialization)
// HTML parser
@ -224,9 +226,6 @@ dependencies {
}
implementation(libs.image.decoder)
// Sort
implementation(libs.natural.comparator)
// UI libraries
implementation(libs.material)
implementation(libs.flexible.adapter.core)

View File

@ -3,7 +3,6 @@ package eu.kanade.data.source
import eu.kanade.domain.source.model.SourcePagingSourceType
import eu.kanade.domain.source.repository.SourceRepository
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.FilterList
import kotlinx.coroutines.flow.Flow
@ -11,6 +10,7 @@ import kotlinx.coroutines.flow.map
import tachiyomi.data.DatabaseHandler
import tachiyomi.domain.source.model.Source
import tachiyomi.domain.source.model.SourceWithCount
import tachiyomi.source.local.LocalSource
class SourceRepositoryImpl(
private val sourceManager: SourceManager,

View File

@ -2,12 +2,13 @@ package eu.kanade.domain.manga.model
import eu.kanade.domain.base.BasePreferences
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.TriStateFilter
import tachiyomi.source.local.LocalSource
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -91,3 +92,25 @@ fun Manga.isLocal(): Boolean = source == LocalSource.ID
fun Manga.hasCustomCover(coverCache: CoverCache = Injekt.get()): Boolean {
return coverCache.getCustomCoverFile(id).exists()
}
/**
* Creates a ComicInfo instance based on the manga and chapter metadata.
*/
fun getComicInfo(manga: Manga, chapter: Chapter, chapterUrl: String) = ComicInfo(
title = ComicInfo.Title(chapter.name),
series = ComicInfo.Series(manga.title),
web = ComicInfo.Web(chapterUrl),
summary = manga.description?.let { ComicInfo.Summary(it) },
writer = manga.author?.let { ComicInfo.Writer(it) },
penciller = manga.artist?.let { ComicInfo.Penciller(it) },
translator = chapter.scanlator?.let { ComicInfo.Translator(it) },
genre = manga.genre?.let { ComicInfo.Genre(it.joinToString()) },
publishingStatus = ComicInfo.PublishingStatusTachiyomi(
ComicInfoPublishingStatus.toComicInfoValue(manga.status),
),
inker = null,
colorist = null,
letterer = null,
coverArtist = null,
tags = null,
)

View File

@ -2,13 +2,13 @@ package eu.kanade.domain.source.interactor
import eu.kanade.domain.source.repository.SourceRepository
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.source.LocalSource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import tachiyomi.domain.source.model.Pin
import tachiyomi.domain.source.model.Pins
import tachiyomi.domain.source.model.Source
import tachiyomi.source.local.LocalSource
class GetEnabledSources(
private val repository: SourceRepository,

View File

@ -22,7 +22,6 @@ import eu.kanade.presentation.browse.components.BrowseSourceCompactGrid
import eu.kanade.presentation.browse.components.BrowseSourceList
import eu.kanade.presentation.components.AppBar
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import kotlinx.coroutines.flow.StateFlow
@ -32,6 +31,7 @@ import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.screens.EmptyScreenAction
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.source.local.LocalSource
@Composable
fun BrowseSourceContent(

View File

@ -23,7 +23,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.browse.components.BaseSourceItem
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.ui.browse.source.SourcesState
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel.Listing
import eu.kanade.tachiyomi.util.system.LocaleHelper
@ -36,6 +35,7 @@ import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.screens.LoadingScreen
import tachiyomi.presentation.core.theme.header
import tachiyomi.presentation.core.util.plus
import tachiyomi.source.local.LocalSource
@Composable
fun SourcesScreen(

View File

@ -31,9 +31,9 @@ import eu.kanade.domain.source.model.icon
import eu.kanade.presentation.util.rememberResourceBitmapPainter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.source.LocalSource
import tachiyomi.core.util.lang.withIOContext
import tachiyomi.domain.source.model.Source
import tachiyomi.source.local.LocalSource
private val defaultModifier = Modifier
.height(40.dp)

View File

@ -19,9 +19,9 @@ import eu.kanade.presentation.components.RadioMenuItem
import eu.kanade.presentation.components.SearchToolbar
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import tachiyomi.domain.library.model.LibraryDisplayMode
import tachiyomi.source.local.LocalSource
@Composable
fun BrowseSourceToolbar(

View File

@ -0,0 +1,18 @@
package eu.kanade.presentation.extensions
import android.Manifest
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import com.google.accompanist.permissions.rememberPermissionState
import eu.kanade.tachiyomi.util.storage.DiskUtil
/**
* Launches request for [Manifest.permission.WRITE_EXTERNAL_STORAGE] permission
*/
@Composable
fun DiskUtil.RequestStoragePermission() {
val permissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
LaunchedEffect(Unit) {
permissionState.launchPermissionRequest()
}
}

View File

@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.hippo.unifile.UniFile
import eu.kanade.domain.backup.service.BackupPreferences
import eu.kanade.presentation.extensions.RequestStoragePermission
import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.util.collectAsState
import eu.kanade.tachiyomi.R

View File

@ -48,6 +48,10 @@ import tachiyomi.data.Mangas
import tachiyomi.data.dateAdapter
import tachiyomi.data.listOfStringsAdapter
import tachiyomi.data.updateStrategyAdapter
import tachiyomi.source.local.image.AndroidLocalCoverManager
import tachiyomi.source.local.image.LocalCoverManager
import tachiyomi.source.local.io.AndroidLocalSourceFileSystem
import tachiyomi.source.local.io.LocalSourceFileSystem
import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addSingleton
@ -133,6 +137,9 @@ class AppModule(val app: Application) : InjektModule {
addSingletonFactory { ImageSaver(app) }
addSingletonFactory<LocalSourceFileSystem> { AndroidLocalSourceFileSystem(app) }
addSingletonFactory<LocalCoverManager> { AndroidLocalCoverManager(app, get()) }
// Asynchronously init expensive components for a faster cold start
ContextCompat.getMainExecutor(app).execute {
get<NetworkHelper>()

View File

@ -9,8 +9,8 @@ import coil.decode.ImageDecoderDecoder
import coil.decode.ImageSource
import coil.fetch.SourceResult
import coil.request.Options
import eu.kanade.tachiyomi.util.system.ImageUtil
import okio.BufferedSource
import tachiyomi.core.util.system.ImageUtil
import tachiyomi.decoder.ImageDecoder
/**

View File

@ -21,7 +21,6 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE
import eu.kanade.tachiyomi.util.storage.saveTo
import eu.kanade.tachiyomi.util.system.ImageUtil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@ -45,6 +44,7 @@ import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.lang.launchNow
import tachiyomi.core.util.lang.withIOContext
import tachiyomi.core.util.lang.withUIContext
import tachiyomi.core.util.system.ImageUtil
import tachiyomi.core.util.system.logcat
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.manga.model.Manga

View File

@ -15,9 +15,9 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.cacheImageDir
import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.system.ImageUtil
import logcat.LogPriority
import okio.IOException
import tachiyomi.core.util.system.ImageUtil
import tachiyomi.core.util.system.logcat
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream

View File

@ -4,6 +4,7 @@ import android.graphics.drawable.Drawable
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.extension.ExtensionManager
import tachiyomi.domain.source.model.SourceData
import tachiyomi.source.local.LocalSource
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get

View File

@ -20,6 +20,9 @@ import kotlinx.coroutines.runBlocking
import rx.Observable
import tachiyomi.domain.source.model.SourceData
import tachiyomi.domain.source.repository.SourceDataRepository
import tachiyomi.source.local.LocalSource
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.util.concurrent.ConcurrentHashMap
@ -43,7 +46,15 @@ class SourceManager(
scope.launch {
extensionManager.installedExtensionsFlow
.collectLatest { extensions ->
val mutableMap = ConcurrentHashMap<Long, Source>(mapOf(LocalSource.ID to LocalSource(context)))
val mutableMap = ConcurrentHashMap<Long, Source>(
mapOf(
LocalSource.ID to LocalSource(
context,
Injekt.get(),
Injekt.get(),
),
),
)
extensions.forEach { extension ->
extension.sources.forEach {
mutableMap[it.id] = it

View File

@ -14,6 +14,7 @@ import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
import cafe.adriel.voyager.navigator.tab.TabOptions
import eu.kanade.presentation.components.TabbedScreen
import eu.kanade.presentation.extensions.RequestStoragePermission
import eu.kanade.presentation.util.Tab
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreenModel

View File

@ -23,7 +23,6 @@ import eu.kanade.presentation.browse.BrowseSourceContent
import eu.kanade.presentation.components.SearchToolbar
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel
import eu.kanade.tachiyomi.ui.home.HomeScreen
@ -34,6 +33,7 @@ import tachiyomi.core.Constants
import tachiyomi.domain.manga.model.Manga
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.source.local.LocalSource
data class SourceSearchScreen(
private val oldManga: Manga,

View File

@ -45,7 +45,6 @@ import eu.kanade.presentation.util.AssistContentScreen
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.browse.extension.details.SourcePreferencesScreen
@ -61,6 +60,7 @@ import tachiyomi.core.util.lang.launchIO
import tachiyomi.presentation.core.components.material.Divider
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.source.local.LocalSource
data class BrowseSourceScreen(
private val sourceId: Long,

View File

@ -121,7 +121,7 @@ class MangaCoverScreenModel(
@Suppress("BlockingMethodInNonBlockingContext")
context.contentResolver.openInputStream(data)?.use {
try {
manga.editCover(context, it, updateManga, coverCache)
manga.editCover(Injekt.get(), it, updateManga, coverCache)
notifyCoverUpdated(context)
} catch (e: Exception) {
notifyFailedCoverUpdate(context, e)

View File

@ -774,7 +774,7 @@ class ReaderViewModel(
viewModelScope.launchNonCancellable {
val result = try {
manga.editCover(context, stream())
manga.editCover(Injekt.get(), stream())
if (manga.isLocal() || manga.favorite) {
SetAsCoverResult.Success
} else {

View File

@ -5,7 +5,6 @@ import com.github.junrar.exception.UnsupportedRarV5Exception
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadProvider
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.HttpSource
@ -13,6 +12,8 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import tachiyomi.core.util.lang.withIOContext
import tachiyomi.core.util.system.logcat
import tachiyomi.domain.manga.model.Manga
import tachiyomi.source.local.LocalSource
import tachiyomi.source.local.io.Format
/**
* Loader used to retrieve the [PageLoader] for a given chapter.
@ -80,14 +81,14 @@ class ChapterLoader(
source is HttpSource -> HttpPageLoader(chapter, source)
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)
is LocalSource.Format.Rar -> try {
is Format.Directory -> DirectoryPageLoader(format.file)
is Format.Zip -> ZipPageLoader(format.file)
is Format.Rar -> try {
RarPageLoader(format.file)
} catch (e: UnsupportedRarV5Exception) {
error(context.getString(R.string.loader_rar5_error))
}
is LocalSource.Format.Epub -> EpubPageLoader(format.file)
is Format.Epub -> EpubPageLoader(format.file)
}
}
source is SourceManager.StubSource -> throw source.getSourceNotInstalledException()

View File

@ -3,7 +3,7 @@ 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.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.system.ImageUtil
import tachiyomi.core.util.system.ImageUtil
import java.io.File
import java.io.FileInputStream

View File

@ -5,7 +5,7 @@ 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 tachiyomi.core.util.system.ImageUtil
import java.io.File
import java.io.InputStream
import java.io.PipedInputStream

View File

@ -4,7 +4,7 @@ import android.os.Build
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 tachiyomi.core.util.system.ImageUtil
import java.io.File
import java.nio.charset.StandardCharsets
import java.util.zip.ZipFile

View File

@ -13,7 +13,6 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.system.ImageUtil
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
@ -23,6 +22,7 @@ import kotlinx.coroutines.supervisorScope
import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.lang.withIOContext
import tachiyomi.core.util.lang.withUIContext
import tachiyomi.core.util.system.ImageUtil
import java.io.BufferedInputStream
import java.io.ByteArrayInputStream
import java.io.InputStream

View File

@ -18,7 +18,6 @@ import eu.kanade.tachiyomi.ui.reader.model.StencilPage
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.system.ImageUtil
import eu.kanade.tachiyomi.util.system.dpToPx
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
@ -29,6 +28,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.lang.withIOContext
import tachiyomi.core.util.lang.withUIContext
import tachiyomi.core.util.system.ImageUtil
import java.io.BufferedInputStream
import java.io.InputStream

View File

@ -1,15 +1,14 @@
package eu.kanade.tachiyomi.util
import android.content.Context
import eu.kanade.domain.download.service.DownloadPreferences
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.domain.manga.model.hasCustomCover
import eu.kanade.domain.manga.model.isLocal
import eu.kanade.domain.manga.model.toSManga
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.model.SManga
import tachiyomi.domain.manga.model.Manga
import tachiyomi.source.local.image.LocalCoverManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.InputStream
@ -77,13 +76,13 @@ fun Manga.shouldDownloadNewChapters(dbCategories: List<Long>, preferences: Downl
}
suspend fun Manga.editCover(
context: Context,
coverManager: LocalCoverManager,
stream: InputStream,
updateManga: UpdateManga = Injekt.get(),
coverCache: CoverCache = Injekt.get(),
) {
if (isLocal()) {
LocalSource.updateCover(context, toSManga(), stream)
coverManager.update(toSManga(), stream)
updateManga.awaitUpdateCoverLastModified(id)
} else if (favorite) {
coverCache.setCustomCoverToCache(this, stream)

View File

@ -46,7 +46,6 @@ import tachiyomi.core.util.system.logcat
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import kotlin.math.max
import kotlin.math.roundToInt
/**
@ -113,9 +112,6 @@ fun Context.hasPermission(permission: String) = ContextCompat.checkSelfPermissio
}
}
val getDisplayMaxHeightInPx: Int
get() = Resources.getSystem().displayMetrics.let { max(it.heightPixels, it.widthPixels) }
/**
* Converts to px and takes into account LTR/RTL layout.
*/

1
core-metadata/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -0,0 +1,21 @@
plugins {
id("com.android.library")
kotlin("android")
kotlin("plugin.serialization")
}
android {
namespace = "tachiyomi.core.metadata"
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
}
dependencies {
implementation(project(":source-api"))
implementation(kotlinx.bundles.serialization)
}

View File

21
core-metadata/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@ -5,33 +5,9 @@ import kotlinx.serialization.Serializable
import nl.adaptivity.xmlutil.serialization.XmlElement
import nl.adaptivity.xmlutil.serialization.XmlSerialName
import nl.adaptivity.xmlutil.serialization.XmlValue
import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.manga.model.Manga
const val COMIC_INFO_FILE = "ComicInfo.xml"
/**
* Creates a ComicInfo instance based on the manga and chapter metadata.
*/
fun getComicInfo(manga: Manga, chapter: Chapter, chapterUrl: String) = ComicInfo(
title = ComicInfo.Title(chapter.name),
series = ComicInfo.Series(manga.title),
web = ComicInfo.Web(chapterUrl),
summary = manga.description?.let { ComicInfo.Summary(it) },
writer = manga.author?.let { ComicInfo.Writer(it) },
penciller = manga.artist?.let { ComicInfo.Penciller(it) },
translator = chapter.scanlator?.let { ComicInfo.Translator(it) },
genre = manga.genre?.let { ComicInfo.Genre(it.joinToString()) },
publishingStatus = ComicInfo.PublishingStatusTachiyomi(
ComicInfoPublishingStatus.toComicInfoValue(manga.status),
),
inker = null,
colorist = null,
letterer = null,
coverArtist = null,
tags = null,
)
fun SManga.copyFromComicInfo(comicInfo: ComicInfo) {
comicInfo.series?.let { title = it.value }
comicInfo.writer?.let { author = it.value }
@ -149,7 +125,7 @@ data class ComicInfo(
data class PublishingStatusTachiyomi(@XmlValue(true) val value: String = "")
}
private enum class ComicInfoPublishingStatus(
enum class ComicInfoPublishingStatus(
val comicInfoValue: String,
val sMangaModelValue: Int,
) {

View File

@ -0,0 +1,13 @@
package tachiyomi.core.metadata.tachiyomi
import kotlinx.serialization.Serializable
@Serializable
class MangaDetails(
val title: String? = null,
val author: String? = null,
val artist: String? = null,
val description: String? = null,
val genre: List<String>? = null,
val status: Int? = null,
)

View File

@ -27,12 +27,21 @@ dependencies {
api(libs.okhttp.dnsoverhttps)
api(libs.okio)
implementation(libs.image.decoder)
implementation(libs.unifile)
api(kotlinx.coroutines.core)
api(kotlinx.serialization.json)
api(kotlinx.serialization.json.okio)
api(libs.preferencektx)
implementation(libs.jsoup)
// Sort
implementation(libs.natural.comparator)
// JavaScript engine
implementation(libs.bundles.js.engine)
}

View File

@ -1,15 +1,11 @@
package eu.kanade.tachiyomi.util.storage
import android.Manifest
import android.content.Context
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Environment
import android.os.StatFs
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.core.content.ContextCompat
import com.google.accompanist.permissions.rememberPermissionState
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.util.lang.Hash
import java.io.File
@ -117,16 +113,5 @@ object DiskUtil {
}
}
/**
* Launches request for [Manifest.permission.WRITE_EXTERNAL_STORAGE] permission
*/
@Composable
fun RequestStoragePermission() {
val permissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
LaunchedEffect(Unit) {
permissionState.launchPermissionRequest()
}
}
const val NOMEDIA_FILE = ".nomedia"
}

View File

@ -1,15 +1,10 @@
package eu.kanade.tachiyomi.util.storage
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import java.io.Closeable
import java.io.File
import java.io.InputStream
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
@ -49,58 +44,6 @@ class EpubFile(file: File) : Closeable {
return zip.getEntry(name)
}
/**
* 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
}
}
}
/**
* Returns the path of all the images found in the epub file.
*/
@ -114,7 +57,7 @@ class EpubFile(file: File) : Closeable {
/**
* Returns the path to the package document.
*/
private fun getPackageHref(): String {
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, "") }
@ -129,7 +72,7 @@ class EpubFile(file: File) : Closeable {
/**
* Returns the package document where all the files are listed.
*/
private fun getPackageDocument(ref: String): Document {
fun getPackageDocument(ref: String): Document {
val entry = zip.getEntry(ref)
return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
}
@ -137,7 +80,7 @@ class EpubFile(file: File) : Closeable {
/**
* Returns all the pages from the epub.
*/
private fun getPagesFromDocument(document: Document): List<String> {
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") }

View File

@ -1,7 +1,8 @@
package eu.kanade.tachiyomi.util.system
package tachiyomi.core.util.system
import android.content.Context
import android.content.res.Configuration
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
@ -22,7 +23,6 @@ import androidx.core.graphics.green
import androidx.core.graphics.red
import com.hippo.unifile.UniFile
import logcat.LogPriority
import tachiyomi.core.util.system.logcat
import tachiyomi.decoder.Format
import tachiyomi.decoder.ImageDecoder
import java.io.BufferedInputStream
@ -31,6 +31,7 @@ import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.net.URLConnection
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
object ImageUtil {
@ -587,3 +588,6 @@ object ImageUtil {
"image/jxl" to "jxl",
)
}
val getDisplayMaxHeightInPx: Int
get() = Resources.getSystem().displayMetrics.let { max(it.heightPixels, it.widthPixels) }

View File

@ -45,3 +45,5 @@ include(":data")
include(":domain")
include(":presentation-widget")
include(":presentation-core")
include(":source-local")
include(":core-metadata")

1
source-local/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -0,0 +1,29 @@
plugins {
id("com.android.library")
kotlin("android")
}
android {
namespace = "tachiyomi.source.local"
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
}
dependencies {
implementation(project(":source-api"))
implementation(project(":core"))
implementation(project(":core-metadata"))
// Move ChapterRecognition to separate module?
implementation(project(":domain"))
implementation(kotlinx.bundles.serialization)
implementation(libs.unifile)
implementation(libs.junrar)
}

View File

21
source-local/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@ -1,32 +1,36 @@
package eu.kanade.tachiyomi.source
package tachiyomi.source.local
import android.content.Context
import com.github.junrar.Archive
import com.hippo.unifile.UniFile
import eu.kanade.domain.manga.model.COMIC_INFO_FILE
import eu.kanade.domain.manga.model.ComicInfo
import eu.kanade.domain.manga.model.copyFromComicInfo
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.UnmeteredSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.EpubFile
import eu.kanade.tachiyomi.util.system.ImageUtil
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import logcat.LogPriority
import nl.adaptivity.xmlutil.AndroidXmlReader
import nl.adaptivity.xmlutil.serialization.XML
import rx.Observable
import tachiyomi.core.metadata.tachiyomi.MangaDetails
import tachiyomi.core.util.lang.withIOContext
import tachiyomi.core.util.system.ImageUtil
import tachiyomi.core.util.system.logcat
import tachiyomi.domain.chapter.service.ChapterRecognition
import tachiyomi.source.local.filter.OrderBy
import tachiyomi.source.local.image.LocalCoverManager
import tachiyomi.source.local.io.Archive
import tachiyomi.source.local.io.Format
import tachiyomi.source.local.io.LocalSourceFileSystem
import tachiyomi.source.local.metadata.fillChapterMetadata
import tachiyomi.source.local.metadata.fillMangaMetadata
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.FileInputStream
@ -34,14 +38,20 @@ import java.io.InputStream
import java.nio.charset.StandardCharsets
import java.util.concurrent.TimeUnit
import java.util.zip.ZipFile
import com.github.junrar.Archive as JunrarArchive
class LocalSource(
private val context: Context,
private val fileSystem: LocalSourceFileSystem,
private val coverManager: LocalCoverManager,
) : CatalogueSource, UnmeteredSource {
private val json: Json by injectLazy()
private val xml: XML by injectLazy()
private val POPULAR_FILTERS = FilterList(OrderBy.Popular(context))
private val LATEST_FILTERS = FilterList(OrderBy.Latest(context))
override val name: String = context.getString(R.string.local_source)
override val id: Long = ID
@ -58,41 +68,34 @@ class LocalSource(
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
val baseDirsFiles = getBaseDirectoriesFiles(context)
val baseDirsFiles = fileSystem.getFilesInBaseDirectories()
val lastModifiedLimit by lazy { if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L }
var mangaDirs = baseDirsFiles
// Filter out files that are hidden and is not a folder
.filter { it.isDirectory && !it.name.startsWith('.') }
.distinctBy { it.name }
val lastModifiedLimit = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
// Filter by query or last modified
mangaDirs = mangaDirs.filter {
if (lastModifiedLimit == 0L) {
it.name.contains(query, ignoreCase = true)
} else {
it.lastModified() >= lastModifiedLimit
.filter { // Filter by query or last modified
if (lastModifiedLimit == 0L) {
it.name.contains(query, ignoreCase = true)
} else {
it.lastModified() >= lastModifiedLimit
}
}
}
filters.forEach { filter ->
when (filter) {
is OrderBy -> {
when (filter.state!!.index) {
0 -> {
mangaDirs = if (filter.state!!.ascending) {
mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
} else {
mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name })
}
}
1 -> {
mangaDirs = if (filter.state!!.ascending) {
mangaDirs.sortedBy(File::lastModified)
} else {
mangaDirs.sortedByDescending(File::lastModified)
}
}
is OrderBy.Popular -> {
mangaDirs = if (filter.state!!.ascending) {
mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
} else {
mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name })
}
}
is OrderBy.Latest -> {
mangaDirs = if (filter.state!!.ascending) {
mangaDirs.sortedBy(File::lastModified)
} else {
mangaDirs.sortedByDescending(File::lastModified)
}
}
@ -109,10 +112,9 @@ class LocalSource(
url = mangaDir.name
// Try to find the cover
val cover = getCoverFile(mangaDir.name, baseDirsFiles)
if (cover != null && cover.exists()) {
thumbnail_url = cover.absolutePath
}
coverManager.find(mangaDir.name)
?.takeIf(File::exists)
?.let { thumbnail_url = it.absolutePath }
}
}
@ -143,15 +145,13 @@ class LocalSource(
// Manga details related
override suspend fun getMangaDetails(manga: SManga): SManga = withIOContext {
val baseDirsFile = getBaseDirectoriesFiles(context)
getCoverFile(manga.url, baseDirsFile)?.let {
coverManager.find(manga.url)?.let {
manga.thumbnail_url = it.absolutePath
}
// Augment manga details based on metadata files
try {
val mangaDirFiles = getMangaDirsFiles(manga.url, baseDirsFile).toList()
val mangaDirFiles = fileSystem.getFilesInMangaDirectory(manga.url).toList()
val comicInfoFile = mangaDirFiles
.firstOrNull { it.name == COMIC_INFO_FILE }
@ -182,10 +182,10 @@ class LocalSource(
// Copy ComicInfo.xml from chapter archive to top level if found
noXmlFile == null -> {
val chapterArchives = mangaDirFiles
.filter { isSupportedArchiveFile(it.extension) }
.filter(Archive::isSupported)
.toList()
val mangaDir = getMangaDir(manga.url, baseDirsFile)
val mangaDir = fileSystem.getMangaDirectory(manga.url)
val folderPath = mangaDir?.absolutePath
val copiedFile = copyComicInfoFileFromArchive(chapterArchives, folderPath)
@ -206,7 +206,7 @@ class LocalSource(
private fun copyComicInfoFileFromArchive(chapterArchives: List<File>, folderPath: String?): File? {
for (chapter in chapterArchives) {
when (getFormat(chapter)) {
when (Format.valueOf(chapter)) {
is Format.Zip -> {
ZipFile(chapter).use { zip: ZipFile ->
zip.getEntry(COMIC_INFO_FILE)?.let { comicInfoFile ->
@ -217,7 +217,7 @@ class LocalSource(
}
}
is Format.Rar -> {
Archive(chapter).use { rar: Archive ->
JunrarArchive(chapter).use { rar ->
rar.fileHeaders.firstOrNull { it.fileName == COMIC_INFO_FILE }?.let { comicInfoFile ->
rar.getInputStream(comicInfoFile).buffered().use { stream ->
return copyComicInfoFile(stream, folderPath)
@ -247,22 +247,11 @@ class LocalSource(
manga.copyFromComicInfo(comicInfo)
}
@Serializable
class MangaDetails(
val title: String? = null,
val author: String? = null,
val artist: String? = null,
val description: String? = null,
val genre: List<String>? = null,
val status: Int? = null,
)
// Chapters
override suspend fun getChapterList(manga: SManga): List<SChapter> {
val baseDirsFile = getBaseDirectoriesFiles(context)
return getMangaDirsFiles(manga.url, baseDirsFile)
return fileSystem.getFilesInMangaDirectory(manga.url)
// Only keep supported formats
.filter { it.isDirectory || isSupportedArchiveFile(it.extension) }
.filter { it.isDirectory || Archive.isSupported(it) }
.map { chapterFile ->
SChapter.create().apply {
url = "${manga.url}/${chapterFile.name}"
@ -274,7 +263,7 @@ class LocalSource(
date_upload = chapterFile.lastModified()
chapter_number = ChapterRecognition.parseChapterNumber(manga.title, this.name, this.chapter_number)
val format = getFormat(chapterFile)
val format = Format.valueOf(chapterFile)
if (format is Format.Epub) {
EpubFile(format.file).use { epub ->
epub.fillChapterMetadata(this)
@ -290,44 +279,22 @@ class LocalSource(
}
// Filters
override fun getFilterList() = FilterList(OrderBy(context))
private val POPULAR_FILTERS = FilterList(OrderBy(context))
private val LATEST_FILTERS = FilterList(OrderBy(context).apply { state = Filter.Sort.Selection(1, false) })
private class OrderBy(context: Context) : Filter.Sort(
context.getString(R.string.local_filter_order_by),
arrayOf(context.getString(R.string.title), context.getString(R.string.date)),
Selection(0, true),
)
override fun getFilterList() = FilterList(OrderBy.Popular(context))
// Unused stuff
override suspend fun getPageList(chapter: SChapter) = throw UnsupportedOperationException("Unused")
// Miscellaneous
private fun isSupportedArchiveFile(extension: String): Boolean {
return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES
}
fun getFormat(chapter: SChapter): Format {
val baseDirs = getBaseDirectories(context)
for (dir in baseDirs) {
val chapFile = File(dir, chapter.url)
if (!chapFile.exists()) continue
return getFormat(chapFile)
}
throw Exception(context.getString(R.string.chapter_not_found))
}
private fun getFormat(file: File) = with(file) {
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)
extension.equals("epub", true) -> Format.Epub(this)
else -> throw Exception(context.getString(R.string.local_invalid_format))
try {
return fileSystem.getBaseDirectories()
.map { directory -> File(directory, chapter.url) }
.find { chapterFile -> chapterFile.exists() }
?.let(Format.Companion::valueOf)
?: throw Exception(context.getString(R.string.chapter_not_found))
} catch (e: Format.UnknownFormatException) {
throw Exception(context.getString(R.string.local_invalid_format))
} catch (e: Exception) {
throw e
}
}
@ -339,7 +306,7 @@ class LocalSource(
?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
entry?.let { updateCover(context, manga, it.inputStream()) }
entry?.let { coverManager.update(manga, it.inputStream()) }
}
is Format.Zip -> {
ZipFile(format.file).use { zip ->
@ -347,16 +314,16 @@ class LocalSource(
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
entry?.let { updateCover(context, manga, zip.getInputStream(it)) }
entry?.let { coverManager.update(manga, zip.getInputStream(it)) }
}
}
is Format.Rar -> {
Archive(format.file).use { archive ->
JunrarArchive(format.file).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(context, manga, archive.getInputStream(it)) }
entry?.let { coverManager.update(manga, archive.getInputStream(it)) }
}
}
is Format.Epub -> {
@ -365,7 +332,7 @@ class LocalSource(
.firstOrNull()
?.let { epub.getEntry(it) }
entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
entry?.let { coverManager.update(manga, epub.getInputStream(it)) }
}
}
}
@ -375,86 +342,10 @@ class LocalSource(
}
}
sealed class Format {
data class Directory(val file: File) : Format()
data class Zip(val file: File) : Format()
data class Rar(val file: File) : Format()
data class Epub(val file: File) : Format()
}
companion object {
const val ID = 0L
const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/"
private const val DEFAULT_COVER_NAME = "cover.jpg"
private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
private fun getBaseDirectories(context: Context): Sequence<File> {
val localFolder = context.getString(R.string.app_name) + File.separator + "local"
return DiskUtil.getExternalStorages(context)
.map { File(it.absolutePath, localFolder) }
.asSequence()
}
private fun getBaseDirectoriesFiles(context: Context): Sequence<File> {
return getBaseDirectories(context)
// Get all the files inside all baseDir
.flatMap { it.listFiles().orEmpty().toList() }
}
private fun getMangaDir(mangaUrl: String, baseDirsFile: Sequence<File>): File? {
return baseDirsFile
// Get the first mangaDir or null
.firstOrNull { it.isDirectory && it.name == mangaUrl }
}
private fun getMangaDirsFiles(mangaUrl: String, baseDirsFile: Sequence<File>): Sequence<File> {
return baseDirsFile
// Filter out ones that are not related to the manga and is not a directory
.filter { it.isDirectory && it.name == mangaUrl }
// Get all the files inside the filtered folders
.flatMap { it.listFiles().orEmpty().toList() }
}
private fun getCoverFile(mangaUrl: String, baseDirsFile: Sequence<File>): File? {
return getMangaDirsFiles(mangaUrl, baseDirsFile)
// Get all file whose names start with 'cover'
.filter { it.isFile && it.nameWithoutExtension.equals("cover", ignoreCase = true) }
// Get the first actual image
.firstOrNull {
ImageUtil.isImage(it.name) { it.inputStream() }
}
}
fun updateCover(context: Context, manga: SManga, inputStream: InputStream): File? {
val baseDirsFiles = getBaseDirectoriesFiles(context)
val mangaDir = getMangaDir(manga.url, baseDirsFiles)
if (mangaDir == null) {
inputStream.close()
return null
}
var coverFile = getCoverFile(manga.url, baseDirsFiles)
if (coverFile == null) {
coverFile = File(mangaDir.absolutePath, DEFAULT_COVER_NAME)
coverFile.createNewFile()
}
// It might not exist at this point
coverFile.parentFile?.mkdirs()
inputStream.use { input ->
coverFile.outputStream().use { output ->
input.copyTo(output)
}
}
DiskUtil.createNoMediaFile(UniFile.fromFile(mangaDir), context)
manga.thumbnail_url = coverFile.absolutePath
return coverFile
}
}
}
private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "epub")

View File

@ -0,0 +1,14 @@
package tachiyomi.source.local.filter
import android.content.Context
import eu.kanade.tachiyomi.source.model.Filter
import tachiyomi.source.local.R
sealed class OrderBy(context: Context, selection: Selection) : Filter.Sort(
context.getString(R.string.local_filter_order_by),
arrayOf(context.getString(R.string.title), context.getString(R.string.date)),
selection,
) {
class Popular(context: Context) : OrderBy(context, Selection(0, true))
class Latest(context: Context) : OrderBy(context, Selection(1, false))
}

View File

@ -0,0 +1,55 @@
package tachiyomi.source.local.image
import android.content.Context
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.storage.DiskUtil
import tachiyomi.core.util.system.ImageUtil
import tachiyomi.source.local.io.LocalSourceFileSystem
import java.io.File
import java.io.InputStream
private const val DEFAULT_COVER_NAME = "cover.jpg"
class AndroidLocalCoverManager(
private val context: Context,
private val fileSystem: LocalSourceFileSystem,
) : LocalCoverManager {
override fun find(mangaUrl: String): File? {
return fileSystem.getFilesInMangaDirectory(mangaUrl)
// Get all file whose names start with 'cover'
.filter { it.isFile && it.nameWithoutExtension.equals("cover", ignoreCase = true) }
// Get the first actual image
.firstOrNull {
ImageUtil.isImage(it.name) { it.inputStream() }
}
}
override fun update(manga: SManga, inputStream: InputStream): File? {
val directory = fileSystem.getMangaDirectory(manga.url)
if (directory == null) {
inputStream.close()
return null
}
var targetFile = find(manga.url)
if (targetFile == null) {
targetFile = File(directory.absolutePath, DEFAULT_COVER_NAME)
targetFile.createNewFile()
}
// It might not exist at this point
targetFile.parentFile?.mkdirs()
inputStream.use { input ->
targetFile.outputStream().use { output ->
input.copyTo(output)
}
}
DiskUtil.createNoMediaFile(UniFile.fromFile(directory), context)
manga.thumbnail_url = targetFile.absolutePath
return targetFile
}
}

View File

@ -0,0 +1,12 @@
package tachiyomi.source.local.image
import eu.kanade.tachiyomi.source.model.SManga
import java.io.File
import java.io.InputStream
interface LocalCoverManager {
fun find(mangaUrl: String): File?
fun update(manga: SManga, inputStream: InputStream): File?
}

View File

@ -0,0 +1,39 @@
package tachiyomi.source.local.io
import android.content.Context
import eu.kanade.tachiyomi.util.storage.DiskUtil
import tachiyomi.source.local.R
import java.io.File
class AndroidLocalSourceFileSystem(
private val context: Context,
) : LocalSourceFileSystem {
private val baseFolderLocation = "${context.getString(R.string.app_name)}${File.separator}local"
override fun getBaseDirectories(): Sequence<File> {
return DiskUtil.getExternalStorages(context)
.map { File(it.absolutePath, baseFolderLocation) }
.asSequence()
}
override fun getFilesInBaseDirectories(): Sequence<File> {
return getBaseDirectories()
// Get all the files inside all baseDir
.flatMap { it.listFiles().orEmpty().toList() }
}
override fun getMangaDirectory(name: String): File? {
return getFilesInBaseDirectories()
// Get the first mangaDir or null
.firstOrNull { it.isDirectory && it.name == name }
}
override fun getFilesInMangaDirectory(name: String): Sequence<File> {
return getFilesInBaseDirectories()
// Filter out ones that are not related to the manga and is not a directory
.filter { it.isDirectory && it.name == name }
// Get all the files inside the filtered folders
.flatMap { it.listFiles().orEmpty().toList() }
}
}

View File

@ -0,0 +1,12 @@
package tachiyomi.source.local.io
import java.io.File
object Archive {
private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "epub")
fun isSupported(file: File): Boolean = with(file) {
return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES
}
}

View File

@ -0,0 +1,25 @@
package tachiyomi.source.local.io
import java.io.File
sealed class Format {
data class Directory(val file: File) : Format()
data class Zip(val file: File) : Format()
data class Rar(val file: File) : Format()
data class Epub(val file: File) : Format()
class UnknownFormatException : Exception()
companion object {
fun valueOf(file: File) = with(file) {
when {
isDirectory -> Directory(this)
extension.equals("zip", true) || extension.equals("cbz", true) -> Zip(this)
extension.equals("rar", true) || extension.equals("cbr", true) -> Rar(this)
extension.equals("epub", true) -> Epub(this)
else -> throw UnknownFormatException()
}
}
}
}

View File

@ -0,0 +1,14 @@
package tachiyomi.source.local.io
import java.io.File
interface LocalSourceFileSystem {
fun getBaseDirectories(): Sequence<File>
fun getFilesInBaseDirectories(): Sequence<File>
fun getMangaDirectory(name: String): File?
fun getFilesInMangaDirectory(name: String): Sequence<File>
}

View File

@ -0,0 +1,60 @@
package tachiyomi.source.local.metadata
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.storage.EpubFile
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
/**
* Fills manga metadata using this epub file's metadata.
*/
fun EpubFile.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 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()
}
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
}
}
}