Move sources from java to kotlin dir

This commit is contained in:
Koitharu
2023-05-22 18:16:50 +03:00
parent a8f5714b35
commit c3216871ed
711 changed files with 1 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.local.data
enum class CacheDir(val dir: String) {
THUMBS("image_cache"),
FAVICONS("favicons"),
PAGES("pages");
}

View File

@@ -0,0 +1,51 @@
package org.koitharu.kotatsu.local.data
import android.net.Uri
import android.webkit.MimeTypeMap
import coil.ImageLoader
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.request.Options
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.buffer
import okio.source
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import java.util.zip.ZipFile
class CbzFetcher(
private val uri: Uri,
private val options: Options
) : Fetcher {
override suspend fun fetch() = runInterruptible(Dispatchers.IO) {
val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment)
val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name)
val bufferedSource = zip.getInputStream(entry).source().withExtraCloseable(zip).buffer()
SourceResult(
source = ImageSource(
source = bufferedSource,
context = options.context,
metadata = CbzMetadata(uri),
),
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext),
dataSource = DataSource.DISK,
)
}
class Factory : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
return if (data.scheme == "cbz") {
CbzFetcher(data, options)
} else {
null
}
}
}
class CbzMetadata(val uri: Uri) : ImageSource.Metadata()
}

View File

@@ -0,0 +1,31 @@
package org.koitharu.kotatsu.local.data
import android.net.Uri
import java.io.File
import java.io.FileFilter
import java.io.FilenameFilter
import java.util.Locale
class CbzFilter : FileFilter, FilenameFilter {
override fun accept(dir: File, name: String): Boolean {
return isFileSupported(name)
}
override fun accept(pathname: File?): Boolean {
return isFileSupported(pathname?.name ?: return false)
}
companion object {
fun isFileSupported(name: String): Boolean {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
return ext == "cbz" || ext == "zip"
}
fun isUriSupported(uri: Uri): Boolean {
val scheme = uri.scheme?.lowercase(Locale.ROOT)
return scheme != null && scheme == "cbz" || scheme == "zip"
}
}
}

View File

@@ -0,0 +1,29 @@
package org.koitharu.kotatsu.local.data
import java.io.File
import java.io.FileFilter
import java.io.FilenameFilter
import java.util.Locale
import java.util.zip.ZipEntry
class ImageFileFilter : FilenameFilter, FileFilter {
override fun accept(dir: File, name: String): Boolean {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
return isExtensionValid(ext)
}
override fun accept(pathname: File?): Boolean {
val ext = pathname?.extension?.lowercase(Locale.ROOT) ?: return false
return isExtensionValid(ext)
}
fun accept(entry: ZipEntry): Boolean {
val ext = entry.name.substringAfterLast('.', "").lowercase(Locale.ROOT)
return isExtensionValid(ext)
}
private fun isExtensionValid(ext: String): Boolean {
return ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "webp"
}
}

View File

@@ -0,0 +1,55 @@
package org.koitharu.kotatsu.local.data
import androidx.core.net.toFile
import androidx.core.net.toUri
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import java.io.File
class LocalManga(
val file: File,
val manga: Manga,
) {
constructor(manga: Manga) : this(manga.url.toUri().toFile(), manga)
var createdAt: Long = -1L
private set
get() {
if (field == -1L) {
field = file.lastModified()
}
return field
}
fun isMatchesQuery(query: String): Boolean {
return manga.title.contains(query, ignoreCase = true) ||
manga.altTitle?.contains(query, ignoreCase = true) == true
}
fun containsTags(tags: Set<MangaTag>): Boolean {
return manga.tags.containsAll(tags)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as LocalManga
if (manga != other.manga) return false
if (file != other.file) return false
return true
}
override fun hashCode(): Int {
var result = manga.hashCode()
result = 31 * result + file.hashCode()
return result
}
override fun toString(): String {
return "LocalManga(${file.path}: ${manga.title})"
}
}

View File

@@ -0,0 +1,139 @@
package org.koitharu.kotatsu.local.data
import android.content.ContentResolver
import android.content.Context
import android.os.StatFs
import androidx.annotation.WorkerThread
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.Cache
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.getStorageName
import org.koitharu.kotatsu.parsers.util.mapToSet
import java.io.File
import javax.inject.Inject
private const val DIR_NAME = "manga"
private const val CACHE_DISK_PERCENTAGE = 0.02
private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB
private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
@Reusable
class LocalStorageManager @Inject constructor(
@ApplicationContext private val context: Context,
private val settings: AppSettings,
) {
val contentResolver: ContentResolver
get() = context.contentResolver
@WorkerThread
fun createHttpCache(): Cache {
val directory = File(context.externalCacheDir ?: context.cacheDir, "http")
directory.mkdirs()
val maxSize = calculateDiskCacheSize(directory)
return Cache(directory, maxSize)
}
suspend fun computeCacheSize(cache: CacheDir) = withContext(Dispatchers.IO) {
getCacheDirs(cache.dir).sumOf { it.computeSize() }
}
suspend fun computeCacheSize() = withContext(Dispatchers.IO) {
getCacheDirs().sumOf { it.computeSize() }
}
suspend fun computeStorageSize() = withContext(Dispatchers.IO) {
getAvailableStorageDirs().sumOf { it.computeSize() }
}
suspend fun computeAvailableSize() = runInterruptible(Dispatchers.IO) {
getAvailableStorageDirs().mapToSet { it.freeSpace }.sum()
}
suspend fun clearCache(cache: CacheDir) = runInterruptible(Dispatchers.IO) {
getCacheDirs(cache.dir).forEach { it.deleteRecursively() }
}
suspend fun getReadableDirs(): List<File> = runInterruptible(Dispatchers.IO) {
getConfiguredStorageDirs()
.filter { it.isReadable() }
}
suspend fun getWriteableDirs(): List<File> = runInterruptible(Dispatchers.IO) {
getConfiguredStorageDirs()
.filter { it.isWriteable() }
}
suspend fun getDefaultWriteableDir(): File? = runInterruptible(Dispatchers.IO) {
val preferredDir = settings.mangaStorageDir?.takeIf { it.isWriteable() }
preferredDir ?: getFallbackStorageDir()?.takeIf { it.isWriteable() }
}
fun getStorageDisplayName(file: File) = file.getStorageName(context)
@WorkerThread
private fun getConfiguredStorageDirs(): MutableSet<File> {
val set = getAvailableStorageDirs()
settings.mangaStorageDir?.let {
set.add(it)
}
return set
}
@WorkerThread
private fun getAvailableStorageDirs(): MutableSet<File> {
val result = LinkedHashSet<File>()
result += File(context.filesDir, DIR_NAME)
context.getExternalFilesDirs(DIR_NAME).filterNotNullTo(result)
result.retainAll { it.exists() || it.mkdirs() }
return result
}
@WorkerThread
private fun getFallbackStorageDir(): File? {
return context.getExternalFilesDir(DIR_NAME) ?: File(context.filesDir, DIR_NAME).takeIf {
it.exists() || it.mkdirs()
}
}
@WorkerThread
private fun getCacheDirs(subDir: String): MutableSet<File> {
val result = LinkedHashSet<File>()
result += File(context.cacheDir, subDir)
context.externalCacheDirs.mapNotNullTo(result) {
File(it ?: return@mapNotNullTo null, subDir)
}
return result
}
@WorkerThread
private fun getCacheDirs(): MutableSet<File> {
val result = LinkedHashSet<File>()
result += context.cacheDir
context.externalCacheDirs.filterNotNullTo(result)
return result
}
private fun calculateDiskCacheSize(cacheDirectory: File): Long {
return try {
val cacheDir = StatFs(cacheDirectory.absolutePath)
val size = CACHE_DISK_PERCENTAGE * cacheDir.blockCountLong * cacheDir.blockSizeLong
return size.toLong().coerceIn(CACHE_SIZE_MIN, CACHE_SIZE_MAX)
} catch (_: Exception) {
CACHE_SIZE_MIN
}
}
private fun File.isReadable() = runCatching {
canRead()
}.getOrDefault(false)
private fun File.isWriteable() = runCatching {
canWrite()
}.getOrDefault(false)
}

