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
This commit is contained in:
inorichi 2016-11-20 11:20:57 +01:00 committed by GitHub
parent 59c626b4a8
commit 6f297161de
34 changed files with 1325 additions and 855 deletions

View File

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

View File

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

View File

@ -33,6 +33,15 @@ interface ChapterQueries : DbProvider {
.withGetResolver(MangaChapterGetResolver.INSTANCE) .withGetResolver(MangaChapterGetResolver.INSTANCE)
.prepare() .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 insertChapter(chapter: Chapter) = db.put().`object`(chapter).prepare()
fun insertChapters(chapters: List<Chapter>) = db.put().objects(chapters).prepare() fun insertChapters(chapters: List<Chapter>) = db.put().objects(chapters).prepare()

View File

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

View File

@ -1,30 +1,28 @@
package eu.kanade.tachiyomi.data.download package eu.kanade.tachiyomi.data.download
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory
import android.support.v4.app.NotificationCompat import android.support.v4.app.NotificationCompat
import eu.kanade.tachiyomi.Constants import eu.kanade.tachiyomi.Constants
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue 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.notificationManager
import eu.kanade.tachiyomi.util.toast
/** /**
* DownloadNotifier is used to show notifications when downloading one or multiple chapters. * DownloadNotifier is used to show notifications when downloading one or multiple chapters.
* *
* @param context context of application * @param context context of application
*/ */
class DownloadNotifier(private val context: Context) { internal class DownloadNotifier(private val context: Context) {
/** /**
* Notification builder. * Notification builder.
*/ */
private val notificationBuilder = NotificationCompat.Builder(context) private val notification by lazy {
NotificationCompat.Builder(context)
/** .setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher))
* Id of the notification. }
*/
private val notificationId: Int
get() = Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID
/** /**
* Status of download. Used for correct notification icon. * 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. * The size of queue on start download.
*/ */
internal var initialQueueSize = 0 var initialQueueSize = 0
/** /**
* Simultaneous download setting > 1. * 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. * Called when download progress changes.
@ -47,45 +62,47 @@ class DownloadNotifier(private val context: Context) {
* *
* @param queue the queue containing downloads. * @param queue the queue containing downloads.
*/ */
internal fun onProgressChange(queue: DownloadQueue) { fun onProgressChange(queue: DownloadQueue) {
if (multipleDownloadThreads) if (multipleDownloadThreads) {
doOnProgressChange(null, queue) doOnProgressChange(null, queue)
}
} }
/** /**
* Called when download progress changes * Called when download progress changes.
* Note: Only accepted when single download active * Note: Only accepted when single download active.
* *
* @param download download object containing download information * @param download download object containing download information.
* @param queue the queue containing downloads * @param queue the queue containing downloads.
*/ */
internal fun onProgressChange(download: Download, queue: DownloadQueue) { fun onProgressChange(download: Download, queue: DownloadQueue) {
if (!multipleDownloadThreads) if (!multipleDownloadThreads) {
doOnProgressChange(download, queue) doOnProgressChange(download, queue)
}
} }
/** /**
* Show notification progress of chapter * Show notification progress of chapter.
* *
* @param download download object containing download information * @param download download object containing download information.
* @param queue the queue containing downloads * @param queue the queue containing downloads.
*/ */
private fun doOnProgressChange(download: Download?, queue: DownloadQueue) { private fun doOnProgressChange(download: Download?, queue: DownloadQueue) {
// Check if download is completed // Check if download is completed
if (multipleDownloadThreads) { if (multipleDownloadThreads) {
if (queue.isEmpty()) { if (queue.isEmpty()) {
onComplete(null) onChapterCompleted(null)
return return
} }
} else { } else {
if (download != null && download.pages!!.size == download.downloadedImages) { if (download != null && download.pages!!.size == download.downloadedImages) {
onComplete(download) onChapterCompleted(download)
return return
} }
} }
// Create notification // Create notification
with(notificationBuilder) { with(notification) {
// Check if icon needs refresh // Check if icon needs refresh
if (!isDownloading) { if (!isDownloading) {
setSmallIcon(android.R.drawable.stat_sys_download) setSmallIcon(android.R.drawable.stat_sys_download)
@ -104,11 +121,7 @@ class DownloadNotifier(private val context: Context) {
setProgress(initialQueueSize, initialQueueSize - queue.size, false) setProgress(initialQueueSize, initialQueueSize - queue.size, false)
} else { } else {
download?.let { download?.let {
if (it.chapter.name.length >= 33) setContentTitle(it.chapter.name.chop(30))
setContentTitle(it.chapter.name.slice(IntRange(0, 30)).plus("..."))
else
setContentTitle(it.chapter.name)
setContentText(context.getString(R.string.chapter_downloading_progress) setContentText(context.getString(R.string.chapter_downloading_progress)
.format(it.downloadedImages, it.pages!!.size)) .format(it.downloadedImages, it.pages!!.size))
setProgress(it.pages!!.size, it.downloadedImages, false) setProgress(it.pages!!.size, it.downloadedImages, false)
@ -117,17 +130,17 @@ class DownloadNotifier(private val context: Context) {
} }
} }
// Displays the progress bar on notification // 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. // Create notification.
with(notificationBuilder) { with(notification) {
setContentTitle(download?.chapter?.name ?: context.getString(R.string.app_name)) setContentTitle(download?.chapter?.name ?: context.getString(R.string.app_name))
setContentText(context.getString(R.string.update_check_notification_download_complete)) setContentText(context.getString(R.string.update_check_notification_download_complete))
setSmallIcon(android.R.drawable.stat_sys_download_done) setSmallIcon(android.R.drawable.stat_sys_download_done)
@ -135,7 +148,7 @@ class DownloadNotifier(private val context: Context) {
} }
// Show notification. // Show notification.
context.notificationManager.notify(notificationId, notificationBuilder.build()) notification.show()
// Reset initial values // Reset initial values
isDownloading = false 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() { fun onWarning(reason: String) {
context.notificationManager.cancel(notificationId) 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 error string containing error information.
* @param chapter string containing chapter title * @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 // Create notification
with(notificationBuilder) { with(notification) {
setContentTitle(chapter ?: context.getString(R.string.download_notifier_title_error)) setContentTitle(chapter ?: context.getString(R.string.download_notifier_downloader_title))
setContentText(error ?: context.getString(R.string.download_notifier_unkown_error)) setContentText(error ?: context.getString(R.string.download_notifier_unkown_error))
setSmallIcon(android.R.drawable.stat_sys_warning) setSmallIcon(android.R.drawable.stat_sys_warning)
setProgress(0, 0, false) 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 // Reset download information
onClear()
isDownloading = false isDownloading = false
} }
} }

View File

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

View File

@ -3,130 +3,177 @@ package eu.kanade.tachiyomi.data.download
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.NetworkInfo.State.CONNECTED
import android.net.NetworkInfo.State.DISCONNECTED
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager 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.github.pwittchen.reactivenetwork.library.ReactiveNetwork
import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper 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 eu.kanade.tachiyomi.util.toast
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.injectLazy 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() { class DownloadService : Service() {
companion object { companion object {
/**
* Relay used to know when the service is running.
*/
val runningRelay: BehaviorRelay<Boolean> = BehaviorRelay.create(false)
/**
* Starts this service.
*
* @param context the application context.
*/
fun start(context: Context) { fun start(context: Context) {
context.startService(Intent(context, DownloadService::class.java)) context.startService(Intent(context, DownloadService::class.java))
} }
/**
* Stops this service.
*
* @param context the application context.
*/
fun stop(context: Context) { fun stop(context: Context) {
context.stopService(Intent(context, DownloadService::class.java)) 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 * Preferences helper.
private var queueRunningSubscription: Subscription? = null */
private var isRunning: Boolean = false 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() { override fun onCreate() {
super.onCreate() super.onCreate()
runningRelay.call(true)
createWakeLock() subscriptions = CompositeSubscription()
listenDownloaderState()
listenQueueRunningChanges()
listenNetworkChanges() listenNetworkChanges()
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { /**
return Service.START_STICKY * Called when the service is destroyed.
} */
override fun onDestroy() { override fun onDestroy() {
queueRunningSubscription?.unsubscribe() runningRelay.call(false)
networkChangeSubscription?.unsubscribe() subscriptions.unsubscribe()
downloadManager.destroySubscriptions() downloadManager.stopDownloads()
destroyWakeLock() wakeLock.releaseIfNeeded()
super.onDestroy() 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? { override fun onBind(intent: Intent): IBinder? {
return null return null
} }
/**
* Listens to network changes.
*
* @see onNetworkStateChanged
*/
private fun listenNetworkChanges() { private fun listenNetworkChanges() {
networkChangeSubscription = ReactiveNetwork().enableInternetCheck() subscriptions += ReactiveNetwork.observeNetworkConnectivity(applicationContext)
.observeConnectivity(applicationContext)
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe({ state -> .subscribe({ state -> onNetworkStateChanged(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))
}
}
}
}, { error -> }, { error ->
toast(R.string.download_queue_error) toast(R.string.download_queue_error)
stopSelf() stopSelf()
}) })
} }
private fun listenQueueRunningChanges() { /**
queueRunningSubscription = downloadManager.runningSubject.subscribe { running -> * Called when the network state changes.
isRunning = running *
* @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) if (running)
acquireWakeLock() wakeLock.acquireIfNeeded()
else else
releaseWakeLock() wakeLock.releaseIfNeeded()
} }
} }
private fun createWakeLock() { /**
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( * Releases the wake lock if it's held.
PowerManager.PARTIAL_WAKE_LOCK, "DownloadService:WakeLock") */
fun PowerManager.WakeLock.releaseIfNeeded() {
if (isHeld) release()
} }
private fun destroyWakeLock() { /**
if (wakeLock != null && wakeLock!!.isHeld) { * Acquires the wake lock if it's not held.
wakeLock!!.release() */
wakeLock = null fun PowerManager.WakeLock.acquireIfNeeded() {
} if (!isHeld) acquire()
}
fun acquireWakeLock() {
if (wakeLock != null && !wakeLock!!.isHeld) {
wakeLock!!.acquire()
}
}
fun releaseWakeLock() {
if (wakeLock != null && wakeLock!!.isHeld) {
wakeLock!!.release()
}
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.data.preference package eu.kanade.tachiyomi.data.preference
import android.content.Context import android.content.Context
import android.net.Uri
import android.os.Environment import android.os.Environment
import android.preference.PreferenceManager import android.preference.PreferenceManager
import com.f2prateek.rx.preferences.Preference 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.mangasync.MangaSyncService
import eu.kanade.tachiyomi.data.source.Source import eu.kanade.tachiyomi.data.source.Source
import java.io.File import java.io.File
import java.io.IOException
fun <T> Preference<T>.getOrDefault(): T = get() ?: defaultValue()!! fun <T> Preference<T>.getOrDefault(): T = get() ?: defaultValue()!!
@ -20,17 +20,9 @@ class PreferencesHelper(context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context) private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private val rxPrefs = RxSharedPreferences.create(prefs) private val rxPrefs = RxSharedPreferences.create(prefs)
private val defaultDownloadsDir = File(Environment.getExternalStorageDirectory().absolutePath + private val defaultDownloadsDir = Uri.fromFile(
File.separator + context.getString(R.string.app_name), "downloads") 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 */
}
}
fun startScreen() = prefs.getInt(keys.startScreen, 1) fun startScreen() = prefs.getInt(keys.startScreen, 1)
@ -112,7 +104,7 @@ class PreferencesHelper(context: Context) {
.apply() .apply()
} }
fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.absolutePath) fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.toString())
fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1) fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1)

View File

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

View File

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

View File

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

View File

@ -29,36 +29,21 @@ class DownloadPresenter : BasePresenter<DownloadFragment>() {
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
Observable.just(ArrayList(downloadQueue)) downloadQueue.getUpdatedObservable()
.doOnNext { syncQueue(it) } .observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache({ view, downloads -> .map { ArrayList(it) }
view.onNextDownloads(downloads) .subscribeLatestCache(DownloadFragment::onNextDownloads, { view, error ->
}, { view, error ->
Timber.e(error) Timber.e(error)
}) })
} }
private fun syncQueue(queue: MutableList<Download>) { fun getDownloadStatusObservable(): Observable<Download> {
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<Download> {
return downloadQueue.getStatusObservable() return downloadQueue.getStatusObservable()
.startWith(downloadQueue.getActiveDownloads()) .startWith(downloadQueue.getActiveDownloads())
} }
fun getProgressObservable(): Observable<Download> { fun getDownloadProgressObservable(): Observable<Download> {
return downloadQueue.getProgressObservable() return downloadQueue.getProgressObservable()
.onBackpressureBuffer() .onBackpressureBuffer()
} }

View File

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

View File

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

View File

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

View File

@ -70,14 +70,15 @@ class ChapterLoader(
private fun retrievePageList(chapter: ReaderChapter) = Observable.just(chapter) private fun retrievePageList(chapter: ReaderChapter) = Observable.just(chapter)
.flatMap { .flatMap {
// Check if the chapter is downloaded. // 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) {
if (chapter.isDownloaded) // Fetch the page list from disk.
Observable.just(downloadManager.getSavedPageList(source, manga, chapter)!!) downloadManager.buildPageList(source, manga, chapter)
// Fetch the page list from cache or fallback to network } else {
else // Fetch the page list from cache or fallback to network
source.fetchPageList(chapter) source.fetchPageList(chapter)
}
} }
.doOnNext { pages -> .doOnNext { pages ->
chapter.pages = pages chapter.pages = pages
@ -85,21 +86,11 @@ class ChapterLoader(
} }
private fun loadPages(chapter: ReaderChapter) { private fun loadPages(chapter: ReaderChapter) {
if (chapter.isDownloaded) { if (!chapter.isDownloaded) {
loadDownloadedPages(chapter)
} else {
loadOnlinePages(chapter) 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) { private fun loadOnlinePages(chapter: ReaderChapter) {
chapter.pages?.let { pages -> chapter.pages?.let { pages ->
val startPage = chapter.requestedPage val startPage = chapter.requestedPage

View File

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

View File

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

View File

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

View File

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

View File

@ -2,12 +2,14 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.content.Context import android.content.Context
import android.graphics.PointF import android.graphics.PointF
import android.os.Build
import android.util.AttributeSet import android.util.AttributeSet
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.source.model.Page import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.ui.reader.ReaderActivity 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. * Called when the page is ready.
*/ */
private fun setImage() { private fun setImage() {
val path = page.imagePath val uri = page.uri
if (path != null && File(path).exists()) { if (uri == null) {
progress_text.visibility = View.INVISIBLE
image_view.setImage(ImageSource.uri(path))
} else {
page.status = Page.ERROR 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))
} }
/** /**

View File

@ -1,11 +1,13 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import android.os.Build
import android.support.v7.widget.RecyclerView import android.support.v7.widget.RecyclerView
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.source.model.Page import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.ui.reader.ReaderActivity 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. * Called when the page is ready.
*/ */
private fun setImage() = with(view) { private fun setImage() = with(view) {
val path = page?.imagePath val uri = page?.uri
if (path != null && File(path).exists()) { if (uri == null) {
progress_text.visibility = View.INVISIBLE
image_view.visibility = View.VISIBLE
image_view.setImage(ImageSource.uri(path))
} else {
page?.status = Page.ERROR 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))
} }
/** /**

View File

@ -116,7 +116,7 @@ class WebtoonReader : BaseReader() {
} }
override fun onSaveInstanceState(outState: Bundle) { 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) outState.putInt(SAVED_POSITION, savedPosition)
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
} }
@ -163,7 +163,7 @@ class WebtoonReader : BaseReader() {
* @param currentPage the initial page to display. * @param currentPage the initial page to display.
*/ */
override fun onChapterSet(chapter: ReaderChapter, currentPage: Page) { override fun onChapterSet(chapter: ReaderChapter, currentPage: Page) {
this.currentPage = currentPage.pageNumber this.currentPage = currentPage.index
// Make sure the view is already initialized. // Make sure the view is already initialized.
if (view != null) { if (view != null) {

View File

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

View File

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

View File

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

View File

@ -5,10 +5,6 @@ import java.net.URISyntaxException;
public final class UrlUtil { 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 { private UrlUtil() throws InstantiationException {
throw new InstantiationException("This class is not for instantiation"); 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;
}
} }

View File

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

View File

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

View File

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