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
|
||||
targetSdkVersion 25
|
||||
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
||||
versionCode 13
|
||||
versionCode 14
|
||||
versionName "0.3.2"
|
||||
|
||||
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
|
||||
@ -99,7 +99,6 @@ dependencies {
|
||||
|
||||
// Modified dependencies
|
||||
compile 'com.github.inorichi:subsampling-scale-image-view:96d2c7f'
|
||||
compile 'com.github.inorichi:ReactiveNetwork:69092ed'
|
||||
|
||||
// Android support library
|
||||
final support_library_version = '25.0.0'
|
||||
@ -117,14 +116,18 @@ dependencies {
|
||||
compile 'com.evernote:android-job:1.1.3'
|
||||
compile 'com.google.android.gms:play-services-gcm:9.8.0'
|
||||
|
||||
compile 'com.github.seven332:unifile:0.2.0'
|
||||
|
||||
// ReactiveX
|
||||
compile 'io.reactivex:rxandroid:1.2.1'
|
||||
compile 'io.reactivex:rxjava:1.2.2'
|
||||
compile 'com.jakewharton.rxrelay:rxrelay:1.2.0'
|
||||
compile 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
|
||||
|
||||
// Network client
|
||||
compile "com.squareup.okhttp3:okhttp:3.4.2"
|
||||
compile 'com.squareup.okio:okio:1.11.0'
|
||||
compile 'com.github.pwittchen:reactivenetwork:0.6.0'
|
||||
|
||||
// REST
|
||||
final retrofit_version = '2.1.0'
|
||||
|
@ -168,11 +168,11 @@ class ChapterCache(private val context: Context) {
|
||||
* @param imageUrl url of image.
|
||||
* @return path of image.
|
||||
*/
|
||||
fun getImagePath(imageUrl: String): String? {
|
||||
fun getImagePath(imageUrl: String): File? {
|
||||
try {
|
||||
// Get file from md5 key.
|
||||
val imageName = DiskUtils.hashKeyForDisk(imageUrl) + ".0"
|
||||
return File(diskCache.directory, imageName).canonicalPath
|
||||
return File(diskCache.directory, imageName)
|
||||
} catch (e: IOException) {
|
||||
return null
|
||||
}
|
||||
|
@ -33,6 +33,15 @@ interface ChapterQueries : DbProvider {
|
||||
.withGetResolver(MangaChapterGetResolver.INSTANCE)
|
||||
.prepare()
|
||||
|
||||
fun getChapter(id: Long) = db.get()
|
||||
.`object`(Chapter::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(ChapterTable.TABLE)
|
||||
.where("${ChapterTable.COL_ID} = ?")
|
||||
.whereArgs(id)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun insertChapter(chapter: Chapter) = db.put().`object`(chapter).prepare()
|
||||
|
||||
fun insertChapters(chapters: List<Chapter>) = db.put().objects(chapters).prepare()
|
||||
|
@ -1,450 +1,152 @@
|
||||
package eu.kanade.tachiyomi.data.download
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.google.gson.stream.JsonReader
|
||||
import eu.kanade.tachiyomi.R
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.jakewharton.rxrelay.BehaviorRelay
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.download.model.Download
|
||||
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.data.source.Source
|
||||
import eu.kanade.tachiyomi.data.source.SourceManager
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
||||
import eu.kanade.tachiyomi.util.*
|
||||
import rx.Observable
|
||||
import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import rx.subjects.BehaviorSubject
|
||||
import rx.subjects.PublishSubject
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.File
|
||||
import java.io.FileReader
|
||||
import java.util.*
|
||||
|
||||
class DownloadManager(
|
||||
private val context: Context,
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
private val preferences: PreferencesHelper = Injekt.get()
|
||||
) {
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
private val downloadsQueueSubject = PublishSubject.create<List<Download>>()
|
||||
val runningSubject = BehaviorSubject.create<Boolean>()
|
||||
private var downloadsSubscription: Subscription? = null
|
||||
|
||||
val downloadNotifier by lazy { DownloadNotifier(context) }
|
||||
|
||||
private val threadsSubject = BehaviorSubject.create<Int>()
|
||||
private var threadsSubscription: Subscription? = null
|
||||
|
||||
val queue = DownloadQueue()
|
||||
|
||||
val imageFilenameRegex = "[^\\sa-zA-Z0-9.-]".toRegex()
|
||||
|
||||
val PAGE_LIST_FILE = "index.json"
|
||||
|
||||
@Volatile var isRunning: Boolean = false
|
||||
private set
|
||||
|
||||
private fun initializeSubscriptions() {
|
||||
|
||||
downloadsSubscription?.unsubscribe()
|
||||
|
||||
threadsSubscription = preferences.downloadThreads().asObservable()
|
||||
.subscribe {
|
||||
threadsSubject.onNext(it)
|
||||
downloadNotifier.multipleDownloadThreads = it > 1
|
||||
}
|
||||
|
||||
downloadsSubscription = downloadsQueueSubject.flatMap { Observable.from(it) }
|
||||
.lift(DynamicConcurrentMergeOperator<Download, Download>({ downloadChapter(it) }, threadsSubject))
|
||||
.onBackpressureBuffer()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({
|
||||
// Delete successful downloads from queue
|
||||
if (it.status == Download.DOWNLOADED) {
|
||||
// remove downloaded chapter from queue
|
||||
queue.del(it)
|
||||
downloadNotifier.onProgressChange(queue)
|
||||
}
|
||||
if (areAllDownloadsFinished()) {
|
||||
DownloadService.stop(context)
|
||||
}
|
||||
}, { error ->
|
||||
DownloadService.stop(context)
|
||||
Timber.e(error)
|
||||
downloadNotifier.onError(error.message)
|
||||
})
|
||||
|
||||
if (!isRunning) {
|
||||
isRunning = true
|
||||
runningSubject.onNext(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun destroySubscriptions() {
|
||||
if (isRunning) {
|
||||
isRunning = false
|
||||
runningSubject.onNext(false)
|
||||
}
|
||||
|
||||
if (downloadsSubscription != null) {
|
||||
downloadsSubscription?.unsubscribe()
|
||||
downloadsSubscription = null
|
||||
}
|
||||
|
||||
if (threadsSubscription != null) {
|
||||
threadsSubscription?.unsubscribe()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Create a download object for every chapter and add them to the downloads queue
|
||||
fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
|
||||
val source = sourceManager.get(manga.source) as? OnlineSource ?: return
|
||||
|
||||
// Add chapters to queue from the start
|
||||
val sortedChapters = chapters.sortedByDescending { it.source_order }
|
||||
|
||||
// Used to avoid downloading chapters with the same name
|
||||
val addedChapters = ArrayList<String>()
|
||||
val pending = ArrayList<Download>()
|
||||
|
||||
for (chapter in sortedChapters) {
|
||||
if (addedChapters.contains(chapter.name))
|
||||
continue
|
||||
|
||||
addedChapters.add(chapter.name)
|
||||
val download = Download(source, manga, chapter)
|
||||
|
||||
if (!prepareDownload(download)) {
|
||||
queue.add(download)
|
||||
pending.add(download)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize queue size
|
||||
downloadNotifier.initialQueueSize = queue.size
|
||||
// Show notification
|
||||
downloadNotifier.onProgressChange(queue)
|
||||
|
||||
if (isRunning) downloadsQueueSubject.onNext(pending)
|
||||
}
|
||||
|
||||
// Public method to check if a chapter is downloaded
|
||||
fun isChapterDownloaded(source: Source, manga: Manga, chapter: Chapter): Boolean {
|
||||
val directory = getAbsoluteChapterDirectory(source, manga, chapter)
|
||||
if (!directory.exists())
|
||||
return false
|
||||
|
||||
val pages = getSavedPageList(source, manga, chapter)
|
||||
return isChapterDownloaded(directory, pages)
|
||||
}
|
||||
|
||||
// Prepare the download. Returns true if the chapter is already downloaded
|
||||
private fun prepareDownload(download: Download): Boolean {
|
||||
// If the chapter is already queued, don't add it again
|
||||
for (queuedDownload in queue) {
|
||||
if (download.chapter.id == queuedDownload.chapter.id)
|
||||
return true
|
||||
}
|
||||
|
||||
// Add the directory to the download object for future access
|
||||
download.directory = getAbsoluteChapterDirectory(download)
|
||||
|
||||
// If the directory doesn't exist, the chapter isn't downloaded.
|
||||
if (!download.directory.exists()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If the page list doesn't exist, the chapter isn't downloaded
|
||||
val savedPages = getSavedPageList(download) ?: return false
|
||||
|
||||
// Add the page list to the download object for future access
|
||||
download.pages = savedPages
|
||||
|
||||
// If the number of files matches the number of pages, the chapter is downloaded.
|
||||
// We have the index file, so we check one file more
|
||||
return isChapterDownloaded(download.directory, download.pages)
|
||||
}
|
||||
|
||||
// Check that all the images are downloaded
|
||||
private fun isChapterDownloaded(directory: File, pages: List<Page>?): Boolean {
|
||||
return pages != null && !pages.isEmpty() && pages.size + 1 == directory.listFiles().size
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if (queue.isEmpty())
|
||||
return false
|
||||
|
||||
if (downloadsSubscription == null || downloadsSubscription!!.isUnsubscribed)
|
||||
initializeSubscriptions()
|
||||
|
||||
val pending = ArrayList<Download>()
|
||||
for (download in queue) {
|
||||
if (download.status != Download.DOWNLOADED) {
|
||||
if (download.status != Download.QUEUE) download.status = Download.QUEUE
|
||||
pending.add(download)
|
||||
}
|
||||
}
|
||||
downloadsQueueSubject.onNext(pending)
|
||||
|
||||
return !pending.isEmpty()
|
||||
return downloader.start()
|
||||
}
|
||||
|
||||
fun stopDownloads(errorMessage: String? = null) {
|
||||
destroySubscriptions()
|
||||
for (download in queue) {
|
||||
if (download.status == Download.DOWNLOADING) {
|
||||
download.status = Download.ERROR
|
||||
}
|
||||
}
|
||||
errorMessage?.let { downloadNotifier.onError(it) }
|
||||
/**
|
||||
* Tells the downloader to stop downloads.
|
||||
*
|
||||
* @param reason an optional reason for being stopped, used to notify the user.
|
||||
*/
|
||||
fun stopDownloads(reason: String? = null) {
|
||||
downloader.stop(reason)
|
||||
}
|
||||
|
||||
/**
|
||||
* Empties the download queue.
|
||||
*/
|
||||
fun clearQueue() {
|
||||
queue.clear()
|
||||
downloadNotifier.onClear()
|
||||
downloader.clearQueue()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the downloader to enqueue the given list of chapters.
|
||||
*
|
||||
* @param manga the manga of the chapters.
|
||||
* @param chapters the list of chapters to enqueue.
|
||||
*/
|
||||
fun downloadChapters(manga: Manga, chapters: List<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
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import eu.kanade.tachiyomi.Constants
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.download.model.Download
|
||||
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
||||
import eu.kanade.tachiyomi.util.chop
|
||||
import eu.kanade.tachiyomi.util.notificationManager
|
||||
import eu.kanade.tachiyomi.util.toast
|
||||
|
||||
/**
|
||||
* DownloadNotifier is used to show notifications when downloading one or multiple chapters.
|
||||
*
|
||||
* @param context context of application
|
||||
*/
|
||||
class DownloadNotifier(private val context: Context) {
|
||||
internal class DownloadNotifier(private val context: Context) {
|
||||
/**
|
||||
* Notification builder.
|
||||
*/
|
||||
private val notificationBuilder = NotificationCompat.Builder(context)
|
||||
|
||||
/**
|
||||
* Id of the notification.
|
||||
*/
|
||||
private val notificationId: Int
|
||||
get() = Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID
|
||||
private val notification by lazy {
|
||||
NotificationCompat.Builder(context)
|
||||
.setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher))
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of download. Used for correct notification icon.
|
||||
@ -34,12 +32,29 @@ class DownloadNotifier(private val context: Context) {
|
||||
/**
|
||||
* The size of queue on start download.
|
||||
*/
|
||||
internal var initialQueueSize = 0
|
||||
var initialQueueSize = 0
|
||||
|
||||
/**
|
||||
* Simultaneous download setting > 1.
|
||||
*/
|
||||
internal var multipleDownloadThreads = false
|
||||
var multipleDownloadThreads = false
|
||||
|
||||
/**
|
||||
* Shows a notification from this builder.
|
||||
*
|
||||
* @param id the id of the notification.
|
||||
*/
|
||||
private fun NotificationCompat.Builder.show(id: Int = Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID) {
|
||||
context.notificationManager.notify(id, build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the downloader's notification. Downloader error notifications use a different id, so
|
||||
* those can only be dismissed by the user.
|
||||
*/
|
||||
fun dismiss() {
|
||||
context.notificationManager.cancel(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when download progress changes.
|
||||
@ -47,45 +62,47 @@ class DownloadNotifier(private val context: Context) {
|
||||
*
|
||||
* @param queue the queue containing downloads.
|
||||
*/
|
||||
internal fun onProgressChange(queue: DownloadQueue) {
|
||||
if (multipleDownloadThreads)
|
||||
fun onProgressChange(queue: DownloadQueue) {
|
||||
if (multipleDownloadThreads) {
|
||||
doOnProgressChange(null, queue)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when download progress changes
|
||||
* Note: Only accepted when single download active
|
||||
* Called when download progress changes.
|
||||
* Note: Only accepted when single download active.
|
||||
*
|
||||
* @param download download object containing download information
|
||||
* @param queue the queue containing downloads
|
||||
* @param download download object containing download information.
|
||||
* @param queue the queue containing downloads.
|
||||
*/
|
||||
internal fun onProgressChange(download: Download, queue: DownloadQueue) {
|
||||
if (!multipleDownloadThreads)
|
||||
fun onProgressChange(download: Download, queue: DownloadQueue) {
|
||||
if (!multipleDownloadThreads) {
|
||||
doOnProgressChange(download, queue)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show notification progress of chapter
|
||||
* Show notification progress of chapter.
|
||||
*
|
||||
* @param download download object containing download information
|
||||
* @param queue the queue containing downloads
|
||||
* @param download download object containing download information.
|
||||
* @param queue the queue containing downloads.
|
||||
*/
|
||||
private fun doOnProgressChange(download: Download?, queue: DownloadQueue) {
|
||||
// Check if download is completed
|
||||
if (multipleDownloadThreads) {
|
||||
if (queue.isEmpty()) {
|
||||
onComplete(null)
|
||||
onChapterCompleted(null)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (download != null && download.pages!!.size == download.downloadedImages) {
|
||||
onComplete(download)
|
||||
onChapterCompleted(download)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Create notification
|
||||
with(notificationBuilder) {
|
||||
with(notification) {
|
||||
// Check if icon needs refresh
|
||||
if (!isDownloading) {
|
||||
setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
@ -104,11 +121,7 @@ class DownloadNotifier(private val context: Context) {
|
||||
setProgress(initialQueueSize, initialQueueSize - queue.size, false)
|
||||
} else {
|
||||
download?.let {
|
||||
if (it.chapter.name.length >= 33)
|
||||
setContentTitle(it.chapter.name.slice(IntRange(0, 30)).plus("..."))
|
||||
else
|
||||
setContentTitle(it.chapter.name)
|
||||
|
||||
setContentTitle(it.chapter.name.chop(30))
|
||||
setContentText(context.getString(R.string.chapter_downloading_progress)
|
||||
.format(it.downloadedImages, it.pages!!.size))
|
||||
setProgress(it.pages!!.size, it.downloadedImages, false)
|
||||
@ -117,17 +130,17 @@ class DownloadNotifier(private val context: Context) {
|
||||
}
|
||||
}
|
||||
// Displays the progress bar on notification
|
||||
context.notificationManager.notify(notificationId, notificationBuilder.build())
|
||||
notification.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when chapter is downloaded
|
||||
* Called when chapter is downloaded.
|
||||
*
|
||||
* @param download download object containing download information
|
||||
* @param download download object containing download information.
|
||||
*/
|
||||
private fun onComplete(download: Download?) {
|
||||
private fun onChapterCompleted(download: Download?) {
|
||||
// Create notification.
|
||||
with(notificationBuilder) {
|
||||
with(notification) {
|
||||
setContentTitle(download?.chapter?.name ?: context.getString(R.string.app_name))
|
||||
setContentText(context.getString(R.string.update_check_notification_download_complete))
|
||||
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
@ -135,7 +148,7 @@ class DownloadNotifier(private val context: Context) {
|
||||
}
|
||||
|
||||
// Show notification.
|
||||
context.notificationManager.notify(notificationId, notificationBuilder.build())
|
||||
notification.show()
|
||||
|
||||
// Reset initial values
|
||||
isDownloading = false
|
||||
@ -143,29 +156,38 @@ class DownloadNotifier(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the notification message
|
||||
* Called when the downloader receives a warning.
|
||||
*
|
||||
* @param reason the text to show.
|
||||
*/
|
||||
internal fun onClear() {
|
||||
context.notificationManager.cancel(notificationId)
|
||||
fun onWarning(reason: String) {
|
||||
with(notification) {
|
||||
setContentTitle(context.getString(R.string.download_notifier_downloader_title))
|
||||
setContentText(reason)
|
||||
setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||
setProgress(0, 0, false)
|
||||
}
|
||||
notification.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on error while downloading chapter
|
||||
* Called when the downloader receives an error. It's shown as a separate notification to avoid
|
||||
* being overwritten.
|
||||
*
|
||||
* @param error string containing error information
|
||||
* @param chapter string containing chapter title
|
||||
* @param error string containing error information.
|
||||
* @param chapter string containing chapter title.
|
||||
*/
|
||||
internal fun onError(error: String? = null, chapter: String? = null) {
|
||||
fun onError(error: String? = null, chapter: String? = null) {
|
||||
// Create notification
|
||||
with(notificationBuilder) {
|
||||
setContentTitle(chapter ?: context.getString(R.string.download_notifier_title_error))
|
||||
with(notification) {
|
||||
setContentTitle(chapter ?: context.getString(R.string.download_notifier_downloader_title))
|
||||
setContentText(error ?: context.getString(R.string.download_notifier_unkown_error))
|
||||
setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||
setProgress(0, 0, false)
|
||||
}
|
||||
context.notificationManager.notify(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID, notificationBuilder.build())
|
||||
notification.show(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID)
|
||||
|
||||
// Reset download information
|
||||
onClear()
|
||||
isDownloading = false
|
||||
}
|
||||
}
|
||||
|
@ -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.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.NetworkInfo.State.CONNECTED
|
||||
import android.net.NetworkInfo.State.DISCONNECTED
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import com.github.pwittchen.reactivenetwork.library.ConnectivityStatus
|
||||
import com.github.pwittchen.reactivenetwork.library.Connectivity
|
||||
import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork
|
||||
import com.jakewharton.rxrelay.BehaviorRelay
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.util.connectivityManager
|
||||
import eu.kanade.tachiyomi.util.plusAssign
|
||||
import eu.kanade.tachiyomi.util.powerManager
|
||||
import eu.kanade.tachiyomi.util.toast
|
||||
import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import rx.subscriptions.CompositeSubscription
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
* This service is used to manage the downloader. The system can decide to stop the service, in
|
||||
* which case the downloader is also stopped. It's also stopped while there's no network available.
|
||||
* While the downloader is running, a wake lock will be held.
|
||||
*/
|
||||
class DownloadService : Service() {
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Relay used to know when the service is running.
|
||||
*/
|
||||
val runningRelay: BehaviorRelay<Boolean> = BehaviorRelay.create(false)
|
||||
|
||||
/**
|
||||
* Starts this service.
|
||||
*
|
||||
* @param context the application context.
|
||||
*/
|
||||
fun start(context: Context) {
|
||||
context.startService(Intent(context, DownloadService::class.java))
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops this service.
|
||||
*
|
||||
* @param context the application context.
|
||||
*/
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, DownloadService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
val downloadManager: DownloadManager by injectLazy()
|
||||
val preferences: PreferencesHelper by injectLazy()
|
||||
/**
|
||||
* Download manager.
|
||||
*/
|
||||
private val downloadManager: DownloadManager by injectLazy()
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var networkChangeSubscription: Subscription? = null
|
||||
private var queueRunningSubscription: Subscription? = null
|
||||
private var isRunning: Boolean = false
|
||||
/**
|
||||
* Preferences helper.
|
||||
*/
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* Wake lock to prevent the device to enter sleep mode.
|
||||
*/
|
||||
private val wakeLock by lazy {
|
||||
powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "DownloadService:WakeLock")
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscriptions to store while the service is running.
|
||||
*/
|
||||
private lateinit var subscriptions: CompositeSubscription
|
||||
|
||||
/**
|
||||
* Called when the service is created.
|
||||
*/
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
createWakeLock()
|
||||
|
||||
listenQueueRunningChanges()
|
||||
runningRelay.call(true)
|
||||
subscriptions = CompositeSubscription()
|
||||
listenDownloaderState()
|
||||
listenNetworkChanges()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
return Service.START_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the service is destroyed.
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
queueRunningSubscription?.unsubscribe()
|
||||
networkChangeSubscription?.unsubscribe()
|
||||
downloadManager.destroySubscriptions()
|
||||
destroyWakeLock()
|
||||
runningRelay.call(false)
|
||||
subscriptions.unsubscribe()
|
||||
downloadManager.stopDownloads()
|
||||
wakeLock.releaseIfNeeded()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Not used.
|
||||
*/
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
return Service.START_NOT_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* Not used.
|
||||
*/
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens to network changes.
|
||||
*
|
||||
* @see onNetworkStateChanged
|
||||
*/
|
||||
private fun listenNetworkChanges() {
|
||||
networkChangeSubscription = ReactiveNetwork().enableInternetCheck()
|
||||
.observeConnectivity(applicationContext)
|
||||
subscriptions += ReactiveNetwork.observeNetworkConnectivity(applicationContext)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({ state ->
|
||||
when (state) {
|
||||
ConnectivityStatus.WIFI_CONNECTED_HAS_INTERNET -> {
|
||||
// If there are no remaining downloads, destroy the service
|
||||
if (!isRunning && !downloadManager.startDownloads()) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
ConnectivityStatus.MOBILE_CONNECTED -> {
|
||||
if (!preferences.downloadOnlyOverWifi()) {
|
||||
if (!isRunning && !downloadManager.startDownloads()) {
|
||||
stopSelf()
|
||||
}
|
||||
} else if (isRunning) {
|
||||
downloadManager.stopDownloads(getString(R.string.download_notifier_text_only_wifi))
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (isRunning) {
|
||||
downloadManager.stopDownloads(getString(R.string.download_notifier_text_only_wifi))
|
||||
}
|
||||
}
|
||||
}
|
||||
.subscribe({ state -> onNetworkStateChanged(state)
|
||||
}, { error ->
|
||||
toast(R.string.download_queue_error)
|
||||
stopSelf()
|
||||
})
|
||||
}
|
||||
|
||||
private fun listenQueueRunningChanges() {
|
||||
queueRunningSubscription = downloadManager.runningSubject.subscribe { running ->
|
||||
isRunning = running
|
||||
/**
|
||||
* Called when the network state changes.
|
||||
*
|
||||
* @param connectivity the new network state.
|
||||
*/
|
||||
private fun onNetworkStateChanged(connectivity: Connectivity) {
|
||||
when (connectivity.state) {
|
||||
CONNECTED -> {
|
||||
if (preferences.downloadOnlyOverWifi() && connectivityManager.isActiveNetworkMetered) {
|
||||
downloadManager.stopDownloads(getString(R.string.download_notifier_text_only_wifi))
|
||||
} else {
|
||||
val started = downloadManager.startDownloads()
|
||||
if (!started) stopSelf()
|
||||
}
|
||||
}
|
||||
DISCONNECTED -> {
|
||||
downloadManager.stopDownloads(getString(R.string.download_notifier_no_network))
|
||||
}
|
||||
else -> { /* Do nothing */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens to downloader status. Enables or disables the wake lock depending on the status.
|
||||
*/
|
||||
private fun listenDownloaderState() {
|
||||
subscriptions += downloadManager.runningRelay.subscribe { running ->
|
||||
if (running)
|
||||
acquireWakeLock()
|
||||
wakeLock.acquireIfNeeded()
|
||||
else
|
||||
releaseWakeLock()
|
||||
wakeLock.releaseIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createWakeLock() {
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK, "DownloadService:WakeLock")
|
||||
/**
|
||||
* Releases the wake lock if it's held.
|
||||
*/
|
||||
fun PowerManager.WakeLock.releaseIfNeeded() {
|
||||
if (isHeld) release()
|
||||
}
|
||||
|
||||
private fun destroyWakeLock() {
|
||||
if (wakeLock != null && wakeLock!!.isHeld) {
|
||||
wakeLock!!.release()
|
||||
wakeLock = null
|
||||
}
|
||||
}
|
||||
|
||||
fun acquireWakeLock() {
|
||||
if (wakeLock != null && !wakeLock!!.isHeld) {
|
||||
wakeLock!!.acquire()
|
||||
}
|
||||
}
|
||||
|
||||
fun releaseWakeLock() {
|
||||
if (wakeLock != null && wakeLock!!.isHeld) {
|
||||
wakeLock!!.release()
|
||||
}
|
||||
/**
|
||||
* Acquires the wake lock if it's not held.
|
||||
*/
|
||||
fun PowerManager.WakeLock.acquireIfNeeded() {
|
||||
if (!isHeld) acquire()
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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.online.OnlineSource
|
||||
import rx.subjects.PublishSubject
|
||||
import java.io.File
|
||||
|
||||
class Download(val source: OnlineSource, val manga: Manga, val chapter: Chapter) {
|
||||
|
||||
lateinit var directory: File
|
||||
|
||||
var pages: List<Page>? = null
|
||||
|
||||
@Volatile @Transient var totalProgress: Int = 0
|
||||
|
@ -1,38 +1,51 @@
|
||||
package eu.kanade.tachiyomi.data.download.model
|
||||
|
||||
import com.jakewharton.rxrelay.PublishRelay
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.download.DownloadStore
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import rx.Observable
|
||||
import rx.subjects.PublishSubject
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
class DownloadQueue(private val queue: MutableList<Download> = CopyOnWriteArrayList<Download>())
|
||||
class DownloadQueue(
|
||||
private val store: DownloadStore,
|
||||
private val queue: MutableList<Download> = CopyOnWriteArrayList<Download>())
|
||||
: List<Download> by queue {
|
||||
|
||||
private val statusSubject = PublishSubject.create<Download>()
|
||||
|
||||
private val removeSubject = PublishSubject.create<Download>()
|
||||
private val updatedRelay = PublishRelay.create<Unit>()
|
||||
|
||||
fun add(download: Download): Boolean {
|
||||
download.setStatusSubject(statusSubject)
|
||||
download.status = Download.QUEUE
|
||||
return queue.add(download)
|
||||
fun addAll(downloads: List<Download>) {
|
||||
downloads.forEach { download ->
|
||||
download.setStatusSubject(statusSubject)
|
||||
download.status = Download.QUEUE
|
||||
}
|
||||
queue.addAll(downloads)
|
||||
store.addAll(downloads)
|
||||
updatedRelay.call(Unit)
|
||||
}
|
||||
|
||||
fun del(download: Download) {
|
||||
fun remove(download: Download) {
|
||||
val removed = queue.remove(download)
|
||||
store.remove(download)
|
||||
download.setStatusSubject(null)
|
||||
if (removed) {
|
||||
removeSubject.onNext(download)
|
||||
updatedRelay.call(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
fun del(chapter: Chapter) {
|
||||
find { it.chapter.id == chapter.id }?.let { del(it) }
|
||||
fun remove(chapter: Chapter) {
|
||||
find { it.chapter.id == chapter.id }?.let { remove(it) }
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
queue.forEach { del(it) }
|
||||
queue.forEach { download ->
|
||||
download.setStatusSubject(null)
|
||||
}
|
||||
queue.clear()
|
||||
updatedRelay.call(Unit)
|
||||
}
|
||||
|
||||
fun getActiveDownloads(): Observable<Download> =
|
||||
@ -40,7 +53,9 @@ class DownloadQueue(private val queue: MutableList<Download> = CopyOnWriteArrayL
|
||||
|
||||
fun getStatusObservable(): Observable<Download> = statusSubject.onBackpressureBuffer()
|
||||
|
||||
fun getRemovedObservable(): Observable<Download> = removeSubject.onBackpressureBuffer()
|
||||
fun getUpdatedObservable(): Observable<List<Download>> = updatedRelay.onBackpressureBuffer()
|
||||
.startWith(Unit)
|
||||
.map { this }
|
||||
|
||||
fun getProgressObservable(): Observable<Download> {
|
||||
return statusSubject.onBackpressureBuffer()
|
||||
|
@ -1,6 +1,7 @@
|
||||
package eu.kanade.tachiyomi.data.preference
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.preference.PreferenceManager
|
||||
import com.f2prateek.rx.preferences.Preference
|
||||
@ -9,7 +10,6 @@ import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
|
||||
import eu.kanade.tachiyomi.data.source.Source
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
fun <T> Preference<T>.getOrDefault(): T = get() ?: defaultValue()!!
|
||||
|
||||
@ -20,17 +20,9 @@ class PreferencesHelper(context: Context) {
|
||||
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
private val rxPrefs = RxSharedPreferences.create(prefs)
|
||||
|
||||
private val defaultDownloadsDir = File(Environment.getExternalStorageDirectory().absolutePath +
|
||||
File.separator + context.getString(R.string.app_name), "downloads")
|
||||
|
||||
init {
|
||||
// Don't display downloaded chapters in gallery apps creating a ".nomedia" file
|
||||
try {
|
||||
File(downloadsDirectory().getOrDefault(), ".nomedia").createNewFile()
|
||||
} catch (e: IOException) {
|
||||
/* Ignore */
|
||||
}
|
||||
}
|
||||
private val defaultDownloadsDir = Uri.fromFile(
|
||||
File(Environment.getExternalStorageDirectory().absolutePath + File.separator +
|
||||
context.getString(R.string.app_name), "downloads"))
|
||||
|
||||
fun startScreen() = prefs.getInt(keys.startScreen, 1)
|
||||
|
||||
@ -112,7 +104,7 @@ class PreferencesHelper(context: Context) {
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.absolutePath)
|
||||
fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.toString())
|
||||
|
||||
fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1)
|
||||
|
||||
|
@ -1,14 +1,15 @@
|
||||
package eu.kanade.tachiyomi.data.source.model
|
||||
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.data.network.ProgressListener
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
|
||||
import rx.subjects.Subject
|
||||
|
||||
class Page(
|
||||
val pageNumber: Int,
|
||||
val url: String,
|
||||
val index: Int,
|
||||
val url: String = "",
|
||||
var imageUrl: String? = null,
|
||||
@Transient var imagePath: String? = null
|
||||
@Transient var uri: Uri? = null
|
||||
) : ProgressListener {
|
||||
|
||||
@Transient lateinit var chapter: ReaderChapter
|
||||
|
@ -1,5 +1,6 @@
|
||||
package eu.kanade.tachiyomi.data.source.online
|
||||
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
@ -416,7 +417,7 @@ abstract class OnlineSource() : Source {
|
||||
}
|
||||
}
|
||||
.doOnNext {
|
||||
page.imagePath = chapterCache.getImagePath(imageUrl)
|
||||
page.uri = Uri.fromFile(chapterCache.getImagePath(imageUrl))
|
||||
page.status = Page.READY
|
||||
}
|
||||
.doOnError { page.status = Page.ERROR }
|
||||
|
@ -6,6 +6,7 @@ import android.view.*
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.download.DownloadService
|
||||
import eu.kanade.tachiyomi.data.download.model.Download
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
|
||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||
import eu.kanade.tachiyomi.util.plusAssign
|
||||
@ -30,21 +31,6 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
|
||||
*/
|
||||
private lateinit var adapter: DownloadAdapter
|
||||
|
||||
/**
|
||||
* Menu item to start the queue.
|
||||
*/
|
||||
private var startButton: MenuItem? = null
|
||||
|
||||
/**
|
||||
* Menu item to pause the queue.
|
||||
*/
|
||||
private var pauseButton: MenuItem? = null
|
||||
|
||||
/**
|
||||
* Menu item to clear the queue.
|
||||
*/
|
||||
private var clearButton: MenuItem? = null
|
||||
|
||||
/**
|
||||
* Subscription list to be cleared during [onDestroyView].
|
||||
*/
|
||||
@ -95,15 +81,15 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
|
||||
recycler.setHasFixedSize(true)
|
||||
|
||||
// Suscribe to changes
|
||||
subscriptions += presenter.downloadManager.runningSubject
|
||||
subscriptions += DownloadService.runningRelay
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { onQueueStatusChange(it) }
|
||||
|
||||
subscriptions += presenter.getStatusObservable()
|
||||
subscriptions += presenter.getDownloadStatusObservable()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { onStatusChange(it) }
|
||||
|
||||
subscriptions += presenter.getProgressObservable()
|
||||
subscriptions += presenter.getDownloadProgressObservable()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { onUpdateDownloadedPages(it) }
|
||||
}
|
||||
@ -119,23 +105,17 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.download_queue, menu)
|
||||
}
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu) {
|
||||
// Set start button visibility.
|
||||
startButton = menu.findItem(R.id.start_queue).apply {
|
||||
isVisible = !isRunning && !presenter.downloadQueue.isEmpty()
|
||||
}
|
||||
menu.findItem(R.id.start_queue).isVisible = !isRunning && !presenter.downloadQueue.isEmpty()
|
||||
|
||||
// Set pause button visibility.
|
||||
pauseButton = menu.findItem(R.id.pause_queue).apply {
|
||||
isVisible = isRunning
|
||||
}
|
||||
menu.findItem(R.id.pause_queue).isVisible = isRunning
|
||||
|
||||
// Set clear button visibility.
|
||||
clearButton = menu.findItem(R.id.clear_queue).apply {
|
||||
if (!presenter.downloadQueue.isEmpty()) {
|
||||
isVisible = true
|
||||
}
|
||||
}
|
||||
menu.findItem(R.id.clear_queue).isVisible = !presenter.downloadQueue.isEmpty()
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
@ -182,7 +162,7 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
|
||||
// Get the sum of percentages for all the pages.
|
||||
.flatMap {
|
||||
Observable.from(download.pages)
|
||||
.map { it.progress }
|
||||
.map(Page::progress)
|
||||
.reduce { x, y -> x + y }
|
||||
}
|
||||
// Keep only the latest emission to avoid backpressure.
|
||||
@ -218,9 +198,7 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
|
||||
*/
|
||||
private fun onQueueStatusChange(running: Boolean) {
|
||||
isRunning = running
|
||||
startButton?.isVisible = !running && !presenter.downloadQueue.isEmpty()
|
||||
pauseButton?.isVisible = running
|
||||
clearButton?.isVisible = !presenter.downloadQueue.isEmpty()
|
||||
activity.supportInvalidateOptionsMenu()
|
||||
|
||||
// Check if download queue is empty and update information accordingly.
|
||||
setInformationView()
|
||||
@ -232,13 +210,11 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
|
||||
* @param downloads the downloads from the queue.
|
||||
*/
|
||||
fun onNextDownloads(downloads: List<Download>) {
|
||||
activity.supportInvalidateOptionsMenu()
|
||||
setInformationView()
|
||||
adapter.setItems(downloads)
|
||||
}
|
||||
|
||||
fun onDownloadRemoved(position: Int) {
|
||||
adapter.notifyItemRemoved(position)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the progress of a download changes.
|
||||
*
|
||||
|
@ -30,35 +30,20 @@ class DownloadPresenter : BasePresenter<DownloadFragment>() {
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
super.onCreate(savedState)
|
||||
|
||||
Observable.just(ArrayList(downloadQueue))
|
||||
.doOnNext { syncQueue(it) }
|
||||
.subscribeLatestCache({ view, downloads ->
|
||||
view.onNextDownloads(downloads)
|
||||
}, { view, error ->
|
||||
downloadQueue.getUpdatedObservable()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.map { ArrayList(it) }
|
||||
.subscribeLatestCache(DownloadFragment::onNextDownloads, { view, error ->
|
||||
Timber.e(error)
|
||||
})
|
||||
}
|
||||
|
||||
private fun syncQueue(queue: MutableList<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> {
|
||||
fun getDownloadStatusObservable(): Observable<Download> {
|
||||
return downloadQueue.getStatusObservable()
|
||||
.startWith(downloadQueue.getActiveDownloads())
|
||||
}
|
||||
|
||||
fun getProgressObservable(): Observable<Download> {
|
||||
fun getDownloadProgressObservable(): Observable<Download> {
|
||||
return downloadQueue.getProgressObservable()
|
||||
.onBackpressureBuffer()
|
||||
}
|
||||
|
@ -185,15 +185,10 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
|
||||
}
|
||||
|
||||
if (prefFilterDownloaded) {
|
||||
val mangaDir = downloadManager.getAbsoluteMangaDirectory(source, manga)
|
||||
val mangaDir = downloadManager.findMangaDir(source, manga)
|
||||
|
||||
if (mangaDir.exists()) {
|
||||
for (file in mangaDir.listFiles()) {
|
||||
if (file.isDirectory && file.listFiles().isNotEmpty()) {
|
||||
hasDownloaded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (mangaDir != null) {
|
||||
hasDownloaded = mangaDir.listFiles()?.any { it.isDirectory } ?: false
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -38,7 +38,7 @@ class ChangelogDialogFragment : DialogFragment() {
|
||||
override fun onCreateDialog(savedState: Bundle?): Dialog {
|
||||
val view = WhatsNewRecyclerView(context)
|
||||
return MaterialDialog.Builder(activity)
|
||||
.title("Changelog")
|
||||
.title(if (BuildConfig.DEBUG) "Notices" else "Changelog")
|
||||
.customView(view, false)
|
||||
.positiveText(android.R.string.yes)
|
||||
.build()
|
||||
|
@ -132,6 +132,9 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
|
||||
chapters.map { it.toModel() }
|
||||
}
|
||||
.doOnNext { chapters ->
|
||||
// Find downloaded chapters
|
||||
setDownloadedChapters(chapters)
|
||||
|
||||
// Store the last emission
|
||||
this.chapters = chapters
|
||||
|
||||
@ -157,16 +160,25 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
|
||||
if (download != null) {
|
||||
// If there's an active download, assign it.
|
||||
model.download = download
|
||||
} else {
|
||||
// Otherwise ask the manager if the chapter is downloaded and assign it to the status.
|
||||
model.status = if (downloadManager.isChapterDownloaded(source, manga, this))
|
||||
Download.DOWNLOADED
|
||||
else
|
||||
Download.NOT_DOWNLOADED
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and assigns the list of downloaded chapters.
|
||||
*
|
||||
* @param chapters the list of chapter from the database.
|
||||
*/
|
||||
private fun setDownloadedChapters(chapters: List<ChapterModel>) {
|
||||
val files = downloadManager.findMangaDir(source, manga)?.listFiles() ?: return
|
||||
val cached = mutableMapOf<Chapter, String>()
|
||||
files.mapNotNull { it.name }
|
||||
.mapNotNull { name -> chapters.find {
|
||||
name == cached.getOrPut(it) { downloadManager.getChapterDirName(it) }
|
||||
} }
|
||||
.forEach { it.status = Download.DOWNLOADED }
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests an updated list of chapters from the source.
|
||||
*/
|
||||
@ -318,10 +330,6 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
|
||||
* @param chapters the list of chapters to delete.
|
||||
*/
|
||||
fun deleteChapters(chapters: List<ChapterModel>) {
|
||||
val wasRunning = downloadManager.isRunning
|
||||
if (wasRunning) {
|
||||
DownloadService.stop(context)
|
||||
}
|
||||
Observable.from(chapters)
|
||||
.doOnNext { deleteChapter(it) }
|
||||
.toList()
|
||||
@ -330,9 +338,6 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeFirst({ view, result ->
|
||||
view.onChaptersDeleted()
|
||||
if (wasRunning) {
|
||||
DownloadService.start(context)
|
||||
}
|
||||
}, { view, error ->
|
||||
view.onChaptersDeletedError(error)
|
||||
})
|
||||
@ -343,7 +348,7 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
|
||||
* @param chapter the chapter to delete.
|
||||
*/
|
||||
private fun deleteChapter(chapter: ChapterModel) {
|
||||
downloadManager.queue.del(chapter)
|
||||
downloadManager.queue.remove(chapter)
|
||||
downloadManager.deleteChapter(source, manga, chapter)
|
||||
chapter.status = Download.NOT_DOWNLOADED
|
||||
chapter.download = null
|
||||
|
@ -70,14 +70,15 @@ class ChapterLoader(
|
||||
private fun retrievePageList(chapter: ReaderChapter) = Observable.just(chapter)
|
||||
.flatMap {
|
||||
// Check if the chapter is downloaded.
|
||||
chapter.isDownloaded = downloadManager.isChapterDownloaded(source, manga, chapter)
|
||||
chapter.isDownloaded = downloadManager.findChapterDir(source, manga, chapter) != null
|
||||
|
||||
// Fetch the page list from disk.
|
||||
if (chapter.isDownloaded)
|
||||
Observable.just(downloadManager.getSavedPageList(source, manga, chapter)!!)
|
||||
// Fetch the page list from cache or fallback to network
|
||||
else
|
||||
if (chapter.isDownloaded) {
|
||||
// Fetch the page list from disk.
|
||||
downloadManager.buildPageList(source, manga, chapter)
|
||||
} else {
|
||||
// Fetch the page list from cache or fallback to network
|
||||
source.fetchPageList(chapter)
|
||||
}
|
||||
}
|
||||
.doOnNext { pages ->
|
||||
chapter.pages = pages
|
||||
@ -85,21 +86,11 @@ class ChapterLoader(
|
||||
}
|
||||
|
||||
private fun loadPages(chapter: ReaderChapter) {
|
||||
if (chapter.isDownloaded) {
|
||||
loadDownloadedPages(chapter)
|
||||
} else {
|
||||
if (!chapter.isDownloaded) {
|
||||
loadOnlinePages(chapter)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadDownloadedPages(chapter: ReaderChapter) {
|
||||
val chapterDir = downloadManager.getAbsoluteChapterDirectory(source, manga, chapter)
|
||||
subscriptions += Observable.from(chapter.pages!!)
|
||||
.flatMap { downloadManager.getDownloadedImage(it, chapterDir) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
private fun loadOnlinePages(chapter: ReaderChapter) {
|
||||
chapter.pages?.let { pages ->
|
||||
val startPage = chapter.requestedPage
|
||||
|
@ -5,7 +5,6 @@ import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Build.VERSION_CODES.KITKAT
|
||||
import android.os.Bundle
|
||||
@ -265,7 +264,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
|
||||
val activePage = pages.getOrElse(chapter.requestedPage) { pages.first() }
|
||||
|
||||
viewer?.onPageListReady(chapter, activePage)
|
||||
setActiveChapter(chapter, activePage.pageNumber)
|
||||
setActiveChapter(chapter, activePage.index)
|
||||
}
|
||||
|
||||
fun onEnterChapter(chapter: ReaderChapter, currentPage: Int) {
|
||||
@ -332,7 +331,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
|
||||
fun onPageChanged(page: Page) {
|
||||
presenter.onPageChanged(page)
|
||||
|
||||
val pageNumber = page.pageNumber + 1
|
||||
val pageNumber = page.index + 1
|
||||
val pageCount = page.chapter.pages!!.size
|
||||
page_number.text = "$pageNumber/$pageCount"
|
||||
if (page_seekbar.rotation != 180f) {
|
||||
@ -340,7 +339,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
|
||||
} else {
|
||||
right_page_text.text = "$pageNumber"
|
||||
}
|
||||
page_seekbar.progress = page.pageNumber
|
||||
page_seekbar.progress = page.index
|
||||
}
|
||||
|
||||
fun gotoPageInCurrentChapter(pageIndex: Int) {
|
||||
@ -481,7 +480,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
|
||||
|
||||
val shareIntent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_STREAM, Uri.parse(page.imagePath))
|
||||
putExtra(Intent.EXTRA_STREAM, page.uri)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
type = "image/jpeg"
|
||||
}
|
||||
|
@ -29,7 +29,6 @@ import rx.schedulers.Schedulers
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
@ -98,15 +97,6 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
|
||||
*/
|
||||
private val source by lazy { sourceManager.get(manga.source)!! }
|
||||
|
||||
/**
|
||||
* Directory of pictures
|
||||
*/
|
||||
private val pictureDirectory: String by lazy {
|
||||
Environment.getExternalStorageDirectory().absolutePath + File.separator +
|
||||
Environment.DIRECTORY_PICTURES + File.separator +
|
||||
context.getString(R.string.app_name) + File.separator
|
||||
}
|
||||
|
||||
/**
|
||||
* Chapter list for the active manga. It's retrieved lazily and should be accessed for the first
|
||||
* time in a background thread to avoid blocking the UI.
|
||||
@ -351,9 +341,9 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
|
||||
fun retryPage(page: Page?) {
|
||||
if (page != null && source is OnlineSource) {
|
||||
page.status = Page.QUEUE
|
||||
val path = page.imagePath
|
||||
if (!path.isNullOrEmpty() && !page.chapter.isDownloaded) {
|
||||
chapterCache.removeFileFromCache(File(path).name)
|
||||
val uri = page.uri
|
||||
if (uri != null && !page.chapter.isDownloaded) {
|
||||
chapterCache.removeFileFromCache(uri.encodedPath.substringAfterLast('/'))
|
||||
}
|
||||
loader.retryPage(page)
|
||||
}
|
||||
@ -370,27 +360,27 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
|
||||
val pages = chapter.pages ?: return
|
||||
|
||||
Observable.fromCallable {
|
||||
// Chapters with 1 page don't trigger page changes, so mark them as read.
|
||||
if (pages.size == 1) {
|
||||
chapter.read = true
|
||||
}
|
||||
|
||||
// Cache current page list progress for online chapters to allow a faster reopen
|
||||
if (!chapter.isDownloaded) {
|
||||
source.let { if (it is OnlineSource) it.savePageList(chapter, pages) }
|
||||
}
|
||||
|
||||
if (chapter.read) {
|
||||
val removeAfterReadSlots = prefs.removeAfterReadSlots()
|
||||
when (removeAfterReadSlots) {
|
||||
// Setting disabled
|
||||
-1 -> { /**Empty function**/ }
|
||||
// Remove current read chapter
|
||||
0 -> deleteChapter(chapter, manga)
|
||||
// Remove previous chapter specified by user in settings.
|
||||
else -> getAdjacentChaptersStrategy(chapter, removeAfterReadSlots)
|
||||
.first?.let { deleteChapter(it, manga) }
|
||||
try {
|
||||
if (chapter.read) {
|
||||
val removeAfterReadSlots = prefs.removeAfterReadSlots()
|
||||
when (removeAfterReadSlots) {
|
||||
// Setting disabled
|
||||
-1 -> { /* Empty function */ }
|
||||
// Remove current read chapter
|
||||
0 -> deleteChapter(chapter, manga)
|
||||
// Remove previous chapter specified by user in settings.
|
||||
else -> getAdjacentChaptersStrategy(chapter, removeAfterReadSlots)
|
||||
.first?.let { deleteChapter(it, manga) }
|
||||
}
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
// TODO find out why it crashes
|
||||
Timber.e(error)
|
||||
}
|
||||
|
||||
db.updateChapterProgress(chapter).executeAsBlocking()
|
||||
@ -414,7 +404,7 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
|
||||
*/
|
||||
fun onPageChanged(page: Page) {
|
||||
val chapter = page.chapter
|
||||
chapter.last_page_read = page.pageNumber
|
||||
chapter.last_page_read = page.index
|
||||
if (chapter.pages!!.last() === page) {
|
||||
chapter.read = true
|
||||
}
|
||||
@ -537,7 +527,8 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
|
||||
try {
|
||||
if (manga.favorite) {
|
||||
if (manga.thumbnail_url != null) {
|
||||
coverCache.copyToCache(manga.thumbnail_url!!, File(page.imagePath).inputStream())
|
||||
val input = context.contentResolver.openInputStream(page.uri)
|
||||
coverCache.copyToCache(manga.thumbnail_url!!, input)
|
||||
context.toast(R.string.cover_updated)
|
||||
} else {
|
||||
throw Exception("Image url not found")
|
||||
@ -552,40 +543,47 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Save page to local storage
|
||||
* @throws IOException
|
||||
* Save page to local storage.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
internal fun savePage(page: Page) {
|
||||
if (page.status != Page.READY)
|
||||
return
|
||||
|
||||
// Used to show image notification
|
||||
// Used to show image notification.
|
||||
val imageNotifier = ImageNotifier(context)
|
||||
|
||||
// Location of image file.
|
||||
val inputFile = File(page.imagePath)
|
||||
|
||||
// File where the image will be saved.
|
||||
val destFile = File(pictureDirectory, manga.title + " - " + chapter.name +
|
||||
" - " + downloadManager.getImageFilename(page))
|
||||
|
||||
//Remove the notification if already exist (user feedback)
|
||||
// Remove the notification if it already exists (user feedback).
|
||||
imageNotifier.onClear()
|
||||
if (inputFile.exists()) {
|
||||
// Copy file
|
||||
Observable.fromCallable { inputFile.copyTo(destFile, true) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
// Show notification
|
||||
imageNotifier.onComplete(it)
|
||||
},
|
||||
{ error ->
|
||||
Timber.e(error)
|
||||
imageNotifier.onError(error.message)
|
||||
})
|
||||
}
|
||||
|
||||
// Pictures directory.
|
||||
val pictureDirectory = Environment.getExternalStorageDirectory().absolutePath +
|
||||
File.separator + Environment.DIRECTORY_PICTURES +
|
||||
File.separator + context.getString(R.string.app_name)
|
||||
|
||||
// Copy file in background.
|
||||
Observable
|
||||
.fromCallable {
|
||||
// File where the image will be saved.
|
||||
val destDir = File(pictureDirectory)
|
||||
destDir.mkdirs()
|
||||
|
||||
val destFile = File(destDir, manga.title + " - " + chapter.name +
|
||||
" - " + (page.index + 1))
|
||||
|
||||
// Location of image file.
|
||||
context.contentResolver.openInputStream(page.uri).use { input ->
|
||||
destFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
imageNotifier.onComplete(destFile)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe({},
|
||||
{ error ->
|
||||
Timber.e(error)
|
||||
imageNotifier.onError(error.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -2,12 +2,9 @@ package eu.kanade.tachiyomi.ui.reader.notification
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.media.Image
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.request.animation.GlideAnimation
|
||||
import com.bumptech.glide.request.target.SimpleTarget
|
||||
import eu.kanade.tachiyomi.Constants
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.util.notificationManager
|
||||
@ -29,24 +26,25 @@ class ImageNotifier(private val context: Context) {
|
||||
get() = Constants.NOTIFICATION_DOWNLOAD_IMAGE_ID
|
||||
|
||||
/**
|
||||
* Called when image download/copy is complete
|
||||
* @param file image file containing downloaded page image
|
||||
* Called when image download/copy is complete. This method must be called in a background
|
||||
* thread.
|
||||
*
|
||||
* @param file image file containing downloaded page image.
|
||||
*/
|
||||
fun onComplete(file: File) {
|
||||
val bitmap = Glide.with(context)
|
||||
.load(file)
|
||||
.asBitmap()
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.skipMemoryCache(true)
|
||||
.into(720, 1280)
|
||||
.get()
|
||||
|
||||
Glide.with(context).load(file).asBitmap().diskCacheStrategy(DiskCacheStrategy.NONE).skipMemoryCache(true).into(object : SimpleTarget<Bitmap>(720, 1280) {
|
||||
/**
|
||||
* The method that will be called when the resource load has finished.
|
||||
* @param resource the loaded resource.
|
||||
*/
|
||||
override fun onResourceReady(resource: Bitmap?, glideAnimation: GlideAnimation<in Bitmap>?) {
|
||||
if (resource!= null){
|
||||
showCompleteNotification(file, resource)
|
||||
}else{
|
||||
onError(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
if (bitmap != null) {
|
||||
showCompleteNotification(file, bitmap)
|
||||
} else {
|
||||
onError(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showCompleteNotification(file: File, image: Bitmap) {
|
||||
@ -75,7 +73,7 @@ class ImageNotifier(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the notification message
|
||||
* Clears the notification message.
|
||||
*/
|
||||
fun onClear() {
|
||||
context.notificationManager.cancel(notificationId)
|
||||
@ -88,8 +86,8 @@ class ImageNotifier(private val context: Context) {
|
||||
|
||||
|
||||
/**
|
||||
* Called on error while downloading image
|
||||
* @param error string containing error information
|
||||
* Called on error while downloading image.
|
||||
* @param error string containing error information.
|
||||
*/
|
||||
fun onError(error: String?) {
|
||||
// Create notification
|
||||
|
@ -95,7 +95,7 @@ abstract class BaseReader : BaseFragment() {
|
||||
|
||||
// Active chapter has changed.
|
||||
if (oldChapter.id != newChapter.id) {
|
||||
readerActivity.onEnterChapter(newPage.chapter, newPage.pageNumber)
|
||||
readerActivity.onEnterChapter(newPage.chapter, newPage.index)
|
||||
}
|
||||
// Request next chapter only when the conditions are met.
|
||||
if (pages.size - position < 5 && chapters.last().id == newChapter.id
|
||||
@ -125,7 +125,7 @@ abstract class BaseReader : BaseFragment() {
|
||||
*/
|
||||
fun getPageIndex(search: Page): Int {
|
||||
for ((index, page) in pages.withIndex()) {
|
||||
if (page.pageNumber == search.pageNumber && page.chapter.id == search.chapter.id) {
|
||||
if (page.index == search.index && page.chapter.id == search.chapter.id) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
@ -2,12 +2,14 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.PointF
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
@ -208,13 +210,25 @@ class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
|
||||
* Called when the page is ready.
|
||||
*/
|
||||
private fun setImage() {
|
||||
val path = page.imagePath
|
||||
if (path != null && File(path).exists()) {
|
||||
progress_text.visibility = View.INVISIBLE
|
||||
image_view.setImage(ImageSource.uri(path))
|
||||
} else {
|
||||
val uri = page.uri
|
||||
if (uri == null) {
|
||||
page.status = Page.ERROR
|
||||
return
|
||||
}
|
||||
|
||||
val file = if (Build.VERSION.SDK_INT < 21 || UniFile.isFileUri(uri)) {
|
||||
UniFile.fromFile(File(uri.path))
|
||||
} else {
|
||||
// Tree uri returns the root folder
|
||||
UniFile.fromSingleUri(context, uri)
|
||||
}!!
|
||||
if (!file.exists()) {
|
||||
page.status = Page.ERROR
|
||||
return
|
||||
}
|
||||
|
||||
progress_text.visibility = View.INVISIBLE
|
||||
image_view.setImage(ImageSource.uri(file.uri))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,11 +1,13 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
|
||||
|
||||
import android.os.Build
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
@ -242,14 +244,26 @@ class WebtoonHolder(private val view: View, private val adapter: WebtoonAdapter)
|
||||
* Called when the page is ready.
|
||||
*/
|
||||
private fun setImage() = with(view) {
|
||||
val path = page?.imagePath
|
||||
if (path != null && File(path).exists()) {
|
||||
progress_text.visibility = View.INVISIBLE
|
||||
image_view.visibility = View.VISIBLE
|
||||
image_view.setImage(ImageSource.uri(path))
|
||||
} else {
|
||||
val uri = page?.uri
|
||||
if (uri == null) {
|
||||
page?.status = Page.ERROR
|
||||
return
|
||||
}
|
||||
|
||||
val file = if (Build.VERSION.SDK_INT < 21 || UniFile.isFileUri(uri)) {
|
||||
UniFile.fromFile(File(uri.path))
|
||||
} else {
|
||||
// Tree uri returns the root folder
|
||||
UniFile.fromSingleUri(context, uri)
|
||||
}!!
|
||||
if (!file.exists()) {
|
||||
page?.status = Page.ERROR
|
||||
return
|
||||
}
|
||||
|
||||
progress_text.visibility = View.INVISIBLE
|
||||
image_view.visibility = View.VISIBLE
|
||||
image_view.setImage(ImageSource.uri(file.uri))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -116,7 +116,7 @@ class WebtoonReader : BaseReader() {
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
val savedPosition = pages.getOrNull(layoutManager.findFirstVisibleItemPosition())?.pageNumber ?: 0
|
||||
val savedPosition = pages.getOrNull(layoutManager.findFirstVisibleItemPosition())?.index ?: 0
|
||||
outState.putInt(SAVED_POSITION, savedPosition)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
@ -163,7 +163,7 @@ class WebtoonReader : BaseReader() {
|
||||
* @param currentPage the initial page to display.
|
||||
*/
|
||||
override fun onChapterSet(chapter: ReaderChapter, currentPage: Page) {
|
||||
this.currentPage = currentPage.pageNumber
|
||||
this.currentPage = currentPage.index
|
||||
|
||||
// Make sure the view is already initialized.
|
||||
if (view != null) {
|
||||
|
@ -1,6 +1,7 @@
|
||||
package eu.kanade.tachiyomi.ui.recent_updates
|
||||
|
||||
import android.os.Bundle
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaChapter
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
@ -97,7 +98,10 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
|
||||
.map { mangaChapters ->
|
||||
mangaChapters.map { it.toModel() }
|
||||
}
|
||||
.doOnNext { chapters = it }
|
||||
.doOnNext {
|
||||
setDownloadedChapters(it)
|
||||
chapters = it
|
||||
}
|
||||
// Group chapters by the date they were fetched on a ordered map.
|
||||
.flatMap { recentItems ->
|
||||
Observable.from(recentItems)
|
||||
@ -142,18 +146,29 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
|
||||
// downloaded and assign it to the status.
|
||||
if (download != null) {
|
||||
model.download = download
|
||||
} else {
|
||||
// Get source of chapter.
|
||||
val source = sourceManager.get(manga.source)!!
|
||||
|
||||
model.status = if (downloadManager.isChapterDownloaded(source, manga, chapter))
|
||||
Download.DOWNLOADED
|
||||
else
|
||||
Download.NOT_DOWNLOADED
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and assigns the list of downloaded chapters.
|
||||
*
|
||||
* @param chapters the list of chapter from the database.
|
||||
*/
|
||||
private fun setDownloadedChapters(chapters: List<RecentChapter>) {
|
||||
val cachedDirs = mutableMapOf<Long, UniFile?>()
|
||||
|
||||
chapters.forEach { chapter ->
|
||||
val manga = chapter.manga
|
||||
val mangaDir = cachedDirs.getOrPut(manga.id!!)
|
||||
{ downloadManager.findMangaDir(sourceManager.get(manga.source)!!, manga) }
|
||||
|
||||
if (mangaDir?.findFile(downloadManager.getChapterDirName(chapter)) != null) {
|
||||
chapter.status = Download.DOWNLOADED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status of chapters.
|
||||
* @param download download object containing progress.
|
||||
@ -207,10 +222,6 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
|
||||
* @param chapters list of chapters
|
||||
*/
|
||||
fun deleteChapters(chapters: List<RecentChapter>) {
|
||||
val wasRunning = downloadManager.isRunning
|
||||
if (wasRunning) {
|
||||
DownloadService.stop(context)
|
||||
}
|
||||
Observable.from(chapters)
|
||||
.doOnNext { deleteChapter(it) }
|
||||
.toList()
|
||||
@ -218,9 +229,6 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeFirst({ view, result ->
|
||||
view.onChaptersDeleted()
|
||||
if (wasRunning) {
|
||||
DownloadService.start(context)
|
||||
}
|
||||
}, { view, error ->
|
||||
view.onChaptersDeletedError(error)
|
||||
})
|
||||
@ -253,7 +261,7 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
|
||||
*/
|
||||
private fun deleteChapter(chapter: RecentChapter) {
|
||||
val source = sourceManager.get(chapter.manga.source) ?: return
|
||||
downloadManager.queue.del(chapter)
|
||||
downloadManager.queue.remove(chapter)
|
||||
downloadManager.deleteChapter(source, chapter.manga, chapter)
|
||||
chapter.status = Download.NOT_DOWNLOADED
|
||||
chapter.download = null
|
||||
|
@ -2,6 +2,8 @@ package eu.kanade.tachiyomi.ui.setting
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.support.v4.content.ContextCompat
|
||||
@ -11,6 +13,7 @@ import android.support.v7.widget.RecyclerView
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.nononsenseapps.filepicker.AbstractFilePickerFragment
|
||||
import com.nononsenseapps.filepicker.FilePickerActivity
|
||||
import com.nononsenseapps.filepicker.FilePickerFragment
|
||||
@ -26,7 +29,8 @@ import java.io.File
|
||||
class SettingsDownloadsFragment : SettingsFragment() {
|
||||
|
||||
companion object {
|
||||
val DOWNLOAD_DIR_CODE = 103
|
||||
const val DOWNLOAD_DIR_PRE_L = 103
|
||||
const val DOWNLOAD_DIR_L = 104
|
||||
|
||||
fun newInstance(rootKey: String): SettingsDownloadsFragment {
|
||||
val args = Bundle()
|
||||
@ -45,24 +49,30 @@ class SettingsDownloadsFragment : SettingsFragment() {
|
||||
downloadDirPref.setOnPreferenceClickListener {
|
||||
|
||||
val currentDir = preferences.downloadsDirectory().getOrDefault()
|
||||
val externalDirs = getExternalFilesDirs() + getString(R.string.custom_dir)
|
||||
val selectedIndex = externalDirs.indexOf(File(currentDir))
|
||||
val externalDirs = getExternalFilesDirs() + File(getString(R.string.custom_dir))
|
||||
val selectedIndex = externalDirs.map(File::toString).indexOfFirst { it in currentDir }
|
||||
|
||||
MaterialDialog.Builder(activity)
|
||||
.items(externalDirs)
|
||||
.itemsCallbackSingleChoice(selectedIndex, { dialog, view, which, text ->
|
||||
if (which == externalDirs.lastIndex) {
|
||||
// Custom dir selected, open directory selector
|
||||
val i = Intent(activity, CustomLayoutPickerActivity::class.java)
|
||||
i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false)
|
||||
i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true)
|
||||
i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR)
|
||||
i.putExtra(FilePickerActivity.EXTRA_START_PATH, currentDir)
|
||||
if (Build.VERSION.SDK_INT < 21) {
|
||||
// Custom dir selected, open directory selector
|
||||
val i = Intent(activity, CustomLayoutPickerActivity::class.java)
|
||||
i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false)
|
||||
i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true)
|
||||
i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR)
|
||||
i.putExtra(FilePickerActivity.EXTRA_START_PATH, currentDir)
|
||||
|
||||
startActivityForResult(i, DOWNLOAD_DIR_CODE)
|
||||
startActivityForResult(i, DOWNLOAD_DIR_PRE_L)
|
||||
} else {
|
||||
val i = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
startActivityForResult(i, DOWNLOAD_DIR_L)
|
||||
}
|
||||
} else {
|
||||
// One of the predefined folders was selected
|
||||
preferences.downloadsDirectory().set(text.toString())
|
||||
val path = Uri.fromFile(File(text.toString()))
|
||||
preferences.downloadsDirectory().set(path.toString())
|
||||
}
|
||||
true
|
||||
})
|
||||
@ -72,7 +82,15 @@ class SettingsDownloadsFragment : SettingsFragment() {
|
||||
}
|
||||
|
||||
subscriptions += preferences.downloadsDirectory().asObservable()
|
||||
.subscribe { downloadDirPref.summary = it }
|
||||
.subscribe { path ->
|
||||
downloadDirPref.summary = path
|
||||
|
||||
// Don't display downloaded chapters in gallery apps creating a ".nomedia" file.
|
||||
val dir = UniFile.fromUri(context, Uri.parse(path))
|
||||
if (dir != null && dir.exists()) {
|
||||
dir.createFile(".nomedia")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getExternalFilesDirs(): List<File> {
|
||||
@ -85,8 +103,22 @@ class SettingsDownloadsFragment : SettingsFragment() {
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (data != null && requestCode == DOWNLOAD_DIR_CODE && resultCode == Activity.RESULT_OK) {
|
||||
preferences.downloadsDirectory().set(data.data.path)
|
||||
when (requestCode) {
|
||||
DOWNLOAD_DIR_PRE_L -> if (data != null && resultCode == Activity.RESULT_OK) {
|
||||
val uri = Uri.fromFile(File(data.data.path))
|
||||
preferences.downloadsDirectory().set(uri.toString())
|
||||
}
|
||||
DOWNLOAD_DIR_L -> if (data != null && resultCode == Activity.RESULT_OK) {
|
||||
val uri = data.data
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
||||
@Suppress("NewApi")
|
||||
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
|
||||
val file = UniFile.fromTreeUri(context, uri)
|
||||
preferences.downloadsDirectory().set(file.uri.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,10 +1,11 @@
|
||||
package eu.kanade.tachiyomi.util
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.PowerManager
|
||||
import android.support.annotation.StringRes
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import android.support.v4.content.ContextCompat
|
||||
@ -54,8 +55,13 @@ val Context.notificationManager: NotificationManager
|
||||
get() = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
/**
|
||||
* Property to get the alarm manager from the context.
|
||||
* @return the alarm manager.
|
||||
* Property to get the connectivity manager from the context.
|
||||
*/
|
||||
val Context.alarmManager: AlarmManager
|
||||
get() = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val Context.connectivityManager: ConnectivityManager
|
||||
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
/**
|
||||
* Property to get the power manager from the context.
|
||||
*/
|
||||
val Context.powerManager: PowerManager
|
||||
get() = getSystemService(Context.POWER_SERVICE) as PowerManager
|
@ -5,10 +5,6 @@ import java.net.URISyntaxException;
|
||||
|
||||
public final class UrlUtil {
|
||||
|
||||
private static final String JPG = ".jpg";
|
||||
private static final String PNG = ".png";
|
||||
private static final String GIF = ".gif";
|
||||
|
||||
private UrlUtil() throws InstantiationException {
|
||||
throw new InstantiationException("This class is not for instantiation");
|
||||
}
|
||||
@ -27,36 +23,4 @@ public final class UrlUtil {
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isJpg(String url) {
|
||||
return containsIgnoreCase(url, JPG);
|
||||
}
|
||||
|
||||
public static boolean isPng(String url) {
|
||||
return containsIgnoreCase(url, PNG);
|
||||
}
|
||||
|
||||
public static boolean isGif(String url) {
|
||||
return containsIgnoreCase(url, GIF);
|
||||
}
|
||||
|
||||
public static boolean containsIgnoreCase(String src, String what) {
|
||||
final int length = what.length();
|
||||
if (length == 0)
|
||||
return true; // Empty string is contained
|
||||
|
||||
final char firstLo = Character.toLowerCase(what.charAt(0));
|
||||
final char firstUp = Character.toUpperCase(what.charAt(0));
|
||||
|
||||
for (int i = src.length() - length; i >= 0; i--) {
|
||||
// Quick check before calling the more expensive regionMatches() method:
|
||||
final char ch = src.charAt(i);
|
||||
if (ch != firstLo && ch != firstUp)
|
||||
continue;
|
||||
|
||||
if (src.regionMatches(true, i, what, 0, length))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<changelog bulletedList="false">
|
||||
|
||||
<changelogversion changeDate="" versionName="r959">
|
||||
<changelogtext>The download manager has been rewritten and it's possible some of your downloads
|
||||
aren't recognized anymore. You may have to check your downloads folder and manually delete those.
|
||||
</changelogtext>
|
||||
<changelogtext>You can now download to any folder in your SD card.</changelogtext>
|
||||
<changelogtext>The download directory setting has been reset.</changelogtext>
|
||||
</changelogversion>
|
||||
|
||||
<changelogversion changeDate="" versionName="r857">
|
||||
<changelogtext>[b]Important![/b] Delete after read has been updated.
|
||||
This means the value has been reset set to disabled.
|
||||
|
@ -42,12 +42,11 @@
|
||||
<string name="pref_filter_downloaded_key">pref_filter_downloaded_key</string>
|
||||
<string name="pref_filter_unread_key">pref_filter_unread_key</string>
|
||||
|
||||
<string name="pref_download_directory_key">pref_download_directory_key</string>
|
||||
<string name="pref_download_directory_key">download_directory</string>
|
||||
<string name="pref_download_slots_key">pref_download_slots_key</string>
|
||||
<string name="pref_remove_after_read_slots_key">remove_after_read_slots</string>
|
||||
<string name="pref_download_only_over_wifi_key">pref_download_only_over_wifi_key</string>
|
||||
<string name="pref_remove_after_marked_as_read_key">pref_remove_after_marked_as_read_key</string>
|
||||
<string name="pref_category_remove_after_read_key">pref_category_remove_after_read_key</string>
|
||||
<string name="pref_last_used_category_key">last_used_category</string>
|
||||
|
||||
<string name="pref_source_languages">pref_source_languages</string>
|
||||
|
@ -350,10 +350,12 @@
|
||||
<string name="information_empty_library">Empty library</string>
|
||||
|
||||
<!-- Download Notification -->
|
||||
<string name="download_notifier_downloader_title">Downloader</string>
|
||||
<string name="download_notifier_title_error">Error</string>
|
||||
<string name="download_notifier_unkown_error">An unexpected error occurred while downloading chapter</string>
|
||||
<string name="download_notifier_page_error">A page is missing in directory</string>
|
||||
<string name="download_notifier_page_ready_error">A page is not loaded</string>
|
||||
<string name="download_notifier_text_only_wifi">No wifi connection available</string>
|
||||
<string name="download_notifier_no_network">No network connection available</string>
|
||||
|
||||
</resources>
|
||||
|
Loading…
Reference in New Issue
Block a user