View File

@@ -0,0 +1,192 @@
package org.koitharu.kotatsu.local.data
import androidx.annotation.WorkerThread
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.io.File
class MangaIndex(source: String?) {
private val json: JSONObject = source?.let(::JSONObject) ?: JSONObject()
fun setMangaInfo(manga: Manga, append: Boolean) {
json.put("id", manga.id)
json.put("title", manga.title)
json.put("title_alt", manga.altTitle)
json.put("url", manga.url)
json.put("public_url", manga.publicUrl)
json.put("author", manga.author)
json.put("cover", manga.coverUrl)
json.put("description", manga.description)
json.put("rating", manga.rating)
json.put("nsfw", manga.isNsfw)
json.put("state", manga.state?.name)
json.put("source", manga.source.name)
json.put("cover_large", manga.largeCoverUrl)
json.put(
"tags",
JSONArray().also { a ->
for (tag in manga.tags) {
val jo = JSONObject()
jo.put("key", tag.key)
jo.put("title", tag.title)
a.put(jo)
}
},
)
if (!append || !json.has("chapters")) {
json.put("chapters", JSONObject())
}
json.put("app_id", BuildConfig.APPLICATION_ID)
json.put("app_version", BuildConfig.VERSION_CODE)
}
fun getMangaInfo(): Manga? = if (json.length() == 0) null else runCatching {
val source = MangaSource.valueOf(json.getString("source"))
Manga(
id = json.getLong("id"),
title = json.getString("title"),
altTitle = json.getStringOrNull("title_alt"),
url = json.getString("url"),
publicUrl = json.getStringOrNull("public_url").orEmpty(),
author = json.getStringOrNull("author"),
largeCoverUrl = json.getStringOrNull("cover_large"),
source = source,
rating = json.getDouble("rating").toFloat(),
isNsfw = json.getBooleanOrDefault("nsfw", false),
coverUrl = json.getString("cover"),
state = json.getStringOrNull("state")?.let { stateString ->
MangaState.values().find { it.name == stateString }
},
description = json.getStringOrNull("description"),
tags = json.getJSONArray("tags").mapJSONToSet { x ->
MangaTag(
title = x.getString("title").toTitleCase(),
key = x.getString("key"),
source = source,
)
},
chapters = getChapters(json.getJSONObject("chapters"), source),
)
}.getOrNull()
fun getCoverEntry(): String? = json.getStringOrNull("cover_entry")
fun addChapter(chapter: MangaChapter, filename: String?) {
val chapters = json.getJSONObject("chapters")
if (!chapters.has(chapter.id.toString())) {
val jo = JSONObject()
jo.put("number", chapter.number)
jo.put("url", chapter.url)
jo.put("name", chapter.name)
jo.put("uploadDate", chapter.uploadDate)
jo.put("scanlator", chapter.scanlator)
jo.put("branch", chapter.branch)
jo.put("entries", "%08d_%03d\\d{3}".format(chapter.branch.hashCode(), chapter.number))
jo.put("file", filename)
chapters.put(chapter.id.toString(), jo)
}
}
fun removeChapter(id: Long): Boolean {
return json.getJSONObject("chapters").remove(id.toString()) != null
}
fun getChapterFileName(chapterId: Long): String? {
return json.optJSONObject("chapters")?.optJSONObject(chapterId.toString())?.getStringOrNull("file")
}
fun setCoverEntry(name: String) {
json.put("cover_entry", name)
}
fun getChapterNamesPattern(chapter: MangaChapter) = Regex(
json.getJSONObject("chapters")
.getJSONObject(chapter.id.toString())
.getString("entries"),
)
fun sortChaptersByName() {
val jo = json.getJSONObject("chapters")
val list = ArrayList<JSONObject>(jo.length())
jo.keys().forEach { id ->
val item = jo.getJSONObject(id)
item.put("id", id)
list.add(item)
}
val comparator = org.koitharu.kotatsu.core.util.AlphanumComparator()
list.sortWith(compareBy(comparator) { it.getString("name") })
val newJo = JSONObject()
list.forEachIndexed { i, obj ->
obj.put("number", i + 1)
val id = obj.remove("id") as String
newJo.put(id, obj)
}
json.put("chapters", newJo)
}
fun clear() {
val keys = json.keys()
while (keys.hasNext()) {
json.remove(keys.next())
}
}
fun setFrom(other: MangaIndex) {
clear()
other.json.keys().forEach { key ->
json.putOpt(key, other.json.opt(key))
}
}
private fun getChapters(json: JSONObject, source: MangaSource): List<MangaChapter> {
val chapters = ArrayList<MangaChapter>(json.length())
for (k in json.keys()) {
val v = json.getJSONObject(k)
chapters.add(
MangaChapter(
id = k.toLong(),
name = v.getString("name"),
url = v.getString("url"),
number = v.getInt("number"),
uploadDate = v.getLongOrDefault("uploadDate", 0L),
scanlator = v.getStringOrNull("scanlator"),
branch = v.getStringOrNull("branch"),
source = source,
),
)
}
return chapters.sortedBy { it.number }
}
override fun toString(): String = if (BuildConfig.DEBUG) {
json.toString(4)
} else {
json.toString()
}
companion object {
@WorkerThread
fun read(file: File): MangaIndex? {
if (file.exists() && file.canRead()) {
val text = file.readText()
if (text.length > 2) {
return MangaIndex(text)
}
}
return null
}
}
}

View File

