123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602 |
- package eu.kanade.tachiyomi.ui.reader
- import android.os.Bundle
- import android.os.Environment
- import android.webkit.MimeTypeMap
- import eu.kanade.tachiyomi.R
- import eu.kanade.tachiyomi.data.cache.ChapterCache
- import eu.kanade.tachiyomi.data.cache.CoverCache
- import eu.kanade.tachiyomi.data.database.DatabaseHelper
- import eu.kanade.tachiyomi.data.database.models.Chapter
- import eu.kanade.tachiyomi.data.database.models.History
- import eu.kanade.tachiyomi.data.database.models.Manga
- import eu.kanade.tachiyomi.data.database.models.Track
- import eu.kanade.tachiyomi.data.download.DownloadManager
- import eu.kanade.tachiyomi.data.preference.PreferencesHelper
- import eu.kanade.tachiyomi.data.track.TrackManager
- import eu.kanade.tachiyomi.source.LocalSource
- import eu.kanade.tachiyomi.source.SourceManager
- import eu.kanade.tachiyomi.source.model.Page
- import eu.kanade.tachiyomi.source.online.HttpSource
- import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
- import eu.kanade.tachiyomi.util.DiskUtil
- import eu.kanade.tachiyomi.util.RetryWithDelay
- import eu.kanade.tachiyomi.util.SharedData
- import eu.kanade.tachiyomi.util.toast
- import rx.Observable
- import rx.Subscription
- import rx.android.schedulers.AndroidSchedulers
- import rx.schedulers.Schedulers
- import timber.log.Timber
- import uy.kohesive.injekt.Injekt
- import uy.kohesive.injekt.api.get
- import java.io.File
- import java.net.URLConnection
- import java.util.Comparator
- import java.util.Date
- /**
- * Presenter of [ReaderActivity].
- */
- class ReaderPresenter(
- val prefs: PreferencesHelper = Injekt.get(),
- val db: DatabaseHelper = Injekt.get(),
- val downloadManager: DownloadManager = Injekt.get(),
- val trackManager: TrackManager = Injekt.get(),
- val sourceManager: SourceManager = Injekt.get(),
- val chapterCache: ChapterCache = Injekt.get(),
- val coverCache: CoverCache = Injekt.get()
- ) : BasePresenter<ReaderActivity>() {
- private val context = prefs.context
- /**
- * Manga being read.
- */
- lateinit var manga: Manga
- private set
- /**
- * Active chapter.
- */
- lateinit var chapter: ReaderChapter
- private set
- /**
- * Previous chapter of the active.
- */
- private var prevChapter: ReaderChapter? = null
- /**
- * Next chapter of the active.
- */
- private var nextChapter: ReaderChapter? = null
- /**
- * Source of the manga.
- */
- private val source by lazy { sourceManager.getOrStub(manga.source) }
- /**
- * 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.
- */
- private val chapterList by lazy {
- val dbChapters = db.getChapters(manga).executeAsBlocking().map { it.toModel() }
- val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) {
- Manga.SORTING_SOURCE -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) }
- Manga.SORTING_NUMBER -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) }
- else -> throw NotImplementedError("Unknown sorting method")
- }
- dbChapters.sortedWith(Comparator<Chapter> { c1, c2 -> sortFunction(c1, c2) })
- }
- /**
- * Map of chapters that have been loaded in the reader.
- */
- private val loadedChapters = hashMapOf<Long?, ReaderChapter>()
- /**
- * List of manga services linked to the active manga, or null if auto syncing is not enabled.
- */
- private var trackList: List<Track>? = null
- /**
- * Chapter loader whose job is to obtain the chapter list and initialize every page.
- */
- private val loader by lazy { ChapterLoader(downloadManager, manga, source) }
- /**
- * Subscription for appending a chapter to the reader (seamless mode).
- */
- private var appenderSubscription: Subscription? = null
- /**
- * Subscription for retrieving the adjacent chapters to the current one.
- */
- private var adjacentChaptersSubscription: Subscription? = null
- /**
- * Whether the active chapter has been loaded.
- */
- private var chapterLoaded = false
- companion object {
- /**
- * Id of the restartable that loads the active chapter.
- */
- private const val LOAD_ACTIVE_CHAPTER = 1
- }
- override fun onCreate(savedState: Bundle?) {
- super.onCreate(savedState)
- if (savedState == null) {
- val event = SharedData.get(ReaderEvent::class.java) ?: return
- manga = event.manga
- chapter = event.chapter.toModel()
- } else {
- manga = savedState.getSerializable(ReaderPresenter::manga.name) as Manga
- chapter = savedState.getSerializable(ReaderPresenter::chapter.name) as ReaderChapter
- }
- // Send the active manga to the view to initialize the reader.
- Observable.just(manga)
- .subscribeLatestCache({ view, manga -> view.onMangaOpen(manga) })
- // Retrieve the sync list if auto syncing is enabled.
- if (prefs.autoUpdateTrack()) {
- add(db.getTracks(manga).asRxSingle()
- .subscribe({ trackList = it }))
- }
- restartableLatestCache(LOAD_ACTIVE_CHAPTER,
- { loadChapterObservable(chapter) },
- { view, _ -> view.onChapterReady(this.chapter) },
- { view, error -> view.onChapterError(error) })
- if (savedState == null) {
- loadChapter(chapter)
- }
- }
- override fun onSave(state: Bundle) {
- chapter.requestedPage = chapter.last_page_read
- state.putSerializable(ReaderPresenter::manga.name, manga)
- state.putSerializable(ReaderPresenter::chapter.name, chapter)
- super.onSave(state)
- }
- override fun onDestroy() {
- loader.cleanup()
- onChapterLeft()
- super.onDestroy()
- }
- /**
- * Converts a chapter to a [ReaderChapter] if needed.
- */
- private fun Chapter.toModel(): ReaderChapter {
- if (this is ReaderChapter) return this
- return ReaderChapter(this)
- }
- /**
- * Returns an observable that loads the given chapter, discarding any previous work.
- *
- * @param chapter the now active chapter.
- */
- private fun loadChapterObservable(chapter: ReaderChapter): Observable<ReaderChapter> {
- loader.restart()
- return loader.loadChapter(chapter)
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .doOnNext { chapterLoaded = true }
- }
- /**
- * Obtains the adjacent chapters of the given one in a background thread, and notifies the view
- * when they are known.
- *
- * @param chapter the current active chapter.
- */
- private fun getAdjacentChapters(chapter: ReaderChapter) {
- // Keep only one subscription
- adjacentChaptersSubscription?.let { remove(it) }
- adjacentChaptersSubscription = Observable
- .fromCallable { getAdjacentChaptersStrategy(chapter) }
- .doOnNext { pair ->
- prevChapter = loadedChapters.getOrElse(pair.first?.id) { pair.first }
- nextChapter = loadedChapters.getOrElse(pair.second?.id) { pair.second }
- }
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribeLatestCache({ view, pair ->
- view.onAdjacentChapters(pair.first, pair.second)
- })
- }
- /**
- * Returns the previous and next chapters of the given one in a [Pair] according to the sorting
- * strategy set for the manga.
- *
- * @param chapter the current active chapter.
- * @param previousChapterAmount the desired number of chapters preceding the current active chapter (Default: 1).
- * @param nextChapterAmount the desired number of chapters succeeding the current active chapter (Default: 1).
- */
- private fun getAdjacentChaptersStrategy(chapter: ReaderChapter, previousChapterAmount: Int = 1, nextChapterAmount: Int = 1) = when (manga.sorting) {
- Manga.SORTING_SOURCE -> {
- val currChapterIndex = chapterList.indexOfFirst { chapter.id == it.id }
- val nextChapter = chapterList.getOrNull(currChapterIndex + nextChapterAmount)
- val prevChapter = chapterList.getOrNull(currChapterIndex - previousChapterAmount)
- Pair(prevChapter, nextChapter)
- }
- Manga.SORTING_NUMBER -> {
- val currChapterIndex = chapterList.indexOfFirst { chapter.id == it.id }
- val chapterNumber = chapter.chapter_number
- var prevChapter: ReaderChapter? = null
- for (i in (currChapterIndex - previousChapterAmount) downTo 0) {
- val c = chapterList[i]
- if (c.chapter_number < chapterNumber && c.chapter_number >= chapterNumber - previousChapterAmount) {
- prevChapter = c
- break
- }
- }
- var nextChapter: ReaderChapter? = null
- for (i in (currChapterIndex + nextChapterAmount) until chapterList.size) {
- val c = chapterList[i]
- if (c.chapter_number > chapterNumber && c.chapter_number <= chapterNumber + nextChapterAmount) {
- nextChapter = c
- break
- }
- }
- Pair(prevChapter, nextChapter)
- }
- else -> throw NotImplementedError("Unknown sorting method")
- }
- /**
- * Loads the given chapter and sets it as the active one. This method also accepts a requested
- * page, which will be set as active when it's displayed in the view.
- *
- * @param chapter the chapter to load.
- * @param requestedPage the requested page from the view.
- */
- private fun loadChapter(chapter: ReaderChapter, requestedPage: Int = 0) {
- // Cleanup any append.
- appenderSubscription?.let { remove(it) }
- this.chapter = loadedChapters.getOrPut(chapter.id) { chapter }
- // If the chapter is partially read, set the starting page to the last the user read
- // otherwise use the requested page.
- chapter.requestedPage = if (!chapter.read) chapter.last_page_read else requestedPage
- // Reset next and previous chapter. They have to be fetched again
- nextChapter = null
- prevChapter = null
- chapterLoaded = false
- start(LOAD_ACTIVE_CHAPTER)
- getAdjacentChapters(chapter)
- }
- /**
- * Changes the active chapter, but doesn't load anything. Called when changing chapters from
- * the reader with the seamless mode.
- *
- * @param chapter the chapter to set as active.
- */
- fun setActiveChapter(chapter: ReaderChapter) {
- onChapterLeft()
- this.chapter = chapter
- nextChapter = null
- prevChapter = null
- getAdjacentChapters(chapter)
- }
- /**
- * Appends the next chapter to the reader, if possible.
- */
- fun appendNextChapter() {
- appenderSubscription?.let { remove(it) }
- val nextChapter = nextChapter ?: return
- val chapterToLoad = loadedChapters.getOrPut(nextChapter.id) { nextChapter }
- appenderSubscription = loader.loadChapter(chapterToLoad)
- .subscribeOn(Schedulers.io())
- .retryWhen(RetryWithDelay(1, { 3000 }))
- .observeOn(AndroidSchedulers.mainThread())
- .subscribeLatestCache({ view, chapter ->
- view.onAppendChapter(chapter)
- }, { view, _ ->
- view.onChapterAppendError()
- })
- }
- /**
- * Retries a page that failed to load due to network error or corruption.
- *
- * @param page the page that failed.
- */
- fun retryPage(page: Page?) {
- if (page != null && source is HttpSource) {
- page.status = Page.QUEUE
- val imageUrl = page.imageUrl
- if (imageUrl != null && !page.chapter.isDownloaded) {
- val key = DiskUtil.hashKeyForDisk(page.url)
- chapterCache.removeFileFromCache(key)
- }
- loader.retryPage(page)
- }
- }
- /**
- * Called before loading another chapter or leaving the reader. It allows to do operations
- * over the chapter read like saving progress
- */
- fun onChapterLeft() {
- // Reference these locally because they are needed later from another thread.
- val chapter = chapter
- val pages = chapter.pages ?: return
- Observable.fromCallable {
- // Cache current page list progress for online chapters to allow a faster reopen
- if (!chapter.isDownloaded) {
- source.let {
- if (it is HttpSource) chapterCache.putPageListToCache(chapter, pages)
- }
- }
- 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()
- try {
- val history = History.create(chapter).apply { last_read = Date().time }
- db.updateHistoryLastRead(history).executeAsBlocking()
- } catch (error: Exception) {
- // TODO find out why it crashes
- Timber.e(error)
- }
- }
- .subscribeOn(Schedulers.io())
- .subscribe()
- }
- /**
- * Called when the active page changes in the reader.
- *
- * @param page the active page
- */
- fun onPageChanged(page: Page) {
- val chapter = page.chapter
- chapter.last_page_read = page.index
- if (chapter.pages!!.last() === page) {
- chapter.read = true
- }
- if (!chapter.isDownloaded && page.status == Page.QUEUE) {
- loader.loadPriorizedPage(page)
- }
- }
- /**
- * Delete selected chapter
- *
- * @param chapter chapter that is selected
- * @param manga manga that belongs to chapter
- */
- fun deleteChapter(chapter: ReaderChapter, manga: Manga) {
- chapter.isDownloaded = false
- chapter.pages?.forEach { it.status == Page.QUEUE }
- downloadManager.deleteChapter(chapter, manga, source)
- }
- /**
- * Returns the chapter to be marked as last read in sync services or 0 if no update required.
- */
- fun getTrackChapterToUpdate(): Int {
- val trackList = trackList
- if (chapter.pages == null || trackList == null || trackList.isEmpty())
- return 0
- val prevChapter = prevChapter
- // Get the last chapter read from the reader.
- val lastChapterRead = if (chapter.read)
- Math.floor(chapter.chapter_number.toDouble()).toInt()
- else if (prevChapter != null && prevChapter.read)
- Math.floor(prevChapter.chapter_number.toDouble()).toInt()
- else
- return 0
- return if (trackList.any { lastChapterRead > it.last_chapter_read })
- lastChapterRead
- else
- 0
- }
- /**
- * Starts the service that updates the last chapter read in sync services
- */
- fun updateTrackLastChapterRead(lastChapterRead: Int) {
- trackList?.forEach { track ->
- val service = trackManager.getService(track.sync_id)
- if (service != null && service.isLogged && lastChapterRead > track.last_chapter_read) {
- track.last_chapter_read = lastChapterRead
- // We wan't these to execute even if the presenter is destroyed and leaks for a
- // while. The view can still be garbage collected.
- Observable.defer { service.update(track) }
- .map { db.insertTrack(track).executeAsBlocking() }
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe({}, { Timber.e(it) })
- }
- }
- }
- /**
- * Loads the next chapter.
- *
- * @return true if the next chapter is being loaded, false if there is no next chapter.
- */
- fun loadNextChapter(): Boolean {
- // Avoid skipping chapters.
- if (!chapterLoaded) return true
- nextChapter?.let {
- onChapterLeft()
- loadChapter(it, 0)
- return true
- }
- return false
- }
- /**
- * Loads the next chapter.
- *
- * @return true if the previous chapter is being loaded, false if there is no previous chapter.
- */
- fun loadPreviousChapter(): Boolean {
- // Avoid skipping chapters.
- if (!chapterLoaded) return true
- prevChapter?.let {
- onChapterLeft()
- loadChapter(it, if (it.read) -1 else 0)
- return true
- }
- return false
- }
- /**
- * Returns true if there's a next chapter.
- */
- fun hasNextChapter(): Boolean {
- return nextChapter != null
- }
- /**
- * Returns true if there's a previous chapter.
- */
- fun hasPreviousChapter(): Boolean {
- return prevChapter != null
- }
- /**
- * Updates the viewer for this manga.
- *
- * @param viewer the id of the viewer to set.
- */
- fun updateMangaViewer(viewer: Int) {
- manga.viewer = viewer
- db.insertManga(manga).executeAsBlocking()
- }
- /**
- * Update cover with page file.
- */
- internal fun setImageAsCover(page: Page) {
- try {
- if (manga.source == LocalSource.ID) {
- val input = context.contentResolver.openInputStream(page.uri)
- LocalSource.updateCover(context, manga, input)
- context.toast(R.string.cover_updated)
- return
- }
- val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found")
- if (manga.favorite) {
- val input = context.contentResolver.openInputStream(page.uri)
- coverCache.copyToCache(thumbUrl, input)
- context.toast(R.string.cover_updated)
- } else {
- context.toast(R.string.notification_first_add_to_library)
- }
- } catch (error: Exception) {
- context.toast(R.string.notification_cover_update_failed)
- Timber.e(error)
- }
- }
- /**
- * Save page to local storage.
- */
- internal fun savePage(page: Page) {
- if (page.status != Page.READY)
- return
- // Used to show image notification.
- val imageNotifier = SaveImageNotifier(context)
- // Remove the notification if it already exists (user feedback).
- imageNotifier.onClear()
- // 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 {
- // Folder where the image will be saved.
- val destDir = File(pictureDirectory)
- destDir.mkdirs()
- // Find out file mime type.
- val mime = context.contentResolver.getType(page.uri)
- ?: context.contentResolver.openInputStream(page.uri).buffered().use {
- URLConnection.guessContentTypeFromStream(it)
- }
- // Build destination file.
- val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
- val filename = DiskUtil.buildValidFilename(
- "${manga.title} - ${chapter.name}") + " - ${page.number}.$ext"
- val destFile = File(destDir, filename)
- context.contentResolver.openInputStream(page.uri).use { input ->
- destFile.outputStream().use { output ->
- input.copyTo(output)
- }
- }
- DiskUtil.scanMedia(context, destFile)
- imageNotifier.onComplete(destFile)
- }
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe({
- context.toast(R.string.picture_saved)
- }, { error ->
- Timber.e(error)
- imageNotifier.onError(error.message)
- })
- }
- }
|