Browse Source

Move Local Source to separate module (#9152)

* Move Local Source to separate module

* Review changes
Andreas 2 years ago
parent
commit
f27dc19b37
57 changed files with 523 additions and 314 deletions
  1. 3 4
      app/build.gradle.kts
  2. 1 1
      app/src/main/java/eu/kanade/data/source/SourceRepositoryImpl.kt
  3. 24 1
      app/src/main/java/eu/kanade/domain/manga/model/Manga.kt
  4. 1 1
      app/src/main/java/eu/kanade/domain/source/interactor/GetEnabledSources.kt
  5. 1 1
      app/src/main/java/eu/kanade/presentation/browse/BrowseSourceScreen.kt
  6. 1 1
      app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt
  7. 1 1
      app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt
  8. 1 1
      app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceToolbar.kt
  9. 18 0
      app/src/main/java/eu/kanade/presentation/extensions/DiskUtil.kt
  10. 1 0
      app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBackupScreen.kt
  11. 7 0
      app/src/main/java/eu/kanade/tachiyomi/AppModule.kt
  12. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/coil/TachiyomiImageDecoder.kt
  13. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt
  14. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/saver/ImageSaver.kt
  15. 1 0
      app/src/main/java/eu/kanade/tachiyomi/source/SourceExtensions.kt
  16. 12 1
      app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt
  17. 1 0
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseTab.kt
  18. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchScreen.kt
  19. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt
  20. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaCoverScreenModel.kt
  21. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt
  22. 6 5
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt
  23. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt
  24. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt
  25. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt
  26. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt
  27. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt
  28. 3 4
      app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt
  29. 0 4
      app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt
  30. 1 0
      core-metadata/.gitignore
  31. 21 0
      core-metadata/build.gradle.kts
  32. 0 0
      core-metadata/consumer-rules.pro
  33. 21 0
      core-metadata/proguard-rules.pro
  34. 2 0
      core-metadata/src/main/AndroidManifest.xml
  35. 1 25
      core-metadata/src/main/java/tachiyomi/core/metadata/comicinfo/ComicInfo.kt
  36. 13 0
      core-metadata/src/main/java/tachiyomi/core/metadata/tachiyomi/MangaDetails.kt
  37. 9 0
      core/build.gradle.kts
  38. 0 0
      core/src/main/java/eu/kanade/tachiyomi/util/lang/Hash.kt
  39. 0 0
      core/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt
  40. 0 15
      core/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt
  41. 3 60
      core/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt
  42. 6 2
      core/src/main/java/tachiyomi/core/util/system/ImageUtil.kt
  43. 2 0
      settings.gradle.kts
  44. 1 0
      source-local/.gitignore
  45. 29 0
      source-local/build.gradle.kts
  46. 0 0
      source-local/consumer-rules.pro
  47. 21 0
      source-local/proguard-rules.pro
  48. 2 0
      source-local/src/main/AndroidManifest.xml
  49. 66 175
      source-local/src/main/java/tachiyomi/source/local/LocalSource.kt
  50. 14 0
      source-local/src/main/java/tachiyomi/source/local/filter/OrderBy.kt
  51. 55 0
      source-local/src/main/java/tachiyomi/source/local/image/AndroidLocalCoverManager.kt
  52. 12 0
      source-local/src/main/java/tachiyomi/source/local/image/LocalCoverManager.kt
  53. 39 0
      source-local/src/main/java/tachiyomi/source/local/io/AndroidLocalSourceFileSystem.kt
  54. 12 0
      source-local/src/main/java/tachiyomi/source/local/io/Archive.kt
  55. 25 0
      source-local/src/main/java/tachiyomi/source/local/io/Format.kt
  56. 14 0
      source-local/src/main/java/tachiyomi/source/local/io/LocalSourceFileSystem.kt
  57. 60 0
      source-local/src/main/java/tachiyomi/source/local/metadata/EpubFile.kt

+ 3 - 4
app/build.gradle.kts

@@ -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)

+ 1 - 1
app/src/main/java/eu/kanade/data/source/SourceRepositoryImpl.kt

@@ -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,

+ 24 - 1
app/src/main/java/eu/kanade/domain/manga/model/Manga.kt

@@ -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,
+)

+ 1 - 1
app/src/main/java/eu/kanade/domain/source/interactor/GetEnabledSources.kt

@@ -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,

+ 1 - 1
app/src/main/java/eu/kanade/presentation/browse/BrowseSourceScreen.kt

