Sfoglia il codice sorgente

Download manager rewrite (#535)

* Saving to SD working

* Rename imagePath to uri

* Handle android < 21

* Minor changes

* Separate downloader from the manager. Optimize folder lookups

* Persist downloads across restarts

* Fix for #511

* Updated ReactiveNetwork. Add some documentation

* More documentation and minor fixes

* Handle persistent notifications. Other minor changes

* Improve downloader and add documentation

* Rename pageNumber to index in Page class

* Remove unused methods

* Use chop method

* Make sure dest dir is created

* Reset downloads dir preference

* Use invalidate options menu in download fragment and fix wrong condition

* Fix empty download queue after application restart

* Use addAll method in download queue to avoid too many notifications

* Inform download manager changes
inorichi 8 anni fa
parent
commit
6f297161de
34 ha cambiato i file con 1321 aggiunte e 851 eliminazioni
  1. 5 2
      app/build.gradle
  2. 2 2
      app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt
  3. 9 0
      app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt
  4. 131 429
      app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt
  5. 69 47
      app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt
  6. 130 0
      app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt
  7. 116 69
      app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.kt
  8. 128 0
      app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt
  9. 429 0
      app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt
  10. 0 3
      app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt
  11. 27 12
      app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt
  12. 5 13
      app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt
  13. 4 3
      app/src/main/java/eu/kanade/tachiyomi/data/source/model/Page.kt
  14. 2 1
      app/src/main/java/eu/kanade/tachiyomi/data/source/online/OnlineSource.kt
  15. 13 37
      app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadFragment.kt
  16. 6 21
      app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadPresenter.kt
  17. 4 9
      app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt
  18. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogFragment.kt
  19. 19 14
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt
  20. 8 17
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoader.kt
  21. 4 5
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt
  22. 55 57
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt
  23. 19 21
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/notification/ImageNotifier.kt
  24. 2 2
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/BaseReader.kt
  25. 18 4
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PageView.kt
  26. 19 5
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonHolder.kt
  27. 2 2
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonReader.kt
  28. 25 17
      app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt
  29. 47 15
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadsFragment.kt
  30. 11 5
      app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt
  31. 0 36
      app/src/main/java/eu/kanade/tachiyomi/util/UrlUtil.java
  32. 8 0
      app/src/main/res/raw/changelog_debug.xml
  33. 1 2
      app/src/main/res/values/keys.xml
  34. 2 0
      app/src/main/res/values/strings.xml

+ 5 - 2
app/build.gradle

@@ -38,7 +38,7 @@ android {
         minSdkVersion 16
         targetSdkVersion 25
         testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
-        versionCode 13
+        versionCode 14
         versionName "0.3.2"
 
         buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
@@ -99,7 +99,6 @@ dependencies {
 
     // Modified dependencies
     compile 'com.github.inorichi:subsampling-scale-image-view:96d2c7f'
-    compile 'com.github.inorichi:ReactiveNetwork:69092ed'
 
     // Android support library
     final support_library_version = '25.0.0'
@@ -117,14 +116,18 @@ dependencies {
     compile 'com.evernote:android-job:1.1.3'
     compile 'com.google.android.gms:play-services-gcm:9.8.0'
 
+    compile 'com.github.seven332:unifile:0.2.0'
+
     // ReactiveX
     compile 'io.reactivex:rxandroid:1.2.1'
     compile 'io.reactivex:rxjava:1.2.2'
+    compile 'com.jakewharton.rxrelay:rxrelay:1.2.0'
     compile 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
 
     // Network client
     compile "com.squareup.okhttp3:okhttp:3.4.2"
     compile 'com.squareup.okio:okio:1.11.0'
+    compile 'com.github.pwittchen:reactivenetwork:0.6.0'
 
     // REST
     final retrofit_version = '2.1.0'

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt

@@ -168,11 +168,11 @@ class ChapterCache(private val context: Context) {
      * @param imageUrl url of image.
      * @return path of image.
      */
-    fun getImagePath(imageUrl: String): String? {
+    fun getImagePath(imageUrl: String): File? {
         try {
             // Get file from md5 key.
             val imageName = DiskUtils.hashKeyForDisk(imageUrl) + ".0"
-            return File(diskCache.directory, imageName).canonicalPath
+            return File(diskCache.directory, imageName)
         } catch (e: IOException) {
             return null
         }

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

@@ -33,6 +33,15 @@ interface ChapterQueries : DbProvider {
             .withGetResolver(MangaChapterGetResolver.INSTANCE)
             .prepare()
 
+    fun getChapter(id: Long) = db.get()
+            .`object`(Chapter::class.java)
+            .withQuery(Query.builder()
+                    .table(ChapterTable.TABLE)
+                    .where("${ChapterTable.COL_ID} = ?")
+                    .whereArgs(id)
+                    .build())
+            .prepare()
+
     fun insertChapter(chapter: Chapter) = db.put().`object`(chapter).prepare()
 
     fun insertChapters(chapters: List<Chapter>) = db.put().objects(chapters).prepare()

+ 131 - 429
app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt

@@ -1,450 +1,152 @@
 package eu.kanade.tachiyomi.data.download
 
 import android.content.Context
-import android.net.Uri
-import com.google.gson.Gson
-import com.google.gson.reflect.TypeToken
-import com.google.gson.stream.JsonReader
-import eu.kanade.tachiyomi.R
+import com.hippo.unifile.UniFile
+import com.jakewharton.rxrelay.BehaviorRelay
 import eu.kanade.tachiyomi.data.database.models.Chapter
 import eu.kanade.tachiyomi.data.database.models.Manga
-import eu.kanade.tachiyomi.data.download.model.Download
 import eu.kanade.tachiyomi.data.download.model.DownloadQueue
-import eu.kanade.tachiyomi.data.preference.PreferencesHelper
-import eu.kanade.tachiyomi.data.preference.getOrDefault
 import eu.kanade.tachiyomi.data.source.Source
-import eu.kanade.tachiyomi.data.source.SourceManager
 import eu.kanade.tachiyomi.data.source.model.Page
-import eu.kanade.tachiyomi.data.source.online.OnlineSource
-import eu.kanade.tachiyomi.util.*
 import rx.Observable
-import rx.Subscription
-import rx.android.schedulers.AndroidSchedulers
-import rx.schedulers.Schedulers
-import rx.subjects.BehaviorSubject
-import rx.subjects.PublishSubject
-import timber.log.Timber
-import uy.kohesive.injekt.Injekt
-import uy.kohesive.injekt.api.get
-import java.io.File
-import java.io.FileReader
-import java.util.*
 
-class DownloadManager(
-        private val context: Context,
-        private val sourceManager: SourceManager = Injekt.get(),
-        private val preferences: PreferencesHelper = Injekt.get()
-) {
-
-    private val gson = Gson()
-
-    private val downloadsQueueSubject = PublishSubject.create<List<Download>>()
-    val runningSubject = BehaviorSubject.create<Boolean>()
-    private var downloadsSubscription: Subscription? = null
-
-    val downloadNotifier by lazy { DownloadNotifier(context) }
-
-    private val threadsSubject = BehaviorSubject.create<Int>()
-    private var threadsSubscription: Subscription? = null
-
-    val queue = DownloadQueue()
-
-    val imageFilenameRegex = "[^\\sa-zA-Z0-9.-]".toRegex()
-
-    val PAGE_LIST_FILE = "index.json"
-
-    @Volatile var isRunning: Boolean = false
-        private set
-
-    private fun initializeSubscriptions() {
-
-        downloadsSubscription?.unsubscribe()
-
-        threadsSubscription = preferences.downloadThreads().asObservable()
-                .subscribe {
-                    threadsSubject.onNext(it)
-                    downloadNotifier.multipleDownloadThreads = it > 1
-                }
-
-        downloadsSubscription = downloadsQueueSubject.flatMap { Observable.from(it) }
-                .lift(DynamicConcurrentMergeOperator<Download, Download>({ downloadChapter(it) }, threadsSubject))
-                .onBackpressureBuffer()
-                .observeOn(AndroidSchedulers.mainThread())
-                .subscribe({
-                    // Delete successful downloads from queue
-                    if (it.status == Download.DOWNLOADED) {
-                        // remove downloaded chapter from queue
-                        queue.del(it)
-                        downloadNotifier.onProgressChange(queue)
-                    }
-                    if (areAllDownloadsFinished()) {
-                        DownloadService.stop(context)
-                    }
-                }, { error ->
-                    DownloadService.stop(context)
-                    Timber.e(error)
-                    downloadNotifier.onError(error.message)
-                })
-
-        if (!isRunning) {
-            isRunning = true
-            runningSubject.onNext(true)
-        }
-    }
-
-    fun destroySubscriptions() {
-        if (isRunning) {
-            isRunning = false
-            runningSubject.onNext(false)
-        }
-
-        if (downloadsSubscription != null) {
-            downloadsSubscription?.unsubscribe()
-            downloadsSubscription = null
-        }
-
-        if (threadsSubscription != null) {
-            threadsSubscription?.unsubscribe()
-        }
-
-    }
-
-    // Create a download object for every chapter and add them to the downloads queue
-    fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
-        val source = sourceManager.get(manga.source) as? OnlineSource ?: return
-
-        // Add chapters to queue from the start
-        val sortedChapters = chapters.sortedByDescending { it.source_order }
-
-        // Used to avoid downloading chapters with the same name
-        val addedChapters = ArrayList<String>()
-        val pending = ArrayList<Download>()
-
-        for (chapter in sortedChapters) {
-            if (addedChapters.contains(chapter.name))
-                continue
-
-            addedChapters.add(chapter.name)
-            val download = Download(source, manga, chapter)
-
-            if (!prepareDownload(download)) {
-                queue.add(download)
-                pending.add(download)
-            }
-        }
-
-        // Initialize queue size
-        downloadNotifier.initialQueueSize = queue.size
-        // Show notification
-        downloadNotifier.onProgressChange(queue)
-
-        if (isRunning) downloadsQueueSubject.onNext(pending)
-    }
-
-    // Public method to check if a chapter is downloaded
-    fun isChapterDownloaded(source: Source, manga: Manga, chapter: Chapter): Boolean {
-        val directory = getAbsoluteChapterDirectory(source, manga, chapter)
-        if (!directory.exists())
-            return false
-
-        val pages = getSavedPageList(source, manga, chapter)
-        return isChapterDownloaded(directory, pages)
-    }
-
-    // Prepare the download. Returns true if the chapter is already downloaded
-    private fun prepareDownload(download: Download): Boolean {
-        // If the chapter is already queued, don't add it again
-        for (queuedDownload in queue) {
-            if (download.chapter.id == queuedDownload.chapter.id)
-                return true
-        }
-
-        // Add the directory to the download object for future access
-        download.directory = getAbsoluteChapterDirectory(download)
-
-        // If the directory doesn't exist, the chapter isn't downloaded.
-        if (!download.directory.exists()) {
-            return false
-        }
-
-        // If the page list doesn't exist, the chapter isn't downloaded
-        val savedPages = getSavedPageList(download) ?: return false
-
-        // Add the page list to the download object for future access
-        download.pages = savedPages
-
-        // If the number of files matches the number of pages, the chapter is downloaded.
-        // We have the index file, so we check one file more
-        return isChapterDownloaded(download.directory, download.pages)
-    }
-
-    // Check that all the images are downloaded
-    private fun isChapterDownloaded(directory: File, pages: List<Page>?): Boolean {
-        return pages != null && !pages.isEmpty() && pages.size + 1 == directory.listFiles().size
+/**
+ * This class is used to manage chapter downloads in the application. It must be instantiated once
+ * and retrieved through dependency injection. You can use this class to queue new chapters or query
+ * downloaded chapters.
+ *
+ * @param context the application context.
+ */
+class DownloadManager(context: Context) {
+
+    /**
+     * Downloads provider, used to retrieve the folders where the chapters are or should be stored.
+     */
+    private val provider = DownloadProvider(context)
+
+    /**
+     * Downloader whose only task is to download chapters.
+     */
+    private val downloader = Downloader(context, provider)
+
+    /**
+     * Downloads queue, where the pending chapters are stored.
+     */
+    val queue: DownloadQueue
+        get() = downloader.queue
+
+    /**
+     * Subject for subscribing to downloader status.
+     */
+    val runningRelay: BehaviorRelay<Boolean>
+        get() = downloader.runningRelay
+
+    /**
+     * Tells the downloader to begin downloads.
+     *
+     * @return true if it's started, false otherwise (empty queue).
+     */
+    fun startDownloads(): Boolean {
+        return downloader.start()
     }
 
-    // Download the entire chapter
-    private fun downloadChapter(download: Download): Observable<Download> {
-        DiskUtils.createDirectory(download.directory)
-
-        val pageListObservable: Observable<List<Page>> = if (download.pages == null)
-            // Pull page list from network and add them to download object
-            download.source.fetchPageListFromNetwork(download.chapter)
-                    .doOnNext { pages ->
-                        download.pages = pages
-                        savePageList(download)
-                    }
-        else
-        // Or if the page list already exists, start from the file
-            Observable.just(download.pages)
-
-        return Observable.defer {
-            pageListObservable
-                    .doOnNext { pages ->
-                        download.downloadedImages = 0
-                        download.status = Download.DOWNLOADING
-                    }
-                    // Get all the URLs to the source images, fetch pages if necessary
-                    .flatMap { download.source.fetchAllImageUrlsFromPageList(it) }
-                    // Start downloading images, consider we can have downloaded images already
-                    .concatMap { page -> getOrDownloadImage(page, download) }
-                    // Do when page is downloaded.
-                    .doOnNext {
-                        downloadNotifier.onProgressChange(download, queue)
-                    }
-                    // Do after download completes
-                    .doOnCompleted { onDownloadCompleted(download) }
-                    .toList()
-                    .map { pages -> download }
-                    // If the page list threw, it will resume here
-                    .onErrorResumeNext { error ->
-                        download.status = Download.ERROR
-                        downloadNotifier.onError(error.message, download.chapter.name)
-                        Observable.just(download)
-                    }
-        }.subscribeOn(Schedulers.io())
+    /**
+     * Tells the downloader to stop downloads.
+     *
+     * @param reason an optional reason for being stopped, used to notify the user.
+     */
+    fun stopDownloads(reason: String? = null) {
+        downloader.stop(reason)
     }
 
-    // Get the image from the filesystem if it exists or download from network
-    private fun getOrDownloadImage(page: Page, download: Download): Observable<Page> {
-        // If the image URL is empty, do nothing
-        if (page.imageUrl == null)
-            return Observable.just(page)
-
-        val filename = getImageFilename(page)
-        val imagePath = File(download.directory, filename)
-
-        // If the image is already downloaded, do nothing. Otherwise download from network
-        val pageObservable = if (isImageDownloaded(imagePath))
-            Observable.just(page)
-        else
-            downloadImage(page, download.source, download.directory, filename)
-
-        return pageObservable
-                // When the image is ready, set image path, progress (just in case) and status
-                .doOnNext {
-                    page.imagePath = imagePath.absolutePath
-                    page.progress = 100
-                    download.downloadedImages++
-                    page.status = Page.READY
-                }
-                // Mark this page as error and allow to download the remaining
-                .onErrorResumeNext {
-                    page.progress = 0
-                    page.status = Page.ERROR
-                    Observable.just(page)
-                }
+    /**
+     * Empties the download queue.
+     */
+    fun clearQueue() {
+        downloader.clearQueue()
     }
 
-    // Save image on disk
-    private fun downloadImage(page: Page, source: OnlineSource, directory: File, filename: String): Observable<Page> {
-        page.status = Page.DOWNLOAD_IMAGE
-        return source.imageResponse(page)
-                .map {
-                    val file = File(directory, filename)
-                    try {
-                        file.parentFile.mkdirs()
-                        it.body().source().saveTo(file.outputStream())
-                    } catch (e: Exception) {
-                        it.close()
-                        file.delete()
-                        throw e
+    /**
+     * Tells the downloader to enqueue the given list of chapters.
+     *
+     * @param manga the manga of the chapters.
+     * @param chapters the list of chapters to enqueue.
+     */
+    fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
+        downloader.queueChapters(manga, chapters)
+    }
+
+    /**
+     * Builds the page list of a downloaded chapter.
+     *
+     * @param source the source of the chapter.
+     * @param manga the manga of the chapter.
+     * @param chapter the downloaded chapter.
+     * @return an observable containing the list of pages from the chapter.
+     */
+    fun buildPageList(source: Source, manga: Manga, chapter: Chapter): Observable<List<Page>> {
+        return buildPageList(provider.findChapterDir(source, manga, chapter))
+    }
+
+    /**
+     * Builds the page list of a downloaded chapter.
+     *
+     * @param chapterDir the file where the chapter is downloaded.
+     * @return an observable containing the list of pages from the chapter.
+     */
+    private fun buildPageList(chapterDir: UniFile?): Observable<List<Page>> {
+        return Observable.fromCallable {
+            val pages = mutableListOf<Page>()
+            chapterDir?.listFiles()
+                    ?.filter { it.type?.startsWith("image") ?: false }
+                    ?.forEach { file ->
+                        val page = Page(pages.size, uri = file.uri)
+                        pages.add(page)
+                        page.status = Page.READY
                     }
-                    page
-                }
-                // Retry 3 times, waiting 2, 4 and 8 seconds between attempts.
-                .retryWhen(RetryWithDelay(3, { (2 shl it - 1) * 1000 }, Schedulers.trampoline()))
-    }
-
-    // Public method to get the image from the filesystem. It does NOT provide any way to download the image
-    fun getDownloadedImage(page: Page, chapterDir: File): Observable<Page> {
-        if (page.imageUrl == null) {
-            page.status = Page.ERROR
-            return Observable.just(page)
-        }
-
-        val imagePath = File(chapterDir, getImageFilename(page))
-
-        // When the image is ready, set image path, progress (just in case) and status
-        if (isImageDownloaded(imagePath)) {
-            page.imagePath = imagePath.absolutePath
-            page.progress = 100
-            page.status = Page.READY
-        } else {
-            page.status = Page.ERROR
-        }
-        return Observable.just(page)
-    }
-
-    // Get the filename for an image given the page
-    fun getImageFilename(page: Page): String {
-        val url = page.imageUrl
-        val number = String.format("%03d", page.pageNumber + 1)
-
-        // Try to preserve file extension
-        return when {
-            UrlUtil.isJpg(url) -> "$number.jpg"
-            UrlUtil.isPng(url) -> "$number.png"
-            UrlUtil.isGif(url) -> "$number.gif"
-            else -> Uri.parse(url).lastPathSegment.replace(imageFilenameRegex, "_")
-        }
-    }
-
-    private fun isImageDownloaded(imagePath: File): Boolean {
-        return imagePath.exists()
-    }
-
-    // Called when a download finishes. This doesn't mean the download was successful, so we check it
-    private fun onDownloadCompleted(download: Download) {
-        checkDownloadIsSuccessful(download)
-        savePageList(download)
-    }
-
-    private fun checkDownloadIsSuccessful(download: Download) {
-        var actualProgress = 0
-        var status = Download.DOWNLOADED
-        // If any page has an error, the download result will be error
-        for (page in download.pages!!) {
-            actualProgress += page.progress
-            if (page.status != Page.READY) {
-                status = Download.ERROR
-                downloadNotifier.onError(context.getString(R.string.download_notifier_page_ready_error), download.chapter.name)
-            }
-        }
-        // Ensure that the chapter folder has all the images
-        if (!isChapterDownloaded(download.directory, download.pages)) {
-            status = Download.ERROR
-            downloadNotifier.onError(context.getString(R.string.download_notifier_page_error), download.chapter.name)
-        }
-        download.totalProgress = actualProgress
-        download.status = status
-    }
-
-    // Return the page list from the chapter's directory if it exists, null otherwise
-    fun getSavedPageList(source: Source, manga: Manga, chapter: Chapter): List<Page>? {
-        val chapterDir = getAbsoluteChapterDirectory(source, manga, chapter)
-        val pagesFile = File(chapterDir, PAGE_LIST_FILE)
-
-        return try {
-            JsonReader(FileReader(pagesFile)).use {
-                val collectionType = object : TypeToken<List<Page>>() {}.type
-                gson.fromJson(it, collectionType)
-            }
-        } catch (e: Exception) {
-            null
-        }
-    }
-
-    // Shortcut for the method above
-    private fun getSavedPageList(download: Download): List<Page>? {
-        return getSavedPageList(download.source, download.manga, download.chapter)
-    }
-
-    // Save the page list to the chapter's directory
-    fun savePageList(source: Source, manga: Manga, chapter: Chapter, pages: List<Page>) {
-        val chapterDir = getAbsoluteChapterDirectory(source, manga, chapter)
-        val pagesFile = File(chapterDir, PAGE_LIST_FILE)
-
-        pagesFile.outputStream().use {
-            try {
-                it.write(gson.toJson(pages).toByteArray())
-                it.flush()
-            } catch (error: Exception) {
-                Timber.e(error)
-            }
-        }
-    }
-
-    // Shortcut for the method above
-    private fun savePageList(download: Download) {
-        savePageList(download.source, download.manga, download.chapter, download.pages!!)
-    }
-
-    fun getAbsoluteMangaDirectory(source: Source, manga: Manga): File {
-        val mangaRelativePath = source.toString() +
-                File.separator +
-                manga.title.replace("[^\\sa-zA-Z0-9.-]".toRegex(), "_")
-
-        return File(preferences.downloadsDirectory().getOrDefault(), mangaRelativePath)
-    }
-
-    // Get the absolute path to the chapter directory
-    fun getAbsoluteChapterDirectory(source: Source, manga: Manga, chapter: Chapter): File {
-        val chapterRelativePath = chapter.name.replace("[^\\sa-zA-Z0-9.-]".toRegex(), "_")
-
-        return File(getAbsoluteMangaDirectory(source, manga), chapterRelativePath)
-    }
-
-    // Shortcut for the method above
-    private fun getAbsoluteChapterDirectory(download: Download): File {
-        return getAbsoluteChapterDirectory(download.source, download.manga, download.chapter)
-    }
-
+            pages
+        }
+    }
+
+    /**
+     * Returns the directory name for the given chapter.
+     *
+     * @param chapter the chapter to query.
+     */
+    fun getChapterDirName(chapter: Chapter): String {
+        return provider.getChapterDirName(chapter)
+    }
+
+    /**
+     * Returns the directory for the given manga, if it exists.
+     *
+     * @param source the source of the manga.
+     * @param manga the manga to query.
+     */
+    fun findMangaDir(source: Source, manga: Manga): UniFile? {
+        return provider.findMangaDir(source, manga)
+    }
+
+    /**
+     * Returns the directory for the given chapter, if it exists.
+     *
+     * @param source the source of the chapter.
+     * @param manga the manga of the chapter.
+     * @param chapter the chapter to query.
+     */
+    fun findChapterDir(source: Source, manga: Manga, chapter: Chapter): UniFile? {
+        return provider.findChapterDir(source, manga, chapter)
+    }
+
+    /**
+     * Deletes the directory of a downloaded chapter.
+     *
+     * @param source the source of the chapter.
+     * @param manga the manga of the chapter.
+     * @param chapter the chapter to delete.
+     */
     fun deleteChapter(source: Source, manga: Manga, chapter: Chapter) {
-        val path = getAbsoluteChapterDirectory(source, manga, chapter)
-        DiskUtils.deleteFiles(path)
-    }
-
-    fun areAllDownloadsFinished(): Boolean {
-        for (download in queue) {
-            if (download.status <= Download.DOWNLOADING)
-                return false
-        }
-        return true
-    }
-
-    fun startDownloads(): Boolean {
-        if (queue.isEmpty())
-            return false
-
-        if (downloadsSubscription == null || downloadsSubscription!!.isUnsubscribed)
-            initializeSubscriptions()
-
-        val pending = ArrayList<Download>()
-        for (download in queue) {
-            if (download.status != Download.DOWNLOADED) {
-                if (download.status != Download.QUEUE) download.status = Download.QUEUE
-                pending.add(download)
-            }
-        }
-        downloadsQueueSubject.onNext(pending)
-
-        return !pending.isEmpty()
-    }
-
-    fun stopDownloads(errorMessage: String? = null) {
-        destroySubscriptions()
-        for (download in queue) {
-            if (download.status == Download.DOWNLOADING) {
-                download.status = Download.ERROR
-            }
-        }
-        errorMessage?.let { downloadNotifier.onError(it) }
-    }
-
-    fun clearQueue() {
-        queue.clear()
-        downloadNotifier.onClear()
+        provider.findChapterDir(source, manga, chapter)?.delete()
     }
 
 }

+ 69 - 47
app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt

@@ -1,30 +1,28 @@
 package eu.kanade.tachiyomi.data.download
 
 import android.content.Context
+import android.graphics.BitmapFactory
 import android.support.v4.app.NotificationCompat
 import eu.kanade.tachiyomi.Constants
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.download.model.Download
 import eu.kanade.tachiyomi.data.download.model.DownloadQueue
+import eu.kanade.tachiyomi.util.chop
 import eu.kanade.tachiyomi.util.notificationManager
-import eu.kanade.tachiyomi.util.toast
 
 /**
  * DownloadNotifier is used to show notifications when downloading one or multiple chapters.
  *
  * @param context context of application
  */
-class DownloadNotifier(private val context: Context) {
+internal class DownloadNotifier(private val context: Context) {
     /**
      * Notification builder.
      */
-    private val notificationBuilder = NotificationCompat.Builder(context)
-
-    /**
-     * Id of the notification.
-     */
-    private val notificationId: Int
-        get() = Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID
+    private val notification by lazy {
+        NotificationCompat.Builder(context)
+                .setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher))
+    }
 
     /**
      * Status of download. Used for correct notification icon.
@@ -34,12 +32,29 @@ class DownloadNotifier(private val context: Context) {
     /**
      * The size of queue on start download.
      */
-    internal var initialQueueSize = 0
+    var initialQueueSize = 0
 
     /**
      * Simultaneous download setting > 1.
      */
-    internal var multipleDownloadThreads = false
+    var multipleDownloadThreads = false
+
+    /**
+     * Shows a notification from this builder.
+     *
+     * @param id the id of the notification.
+     */
+    private fun NotificationCompat.Builder.show(id: Int = Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID) {
+        context.notificationManager.notify(id, build())
+    }
+
+    /**
+     * Dismiss the downloader's notification. Downloader error notifications use a different id, so
+     * those can only be dismissed by the user.
+     */
+    fun dismiss() {
+        context.notificationManager.cancel(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID)
+    }
 
     /**
      * Called when download progress changes.
@@ -47,45 +62,47 @@ class DownloadNotifier(private val context: Context) {
      *
      * @param queue the queue containing downloads.
      */
-    internal fun onProgressChange(queue: DownloadQueue) {
-        if (multipleDownloadThreads)
+    fun onProgressChange(queue: DownloadQueue) {
+        if (multipleDownloadThreads) {
             doOnProgressChange(null, queue)
+        }
     }
 
     /**
-     * Called when download progress changes
-     * Note: Only accepted when single download active
+     * Called when download progress changes.
+     * Note: Only accepted when single download active.
      *
-     * @param download download object containing download information
-     * @param queue the queue containing downloads
+     * @param download download object containing download information.
+     * @param queue the queue containing downloads.
      */
-    internal fun onProgressChange(download: Download, queue: DownloadQueue) {
-        if (!multipleDownloadThreads)
+    fun onProgressChange(download: Download, queue: DownloadQueue) {
+        if (!multipleDownloadThreads) {
             doOnProgressChange(download, queue)
+        }
     }
 
     /**
-     * Show notification progress of chapter
+     * Show notification progress of chapter.
      *
-     * @param download download object containing download information
-     * @param queue the queue containing downloads
+     * @param download download object containing download information.
+     * @param queue the queue containing downloads.
      */
     private fun doOnProgressChange(download: Download?, queue: DownloadQueue) {
         // Check if download is completed
         if (multipleDownloadThreads) {
             if (queue.isEmpty()) {
-                onComplete(null)
+                onChapterCompleted(null)
                 return
             }
         } else {
             if (download != null && download.pages!!.size == download.downloadedImages) {
-                onComplete(download)
+                onChapterCompleted(download)
                 return
             }
         }
 
         // Create notification
-        with(notificationBuilder) {
+        with(notification) {
             // Check if icon needs refresh
             if (!isDownloading) {
                 setSmallIcon(android.R.drawable.stat_sys_download)
@@ -104,11 +121,7 @@ class DownloadNotifier(private val context: Context) {
                 setProgress(initialQueueSize, initialQueueSize - queue.size, false)
             } else {
                 download?.let {
-                    if (it.chapter.name.length >= 33)
-                        setContentTitle(it.chapter.name.slice(IntRange(0, 30)).plus("..."))
-                    else
-                        setContentTitle(it.chapter.name)
-
+                    setContentTitle(it.chapter.name.chop(30))
                     setContentText(context.getString(R.string.chapter_downloading_progress)
                             .format(it.downloadedImages, it.pages!!.size))
                     setProgress(it.pages!!.size, it.downloadedImages, false)
@@ -117,17 +130,17 @@ class DownloadNotifier(private val context: Context) {
             }
         }
         // Displays the progress bar on notification
-        context.notificationManager.notify(notificationId, notificationBuilder.build())
+        notification.show()
     }
 
     /**
-     * Called when chapter is downloaded
+     * Called when chapter is downloaded.
      *
-     * @param download download object containing download information
+     * @param download download object containing download information.
      */
-    private fun onComplete(download: Download?) {
+    private fun onChapterCompleted(download: Download?) {
         // Create notification.
-        with(notificationBuilder) {
+        with(notification) {
             setContentTitle(download?.chapter?.name ?: context.getString(R.string.app_name))
             setContentText(context.getString(R.string.update_check_notification_download_complete))
             setSmallIcon(android.R.drawable.stat_sys_download_done)
@@ -135,7 +148,7 @@ class DownloadNotifier(private val context: Context) {
         }
 
         // Show notification.
-        context.notificationManager.notify(notificationId, notificationBuilder.build())
+        notification.show()
 
         // Reset initial values
         isDownloading = false
@@ -143,29 +156,38 @@ class DownloadNotifier(private val context: Context) {
     }
 
     /**
-     * Clears the notification message
+     * Called when the downloader receives a warning.
+     *
+     * @param reason the text to show.
      */
-    internal fun onClear() {
-        context.notificationManager.cancel(notificationId)
+    fun onWarning(reason: String) {
+        with(notification) {
+            setContentTitle(context.getString(R.string.download_notifier_downloader_title))
+            setContentText(reason)
+            setSmallIcon(android.R.drawable.stat_sys_warning)
+            setProgress(0, 0, false)
+        }
+        notification.show()
     }
 
     /**
-     * Called on error while downloading chapter
+     * Called when the downloader receives an error. It's shown as a separate notification to avoid
+     * being overwritten.
      *
-     * @param error string containing error information
-     * @param chapter string containing chapter title
+     * @param error string containing error information.
+     * @param chapter string containing chapter title.
      */
-    internal fun onError(error: String? = null, chapter: String? = null) {
+    fun onError(error: String? = null, chapter: String? = null) {
         // Create notification
-        with(notificationBuilder) {
-            setContentTitle(chapter ?: context.getString(R.string.download_notifier_title_error))
+        with(notification) {
+            setContentTitle(chapter ?: context.getString(R.string.download_notifier_downloader_title))
             setContentText(error ?: context.getString(R.string.download_notifier_unkown_error))
             setSmallIcon(android.R.drawable.stat_sys_warning)
             setProgress(0, 0, false)
         }
-        context.notificationManager.notify(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID, notificationBuilder.build())
+        notification.show(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID)
+
         // Reset download information
-        onClear()
         isDownloading = false
     }
 }

+ 130 - 0
app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt

@@ -0,0 +1,130 @@
+package eu.kanade.tachiyomi.data.download
+
+import android.content.Context
+import android.net.Uri
+import com.hippo.unifile.UniFile
+import eu.kanade.tachiyomi.data.database.models.Chapter
+import eu.kanade.tachiyomi.data.database.models.Manga
+import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.data.source.Source
+import uy.kohesive.injekt.injectLazy
+
+/**
+ * This class is used to provide the directories where the downloads should be saved.
+ * It uses the following path scheme: /<root downloads dir>/<source name>/<manga>/<chapter>
+ *
+ * @param context the application context.
+ */
+class DownloadProvider(private val context: Context) {
+
+    /**
+     * Preferences helper.
+     */
+    private val preferences: PreferencesHelper by injectLazy()
+
+    /**
+     * The root directory for downloads.
+     */
+    private lateinit var downloadsDir: UniFile
+
+    init {
+        preferences.downloadsDirectory().asObservable()
+                .subscribe { downloadsDir = UniFile.fromUri(context, Uri.parse(it)) }
+    }
+
+    /**
+     * Returns the download directory for a manga. For internal use only.
+     *
+     * @param source the source of the manga.
+     * @param manga the manga to query.
+     */
+    internal fun getMangaDir(source: Source, manga: Manga): UniFile {
+        return downloadsDir
+                .subFile(getSourceDirName(source))!!
+                .subFile(getMangaDirName(manga))!!
+    }
+
+    /**
+     * Returns the download directory for a manga if it exists.
+     *
+     * @param source the source of the manga.
+     * @param manga the manga to query.
+     */
+    fun findMangaDir(source: Source, manga: Manga): UniFile? {
+        val sourceDir = downloadsDir.findFile(getSourceDirName(source))
+        return sourceDir?.findFile(getMangaDirName(manga))
+    }
+
+    /**
+     * Returns the download directory for a chapter if it exists.
+     *
+     * @param source the source of the chapter.
+     * @param manga the manga of the chapter.
+     * @param chapter the chapter to query.
+     */
+    fun findChapterDir(source: Source, manga: Manga, chapter: Chapter): UniFile? {
+        val mangaDir = findMangaDir(source, manga)
+        return mangaDir?.findFile(getChapterDirName(chapter))
+    }
+
+    /**
+     * Returns the download directory name for a source.
+     *
+     * @param source the source to query.
+     */
+    fun getSourceDirName(source: Source): String {
+        return source.toString()
+    }
+
+    /**
+     * Returns the download directory name for a manga.
+     *
+     * @param manga the manga to query.
+     */
+    fun getMangaDirName(manga: Manga): String {
+        return buildValidFatFilename(manga.title.trim('.', ' '))
+    }
+
+    /**
+     * Returns the chapter directory name for a chapter.
+     *
+     * @param chapter the chapter to query.
+     */
+    fun getChapterDirName(chapter: Chapter): String {
+        return buildValidFatFilename(chapter.name.trim('.', ' '))
+    }
+
+    /**
+     * Mutate the given filename to make it valid for a FAT filesystem,
+     * replacing any invalid characters with "_".
+     */
+    private fun buildValidFatFilename(name: String): String {
+        if (name.isNullOrEmpty()) {
+            return "(invalid)"
+        }
+        val res = StringBuilder(name.length)
+        name.forEach { c ->
+            if (isValidFatFilenameChar(c)) {
+                res.append(c)
+            } else {
+                res.append('_')
+            }
+        }
+        // Even though vfat allows 255 UCS-2 chars, we might eventually write to
+        // ext4 through a FUSE layer, so use that limit minus 5 reserved characters.
+        return res.toString().take(250)
+    }
+
+    /**
+     * Returns true if the given character is a valid filename character, false otherwise.
+     */
+    private fun isValidFatFilenameChar(c: Char): Boolean {
+        if (0x00.toChar() <= c && c <= 0x1f.toChar()) {
+            return false
+        }
+        when (c) {
+            '"', '*', '/', ':', '<', '>', '?', '\\', '|', 0x7f.toChar() -> return false
+            else -> return true
+        }
+    }
+}

+ 116 - 69
app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.kt

@@ -3,130 +3,177 @@ package eu.kanade.tachiyomi.data.download
 import android.app.Service
 import android.content.Context
 import android.content.Intent
+import android.net.NetworkInfo.State.CONNECTED
+import android.net.NetworkInfo.State.DISCONNECTED
 import android.os.IBinder
 import android.os.PowerManager
-import com.github.pwittchen.reactivenetwork.library.ConnectivityStatus
+import com.github.pwittchen.reactivenetwork.library.Connectivity
 import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork
+import com.jakewharton.rxrelay.BehaviorRelay
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.util.connectivityManager
+import eu.kanade.tachiyomi.util.plusAssign
+import eu.kanade.tachiyomi.util.powerManager
 import eu.kanade.tachiyomi.util.toast
-import rx.Subscription
 import rx.android.schedulers.AndroidSchedulers
 import rx.schedulers.Schedulers
+import rx.subscriptions.CompositeSubscription
 import uy.kohesive.injekt.injectLazy
 
+/**
+ * This service is used to manage the downloader. The system can decide to stop the service, in
+ * which case the downloader is also stopped. It's also stopped while there's no network available.
+ * While the downloader is running, a wake lock will be held.
+ */
 class DownloadService : Service() {
 
     companion object {
 
+        /**
+         * Relay used to know when the service is running.
+         */
+        val runningRelay: BehaviorRelay<Boolean> = BehaviorRelay.create(false)
+
+        /**
+         * Starts this service.
+         *
+         * @param context the application context.
+         */
         fun start(context: Context) {
             context.startService(Intent(context, DownloadService::class.java))
         }
 
+        /**
+         * Stops this service.
+         *
+         * @param context the application context.
+         */
         fun stop(context: Context) {
             context.stopService(Intent(context, DownloadService::class.java))
         }
     }
 
-    val downloadManager: DownloadManager by injectLazy()
-    val preferences: PreferencesHelper by injectLazy()
+    /**
+     * Download manager.
+     */
+    private val downloadManager: DownloadManager by injectLazy()
+
+    /**
+     * Preferences helper.
+     */
+    private val preferences: PreferencesHelper by injectLazy()
+
+    /**
+     * Wake lock to prevent the device to enter sleep mode.
+     */
+    private val wakeLock by lazy {
+        powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "DownloadService:WakeLock")
+    }
 
-    private var wakeLock: PowerManager.WakeLock? = null
-    private var networkChangeSubscription: Subscription? = null
-    private var queueRunningSubscription: Subscription? = null
-    private var isRunning: Boolean = false
+    /**
+     * Subscriptions to store while the service is running.
+     */
+    private lateinit var subscriptions: CompositeSubscription
 
+    /**
+     * Called when the service is created.
+     */
     override fun onCreate() {
         super.onCreate()
-
-        createWakeLock()
-
-        listenQueueRunningChanges()
+        runningRelay.call(true)
+        subscriptions = CompositeSubscription()
+        listenDownloaderState()
         listenNetworkChanges()
     }
 
-    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
-        return Service.START_STICKY
-    }
-
+    /**
+     * Called when the service is destroyed.
+     */
     override fun onDestroy() {
-        queueRunningSubscription?.unsubscribe()
-        networkChangeSubscription?.unsubscribe()
-        downloadManager.destroySubscriptions()
-        destroyWakeLock()
+        runningRelay.call(false)
+        subscriptions.unsubscribe()
+        downloadManager.stopDownloads()
+        wakeLock.releaseIfNeeded()
         super.onDestroy()
     }
 
+    /**
+     * Not used.
+     */
+    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+        return Service.START_NOT_STICKY
+    }
+
+    /**
+     * Not used.
+     */
     override fun onBind(intent: Intent): IBinder? {
         return null
     }
 
+    /**
+     * Listens to network changes.
+     *
+     * @see onNetworkStateChanged
+     */
     private fun listenNetworkChanges() {
-        networkChangeSubscription = ReactiveNetwork().enableInternetCheck()
-                .observeConnectivity(applicationContext)
+        subscriptions += ReactiveNetwork.observeNetworkConnectivity(applicationContext)
                 .subscribeOn(Schedulers.io())
                 .observeOn(AndroidSchedulers.mainThread())
-                .subscribe({ state ->
-                    when (state) {
-                        ConnectivityStatus.WIFI_CONNECTED_HAS_INTERNET -> {
-                            // If there are no remaining downloads, destroy the service
-                            if (!isRunning && !downloadManager.startDownloads()) {
-                                stopSelf()
-                            }
-                        }
-                        ConnectivityStatus.MOBILE_CONNECTED -> {
-                            if (!preferences.downloadOnlyOverWifi()) {
-                                if (!isRunning && !downloadManager.startDownloads()) {
-                                    stopSelf()
-                                }
-                            } else if (isRunning) {
-                                downloadManager.stopDownloads(getString(R.string.download_notifier_text_only_wifi))
-                            }
-                        }
-                        else -> {
-                            if (isRunning) {
-                                downloadManager.stopDownloads(getString(R.string.download_notifier_text_only_wifi))
-                            }
-                        }
-                    }
+                .subscribe({ state -> onNetworkStateChanged(state)
                 }, { error ->
                     toast(R.string.download_queue_error)
                     stopSelf()
                 })
     }
 
-    private fun listenQueueRunningChanges() {
-        queueRunningSubscription = downloadManager.runningSubject.subscribe { running ->
-            isRunning = running
-            if (running)
-                acquireWakeLock()
-            else
-                releaseWakeLock()
+    /**
+     * Called when the network state changes.
+     *
+     * @param connectivity the new network state.
+     */
+    private fun onNetworkStateChanged(connectivity: Connectivity) {
+        when (connectivity.state) {
+            CONNECTED -> {
+                if (preferences.downloadOnlyOverWifi() && connectivityManager.isActiveNetworkMetered) {
+                    downloadManager.stopDownloads(getString(R.string.download_notifier_text_only_wifi))
+                } else {
+                    val started = downloadManager.startDownloads()
+                    if (!started) stopSelf()
+                }
+            }
+            DISCONNECTED -> {
+                downloadManager.stopDownloads(getString(R.string.download_notifier_no_network))
+            }
+            else -> { /* Do nothing */ }
         }
     }
 
-    private fun createWakeLock() {
-        wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
-                PowerManager.PARTIAL_WAKE_LOCK, "DownloadService:WakeLock")
-    }
-
-    private fun destroyWakeLock() {
-        if (wakeLock != null && wakeLock!!.isHeld) {
-            wakeLock!!.release()
-            wakeLock = null
+    /**
+     * Listens to downloader status. Enables or disables the wake lock depending on the status.
+     */
+    private fun listenDownloaderState() {
+        subscriptions += downloadManager.runningRelay.subscribe { running ->
+            if (running)
+                wakeLock.acquireIfNeeded()
+            else
+                wakeLock.releaseIfNeeded()
         }
     }
 
-    fun acquireWakeLock() {
-        if (wakeLock != null && !wakeLock!!.isHeld) {
-            wakeLock!!.acquire()
-        }
+    /**
+     * Releases the wake lock if it's held.
+     */
+    fun PowerManager.WakeLock.releaseIfNeeded() {
+        if (isHeld) release()
     }
 
-    fun releaseWakeLock() {
-        if (wakeLock != null && wakeLock!!.isHeld) {
-            wakeLock!!.release()
-        }
+    /**
+     * Acquires the wake lock if it's not held.
+     */
+    fun PowerManager.WakeLock.acquireIfNeeded() {
+        if (!isHeld) acquire()
     }
 
 }

+ 128 - 0
app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt

@@ -0,0 +1,128 @@
+package eu.kanade.tachiyomi.data.download
+
+import android.content.Context
+import com.google.gson.Gson
+import eu.kanade.tachiyomi.data.database.DatabaseHelper
+import eu.kanade.tachiyomi.data.database.models.Manga
+import eu.kanade.tachiyomi.data.download.model.Download
+import eu.kanade.tachiyomi.data.source.SourceManager
+import eu.kanade.tachiyomi.data.source.online.OnlineSource
+import uy.kohesive.injekt.injectLazy
+
+/**
+ * This class is used to persist active downloads across application restarts.
+ *
+ * @param context the application context.
+ */
+class DownloadStore(context: Context) {
+
+    /**
+     * Preference file where active downloads are stored.
+     */
+    private val preferences = context.getSharedPreferences("active_downloads", Context.MODE_PRIVATE)
+
+    /**
+     * Gson instance to serialize/deserialize downloads.
+     */
+    private val gson: Gson by injectLazy()
+
+    /**
+     * Source manager.
+     */
+    private val sourceManager: SourceManager by injectLazy()
+
+    /**
+     * Database helper.
+     */
+    private val db: DatabaseHelper by injectLazy()
+
+    /**
+     * Counter used to keep the queue order.
+     */
+    private var counter = 0
+
+    /**
+     * Adds a list of downloads to the store.
+     *
+     * @param downloads the list of downloads to add.
+     */
+    fun addAll(downloads: List<Download>) {
+        val editor = preferences.edit()
+        downloads.forEach { editor.putString(getKey(it), serialize(it)) }
+        editor.apply()
+    }
+
+    /**
+     * Removes a download from the store.
+     *
+     * @param download the download to remove.
+     */
+    fun remove(download: Download) {
+        preferences.edit().remove(getKey(download)).apply()
+    }
+
+    /**
+     * Returns the preference's key for the given download.
+     *
+     * @param download the download.
+     */
+    private fun getKey(download: Download): String {
+        return download.chapter.id!!.toString()
+    }
+
+    /**
+     * Returns the list of downloads to restore. It should be called in a background thread.
+     */
+    fun restore(): List<Download> {
+        val objs = preferences.all
+                .mapNotNull { it.value as? String }
+                .map { deserialize(it) }
+                .sortedBy { it.order }
+
+        val downloads = mutableListOf<Download>()
+        if (objs.isNotEmpty()) {
+            val cachedManga = mutableMapOf<Long, Manga?>()
+            for ((mangaId, chapterId) in objs) {
+                val manga = cachedManga.getOrPut(mangaId) {
+                    db.getManga(mangaId).executeAsBlocking()
+                } ?: continue
+                val source = sourceManager.get(manga.source) as? OnlineSource ?: continue
+                val chapter = db.getChapter(chapterId).executeAsBlocking() ?: continue
+                downloads.add(Download(source, manga, chapter))
+            }
+        }
+
+        // Clear the store, downloads will be added again immediately.
+        preferences.edit().clear().apply()
+        return downloads
+    }
+
+    /**
+     * Converts a download to a string.
+     *
+     * @param download the download to serialize.
+     */
+    private fun serialize(download: Download): String {
+        val obj = DownloadObject(download.manga.id!!, download.chapter.id!!, counter++)
+        return gson.toJson(obj)
+    }
+
+    /**
+     * Restore a download from a string.
+     *
+     * @param string the download as string.
+     */
+    private fun deserialize(string: String): DownloadObject {
+        return gson.fromJson(string, DownloadObject::class.java)
+    }
+
+    /**
+     * Class used for download serialization
+     *
+     * @param mangaId the id of the manga.
+     * @param chapterId the id of the chapter.
+     * @param order the order of the download in the queue.
+     */
+    data class DownloadObject(val mangaId: Long, val chapterId: Long, val order: Int)
+
+}

+ 429 - 0
app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt

@@ -0,0 +1,429 @@
+package eu.kanade.tachiyomi.data.download
+
+import android.content.Context
+import android.webkit.MimeTypeMap
+import com.hippo.unifile.UniFile
+import com.jakewharton.rxrelay.BehaviorRelay
+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.model.Download
+import eu.kanade.tachiyomi.data.download.model.DownloadQueue
+import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.data.source.SourceManager
+import eu.kanade.tachiyomi.data.source.model.Page
+import eu.kanade.tachiyomi.data.source.online.OnlineSource
+import eu.kanade.tachiyomi.util.DynamicConcurrentMergeOperator
+import eu.kanade.tachiyomi.util.RetryWithDelay
+import eu.kanade.tachiyomi.util.plusAssign
+import eu.kanade.tachiyomi.util.saveTo
+import okhttp3.Response
+import rx.Observable
+import rx.android.schedulers.AndroidSchedulers
+import rx.schedulers.Schedulers
+import rx.subjects.BehaviorSubject
+import rx.subscriptions.CompositeSubscription
+import timber.log.Timber
+import uy.kohesive.injekt.injectLazy
+
+/**
+ * This class is the one in charge of downloading chapters.
+ *
+ * Its [queue] contains the list of chapters to download. In order to download them, the downloader
+ * subscriptions must be running and the list of chapters must be sent to them by [downloadsRelay].
+ *
+ * The queue manipulation must be done in one thread (currently the main thread) to avoid unexpected
+ * behavior, but it's safe to read it from multiple threads.
+ *
+ * @param context the application context.
+ * @param provider the downloads directory provider.
+ */
+class Downloader(private val context: Context, private val provider: DownloadProvider) {
+
+    /**
+     * Store for persisting downloads across restarts.
+     */
+    private val store = DownloadStore(context)
+
+    /**
+     * Queue where active downloads are kept.
+     */
+    val queue = DownloadQueue(store)
+
+    /**
+     * Source manager.
+     */
+    private val sourceManager: SourceManager by injectLazy()
+
+    /**
+     * Preferences.
+     */
+    private val preferences: PreferencesHelper by injectLazy()
+
+    /**
+     * Notifier for the downloader state and progress.
+     */
+    private val notifier by lazy { DownloadNotifier(context) }
+
+    /**
+     * Downloader subscriptions.
+     */
+    private val subscriptions = CompositeSubscription()
+
+    /**
+     * Subject to do a live update of the number of simultaneous downloads.
+     */
+    private val threadsSubject = BehaviorSubject.create<Int>()
+
+    /**
+     * Relay to send a list of downloads to the downloader.
+     */
+    private val downloadsRelay = PublishRelay.create<List<Download>>()
+
+    /**
+     * Relay to subscribe to the downloader status.
+     */
+    val runningRelay: BehaviorRelay<Boolean> = BehaviorRelay.create(false)
+
+    /**
+     * Whether the downloader is running.
+     */
+    @Volatile private var isRunning: Boolean = false
+
+    init {
+        Observable.fromCallable { store.restore() }
+                .map { downloads -> downloads.filter { isDownloadAllowed(it) } }
+                .subscribeOn(Schedulers.io())
+                .observeOn(AndroidSchedulers.mainThread())
+                .subscribe({ downloads -> queue.addAll(downloads)
+                }, { error -> Timber.e(error) })
+    }
+
+    /**
+     * Starts the downloader. It doesn't do anything if it's already running or there isn't anything
+     * to download.
+     *
+     * @return true if the downloader is started, false otherwise.
+     */
+    fun start(): Boolean {
+        if (isRunning || queue.isEmpty())
+            return false
+
+        if (!subscriptions.hasSubscriptions())
+            initializeSubscriptions()
+
+        val pending = queue.filter { it.status != Download.DOWNLOADED }
+        pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE }
+
+        downloadsRelay.call(pending)
+        return !pending.isEmpty()
+    }
+
+    /**
+     * Stops the downloader.
+     */
+    fun stop(reason: String? = null) {
+        destroySubscriptions()
+        queue
+                .filter { it.status == Download.DOWNLOADING }
+                .forEach { it.status = Download.ERROR }
+
+        if (reason != null) {
+            notifier.onWarning(reason)
+        } else {
+            notifier.dismiss()
+        }
+    }
+
+    /**
+     * Removes everything from the queue.
+     */
+    fun clearQueue() {
+        destroySubscriptions()
+        queue.clear()
+        notifier.dismiss()
+    }
+
+    /**
+     * Prepares the subscriptions to start downloading.
+     */
+    private fun initializeSubscriptions() {
+        if (isRunning) return
+        isRunning = true
+        runningRelay.call(true)
+
+        subscriptions.clear()
+
+        subscriptions += preferences.downloadThreads().asObservable()
+                .subscribe {
+                    threadsSubject.onNext(it)
+                    notifier.multipleDownloadThreads = it > 1
+                }
+
+        subscriptions += downloadsRelay.flatMap { Observable.from(it) }
+                .lift(DynamicConcurrentMergeOperator<Download, Download>({ downloadChapter(it) }, threadsSubject))
+                .onBackpressureBuffer()
+                .observeOn(AndroidSchedulers.mainThread())
+                .subscribe({ completeDownload(it)
+                }, { error ->
+                    DownloadService.stop(context)
+                    Timber.e(error)
+                    notifier.onError(error.message)
+                })
+    }
+
+    /**
+     * Destroys the downloader subscriptions.
+     */
+    private fun destroySubscriptions() {
+        if (!isRunning) return
+        isRunning = false
+        runningRelay.call(false)
+
+        subscriptions.clear()
+    }
+
+    /**
+     * Creates a download object for every chapter and adds them to the downloads queue. This method
+     * must be called in the main thread.
+     *
+     * @param manga the manga of the chapters to download.
+     * @param chapters the list of chapters to download.
+     */
+    fun queueChapters(manga: Manga, chapters: List<Chapter>) {
+        val source = sourceManager.get(manga.source) as? OnlineSource ?: return
+
+        val chaptersToQueue = chapters
+                // Avoid downloading chapters with the same name.
+                .distinctBy { it.name }
+                // Add chapters to queue from the start.
+                .sortedByDescending { it.source_order }
+                // Create a downloader for each one.
+                .map { Download(source, manga, it) }
+                // Filter out those already queued or downloaded.
+                .filter { isDownloadAllowed(it) }
+
+        // Return if there's nothing to queue.
+        if (chaptersToQueue.isEmpty())
+            return
+
+        queue.addAll(chaptersToQueue)
+
+        // Initialize queue size.
+        notifier.initialQueueSize = queue.size
+
+        if (isRunning) {
+            // Send the list of downloads to the downloader.
+            downloadsRelay.call(chaptersToQueue)
+        } else {
+            // Show initial notification.
+            notifier.onProgressChange(queue)
+        }
+    }
+
+    /**
+     * Returns true if the given download can be queued and downloaded.
+     *
+     * @param download the download to be checked.
+     */
+    private fun isDownloadAllowed(download: Download): Boolean {
+        // If the chapter is already queued, don't add it again
+        if (queue.any { it.chapter.id == download.chapter.id })
+            return false
+
+        val dir = provider.findChapterDir(download.source, download.manga, download.chapter)
+        if (dir != null && dir.exists())
+            return false
+
+        return true
+    }
+
+    /**
+     * Returns the observable which downloads a chapter.
+     *
+     * @param download the chapter to be downloaded.
+     */
+    private fun downloadChapter(download: Download): Observable<Download> {
+        val chapterDirname = provider.getChapterDirName(download.chapter)
+        val mangaDir = provider.getMangaDir(download.source, download.manga)
+        val tmpDir = mangaDir.subFile("${chapterDirname}_tmp")!!
+
+        val pageListObservable = if (download.pages == null) {
+            // Pull page list from network and add them to download object
+            download.source.fetchPageListFromNetwork(download.chapter)
+                    .doOnNext { pages ->
+                        download.pages = pages
+                    }
+        } else {
+            // Or if the page list already exists, start from the file
+            Observable.just(download.pages!!)
+        }
+
+        return pageListObservable
+                .doOnNext { pages ->
+                    tmpDir.ensureDir()
+
+                    // Delete all temporary (unfinished) files
+                    tmpDir.listFiles()
+                            ?.filter { it.name!!.endsWith(".tmp") }
+                            ?.forEach { it.delete() }
+
+                    download.downloadedImages = 0
+                    download.status = Download.DOWNLOADING
+                }
+                // Get all the URLs to the source images, fetch pages if necessary
+                .flatMap { download.source.fetchAllImageUrlsFromPageList(it) }
+                // Start downloading images, consider we can have downloaded images already
+                .concatMap { page -> getOrDownloadImage(page, download, tmpDir) }
+                // Do when page is downloaded.
+                .doOnNext { notifier.onProgressChange(download, queue) }
+                .toList()
+                .map { pages -> download }
+                // Do after download completes
+                .doOnNext { ensureSuccessfulDownload(download, tmpDir, chapterDirname) }
+                // If the page list threw, it will resume here
+                .onErrorReturn { error ->
+                    download.status = Download.ERROR
+                    notifier.onError(error.message, download.chapter.name)
+                    download
+                }
+                .subscribeOn(Schedulers.io())
+    }
+
+    /**
+     * Returns the observable which gets the image from the filesystem if it exists or downloads it
+     * otherwise.
+     *
+     * @param page the page to download.
+     * @param download the download of the page.
+     * @param tmpDir the temporary directory of the download.
+     */
+    private fun getOrDownloadImage(page: Page, download: Download, tmpDir: UniFile): Observable<Page> {
+        // If the image URL is empty, do nothing
+        if (page.imageUrl == null)
+            return Observable.just(page)
+
+        val filename = String.format("%03d", page.index + 1)
+        val tmpFile = tmpDir.findFile("$filename.tmp")
+
+        // Delete temp file if it exists.
+        tmpFile?.delete()
+
+        // Try to find the image file.
+        val imageFile = tmpDir.listFiles()!!.find { it.name!!.startsWith("$filename.")}
+
+        // If the image is already downloaded, do nothing. Otherwise download from network
+        val pageObservable = if (imageFile != null)
+            Observable.just(imageFile)
+        else
+            downloadImage(page, download.source, tmpDir, filename)
+
+        return pageObservable
+                // When the image is ready, set image path, progress (just in case) and status
+                .doOnNext { file ->
+                    page.uri = file.uri
+                    page.progress = 100
+                    download.downloadedImages++
+                    page.status = Page.READY
+                }
+                .map { page }
+                // Mark this page as error and allow to download the remaining
+                .onErrorReturn {
+                    page.progress = 0
+                    page.status = Page.ERROR
+                    page
+                }
+    }
+
+    /**
+     * Returns the observable which downloads the image from network.
+     *
+     * @param page the page to download.
+     * @param source the source of the page.
+     * @param tmpDir the temporary directory of the download.
+     * @param filename the filename of the image.
+     */
+    private fun downloadImage(page: Page, source: OnlineSource, tmpDir: UniFile, filename: String): Observable<UniFile> {
+        page.status = Page.DOWNLOAD_IMAGE
+        page.progress = 0
+        return source.imageResponse(page)
+                .map { response ->
+                    val file = tmpDir.createFile("$filename.tmp")
+                    try {
+                        response.body().source().saveTo(file.openOutputStream())
+                        val extension = getImageExtension(response, file)
+                        file.renameTo("$filename.$extension")
+                    } catch (e: Exception) {
+                        response.close()
+                        file.delete()
+                        throw e
+                    }
+                    file
+                }
+                // Retry 3 times, waiting 2, 4 and 8 seconds between attempts.
+                .retryWhen(RetryWithDelay(3, { (2 shl it - 1) * 1000 }, Schedulers.trampoline()))
+    }
+
+    /**
+     * Returns the extension of the downloaded image from the network response, or if it's null,
+     * analyze the file. If both fail, assume it's a jpg.
+     *
+     * @param response the network response of the image.
+     * @param file the file where the image is already downloaded.
+     */
+    private fun getImageExtension(response: Response, file: UniFile): String {
+        val contentType = response.body().contentType()
+        val mimeStr = if (contentType != null) {
+            "${contentType.type()}/${contentType.subtype()}"
+        } else {
+            context.contentResolver.getType(file.uri)
+        }
+        return MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeStr) ?: "jpg"
+    }
+
+    /**
+     * Checks if the download was successful.
+     *
+     * @param download the download to check.
+     * @param tmpDir the directory where the download is currently stored.
+     * @param dirname the real (non temporary) directory name of the download.
+     */
+    private fun ensureSuccessfulDownload(download: Download, tmpDir: UniFile, dirname: String) {
+        // Ensure that the chapter folder has all the images.
+        val downloadedImages = tmpDir.listFiles().orEmpty().filterNot { it.name!!.endsWith(".tmp") }
+
+        download.status = if (downloadedImages.size == download.pages!!.size) {
+            Download.DOWNLOADED
+        } else {
+            Download.ERROR
+        }
+
+        // Only rename the directory if it's downloaded.
+        if (download.status == Download.DOWNLOADED) {
+            tmpDir.renameTo(dirname)
+        }
+    }
+
+    /**
+     * Completes a download. This method is called in the main thread.
+     */
+    private fun completeDownload(download: Download) {
+        // Delete successful downloads from queue
+        if (download.status == Download.DOWNLOADED) {
+            // remove downloaded chapter from queue
+            queue.remove(download)
+            notifier.onProgressChange(queue)
+        }
+        if (areAllDownloadsFinished()) {
+            DownloadService.stop(context)
+        }
+    }
+
+    /**
+     * Returns true if all the queued downloads are in DOWNLOADED or ERROR state.
+     */
+    private fun areAllDownloadsFinished(): Boolean {
+        return queue.none { it.status <= Download.DOWNLOADING }
+    }
+
+}

+ 0 - 3
app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt

@@ -5,12 +5,9 @@ import eu.kanade.tachiyomi.data.database.models.Manga
 import eu.kanade.tachiyomi.data.source.model.Page
 import eu.kanade.tachiyomi.data.source.online.OnlineSource
 import rx.subjects.PublishSubject
-import java.io.File
 
 class Download(val source: OnlineSource, val manga: Manga, val chapter: Chapter) {
 
-    lateinit var directory: File
-
     var pages: List<Page>? = null
 
     @Volatile @Transient var totalProgress: Int = 0

+ 27 - 12
app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt

@@ -1,38 +1,51 @@
 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.download.DownloadStore
 import eu.kanade.tachiyomi.data.source.model.Page
 import rx.Observable
 import rx.subjects.PublishSubject
 import java.util.concurrent.CopyOnWriteArrayList
 
-class DownloadQueue(private val queue: MutableList<Download> = CopyOnWriteArrayList<Download>())
+class DownloadQueue(
+        private val store: DownloadStore,
+        private val queue: MutableList<Download> = CopyOnWriteArrayList<Download>())
 : List<Download> by queue {
 
     private val statusSubject = PublishSubject.create<Download>()
 
-    private val removeSubject = PublishSubject.create<Download>()
+    private val updatedRelay = PublishRelay.create<Unit>()
 
-    fun add(download: Download): Boolean {
-        download.setStatusSubject(statusSubject)
-        download.status = Download.QUEUE
-        return queue.add(download)
+    fun addAll(downloads: List<Download>) {
+        downloads.forEach { download ->
+            download.setStatusSubject(statusSubject)
+            download.status = Download.QUEUE
+        }
+        queue.addAll(downloads)
+        store.addAll(downloads)
+        updatedRelay.call(Unit)
     }
 
-    fun del(download: Download) {
+    fun remove(download: Download) {
         val removed = queue.remove(download)
+        store.remove(download)
         download.setStatusSubject(null)
         if (removed) {
-            removeSubject.onNext(download)
+            updatedRelay.call(Unit)
         }
     }
 
-    fun del(chapter: Chapter) {
-        find { it.chapter.id == chapter.id }?.let { del(it) }
+    fun remove(chapter: Chapter) {
+        find { it.chapter.id == chapter.id }?.let { remove(it) }
     }
 
     fun clear() {
-        queue.forEach { del(it) }
+        queue.forEach { download ->
+            download.setStatusSubject(null)
+        }
+        queue.clear()
+        updatedRelay.call(Unit)
     }
 
     fun getActiveDownloads(): Observable<Download> =
@@ -40,7 +53,9 @@ class DownloadQueue(private val queue: MutableList<Download> = CopyOnWriteArrayL
 
     fun getStatusObservable(): Observable<Download> = statusSubject.onBackpressureBuffer()
 
-    fun getRemovedObservable(): Observable<Download> = removeSubject.onBackpressureBuffer()
+    fun getUpdatedObservable(): Observable<List<Download>> = updatedRelay.onBackpressureBuffer()
+            .startWith(Unit)
+            .map { this }
 
     fun getProgressObservable(): Observable<Download> {
         return statusSubject.onBackpressureBuffer()

+ 5 - 13
app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt

@@ -1,6 +1,7 @@
 package eu.kanade.tachiyomi.data.preference
 
 import android.content.Context
+import android.net.Uri
 import android.os.Environment
 import android.preference.PreferenceManager
 import com.f2prateek.rx.preferences.Preference
@@ -9,7 +10,6 @@ import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
 import eu.kanade.tachiyomi.data.source.Source
 import java.io.File
-import java.io.IOException
 
 fun <T> Preference<T>.getOrDefault(): T = get() ?: defaultValue()!!
 
@@ -20,17 +20,9 @@ class PreferencesHelper(context: Context) {
     private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
     private val rxPrefs = RxSharedPreferences.create(prefs)
 
-    private val defaultDownloadsDir = File(Environment.getExternalStorageDirectory().absolutePath +
-            File.separator + context.getString(R.string.app_name), "downloads")
-
-    init {
-        // Don't display downloaded chapters in gallery apps creating a ".nomedia" file
-        try {
-            File(downloadsDirectory().getOrDefault(), ".nomedia").createNewFile()
-        } catch (e: IOException) {
-            /* Ignore */
-        }
-    }
+    private val defaultDownloadsDir = Uri.fromFile(
+            File(Environment.getExternalStorageDirectory().absolutePath + File.separator +
+                    context.getString(R.string.app_name), "downloads"))
 
     fun startScreen() = prefs.getInt(keys.startScreen, 1)
 
@@ -112,7 +104,7 @@ class PreferencesHelper(context: Context) {
                 .apply()
     }
 
-    fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.absolutePath)
+    fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.toString())
 
     fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1)
 

+ 4 - 3
app/src/main/java/eu/kanade/tachiyomi/data/source/model/Page.kt

@@ -1,14 +1,15 @@
 package eu.kanade.tachiyomi.data.source.model
 
+import android.net.Uri
 import eu.kanade.tachiyomi.data.network.ProgressListener
 import eu.kanade.tachiyomi.ui.reader.ReaderChapter
 import rx.subjects.Subject
 
 class Page(
-        val pageNumber: Int,
-        val url: String,
+        val index: Int,
+        val url: String = "",
         var imageUrl: String? = null,
-        @Transient var imagePath: String? = null
+        @Transient var uri: Uri? = null
 ) : ProgressListener {
 
     @Transient lateinit var chapter: ReaderChapter

+ 2 - 1
app/src/main/java/eu/kanade/tachiyomi/data/source/online/OnlineSource.kt

@@ -1,5 +1,6 @@
 package eu.kanade.tachiyomi.data.source.online
 
+import android.net.Uri
 import eu.kanade.tachiyomi.data.cache.ChapterCache
 import eu.kanade.tachiyomi.data.database.models.Chapter
 import eu.kanade.tachiyomi.data.database.models.Manga
@@ -416,7 +417,7 @@ abstract class OnlineSource() : Source {
                     }
                 }
                 .doOnNext {
-                    page.imagePath = chapterCache.getImagePath(imageUrl)
+                    page.uri = Uri.fromFile(chapterCache.getImagePath(imageUrl))
                     page.status = Page.READY
                 }
                 .doOnError { page.status = Page.ERROR }

+ 13 - 37
app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadFragment.kt

@@ -6,6 +6,7 @@ import android.view.*
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.download.DownloadService
 import eu.kanade.tachiyomi.data.download.model.Download
+import eu.kanade.tachiyomi.data.source.model.Page
 import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
 import eu.kanade.tachiyomi.ui.main.MainActivity
 import eu.kanade.tachiyomi.util.plusAssign
@@ -30,21 +31,6 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
      */
     private lateinit var adapter: DownloadAdapter
 
-    /**
-     * Menu item to start the queue.
-     */
-    private var startButton: MenuItem? = null
-
-    /**
-     * Menu item to pause the queue.
-     */
-    private var pauseButton: MenuItem? = null
-
-    /**
-     * Menu item to clear the queue.
-     */
-    private var clearButton: MenuItem? = null
-
     /**
      * Subscription list to be cleared during [onDestroyView].
      */
@@ -95,15 +81,15 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
         recycler.setHasFixedSize(true)
 
         // Suscribe to changes
-        subscriptions += presenter.downloadManager.runningSubject
+        subscriptions += DownloadService.runningRelay
                 .observeOn(AndroidSchedulers.mainThread())
                 .subscribe { onQueueStatusChange(it) }
 
-        subscriptions += presenter.getStatusObservable()
+        subscriptions += presenter.getDownloadStatusObservable()
                 .observeOn(AndroidSchedulers.mainThread())
                 .subscribe { onStatusChange(it) }
 
-        subscriptions += presenter.getProgressObservable()
+        subscriptions += presenter.getDownloadProgressObservable()
                 .observeOn(AndroidSchedulers.mainThread())
                 .subscribe { onUpdateDownloadedPages(it) }
     }
@@ -119,23 +105,17 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
 
     override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
         inflater.inflate(R.menu.download_queue, menu)
+    }
 
+    override fun onPrepareOptionsMenu(menu: Menu) {
         // Set start button visibility.
-        startButton = menu.findItem(R.id.start_queue).apply {
-            isVisible = !isRunning && !presenter.downloadQueue.isEmpty()
-        }
+        menu.findItem(R.id.start_queue).isVisible = !isRunning && !presenter.downloadQueue.isEmpty()
 
         // Set pause button visibility.
-        pauseButton = menu.findItem(R.id.pause_queue).apply {
-            isVisible = isRunning
-        }
+        menu.findItem(R.id.pause_queue).isVisible = isRunning
 
         // Set clear button visibility.
-        clearButton = menu.findItem(R.id.clear_queue).apply {
-            if (!presenter.downloadQueue.isEmpty()) {
-                isVisible = true
-            }
-        }
+        menu.findItem(R.id.clear_queue).isVisible = !presenter.downloadQueue.isEmpty()
     }
 
     override fun onOptionsItemSelected(item: MenuItem): Boolean {
@@ -182,7 +162,7 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
                 // Get the sum of percentages for all the pages.
                 .flatMap {
                     Observable.from(download.pages)
-                            .map { it.progress }
+                            .map(Page::progress)
                             .reduce { x, y -> x + y }
                 }
                 // Keep only the latest emission to avoid backpressure.
@@ -218,9 +198,7 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
      */
     private fun onQueueStatusChange(running: Boolean) {
         isRunning = running
-        startButton?.isVisible = !running && !presenter.downloadQueue.isEmpty()
-        pauseButton?.isVisible = running
-        clearButton?.isVisible = !presenter.downloadQueue.isEmpty()
+        activity.supportInvalidateOptionsMenu()
 
         // Check if download queue is empty and update information accordingly.
         setInformationView()
@@ -232,13 +210,11 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
      * @param downloads the downloads from the queue.
      */
     fun onNextDownloads(downloads: List<Download>) {
+        activity.supportInvalidateOptionsMenu()
+        setInformationView()
         adapter.setItems(downloads)
     }
 
-    fun onDownloadRemoved(position: Int) {
-        adapter.notifyItemRemoved(position)
-    }
-
     /**
      * Called when the progress of a download changes.
      *

+ 6 - 21
app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadPresenter.kt

@@ -29,36 +29,21 @@ class DownloadPresenter : BasePresenter<DownloadFragment>() {
 
     override fun onCreate(savedState: Bundle?) {
         super.onCreate(savedState)
-        
-        Observable.just(ArrayList(downloadQueue))
-                .doOnNext { syncQueue(it) }
-                .subscribeLatestCache({ view, downloads ->
-                    view.onNextDownloads(downloads)
-                }, { view, error ->
-                    Timber.e(error)
-                })
-    }
 
-    private fun syncQueue(queue: MutableList<Download>) {
-        add(downloadQueue.getRemovedObservable()
+        downloadQueue.getUpdatedObservable()
                 .observeOn(AndroidSchedulers.mainThread())
-                .subscribe { download ->
-                    val position = queue.indexOf(download)
-                    if (position != -1) {
-                        queue.removeAt(position)
-
-                        @Suppress("DEPRECATION")
-                        view?.onDownloadRemoved(position)
-                    }
+                .map { ArrayList(it) }
+                .subscribeLatestCache(DownloadFragment::onNextDownloads, { view, error ->
+                    Timber.e(error)
                 })
     }
 
-    fun getStatusObservable(): Observable<Download> {
+    fun getDownloadStatusObservable(): Observable<Download> {
         return downloadQueue.getStatusObservable()
                 .startWith(downloadQueue.getActiveDownloads())
     }
 
-    fun getProgressObservable(): Observable<Download> {
+    fun getDownloadProgressObservable(): Observable<Download> {
         return downloadQueue.getProgressObservable()
                 .onBackpressureBuffer()
     }

+ 4 - 9
app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt

@@ -185,15 +185,10 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
             }
 
             if (prefFilterDownloaded) {
-                val mangaDir = downloadManager.getAbsoluteMangaDirectory(source, manga)
-
-                if (mangaDir.exists()) {
-                    for (file in mangaDir.listFiles()) {
-                        if (file.isDirectory && file.listFiles().isNotEmpty()) {
-                            hasDownloaded = true
-                            break
-                        }
-                    }
+                val mangaDir = downloadManager.findMangaDir(source, manga)
+
+                if (mangaDir != null) {
+                    hasDownloaded = mangaDir.listFiles()?.any { it.isDirectory } ?: false
                 }
             }
 

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogFragment.kt

@@ -38,7 +38,7 @@ class ChangelogDialogFragment : DialogFragment() {
     override fun onCreateDialog(savedState: Bundle?): Dialog {
         val view = WhatsNewRecyclerView(context)
         return MaterialDialog.Builder(activity)
-                .title("Changelog")
+                .title(if (BuildConfig.DEBUG) "Notices" else "Changelog")
                 .customView(view, false)
                 .positiveText(android.R.string.yes)
                 .build()

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

@@ -132,6 +132,9 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
                     chapters.map { it.toModel() }
                 }
                 .doOnNext { chapters ->
+                    // Find downloaded chapters
+                    setDownloadedChapters(chapters)
+
                     // Store the last emission
                     this.chapters = chapters
 
@@ -157,16 +160,25 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
         if (download != null) {
             // If there's an active download, assign it.
             model.download = download
-        } else {
-            // Otherwise ask the manager if the chapter is downloaded and assign it to the status.
-            model.status = if (downloadManager.isChapterDownloaded(source, manga, this))
-                Download.DOWNLOADED
-            else
-                Download.NOT_DOWNLOADED
         }
         return model
     }
 
+    /**
+     * Finds and assigns the list of downloaded chapters.
+     *
+     * @param chapters the list of chapter from the database.
+     */
+    private fun setDownloadedChapters(chapters: List<ChapterModel>) {
+        val files = downloadManager.findMangaDir(source, manga)?.listFiles() ?: return
+        val cached = mutableMapOf<Chapter, String>()
+        files.mapNotNull { it.name }
+                .mapNotNull { name -> chapters.find {
+                    name == cached.getOrPut(it) { downloadManager.getChapterDirName(it) }
+                } }
+                .forEach { it.status = Download.DOWNLOADED }
+    }
+
     /**
      * Requests an updated list of chapters from the source.
      */
@@ -318,10 +330,6 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
      * @param chapters the list of chapters to delete.
      */
     fun deleteChapters(chapters: List<ChapterModel>) {
-        val wasRunning = downloadManager.isRunning
-        if (wasRunning) {
-            DownloadService.stop(context)
-        }
         Observable.from(chapters)
                 .doOnNext { deleteChapter(it) }
                 .toList()
@@ -330,9 +338,6 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
                 .observeOn(AndroidSchedulers.mainThread())
                 .subscribeFirst({ view, result ->
                     view.onChaptersDeleted()
-                    if (wasRunning) {
-                        DownloadService.start(context)
-                    }
                 }, { view, error ->
                     view.onChaptersDeletedError(error)
                 })
@@ -343,7 +348,7 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
      * @param chapter the chapter to delete.
      */
     private fun deleteChapter(chapter: ChapterModel) {
-        downloadManager.queue.del(chapter)
+        downloadManager.queue.remove(chapter)
         downloadManager.deleteChapter(source, manga, chapter)
         chapter.status = Download.NOT_DOWNLOADED
         chapter.download = null

+ 8 - 17
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoader.kt

@@ -70,14 +70,15 @@ class ChapterLoader(
     private fun retrievePageList(chapter: ReaderChapter) = Observable.just(chapter)
             .flatMap {
                 // Check if the chapter is downloaded.
-                chapter.isDownloaded = downloadManager.isChapterDownloaded(source, manga, chapter)
+                chapter.isDownloaded = downloadManager.findChapterDir(source, manga, chapter) != null
 
-                // Fetch the page list from disk.
-                if (chapter.isDownloaded)
-                    Observable.just(downloadManager.getSavedPageList(source, manga, chapter)!!)
-                // Fetch the page list from cache or fallback to network
-                else
+                if (chapter.isDownloaded) {
+                    // Fetch the page list from disk.
+                    downloadManager.buildPageList(source, manga, chapter)
+                } else {
+                    // Fetch the page list from cache or fallback to network
                     source.fetchPageList(chapter)
+                }
             }
             .doOnNext { pages ->
                 chapter.pages = pages
@@ -85,21 +86,11 @@ class ChapterLoader(
             }
 
     private fun loadPages(chapter: ReaderChapter) {
-        if (chapter.isDownloaded) {
-            loadDownloadedPages(chapter)
-        } else {
+        if (!chapter.isDownloaded) {
             loadOnlinePages(chapter)
         }
     }
 
-    private fun loadDownloadedPages(chapter: ReaderChapter) {
-        val chapterDir = downloadManager.getAbsoluteChapterDirectory(source, manga, chapter)
-        subscriptions += Observable.from(chapter.pages!!)
-                .flatMap { downloadManager.getDownloadedImage(it, chapterDir) }
-                .subscribeOn(Schedulers.io())
-                .subscribe()
-    }
-
     private fun loadOnlinePages(chapter: ReaderChapter) {
         chapter.pages?.let { pages ->
             val startPage = chapter.requestedPage

+ 4 - 5
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt

@@ -5,7 +5,6 @@ import android.content.Intent
 import android.content.pm.ActivityInfo
 import android.content.res.Configuration
 import android.graphics.Color
-import android.net.Uri
 import android.os.Build
 import android.os.Build.VERSION_CODES.KITKAT
 import android.os.Bundle
@@ -265,7 +264,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
         val activePage = pages.getOrElse(chapter.requestedPage) { pages.first() }
 
         viewer?.onPageListReady(chapter, activePage)
-        setActiveChapter(chapter, activePage.pageNumber)
+        setActiveChapter(chapter, activePage.index)
     }
 
     fun onEnterChapter(chapter: ReaderChapter, currentPage: Int) {
@@ -332,7 +331,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
     fun onPageChanged(page: Page) {
         presenter.onPageChanged(page)
 
-        val pageNumber = page.pageNumber + 1
+        val pageNumber = page.index + 1
         val pageCount = page.chapter.pages!!.size
         page_number.text = "$pageNumber/$pageCount"
         if (page_seekbar.rotation != 180f) {
@@ -340,7 +339,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
         } else {
             right_page_text.text = "$pageNumber"
         }
-        page_seekbar.progress = page.pageNumber
+        page_seekbar.progress = page.index
     }
 
     fun gotoPageInCurrentChapter(pageIndex: Int) {
@@ -481,7 +480,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
 
         val shareIntent = Intent().apply {
             action = Intent.ACTION_SEND
-            putExtra(Intent.EXTRA_STREAM, Uri.parse(page.imagePath))
+            putExtra(Intent.EXTRA_STREAM, page.uri)
             flags = Intent.FLAG_ACTIVITY_NEW_TASK
             type = "image/jpeg"
         }

+ 55 - 57
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt

@@ -29,7 +29,6 @@ import rx.schedulers.Schedulers
 import timber.log.Timber
 import uy.kohesive.injekt.injectLazy
 import java.io.File
-import java.io.IOException
 import java.util.*
 
 /**
@@ -98,15 +97,6 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
      */
     private val source by lazy { sourceManager.get(manga.source)!! }
 
-    /**
-     * Directory of pictures
-     */
-    private val pictureDirectory: String by lazy {
-        Environment.getExternalStorageDirectory().absolutePath + File.separator +
-                Environment.DIRECTORY_PICTURES + File.separator +
-                context.getString(R.string.app_name) + File.separator
-    }
-
     /**
      * Chapter list for the active manga. It's retrieved lazily and should be accessed for the first
      * time in a background thread to avoid blocking the UI.
@@ -351,9 +341,9 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
     fun retryPage(page: Page?) {
         if (page != null && source is OnlineSource) {
             page.status = Page.QUEUE
-            val path = page.imagePath
-            if (!path.isNullOrEmpty() && !page.chapter.isDownloaded) {
-                chapterCache.removeFileFromCache(File(path).name)
+            val uri = page.uri
+            if (uri != null && !page.chapter.isDownloaded) {
+                chapterCache.removeFileFromCache(uri.encodedPath.substringAfterLast('/'))
             }
             loader.retryPage(page)
         }
@@ -370,27 +360,27 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
         val pages = chapter.pages ?: return
 
         Observable.fromCallable {
-            // Chapters with 1 page don't trigger page changes, so mark them as read.
-            if (pages.size == 1) {
-                chapter.read = true
-            }
-
             // Cache current page list progress for online chapters to allow a faster reopen
             if (!chapter.isDownloaded) {
                 source.let { if (it is OnlineSource) it.savePageList(chapter, pages) }
             }
 
-            if (chapter.read) {
-                val removeAfterReadSlots = prefs.removeAfterReadSlots()
-                when (removeAfterReadSlots) {
-                    // Setting disabled
-                    -1 -> { /**Empty function**/ }
-                    // Remove current read chapter
-                    0 -> deleteChapter(chapter, manga)
-                    // Remove previous chapter specified by user in settings.
-                    else -> getAdjacentChaptersStrategy(chapter, removeAfterReadSlots)
-                            .first?.let { deleteChapter(it, manga) }
+            try {
+                if (chapter.read) {
+                    val removeAfterReadSlots = prefs.removeAfterReadSlots()
+                    when (removeAfterReadSlots) {
+                        // Setting disabled
+                        -1 -> { /* Empty function */ }
+                        // Remove current read chapter
+                        0 -> deleteChapter(chapter, manga)
+                        // Remove previous chapter specified by user in settings.
+                        else -> getAdjacentChaptersStrategy(chapter, removeAfterReadSlots)
+                                .first?.let { deleteChapter(it, manga) }
+                    }
                 }
+            } catch (error: Exception) {
+                // TODO find out why it crashes
+                Timber.e(error)
             }
 
             db.updateChapterProgress(chapter).executeAsBlocking()
@@ -414,7 +404,7 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
      */
     fun onPageChanged(page: Page) {
         val chapter = page.chapter
-        chapter.last_page_read = page.pageNumber
+        chapter.last_page_read = page.index
         if (chapter.pages!!.last() === page) {
             chapter.read = true
         }
@@ -537,7 +527,8 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
         try {
             if (manga.favorite) {
                 if (manga.thumbnail_url != null) {
-                    coverCache.copyToCache(manga.thumbnail_url!!, File(page.imagePath).inputStream())
+                    val input = context.contentResolver.openInputStream(page.uri)
+                    coverCache.copyToCache(manga.thumbnail_url!!, input)
                     context.toast(R.string.cover_updated)
                 } else {
                     throw Exception("Image url not found")
@@ -552,40 +543,47 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
     }
 
     /**
-     * Save page to local storage
-     * @throws IOException
+     * Save page to local storage.
      */
-    @Throws(IOException::class)
     internal fun savePage(page: Page) {
         if (page.status != Page.READY)
             return
 
-        // Used to show image notification
+        // Used to show image notification.
         val imageNotifier = ImageNotifier(context)
 
-        // Location of image file.
-        val inputFile = File(page.imagePath)
-
-        // File where the image will be saved.
-        val destFile = File(pictureDirectory, manga.title + " - " + chapter.name +
-                " - " + downloadManager.getImageFilename(page))
-
-        //Remove the notification if already exist (user feedback)
+        // Remove the notification if it already exists (user feedback).
         imageNotifier.onClear()
-        if (inputFile.exists()) {
-            // Copy file
-            Observable.fromCallable { inputFile.copyTo(destFile, true) }
-                    .subscribeOn(Schedulers.io())
-                    .observeOn(AndroidSchedulers.mainThread())
-                    .subscribe(
-                            {
-                                // Show notification
-                                imageNotifier.onComplete(it)
-                            },
-                            { error ->
-                                Timber.e(error)
-                                imageNotifier.onError(error.message)
-                            })
-        }
+
+        // Pictures directory.
+        val pictureDirectory = Environment.getExternalStorageDirectory().absolutePath +
+                File.separator + Environment.DIRECTORY_PICTURES +
+                File.separator + context.getString(R.string.app_name)
+
+        // Copy file in background.
+        Observable
+                .fromCallable {
+                    // File where the image will be saved.
+                    val destDir = File(pictureDirectory)
+                    destDir.mkdirs()
+
+                    val destFile = File(destDir, manga.title + " - " + chapter.name +
+                            " - " + (page.index + 1))
+
+                    // Location of image file.
+                    context.contentResolver.openInputStream(page.uri).use { input ->
+                        destFile.outputStream().use { output ->
+                            input.copyTo(output)
+                        }
+                    }
+
+                    imageNotifier.onComplete(destFile)
+                }
+                .subscribeOn(Schedulers.io())
+                .subscribe({},
+                        { error ->
+                            Timber.e(error)
+                            imageNotifier.onError(error.message)
+                        })
     }
 }

+ 19 - 21
app/src/main/java/eu/kanade/tachiyomi/ui/reader/notification/ImageNotifier.kt

@@ -2,12 +2,9 @@ package eu.kanade.tachiyomi.ui.reader.notification
 
 import android.content.Context
 import android.graphics.Bitmap
-import android.media.Image
 import android.support.v4.app.NotificationCompat
 import com.bumptech.glide.Glide
 import com.bumptech.glide.load.engine.DiskCacheStrategy
-import com.bumptech.glide.request.animation.GlideAnimation
-import com.bumptech.glide.request.target.SimpleTarget
 import eu.kanade.tachiyomi.Constants
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.util.notificationManager
@@ -29,24 +26,25 @@ class ImageNotifier(private val context: Context) {
         get() = Constants.NOTIFICATION_DOWNLOAD_IMAGE_ID
 
     /**
-     * Called when image download/copy is complete
-     * @param file image file containing downloaded page image
+     * Called when image download/copy is complete. This method must be called in a background
+     * thread.
+     *
+     * @param file image file containing downloaded page image.
      */
     fun onComplete(file: File) {
+        val bitmap = Glide.with(context)
+                .load(file)
+                .asBitmap()
+                .diskCacheStrategy(DiskCacheStrategy.NONE)
+                .skipMemoryCache(true)
+                .into(720, 1280)
+                .get()
 
-        Glide.with(context).load(file).asBitmap().diskCacheStrategy(DiskCacheStrategy.NONE).skipMemoryCache(true).into(object : SimpleTarget<Bitmap>(720, 1280) {
-            /**
-             * The method that will be called when the resource load has finished.
-             * @param resource the loaded resource.
-             */
-            override fun onResourceReady(resource: Bitmap?, glideAnimation: GlideAnimation<in Bitmap>?) {
-                if (resource!= null){
-                    showCompleteNotification(file, resource)
-                }else{
-                    onError(null)
-                }
-            }
-        })
+        if (bitmap != null) {
+            showCompleteNotification(file, bitmap)
+        } else {
+            onError(null)
+        }
     }
 
     private fun showCompleteNotification(file: File, image: Bitmap) {
@@ -75,7 +73,7 @@ class ImageNotifier(private val context: Context) {
     }
 
     /**
-     * Clears the notification message
+     * Clears the notification message.
      */
     fun onClear() {
         context.notificationManager.cancel(notificationId)
@@ -88,8 +86,8 @@ class ImageNotifier(private val context: Context) {
 
 
     /**
-     * Called on error while downloading image
-     * @param error string containing error information
+     * Called on error while downloading image.
+     * @param error string containing error information.
      */
     fun onError(error: String?) {
         // Create notification

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/BaseReader.kt

@@ -95,7 +95,7 @@ abstract class BaseReader : BaseFragment() {
 
         // Active chapter has changed.
         if (oldChapter.id != newChapter.id) {
-            readerActivity.onEnterChapter(newPage.chapter, newPage.pageNumber)
+            readerActivity.onEnterChapter(newPage.chapter, newPage.index)
         }
         // Request next chapter only when the conditions are met.
         if (pages.size - position < 5 && chapters.last().id == newChapter.id
@@ -125,7 +125,7 @@ abstract class BaseReader : BaseFragment() {
      */
     fun getPageIndex(search: Page): Int {
         for ((index, page) in pages.withIndex()) {
-            if (page.pageNumber == search.pageNumber && page.chapter.id == search.chapter.id) {
+            if (page.index == search.index && page.chapter.id == search.chapter.id) {
                 return index
             }
         }

+ 18 - 4
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PageView.kt

@@ -2,12 +2,14 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager
 
 import android.content.Context
 import android.graphics.PointF
+import android.os.Build
 import android.util.AttributeSet
 import android.view.MotionEvent
 import android.view.View
 import android.widget.FrameLayout
 import com.davemorrissey.labs.subscaleview.ImageSource
 import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
+import com.hippo.unifile.UniFile
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.source.model.Page
 import eu.kanade.tachiyomi.ui.reader.ReaderActivity
@@ -208,13 +210,25 @@ class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
      * Called when the page is ready.
      */
     private fun setImage() {
-        val path = page.imagePath
-        if (path != null && File(path).exists()) {
-            progress_text.visibility = View.INVISIBLE
-            image_view.setImage(ImageSource.uri(path))
+        val uri = page.uri
+        if (uri == null) {
+            page.status = Page.ERROR
+            return
+        }
+
+        val file = if (Build.VERSION.SDK_INT < 21 || UniFile.isFileUri(uri)) {
+            UniFile.fromFile(File(uri.path))
         } else {
+            // Tree uri returns the root folder
+            UniFile.fromSingleUri(context, uri)
+        }!!
+        if (!file.exists()) {
             page.status = Page.ERROR
+            return
         }
+
+        progress_text.visibility = View.INVISIBLE
+        image_view.setImage(ImageSource.uri(file.uri))
     }
 
     /**

+ 19 - 5
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonHolder.kt

@@ -1,11 +1,13 @@
 package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
 
+import android.os.Build
 import android.support.v7.widget.RecyclerView
 import android.view.MotionEvent
 import android.view.View
 import android.view.ViewGroup
 import com.davemorrissey.labs.subscaleview.ImageSource
 import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
+import com.hippo.unifile.UniFile
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.source.model.Page
 import eu.kanade.tachiyomi.ui.reader.ReaderActivity
@@ -242,14 +244,26 @@ class WebtoonHolder(private val view: View, private val adapter: WebtoonAdapter)
      * Called when the page is ready.
      */
     private fun setImage() = with(view) {
-        val path = page?.imagePath
-        if (path != null && File(path).exists()) {
-            progress_text.visibility = View.INVISIBLE
-            image_view.visibility = View.VISIBLE
-            image_view.setImage(ImageSource.uri(path))
+        val uri = page?.uri
+        if (uri == null) {
+            page?.status = Page.ERROR
+            return
+        }
+
+        val file = if (Build.VERSION.SDK_INT < 21 || UniFile.isFileUri(uri)) {
+            UniFile.fromFile(File(uri.path))
         } else {
+            // Tree uri returns the root folder
+            UniFile.fromSingleUri(context, uri)
+        }!!
+        if (!file.exists()) {
             page?.status = Page.ERROR
+            return
         }
+
+        progress_text.visibility = View.INVISIBLE
+        image_view.visibility = View.VISIBLE
+        image_view.setImage(ImageSource.uri(file.uri))
     }
 
     /**

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonReader.kt

@@ -116,7 +116,7 @@ class WebtoonReader : BaseReader() {
     }
 
     override fun onSaveInstanceState(outState: Bundle) {
-        val savedPosition = pages.getOrNull(layoutManager.findFirstVisibleItemPosition())?.pageNumber ?: 0
+        val savedPosition = pages.getOrNull(layoutManager.findFirstVisibleItemPosition())?.index ?: 0
         outState.putInt(SAVED_POSITION, savedPosition)
         super.onSaveInstanceState(outState)
     }
@@ -163,7 +163,7 @@ class WebtoonReader : BaseReader() {
      * @param currentPage the initial page to display.
      */
     override fun onChapterSet(chapter: ReaderChapter, currentPage: Page) {
-        this.currentPage = currentPage.pageNumber
+        this.currentPage = currentPage.index
 
         // Make sure the view is already initialized.
         if (view != null) {

+ 25 - 17
app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt

@@ -1,6 +1,7 @@
 package eu.kanade.tachiyomi.ui.recent_updates
 
 import android.os.Bundle
+import com.hippo.unifile.UniFile
 import eu.kanade.tachiyomi.data.database.DatabaseHelper
 import eu.kanade.tachiyomi.data.database.models.MangaChapter
 import eu.kanade.tachiyomi.data.download.DownloadManager
@@ -97,7 +98,10 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
                 .map { mangaChapters ->
                     mangaChapters.map { it.toModel() }
                 }
-                .doOnNext { chapters = it }
+                .doOnNext {
+                    setDownloadedChapters(it)
+                    chapters = it
+                }
                 // Group chapters by the date they were fetched on a ordered map.
                 .flatMap { recentItems ->
                     Observable.from(recentItems)
@@ -142,18 +146,29 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
         // downloaded and assign it to the status.
         if (download != null) {
             model.download = download
-        } else {
-            // Get source of chapter.
-            val source = sourceManager.get(manga.source)!!
-
-            model.status = if (downloadManager.isChapterDownloaded(source, manga, chapter))
-                Download.DOWNLOADED
-            else
-                Download.NOT_DOWNLOADED
         }
         return model
     }
 
+    /**
+     * Finds and assigns the list of downloaded chapters.
+     *
+     * @param chapters the list of chapter from the database.
+     */
+    private fun setDownloadedChapters(chapters: List<RecentChapter>) {
+        val cachedDirs = mutableMapOf<Long, UniFile?>()
+
+        chapters.forEach { chapter ->
+            val manga = chapter.manga
+            val mangaDir = cachedDirs.getOrPut(manga.id!!)
+                    { downloadManager.findMangaDir(sourceManager.get(manga.source)!!, manga) }
+
+            if (mangaDir?.findFile(downloadManager.getChapterDirName(chapter)) != null) {
+                chapter.status = Download.DOWNLOADED
+            }
+        }
+    }
+
     /**
      * Update status of chapters.
      * @param download download object containing progress.
@@ -207,10 +222,6 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
      * @param chapters list of chapters
      */
     fun deleteChapters(chapters: List<RecentChapter>) {
-        val wasRunning = downloadManager.isRunning
-        if (wasRunning) {
-            DownloadService.stop(context)
-        }
         Observable.from(chapters)
                 .doOnNext { deleteChapter(it) }
                 .toList()
@@ -218,9 +229,6 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
                 .observeOn(AndroidSchedulers.mainThread())
                 .subscribeFirst({ view, result ->
                     view.onChaptersDeleted()
-                    if (wasRunning) {
-                        DownloadService.start(context)
-                    }
                 }, { view, error ->
                     view.onChaptersDeletedError(error)
                 })
@@ -253,7 +261,7 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
      */
     private fun deleteChapter(chapter: RecentChapter) {
         val source = sourceManager.get(chapter.manga.source) ?: return
-        downloadManager.queue.del(chapter)
+        downloadManager.queue.remove(chapter)
         downloadManager.deleteChapter(source, chapter.manga, chapter)
         chapter.status = Download.NOT_DOWNLOADED
         chapter.download = null

+ 47 - 15
app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadsFragment.kt

@@ -2,6 +2,8 @@ package eu.kanade.tachiyomi.ui.setting
 
 import android.app.Activity
 import android.content.Intent
+import android.net.Uri
+import android.os.Build
 import android.os.Bundle
 import android.os.Environment
 import android.support.v4.content.ContextCompat
@@ -11,6 +13,7 @@ import android.support.v7.widget.RecyclerView
 import android.view.View
 import android.view.ViewGroup
 import com.afollestad.materialdialogs.MaterialDialog
+import com.hippo.unifile.UniFile
 import com.nononsenseapps.filepicker.AbstractFilePickerFragment
 import com.nononsenseapps.filepicker.FilePickerActivity
 import com.nononsenseapps.filepicker.FilePickerFragment
@@ -26,7 +29,8 @@ import java.io.File
 class SettingsDownloadsFragment : SettingsFragment() {
 
     companion object {
-        val DOWNLOAD_DIR_CODE = 103
+        const val DOWNLOAD_DIR_PRE_L = 103
+        const val DOWNLOAD_DIR_L = 104
 
         fun newInstance(rootKey: String): SettingsDownloadsFragment {
             val args = Bundle()
@@ -45,24 +49,30 @@ class SettingsDownloadsFragment : SettingsFragment() {
         downloadDirPref.setOnPreferenceClickListener {
 
             val currentDir = preferences.downloadsDirectory().getOrDefault()
-            val externalDirs = getExternalFilesDirs() + getString(R.string.custom_dir)
-            val selectedIndex = externalDirs.indexOf(File(currentDir))
+            val externalDirs = getExternalFilesDirs() + File(getString(R.string.custom_dir))
+            val selectedIndex = externalDirs.map(File::toString).indexOfFirst { it in currentDir }
 
             MaterialDialog.Builder(activity)
                     .items(externalDirs)
                     .itemsCallbackSingleChoice(selectedIndex, { dialog, view, which, text ->
                         if (which == externalDirs.lastIndex) {
-                            // Custom dir selected, open directory selector
-                            val i = Intent(activity, CustomLayoutPickerActivity::class.java)
-                            i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false)
-                            i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true)
-                            i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR)
-                            i.putExtra(FilePickerActivity.EXTRA_START_PATH, currentDir)
-
-                            startActivityForResult(i, DOWNLOAD_DIR_CODE)
+                            if (Build.VERSION.SDK_INT < 21) {
+                                // Custom dir selected, open directory selector
+                                val i = Intent(activity, CustomLayoutPickerActivity::class.java)
+                                i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false)
+                                i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true)
+                                i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR)
+                                i.putExtra(FilePickerActivity.EXTRA_START_PATH, currentDir)
+
+                                startActivityForResult(i, DOWNLOAD_DIR_PRE_L)
+                            } else {
+                                val i = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
+                                startActivityForResult(i, DOWNLOAD_DIR_L)
+                            }
                         } else {
                             // One of the predefined folders was selected
-                            preferences.downloadsDirectory().set(text.toString())
+                            val path = Uri.fromFile(File(text.toString()))
+                            preferences.downloadsDirectory().set(path.toString())
                         }
                         true
                     })
@@ -72,7 +82,15 @@ class SettingsDownloadsFragment : SettingsFragment() {
         }
 
         subscriptions += preferences.downloadsDirectory().asObservable()
-                .subscribe { downloadDirPref.summary = it }
+                .subscribe { path ->
+                    downloadDirPref.summary = path
+
+                    // Don't display downloaded chapters in gallery apps creating a ".nomedia" file.
+                    val dir = UniFile.fromUri(context, Uri.parse(path))
+                    if (dir != null && dir.exists()) {
+                        dir.createFile(".nomedia")
+                    }
+                }
     }
 
     fun getExternalFilesDirs(): List<File> {
@@ -85,8 +103,22 @@ class SettingsDownloadsFragment : SettingsFragment() {
     }
 
     override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
-        if (data != null && requestCode == DOWNLOAD_DIR_CODE && resultCode == Activity.RESULT_OK) {
-            preferences.downloadsDirectory().set(data.data.path)
+        when (requestCode) {
+            DOWNLOAD_DIR_PRE_L -> if (data != null && resultCode == Activity.RESULT_OK) {
+                val uri = Uri.fromFile(File(data.data.path))
+                preferences.downloadsDirectory().set(uri.toString())
+            }
+            DOWNLOAD_DIR_L -> if (data != null && resultCode == Activity.RESULT_OK) {
+                val uri = data.data
+                val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
+                        Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+
+                @Suppress("NewApi")
+                context.contentResolver.takePersistableUriPermission(uri, flags)
+
+                val file = UniFile.fromTreeUri(context, uri)
+                preferences.downloadsDirectory().set(file.uri.toString())
+            }
         }
     }
 

+ 11 - 5
app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt

@@ -1,10 +1,11 @@
 package eu.kanade.tachiyomi.util
 
-import android.app.AlarmManager
 import android.app.Notification
 import android.app.NotificationManager
 import android.content.Context
 import android.content.pm.PackageManager
+import android.net.ConnectivityManager
+import android.os.PowerManager
 import android.support.annotation.StringRes
 import android.support.v4.app.NotificationCompat
 import android.support.v4.content.ContextCompat
@@ -54,8 +55,13 @@ val Context.notificationManager: NotificationManager
     get() = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
 
 /**
- * Property to get the alarm manager from the context.
- * @return the alarm manager.
+ * Property to get the connectivity manager from the context.
  */
-val Context.alarmManager: AlarmManager
-    get() = getSystemService(Context.ALARM_SERVICE) as AlarmManager
+val Context.connectivityManager: ConnectivityManager
+    get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+
+/**
+ * Property to get the power manager from the context.
+ */
+val Context.powerManager: PowerManager
+    get() = getSystemService(Context.POWER_SERVICE) as PowerManager

+ 0 - 36
app/src/main/java/eu/kanade/tachiyomi/util/UrlUtil.java

@@ -5,10 +5,6 @@ import java.net.URISyntaxException;
 
 public final class UrlUtil {
 
-    private static final String JPG = ".jpg";
-    private static final String PNG = ".png";
-    private static final String GIF = ".gif";
-
     private UrlUtil() throws InstantiationException {
         throw new InstantiationException("This class is not for instantiation");
     }
@@ -27,36 +23,4 @@ public final class UrlUtil {
         }
     }
 
-    public static boolean isJpg(String url) {
-        return containsIgnoreCase(url, JPG);
-    }
-
-    public static boolean isPng(String url) {
-        return containsIgnoreCase(url, PNG);
-    }
-
-    public static boolean isGif(String url) {
-        return containsIgnoreCase(url, GIF);
-    }
-
-    public static boolean containsIgnoreCase(String src, String what) {
-        final int length = what.length();
-        if (length == 0)
-            return true; // Empty string is contained
-
-        final char firstLo = Character.toLowerCase(what.charAt(0));
-        final char firstUp = Character.toUpperCase(what.charAt(0));
-
-        for (int i = src.length() - length; i >= 0; i--) {
-            // Quick check before calling the more expensive regionMatches() method:
-            final char ch = src.charAt(i);
-            if (ch != firstLo && ch != firstUp)
-                continue;
-
-            if (src.regionMatches(true, i, what, 0, length))
-                return true;
-        }
-
-        return false;
-    }
 }

+ 8 - 0
app/src/main/res/raw/changelog_debug.xml

@@ -1,6 +1,14 @@
 <?xml version="1.0" encoding="utf-8"?>
 <changelog bulletedList="false">
 
+    <changelogversion changeDate="" versionName="r959">
+        <changelogtext>The download manager has been rewritten and it's possible some of your downloads
+            aren't recognized anymore. You may have to check your downloads folder and manually delete those.
+        </changelogtext>
+        <changelogtext>You can now download to any folder in your SD card.</changelogtext>
+        <changelogtext>The download directory setting has been reset.</changelogtext>
+    </changelogversion>
+
     <changelogversion changeDate="" versionName="r857">
         <changelogtext>[b]Important![/b] Delete after read has been updated.
             This means the value has been reset set to disabled.

+ 1 - 2
app/src/main/res/values/keys.xml

@@ -42,12 +42,11 @@
     <string name="pref_filter_downloaded_key">pref_filter_downloaded_key</string>
     <string name="pref_filter_unread_key">pref_filter_unread_key</string>
 
-    <string name="pref_download_directory_key">pref_download_directory_key</string>
+    <string name="pref_download_directory_key">download_directory</string>
     <string name="pref_download_slots_key">pref_download_slots_key</string>
     <string name="pref_remove_after_read_slots_key">remove_after_read_slots</string>
     <string name="pref_download_only_over_wifi_key">pref_download_only_over_wifi_key</string>
     <string name="pref_remove_after_marked_as_read_key">pref_remove_after_marked_as_read_key</string>
-    <string name="pref_category_remove_after_read_key">pref_category_remove_after_read_key</string>
     <string name="pref_last_used_category_key">last_used_category</string>
 
     <string name="pref_source_languages">pref_source_languages</string>

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

@@ -350,10 +350,12 @@
     <string name="information_empty_library">Empty library</string>
 
     <!-- Download Notification -->
+    <string name="download_notifier_downloader_title">Downloader</string>
     <string name="download_notifier_title_error">Error</string>
     <string name="download_notifier_unkown_error">An unexpected error occurred while downloading chapter</string>
     <string name="download_notifier_page_error">A page is missing in directory</string>
     <string name="download_notifier_page_ready_error">A page is not loaded</string>
     <string name="download_notifier_text_only_wifi">No wifi connection available</string>
+    <string name="download_notifier_no_network">No network connection available</string>
 
 </resources>