@@ -0,0 +1,65 @@
package org.koitharu.kotatsu.local.data
import android.content.Context
import com.tomclaw.cache.DiskLruCache
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okio.Source
import okio.buffer
import okio.sink
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.core.util.ext.subdir
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.takeIfWriteable
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PagesCache @Inject constructor(@ApplicationContext context: Context) {
private val cacheDir = SuspendLazy {
val dirs = context.externalCacheDirs + context.cacheDir
dirs.firstNotNullOf {
it?.subdir(CacheDir.PAGES.dir)?.takeIfWriteable()
}
}
private val lruCache = SuspendLazy {
val dir = cacheDir.get()
val size = FileSize.MEGABYTES.convert(200, FileSize.BYTES)
runCatchingCancellable {
DiskLruCache.create(dir, size)
}.recoverCatching { error ->
error.printStackTraceDebug()
dir.deleteRecursively()
dir.mkdir()
DiskLruCache.create(dir, size)
}.getOrThrow()
}
suspend fun get(url: String): File? {
val cache = lruCache.get()
return runInterruptible(Dispatchers.IO) {
cache.get(url)?.takeIfReadable()
}
}
suspend fun put(url: String, source: Source): File = withContext(Dispatchers.IO) {
val file = File(cacheDir.get().parentFile, url.longHashCode().toString())
try {
file.sink(append = false).buffer().use {
it.writeAllCancellable(source)
}
lruCache.get().put(url, file)
} finally {
file.delete()
}
}
}

View File

@@ -0,0 +1,7 @@
package org.koitharu.kotatsu.local.data
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class LocalStorageChanges

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.local.data
import java.io.File
import java.io.FilenameFilter
class TempFileFilter : FilenameFilter {
override fun accept(dir: File, name: String): Boolean {
return name.endsWith(".tmp", ignoreCase = true)
}
}

View File

@@ -0,0 +1,111 @@
package org.koitharu.kotatsu.local.data.importer
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okio.buffer
import okio.sink
import okio.source
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.util.ext.resolveName
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import java.io.File
import java.io.IOException
import javax.inject.Inject
@Reusable
class SingleMangaImporter @Inject constructor(
@ApplicationContext private val context: Context,
private val storageManager: LocalStorageManager,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
) {
private val contentResolver = context.contentResolver
suspend fun import(uri: Uri): LocalManga {
val result = if (isDirectory(uri)) {
importDirectory(uri)
} else {
importFile(uri)
}
localStorageChanges.emit(result)
return result
}
private suspend fun importFile(uri: Uri): LocalManga = withContext(Dispatchers.IO) {
val contentResolver = storageManager.contentResolver
val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri")
if (!CbzFilter.isFileSupported(name)) {
throw UnsupportedFileException("Unsupported file on $uri")
}
val dest = File(getOutputDir(), name)
runInterruptible {
contentResolver.openInputStream(uri)
}?.use { source ->
dest.sink().buffer().use { output ->
output.writeAllCancellable(source.source())
}
} ?: throw IOException("Cannot open input stream: $uri")
LocalMangaInput.of(dest).getManga()
}
private suspend fun importDirectory(uri: Uri): LocalManga {
val root = requireNotNull(DocumentFile.fromTreeUri(context, uri)) {
"Provided uri $uri is not a tree"
}
val dest = File(getOutputDir(), root.requireName())
dest.mkdir()
for (docFile in root.listFiles()) {
docFile.copyTo(dest)
}
return LocalMangaInput.of(dest).getManga()
}
/**
* TODO: progress
*/
private suspend fun DocumentFile.copyTo(destDir: File) {
if (isDirectory) {
val subDir = File(destDir, requireName())
subDir.mkdir()
for (docFile in listFiles()) {
docFile.copyTo(subDir)
}
} else {
inputStream().source().use { input ->
File(destDir, requireName()).sink().buffer().use { output ->
output.writeAllCancellable(input)
}
}
}
}
private suspend fun getOutputDir(): File {
return storageManager.getDefaultWriteableDir() ?: throw IOException("External files dir unavailable")
}
private suspend fun DocumentFile.inputStream() = runInterruptible(Dispatchers.IO) {
contentResolver.openInputStream(uri) ?: throw IOException("Cannot open input stream: $uri")
}
private fun DocumentFile.requireName(): String {
return name ?: throw IOException("Cannot fetch name from uri: $uri")
}
private fun isDirectory(uri: Uri): Boolean {
return runCatching {
DocumentFile.fromTreeUri(context, uri)
}.isSuccess
}
}

View File

@@ -0,0 +1,143 @@
package org.koitharu.kotatsu.local.data.input
import androidx.core.net.toFile
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.util.ext.listFilesRecursive
import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.core.util.ext.toListSorted
import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.ImageFileFilter
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toCamelCase
import java.io.File
import java.util.zip.ZipFile
/**
* Manga {Folder}
* |--- index.json (optional)
* |--- Chapter 1.cbz
* |--- Page 1.png
* :
* L--- Page x.png
* |--- Chapter 2.cbz
* :
* L--- Chapter x.cbz
*/
class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
override suspend fun getManga(): LocalManga = runInterruptible(Dispatchers.IO) {
val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX))
val mangaUri = root.toUri().toString()
val chapterFiles = getChaptersFiles()
val info = index?.getMangaInfo()
val manga = info?.copy2(
source = MangaSource.LOCAL,
url = mangaUri,
coverUrl = fileUri(
root,
index.getCoverEntry() ?: findFirstImageEntry().orEmpty(),
),
chapters = info.chapters?.mapIndexed { i, c ->
c.copy(url = chapterFiles[i].toUri().toString(), source = MangaSource.LOCAL)
},
) ?: Manga(
id = root.absolutePath.longHashCode(),
title = root.name.toHumanReadable(),
url = mangaUri,
publicUrl = mangaUri,
source = MangaSource.LOCAL,
coverUrl = findFirstImageEntry().orEmpty(),
chapters = chapterFiles.mapIndexed { i, f ->
MangaChapter(
id = "$i${f.name}".longHashCode(),
name = f.nameWithoutExtension.toHumanReadable(),
number = i + 1,
source = MangaSource.LOCAL,
uploadDate = f.lastModified(),
url = f.toUri().toString(),
scanlator = null,
branch = null,
)
},
altTitle = null,
rating = -1f,
isNsfw = false,
tags = setOf(),
state = null,
author = null,
largeCoverUrl = null,
description = null,
)
LocalManga(root, manga)
}
override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) {
val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX))
index?.getMangaInfo()
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
val file = chapter.url.toUri().toFile()
if (file.isDirectory) {
file.listFilesRecursive(ImageFileFilter())
.toListSorted(compareBy(org.koitharu.kotatsu.core.util.AlphanumComparator()) { x -> x.name })
.map {
val pageUri = it.toUri().toString()
MangaPage(
id = pageUri.longHashCode(),
url = pageUri,
preview = null,
source = MangaSource.LOCAL,
)
}
} else {
ZipFile(file).use { zip ->
zip.entries()
.asSequence()
.filter { x -> !x.isDirectory }
.map { it.name }
.toListSorted(org.koitharu.kotatsu.core.util.AlphanumComparator())
.map {
val pageUri = zipUri(file, it)
MangaPage(
id = pageUri.longHashCode(),
url = pageUri,
preview = null,
source = MangaSource.LOCAL,
)
}
}
}
}
private fun String.toHumanReadable() = replace("_", " ").toCamelCase()
private fun getChaptersFiles(): List<File> = root.listFilesRecursive(CbzFilter())
.toListSorted(compareBy(org.koitharu.kotatsu.core.util.AlphanumComparator()) { x -> x.name })
private fun findFirstImageEntry(): String? {
val filter = ImageFileFilter()
root.listFilesRecursive(filter).firstOrNull()?.let {
return it.toUri().toString()
}
val cbz = root.listFilesRecursive(CbzFilter()).firstOrNull() ?: return null
return ZipFile(cbz).use { zip ->
val filter = ImageFileFilter()
zip.entries().asSequence()
.firstOrNull { x -> !x.isDirectory && filter.accept(x) }
?.let { entry -> zipUri(cbz, entry.name) }
}
}
private fun fileUri(base: File, name: String): String {
return File(base, name).toUri().toString()
}
}

