Browse Source

Manga cover updates (#3101)

* cover caching overhaul

* add ui for removing custom cover

* skip some loading work

* minor cleanup

* allow refresh library metadata to refresh local manga

* rename metadata_date to cover_last_modified

* rearrange removeMangaFromLibrary

* change custom cover directory
add setting for updating cover when refreshing library

* remove toggle and explicit action for updating covers
MCAxiaz 4 years ago
parent
commit
dc54299e24
30 changed files with 440 additions and 207 deletions
  1. 60 21
      app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt
  2. 4 1
      app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt
  3. 3 0
      app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaTypeMapping.kt
  4. 2 0
      app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt
  5. 2 0
      app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaImpl.kt
  6. 6 0
      app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaQueries.kt
  7. 31 0
      app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaCoverLastModifiedPutResolver.kt
  8. 7 1
      app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaTable.kt
  9. 6 2
      app/src/main/java/eu/kanade/tachiyomi/data/glide/FileFetcher.kt
  10. 25 0
      app/src/main/java/eu/kanade/tachiyomi/data/glide/LibraryMangaCustomCoverFetcher.kt
  11. 20 9
      app/src/main/java/eu/kanade/tachiyomi/data/glide/LibraryMangaUrlFetcher.kt
  12. 0 27
      app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaSignature.kt
  13. 10 2
      app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaThumbnail.kt
  14. 14 23
      app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaThumbnailModelLoader.kt
  15. 25 3
      app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt
  16. 2 1
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt
  17. 38 0
      app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCoverDialog.kt
  18. 52 37
      app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt
  19. 2 2
      app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryGridHolder.kt
  20. 2 2
      app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListHolder.kt
  21. 48 23
      app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt
  22. 2 2
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt
  23. 12 17
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt
  24. 13 17
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoPresenter.kt
  25. 6 3
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt
  26. 5 7
      app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryHolder.kt
  27. 5 7
      app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesHolder.kt
  28. 5 0
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt
  29. 32 0
      app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt
  30. 1 0
      app/src/main/res/values/strings.xml

+ 60 - 21
app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt

@@ -1,6 +1,7 @@
 package eu.kanade.tachiyomi.data.cache
 
 import android.content.Context
+import eu.kanade.tachiyomi.data.database.models.Manga
 import eu.kanade.tachiyomi.util.storage.DiskUtil
 import java.io.File
 import java.io.IOException
@@ -17,51 +18,89 @@ import java.io.InputStream
  */
 class CoverCache(private val context: Context) {
 
+    companion object {
+        private const val COVERS_DIR = "covers"
+        private const val CUSTOM_COVERS_DIR = "covers/custom"
+    }
+
     /**
      * Cache directory used for cache management.
      */
-    private val cacheDir = context.getExternalFilesDir("covers")
-        ?: File(context.filesDir, "covers").also { it.mkdirs() }
+    private val cacheDir = getCacheDir(COVERS_DIR)
+
+    private val customCoverCacheDir = getCacheDir(CUSTOM_COVERS_DIR)
 
     /**
      * Returns the cover from cache.
      *
-     * @param thumbnailUrl the thumbnail url.
+     * @param manga the manga.
      * @return cover image.
      */
-    fun getCoverFile(thumbnailUrl: String): File {
-        return File(cacheDir, DiskUtil.hashKeyForDisk(thumbnailUrl))
+    fun getCoverFile(manga: Manga): File? {
+        return manga.thumbnail_url?.let {
+            File(cacheDir, DiskUtil.hashKeyForDisk(it))
+        }
     }
 
     /**
-     * Copy the given stream to this cache.
+     * Returns the custom cover from cache.
      *
-     * @param thumbnailUrl url of the thumbnail.
+     * @param manga the manga.
+     * @return cover image.
+     */
+    fun getCustomCoverFile(manga: Manga): File {
+        return File(customCoverCacheDir, DiskUtil.hashKeyForDisk(manga.id.toString()))
+    }
+
+    /**
+     * Saves the given stream as the manga's custom cover to cache.
+     *
+     * @param manga the manga.
      * @param inputStream the stream to copy.
      * @throws IOException if there's any error.
      */
     @Throws(IOException::class)
-    fun copyToCache(thumbnailUrl: String, inputStream: InputStream) {
-        // Get destination file.
-        val destFile = getCoverFile(thumbnailUrl)
+    fun setCustomCoverToCache(manga: Manga, inputStream: InputStream) {
+        getCustomCoverFile(manga).outputStream().use {
+            inputStream.copyTo(it)
+        }
+    }
+
+    /**
+     * Delete the cover files of the manga from the cache.
+     *
+     * @param manga the manga.
+     * @param deleteCustomCover whether the custom cover should be deleted.
+     * @return number of files that were deleted.
+     */
+    fun deleteFromCache(manga: Manga, deleteCustomCover: Boolean = false): Int {
+        var deleted = 0
 
-        destFile.outputStream().use { inputStream.copyTo(it) }
+        getCoverFile(manga)?.let {
+            if (it.exists() && it.delete()) ++deleted
+        }
+
+        if (deleteCustomCover) {
+            if (deleteCustomCover(manga)) ++deleted
+        }
+
+        return deleted
     }
 
     /**
-     * Delete the cover file from the cache.
+     * Delete custom cover of the manga from the cache
      *
-     * @param thumbnailUrl the thumbnail url.
-     * @return status of deletion.
+     * @param manga the manga.
+     * @return whether the cover was deleted.
      */
-    fun deleteFromCache(thumbnailUrl: String?): Boolean {
-        // Check if url is empty.
-        if (thumbnailUrl.isNullOrEmpty()) {
-            return false
+    fun deleteCustomCover(manga: Manga): Boolean {
+        return getCustomCoverFile(manga).let {
+            it.exists() && it.delete()
         }
+    }
 
-        // Remove file.
-        val file = getCoverFile(thumbnailUrl)
-        return file.exists() && file.delete()
+    private fun getCacheDir(dir: String): File {
+        return context.getExternalFilesDir(dir)
+            ?: File(context.filesDir, dir).also { it.mkdirs() }
     }
 }

+ 4 - 1
app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt

@@ -20,7 +20,7 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
         /**
          * Version of the database.
          */
-        const val DATABASE_VERSION = 9
+        const val DATABASE_VERSION = 10
     }
 
     override fun onCreate(db: SupportSQLiteDatabase) = with(db) {
@@ -75,6 +75,9 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
             db.execSQL(TrackTable.addStartDate)
             db.execSQL(TrackTable.addFinishDate)
         }
+        if (oldVersion < 10) {
+            db.execSQL(MangaTable.addCoverLastModified)
+        }
     }
 
     override fun onConfigure(db: SupportSQLiteDatabase) {

+ 3 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaTypeMapping.kt

@@ -14,6 +14,7 @@ import eu.kanade.tachiyomi.data.database.models.MangaImpl
 import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_ARTIST
 import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_AUTHOR
 import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_CHAPTER_FLAGS
+import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_COVER_LAST_MODIFIED
 import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_DESCRIPTION
 import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_FAVORITE
 import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_GENRE
@@ -62,6 +63,7 @@ class MangaPutResolver : DefaultPutResolver<Manga>() {
         put(COL_INITIALIZED, obj.initialized)
         put(COL_VIEWER, obj.viewer)
         put(COL_CHAPTER_FLAGS, obj.chapter_flags)
+        put(COL_COVER_LAST_MODIFIED, obj.cover_last_modified)
     }
 }
 
@@ -82,6 +84,7 @@ interface BaseMangaGetResolver {
         initialized = cursor.getInt(cursor.getColumnIndex(COL_INITIALIZED)) == 1
         viewer = cursor.getInt(cursor.getColumnIndex(COL_VIEWER))
         chapter_flags = cursor.getInt(cursor.getColumnIndex(COL_CHAPTER_FLAGS))
+        cover_last_modified = cursor.getLong(cursor.getColumnIndex(COL_COVER_LAST_MODIFIED))
     }
 }
 

+ 2 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt

@@ -16,6 +16,8 @@ interface Manga : SManga {
 
     var chapter_flags: Int
 
+    var cover_last_modified: Long
+
     fun setChapterOrder(order: Int) {
         setFlags(order, SORT_MASK)
     }

+ 2 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaImpl.kt

@@ -32,6 +32,8 @@ open class MangaImpl : Manga {
 
     override var chapter_flags: Int = 0
 
+    override var cover_last_modified: Long = 0
+
     override fun equals(other: Any?): Boolean {
         if (this === other) return true
         if (other == null || javaClass != other.javaClass) return false

+ 6 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaQueries.kt

@@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.data.database.DbProvider
 import eu.kanade.tachiyomi.data.database.models.LibraryManga
 import eu.kanade.tachiyomi.data.database.models.Manga
 import eu.kanade.tachiyomi.data.database.resolvers.LibraryMangaGetResolver
+import eu.kanade.tachiyomi.data.database.resolvers.MangaCoverLastModifiedPutResolver
 import eu.kanade.tachiyomi.data.database.resolvers.MangaFavoritePutResolver
 import eu.kanade.tachiyomi.data.database.resolvers.MangaFlagsPutResolver
 import eu.kanade.tachiyomi.data.database.resolvers.MangaLastUpdatedPutResolver
@@ -102,6 +103,11 @@ interface MangaQueries : DbProvider {
         .withPutResolver(MangaTitlePutResolver())
         .prepare()
 
+    fun updateMangaCoverLastModified(manga: Manga) = db.put()
+        .`object`(manga)
+        .withPutResolver(MangaCoverLastModifiedPutResolver())
+        .prepare()
+
     fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare()
 
     fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare()

+ 31 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaCoverLastModifiedPutResolver.kt

@@ -0,0 +1,31 @@
+package eu.kanade.tachiyomi.data.database.resolvers
+
+import android.content.ContentValues
+import com.pushtorefresh.storio.sqlite.StorIOSQLite
+import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
+import com.pushtorefresh.storio.sqlite.operations.put.PutResult
+import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
+import eu.kanade.tachiyomi.data.database.inTransactionReturn
+import eu.kanade.tachiyomi.data.database.models.Manga
+import eu.kanade.tachiyomi.data.database.tables.MangaTable
+
+class MangaCoverLastModifiedPutResolver : PutResolver<Manga>() {
+
+    override fun performPut(db: StorIOSQLite, manga: Manga) = db.inTransactionReturn {
+        val updateQuery = mapToUpdateQuery(manga)
+        val contentValues = mapToContentValues(manga)
+
+        val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
+        PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
+    }
+
+    fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder()
+        .table(MangaTable.TABLE)
+        .where("${MangaTable.COL_ID} = ?")
+        .whereArgs(manga.id)
+        .build()
+
+    fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
+        put(MangaTable.COL_COVER_LAST_MODIFIED, manga.cover_last_modified)
+    }
+}

+ 7 - 1
app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaTable.kt

@@ -38,6 +38,8 @@ object MangaTable {
 
     const val COL_CATEGORY = "category"
 
+    const val COL_COVER_LAST_MODIFIED = "cover_last_modified"
+
     val createTableQuery: String
         get() =
             """CREATE TABLE $TABLE(
@@ -55,7 +57,8 @@ object MangaTable {
             $COL_LAST_UPDATE LONG,
             $COL_INITIALIZED BOOLEAN NOT NULL,
             $COL_VIEWER INTEGER NOT NULL,
-            $COL_CHAPTER_FLAGS INTEGER NOT NULL
+            $COL_CHAPTER_FLAGS INTEGER NOT NULL,
+            $COL_COVER_LAST_MODIFIED LONG NOT NULL
             )"""
 
     val createUrlIndexQuery: String
@@ -64,4 +67,7 @@ object MangaTable {
     val createLibraryIndexQuery: String
         get() = "CREATE INDEX library_${COL_FAVORITE}_index ON $TABLE($COL_FAVORITE) " +
             "WHERE $COL_FAVORITE = 1"
+
+    val addCoverLastModified: String
+        get() = "ALTER TABLE $TABLE ADD COLUMN $COL_COVER_LAST_MODIFIED LONG NOT NULL DEFAULT 0"
 }

+ 6 - 2
app/src/main/java/eu/kanade/tachiyomi/data/glide/FileFetcher.kt

@@ -12,7 +12,7 @@ import java.io.IOException
 import java.io.InputStream
 import timber.log.Timber
 
-open class FileFetcher(private val file: File) : DataFetcher<InputStream> {
+open class FileFetcher(private val filePath: String = "") : DataFetcher<InputStream> {
 
     private var data: InputStream? = null
 
@@ -20,7 +20,11 @@ open class FileFetcher(private val file: File) : DataFetcher<InputStream> {
         loadFromFile(callback)
     }
 
-    protected fun loadFromFile(callback: DataFetcher.DataCallback<in InputStream>) {
+    private fun loadFromFile(callback: DataFetcher.DataCallback<in InputStream>) {
+        loadFromFile(File(filePath), callback)
+    }
+
+    protected fun loadFromFile(file: File, callback: DataFetcher.DataCallback<in InputStream>) {
         try {
             data = FileInputStream(file)
         } catch (e: FileNotFoundException) {

+ 25 - 0
app/src/main/java/eu/kanade/tachiyomi/data/glide/LibraryMangaCustomCoverFetcher.kt

@@ -0,0 +1,25 @@
+package eu.kanade.tachiyomi.data.glide
+
+import com.bumptech.glide.Priority
+import com.bumptech.glide.load.data.DataFetcher
+import eu.kanade.tachiyomi.data.cache.CoverCache
+import eu.kanade.tachiyomi.data.database.models.Manga
+import java.io.File
+import java.io.InputStream
+import java.lang.Exception
+
+open class LibraryMangaCustomCoverFetcher(
+    private val manga: Manga,
+    private val coverCache: CoverCache
+) : FileFetcher() {
+
+    override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in InputStream>) {
+        getCustomCoverFile()?.let {
+            loadFromFile(it, callback)
+        } ?: callback.onLoadFailed(Exception("Custom cover file not found"))
+    }
+
+    protected fun getCustomCoverFile(): File? {
+        return coverCache.getCustomCoverFile(manga).takeIf { it.exists() }
+    }
+}

+ 20 - 9
app/src/main/java/eu/kanade/tachiyomi/data/glide/LibraryMangaUrlFetcher.kt

@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.glide
 
 import com.bumptech.glide.Priority
 import com.bumptech.glide.load.data.DataFetcher
+import eu.kanade.tachiyomi.data.cache.CoverCache
 import eu.kanade.tachiyomi.data.database.models.Manga
 import java.io.File
 import java.io.FileNotFoundException
@@ -19,31 +20,41 @@ import java.io.InputStream
 class LibraryMangaUrlFetcher(
     private val networkFetcher: DataFetcher<InputStream>,
     private val manga: Manga,
-    private val file: File
-) :
-    FileFetcher(file) {
+    private val coverCache: CoverCache
+) : LibraryMangaCustomCoverFetcher(manga, coverCache) {
 
     override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in InputStream>) {
-        if (!file.exists()) {
+        getCustomCoverFile()?.let {
+            loadFromFile(it, callback)
+            return
+        }
+
+        val cover = coverCache.getCoverFile(manga)
+        if (cover == null) {
+            callback.onLoadFailed(Exception("Null thumbnail url"))
+            return
+        }
+
+        if (!cover.exists()) {
             networkFetcher.loadData(
                 priority,
                 object : DataFetcher.DataCallback<InputStream> {
                     override fun onDataReady(data: InputStream?) {
                         if (data != null) {
-                            val tmpFile = File(file.path + ".tmp")
+                            val tmpFile = File(cover.path + ".tmp")
                             try {
                                 // Retrieve destination stream, create parent folders if needed.
                                 val output = try {
                                     tmpFile.outputStream()
                                 } catch (e: FileNotFoundException) {
-                                    tmpFile.parentFile.mkdirs()
+                                    tmpFile.parentFile!!.mkdirs()
                                     tmpFile.outputStream()
                                 }
 
                                 // Copy the file and rename to the original.
                                 data.use { output.use { data.copyTo(output) } }
-                                tmpFile.renameTo(file)
-                                loadFromFile(callback)
+                                tmpFile.renameTo(cover)
+                                loadFromFile(cover, callback)
                             } catch (e: Exception) {
                                 tmpFile.delete()
                                 callback.onLoadFailed(e)
@@ -59,7 +70,7 @@ class LibraryMangaUrlFetcher(
                 }
             )
         } else {
-            loadFromFile(callback)
+            loadFromFile(cover, callback)
         }
     }
 

+ 0 - 27
app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaSignature.kt

@@ -1,27 +0,0 @@
-package eu.kanade.tachiyomi.data.glide
-
-import com.bumptech.glide.load.Key
-import eu.kanade.tachiyomi.data.database.models.Manga
-import java.io.File
-import java.security.MessageDigest
-
-class MangaSignature(manga: Manga, file: File) : Key {
-
-    private val key = manga.thumbnail_url + file.lastModified()
-
-    override fun equals(other: Any?): Boolean {
-        return if (other is MangaSignature) {
-            key == other.key
-        } else {
-            false
-        }
-    }
-
-    override fun hashCode(): Int {
-        return key.hashCode()
-    }
-
-    override fun updateDiskCacheKey(md: MessageDigest) {
-        md.update(key.toByteArray(Key.CHARSET))
-    }
-}

+ 10 - 2
app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaThumbnail.kt

@@ -1,7 +1,15 @@
 package eu.kanade.tachiyomi.data.glide
 
+import com.bumptech.glide.load.Key
 import eu.kanade.tachiyomi.data.database.models.Manga
+import java.security.MessageDigest
 
-data class MangaThumbnail(val manga: Manga, val url: String?)
+data class MangaThumbnail(val manga: Manga, val coverLastModified: Long) : Key {
+    val key = manga.url + coverLastModified
 
-fun Manga.toMangaThumbnail() = MangaThumbnail(this, this.thumbnail_url)
+    override fun updateDiskCacheKey(messageDigest: MessageDigest) {
+        messageDigest.update(key.toByteArray(Key.CHARSET))
+    }
+}
+
+fun Manga.toMangaThumbnail() = MangaThumbnail(this, cover_last_modified)

+ 14 - 23
app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaThumbnailModelLoader.kt

@@ -14,7 +14,7 @@ import eu.kanade.tachiyomi.data.database.models.Manga
 import eu.kanade.tachiyomi.network.NetworkHelper
 import eu.kanade.tachiyomi.source.SourceManager
 import eu.kanade.tachiyomi.source.online.HttpSource
-import java.io.File
+import eu.kanade.tachiyomi.util.isLocal
 import java.io.InputStream
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
@@ -48,12 +48,6 @@ class MangaThumbnailModelLoader : ModelLoader<MangaThumbnail, InputStream> {
      */
     private val defaultClient = Injekt.get<NetworkHelper>().client
 
-    /**
-     * LRU cache whose key is the thumbnail url of the manga, and the value contains the request url
-     * and the file where it should be stored in case the manga is a favorite.
-     */
-    private val lruCache = LruCache<GlideUrl, File>(100)
-
     /**
      * Map where request headers are stored for a source.
      */
@@ -78,7 +72,7 @@ class MangaThumbnailModelLoader : ModelLoader<MangaThumbnail, InputStream> {
     /**
      * Returns a fetcher for the given manga or null if the url is empty.
      *
-     * @param manga the model.
+     * @param mangaThumbnail the model.
      * @param width the width of the view where the resource will be loaded.
      * @param height the height of the view where the resource will be loaded.
      */
@@ -88,13 +82,16 @@ class MangaThumbnailModelLoader : ModelLoader<MangaThumbnail, InputStream> {
         height: Int,
         options: Options
     ): ModelLoader.LoadData<InputStream>? {
-        // Check thumbnail is not null or empty
-        val url = mangaThumbnail.url
-        if (url == null || url.isEmpty()) {
-            return null
-        }
-
         val manga = mangaThumbnail.manga
+        val url = manga.thumbnail_url
+
+        if (url.isNullOrEmpty()) {
+            return if (!manga.favorite || manga.isLocal()) {
+                null
+            } else {
+                ModelLoader.LoadData(mangaThumbnail, LibraryMangaCustomCoverFetcher(manga, coverCache))
+            }
+        }
 
         if (url.startsWith("http", true)) {
             val source = sourceManager.get(manga.source) as? HttpSource
@@ -107,19 +104,13 @@ class MangaThumbnailModelLoader : ModelLoader<MangaThumbnail, InputStream> {
                 return ModelLoader.LoadData(glideUrl, networkFetcher)
             }
 
-            // Obtain the file for this url from the LRU cache, or retrieve and add it to the cache.
-            val file = lruCache.getOrPut(glideUrl) { coverCache.getCoverFile(url) }
-
-            val libraryFetcher = LibraryMangaUrlFetcher(networkFetcher, manga, file)
+            val libraryFetcher = LibraryMangaUrlFetcher(networkFetcher, manga, coverCache)
 
             // Return an instance of the fetcher providing the needed elements.
-            return ModelLoader.LoadData(MangaSignature(manga, file), libraryFetcher)
+            return ModelLoader.LoadData(mangaThumbnail, libraryFetcher)
         } else {
-            // Get the file from the url, removing the scheme if present.
-            val file = File(url.substringAfter("file://"))
-
             // Return an instance of the fetcher providing the needed elements.
-            return ModelLoader.LoadData(MangaSignature(manga, file), FileFetcher(file))
+            return ModelLoader.LoadData(mangaThumbnail, FileFetcher(url.removePrefix("file://")))
         }
     }
 

+ 25 - 3
app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt

@@ -15,6 +15,7 @@ import androidx.core.app.NotificationCompat.GROUP_ALERT_SUMMARY
 import androidx.core.app.NotificationManagerCompat
 import com.bumptech.glide.Glide
 import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.cache.CoverCache
 import eu.kanade.tachiyomi.data.database.DatabaseHelper
 import eu.kanade.tachiyomi.data.database.models.Category
 import eu.kanade.tachiyomi.data.database.models.Chapter
@@ -31,10 +32,10 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 import eu.kanade.tachiyomi.data.track.TrackManager
 import eu.kanade.tachiyomi.source.SourceManager
 import eu.kanade.tachiyomi.source.model.SManga
-import eu.kanade.tachiyomi.source.online.HttpSource
 import eu.kanade.tachiyomi.ui.main.MainActivity
 import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
 import eu.kanade.tachiyomi.util.lang.chop
+import eu.kanade.tachiyomi.util.prepUpdateCover
 import eu.kanade.tachiyomi.util.system.acquireWakeLock
 import eu.kanade.tachiyomi.util.system.isServiceRunning
 import eu.kanade.tachiyomi.util.system.notification
@@ -64,7 +65,8 @@ class LibraryUpdateService(
     val sourceManager: SourceManager = Injekt.get(),
     val preferences: PreferencesHelper = Injekt.get(),
     val downloadManager: DownloadManager = Injekt.get(),
-    val trackManager: TrackManager = Injekt.get()
+    val trackManager: TrackManager = Injekt.get(),
+    val coverCache: CoverCache = Injekt.get()
 ) : Service() {
 
     /**
@@ -110,6 +112,7 @@ class LibraryUpdateService(
      */
     enum class Target {
         CHAPTERS, // Manga chapters
+        COVERS, // Manga covers
         TRACKING // Tracking metadata
     }
 
@@ -233,6 +236,7 @@ class LibraryUpdateService(
                 // Update either chapter list or manga details.
                 when (target) {
                     Target.CHAPTERS -> updateChapterList(mangaList)
+                    Target.COVERS -> updateCovers(mangaList)
                     Target.TRACKING -> updateTrackings(mangaList)
                 }
             }
@@ -387,11 +391,14 @@ class LibraryUpdateService(
      * @return a pair of the inserted and removed chapters.
      */
     fun updateManga(manga: Manga): Observable<Pair<List<Chapter>, List<Chapter>>> {
-        val source = sourceManager.get(manga.source) as? HttpSource ?: return Observable.empty()
+        val source = sourceManager.get(manga.source) ?: return Observable.empty()
 
         // Update manga details metadata in the background
         source.fetchMangaDetails(manga)
             .map { networkManga ->
+                if (manga.thumbnail_url != networkManga.thumbnail_url) {
+                    manga.prepUpdateCover(coverCache)
+                }
                 manga.copyFrom(networkManga)
                 db.insertManga(manga).executeAsBlocking()
                 manga
@@ -404,6 +411,21 @@ class LibraryUpdateService(
             .map { syncChaptersWithSource(db, it, manga, source) }
     }
 
+    private fun updateCovers(mangaToUpdate: List<LibraryManga>): Observable<LibraryManga> {
+        var count = 0
+
+        return Observable.from(mangaToUpdate)
+            .doOnNext { showProgressNotification(it, count++, mangaToUpdate.size) }
+            .map { manga ->
+                manga.prepUpdateCover(coverCache)
+                db.insertManga(manga).executeAsBlocking()
+                manga
+            }
+            .doOnCompleted {
+                cancelProgressNotification()
+            }
+    }
+
     /**
      * Method that updates the metadata of the connected tracking services. It's called in a
      * background thread, so it's safe to do heavy operations or network calls here.

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

@@ -28,6 +28,7 @@ import eu.kanade.tachiyomi.ui.browse.source.filter.TextItem
 import eu.kanade.tachiyomi.ui.browse.source.filter.TextSectionItem
 import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateItem
 import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateSectionItem
+import eu.kanade.tachiyomi.util.removeCovers
 import rx.Observable
 import rx.Subscription
 import rx.android.schedulers.AndroidSchedulers
@@ -279,7 +280,7 @@ open class BrowseSourcePresenter(
     fun changeMangaFavorite(manga: Manga) {
         manga.favorite = !manga.favorite
         if (!manga.favorite) {
-            coverCache.deleteFromCache(manga.thumbnail_url)
+            manga.removeCovers(coverCache)
         }
         db.insertManga(manga).executeAsBlocking()
     }

+ 38 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCoverDialog.kt

@@ -0,0 +1,38 @@
+package eu.kanade.tachiyomi.ui.library
+
+import android.app.Dialog
+import android.os.Bundle
+import com.afollestad.materialdialogs.MaterialDialog
+import com.bluelinelabs.conductor.Controller
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.database.models.Manga
+import eu.kanade.tachiyomi.ui.base.controller.DialogController
+
+class ChangeMangaCoverDialog<T>(bundle: Bundle? = null) :
+    DialogController(bundle) where T : Controller, T : ChangeMangaCoverDialog.Listener {
+
+    private lateinit var manga: Manga
+
+    constructor(target: T, manga: Manga) : this() {
+        targetController = target
+        this.manga = manga
+    }
+
+    override fun onCreateDialog(savedViewState: Bundle?): Dialog {
+        return MaterialDialog(activity!!)
+            .title(R.string.action_edit_cover)
+            .positiveButton(R.string.action_edit) {
+                (targetController as? Listener)?.openMangaCoverPicker(manga)
+            }
+            .negativeButton(android.R.string.cancel)
+            .neutralButton(R.string.action_delete) {
+                (targetController as? Listener)?.deleteMangaCover(manga)
+            }
+    }
+
+    interface Listener {
+        fun deleteMangaCover(manga: Manga)
+
+        fun openMangaCoverPicker(manga: Manga)
+    }
+}

+ 52 - 37
app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt

@@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.ui.library
 import android.app.Activity
 import android.content.Intent
 import android.content.res.Configuration
-import android.net.Uri
 import android.os.Bundle
 import android.view.LayoutInflater
 import android.view.Menu
@@ -22,6 +21,7 @@ import com.google.android.material.tabs.TabLayout
 import com.jakewharton.rxrelay.BehaviorRelay
 import com.jakewharton.rxrelay.PublishRelay
 import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.cache.CoverCache
 import eu.kanade.tachiyomi.data.database.models.Category
 import eu.kanade.tachiyomi.data.database.models.Manga
 import eu.kanade.tachiyomi.data.library.LibraryUpdateService
@@ -37,7 +37,6 @@ import eu.kanade.tachiyomi.ui.manga.MangaController
 import eu.kanade.tachiyomi.util.system.getResourceColor
 import eu.kanade.tachiyomi.util.system.toast
 import eu.kanade.tachiyomi.util.view.visible
-import java.io.IOException
 import kotlinx.android.synthetic.main.main_activity.tabs
 import kotlinx.coroutines.flow.filter
 import kotlinx.coroutines.flow.launchIn
@@ -51,11 +50,13 @@ import uy.kohesive.injekt.api.get
 
 class LibraryController(
     bundle: Bundle? = null,
-    private val preferences: PreferencesHelper = Injekt.get()
+    private val preferences: PreferencesHelper = Injekt.get(),
+    private val coverCache: CoverCache = Injekt.get()
 ) : NucleusController<LibraryControllerBinding, LibraryPresenter>(bundle),
     RootController,
     TabbedController,
     ActionMode.Callback,
+    ChangeMangaCoverDialog.Listener,
     ChangeMangaCategoriesDialog.Listener,
     DeleteLibraryMangasDialog.Listener {
 
@@ -424,10 +425,7 @@ class LibraryController(
 
     private fun onActionItemClicked(item: MenuItem): Boolean {
         when (item.itemId) {
-            R.id.action_edit_cover -> {
-                changeSelectedCover()
-                destroyActionModeIfNeeded()
-            }
+            R.id.action_edit_cover -> handleChangeCover()
             R.id.action_move_to_category -> showChangeMangaCategoriesDialog()
             R.id.action_delete -> showDeleteMangaDialog()
             R.id.action_select_all -> selectAllCategoryManga()
@@ -486,6 +484,23 @@ class LibraryController(
         }
     }
 
+    private fun handleChangeCover() {
+        val manga = selectedMangas.firstOrNull() ?: return
+
+        if (coverCache.getCustomCoverFile(manga).exists()) {
+            showEditCoverDialog(manga)
+        } else {
+            openMangaCoverPicker(manga)
+        }
+    }
+
+    /**
+     * Edit custom cover for selected manga.
+     */
+    private fun showEditCoverDialog(manga: Manga) {
+        ChangeMangaCoverDialog(this, manga).showDialog(router)
+    }
+
     /**
      * Move the selected manga to a list of categories.
      */
@@ -509,21 +524,7 @@ class LibraryController(
         DeleteLibraryMangasDialog(this, selectedMangas.toList()).showDialog(router)
     }
 
-    override fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>) {
-        presenter.moveMangasToCategories(categories, mangas)
-        destroyActionModeIfNeeded()
-    }
-
-    override fun deleteMangasFromLibrary(mangas: List<Manga>, deleteChapters: Boolean) {
-        presenter.removeMangaFromLibrary(mangas, deleteChapters)
-        destroyActionModeIfNeeded()
-    }
-
-    /**
-     * Changes the cover for the selected manga.
-     */
-    private fun changeSelectedCover() {
-        val manga = selectedMangas.firstOrNull() ?: return
+    override fun openMangaCoverPicker(manga: Manga) {
         selectedCoverManga = manga
 
         if (manga.favorite) {
@@ -539,6 +540,23 @@ class LibraryController(
         } else {
             activity?.toast(R.string.notification_first_add_to_library)
         }
+
+        destroyActionModeIfNeeded()
+    }
+
+    override fun deleteMangaCover(manga: Manga) {
+        presenter.deleteCustomCover(manga)
+        destroyActionModeIfNeeded()
+    }
+
+    override fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>) {
+        presenter.moveMangasToCategories(categories, mangas)
+        destroyActionModeIfNeeded()
+    }
+
+    override fun deleteMangasFromLibrary(mangas: List<Manga>, deleteChapters: Boolean) {
+        presenter.removeMangaFromLibrary(mangas, deleteChapters)
+        destroyActionModeIfNeeded()
     }
 
     private fun selectAllCategoryManga() {
@@ -555,28 +573,25 @@ class LibraryController(
 
     override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
         if (requestCode == REQUEST_IMAGE_OPEN) {
-            if (data == null || resultCode != Activity.RESULT_OK) return
+            val dataUri = data?.data
+            if (dataUri == null || resultCode != Activity.RESULT_OK) return
             val activity = activity ?: return
             val manga = selectedCoverManga ?: return
 
-            try {
-                // Get the file's input stream from the incoming Intent
-                activity.contentResolver.openInputStream(data.data ?: Uri.EMPTY).use {
-                    // Update cover to selected file, show error if something went wrong
-                    if (it != null && presenter.editCoverWithStream(it, manga)) {
-                        // TODO refresh cover
-                    } else {
-                        activity.toast(R.string.notification_cover_update_failed)
-                    }
-                }
-            } catch (error: IOException) {
-                activity.toast(R.string.notification_cover_update_failed)
-                Timber.e(error)
-            }
             selectedCoverManga = null
+            presenter.editCover(manga, activity, dataUri)
         }
     }
 
+    fun onSetCoverSuccess() {
+        activity?.toast(R.string.cover_updated)
+    }
+
+    fun onSetCoverError(error: Throwable) {
+        activity?.toast(R.string.notification_cover_update_failed)
+        Timber.e(error)
+    }
+
     private companion object {
         /**
          * Key to change the cover of a manga in [onActivityResult].

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryGridHolder.kt

@@ -5,7 +5,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
 import eu.davidea.flexibleadapter.FlexibleAdapter
 import eu.kanade.tachiyomi.data.glide.GlideApp
 import eu.kanade.tachiyomi.data.glide.toMangaThumbnail
-import eu.kanade.tachiyomi.source.LocalSource
+import eu.kanade.tachiyomi.util.isLocal
 import eu.kanade.tachiyomi.util.view.visibleIf
 import kotlinx.android.synthetic.main.source_grid_item.download_text
 import kotlinx.android.synthetic.main.source_grid_item.local_text
@@ -48,7 +48,7 @@ class LibraryGridHolder(
             text = item.downloadCount.toString()
         }
         // set local visibility if its local manga
-        local_text.visibleIf { item.manga.source == LocalSource.ID }
+        local_text.visibleIf { item.manga.isLocal() }
 
         // Update the cover.
         GlideApp.with(view.context).clear(thumbnail)

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListHolder.kt

@@ -5,7 +5,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
 import eu.davidea.flexibleadapter.FlexibleAdapter
 import eu.kanade.tachiyomi.data.glide.GlideApp
 import eu.kanade.tachiyomi.data.glide.toMangaThumbnail
-import eu.kanade.tachiyomi.source.LocalSource
+import eu.kanade.tachiyomi.util.isLocal
 import eu.kanade.tachiyomi.util.view.visibleIf
 import kotlinx.android.synthetic.main.source_list_item.download_text
 import kotlinx.android.synthetic.main.source_list_item.local_text
@@ -49,7 +49,7 @@ class LibraryListHolder(
             text = "${item.downloadCount}"
         }
         // show local text badge if local manga
-        local_text.visibleIf { item.manga.source == LocalSource.ID }
+        local_text.visibleIf { item.manga.isLocal() }
 
         // Create thumbnail onclick to simulate long click
         thumbnail.setOnClickListener {

+ 48 - 23
app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt

@@ -1,5 +1,7 @@
 package eu.kanade.tachiyomi.ui.library
 
+import android.content.Context
+import android.net.Uri
 import android.os.Bundle
 import com.jakewharton.rxrelay.BehaviorRelay
 import eu.kanade.tachiyomi.data.cache.CoverCache
@@ -14,11 +16,12 @@ import eu.kanade.tachiyomi.source.SourceManager
 import eu.kanade.tachiyomi.source.model.SManga
 import eu.kanade.tachiyomi.source.online.HttpSource
 import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
+import eu.kanade.tachiyomi.util.isLocal
 import eu.kanade.tachiyomi.util.lang.combineLatest
 import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
 import eu.kanade.tachiyomi.util.lang.launchIO
-import java.io.IOException
-import java.io.InputStream
+import eu.kanade.tachiyomi.util.removeCovers
+import eu.kanade.tachiyomi.util.updateCoverLastModified
 import java.util.ArrayList
 import java.util.Collections
 import java.util.Comparator
@@ -128,7 +131,7 @@ class LibraryPresenter(
             // Filter when there are no downloads.
             if (filterDownloaded) {
                 // Local manga are always downloaded
-                if (item.manga.source == LocalSource.ID) {
+                if (item.manga.isLocal()) {
                     return@f true
                 }
                 // Don't bother with directory checking if download count has been set.
@@ -318,16 +321,17 @@ class LibraryPresenter(
      * @param deleteChapters whether to also delete downloaded chapters.
      */
     fun removeMangaFromLibrary(mangas: List<Manga>, deleteChapters: Boolean) {
-        // Create a set of the list
-        val mangaToDelete = mangas.distinctBy { it.id }
-        mangaToDelete.forEach { it.favorite = false }
-
         launchIO {
+            val mangaToDelete = mangas.distinctBy { it.id }
+
+            mangaToDelete.forEach {
+                it.favorite = false
+                it.removeCovers(coverCache)
+            }
             db.insertMangas(mangaToDelete).executeAsBlocking()
 
-            mangaToDelete.forEach { manga ->
-                coverCache.deleteFromCache(manga.thumbnail_url)
-                if (deleteChapters) {
+            if (deleteChapters) {
+                mangaToDelete.forEach { manga ->
                     val source = sourceManager.get(manga.source) as? HttpSource
                     if (source != null) {
                         downloadManager.deleteManga(manga, source)
@@ -358,21 +362,42 @@ class LibraryPresenter(
     /**
      * Update cover with local file.
      *
-     * @param inputStream the new cover.
      * @param manga the manga edited.
-     * @return true if the cover is updated, false otherwise
+     * @param context Context.
+     * @param data uri of the cover resource.
      */
-    @Throws(IOException::class)
-    fun editCoverWithStream(inputStream: InputStream, manga: Manga): Boolean {
-        if (manga.source == LocalSource.ID) {
-            LocalSource.updateCover(context, manga, inputStream)
-            return true
-        }
+    fun editCover(manga: Manga, context: Context, data: Uri) {
+        Observable
+            .fromCallable {
+                context.contentResolver.openInputStream(data)?.use {
+                    if (manga.isLocal()) {
+                        LocalSource.updateCover(context, manga, it)
+                        manga.updateCoverLastModified(db)
+                    } else if (manga.favorite) {
+                        coverCache.setCustomCoverToCache(manga, it)
+                        manga.updateCoverLastModified(db)
+                    }
+                }
+            }
+            .subscribeOn(Schedulers.io())
+            .observeOn(AndroidSchedulers.mainThread())
+            .subscribeFirst(
+                { view, _ -> view.onSetCoverSuccess() },
+                { view, e -> view.onSetCoverError(e) }
+            )
+    }
 
-        if (manga.thumbnail_url != null && manga.favorite) {
-            coverCache.copyToCache(manga.thumbnail_url!!, inputStream)
-            return true
-        }
-        return false
+    fun deleteCustomCover(manga: Manga) {
+        Observable
+            .fromCallable {
+                coverCache.deleteCustomCover(manga)
+                manga.updateCoverLastModified(db)
+            }
+            .subscribeOn(Schedulers.io())
+            .observeOn(AndroidSchedulers.mainThread())
+            .subscribeFirst(
+                { view, _ -> view.onSetCoverSuccess() },
+                { view, e -> view.onSetCoverError(e) }
+            )
     }
 }

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt

@@ -9,10 +9,10 @@ import eu.kanade.tachiyomi.data.database.models.Manga
 import eu.kanade.tachiyomi.data.download.DownloadManager
 import eu.kanade.tachiyomi.data.download.model.Download
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
-import eu.kanade.tachiyomi.source.LocalSource
 import eu.kanade.tachiyomi.source.Source
 import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
 import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
+import eu.kanade.tachiyomi.util.isLocal
 import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
 import java.util.Date
 import rx.Observable
@@ -189,7 +189,7 @@ class ChaptersPresenter(
             observable = observable.filter { it.read }
         }
         if (onlyDownloaded()) {
-            observable = observable.filter { it.isDownloaded || it.manga.source == LocalSource.ID }
+            observable = observable.filter { it.isDownloaded || it.manga.isLocal() }
         }
         if (onlyBookmarked()) {
             observable = observable.filter { it.bookmark }

+ 12 - 17
app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt

@@ -67,8 +67,6 @@ class MangaInfoController(private val fromSource: Boolean = false) :
 
     private var initialLoad: Boolean = true
 
-    private var thumbnailUrl: String? = null
-
     override fun createPresenter(): MangaInfoPresenter {
         val ctrl = parentController as MangaController
         return MangaInfoPresenter(
@@ -113,7 +111,7 @@ class MangaInfoController(private val fromSource: Boolean = false) :
 
         // Set SwipeRefresh to refresh manga data.
         binding.swipeRefresh.refreshes()
-            .onEach { fetchMangaFromSource() }
+            .onEach { fetchMangaFromSource(manualFetch = true) }
             .launchIn(scope)
 
         binding.mangaFullTitle.longClicks()
@@ -241,23 +239,20 @@ class MangaInfoController(private val fromSource: Boolean = false) :
         setFavoriteButtonState(manga.favorite)
 
         // Set cover if it wasn't already.
-        if (binding.mangaCover.drawable == null || manga.thumbnail_url != thumbnailUrl) {
-            thumbnailUrl = manga.thumbnail_url
-            val mangaThumbnail = manga.toMangaThumbnail()
+        val mangaThumbnail = manga.toMangaThumbnail()
+
+        GlideApp.with(view.context)
+            .load(mangaThumbnail)
+            .diskCacheStrategy(DiskCacheStrategy.RESOURCE)
+            .centerCrop()
+            .into(binding.mangaCover)
 
+        binding.backdrop?.let {
             GlideApp.with(view.context)
                 .load(mangaThumbnail)
                 .diskCacheStrategy(DiskCacheStrategy.RESOURCE)
                 .centerCrop()
-                .into(binding.mangaCover)
-
-            if (binding.backdrop != null) {
-                GlideApp.with(view.context)
-                    .load(mangaThumbnail)
-                    .diskCacheStrategy(DiskCacheStrategy.RESOURCE)
-                    .centerCrop()
-                    .into(binding.backdrop!!)
-            }
+                .into(it)
         }
 
         // Manga info section
@@ -422,10 +417,10 @@ class MangaInfoController(private val fromSource: Boolean = false) :
     /**
      * Start fetching manga information from source.
      */
-    private fun fetchMangaFromSource() {
+    private fun fetchMangaFromSource(manualFetch: Boolean = false) {
         setRefreshing(true)
         // Call presenter and start fetching manga information
-        presenter.fetchMangaFromSource()
+        presenter.fetchMangaFromSource(manualFetch)
     }
 
     /**

+ 13 - 17
app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoPresenter.kt

@@ -12,6 +12,8 @@ import eu.kanade.tachiyomi.data.download.DownloadManager
 import eu.kanade.tachiyomi.source.Source
 import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
 import eu.kanade.tachiyomi.util.lang.isNullOrUnsubscribed
+import eu.kanade.tachiyomi.util.prepUpdateCover
+import eu.kanade.tachiyomi.util.removeCovers
 import java.util.Date
 import rx.Observable
 import rx.Subscription
@@ -36,11 +38,6 @@ class MangaInfoPresenter(
     private val coverCache: CoverCache = Injekt.get()
 ) : BasePresenter<MangaInfoController>() {
 
-    /**
-     * Subscription to send the manga to the view.
-     */
-    private var viewMangaSubscription: Subscription? = null
-
     /**
      * Subscription to update the manga from the source.
      */
@@ -48,7 +45,9 @@ class MangaInfoPresenter(
 
     override fun onCreate(savedState: Bundle?) {
         super.onCreate(savedState)
-        sendMangaToView()
+
+        getMangaObservable()
+            .subscribeLatestCache({ view, manga -> view.onNextManga(manga, source) })
 
         // Update chapter count
         chapterCountRelay.observeOn(AndroidSchedulers.mainThread())
@@ -64,22 +63,21 @@ class MangaInfoPresenter(
             .subscribeLatestCache(MangaInfoController::setLastUpdateDate)
     }
 
-    /**
-     * Sends the active manga to the view.
-     */
-    fun sendMangaToView() {
-        viewMangaSubscription?.let { remove(it) }
-        viewMangaSubscription = Observable.just(manga)
-            .subscribeLatestCache({ view, manga -> view.onNextManga(manga, source) })
+    private fun getMangaObservable(): Observable<Manga> {
+        return db.getManga(manga.url, manga.source).asRxObservable()
+            .observeOn(AndroidSchedulers.mainThread())
     }
 
     /**
      * Fetch manga information from source.
      */
-    fun fetchMangaFromSource() {
+    fun fetchMangaFromSource(manualFetch: Boolean = false) {
         if (!fetchMangaSubscription.isNullOrUnsubscribed()) return
         fetchMangaSubscription = Observable.defer { source.fetchMangaDetails(manga) }
             .map { networkManga ->
+                if (manualFetch || manga.thumbnail_url != networkManga.thumbnail_url) {
+                    manga.prepUpdateCover(coverCache)
+                }
                 manga.copyFrom(networkManga)
                 manga.initialized = true
                 db.insertManga(manga).executeAsBlocking()
@@ -87,7 +85,6 @@ class MangaInfoPresenter(
             }
             .subscribeOn(Schedulers.io())
             .observeOn(AndroidSchedulers.mainThread())
-            .doOnNext { sendMangaToView() }
             .subscribeFirst(
                 { view, _ ->
                     view.onFetchMangaDone()
@@ -104,10 +101,9 @@ class MangaInfoPresenter(
     fun toggleFavorite(): Boolean {
         manga.favorite = !manga.favorite
         if (!manga.favorite) {
-            coverCache.deleteFromCache(manga.thumbnail_url)
+            manga.removeCovers(coverCache)
         }
         db.insertManga(manga).executeAsBlocking()
-        sendMangaToView()
         return manga.favorite
     }
 

+ 6 - 3
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt

@@ -21,11 +21,13 @@ import eu.kanade.tachiyomi.ui.reader.loader.DownloadPageLoader
 import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
 import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
 import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
+import eu.kanade.tachiyomi.util.isLocal
 import eu.kanade.tachiyomi.util.lang.byteSize
 import eu.kanade.tachiyomi.util.lang.launchIO
 import eu.kanade.tachiyomi.util.lang.takeBytes
 import eu.kanade.tachiyomi.util.storage.DiskUtil
 import eu.kanade.tachiyomi.util.system.ImageUtil
+import eu.kanade.tachiyomi.util.updateCoverLastModified
 import java.io.File
 import java.util.Date
 import java.util.concurrent.TimeUnit
@@ -565,15 +567,16 @@ class ReaderPresenter(
 
         Observable
             .fromCallable {
-                if (manga.source == LocalSource.ID) {
+                if (manga.isLocal()) {
                     val context = Injekt.get<Application>()
                     LocalSource.updateCover(context, manga, stream())
+                    manga.updateCoverLastModified(db)
                     R.string.cover_updated
                     SetAsCoverResult.Success
                 } else {
-                    val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found")
                     if (manga.favorite) {
-                        coverCache.copyToCache(thumbUrl, stream())
+                        coverCache.setCustomCoverToCache(manga, stream())
+                        manga.updateCoverLastModified(db)
                         SetAsCoverResult.Success
                     } else {
                         SetAsCoverResult.AddToLibraryFirst

+ 5 - 7
app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryHolder.kt

@@ -64,12 +64,10 @@ class HistoryHolder(
 
         // Set cover
         GlideApp.with(itemView.context).clear(cover)
-        if (!manga.thumbnail_url.isNullOrEmpty()) {
-            GlideApp.with(itemView.context)
-                .load(manga.toMangaThumbnail())
-                .diskCacheStrategy(DiskCacheStrategy.RESOURCE)
-                .centerCrop()
-                .into(cover)
-        }
+        GlideApp.with(itemView.context)
+            .load(manga.toMangaThumbnail())
+            .diskCacheStrategy(DiskCacheStrategy.RESOURCE)
+            .centerCrop()
+            .into(cover)
     }
 }

+ 5 - 7
app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesHolder.kt

@@ -56,13 +56,11 @@ class UpdatesHolder(private val view: View, private val adapter: UpdatesAdapter)
 
         // Set cover
         GlideApp.with(itemView.context).clear(manga_cover)
-        if (!item.manga.thumbnail_url.isNullOrEmpty()) {
-            GlideApp.with(itemView.context)
-                .load(item.manga.toMangaThumbnail())
-                .diskCacheStrategy(DiskCacheStrategy.RESOURCE)
-                .circleCrop()
-                .into(manga_cover)
-        }
+        GlideApp.with(itemView.context)
+            .load(item.manga.toMangaThumbnail())
+            .diskCacheStrategy(DiskCacheStrategy.RESOURCE)
+            .circleCrop()
+            .into(manga_cover)
 
         // Check if chapter is read and set correct color
         if (item.chapter.read) {

+ 5 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt

@@ -76,6 +76,11 @@ class SettingsAdvancedController : SettingsController() {
                 ctrl.showDialog(router)
             }
         }
+        preference {
+            titleRes = R.string.pref_refresh_library_covers
+
+            onClick { LibraryUpdateService.start(context, target = Target.COVERS) }
+        }
         preference {
             titleRes = R.string.pref_refresh_library_tracking
             summaryRes = R.string.pref_refresh_library_tracking_summary

+ 32 - 0
app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt

@@ -0,0 +1,32 @@
+package eu.kanade.tachiyomi.util
+
+import eu.kanade.tachiyomi.data.cache.CoverCache
+import eu.kanade.tachiyomi.data.database.DatabaseHelper
+import eu.kanade.tachiyomi.data.database.models.Manga
+import eu.kanade.tachiyomi.source.LocalSource
+import java.util.Date
+
+fun Manga.isLocal() = source == LocalSource.ID
+
+/**
+ * Call before updating [Manga.thumbnail_url] to ensure old cover can be cleared from cache
+ */
+fun Manga.prepUpdateCover(coverCache: CoverCache) {
+    cover_last_modified = Date().time
+
+    if (!isLocal()) {
+        coverCache.deleteFromCache(this, false)
+    }
+}
+
+fun Manga.removeCovers(coverCache: CoverCache) {
+    if (isLocal()) return
+
+    cover_last_modified = Date().time
+    coverCache.deleteFromCache(this, true)
+}
+
+fun Manga.updateCoverLastModified(db: DatabaseHelper) {
+    cover_last_modified = Date().time
+    db.updateMangaCoverLastModified(this).executeAsBlocking()
+}

+ 1 - 0
app/src/main/res/values/strings.xml

@@ -354,6 +354,7 @@
     <string name="pref_clear_database_summary">Delete manga and chapters that are not in your library</string>
     <string name="clear_database_confirmation">Are you sure? Read chapters and progress of non-library manga will be lost</string>
     <string name="clear_database_completed">Entries deleted</string>
+    <string name="pref_refresh_library_covers">Refresh library manga covers</string>
     <string name="pref_refresh_library_tracking">Refresh tracking</string>
     <string name="pref_refresh_library_tracking_summary">Updates status, score and last chapter read from the tracking services</string>
     <string name="pref_disable_battery_optimization">Disable battery optimization</string>