Move sources from java to kotlin dir
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
enum class CacheDir(val dir: String) {
|
||||
|
||||
THUMBS("image_cache"),
|
||||
FAVICONS("favicons"),
|
||||
PAGES("pages");
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class LocalStorageChanges
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user