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:
parent
59c626b4a8
commit
6f297161de
@ -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'
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
}
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -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
|
||||||
|
@ -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()
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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 }
|
||||||
|
@ -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.
|
||||||
*
|
*
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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) {
|
||||||
|
@ -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
|
||||||
|
@ -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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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.
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
Loading…
Reference in New Issue
Block a user