View File

@@ -0,0 +1,76 @@
package org.koitharu.kotatsu.local.data.input
import android.net.Uri
import androidx.core.net.toFile
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.io.File
sealed class LocalMangaInput(
protected val root: File,
) {
abstract suspend fun getManga(): LocalManga
abstract suspend fun getMangaInfo(): Manga?
abstract suspend fun getPages(chapter: MangaChapter): List<MangaPage>
companion object {
fun of(manga: Manga): LocalMangaInput = of(Uri.parse(manga.url).toFile())
fun of(chapter: MangaChapter): LocalMangaInput = of(Uri.parse(chapter.url).toFile())
fun of(file: File): LocalMangaInput = when {
file.isDirectory -> LocalMangaDirInput(file)
else -> LocalMangaZipInput(file)
}
@JvmStatic
protected fun zipUri(file: File, entryName: String): String =
Uri.fromParts("cbz", file.path, entryName).toString()
@JvmStatic
protected fun Manga.copy2(
url: String = this.url,
coverUrl: String = this.coverUrl,
chapters: List<MangaChapter>? = this.chapters,
source: MangaSource = this.source,
) = Manga(
id = id,
title = title,
altTitle = altTitle,
url = url,
publicUrl = publicUrl,
rating = rating,
isNsfw = isNsfw,
coverUrl = coverUrl,
tags = tags,
state = state,
author = author,
largeCoverUrl = largeCoverUrl,
description = description,
chapters = chapters,
source = source,
)
@JvmStatic
protected fun MangaChapter.copy(
url: String = this.url,
source: MangaSource = this.source,
) = MangaChapter(
id = id,
name = name,
number = number,
url = url,
scanlator = scanlator,
uploadDate = uploadDate,
branch = branch,
source = source,
)
}
}

View File

@@ -0,0 +1,151 @@
package org.koitharu.kotatsu.local.data.input
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.collection.ArraySet
import androidx.core.net.toFile
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.core.util.ext.readText
import org.koitharu.kotatsu.core.util.ext.toListSorted
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toCamelCase
import java.io.File
import java.util.Enumeration
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
/**
* Manga archive {.cbz or .zip file}
* |--- index.json (optional)
* |--- Page 1.png
* |--- Page 2.png
* :
* L--- Page x.png
*/
class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
override suspend fun getManga(): LocalManga {
val manga = runInterruptible(Dispatchers.IO) {
ZipFile(root).use { zip ->
val fileUri = root.toUri().toString()
val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)
val index = entry?.let(zip::readText)?.let(::MangaIndex)
val info = index?.getMangaInfo()
if (info != null) {
return@use info.copy2(
source = MangaSource.LOCAL,
url = fileUri,
coverUrl = zipUri(
root,
entryName = index.getCoverEntry()
?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
),
chapters = info.chapters?.map { c ->
c.copy(url = fileUri, source = MangaSource.LOCAL)
},
)
}
// fallback
val title = root.nameWithoutExtension.replace("_", " ").toCamelCase()
val chapters = ArraySet<String>()
for (x in zip.entries()) {
if (!x.isDirectory) {
chapters += x.name.substringBeforeLast(File.separatorChar, "")
}
}
val uriBuilder = root.toUri().buildUpon()
Manga(
id = root.absolutePath.longHashCode(),
title = title,
url = fileUri,
publicUrl = fileUri,
source = MangaSource.LOCAL,
coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()),
chapters = chapters.sortedWith(org.koitharu.kotatsu.core.util.AlphanumComparator())
.mapIndexed { i, s ->
MangaChapter(
id = "$i$s".longHashCode(),
name = s.ifEmpty { title },
number = i + 1,
source = MangaSource.LOCAL,
uploadDate = 0L,
url = uriBuilder.fragment(s).build().toString(),
scanlator = null,
branch = null,
)
},
altTitle = null,
rating = -1f,
isNsfw = false,
tags = setOf(),
state = null,
author = null,
largeCoverUrl = null,
description = null,
)
}
}
return LocalManga(root, manga)
}
override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) {
ZipFile(root).use { zip ->
val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)
val index = entry?.let(zip::readText)?.let(::MangaIndex)
index?.getMangaInfo()
}
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
return runInterruptible(Dispatchers.IO) {
val uri = Uri.parse(chapter.url)
val file = uri.toFile()
val zip = ZipFile(file)
val index = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)?.let(zip::readText)?.let(::MangaIndex)
var entries = zip.entries().asSequence()
entries = if (index != null) {
val pattern = index.getChapterNamesPattern(chapter)
entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
} else {
val parent = uri.fragment.orEmpty()
entries.filter { x ->
!x.isDirectory && x.name.substringBeforeLast(
File.separatorChar,
"",
) == parent
}
}
entries
.toListSorted(compareBy(org.koitharu.kotatsu.core.util.AlphanumComparator()) { x -> x.name })
.map { x ->
val entryUri = zipUri(file, x.name)
MangaPage(
id = entryUri.longHashCode(),
url = entryUri,
preview = null,
source = MangaSource.LOCAL,
)
}
}
}
private fun findFirstImageEntry(entries: Enumeration<out ZipEntry>): ZipEntry? {
val list = entries.toList()
.filterNot { it.isDirectory }
.sortedWith(compareBy(org.koitharu.kotatsu.core.util.AlphanumComparator()) { x -> x.name })
val map = MimeTypeMap.getSingleton()
return list.firstOrNull {
map.getMimeTypeFromExtension(it.name.substringAfterLast('.'))
?.startsWith("image/") == true
}
}
}

View File