@@ -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(

+ 1 - 1
app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt

@@ -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(

+ 1 - 1
app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt

@@ -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)

+ 1 - 1
app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceToolbar.kt

@@ -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(

+ 18 - 0
app/src/main/java/eu/kanade/presentation/extensions/DiskUtil.kt

@@ -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()
+    }
+}

+ 1 - 0
app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBackupScreen.kt

@@ -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

+ 7 - 0
app/src/main/java/eu/kanade/tachiyomi/AppModule.kt

@@ -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>()

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/coil/TachiyomiImageDecoder.kt

@@ -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
 
 /**

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt

@@ -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

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/saver/ImageSaver.kt

@@ -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

+ 1 - 0
app/src/main/java/eu/kanade/tachiyomi/source/SourceExtensions.kt

@@ -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
 

+ 12 - 1
app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt

@@ -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

+ 1 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/browse/BrowseTab.kt

@@ -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

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchScreen.kt

@@ -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,

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt

@@ -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,

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaCoverScreenModel.kt

@@ -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)

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt

@@ -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 {

+ 6 - 5
app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt

@@ -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()

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt

@@ -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
 

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt

@@ -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

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt

@@ -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

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt

@@ -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

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt

@@ -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
 

+ 3 - 4
app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt

@@ -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)

+ 0 - 4
app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt

@@ -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 - 0
core-metadata/.gitignore

@@ -0,0 +1 @@
+/build

+ 21 - 0
core-metadata/build.gradle.kts

@@ -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)
+}

+ 0 - 0
core-metadata/consumer-rules.pro


+ 21 - 0
core-metadata/proguard-rules.pro

@@ -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

+ 2 - 0
core-metadata/src/main/AndroidManifest.xml

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

+ 1 - 25
app/src/main/java/eu/kanade/domain/manga/model/ComicInfo.kt → core-metadata/src/main/java/tachiyomi/core/metadata/comicinfo/ComicInfo.kt

@@ -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,
 ) {

+ 13 - 0
core-metadata/src/main/java/tachiyomi/core/metadata/tachiyomi/MangaDetails.kt

@@ -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,
+)

+ 9 - 0
core/build.gradle.kts

@@ -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)
 }

+ 0 - 0
app/src/main/java/eu/kanade/tachiyomi/util/lang/Hash.kt → core/src/main/java/eu/kanade/tachiyomi/util/lang/Hash.kt


+ 0 - 0
app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt → core/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt


+ 0 - 15
app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt → core/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt

@@ -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"
 }

+ 3 - 60
app/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt → core/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt

@@ -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") }

+ 6 - 2
app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt → core/src/main/java/tachiyomi/core/util/system/ImageUtil.kt

@@ -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) }

+ 2 - 0
settings.gradle.kts

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

+ 1 - 0
source-local/.gitignore

@@ -0,0 +1 @@
+/build

+ 29 - 0
source-local/build.gradle.kts

@@ -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)
+}

+ 0 - 0
source-local/consumer-rules.pro


+ 21 - 0
source-local/proguard-rules.pro

@@ -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

+ 2 - 0
source-local/src/main/AndroidManifest.xml

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

+ 66 - 175
app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt → source-local/src/main/java/tachiyomi/source/local/LocalSource.kt

@@ -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")

+ 14 - 0
source-local/src/main/java/tachiyomi/source/local/filter/OrderBy.kt

@@ -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))
+}

+ 55 - 0
source-local/src/main/java/tachiyomi/source/local/image/AndroidLocalCoverManager.kt

@@ -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
+    }
+}

+ 12 - 0
source-local/src/main/java/tachiyomi/source/local/image/LocalCoverManager.kt

@@ -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?
+}

+ 39 - 0
source-local/src/main/java/tachiyomi/source/local/io/AndroidLocalSourceFileSystem.kt

@@ -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() }
+    }
+}

+ 12 - 0
source-local/src/main/java/tachiyomi/source/local/io/Archive.kt

@@ -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
+    }
+}

+ 25 - 0
source-local/src/main/java/tachiyomi/source/local/io/Format.kt

@@ -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()
+            }
+        }
+    }
+}

+ 14 - 0
source-local/src/main/java/tachiyomi/source/local/io/LocalSourceFileSystem.kt

@@ -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>
+}

+ 60 - 0
source-local/src/main/java/tachiyomi/source/local/metadata/EpubFile.kt

@@ -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
+        }
+    }
+}