From 6f297161de6d062d4280dfd5352b3e5e76353df6 Mon Sep 17 00:00:00 2001 From: inorichi Date: Sun, 20 Nov 2016 11:20:57 +0100 Subject: [PATCH] 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 --- app/build.gradle | 7 +- .../tachiyomi/data/cache/ChapterCache.kt | 4 +- .../data/database/queries/ChapterQueries.kt | 9 + .../data/download/DownloadManager.kt | 564 +++++------------- .../data/download/DownloadNotifier.kt | 116 ++-- .../data/download/DownloadProvider.kt | 130 ++++ .../data/download/DownloadService.kt | 187 +++--- .../tachiyomi/data/download/DownloadStore.kt | 128 ++++ .../tachiyomi/data/download/Downloader.kt | 429 +++++++++++++ .../tachiyomi/data/download/model/Download.kt | 3 - .../data/download/model/DownloadQueue.kt | 39 +- .../data/preference/PreferencesHelper.kt | 18 +- .../tachiyomi/data/source/model/Page.kt | 7 +- .../data/source/online/OnlineSource.kt | 3 +- .../tachiyomi/ui/download/DownloadFragment.kt | 50 +- .../ui/download/DownloadPresenter.kt | 29 +- .../tachiyomi/ui/library/LibraryPresenter.kt | 11 +- .../ui/main/ChangelogDialogFragment.kt | 2 +- .../ui/manga/chapter/ChaptersPresenter.kt | 33 +- .../tachiyomi/ui/reader/ChapterLoader.kt | 25 +- .../tachiyomi/ui/reader/ReaderActivity.kt | 9 +- .../tachiyomi/ui/reader/ReaderPresenter.kt | 112 ++-- .../ui/reader/notification/ImageNotifier.kt | 40 +- .../ui/reader/viewer/base/BaseReader.kt | 4 +- .../ui/reader/viewer/pager/PageView.kt | 24 +- .../ui/reader/viewer/webtoon/WebtoonHolder.kt | 26 +- .../ui/reader/viewer/webtoon/WebtoonReader.kt | 4 +- .../recent_updates/RecentChaptersPresenter.kt | 42 +- .../ui/setting/SettingsDownloadsFragment.kt | 60 +- .../tachiyomi/util/ContextExtensions.kt | 16 +- .../eu/kanade/tachiyomi/util/UrlUtil.java | 36 -- app/src/main/res/raw/changelog_debug.xml | 8 + app/src/main/res/values/keys.xml | 3 +- app/src/main/res/values/strings.xml | 2 + 34 files changed, 1325 insertions(+), 855 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt diff --git a/app/build.gradle b/app/build.gradle index 1c3a399310..5f5531773d 100644 --- a/app/build.gradle +++ b/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' diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt index de36e85510..cc594d7fb1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt +++ b/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 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt index f2aa6d2d56..1d263d8822 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt +++ b/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) = db.put().objects(chapters).prepare() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt index 115e10bd63..cf9ea97c33 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt +++ b/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>() - val runningSubject = BehaviorSubject.create() - private var downloadsSubscription: Subscription? = null - - val downloadNotifier by lazy { DownloadNotifier(context) } - - private val threadsSubject = BehaviorSubject.create() - 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({ 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) { - 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() - val pending = ArrayList() - - 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?): Boolean { - return pages != null && !pages.isEmpty() && pages.size + 1 == directory.listFiles().size - } - - // Download the entire chapter - private fun downloadChapter(download: Download): Observable { - DiskUtils.createDirectory(download.directory) - - val pageListObservable: Observable> = 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()) - } - - // Get the image from the filesystem if it exists or download from network - private fun getOrDownloadImage(page: Page, download: Download): Observable { - // 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) - } - } - - // Save image on disk - private fun downloadImage(page: Page, source: OnlineSource, directory: File, filename: String): Observable { - 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 - } - 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 { - 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? { - val chapterDir = getAbsoluteChapterDirectory(source, manga, chapter) - val pagesFile = File(chapterDir, PAGE_LIST_FILE) - - return try { - JsonReader(FileReader(pagesFile)).use { - val collectionType = object : TypeToken>() {}.type - gson.fromJson(it, collectionType) - } - } catch (e: Exception) { - null - } - } - - // Shortcut for the method above - private fun getSavedPageList(download: Download): List? { - 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) { - 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) - } - - 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 - } +/** + * 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 + get() = downloader.runningRelay + + /** + * Tells the downloader to begin downloads. + * + * @return true if it's started, false otherwise (empty queue). + */ fun startDownloads(): Boolean { - if (queue.isEmpty()) - return false - - if (downloadsSubscription == null || downloadsSubscription!!.isUnsubscribed) - initializeSubscriptions() - - val pending = ArrayList() - 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() + return downloader.start() } - fun stopDownloads(errorMessage: String? = null) { - destroySubscriptions() - for (download in queue) { - if (download.status == Download.DOWNLOADING) { - download.status = Download.ERROR - } - } - errorMessage?.let { downloadNotifier.onError(it) } + /** + * 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) } + /** + * Empties the download queue. + */ fun clearQueue() { - queue.clear() - downloadNotifier.onClear() + downloader.clearQueue() + } + + /** + * 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) { + 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> { + 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> { + return Observable.fromCallable { + val pages = mutableListOf() + 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 + } + 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) { + provider.findChapterDir(source, manga, chapter)?.delete() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt index 2743988ee5..6fab6691a6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt +++ b/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 } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt new file mode 100644 index 0000000000..2e9182156b --- /dev/null +++ b/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: //// + * + * @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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.kt index 98c64113a6..0a65f21192 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.kt +++ b/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 = 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() - private var wakeLock: PowerManager.WakeLock? = null - private var networkChangeSubscription: Subscription? = null - private var queueRunningSubscription: Subscription? = null - private var isRunning: Boolean = false + /** + * 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") + } + + /** + * 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 + /** + * 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 */ } + } + } + + /** + * Listens to downloader status. Enables or disables the wake lock depending on the status. + */ + private fun listenDownloaderState() { + subscriptions += downloadManager.runningRelay.subscribe { running -> if (running) - acquireWakeLock() + wakeLock.acquireIfNeeded() else - releaseWakeLock() + wakeLock.releaseIfNeeded() } } - private fun createWakeLock() { - wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, "DownloadService:WakeLock") + /** + * Releases the wake lock if it's held. + */ + fun PowerManager.WakeLock.releaseIfNeeded() { + if (isHeld) release() } - private fun destroyWakeLock() { - if (wakeLock != null && wakeLock!!.isHeld) { - wakeLock!!.release() - wakeLock = null - } - } - - fun acquireWakeLock() { - if (wakeLock != null && !wakeLock!!.isHeld) { - wakeLock!!.acquire() - } - } - - 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() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadStore.kt new file mode 100644 index 0000000000..48253865f8 --- /dev/null +++ b/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) { + 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 { + val objs = preferences.all + .mapNotNull { it.value as? String } + .map { deserialize(it) } + .sortedBy { it.order } + + val downloads = mutableListOf() + if (objs.isNotEmpty()) { + val cachedManga = mutableMapOf() + 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) + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt new file mode 100644 index 0000000000..e1f5decb12 --- /dev/null +++ b/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() + + /** + * Relay to send a list of downloads to the downloader. + */ + private val downloadsRelay = PublishRelay.create>() + + /** + * Relay to subscribe to the downloader status. + */ + val runningRelay: BehaviorRelay = 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({ 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) { + 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 { + 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 { + // 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 { + 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 } + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt index e283d93f9c..48b2e0b623 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt +++ b/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? = null @Volatile @Transient var totalProgress: Int = 0 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt index 46d87ec497..7bf190feb3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt +++ b/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 = CopyOnWriteArrayList()) +class DownloadQueue( + private val store: DownloadStore, + private val queue: MutableList = CopyOnWriteArrayList()) : List by queue { private val statusSubject = PublishSubject.create() - private val removeSubject = PublishSubject.create() + private val updatedRelay = PublishRelay.create() - fun add(download: Download): Boolean { - download.setStatusSubject(statusSubject) - download.status = Download.QUEUE - return queue.add(download) + fun addAll(downloads: List) { + 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 = @@ -40,7 +53,9 @@ class DownloadQueue(private val queue: MutableList = CopyOnWriteArrayL fun getStatusObservable(): Observable = statusSubject.onBackpressureBuffer() - fun getRemovedObservable(): Observable = removeSubject.onBackpressureBuffer() + fun getUpdatedObservable(): Observable> = updatedRelay.onBackpressureBuffer() + .startWith(Unit) + .map { this } fun getProgressObservable(): Observable { return statusSubject.onBackpressureBuffer() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index c18c1cd0a0..97a66fe143 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/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 Preference.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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/model/Page.kt b/app/src/main/java/eu/kanade/tachiyomi/data/source/model/Page.kt index bfdf22fd3d..ac1dc84d42 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/model/Page.kt +++ b/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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/OnlineSource.kt b/app/src/main/java/eu/kanade/tachiyomi/data/source/online/OnlineSource.kt index 84f0970d8d..189efba344 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/OnlineSource.kt +++ b/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 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadFragment.kt index ca0b70ab75..39e4226f9b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadFragment.kt +++ b/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() { */ 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() { 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() { 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() { // 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() { */ 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() { * @param downloads the downloads from the queue. */ fun onNextDownloads(downloads: List) { + activity.supportInvalidateOptionsMenu() + setInformationView() adapter.setItems(downloads) } - fun onDownloadRemoved(position: Int) { - adapter.notifyItemRemoved(position) - } - /** * Called when the progress of a download changes. * diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadPresenter.kt index d02582a5cc..6e71eb585b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadPresenter.kt @@ -29,36 +29,21 @@ class DownloadPresenter : BasePresenter() { override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) - - Observable.just(ArrayList(downloadQueue)) - .doOnNext { syncQueue(it) } - .subscribeLatestCache({ view, downloads -> - view.onNextDownloads(downloads) - }, { view, error -> + + downloadQueue.getUpdatedObservable() + .observeOn(AndroidSchedulers.mainThread()) + .map { ArrayList(it) } + .subscribeLatestCache(DownloadFragment::onNextDownloads, { view, error -> Timber.e(error) }) } - private fun syncQueue(queue: MutableList) { - add(downloadQueue.getRemovedObservable() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { download -> - val position = queue.indexOf(download) - if (position != -1) { - queue.removeAt(position) - - @Suppress("DEPRECATION") - view?.onDownloadRemoved(position) - } - }) - } - - fun getStatusObservable(): Observable { + fun getDownloadStatusObservable(): Observable { return downloadQueue.getStatusObservable() .startWith(downloadQueue.getActiveDownloads()) } - fun getProgressObservable(): Observable { + fun getDownloadProgressObservable(): Observable { return downloadQueue.getProgressObservable() .onBackpressureBuffer() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt index 51a81cc9e5..1aba27e3dc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt @@ -185,15 +185,10 @@ class LibraryPresenter : BasePresenter() { } if (prefFilterDownloaded) { - val mangaDir = downloadManager.getAbsoluteMangaDirectory(source, manga) + val mangaDir = downloadManager.findMangaDir(source, manga) - if (mangaDir.exists()) { - for (file in mangaDir.listFiles()) { - if (file.isDirectory && file.listFiles().isNotEmpty()) { - hasDownloaded = true - break - } - } + if (mangaDir != null) { + hasDownloaded = mangaDir.listFiles()?.any { it.isDirectory } ?: false } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogFragment.kt index 526f0fc0ab..bed71e7e6f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogFragment.kt +++ b/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() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt index 1a669426e5..b98d6e200c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersPresenter.kt @@ -132,6 +132,9 @@ class ChaptersPresenter : BasePresenter() { 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() { 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) { + val files = downloadManager.findMangaDir(source, manga)?.listFiles() ?: return + val cached = mutableMapOf() + 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() { * @param chapters the list of chapters to delete. */ fun deleteChapters(chapters: List) { - val wasRunning = downloadManager.isRunning - if (wasRunning) { - DownloadService.stop(context) - } Observable.from(chapters) .doOnNext { deleteChapter(it) } .toList() @@ -330,9 +338,6 @@ class ChaptersPresenter : BasePresenter() { .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() { * @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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoader.kt index e7c8a35b9b..0da76894a9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ChapterLoader.kt +++ b/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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index 4f8634b871..ac7a86a7f2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/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() { 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() { 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() { } 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() { 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" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt index 3f94a3f629..ccfedecc45 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt +++ b/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() { */ 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() { 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() { 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() { */ 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() { 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() { } /** - * 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) + }) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/notification/ImageNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/notification/ImageNotifier.kt index a4edfcb52e..ca49eece42 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/notification/ImageNotifier.kt +++ b/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(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?) { - 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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/BaseReader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/BaseReader.kt index 7ec34b4061..84ddde927b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/base/BaseReader.kt +++ b/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 } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PageView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PageView.kt index 25b1726379..28ce3ac75a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PageView.kt +++ b/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)) - } else { + 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)) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonHolder.kt index c7e6ff400d..6a18a6ca98 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonHolder.kt +++ b/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)) - } else { + 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)) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonReader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonReader.kt index 41fb2ac0a9..f3e65e2f30 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonReader.kt +++ b/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) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt index 219b6b8071..bac1e9eb91 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt +++ b/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() { .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() { // 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) { + val cachedDirs = mutableMapOf() + + 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() { * @param chapters list of chapters */ fun deleteChapters(chapters: List) { - val wasRunning = downloadManager.isRunning - if (wasRunning) { - DownloadService.stop(context) - } Observable.from(chapters) .doOnNext { deleteChapter(it) } .toList() @@ -218,9 +229,6 @@ class RecentChaptersPresenter : BasePresenter() { .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() { */ 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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadsFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadsFragment.kt index 3168cb1baa..327478b49a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadsFragment.kt +++ b/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) + 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_CODE) + 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 { @@ -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()) + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt index e073ddb0d0..f07a0533c2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt +++ b/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 \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/UrlUtil.java b/app/src/main/java/eu/kanade/tachiyomi/util/UrlUtil.java index b877867bda..5918013c63 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/UrlUtil.java +++ b/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; - } } diff --git a/app/src/main/res/raw/changelog_debug.xml b/app/src/main/res/raw/changelog_debug.xml index 345c219278..2393bc9194 100644 --- a/app/src/main/res/raw/changelog_debug.xml +++ b/app/src/main/res/raw/changelog_debug.xml @@ -1,6 +1,14 @@ + + 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. + + You can now download to any folder in your SD card. + The download directory setting has been reset. + + [b]Important![/b] Delete after read has been updated. This means the value has been reset set to disabled. diff --git a/app/src/main/res/values/keys.xml b/app/src/main/res/values/keys.xml index f3694dc7ad..31da7fa4ba 100644 --- a/app/src/main/res/values/keys.xml +++ b/app/src/main/res/values/keys.xml @@ -42,12 +42,11 @@ pref_filter_downloaded_key pref_filter_unread_key - pref_download_directory_key + download_directory pref_download_slots_key remove_after_read_slots pref_download_only_over_wifi_key pref_remove_after_marked_as_read_key - pref_category_remove_after_read_key last_used_category pref_source_languages diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aff0608169..6a877440e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -350,10 +350,12 @@ Empty library + Downloader Error An unexpected error occurred while downloading chapter A page is missing in directory A page is not loaded No wifi connection available + No network connection available