@@ -0,0 +1,130 @@
package org.koitharu.kotatsu.local.data.output
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.zip.ZipOutput
import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import java.io.File
class LocalMangaDirOutput(
rootFile: File,
manga: Manga,
) : LocalMangaOutput(rootFile) {
private val chaptersOutput = HashMap<MangaChapter, ZipOutput>()
private val index = MangaIndex(File(rootFile, ENTRY_NAME_INDEX).takeIfReadable()?.readText())
init {
index.setMangaInfo(manga, append = true)
}
override suspend fun mergeWithExisting() = Unit
override suspend fun addCover(file: File, ext: String) {
val name = buildString {
append("cover")
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
runInterruptible(Dispatchers.IO) {
file.copyTo(File(rootFile, name), overwrite = true)
}
index.setCoverEntry(name)
flushIndex()
}
override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
val output = chaptersOutput.getOrPut(chapter) {
ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP))
}
val name = buildString {
append(FILENAME_PATTERN.format(chapter.branch.hashCode(), chapter.number, pageNumber))
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
runInterruptible(Dispatchers.IO) {
output.put(name, file)
}
index.addChapter(chapter, chapterFileName(chapter))
}
override suspend fun flushChapter(chapter: MangaChapter): Boolean {
val output = chaptersOutput.remove(chapter) ?: return false
output.flushAndFinish()
flushIndex()
return true
}
override suspend fun finish() {
flushIndex()
for (output in chaptersOutput.values) {
output.flushAndFinish()
}
chaptersOutput.clear()
}
override suspend fun cleanup() {
for (output in chaptersOutput.values) {
output.file.deleteAwait()
}
}
override fun close() {
for (output in chaptersOutput.values) {
output.close()
}
}
suspend fun deleteChapter(chapterId: Long) {
val chapter = checkNotNull(index.getMangaInfo()?.chapters) {
"No chapters found"
}.first { it.id == chapterId }
val chapterDir = File(rootFile, chapterFileName(chapter))
chapterDir.deleteAwait()
index.removeChapter(chapterId)
}
fun setIndex(newIndex: MangaIndex) {
index.setFrom(newIndex)
}
private suspend fun ZipOutput.flushAndFinish() = runInterruptible(Dispatchers.IO) {
finish()
close()
val resFile = File(file.absolutePath.removeSuffix(SUFFIX_TMP))
file.renameTo(resFile)
}
private fun chapterFileName(chapter: MangaChapter): String {
index.getChapterFileName(chapter.id)?.let {
return it
}
val baseName = "${chapter.number}_${chapter.name.toFileNameSafe()}".take(18)
var i = 0
while (true) {
val name = (if (i == 0) baseName else baseName + "_$i") + ".cbz"
if (!File(rootFile, name).exists()) {
return name
}
i++
}
}
private suspend fun flushIndex() = runInterruptible(Dispatchers.IO) {
File(rootFile, ENTRY_NAME_INDEX).writeText(index.toString())
}
companion object {
private const val FILENAME_PATTERN = "%08d_%03d%03d"
}
}

View File

@@ -0,0 +1,93 @@
package org.koitharu.kotatsu.local.data.output
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okio.Closeable
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import java.io.File
sealed class LocalMangaOutput(
val rootFile: File,
) : Closeable {
abstract suspend fun mergeWithExisting()
abstract suspend fun addCover(file: File, ext: String)
abstract suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String)
abstract suspend fun flushChapter(chapter: MangaChapter): Boolean
abstract suspend fun finish()
abstract suspend fun cleanup()
companion object {
const val ENTRY_NAME_INDEX = "index.json"
const val SUFFIX_TMP = ".tmp"
private val mutex = Mutex()
suspend fun getOrCreate(root: File, manga: Manga): LocalMangaOutput = withContext(Dispatchers.IO) {
val preferSingleCbz = manga.chapters.let {
it != null && it.size <= 3
}
checkNotNull(getImpl(root, manga, onlyIfExists = false, preferSingleCbz))
}
suspend fun get(root: File, manga: Manga): LocalMangaOutput? = withContext(Dispatchers.IO) {
getImpl(root, manga, onlyIfExists = true, preferSingleCbz = false)
}
private suspend fun getImpl(
root: File,
manga: Manga,
onlyIfExists: Boolean,
preferSingleCbz: Boolean,
): LocalMangaOutput? {
mutex.withLock {
var i = 0
val baseName = manga.title.toFileNameSafe()
while (true) {
val fileName = if (i == 0) baseName else baseName + "_$i"
val dir = File(root, fileName)
val zip = File(root, "$fileName.cbz")
i++
return when {
dir.isDirectory -> {
if (canWriteTo(dir, manga)) {
LocalMangaDirOutput(dir, manga)
} else {
continue
}
}
zip.isFile -> if (canWriteTo(zip, manga)) {
LocalMangaZipOutput(zip, manga)
} else {
continue
}
!onlyIfExists -> if (preferSingleCbz) {
LocalMangaZipOutput(zip, manga)
} else {
LocalMangaDirOutput(dir, manga)
}
else -> null
}
}
}
}
private suspend fun canWriteTo(file: File, manga: Manga): Boolean {
val info = LocalMangaInput.of(file).getMangaInfo() ?: return false
return info.id == manga.id
}
}
}

View File

@@ -0,0 +1,58 @@
package org.koitharu.kotatsu.local.data.output
import androidx.core.net.toFile
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
class LocalMangaUtil(
private val manga: Manga,
) {
init {
require(manga.source == MangaSource.LOCAL) {
"Expected LOCAL source but ${manga.source} found"
}
}
suspend fun deleteChapters(ids: Set<Long>) {
newOutput().use { output ->
when (output) {
is LocalMangaZipOutput -> runInterruptible(Dispatchers.IO) {
LocalMangaZipOutput.filterChapters(output, ids)
}
is LocalMangaDirOutput -> {
for (id in ids) {
output.deleteChapter(id)
}
output.finish()
}
}
}
}
suspend fun writeIndex(index: MangaIndex) {
newOutput().use { output ->
when (output) {
is LocalMangaDirOutput -> {
TODO()
}
is LocalMangaZipOutput -> TODO()
}
}
}
private suspend fun newOutput(): LocalMangaOutput = runInterruptible(Dispatchers.IO) {
val file = manga.url.toUri().toFile()
if (file.isDirectory) {
LocalMangaDirOutput(file, manga)
} else {
LocalMangaZipOutput(file, manga)
}
}
}

View File

