From aaef738dda297eb75f8b2c61dd46711524c449cb Mon Sep 17 00:00:00 2001 From: len Date: Sat, 19 Mar 2016 00:11:34 +0100 Subject: [PATCH] Download manager in Kotlin and fix another crash in reader --- .../data/download/DownloadManager.java | 450 ------------------ .../data/download/DownloadManager.kt | 434 +++++++++++++++++ .../data/download/DownloadService.java | 151 ------ .../data/download/DownloadService.kt | 146 ++++++ .../data/download/model/DownloadQueue.java | 78 --- .../data/download/model/DownloadQueue.kt | 65 +++ .../ui/download/DownloadPresenter.kt | 6 +- .../tachiyomi/ui/library/LibraryPresenter.kt | 2 +- .../ui/manga/chapter/ChaptersPresenter.kt | 4 +- .../tachiyomi/ui/reader/ReaderActivity.kt | 2 +- .../ui/recent/RecentChaptersPresenter.kt | 8 +- 11 files changed, 656 insertions(+), 690 deletions(-) delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.java create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.java create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.java create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.java b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.java deleted file mode 100644 index 3b436c72f1..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.java +++ /dev/null @@ -1,450 +0,0 @@ -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 java.io.File; -import java.io.FileOutputStream; -import java.io.FileReader; -import java.io.IOException; -import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.List; - -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.base.Source; -import eu.kanade.tachiyomi.data.source.model.Page; -import eu.kanade.tachiyomi.event.DownloadChaptersEvent; -import eu.kanade.tachiyomi.util.DiskUtils; -import eu.kanade.tachiyomi.util.DynamicConcurrentMergeOperator; -import eu.kanade.tachiyomi.util.ToastUtil; -import eu.kanade.tachiyomi.util.UrlUtil; -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; - -public class DownloadManager { - - private Context context; - private SourceManager sourceManager; - private PreferencesHelper preferences; - private Gson gson; - - private PublishSubject> downloadsQueueSubject; - private BehaviorSubject runningSubject; - private Subscription downloadsSubscription; - - private BehaviorSubject threadsSubject; - private Subscription threadsSubscription; - - private DownloadQueue queue; - private volatile boolean isRunning; - - public static final String PAGE_LIST_FILE = "index.json"; - - public DownloadManager(Context context, SourceManager sourceManager, PreferencesHelper preferences) { - this.context = context; - this.sourceManager = sourceManager; - this.preferences = preferences; - - gson = new Gson(); - queue = new DownloadQueue(); - - downloadsQueueSubject = PublishSubject.create(); - runningSubject = BehaviorSubject.create(); - threadsSubject = BehaviorSubject.create(); - } - - private void initializeSubscriptions() { - if (downloadsSubscription != null && !downloadsSubscription.isUnsubscribed()) - downloadsSubscription.unsubscribe(); - - threadsSubscription = preferences.downloadThreads().asObservable() - .subscribe(threadsSubject::onNext); - - downloadsSubscription = downloadsQueueSubject - .flatMap(Observable::from) - .lift(new DynamicConcurrentMergeOperator<>(this::downloadChapter, threadsSubject)) - .onBackpressureBuffer() - .observeOn(AndroidSchedulers.mainThread()) - .map(download -> areAllDownloadsFinished()) - .subscribe(finished -> { - if (finished) { - DownloadService.stop(context); - } - }, e -> { - DownloadService.stop(context); - Timber.e(e, e.getMessage()); - ToastUtil.showShort(context, e.getMessage()); - }); - - if (!isRunning) { - isRunning = true; - runningSubject.onNext(true); - } - } - - public void destroySubscriptions() { - if (isRunning) { - isRunning = false; - runningSubject.onNext(false); - } - - if (downloadsSubscription != null && !downloadsSubscription.isUnsubscribed()) { - downloadsSubscription.unsubscribe(); - downloadsSubscription = null; - } - - if (threadsSubscription != null && !threadsSubscription.isUnsubscribed()) { - threadsSubscription.unsubscribe(); - } - - } - - // Create a download object for every chapter in the event and add them to the downloads queue - public void onDownloadChaptersEvent(DownloadChaptersEvent event) { - final Manga manga = event.getManga(); - final Source source = sourceManager.get(manga.source); - - // Used to avoid downloading chapters with the same name - final List addedChapters = new ArrayList<>(); - final List pending = new ArrayList<>(); - - for (Chapter chapter : event.getChapters()) { - if (addedChapters.contains(chapter.name)) - continue; - - addedChapters.add(chapter.name); - Download download = new Download(source, manga, chapter); - - if (!prepareDownload(download)) { - queue.add(download); - pending.add(download); - } - } - if (isRunning) downloadsQueueSubject.onNext(pending); - } - - // Public method to check if a chapter is downloaded - public boolean isChapterDownloaded(Source source, Manga manga, Chapter chapter) { - File directory = getAbsoluteChapterDirectory(source, manga, chapter); - if (!directory.exists()) - return false; - - List pages = getSavedPageList(source, manga, chapter); - return isChapterDownloaded(directory, pages); - } - - // Prepare the download. Returns true if the chapter is already downloaded - private boolean prepareDownload(Download download) { - // If the chapter is already queued, don't add it again - for (Download queuedDownload : queue) { - if (download.chapter.id.equals(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 - List savedPages = getSavedPageList(download); - if (savedPages == null) - 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 boolean isChapterDownloaded(File directory, List pages) { - return pages != null && !pages.isEmpty() && pages.size() + 1 == directory.listFiles().length; - } - - // Download the entire chapter - private Observable downloadChapter(Download download) { - try { - DiskUtils.createDirectory(download.directory); - } catch (IOException e) { - return Observable.error(e); - } - - Observable> pageListObservable = download.pages == null ? - // Pull page list from network and add them to download object - download.source - .pullPageListFromNetwork(download.chapter.url) - .doOnNext(pages -> download.pages = pages) - .doOnNext(pages -> savePageList(download)) : - // 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.setStatus(Download.DOWNLOADING); - }) - // Get all the URLs to the source images, fetch pages if necessary - .flatMap(download.source::getAllImageUrlsFromPageList) - // Start downloading images, consider we can have downloaded images already - .concatMap(page -> getOrDownloadImage(page, download)) - // Do after download completes - .doOnCompleted(() -> onDownloadCompleted(download)) - .toList() - .map(pages -> download) - // If the page list threw, it will resume here - .onErrorResumeNext(error -> { - download.setStatus(Download.ERROR); - return Observable.just(download); - })) - .subscribeOn(Schedulers.io()); - } - - // Get the image from the filesystem if it exists or download from network - private Observable getOrDownloadImage(final Page page, Download download) { - // If the image URL is empty, do nothing - if (page.getImageUrl() == null) - return Observable.just(page); - - String filename = getImageFilename(page); - File imagePath = new File(download.directory, filename); - - // If the image is already downloaded, do nothing. Otherwise download from network - Observable pageObservable = isImageDownloaded(imagePath) ? - Observable.just(page) : - downloadImage(page, download.source, download.directory, filename); - - return pageObservable - // When the image is ready, set image path, progress (just in case) and status - .doOnNext(p -> { - page.setImagePath(imagePath.getAbsolutePath()); - page.setProgress(100); - download.downloadedImages++; - page.setStatus(Page.READY); - }) - // Mark this page as error and allow to download the remaining - .onErrorResumeNext(e -> { - page.setProgress(0); - page.setStatus(Page.ERROR); - return Observable.just(page); - }); - } - - // Save image on disk - private Observable downloadImage(Page page, Source source, File directory, String filename) { - page.setStatus(Page.DOWNLOAD_IMAGE); - return source.getImageProgressResponse(page) - .flatMap(resp -> { - try { - DiskUtils.saveBufferedSourceToDirectory(resp.body().source(), directory, filename); - } catch (Exception e) { - Timber.e(e.getCause(), e.getMessage()); - return Observable.error(e); - } - return Observable.just(page); - }) - .retry(2); - } - - // Public method to get the image from the filesystem. It does NOT provide any way to download the image - public Observable getDownloadedImage(final Page page, File chapterDir) { - if (page.getImageUrl() == null) { - page.setStatus(Page.ERROR); - return Observable.just(page); - } - - File imagePath = new File(chapterDir, getImageFilename(page)); - - // When the image is ready, set image path, progress (just in case) and status - if (isImageDownloaded(imagePath)) { - page.setImagePath(imagePath.getAbsolutePath()); - page.setProgress(100); - page.setStatus(Page.READY); - } else { - page.setStatus(Page.ERROR); - } - return Observable.just(page); - } - - // Get the filename for an image given the page - private String getImageFilename(Page page) { - String url = page.getImageUrl(); - int number = page.getPageNumber() + 1; - // Try to preserve file extension - if (UrlUtil.isJpg(url)) { - return number + ".jpg"; - } else if (UrlUtil.isPng(url)) { - return number + ".png"; - } else if (UrlUtil.isGif(url)) { - return number + ".gif"; - } - return Uri.parse(url).getLastPathSegment().replaceAll("[^\\sa-zA-Z0-9.-]", "_"); - } - - private boolean isImageDownloaded(File imagePath) { - return imagePath.exists(); - } - - // Called when a download finishes. This doesn't mean the download was successful, so we check it - private void onDownloadCompleted(final Download download) { - checkDownloadIsSuccessful(download); - savePageList(download); - } - - private void checkDownloadIsSuccessful(final Download download) { - int actualProgress = 0; - int status = Download.DOWNLOADED; - // If any page has an error, the download result will be error - for (Page page : download.pages) { - actualProgress += page.getProgress(); - if (page.getStatus() != Page.READY) status = Download.ERROR; - } - // Ensure that the chapter folder has all the images - if (!isChapterDownloaded(download.directory, download.pages)) { - status = Download.ERROR; - } - download.totalProgress = actualProgress; - download.setStatus(status); - // Delete successful downloads from queue after notifying - if (status == Download.DOWNLOADED) { - queue.remove(download); - } - } - - // Return the page list from the chapter's directory if it exists, null otherwise - public List getSavedPageList(Source source, Manga manga, Chapter chapter) { - File chapterDir = getAbsoluteChapterDirectory(source, manga, chapter); - File pagesFile = new File(chapterDir, PAGE_LIST_FILE); - - JsonReader reader = null; - try { - if (pagesFile.exists()) { - reader = new JsonReader(new FileReader(pagesFile.getAbsolutePath())); - Type collectionType = new TypeToken>() {}.getType(); - return gson.fromJson(reader, collectionType); - } - } catch (Exception e) { - Timber.e(e.getCause(), e.getMessage()); - } finally { - if (reader != null) try { reader.close(); } catch (IOException e) { /* Do nothing */ } - } - return null; - } - - // Shortcut for the method above - private List getSavedPageList(Download download) { - return getSavedPageList(download.source, download.manga, download.chapter); - } - - // Save the page list to the chapter's directory - public void savePageList(Source source, Manga manga, Chapter chapter, List pages) { - File chapterDir = getAbsoluteChapterDirectory(source, manga, chapter); - File pagesFile = new File(chapterDir, PAGE_LIST_FILE); - - FileOutputStream out = null; - try { - out = new FileOutputStream(pagesFile); - out.write(gson.toJson(pages).getBytes()); - out.flush(); - } catch (IOException e) { - Timber.e(e.getCause(), e.getMessage()); - } finally { - if (out != null) try { out.close(); } catch (IOException e) { /* Do nothing */ } - } - } - - // Shortcut for the method above - private void savePageList(Download download) { - savePageList(download.source, download.manga, download.chapter, download.pages); - } - - public File getAbsoluteMangaDirectory(Source source, Manga manga) { - String mangaRelativePath = source.getVisibleName() + - File.separator + - manga.title.replaceAll("[^\\sa-zA-Z0-9.-]", "_"); - - return new File(preferences.getDownloadsDirectory(), mangaRelativePath); - } - - // Get the absolute path to the chapter directory - public File getAbsoluteChapterDirectory(Source source, Manga manga, Chapter chapter) { - String chapterRelativePath = chapter.name.replaceAll("[^\\sa-zA-Z0-9.-]", "_"); - - return new File(getAbsoluteMangaDirectory(source, manga), chapterRelativePath); - } - - // Shortcut for the method above - private File getAbsoluteChapterDirectory(Download download) { - return getAbsoluteChapterDirectory(download.source, download.manga, download.chapter); - } - - public void deleteChapter(Source source, Manga manga, Chapter chapter) { - File path = getAbsoluteChapterDirectory(source, manga, chapter); - DiskUtils.deleteFiles(path); - } - - public DownloadQueue getQueue() { - return queue; - } - - public boolean areAllDownloadsFinished() { - for (Download download : queue) { - if (download.getStatus() <= Download.DOWNLOADING) - return false; - } - return true; - } - - public boolean startDownloads() { - if (queue.isEmpty()) - return false; - - if (downloadsSubscription == null || downloadsSubscription.isUnsubscribed()) - initializeSubscriptions(); - - final List pending = new ArrayList<>(); - for (Download download : queue) { - if (download.getStatus() != Download.DOWNLOADED) { - if (download.getStatus() != Download.QUEUE) download.setStatus(Download.QUEUE); - pending.add(download); - } - } - downloadsQueueSubject.onNext(pending); - - return !pending.isEmpty(); - } - - public void stopDownloads() { - destroySubscriptions(); - for (Download download : queue) { - if (download.getStatus() == Download.DOWNLOADING) { - download.setStatus(Download.ERROR); - } - } - } - - public BehaviorSubject getRunningSubject() { - return runningSubject; - } - -} 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 new file mode 100644 index 0000000000..46c6f69ce3 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt @@ -0,0 +1,434 @@ +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.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.base.Source +import eu.kanade.tachiyomi.data.source.model.Page +import eu.kanade.tachiyomi.event.DownloadChaptersEvent +import eu.kanade.tachiyomi.util.DiskUtils +import eu.kanade.tachiyomi.util.DynamicConcurrentMergeOperator +import eu.kanade.tachiyomi.util.UrlUtil +import eu.kanade.tachiyomi.util.toast +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 java.io.File +import java.io.FileOutputStream +import java.io.FileReader +import java.io.IOException +import java.util.* + +class DownloadManager(private val context: Context, private val sourceManager: SourceManager, private val preferences: PreferencesHelper) { + + private val gson = Gson() + + private val downloadsQueueSubject = PublishSubject.create>() + val runningSubject = BehaviorSubject.create() + private var downloadsSubscription: Subscription? = null + + 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 private var isRunning: Boolean = false + + private fun initializeSubscriptions() { + downloadsSubscription?.unsubscribe() + + threadsSubscription = preferences.downloadThreads().asObservable() + .subscribe { threadsSubject.onNext(it) } + + downloadsSubscription = downloadsQueueSubject.flatMap { Observable.from(it) } + .lift(DynamicConcurrentMergeOperator({ downloadChapter(it) }, threadsSubject)) + .onBackpressureBuffer() + .observeOn(AndroidSchedulers.mainThread()) + .map { download -> areAllDownloadsFinished() } + .subscribe({ finished -> + if (finished!!) { + DownloadService.stop(context) + } + }, { e -> + DownloadService.stop(context) + Timber.e(e, e.message) + context.toast(e.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 in the event and add them to the downloads queue + fun onDownloadChaptersEvent(event: DownloadChaptersEvent) { + val manga = event.manga + val source = sourceManager.get(manga.source) + + // Used to avoid downloading chapters with the same name + val addedChapters = ArrayList() + val pending = ArrayList() + + for (chapter in event.chapters) { + 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) + } + } + 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 { + try { + DiskUtils.createDirectory(download.directory) + } catch (e: IOException) { + return Observable.error(e) + } + + val pageListObservable = if (download.pages == null) + // Pull page list from network and add them to download object + download.source.pullPageListFromNetwork(download.chapter.url) + .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.getAllImageUrlsFromPageList(it) } + // Start downloading images, consider we can have downloaded images already + .concatMap { page -> getOrDownloadImage(page, download) } + // 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 + 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: Source, directory: File, filename: String): Observable { + page.status = Page.DOWNLOAD_IMAGE + return source.getImageProgressResponse(page) + .flatMap({ resp -> + try { + DiskUtils.saveBufferedSourceToDirectory(resp.body().source(), directory, filename) + Observable.just(page) + } catch (e: Exception) { + Timber.e(e.cause, e.message) + Observable.error(e) + } + }).retry(2) + } + + // 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 + private fun getImageFilename(page: Page): String { + val url = page.imageUrl + val number = page.pageNumber + 1 + // Try to preserve file extension + if (UrlUtil.isJpg(url)) { + return "$number.jpg" + } else if (UrlUtil.isPng(url)) { + return "$number.png" + } else if (UrlUtil.isGif(url)) { + return "$number.gif" + } + return 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 + } + // Ensure that the chapter folder has all the images + if (!isChapterDownloaded(download.directory, download.pages)) { + status = Download.ERROR + } + download.totalProgress = actualProgress + download.status = status + // Delete successful downloads from queue after notifying + if (status == Download.DOWNLOADED) { + queue.del(download) + } + } + + // 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) + + var reader: JsonReader? = null + try { + if (pagesFile.exists()) { + reader = JsonReader(FileReader(pagesFile.absolutePath)) + val collectionType = object : TypeToken>() { + + }.type + return gson.fromJson>(reader, collectionType) + } + } catch (e: Exception) { + Timber.e(e.cause, e.message) + } finally { + if (reader != null) try { + reader.close() + } catch (e: IOException) { + /* Do nothing */ + } + + } + return 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) + + var out: FileOutputStream? = null + try { + out = FileOutputStream(pagesFile) + out.write(gson.toJson(pages).toByteArray()) + out.flush() + } catch (e: IOException) { + Timber.e(e.cause, e.message) + } finally { + if (out != null) try { + out.close() + } catch (e: IOException) { + /* Do nothing */ + } + + } + } + + // 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.visibleName + + File.separator + + manga.title.replace("[^\\sa-zA-Z0-9.-]".toRegex(), "_") + + return File(preferences.downloadsDirectory, 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 + } + + 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() + } + + fun stopDownloads() { + destroySubscriptions() + for (download in queue) { + if (download.status == Download.DOWNLOADING) { + download.status = Download.ERROR + } + } + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.java b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.java deleted file mode 100644 index e232e59e01..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.java +++ /dev/null @@ -1,151 +0,0 @@ -package eu.kanade.tachiyomi.data.download; - -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.os.IBinder; -import android.os.PowerManager; - -import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork; - -import org.greenrobot.eventbus.EventBus; -import org.greenrobot.eventbus.Subscribe; -import org.greenrobot.eventbus.ThreadMode; - -import javax.inject.Inject; - -import eu.kanade.tachiyomi.App; -import eu.kanade.tachiyomi.R; -import eu.kanade.tachiyomi.data.preference.PreferencesHelper; -import eu.kanade.tachiyomi.event.DownloadChaptersEvent; -import eu.kanade.tachiyomi.util.ToastUtil; -import rx.Subscription; -import rx.android.schedulers.AndroidSchedulers; -import rx.schedulers.Schedulers; - -public class DownloadService extends Service { - - @Inject DownloadManager downloadManager; - @Inject PreferencesHelper preferences; - - private PowerManager.WakeLock wakeLock; - private Subscription networkChangeSubscription; - private Subscription queueRunningSubscription; - private boolean isRunning; - - public static void start(Context context) { - context.startService(new Intent(context, DownloadService.class)); - } - - public static void stop(Context context) { - context.stopService(new Intent(context, DownloadService.class)); - } - - @Override - public void onCreate() { - super.onCreate(); - App.get(this).getComponent().inject(this); - - createWakeLock(); - - listenQueueRunningChanges(); - EventBus.getDefault().register(this); - listenNetworkChanges(); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - return START_STICKY; - } - - @Override - public void onDestroy() { - EventBus.getDefault().unregister(this); - queueRunningSubscription.unsubscribe(); - networkChangeSubscription.unsubscribe(); - downloadManager.destroySubscriptions(); - destroyWakeLock(); - super.onDestroy(); - } - - @Override - public IBinder onBind(Intent intent) { - return null; - } - - @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) - public void onEvent(DownloadChaptersEvent event) { - EventBus.getDefault().removeStickyEvent(event); - downloadManager.onDownloadChaptersEvent(event); - } - - private void listenNetworkChanges() { - networkChangeSubscription = new ReactiveNetwork().enableInternetCheck() - .observeConnectivity(getApplicationContext()) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(state -> { - switch (state) { - case WIFI_CONNECTED_HAS_INTERNET: - // If there are no remaining downloads, destroy the service - if (!isRunning && !downloadManager.startDownloads()) { - stopSelf(); - } - break; - case MOBILE_CONNECTED: - if (!preferences.downloadOnlyOverWifi()) { - if (!isRunning && !downloadManager.startDownloads()) { - stopSelf(); - } - } else if (isRunning) { - downloadManager.stopDownloads(); - } - break; - default: - if (isRunning) { - downloadManager.stopDownloads(); - } - break; - } - }, error -> { - ToastUtil.showShort(this, R.string.download_queue_error); - stopSelf(); - }); - } - - private void listenQueueRunningChanges() { - queueRunningSubscription = downloadManager.getRunningSubject() - .subscribe(running -> { - isRunning = running; - if (running) - acquireWakeLock(); - else - releaseWakeLock(); - }); - } - - private void createWakeLock() { - wakeLock = ((PowerManager)getSystemService(POWER_SERVICE)).newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, "DownloadService:WakeLock"); - } - - private void destroyWakeLock() { - if (wakeLock != null && wakeLock.isHeld()) { - wakeLock.release(); - wakeLock = null; - } - } - - public void acquireWakeLock() { - if (wakeLock != null && !wakeLock.isHeld()) { - wakeLock.acquire(); - } - } - - public void releaseWakeLock() { - if (wakeLock != null && wakeLock.isHeld()) { - wakeLock.release(); - } - } - -} 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 new file mode 100644 index 0000000000..50636a1160 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.kt @@ -0,0 +1,146 @@ +package eu.kanade.tachiyomi.data.download + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.os.PowerManager +import com.github.pwittchen.reactivenetwork.library.ConnectivityStatus +import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork +import eu.kanade.tachiyomi.App +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.event.DownloadChaptersEvent +import eu.kanade.tachiyomi.util.toast +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import rx.Subscription +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers +import javax.inject.Inject + +class DownloadService : Service() { + + companion object { + + fun start(context: Context) { + context.startService(Intent(context, DownloadService::class.java)) + } + + fun stop(context: Context) { + context.stopService(Intent(context, DownloadService::class.java)) + } + } + + @Inject lateinit var downloadManager: DownloadManager + @Inject lateinit var preferences: PreferencesHelper + + private var wakeLock: PowerManager.WakeLock? = null + private var networkChangeSubscription: Subscription? = null + private var queueRunningSubscription: Subscription? = null + private var isRunning: Boolean = false + + override fun onCreate() { + super.onCreate() + App.get(this).component.inject(this) + + createWakeLock() + + listenQueueRunningChanges() + EventBus.getDefault().register(this) + listenNetworkChanges() + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + return Service.START_STICKY + } + + override fun onDestroy() { + EventBus.getDefault().unregister(this) + queueRunningSubscription?.unsubscribe() + networkChangeSubscription?.unsubscribe() + downloadManager.destroySubscriptions() + destroyWakeLock() + super.onDestroy() + } + + override fun onBind(intent: Intent): IBinder? { + return null + } + + @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) + fun onEvent(event: DownloadChaptersEvent) { + EventBus.getDefault().removeStickyEvent(event) + downloadManager.onDownloadChaptersEvent(event) + } + + private fun listenNetworkChanges() { + networkChangeSubscription = ReactiveNetwork().enableInternetCheck() + .observeConnectivity(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() + } + } + else -> { + if (isRunning) { + downloadManager.stopDownloads() + } + } + } + }, { error -> + toast(R.string.download_queue_error) + stopSelf() + }) + } + + private fun listenQueueRunningChanges() { + queueRunningSubscription = downloadManager.runningSubject.subscribe { running -> + isRunning = running + if (running) + acquireWakeLock() + else + releaseWakeLock() + } + } + + private fun createWakeLock() { + wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, "DownloadService:WakeLock") + } + + private fun destroyWakeLock() { + if (wakeLock != null && wakeLock!!.isHeld) { + wakeLock!!.release() + wakeLock = null + } + } + + fun acquireWakeLock() { + if (wakeLock != null && !wakeLock!!.isHeld) { + wakeLock!!.acquire() + } + } + + fun releaseWakeLock() { + if (wakeLock != null && wakeLock!!.isHeld) { + wakeLock!!.release() + } + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.java b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.java deleted file mode 100644 index c4c39a2ff0..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.java +++ /dev/null @@ -1,78 +0,0 @@ -package eu.kanade.tachiyomi.data.download.model; - -import java.util.ArrayList; -import java.util.List; - -import eu.kanade.tachiyomi.data.database.models.Chapter; -import eu.kanade.tachiyomi.data.source.model.Page; -import rx.Observable; -import rx.subjects.PublishSubject; - -public class DownloadQueue extends ArrayList { - - private PublishSubject statusSubject; - - public DownloadQueue() { - super(); - statusSubject = PublishSubject.create(); - } - - public boolean add(Download download) { - download.setStatusSubject(statusSubject); - download.setStatus(Download.QUEUE); - return super.add(download); - } - - public void remove(Download download) { - super.remove(download); - download.setStatusSubject(null); - } - - public void remove(Chapter chapter) { - for (Download download : this) { - if (download.chapter.id.equals(chapter.id)) { - remove(download); - break; - } - } - } - - public Observable getActiveDownloads() { - return Observable.from(this) - .filter(download -> download.getStatus() == Download.DOWNLOADING); - } - - public Observable getStatusObservable() { - return statusSubject.onBackpressureBuffer(); - } - - public Observable getProgressObservable() { - return statusSubject.onBackpressureBuffer() - .startWith(getActiveDownloads()) - .flatMap(download -> { - if (download.getStatus() == Download.DOWNLOADING) { - PublishSubject pageStatusSubject = PublishSubject.create(); - setPagesSubject(download.pages, pageStatusSubject); - return pageStatusSubject - .filter(status -> status == Page.READY) - .map(status -> download); - - } else if (download.getStatus() == Download.DOWNLOADED || - download.getStatus() == Download.ERROR) { - - setPagesSubject(download.pages, null); - } - return Observable.just(download); - }) - .filter(download -> download.getStatus() == Download.DOWNLOADING); - } - - private void setPagesSubject(List pages, PublishSubject subject) { - if (pages != null) { - for (Page page : pages) { - page.setStatusSubject(subject); - } - } - } - -} 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 new file mode 100644 index 0000000000..7d3a94c2a5 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt @@ -0,0 +1,65 @@ +package eu.kanade.tachiyomi.data.download.model + +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.source.model.Page +import rx.Observable +import rx.subjects.PublishSubject +import java.util.* + +class DownloadQueue : ArrayList() { + + private val statusSubject = PublishSubject.create() + + override fun add(download: Download): Boolean { + download.setStatusSubject(statusSubject) + download.status = Download.QUEUE + return super.add(download) + } + + fun del(download: Download) { + super.remove(download) + download.setStatusSubject(null) + } + + fun del(chapter: Chapter) { + for (download in this) { + if (download.chapter.id == chapter.id) { + del(download) + break + } + } + } + + fun getActiveDownloads() = + Observable.from(this).filter { download -> download.status == Download.DOWNLOADING } + + fun getStatusObservable() = statusSubject.onBackpressureBuffer() + + fun getProgressObservable(): Observable { + return statusSubject.onBackpressureBuffer() + .startWith(getActiveDownloads()) + .flatMap { download -> + if (download.status == Download.DOWNLOADING) { + val pageStatusSubject = PublishSubject.create() + setPagesSubject(download.pages, pageStatusSubject) + return@flatMap pageStatusSubject + .filter { it == Page.READY } + .map { download } + + } else if (download.status == Download.DOWNLOADED || download.status == Download.ERROR) { + setPagesSubject(download.pages, null) + } + Observable.just(download) + } + .filter { it.status == Download.DOWNLOADING } + } + + private fun setPagesSubject(pages: List?, subject: PublishSubject?) { + if (pages != null) { + for (page in pages) { + page.setStatusSubject(subject) + } + } + } + +} 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 e4cb1d29fc..509ffc8ad3 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 @@ -68,14 +68,14 @@ class DownloadPresenter : BasePresenter() { override fun onTakeView(view: DownloadFragment) { super.onTakeView(view) - statusSubscription = downloadQueue.statusObservable - .startWith(downloadQueue.activeDownloads) + statusSubscription = downloadQueue.getStatusObservable() + .startWith(downloadQueue.getActiveDownloads()) .observeOn(AndroidSchedulers.mainThread()) .subscribe { processStatus(it, view) } add(statusSubscription) - pageProgressSubscription = downloadQueue.progressObservable + pageProgressSubscription = downloadQueue.getProgressObservable() .onBackpressureBuffer() .observeOn(AndroidSchedulers.mainThread()) .subscribe { view.onUpdateDownloadedPages(it) } 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 7e0bf1bf60..78d72e8635 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 @@ -174,7 +174,7 @@ class LibraryPresenter : BasePresenter() { } if (prefFilterDownloaded) { - val mangaDir = downloadManager.getAbsoluteMangaDirectory(sourceManager.get(manga.source), manga) + val mangaDir = downloadManager.getAbsoluteMangaDirectory(sourceManager.get(manga.source)!!, manga) if (mangaDir.exists()) { for (file in mangaDir.listFiles()) { 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 9dc5cc5b07..d287def26c 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 @@ -130,7 +130,7 @@ class ChaptersPresenter : BasePresenter() { } fun getChapterStatusObs(): Observable { - return downloadManager.queue.statusObservable + return downloadManager.queue.getStatusObservable() .observeOn(AndroidSchedulers.mainThread()) .filter { download -> download.manga.id == manga.id } .doOnNext { updateChapterStatus(it) } @@ -214,7 +214,7 @@ class ChaptersPresenter : BasePresenter() { fun deleteChapters(selectedChapters: Observable) { add(selectedChapters.subscribe( - { chapter -> downloadManager.queue.remove(chapter) }, + { chapter -> downloadManager.queue.del(chapter) }, { error -> Timber.e(error.message) }, { if (onlyDownloaded()) 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 cd65ec4941..a567891a66 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 @@ -243,7 +243,7 @@ class ReaderActivity : BaseRxActivity() { } @Suppress("UNUSED_PARAMETER") - fun onAdjacentChapters(previous: Chapter, next: Chapter) { + fun onAdjacentChapters(previous: Chapter?, next: Chapter?) { setAdjacentChaptersVisibility() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/RecentChaptersPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/RecentChaptersPresenter.kt index e797afb2cf..2f1a523f6c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/RecentChaptersPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/RecentChaptersPresenter.kt @@ -88,7 +88,7 @@ class RecentChaptersPresenter : BasePresenter() { * @return download object containing download progress. */ private fun getChapterStatusObs(): Observable { - return downloadManager.queue.statusObservable + return downloadManager.queue.getStatusObservable() .observeOn(AndroidSchedulers.mainThread()) .filter { download: Download -> if (chapterIdEquals(download.chapter.id)) @@ -188,7 +188,7 @@ class RecentChaptersPresenter : BasePresenter() { } // Get source of chapter - val source = sourceManager.get(mangaChapter.manga.source) + val source = sourceManager.get(mangaChapter.manga.source)!! // Check if chapter is downloaded if (downloadManager.isChapterDownloaded(source, mangaChapter.manga, mangaChapter.chapter)) { @@ -271,7 +271,7 @@ class RecentChaptersPresenter : BasePresenter() { * @param manga manga that belongs to chapter */ fun deleteChapter(chapter: Chapter, manga: Manga) { - val source = sourceManager.get(manga.source) + val source = sourceManager.get(manga.source)!! downloadManager.deleteChapter(source, manga, chapter) } @@ -282,7 +282,7 @@ class RecentChaptersPresenter : BasePresenter() { fun deleteChapters(selectedChapters: Observable) { add(selectedChapters .subscribe( - { chapter -> downloadManager.queue.remove(chapter) }) + { chapter -> downloadManager.queue.del(chapter) }) { error -> Timber.e(error.message) }) }