feat(source/local): Scan external storage for entries (GH-197)

An experimental feature that allow user to store local entries on `/storage/sdcard/Android/data/<yokai>/local/`
This commit is contained in:
Ahmad Ansori Palembani 2024-11-25 14:44:57 +07:00
parent f9bfb0b423
commit 8f9194c4a9
Signed by: null2264
GPG key ID: BA64F8B60AF3EFB6
6 changed files with 90 additions and 39 deletions

View file

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.source
import android.app.Application
import android.content.Context
import androidx.core.net.toFile
import co.touchlab.kermit.Logger
@ -10,18 +11,19 @@ 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.storage.fillMetadata
import eu.kanade.tachiyomi.util.system.ImageUtil
import eu.kanade.tachiyomi.util.system.e
import eu.kanade.tachiyomi.util.system.extension
import eu.kanade.tachiyomi.util.system.nameWithoutExtension
import eu.kanade.tachiyomi.util.system.withIOContext
import eu.kanade.tachiyomi.util.system.writeText
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.nio.charset.StandardCharsets
import java.util.concurrent.*
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.serialization.Serializable
@ -38,6 +40,7 @@ import yokai.core.metadata.ComicInfo
import yokai.core.metadata.copyFromComicInfo
import yokai.core.metadata.toComicInfo
import yokai.domain.chapter.services.ChapterRecognition
import yokai.domain.source.SourcePreferences
import yokai.domain.storage.StorageManager
import yokai.i18n.MR
import yokai.util.lang.getString
@ -57,9 +60,14 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
}
}
fun getMangaLang(manga: SManga): String {
fun getMangaLang(manga: SManga, context: Context = Injekt.get<Application>()): String {
return langMap.getOrPut(manga.url) {
val localDetails = getBaseDirectory()?.findFile(manga.url)?.listFiles().orEmpty()
val dir = getBaseDirectories(context).asSequence()
.mapNotNull { it?.findFile(manga.url)?.listFiles() }
.firstOrNull()
.orEmpty()
val localDetails = dir.orEmpty()
.filter { !it.isDirectory }
.firstOrNull { it.name == COMIC_INFO_FILE }
@ -71,15 +79,19 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
}
}
fun invalidateCover(manga: SManga) {
val dir = getBaseDirectory()?.findFile(manga.url) ?: return
fun invalidateCover(manga: SManga, context: Context = Injekt.get<Application>()) {
val dir = getBaseDirectories(context).asSequence()
.mapNotNull { it?.findFile(manga.url) }
.firstOrNull() ?: return
val cover = getCoverFile(dir) ?: return
manga.thumbnail_url = cover.uri.toString()
}
fun updateCover(manga: SManga, input: InputStream): UniFile? {
val dir = getBaseDirectory()?.findFile(manga.url)
fun updateCover(manga: SManga, input: InputStream, context: Context = Injekt.get<Application>()): UniFile? {
val dir = getBaseDirectories(context).asSequence()
.mapNotNull { it?.findFile(manga.url) }
.firstOrNull()
if (dir == null) {
input.close()
return null
@ -117,9 +129,19 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
}
private fun getBaseDirectory(): UniFile? {
val storageManager: StorageManager by injectLazy()
val storageManager: StorageManager = Injekt.get()
return storageManager.getLocalSourceDirectory()
}
private fun getBaseDirectories(context: Context): List<UniFile?> {
val sourcePreferences: SourcePreferences = Injekt.get()
val base = listOf(getBaseDirectory())
if (!sourcePreferences.externalLocalSource().get()) return base
return base + DiskUtil.getExternalStorages(context, false).map {
UniFile.fromFile(File(it, StorageManager.LOCAL_SOURCE_PATH))
}
}
}
private val json: Json by injectLazy()
@ -145,7 +167,9 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
0L
}
var mangaDirs = getBaseDirectory()?.listFiles().orEmpty()
var mangaDirs = getBaseDirectories(context).asSequence()
.mapNotNull { it?.listFiles()?.toList() }
.flatten()
.filter { it.isDirectory && !it.name.orEmpty().startsWith('.') }
.distinctBy { it.name }
.filter {
@ -175,7 +199,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
}
}
val mangas = mangaDirs.map { mangaDir ->
val mangas = mangaDirs.toList().map { mangaDir ->
async {
SManga.create().apply {
title = mangaDir.name.orEmpty()
@ -190,17 +214,19 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
}
}.awaitAll()
MangasPage(mangas.toList(), false)
MangasPage(mangas, false)
}
override suspend fun getLatestUpdates(page: Int) = getSearchManga(page, "", latestFilters)
override suspend fun getMangaDetails(manga: SManga): SManga = withIOContext {
// Making sure that we have the latest cover file path, in case user use different file format
invalidateCover(manga)
invalidateCover(manga, context)
try {
val localMangaDir = getBaseDirectory()?.findFile(manga.url) ?: throw Exception("${manga.url} is not a valid directory")
val localMangaDir = getBaseDirectories(context).asSequence()
.mapNotNull { it?.findFile(manga.url) }
.firstOrNull() ?: throw Exception("${manga.url} is not a valid directory")
val localMangaFiles = localMangaDir.listFiles().orEmpty().filter { !it.isDirectory }
val comicInfoFile = localMangaFiles.firstOrNull { it.name.orEmpty() == COMIC_INFO_FILE }
val legacyJsonFile = localMangaFiles.firstOrNull { it.extension.orEmpty().equals("json", true) }
@ -249,7 +275,9 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
}
fun updateMangaInfo(manga: SManga, lang: String?) {
val directory = getBaseDirectory()?.findFile(manga.url) ?: return
val directory = getBaseDirectories(context).asSequence()
.mapNotNull { it?.findFile(manga.url) }
.firstOrNull() ?: return
if (!directory.exists()) return
lang?.let { langMap[manga.url] = it }
@ -274,8 +302,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
other as MangaJson
if (title != other.title) return false
return true
return title == other.title
}
override fun hashCode(): Int {
@ -284,7 +311,12 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
}
override suspend fun getChapterList(manga: SManga): List<SChapter> = withIOContext {
val chapters = getBaseDirectory()?.findFile(manga.url)?.listFiles().orEmpty()
val dir = getBaseDirectories(context).asSequence()
.mapNotNull { it?.findFile(manga.url)?.listFiles() }
.firstOrNull()
.orEmpty()
val chapters = dir
.filter { it.isDirectory || isSupportedArchive(it.extension.orEmpty()) || it.extension.equals("epub", true) }
.map { chapterFile ->
SChapter.create().apply {
@ -331,12 +363,10 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
}
fun getFormat(chapter: SChapter): Format {
val dir = getBaseDirectory()
val (mangaDirName, chapterName) = chapter.url.split('/', limit = 2)
val chapFile = dir
?.findFile(mangaDirName)
?.findFile(chapterName)
val chapFile = getBaseDirectories(context).asSequence()
.mapNotNull { it?.findFile(mangaDirName)?.findFile(chapterName) }
.firstOrNull()
if (chapFile == null || !chapFile.exists())
throw Exception(context.getString(MR.strings.chapter_not_found))
@ -360,7 +390,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
?.sortedWith { f1, f2 -> f1.name.orEmpty().compareToCaseInsensitiveNaturalOrder(f2.name.orEmpty()) }
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it.uri.toFile()) } }
entry?.let { updateCover(manga, it.openInputStream()) }
entry?.let { updateCover(manga, it.openInputStream(), context) }
}
is Format.Archive -> {
format.file.archiveReader(context).use { reader ->
@ -370,14 +400,14 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
.find { it.isFile && ImageUtil.isImage(it.name) { reader.getInputStream(it.name)!! } }
}
entry?.let { updateCover(manga, reader.getInputStream(it.name)!!) }
entry?.let { updateCover(manga, reader.getInputStream(it.name)!!, context) }
}
}
is Format.Epub -> {
EpubFile(format.file.archiveReader(context)).use { epub ->
val entry = epub.getImagesFromPages().firstOrNull()
entry?.let { updateCover(manga, epub.getInputStream(it)!!) }
entry?.let { updateCover(manga, epub.getInputStream(it)!!, context) }
}
}
}