@@ -0,0 +1,148 @@
package org.koitharu.kotatsu.local.data.output
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.readText
import org.koitharu.kotatsu.core.zip.ZipOutput
import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import java.io.File
import java.util.zip.ZipFile
class LocalMangaZipOutput(
rootFile: File,
manga: Manga,
) : LocalMangaOutput(rootFile) {
private val output = ZipOutput(File(rootFile.path + ".tmp"))
private val index = MangaIndex(null)
init {
index.setMangaInfo(manga, false)
}
override suspend fun mergeWithExisting() {
if (rootFile.exists()) {
runInterruptible(Dispatchers.IO) {
mergeWith(rootFile)
}
}
}
override suspend fun addCover(file: File, ext: String) {
val name = buildString {
append(FILENAME_PATTERN.format(0, 0, 0))
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
runInterruptible(Dispatchers.IO) {
output.put(name, file)
}
index.setCoverEntry(name)
}
override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
val name = buildString {
append(FILENAME_PATTERN.format(chapter.branch.hashCode(), chapter.number, pageNumber))
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
runInterruptible(Dispatchers.IO) {
output.put(name, file)
}
index.addChapter(chapter, null)
}
override suspend fun flushChapter(chapter: MangaChapter): Boolean = false
override suspend fun finish() {
runInterruptible(Dispatchers.IO) {
output.put(ENTRY_NAME_INDEX, index.toString())
output.finish()
output.close()
}
rootFile.deleteAwait()
output.file.renameTo(rootFile)
}
override suspend fun cleanup() {
output.file.deleteAwait()
}
override fun close() {
output.close()
}
@WorkerThread
private fun mergeWith(other: File) {
var otherIndex: MangaIndex? = null
ZipFile(other).use { zip ->
for (entry in zip.entries()) {
if (entry.name == ENTRY_NAME_INDEX) {
otherIndex = MangaIndex(
zip.getInputStream(entry).use {
it.reader().readText()
},
)
} else {
output.copyEntryFrom(zip, entry)
}
}
}
otherIndex?.getMangaInfo()?.chapters?.let { chapters ->
for (chapter in chapters) {
index.addChapter(chapter, null)
}
}
}
companion object {
private const val FILENAME_PATTERN = "%08d_%03d%03d"
@WorkerThread
fun filterChapters(subject: LocalMangaZipOutput, idsToRemove: Set<Long>) {
ZipFile(subject.rootFile).use { zip ->
val index = MangaIndex(zip.readText(zip.getEntry(ENTRY_NAME_INDEX)))
idsToRemove.forEach { id -> index.removeChapter(id) }
val patterns = requireNotNull(index.getMangaInfo()?.chapters).map {
index.getChapterNamesPattern(it)
}
val coverEntryName = index.getCoverEntry()
for (entry in zip.entries()) {
when {
entry.name == ENTRY_NAME_INDEX -> {
subject.output.put(ENTRY_NAME_INDEX, index.toString())
}
entry.isDirectory -> {
subject.output.addDirectory(entry.name)
}
entry.name == coverEntryName -> {
subject.output.copyEntryFrom(zip, entry)
}
else -> {
val name = entry.name.substringBefore('.')
if (patterns.any { it.matches(name) }) {
subject.output.copyEntryFrom(zip, entry)
}
}
}
}
subject.output.finish()
subject.output.close()
subject.rootFile.delete()
subject.output.file.renameTo(subject.rootFile)
}
}
}
}

View File

@@ -0,0 +1,21 @@
package org.koitharu.kotatsu.local.data.util
import okhttp3.internal.closeQuietly
import okio.Closeable
import okio.Source
private class ExtraCloseableSource(
private val delegate: Source,
private val extraCloseable: Closeable,
) : Source by delegate {
override fun close() {
try {
delegate.close()
} finally {
extraCloseable.closeQuietly()
}
}
}
fun Source.withExtraCloseable(closeable: Closeable): Source = ExtraCloseableSource(this, closeable)

View File

@@ -0,0 +1,196 @@
package org.koitharu.kotatsu.local.domain
import android.net.Uri
import androidx.core.net.toFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.CompositeMutex
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.data.TempFileFilter
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.data.output.LocalMangaUtil
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
private const val MAX_PARALLELISM = 4
@Singleton
class LocalMangaRepository @Inject constructor(
private val storageManager: LocalStorageManager,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
) : MangaRepository {
override val source = MangaSource.LOCAL
private val locks = CompositeMutex<Long>()
override suspend fun getList(offset: Int, query: String): List<Manga> {
if (offset > 0) {
return emptyList()
}
val list = getRawList()
if (query.isNotEmpty()) {
list.retainAll { x -> x.isMatchesQuery(query) }
}
return list.unwrap()
}
override suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> {
if (offset > 0) {
return emptyList()
}
val list = getRawList()
if (!tags.isNullOrEmpty()) {
list.retainAll { x -> x.containsTags(tags) }
}
when (sortOrder) {
SortOrder.ALPHABETICAL -> list.sortWith(compareBy(org.koitharu.kotatsu.core.util.AlphanumComparator()) { x -> x.manga.title })
SortOrder.RATING -> list.sortByDescending { it.manga.rating }
SortOrder.NEWEST,
SortOrder.UPDATED,
-> list.sortByDescending { it.createdAt }
else -> Unit
}
return list.unwrap()
}
override suspend fun getDetails(manga: Manga): Manga = when {
manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)?.manga) {
"Manga is not local or saved"
}
else -> LocalMangaInput.of(manga).getManga().manga
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
return LocalMangaInput.of(chapter).getPages(chapter)
}
suspend fun delete(manga: Manga): Boolean {
val file = Uri.parse(manga.url).toFile()
val result = file.deleteAwait()
if (result) {
localStorageChanges.emit(null)
}
return result
}
suspend fun deleteChapters(manga: Manga, ids: Set<Long>) {
lockManga(manga.id)
try {
LocalMangaUtil(manga).deleteChapters(ids)
localStorageChanges.emit(LocalManga(manga))
} finally {
unlockManga(manga.id)
}
}
suspend fun getRemoteManga(localManga: Manga): Manga? {
return runCatchingCancellable {
LocalMangaInput.of(localManga).getMangaInfo()
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
}
suspend fun findSavedManga(remoteManga: Manga): LocalManga? {
val files = getAllFiles()
if (files.isEmpty()) {
return null
}
return channelFlow {
for (file in files) {
launch {
val mangaInput = LocalMangaInput.of(file)
runCatchingCancellable {
val mangaInfo = mangaInput.getMangaInfo()
if (mangaInfo != null && mangaInfo.id == remoteManga.id) {
send(mangaInput)
}
}.onFailure {
it.printStackTraceDebug()
}
}
}
}.firstOrNull()?.getManga()
}
override val sortOrders = setOf(SortOrder.ALPHABETICAL, SortOrder.RATING)
override suspend fun getPageUrl(page: MangaPage) = page.url
override suspend fun getTags() = emptySet<MangaTag>()
suspend fun getOutputDir(manga: Manga): File? {
val defaultDir = storageManager.getDefaultWriteableDir()
if (defaultDir != null && LocalMangaOutput.get(defaultDir, manga) != null) {
return defaultDir
}
return storageManager.getWriteableDirs()
.firstOrNull {
LocalMangaOutput.get(it, manga) != null
} ?: defaultDir
}
suspend fun cleanup(): Boolean {
if (locks.isNotEmpty()) {
return false
}
val dirs = storageManager.getWriteableDirs()
runInterruptible(Dispatchers.IO) {
dirs.flatMap { dir ->
dir.listFiles(TempFileFilter())?.toList().orEmpty()
}.forEach { file ->
file.deleteRecursively()
}
}
return true
}
suspend fun lockManga(id: Long) {
locks.lock(id)
}
fun unlockManga(id: Long) {
locks.unlock(id)
}
private suspend fun getRawList(): ArrayList<LocalManga> {
val files = getAllFiles()
return coroutineScope {
val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
files.map { file ->
async(dispatcher) {
runCatchingCancellable { LocalMangaInput.of(file).getManga() }.getOrNull()
}
}.awaitAll()
}.filterNotNullTo(ArrayList(files.size))
}
private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir ->
dir.listFiles()?.toList().orEmpty()
}
private fun Collection<LocalManga>.unwrap(): List<Manga> = map { it.manga }
}

View File

