New reader (#1550)

* Delete old reader

* Add utility methods

* Update dependencies

* Add new reader

* Update tracking services. Extract transition strings into resources

* Restore delete read chapters

* Documentation and some minor changes

* Remove content providers for compressed files, they are not needed anymore

* Update subsampling. New changes allow to parse magic numbers and decode tiles with a single stream. Drop support for custom image decoders. Other minor fixes
This commit is contained in:
inorichi
2018-09-01 17:12:59 +02:00
committed by GitHub
parent 7c99ae1b3b
commit 18f89cc341
105 changed files with 6918 additions and 7247 deletions

View File

@@ -23,10 +23,12 @@ import java.util.concurrent.TimeUnit
* @param sourceManager the source manager.
* @param preferences the preferences of the app.
*/
class DownloadCache(private val context: Context,
private val provider: DownloadProvider,
private val sourceManager: SourceManager = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get()) {
class DownloadCache(
private val context: Context,
private val provider: DownloadProvider,
private val sourceManager: SourceManager,
private val preferences: PreferencesHelper = Injekt.get()
) {
/**
* The interval after which this cache should be invalidated. 1 hour shouldn't cause major
@@ -194,6 +196,24 @@ class DownloadCache(private val context: Context,
}
}
/**
* Removes a list of chapters that have been deleted from this cache.
*
* @param chapters the list of chapter to remove.
* @param manga the manga of the chapter.
*/
@Synchronized
fun removeChapters(chapters: List<Chapter>, manga: Manga) {
val sourceDir = rootDir.files[manga.source] ?: return
val mangaDir = sourceDir.files[provider.getMangaDirName(manga)] ?: return
for (chapter in chapters) {
val chapterDirName = provider.getChapterDirName(chapter)
if (chapterDirName in mangaDir.files) {
mangaDir.files -= chapterDirName
}
}
}
/**
* Removes a manga that has been deleted from this cache.
*

View File

@@ -7,8 +7,10 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.Page
import rx.Observable
import uy.kohesive.injekt.injectLazy
/**
* This class is used to manage chapter downloads in the application. It must be instantiated once
@@ -19,6 +21,11 @@ import rx.Observable
*/
class DownloadManager(context: Context) {
/**
* The sources manager.
*/
private val sourceManager by injectLazy<SourceManager>()
/**
* Downloads provider, used to retrieve the folders where the chapters are or should be stored.
*/
@@ -27,12 +34,17 @@ class DownloadManager(context: Context) {
/**
* Cache of downloaded chapters.
*/
private val cache = DownloadCache(context, provider)
private val cache = DownloadCache(context, provider, sourceManager)
/**
* Downloader whose only task is to download chapters.
*/
private val downloader = Downloader(context, provider, cache)
private val downloader = Downloader(context, provider, cache, sourceManager)
/**
* Queue to delay the deletion of a list of chapters until triggered.
*/
private val pendingDeleter = DownloadPendingDeleter(context)
/**
* Downloads queue, where the pending chapters are stored.
@@ -146,15 +158,20 @@ class DownloadManager(context: Context) {
}
/**
* Deletes the directory of a downloaded chapter.
* Deletes the directories of a list of downloaded chapters.
*
* @param chapter the chapter to delete.
* @param manga the manga of the chapter.
* @param source the source of the chapter.
* @param chapters the list of chapters to delete.
* @param manga the manga of the chapters.
* @param source the source of the chapters.
*/
fun deleteChapter(chapter: Chapter, manga: Manga, source: Source) {
provider.findChapterDir(chapter, manga, source)?.delete()
cache.removeChapter(chapter, manga)
fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source) {
queue.remove(chapters)
val chapterDirs = provider.findChapterDirs(chapters, manga, source)
chapterDirs.forEach { it.delete() }
cache.removeChapters(chapters, manga)
if (cache.getDownloadCount(manga) == 0) { // Delete manga directory if empty
chapterDirs.firstOrNull()?.parentFile?.delete()
}
}
/**
@@ -164,7 +181,30 @@ class DownloadManager(context: Context) {
* @param source the source of the manga.
*/
fun deleteManga(manga: Manga, source: Source) {
queue.remove(manga)
provider.findMangaDir(manga, source)?.delete()
cache.removeManga(manga)
}
/**
* Adds a list of chapters to be deleted later.
*
* @param chapters the list of chapters to delete.
* @param manga the manga of the chapters.
*/
fun enqueueDeleteChapters(chapters: List<Chapter>, manga: Manga) {
pendingDeleter.addChapters(chapters, manga)
}
/**
* Triggers the execution of the deletion of pending chapters.
*/
fun deletePendingChapters() {
val pendingChapters = pendingDeleter.getPendingChapters()
for ((manga, chapters) in pendingChapters) {
val source = sourceManager.get(manga.source) ?: continue
deleteChapters(chapters, manga, source)
}
}
}

View File

@@ -0,0 +1,180 @@
package eu.kanade.tachiyomi.data.download
import android.content.Context
import com.github.salomonbrys.kotson.fromJson
import com.google.gson.Gson
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import uy.kohesive.injekt.injectLazy
/**
* Class used to keep a list of chapters for future deletion.
*
* @param context the application context.
*/
class DownloadPendingDeleter(context: Context) {
/**
* Gson instance to encode and decode chapters.
*/
private val gson by injectLazy<Gson>()
/**
* Preferences used to store the list of chapters to delete.
*/
private val prefs = context.getSharedPreferences("chapters_to_delete", Context.MODE_PRIVATE)
/**
* Last added chapter, used to avoid decoding from the preference too often.
*/
private var lastAddedEntry: Entry? = null
/**
* Adds a list of chapters for future deletion.
*
* @param chapters the chapters to be deleted.
* @param manga the manga of the chapters.
*/
@Synchronized
fun addChapters(chapters: List<Chapter>, manga: Manga) {
val lastEntry = lastAddedEntry
val newEntry = if (lastEntry != null && lastEntry.manga.id == manga.id) {
// Append new chapters
val newChapters = lastEntry.chapters.addUniqueById(chapters)
// If no chapters were added, do nothing
if (newChapters.size == lastEntry.chapters.size) return
// Last entry matches the manga, reuse it to avoid decoding json from preferences
lastEntry.copy(chapters = newChapters)
} else {
val existingEntry = prefs.getString(manga.id!!.toString(), null)
if (existingEntry != null) {
// Existing entry found on preferences, decode json and add the new chapter
val savedEntry = gson.fromJson<Entry>(existingEntry)
// Append new chapters
val newChapters = savedEntry.chapters.addUniqueById(chapters)
// If no chapters were added, do nothing
if (newChapters.size == savedEntry.chapters.size) return
savedEntry.copy(chapters = newChapters)
} else {
// No entry has been found yet, create a new one
Entry(chapters.map { it.toEntry() }, manga.toEntry())
}
}
// Save current state
val json = gson.toJson(newEntry)
prefs.edit().putString(newEntry.manga.id.toString(), json).apply()
lastAddedEntry = newEntry
}
/**
* Returns the list of chapters to be deleted grouped by its manga.
*
* Note: the returned list of manga and chapters only contain basic information needed by the
* downloader, so don't use them for anything else.
*/
@Synchronized
fun getPendingChapters(): Map<Manga, List<Chapter>> {
val entries = decodeAll()
prefs.edit().clear().apply()
lastAddedEntry = null
return entries.associate { entry ->
entry.manga.toModel() to entry.chapters.map { it.toModel() }
}
}
/**
* Decodes all the chapters from preferences.
*/
private fun decodeAll(): List<Entry> {
return prefs.all.values.mapNotNull { rawEntry ->
try {
(rawEntry as? String)?.let { gson.fromJson<Entry>(it) }
} catch (e: Exception) {
null
}
}
}
/**
* Returns a copy of chapter entries ensuring no duplicates by chapter id.
*/
private fun List<ChapterEntry>.addUniqueById(chapters: List<Chapter>): List<ChapterEntry> {
val newList = toMutableList()
for (chapter in chapters) {
if (none { it.id == chapter.id }) {
newList.add(chapter.toEntry())
}
}
return newList
}
/**
* Class used to save an entry of chapters with their manga into preferences.
*/
private data class Entry(
val chapters: List<ChapterEntry>,
val manga: MangaEntry
)
/**
* Class used to save an entry for a chapter into preferences.
*/
private data class ChapterEntry(
val id: Long,
val url: String,
val name: String
)
/**
* Class used to save an entry for a manga into preferences.
*/
private data class MangaEntry(
val id: Long,
val url: String,
val title: String,
val source: Long
)
/**
* Returns a manga entry from a manga model.
*/
private fun Manga.toEntry(): MangaEntry {
return MangaEntry(id!!, url, title, source)
}
/**
* Returns a chapter entry from a chapter model.
*/
private fun Chapter.toEntry(): ChapterEntry {
return ChapterEntry(id!!, url, name)
}
/**
* Returns a manga model from a manga entry.
*/
private fun MangaEntry.toModel(): Manga {
return Manga.create(url, title, source).also {
it.id = id
}
}
/**
* Returns a chapter model from a chapter entry.
*/
private fun ChapterEntry.toModel(): Chapter {
return Chapter.create().also {
it.id = id
it.url = url
it.name = name
}
}
}

View File

@@ -81,6 +81,18 @@ class DownloadProvider(private val context: Context) {
return mangaDir?.findFile(getChapterDirName(chapter))
}
/**
* Returns a list of downloaded directories for the chapters that exist.
*
* @param chapters the chapters to query.
* @param manga the manga of the chapter.
* @param source the source of the chapter.
*/
fun findChapterDirs(chapters: List<Chapter>, manga: Manga, source: Source): List<UniFile> {
val mangaDir = findMangaDir(manga, source) ?: return emptyList()
return chapters.mapNotNull { mangaDir.findFile(getChapterDirName(it)) }
}
/**
* Returns the download directory name for a source.
*
@@ -108,4 +120,4 @@ class DownloadProvider(private val context: Context) {
return DiskUtil.buildValidFilename(chapter.name)
}
}
}

View File

@@ -14,7 +14,10 @@ import uy.kohesive.injekt.injectLazy
*
* @param context the application context.
*/
class DownloadStore(context: Context) {
class DownloadStore(
context: Context,
private val sourceManager: SourceManager
) {
/**
* Preference file where active downloads are stored.
@@ -26,11 +29,6 @@ class DownloadStore(context: Context) {
*/
private val gson: Gson by injectLazy()
/**
* Source manager.
*/
private val sourceManager: SourceManager by injectLazy()
/**
* Database helper.
*/
@@ -83,7 +81,7 @@ class DownloadStore(context: Context) {
fun restore(): List<Download> {
val objs = preferences.all
.mapNotNull { it.value as? String }
.map { deserialize(it) }
.mapNotNull { deserialize(it) }
.sortedBy { it.order }
val downloads = mutableListOf<Download>()
@@ -119,8 +117,12 @@ class DownloadStore(context: Context) {
*
* @param string the download as string.
*/
private fun deserialize(string: String): DownloadObject {
return gson.fromJson(string, DownloadObject::class.java)
private fun deserialize(string: String): DownloadObject? {
return try {
gson.fromJson(string, DownloadObject::class.java)
} catch (e: Exception) {
null
}
}
/**
@@ -132,4 +134,4 @@ class DownloadStore(context: Context) {
*/
data class DownloadObject(val mangaId: Long, val chapterId: Long, val order: Int)
}
}

View File

@@ -21,7 +21,6 @@ import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import rx.subscriptions.CompositeSubscription
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
/**
* This class is the one in charge of downloading chapters.
@@ -35,28 +34,25 @@ import uy.kohesive.injekt.injectLazy
* @param context the application context.
* @param provider the downloads directory provider.
* @param cache the downloads cache, used to add the downloads to the cache after their completion.
* @param sourceManager the source manager.
*/
class Downloader(
private val context: Context,
private val provider: DownloadProvider,
private val cache: DownloadCache
private val cache: DownloadCache,
private val sourceManager: SourceManager
) {
/**
* Store for persisting downloads across restarts.
*/
private val store = DownloadStore(context)
private val store = DownloadStore(context, sourceManager)
/**
* Queue where active downloads are kept.
*/
val queue = DownloadQueue(store)
/**
* Source manager.
*/
private val sourceManager: SourceManager by injectLazy()
/**
* Notifier for the downloader state and progress.
*/
@@ -382,7 +378,7 @@ class Downloader(
// Else guess from the uri.
?: context.contentResolver.getType(file.uri)
// Else read magic numbers.
?: DiskUtil.findImageMime { file.openInputStream() }
?: ImageUtil.findImageType { file.openInputStream() }?.mime
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
}

View File

@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.download.model
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadStore
import eu.kanade.tachiyomi.source.model.Page
import rx.Observable
@@ -40,6 +41,14 @@ class DownloadQueue(
find { it.chapter.id == chapter.id }?.let { remove(it) }
}
fun remove(chapters: List<Chapter>) {
for (chapter in chapters) { remove(chapter) }
}
fun remove(manga: Manga) {
filter { it.manga.id == manga.id }.forEach { remove(it) }
}
fun clear() {
queue.forEach { download ->
download.setStatusSubject(null)

View File

@@ -0,0 +1,74 @@
package eu.kanade.tachiyomi.data.glide
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.data.DataFetcher
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.signature.ObjectKey
import java.io.IOException
import java.io.InputStream
class PassthroughModelLoader : ModelLoader<InputStream, InputStream> {
override fun buildLoadData(
model: InputStream,
width: Int,
height: Int,
options: Options
): ModelLoader.LoadData<InputStream>? {
return ModelLoader.LoadData(ObjectKey(model), Fetcher(model))
}
override fun handles(model: InputStream): Boolean {
return true
}
class Fetcher(private val stream: InputStream) : DataFetcher<InputStream> {
override fun getDataClass(): Class<InputStream> {
return InputStream::class.java
}
override fun cleanup() {
try {
stream.close()
} catch (e: IOException) {
// Do nothing
}
}
override fun getDataSource(): DataSource {
return DataSource.LOCAL
}
override fun cancel() {
// Do nothing
}
override fun loadData(
priority: Priority,
callback: DataFetcher.DataCallback<in InputStream>
) {
callback.onDataReady(stream)
}
}
/**
* Factory class for creating [PassthroughModelLoader] instances.
*/
class Factory : ModelLoaderFactory<InputStream, InputStream> {
override fun build(
multiFactory: MultiModelLoaderFactory
): ModelLoader<InputStream, InputStream> {
return PassthroughModelLoader()
}
override fun teardown() {}
}
}

View File

@@ -37,5 +37,7 @@ class TachiGlideModule : AppGlideModule() {
registry.replace(GlideUrl::class.java, InputStream::class.java, networkFactory)
registry.append(Manga::class.java, InputStream::class.java, MangaModelLoader.Factory())
registry.append(InputStream::class.java, InputStream::class.java, PassthroughModelLoader
.Factory())
}
}

View File

@@ -31,8 +31,6 @@ object PreferenceKeys {
const val imageScaleType = "pref_image_scale_type_key"
const val imageDecoder = "image_decoder"
const val zoomStart = "pref_zoom_start_key"
const val readerTheme = "pref_reader_theme_key"

View File

@@ -59,8 +59,6 @@ class PreferencesHelper(val context: Context) {
fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1)
fun imageDecoder() = rxPrefs.getInteger(Keys.imageDecoder, 0)
fun zoomStart() = rxPrefs.getInteger(Keys.zoomStart, 1)
fun readerTheme() = rxPrefs.getInteger(Keys.readerTheme, 0)