View file

@ -57,6 +57,7 @@ import eu.kanade.tachiyomi.ui.setting.preference
import eu.kanade.tachiyomi.ui.setting.preferenceCategory
import eu.kanade.tachiyomi.ui.setting.switchPreference
import eu.kanade.tachiyomi.util.CrashLogUtil
import eu.kanade.tachiyomi.util.lang.addBetaTag
import eu.kanade.tachiyomi.util.system.GLUtil
import eu.kanade.tachiyomi.util.system.disableItems
import eu.kanade.tachiyomi.util.system.isPackageInstalled
@ -88,6 +89,7 @@ import uy.kohesive.injekt.injectLazy
import yokai.domain.base.BasePreferences.ExtensionInstaller
import yokai.domain.extension.interactor.TrustExtension
import yokai.domain.manga.interactor.GetManga
import yokai.domain.source.SourcePreferences
import yokai.domain.ui.settings.ReaderPreferences
import yokai.i18n.MR
import yokai.util.lang.getString
@ -100,6 +102,7 @@ class SettingsAdvancedController : SettingsLegacyController() {
private val network: NetworkHelper by injectLazy()
private val networkPreferences: NetworkPreferences by injectLazy()
private val readerPreferences: ReaderPreferences by injectLazy()
private val sourcePreferences: SourcePreferences by injectLazy()
private val db: DatabaseHelper by injectLazy()
@ -440,6 +443,15 @@ class SettingsAdvancedController : SettingsLegacyController() {
}
}
preferenceCategory {
titleRes = MR.strings.local_source
switchPreference {
bindTo(sourcePreferences.externalLocalSource())
title = context.getString(MR.strings.pref_external_local_source).addBetaTag(context)
}
}
preferenceCategory {
title = "Danger zone!"

View file

@ -58,17 +58,21 @@ object DiskUtil {
}
}
fun File.isMounted(): Boolean {
val state = EnvironmentCompat.getStorageState(this)
return state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY
}
/**
* Returns the root folders of all the available external storages.
* Returns the folders of all the available external storages.
*/
fun getExternalStorages(context: Context): List<File> {
fun getExternalStorages(context: Context, root: Boolean = true): List<File> {
return context.getExternalFilesDirs(null)
.filterNotNull()
.mapNotNull {
val file = File(it.absolutePath.substringBefore("/Android/"))
val state = EnvironmentCompat.getStorageState(file)
if (state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY) {
file
if (file.isMounted()) {
if (root) file else it
} else {
null
}

View file

@ -4,4 +4,6 @@ import eu.kanade.tachiyomi.core.preference.PreferenceStore
class SourcePreferences(private val preferenceStore: PreferenceStore) {
fun trustedExtensions() = preferenceStore.getStringSet("trusted_extensions", emptySet())
fun externalLocalSource() = preferenceStore.getBoolean("pref_external_local_source", false)
}

View file

@ -93,12 +93,14 @@ class StorageManager(
fun getLogsDirectory(): UniFile? {
return baseDir?.createDirectory(LOGS_PATH)
}
}
private const val BACKUPS_PATH = "backup"
private const val AUTOMATIC_BACKUPS_PATH = "autobackup"
private const val DOWNLOADS_PATH = "downloads"
private const val LOCAL_SOURCE_PATH = "local"
private const val COVERS_PATH = "covers"
private const val PAGES_PATH = "pages"
private const val LOGS_PATH = "logs"
companion object {
private const val BACKUPS_PATH = "backup"
private const val AUTOMATIC_BACKUPS_PATH = "autobackup"
private const val DOWNLOADS_PATH = "downloads"
const val LOCAL_SOURCE_PATH = "local"
private const val COVERS_PATH = "covers"
private const val PAGES_PATH = "pages"
private const val LOGS_PATH = "logs"
}
}

View file

@ -807,6 +807,7 @@
<string name="pref_verbose_logging">Verbose logging</string>
<string name="pref_verbose_logging_summary">Print verbose logs to system log (may reduces app performance)</string>
<string name="pref_reader_debug_mode">Debug mode</string>
<string name="pref_external_local_source">Scan external storages for entries</string>
<string name="network">Network</string>
<string name="doh">DNS over HTTPS</string>
<string name="user_agent_string">Default user agent string</string>