@@ -0,0 +1,78 @@
package org.koitharu.kotatsu.local.ui
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.databinding.DialogImportBinding
import org.koitharu.kotatsu.settings.backup.BackupDialogFragment
import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment
class ImportDialogFragment : AlertDialogFragment<DialogImportBinding>(), View.OnClickListener {
private val importFileCall = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) {
startImport(it)
}
private val importDirCall = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) {
startImport(listOfNotNull(it))
}
private val backupSelectCall = registerForActivityResult(ActivityResultContracts.OpenDocument()) {
restoreBackup(it)
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogImportBinding {
return DialogImportBinding.inflate(inflater, container, false)
}
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setTitle(R.string._import)
.setNegativeButton(android.R.string.cancel, null)
.setCancelable(true)
}
override fun onViewBindingCreated(binding: DialogImportBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
binding.buttonDir.setOnClickListener(this)
binding.buttonFile.setOnClickListener(this)
binding.buttonBackup.setOnClickListener(this)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_file -> importFileCall.launch(arrayOf("*/*"))
R.id.button_dir -> importDirCall.launch(null)
R.id.button_backup -> backupSelectCall.launch(arrayOf("*/*"))
}
}
private fun startImport(uris: Collection<Uri>) {
if (uris.isEmpty()) {
return
}
val ctx = requireContext()
ImportWorker.start(ctx, uris)
Toast.makeText(ctx, R.string.import_will_start_soon, Toast.LENGTH_LONG).show()
dismiss()
}
private fun restoreBackup(uri: Uri?) {
RestoreDialogFragment.newInstance(uri ?: return)
.show(parentFragmentManager, BackupDialogFragment.TAG)
dismiss()
}
companion object {
private const val TAG = "ImportDialogFragment"
fun show(fm: FragmentManager) = ImportDialogFragment().show(fm, TAG)
}
}

View File

@@ -0,0 +1,148 @@
package org.koitharu.kotatsu.local.ui
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.PendingIntentCompat
import androidx.hilt.work.HiltWorker
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import coil.ImageLoader
import coil.request.ImageRequest
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.local.data.importer.SingleMangaImporter
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@HiltWorker
class ImportWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val importer: SingleMangaImporter,
private val coil: ImageLoader
) : CoroutineWorker(appContext, params) {
private val notificationManager by lazy {
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
override suspend fun doWork(): Result {
val uri = inputData.getString(DATA_URI)?.toUriOrNull() ?: return Result.failure()
setForeground(getForegroundInfo())
val result = runCatchingCancellable {
importer.import(uri).manga
}
val notification = buildNotification(result)
notificationManager.notify(uri.hashCode(), notification)
return Result.success()
}
override suspend fun getForegroundInfo(): ForegroundInfo {
val title = applicationContext.getString(R.string.importing_manga)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(CHANNEL_ID, title, NotificationManager.IMPORTANCE_LOW)
channel.setShowBadge(false)
channel.enableVibration(false)
channel.setSound(null, null)
channel.enableLights(false)
notificationManager.createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(title)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setDefaults(0)
.setSilent(true)
.setOngoing(true)
.setProgress(0, 0, true)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.build()
return ForegroundInfo(FOREGROUND_NOTIFICATION_ID, notification)
}
private suspend fun buildNotification(result: kotlin.Result<Manga>): Notification {
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setDefaults(0)
.setSilent(true)
result.onSuccess { manga ->
notification.setLargeIcon(
coil.execute(
ImageRequest.Builder(applicationContext)
.data(manga.coverUrl)
.tag(manga.source)
.build(),
).toBitmapOrNull(),
)
notification.setSubText(manga.title)
val intent = DetailsActivity.newIntent(applicationContext, manga)
notification.setContentIntent(
PendingIntentCompat.getActivity(
applicationContext,
manga.id.toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT,
false,
),
).setAutoCancel(true)
.setVisibility(
if (manga.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC,
)
notification.setContentTitle(applicationContext.getString(R.string.import_completed))
.setContentText(applicationContext.getString(R.string.import_completed_hint))
.setSmallIcon(R.drawable.ic_stat_done)
NotificationCompat.BigTextStyle(notification)
.bigText(applicationContext.getString(R.string.import_completed_hint))
}.onFailure { error ->
notification.setContentTitle(applicationContext.getString(R.string.error_occurred))
.setContentText(error.getDisplayMessage(applicationContext.resources))
.setSmallIcon(android.R.drawable.stat_notify_error)
}
return notification.build()
}
companion object {
const val DATA_URI = "uri"
private const val TAG = "import"
private const val CHANNEL_ID = "importing"
private const val FOREGROUND_NOTIFICATION_ID = 37
fun start(context: Context, uris: Iterable<Uri>) {
val constraints = Constraints.Builder()
.setRequiresStorageNotLow(true)
.build()
val requests = uris.map { uri ->
OneTimeWorkRequestBuilder<ImportWorker>()
.setConstraints(constraints)
.addTag(TAG)
.setInputData(Data.Builder().putString(DATA_URI, uri.toString()).build())
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
}
WorkManager.getInstance(context)
.enqueue(requests)
}
}
}

View File

@@ -0,0 +1,114 @@
package org.koitharu.kotatsu.local.ui
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.MutableSharedFlow
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject
@AndroidEntryPoint
class LocalChaptersRemoveService : CoroutineIntentService() {
@Inject
lateinit var localMangaRepository: LocalMangaRepository
@Inject
@LocalStorageChanges
lateinit var localStorageChanges: MutableSharedFlow<LocalManga?>
override fun onCreate() {
super.onCreate()
isRunning = true
}
override fun onDestroy() {
isRunning = false
super.onDestroy()
}
override suspend fun processIntent(startId: Int, intent: Intent) {
val manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga ?: return
val chaptersIds = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet() ?: return
startForeground()
val mangaWithChapters = localMangaRepository.getDetails(manga)
localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds)
localStorageChanges.emit(LocalManga(manga))
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
}
override fun onError(startId: Int, error: Throwable) {
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.error_occurred))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setDefaults(0)
.setSilent(true)
.setContentText(error.getDisplayMessage(resources))
.setSmallIcon(android.R.drawable.stat_notify_error)
.setAutoCancel(true)
.build()
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID + startId, notification)
}
private fun startForeground() {
val title = getString(R.string.local_manga_processing)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(CHANNEL_ID, title, NotificationManager.IMPORTANCE_LOW)
channel.setShowBadge(false)
channel.enableVibration(false)
channel.setSound(null, null)
channel.enableLights(false)
manager.createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setDefaults(0)
.setSilent(true)
.setProgress(0, 0, true)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
.setOngoing(true)
.build()
startForeground(NOTIFICATION_ID, notification)
}
companion object {
var isRunning: Boolean = false
private set
private const val CHANNEL_ID = "local_processing"
private const val NOTIFICATION_ID = 21
private const val EXTRA_MANGA = "manga"
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
fun start(context: Context, manga: Manga, chaptersIds: Collection<Long>) {
if (chaptersIds.isEmpty()) {
return
}
val intent = Intent(context, LocalChaptersRemoveService::class.java)
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
ContextCompat.startForegroundService(context, intent)
}
}
}

View File

@@ -0,0 +1,100 @@
package org.koitharu.kotatsu.local.ui
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.SortOrder
class LocalListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener {
override val viewModel by viewModels<LocalListViewModel>()
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
addMenuProvider(LocalListMenuProvider(this::onEmptyActionClick))
viewModel.onMangaRemoved.observe(viewLifecycleOwner) { onItemRemoved() }
}
override fun onEmptyActionClick() {
ImportDialogFragment.show(childFragmentManager)
}
override fun onFilterClick(view: View?) {
super.onFilterClick(view)
val menu = PopupMenu(requireContext(), view ?: requireViewBinding().recyclerView)
menu.inflate(R.menu.popup_order)
menu.setOnMenuItemClickListener(this)
menu.show()
}
override fun onScrolledToEnd() = Unit
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_local, menu)
return super.onCreateActionMode(controller, mode, menu)
}
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_remove -> {
showDeletionConfirm(selectedItemsIds, mode)
true
}
R.id.action_share -> {
val files = selectedItems.map { it.url.toUri().toFile() }
ShareHelper(requireContext()).shareCbz(files)
mode.finish()
true
}
else -> super.onActionItemClicked(controller, mode, item)
}
}
override fun onMenuItemClick(item: MenuItem): Boolean {
val order = when (item.itemId) {
R.id.action_order_new -> SortOrder.NEWEST
R.id.action_order_abs -> SortOrder.ALPHABETICAL
R.id.action_order_rating -> SortOrder.RATING
else -> return false
}
viewModel.setSortOrder(order)
return true
}
private fun showDeletionConfirm(ids: Set<Long>, mode: ActionMode) {
MaterialAlertDialogBuilder(context ?: return)
.setTitle(R.string.delete_manga)
.setMessage(getString(R.string.text_delete_local_manga_batch))
.setPositiveButton(R.string.delete) { _, _ ->
viewModel.delete(ids)
mode.finish()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun onItemRemoved() {
Snackbar.make(requireViewBinding().recyclerView, R.string.removal_completed, Snackbar.LENGTH_SHORT).show()
}
companion object {
fun newInstance() = LocalListFragment()
}
}

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.local.ui
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
class LocalListMenuProvider(
private val onImportClick: Function0<Unit>,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_local, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_import -> {
onImportClick()
true
}
else -> false
}
}
}

View File

@@ -0,0 +1,194 @@
package org.koitharu.kotatsu.local.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.SingleLiveEvent
import org.koitharu.kotatsu.core.util.asFlowLiveData
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader2
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import java.io.IOException
import java.util.LinkedList
import javax.inject.Inject
@HiltViewModel
class LocalListViewModel @Inject constructor(
private val repository: LocalMangaRepository,
private val historyRepository: HistoryRepository,
private val trackingRepository: TrackingRepository,
private val settings: AppSettings,
private val tagHighlighter: MangaTagHighlighter,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler), ListExtraProvider {
val onMangaRemoved = SingleLiveEvent<Unit>()
val sortOrder = MutableLiveData(settings.localListOrder)
private val listError = MutableStateFlow<Throwable?>(null)
private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val selectedTags = MutableStateFlow<Set<MangaTag>>(emptySet())
private var refreshJob: Job? = null
override val content = combine(
mangaList,
listModeFlow,
sortOrder.asFlow(),
selectedTags,
listError,
) { list, mode, order, tags, error ->
when {
error != null -> listOf(error.toErrorState(canRetry = true))
list == null -> listOf(LoadingState)
list.isEmpty() -> listOf(
EmptyState(
icon = R.drawable.ic_empty_local,
textPrimary = R.string.text_local_holder_primary,
textSecondary = R.string.text_local_holder_secondary,
actionStringRes = R.string._import,
),
)
else -> buildList(list.size + 1) {
add(createHeader(list, tags, order))
list.toUi(this, mode, this@LocalListViewModel, tagHighlighter)
}
}
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
init {
onRefresh()
launchJob(Dispatchers.Default) {
localStorageChanges
.collectLatest {
if (refreshJob?.isActive != true) {
doRefresh()
}
}
}
}
override fun onUpdateFilter(tags: Set<MangaTag>) {
selectedTags.value = tags
onRefresh()
}
override fun onRefresh() {
val prevJob = refreshJob
refreshJob = launchLoadingJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
doRefresh()
}
}
override fun onRetry() = onRefresh()
fun setSortOrder(value: SortOrder) {
sortOrder.value = value
settings.localListOrder = value
onRefresh()
}
fun delete(ids: Set<Long>) {
launchLoadingJob(Dispatchers.Default) {
val itemsToRemove = checkNotNull(mangaList.value).filter { it.id in ids }
for (manga in itemsToRemove) {
val original = repository.getRemoteManga(manga)
repository.delete(manga) || throw IOException("Unable to delete file")
runCatchingCancellable {
historyRepository.deleteOrSwap(manga, original)
}
mangaList.update { list ->
list?.filterNot { it.id == manga.id }
}
}
onMangaRemoved.emitCall(Unit)
}
}
private suspend fun doRefresh() {
try {
listError.value = null
mangaList.value = repository.getList(0, selectedTags.value, sortOrder.value)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
listError.value = e
}
}
private fun createHeader(mangaList: List<Manga>, selectedTags: Set<MangaTag>, order: SortOrder): ListHeader2 {
val tags = HashMap<MangaTag, Int>()
for (item in mangaList) {
for (tag in item.tags) {
tags[tag] = tags[tag]?.plus(1) ?: 1
}
}
val topTags = tags.entries.sortedByDescending { it.value }.take(6)
val chips = LinkedList<ChipsView.ChipModel>()
for ((tag, _) in topTags) {
val model = ChipsView.ChipModel(
tint = 0,
title = tag.title,
isCheckable = true,
isChecked = tag in selectedTags,
data = tag,
)
if (model.isChecked) {
chips.addFirst(model)
} else {
chips.addLast(model)
}
}
return ListHeader2(
chips = chips,
sortOrder = order,
hasSelectedTags = selectedTags.isNotEmpty(),
)
}
override suspend fun getCounter(mangaId: Long): Int {
return if (settings.isTrackerEnabled) {
trackingRepository.getNewChaptersCount(mangaId)
} else {
0
}
}
override suspend fun getProgress(mangaId: Long): Float {
return if (settings.isReadingIndicatorsEnabled) {
historyRepository.getProgress(mangaId)
} else {
PROGRESS_NONE
}
}
}

View File

@@ -0,0 +1,48 @@
package org.koitharu.kotatsu.local.ui
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import java.util.concurrent.TimeUnit
@HiltWorker
class LocalStorageCleanupWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val localMangaRepository: LocalMangaRepository,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
return if (localMangaRepository.cleanup()) {
Result.success()
} else {
Result.retry()
}
}
companion object {
private const val TAG = "cleanup"
fun enqueue(context: Context) {
val constraints = Constraints.Builder()
.setRequiresBatteryNotLow(true)
.build()
val request = OneTimeWorkRequestBuilder<ImportWorker>()
.setConstraints(constraints)
.addTag(TAG)
.setBackoffCriteria(BackoffPolicy.LINEAR, 1, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.KEEP, request)
}
}
}