Upstream merge

This commit is contained in:
NerdNumber9 2019-03-31 00:23:44 -04:00
commit 5fbe1a8614
178 changed files with 12799 additions and 8435 deletions

View File

@ -4,6 +4,8 @@
**App version:** **App version:**
**Android version:**
**Issue/Request:** **Issue/Request:**
**Steps to reproduce (if applicable)** **Steps to reproduce (if applicable)**

101
README.md Executable file → Normal file
View File

@ -1,55 +1,70 @@
<div style="text-align:center"><img src ="https://raw.githubusercontent.com/NerdNumber9/TachiyomiEH/master/branding/teh-banner.png" /></div> | Build | Stable | Dev | Contribute | Contact |
<br> |-------|----------|---------|------------|---------|
| [![Travis](https://img.shields.io/travis/inorichi/tachiyomi.svg)](https://travis-ci.org/inorichi/tachiyomi) | [![stable release](https://img.shields.io/github/release/inorichi/tachiyomi.svg?maxAge=3600&label=download%20(autoupdate%20included))](https://github.com/inorichi/tachiyomi/releases) | [![latest dev build](https://img.shields.io/badge/download-latest%20build-blue.svg)](http://tachiyomi.kanade.eu/latest) | [![Translation status](https://hosted.weblate.org/widgets/tachiyomi/-/svg-badge.svg)](https://hosted.weblate.org/engage/tachiyomi/?utm_source=widget) | [![Discord](https://img.shields.io/discord/349436576037732353.svg)](https://discord.gg/tachiyomi) |
TachiyomiEH is a free and open source E-Hentai, ExHentai and PervEden galleries reader for Android.
TachiyomiEH is a fork of the [original Tachiyomi app](https://github.com/inorichi/tachiyomi). # ![app icon](./.github/readme-images/app-icon.png)Tachiyomi
### E-Hentai Thread Tachiyomi is a free and open source manga reader for Android.
[https://forums.e-hentai.org/index.php?showtopic=185421](https://forums.e-hentai.org/index.php?showtopic=185421)
# Download ![screenshots of app](./.github/readme-images/screens.png)
[![stable release](https://img.shields.io/github/release/NerdNumber9/TachiyomiEH.svg?maxAge=3600&label=stable)](https://github.com/NerdNumber9/TachiyomiEH/releases)
# Features ## Features
* Online and offline reading Features include:
* Configurable reader with multiple viewers and settings * Online reading from sources such as KissManga, MangaFox, [and more](https://github.com/inorichi/tachiyomi-extensions)
* MyAnimeList support * Local reading of downloaded manga
* Track your reading position * Configurable reader with multiple viewers, reading directions and other settings
* Chapter filtering * [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), and [Kitsu](https://kitsu.io/explore/anime) support
* Schedule searching for updates
* Categories to organize your library * Categories to organize your library
* Log into ExHentai * Light and dark themes
* Read both NSFW and SFW manga/doujinshi * Schedule updating your library for new chapters
* Full offline tag/namespace searching support * Create backups locally to read offline or to your desired cloud service
* Batch import galleries
* Automatically open E-Hentai/ExHentai links
* Lock the app with a PIN code
### Built-in manga sources ## Download
##### SFW Get the app from our [releases page](https://github.com/inorichi/tachiyomi/releases).
* Batoto
* Mangahere
* Mangafox
* Kissmanga
* Readmanga
* Mintmanga
* Mangachan
* Readmangatoday
* Mangasee
* Wiemanga
* And more!
##### NSFW If you want to try new features before they get to the stable release, you can download the dev version [here](http://tachiyomi.kanade.eu/latest). (auto-updates not included)
* E-Hentai
* ExHentai
* PervEden
* nhentai
* Tsumino
* Hitomi.la
TachiyomiEH is fully compatible with Tachiyomi source extensions. ## Issues, Feature Requests and Contributing
Backups from Tachiyomi are also compatible with TachiyomiEH (and vice versa).
Please make sure to read the full guidelines. Your issue may be closed without warning if you do not.
<details><summary>Issues</summary>
1. **Before reporting a new issue, take a look at the [FAQ](https://github.com/inorichi/tachiyomi/wiki/FAQ), the [changelog](https://github.com/inorichi/tachiyomi/releases) and the already opened [issues](https://github.com/inorichi/tachiyomi/issues).**
2. If you are unsure, ask here: [![Discord](https://img.shields.io/discord/349436576037732353.svg)](https://discord.gg/tachiyomi)
</details>
<details><summary>Bugs</summary>
* Include version (Setting > About > Version)
* If not latest, try updating, it may have already been solved
* Dev version is equal to the number of commits as seen in the main page
* Include steps to reproduce (if not obvious from description)
* Include screenshot (if needed)
* If it could be device-dependent, try reproducing on another device (if possible)
* For large logs use http://pastebin.com/ (or similar)
* Don't group unrelated requests into one issue
DO: https://github.com/inorichi/tachiyomi/issues/24 https://github.com/inorichi/tachiyomi/issues/71
DON'T: https://github.com/inorichi/tachiyomi/issues/75
</details>
<details><summary>Feature Requests</summary>
* Write a detailed issue, explaning what it should do or how. Avoid writing just "like X app does"
* Include screenshot (if needed)
Catalogue requests should be created at https://github.com/inorichi/tachiyomi-extensions, they do not belong in this repository.
</details>
## FAQ
[See our wiki.](https://github.com/inorichi/tachiyomi/wiki/FAQ)
You can also reach out to us on [Discord](https://discord.gg/tachiyomi).
## License ## License

View File

@ -36,7 +36,7 @@ ext {
android { android {
compileSdkVersion 27 compileSdkVersion 27
buildToolsVersion '27.0.3' buildToolsVersion '28.0.3'
publishNonDefault true publishNonDefault true
defaultConfig { defaultConfig {
@ -44,8 +44,8 @@ android {
minSdkVersion 16 minSdkVersion 16
targetSdkVersion 27 targetSdkVersion 27
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
versionCode 7404 versionCode 8200
versionName "v7.4.4-EH" versionName "v8.2.0-EH"
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\"" buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\"" buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
@ -111,7 +111,7 @@ android {
dependencies { dependencies {
// Modified dependencies // Modified dependencies
implementation 'com.github.inorichi:subsampling-scale-image-view:81b9d68' implementation 'com.github.inorichi:subsampling-scale-image-view:ac0dae7'
implementation 'com.github.inorichi:junrar-android:634c1f5' implementation 'com.github.inorichi:junrar-android:634c1f5'
// Android support library // Android support library
@ -170,7 +170,10 @@ dependencies {
implementation 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0' implementation 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
// Database // Database
implementation "com.pushtorefresh.storio:sqlite:1.13.0" implementation 'android.arch.persistence:db:1.0.0'
implementation 'com.github.inorichi.storio:storio-common:8be19de@aar'
implementation 'com.github.inorichi.storio:storio-sqlite:8be19de@aar'
implementation 'io.requery:sqlite-android:3.25.2'
// Model View Presenter // Model View Presenter
final nucleus_version = '3.0.0' final nucleus_version = '3.0.0'
@ -210,11 +213,12 @@ dependencies {
implementation 'com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.0.4' implementation 'com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.0.4'
implementation 'com.github.mthli:Slice:v1.2' implementation 'com.github.mthli:Slice:v1.2'
implementation 'me.gujun.android.taggroup:library:1.4@aar' implementation 'me.gujun.android.taggroup:library:1.4@aar'
implementation 'com.github.chrisbanes:PhotoView:2.1.3'
implementation 'com.github.inorichi:DirectionalViewPager:3acc51a'
// Conductor // Conductor
implementation "com.github.inorichi.Conductor:conductor:be8b3c5" implementation 'com.bluelinelabs:conductor:2.1.5'
implementation("com.bluelinelabs:conductor-support:2.1.5-SNAPSHOT") { implementation("com.bluelinelabs:conductor-support:2.1.5") {
exclude group: "com.bluelinelabs", module: "conductor"
exclude group: "com.android.support" exclude group: "com.android.support"
} }
implementation 'com.github.inorichi:conductor-support-preference:27.0.2' implementation 'com.github.inorichi:conductor-support-preference:27.0.2'
@ -264,7 +268,7 @@ dependencies {
} }
buildscript { buildscript {
ext.kotlin_version = '1.2.60' ext.kotlin_version = '1.2.71'
repositories { repositories {
mavenCentral() mavenCentral()
} }

View File

@ -43,8 +43,7 @@
android:resource="@xml/shortcuts" /> android:resource="@xml/shortcuts" />
</activity> </activity>
<activity <activity
android:name=".ui.reader.ReaderActivity" android:name=".ui.reader.ReaderActivity" />
android:theme="@style/Theme.Reader" />
<activity <activity
android:name=".widget.CustomLayoutPickerActivity" android:name=".widget.CustomLayoutPickerActivity"
android:label="@string/app_name" android:label="@string/app_name"
@ -76,14 +75,6 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" /> android:resource="@xml/provider_paths" />
</provider> </provider>
<provider
android:name=".util.ZipContentProvider"
android:authorities="${applicationId}.zip-provider"
android:exported="false" />
<provider
android:name=".util.RarContentProvider"
android:authorities="${applicationId}.rar-provider"
android:exported="false" />
<receiver <receiver
android:name=".data.notification.NotificationReceiver" android:name=".data.notification.NotificationReceiver"

View File

@ -50,13 +50,17 @@ open class App : Application() {
} }
protected open fun setupJobManager() { protected open fun setupJobManager() {
JobManager.create(this).addJobCreator { tag -> try {
when (tag) { JobManager.create(this).addJobCreator { tag ->
LibraryUpdateJob.TAG -> LibraryUpdateJob() when (tag) {
UpdaterJob.TAG -> UpdaterJob() LibraryUpdateJob.TAG -> LibraryUpdateJob()
BackupCreatorJob.TAG -> BackupCreatorJob() UpdaterJob.TAG -> UpdaterJob()
else -> null BackupCreatorJob.TAG -> BackupCreatorJob()
else -> null
}
} }
} catch (e: Exception) {
Timber.w("Can't initialize job manager")
} }
} }

View File

@ -1,10 +1,12 @@
package eu.kanade.tachiyomi.data.database package eu.kanade.tachiyomi.data.database
import android.arch.persistence.db.SupportSQLiteOpenHelper
import android.content.Context import android.content.Context
import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite
import eu.kanade.tachiyomi.data.database.mappers.* import eu.kanade.tachiyomi.data.database.mappers.*
import eu.kanade.tachiyomi.data.database.models.* import eu.kanade.tachiyomi.data.database.models.*
import eu.kanade.tachiyomi.data.database.queries.* import eu.kanade.tachiyomi.data.database.queries.*
import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
/** /**
* This class provides operations to manage the database through its interfaces. * This class provides operations to manage the database through its interfaces.
@ -12,8 +14,13 @@ import eu.kanade.tachiyomi.data.database.queries.*
open class DatabaseHelper(context: Context) open class DatabaseHelper(context: Context)
: MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries { : MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries {
private val configuration = SupportSQLiteOpenHelper.Configuration.builder(context)
.name(DbOpenCallback.DATABASE_NAME)
.callback(DbOpenCallback())
.build()
override val db = DefaultStorIOSQLite.builder() override val db = DefaultStorIOSQLite.builder()
.sqliteOpenHelper(DbOpenHelper(context)) .sqliteOpenHelper(RequerySQLiteOpenHelperFactory().create(configuration))
.addTypeMapping(Manga::class.java, MangaTypeMapping()) .addTypeMapping(Manga::class.java, MangaTypeMapping())
.addTypeMapping(Chapter::class.java, ChapterTypeMapping()) .addTypeMapping(Chapter::class.java, ChapterTypeMapping())
.addTypeMapping(Track::class.java, TrackTypeMapping()) .addTypeMapping(Track::class.java, TrackTypeMapping())

View File

@ -1,12 +1,13 @@
package eu.kanade.tachiyomi.data.database package eu.kanade.tachiyomi.data.database
import android.arch.persistence.db.SupportSQLiteDatabase
import android.arch.persistence.db.SupportSQLiteOpenHelper
import android.content.Context import android.content.Context
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper import android.database.sqlite.SQLiteOpenHelper
import eu.kanade.tachiyomi.data.database.tables.* import eu.kanade.tachiyomi.data.database.tables.*
class DbOpenHelper(context: Context) class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
: SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
companion object { companion object {
/** /**
@ -17,10 +18,10 @@ class DbOpenHelper(context: Context)
/** /**
* Version of the database. * Version of the database.
*/ */
const val DATABASE_VERSION = 7 const val DATABASE_VERSION = 8
} }
override fun onCreate(db: SQLiteDatabase) = with(db) { override fun onCreate(db: SupportSQLiteDatabase) = with(db) {
execSQL(MangaTable.createTableQuery) execSQL(MangaTable.createTableQuery)
execSQL(ChapterTable.createTableQuery) execSQL(ChapterTable.createTableQuery)
execSQL(TrackTable.createTableQuery) execSQL(TrackTable.createTableQuery)
@ -30,12 +31,13 @@ class DbOpenHelper(context: Context)
// DB indexes // DB indexes
execSQL(MangaTable.createUrlIndexQuery) execSQL(MangaTable.createUrlIndexQuery)
execSQL(MangaTable.createFavoriteIndexQuery) execSQL(MangaTable.createLibraryIndexQuery)
execSQL(ChapterTable.createMangaIdIndexQuery) execSQL(ChapterTable.createMangaIdIndexQuery)
execSQL(ChapterTable.createUnreadChaptersIndexQuery)
execSQL(HistoryTable.createChapterIdIndexQuery) execSQL(HistoryTable.createChapterIdIndexQuery)
} }
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
if (oldVersion < 2) { if (oldVersion < 2) {
db.execSQL(ChapterTable.sourceOrderUpdateQuery) db.execSQL(ChapterTable.sourceOrderUpdateQuery)
@ -60,9 +62,14 @@ class DbOpenHelper(context: Context)
if (oldVersion < 7) { if (oldVersion < 7) {
db.execSQL(TrackTable.addLibraryId) db.execSQL(TrackTable.addLibraryId)
} }
if (oldVersion < 8) {
db.execSQL("DROP INDEX IF EXISTS mangas_favorite_index")
db.execSQL(MangaTable.createLibraryIndexQuery)
db.execSQL(ChapterTable.createUnreadChaptersIndexQuery)
}
} }
override fun onConfigure(db: SQLiteDatabase) { override fun onConfigure(db: SupportSQLiteDatabase) {
db.setForeignKeyConstraintsEnabled(true) db.setForeignKeyConstraintsEnabled(true)
} }

View File

@ -6,10 +6,7 @@ import com.pushtorefresh.storio.sqlite.queries.RawQuery
import eu.kanade.tachiyomi.data.database.DbProvider import eu.kanade.tachiyomi.data.database.DbProvider
import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.resolvers.LibraryMangaGetResolver import eu.kanade.tachiyomi.data.database.resolvers.*
import eu.kanade.tachiyomi.data.database.resolvers.MangaFavoritePutResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaFlagsPutResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaLastUpdatedPutResolver
import eu.kanade.tachiyomi.data.database.tables.CategoryTable import eu.kanade.tachiyomi.data.database.tables.CategoryTable
import eu.kanade.tachiyomi.data.database.tables.ChapterTable import eu.kanade.tachiyomi.data.database.tables.ChapterTable
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable
@ -80,6 +77,11 @@ interface MangaQueries : DbProvider {
.withPutResolver(MangaFavoritePutResolver()) .withPutResolver(MangaFavoritePutResolver())
.prepare() .prepare()
fun updateMangaViewer(manga: Manga) = db.put()
.`object`(manga)
.withPutResolver(MangaViewerPutResolver())
.prepare()
fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare() fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare()
fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare() fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare()
@ -108,4 +110,4 @@ interface MangaQueries : DbProvider {
fun getTotalChapterManga() = db.get().listOfObjects(Manga::class.java) fun getTotalChapterManga() = db.get().listOfObjects(Manga::class.java)
.withQuery(RawQuery.builder().query(getTotalChapterMangaQuery()).observesTables(MangaTable.TABLE).build()).prepare(); .withQuery(RawQuery.builder().query(getTotalChapterMangaQuery()).observesTables(MangaTable.TABLE).build()).prepare();
} }

View File

@ -0,0 +1,32 @@
package eu.kanade.tachiyomi.data.database.resolvers
import android.content.ContentValues
import com.pushtorefresh.storio.sqlite.StorIOSQLite
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
import eu.kanade.tachiyomi.data.database.inTransactionReturn
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.tables.MangaTable
class MangaViewerPutResolver : PutResolver<Manga>() {
override fun performPut(db: StorIOSQLite, manga: Manga) = db.inTransactionReturn {
val updateQuery = mapToUpdateQuery(manga)
val contentValues = mapToContentValues(manga)
val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
}
fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder()
.table(MangaTable.TABLE)
.where("${MangaTable.COL_ID} = ?")
.whereArgs(manga.id)
.build()
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
put(MangaTable.COL_VIEWER, manga.viewer)
}
}

View File

@ -49,6 +49,10 @@ object ChapterTable {
val createMangaIdIndexQuery: String val createMangaIdIndexQuery: String
get() = "CREATE INDEX ${TABLE}_${COL_MANGA_ID}_index ON $TABLE($COL_MANGA_ID)" get() = "CREATE INDEX ${TABLE}_${COL_MANGA_ID}_index ON $TABLE($COL_MANGA_ID)"
val createUnreadChaptersIndexQuery: String
get() = "CREATE INDEX ${TABLE}_unread_by_manga_index ON $TABLE($COL_MANGA_ID, $COL_READ) " +
"WHERE $COL_READ = 0"
val sourceOrderUpdateQuery: String val sourceOrderUpdateQuery: String
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SOURCE_ORDER INTEGER DEFAULT 0" get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SOURCE_ORDER INTEGER DEFAULT 0"

View File

@ -60,6 +60,7 @@ object MangaTable {
val createUrlIndexQuery: String val createUrlIndexQuery: String
get() = "CREATE INDEX ${TABLE}_${COL_URL}_index ON $TABLE($COL_URL)" get() = "CREATE INDEX ${TABLE}_${COL_URL}_index ON $TABLE($COL_URL)"
val createFavoriteIndexQuery: String val createLibraryIndexQuery: String
get() = "CREATE INDEX ${TABLE}_${COL_FAVORITE}_index ON $TABLE($COL_FAVORITE)" get() = "CREATE INDEX library_${COL_FAVORITE}_index ON $TABLE($COL_FAVORITE) " +
"WHERE $COL_FAVORITE = 1"
} }

View File

@ -23,10 +23,12 @@ import java.util.concurrent.TimeUnit
* @param sourceManager the source manager. * @param sourceManager the source manager.
* @param preferences the preferences of the app. * @param preferences the preferences of the app.
*/ */
class DownloadCache(private val context: Context, class DownloadCache(
private val provider: DownloadProvider, private val context: Context,
private val sourceManager: SourceManager = Injekt.get(), private val provider: DownloadProvider,
private val preferences: PreferencesHelper = Injekt.get()) { private val sourceManager: SourceManager,
private val preferences: PreferencesHelper = Injekt.get()
) {
/** /**
* The interval after which this cache should be invalidated. 1 hour shouldn't cause major * The interval after which this cache should be invalidated. 1 hour shouldn't cause major
@ -194,6 +196,24 @@ class DownloadCache(private val context: Context,
} }
} }
/**
* Removes a list of chapters that have been deleted from this cache.
*
* @param chapters the list of chapter to remove.
* @param manga the manga of the chapter.
*/
@Synchronized
fun removeChapters(chapters: List<Chapter>, manga: Manga) {
val sourceDir = rootDir.files[manga.source] ?: return
val mangaDir = sourceDir.files[provider.getMangaDirName(manga)] ?: return
for (chapter in chapters) {
val chapterDirName = provider.getChapterDirName(chapter)
if (chapterDirName in mangaDir.files) {
mangaDir.files -= chapterDirName
}
}
}
/** /**
* Removes a manga that has been deleted from this cache. * Removes a manga that has been deleted from this cache.
* *

View File

@ -7,8 +7,10 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.DownloadQueue import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy
/** /**
* This class is used to manage chapter downloads in the application. It must be instantiated once * This class is used to manage chapter downloads in the application. It must be instantiated once
@ -19,6 +21,11 @@ import rx.Observable
*/ */
class DownloadManager(context: Context) { class DownloadManager(context: Context) {
/**
* The sources manager.
*/
private val sourceManager by injectLazy<SourceManager>()
/** /**
* Downloads provider, used to retrieve the folders where the chapters are or should be stored. * Downloads provider, used to retrieve the folders where the chapters are or should be stored.
*/ */
@ -27,12 +34,17 @@ class DownloadManager(context: Context) {
/** /**
* Cache of downloaded chapters. * Cache of downloaded chapters.
*/ */
private val cache = DownloadCache(context, provider) private val cache = DownloadCache(context, provider, sourceManager)
/** /**
* Downloader whose only task is to download chapters. * Downloader whose only task is to download chapters.
*/ */
private val downloader = Downloader(context, provider, cache) private val downloader = Downloader(context, provider, cache, sourceManager)
/**
* Queue to delay the deletion of a list of chapters until triggered.
*/
private val pendingDeleter = DownloadPendingDeleter(context)
/** /**
* Downloads queue, where the pending chapters are stored. * Downloads queue, where the pending chapters are stored.
@ -146,15 +158,20 @@ class DownloadManager(context: Context) {
} }
/** /**
* Deletes the directory of a downloaded chapter. * Deletes the directories of a list of downloaded chapters.
* *
* @param chapter the chapter to delete. * @param chapters the list of chapters to delete.
* @param manga the manga of the chapter. * @param manga the manga of the chapters.
* @param source the source of the chapter. * @param source the source of the chapters.
*/ */
fun deleteChapter(chapter: Chapter, manga: Manga, source: Source) { fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source) {
provider.findChapterDir(chapter, manga, source)?.delete() queue.remove(chapters)
cache.removeChapter(chapter, manga) val chapterDirs = provider.findChapterDirs(chapters, manga, source)
chapterDirs.forEach { it.delete() }
cache.removeChapters(chapters, manga)
if (cache.getDownloadCount(manga) == 0) { // Delete manga directory if empty
chapterDirs.firstOrNull()?.parentFile?.delete()
}
} }
/** /**
@ -164,7 +181,30 @@ class DownloadManager(context: Context) {
* @param source the source of the manga. * @param source the source of the manga.
*/ */
fun deleteManga(manga: Manga, source: Source) { fun deleteManga(manga: Manga, source: Source) {
queue.remove(manga)
provider.findMangaDir(manga, source)?.delete() provider.findMangaDir(manga, source)?.delete()
cache.removeManga(manga) cache.removeManga(manga)
} }
/**
* Adds a list of chapters to be deleted later.
*
* @param chapters the list of chapters to delete.
* @param manga the manga of the chapters.
*/
fun enqueueDeleteChapters(chapters: List<Chapter>, manga: Manga) {
pendingDeleter.addChapters(chapters, manga)
}
/**
* Triggers the execution of the deletion of pending chapters.
*/
fun deletePendingChapters() {
val pendingChapters = pendingDeleter.getPendingChapters()
for ((manga, chapters) in pendingChapters) {
val source = sourceManager.get(manga.source) ?: continue
deleteChapters(chapters, manga, source)
}
}
} }

View File

@ -37,7 +37,7 @@ internal class DownloadNotifier(private val context: Context) {
*/ */
var initialQueueSize = 0 var initialQueueSize = 0
set(value) { set(value) {
if (value != 0){ if (value != 0) {
isSingleChapter = (value == 1) isSingleChapter = (value == 1)
} }
field = value field = value
@ -99,6 +99,10 @@ internal class DownloadNotifier(private val context: Context) {
// Open download manager when clicked // Open download manager when clicked
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
isDownloading = true isDownloading = true
// Pause action
addAction(R.drawable.ic_av_pause_grey_24dp_img,
context.getString(R.string.action_pause),
NotificationReceiver.pauseDownloadsPendingBroadcast(context))
} }
val title = download.manga.title.chop(15) val title = download.manga.title.chop(15)

View File

@ -0,0 +1,180 @@
package eu.kanade.tachiyomi.data.download
import android.content.Context
import com.github.salomonbrys.kotson.fromJson
import com.google.gson.Gson
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import uy.kohesive.injekt.injectLazy
/**
* Class used to keep a list of chapters for future deletion.
*
* @param context the application context.
*/
class DownloadPendingDeleter(context: Context) {
/**
* Gson instance to encode and decode chapters.
*/
private val gson by injectLazy<Gson>()
/**
* Preferences used to store the list of chapters to delete.
*/
private val prefs = context.getSharedPreferences("chapters_to_delete", Context.MODE_PRIVATE)
/**
* Last added chapter, used to avoid decoding from the preference too often.
*/
private var lastAddedEntry: Entry? = null
/**
* Adds a list of chapters for future deletion.
*
* @param chapters the chapters to be deleted.
* @param manga the manga of the chapters.
*/
@Synchronized
fun addChapters(chapters: List<Chapter>, manga: Manga) {
val lastEntry = lastAddedEntry
val newEntry = if (lastEntry != null && lastEntry.manga.id == manga.id) {
// Append new chapters
val newChapters = lastEntry.chapters.addUniqueById(chapters)
// If no chapters were added, do nothing
if (newChapters.size == lastEntry.chapters.size) return
// Last entry matches the manga, reuse it to avoid decoding json from preferences
lastEntry.copy(chapters = newChapters)
} else {
val existingEntry = prefs.getString(manga.id!!.toString(), null)
if (existingEntry != null) {
// Existing entry found on preferences, decode json and add the new chapter
val savedEntry = gson.fromJson<Entry>(existingEntry)
// Append new chapters
val newChapters = savedEntry.chapters.addUniqueById(chapters)
// If no chapters were added, do nothing
if (newChapters.size == savedEntry.chapters.size) return
savedEntry.copy(chapters = newChapters)
} else {
// No entry has been found yet, create a new one
Entry(chapters.map { it.toEntry() }, manga.toEntry())
}
}
// Save current state
val json = gson.toJson(newEntry)
prefs.edit().putString(newEntry.manga.id.toString(), json).apply()
lastAddedEntry = newEntry
}
/**
* Returns the list of chapters to be deleted grouped by its manga.
*
* Note: the returned list of manga and chapters only contain basic information needed by the
* downloader, so don't use them for anything else.
*/
@Synchronized
fun getPendingChapters(): Map<Manga, List<Chapter>> {
val entries = decodeAll()
prefs.edit().clear().apply()
lastAddedEntry = null
return entries.associate { entry ->
entry.manga.toModel() to entry.chapters.map { it.toModel() }
}
}
/**
* Decodes all the chapters from preferences.
*/
private fun decodeAll(): List<Entry> {
return prefs.all.values.mapNotNull { rawEntry ->
try {
(rawEntry as? String)?.let { gson.fromJson<Entry>(it) }
} catch (e: Exception) {
null
}
}
}
/**
* Returns a copy of chapter entries ensuring no duplicates by chapter id.
*/
private fun List<ChapterEntry>.addUniqueById(chapters: List<Chapter>): List<ChapterEntry> {
val newList = toMutableList()
for (chapter in chapters) {
if (none { it.id == chapter.id }) {
newList.add(chapter.toEntry())
}
}
return newList
}
/**
* Class used to save an entry of chapters with their manga into preferences.
*/
private data class Entry(
val chapters: List<ChapterEntry>,
val manga: MangaEntry
)
/**
* Class used to save an entry for a chapter into preferences.
*/
private data class ChapterEntry(
val id: Long,
val url: String,
val name: String
)
/**
* Class used to save an entry for a manga into preferences.
*/
private data class MangaEntry(
val id: Long,
val url: String,
val title: String,
val source: Long
)
/**
* Returns a manga entry from a manga model.
*/
private fun Manga.toEntry(): MangaEntry {
return MangaEntry(id!!, url, title, source)
}
/**
* Returns a chapter entry from a chapter model.
*/
private fun Chapter.toEntry(): ChapterEntry {
return ChapterEntry(id!!, url, name)
}
/**
* Returns a manga model from a manga entry.
*/
private fun MangaEntry.toModel(): Manga {
return Manga.create(url, title, source).also {
it.id = id
}
}
/**
* Returns a chapter model from a chapter entry.
*/
private fun ChapterEntry.toModel(): Chapter {
return Chapter.create().also {
it.id = id
it.url = url
it.name = name
}
}
}

View File

@ -81,6 +81,18 @@ class DownloadProvider(private val context: Context) {
return mangaDir?.findFile(getChapterDirName(chapter)) return mangaDir?.findFile(getChapterDirName(chapter))
} }
/**
* Returns a list of downloaded directories for the chapters that exist.
*
* @param chapters the chapters to query.
* @param manga the manga of the chapter.
* @param source the source of the chapter.
*/
fun findChapterDirs(chapters: List<Chapter>, manga: Manga, source: Source): List<UniFile> {
val mangaDir = findMangaDir(manga, source) ?: return emptyList()
return chapters.mapNotNull { mangaDir.findFile(getChapterDirName(it)) }
}
/** /**
* Returns the download directory name for a source. * Returns the download directory name for a source.
* *
@ -108,4 +120,4 @@ class DownloadProvider(private val context: Context) {
return DiskUtil.buildValidFilename(chapter.name) return DiskUtil.buildValidFilename(chapter.name)
} }
} }

View File

@ -14,7 +14,10 @@ import uy.kohesive.injekt.injectLazy
* *
* @param context the application context. * @param context the application context.
*/ */
class DownloadStore(context: Context) { class DownloadStore(
context: Context,
private val sourceManager: SourceManager
) {
/** /**
* Preference file where active downloads are stored. * Preference file where active downloads are stored.
@ -26,11 +29,6 @@ class DownloadStore(context: Context) {
*/ */
private val gson: Gson by injectLazy() private val gson: Gson by injectLazy()
/**
* Source manager.
*/
private val sourceManager: SourceManager by injectLazy()
/** /**
* Database helper. * Database helper.
*/ */
@ -83,7 +81,7 @@ class DownloadStore(context: Context) {
fun restore(): List<Download> { fun restore(): List<Download> {
val objs = preferences.all val objs = preferences.all
.mapNotNull { it.value as? String } .mapNotNull { it.value as? String }
.map { deserialize(it) } .mapNotNull { deserialize(it) }
.sortedBy { it.order } .sortedBy { it.order }
val downloads = mutableListOf<Download>() val downloads = mutableListOf<Download>()
@ -119,8 +117,12 @@ class DownloadStore(context: Context) {
* *
* @param string the download as string. * @param string the download as string.
*/ */
private fun deserialize(string: String): DownloadObject { private fun deserialize(string: String): DownloadObject? {
return gson.fromJson(string, DownloadObject::class.java) return try {
gson.fromJson(string, DownloadObject::class.java)
} catch (e: Exception) {
null
}
} }
/** /**
@ -132,4 +134,4 @@ class DownloadStore(context: Context) {
*/ */
data class DownloadObject(val mangaId: Long, val chapterId: Long, val order: Int) data class DownloadObject(val mangaId: Long, val chapterId: Long, val order: Int)
} }

View File

@ -21,7 +21,6 @@ import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
import rx.subscriptions.CompositeSubscription import rx.subscriptions.CompositeSubscription
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.injectLazy
/** /**
* This class is the one in charge of downloading chapters. * This class is the one in charge of downloading chapters.
@ -35,28 +34,25 @@ import uy.kohesive.injekt.injectLazy
* @param context the application context. * @param context the application context.
* @param provider the downloads directory provider. * @param provider the downloads directory provider.
* @param cache the downloads cache, used to add the downloads to the cache after their completion. * @param cache the downloads cache, used to add the downloads to the cache after their completion.
* @param sourceManager the source manager.
*/ */
class Downloader( class Downloader(
private val context: Context, private val context: Context,
private val provider: DownloadProvider, private val provider: DownloadProvider,
private val cache: DownloadCache private val cache: DownloadCache,
private val sourceManager: SourceManager
) { ) {
/** /**
* Store for persisting downloads across restarts. * Store for persisting downloads across restarts.
*/ */
private val store = DownloadStore(context) private val store = DownloadStore(context, sourceManager)
/** /**
* Queue where active downloads are kept. * Queue where active downloads are kept.
*/ */
val queue = DownloadQueue(store) val queue = DownloadQueue(store)
/**
* Source manager.
*/
private val sourceManager: SourceManager by injectLazy()
/** /**
* Notifier for the downloader state and progress. * Notifier for the downloader state and progress.
*/ */
@ -382,7 +378,7 @@ class Downloader(
// Else guess from the uri. // Else guess from the uri.
?: context.contentResolver.getType(file.uri) ?: context.contentResolver.getType(file.uri)
// Else read magic numbers. // Else read magic numbers.
?: DiskUtil.findImageMime { file.openInputStream() } ?: ImageUtil.findImageType { file.openInputStream() }?.mime
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg" return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
} }

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.download.model
import com.jakewharton.rxrelay.PublishRelay import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadStore import eu.kanade.tachiyomi.data.download.DownloadStore
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import rx.Observable import rx.Observable
@ -40,6 +41,14 @@ class DownloadQueue(
find { it.chapter.id == chapter.id }?.let { remove(it) } find { it.chapter.id == chapter.id }?.let { remove(it) }
} }
fun remove(chapters: List<Chapter>) {
for (chapter in chapters) { remove(chapter) }
}
fun remove(manga: Manga) {
filter { it.manga.id == manga.id }.forEach { remove(it) }
}
fun clear() { fun clear() {
queue.forEach { download -> queue.forEach { download ->
download.setStatusSubject(null) download.setStatusSubject(null)

View File

@ -0,0 +1,74 @@
package eu.kanade.tachiyomi.data.glide
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.data.DataFetcher
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.signature.ObjectKey
import java.io.IOException
import java.io.InputStream
class PassthroughModelLoader : ModelLoader<InputStream, InputStream> {
override fun buildLoadData(
model: InputStream,
width: Int,
height: Int,
options: Options
): ModelLoader.LoadData<InputStream>? {
return ModelLoader.LoadData(ObjectKey(model), Fetcher(model))
}
override fun handles(model: InputStream): Boolean {
return true
}
class Fetcher(private val stream: InputStream) : DataFetcher<InputStream> {
override fun getDataClass(): Class<InputStream> {
return InputStream::class.java
}
override fun cleanup() {
try {
stream.close()
} catch (e: IOException) {
// Do nothing
}
}
override fun getDataSource(): DataSource {
return DataSource.LOCAL
}
override fun cancel() {
// Do nothing
}
override fun loadData(
priority: Priority,
callback: DataFetcher.DataCallback<in InputStream>
) {
callback.onDataReady(stream)
}
}
/**
* Factory class for creating [PassthroughModelLoader] instances.
*/
class Factory : ModelLoaderFactory<InputStream, InputStream> {
override fun build(
multiFactory: MultiModelLoaderFactory
): ModelLoader<InputStream, InputStream> {
return PassthroughModelLoader()
}
override fun teardown() {}
}
}

View File

@ -37,5 +37,7 @@ class TachiGlideModule : AppGlideModule() {
registry.replace(GlideUrl::class.java, InputStream::class.java, networkFactory) registry.replace(GlideUrl::class.java, InputStream::class.java, networkFactory)
registry.append(Manga::class.java, InputStream::class.java, MangaModelLoader.Factory()) registry.append(Manga::class.java, InputStream::class.java, MangaModelLoader.Factory())
registry.append(InputStream::class.java, InputStream::class.java, PassthroughModelLoader
.Factory())
} }
} }

View File

@ -38,6 +38,11 @@ class NotificationReceiver : BroadcastReceiver() {
ACTION_DISMISS_NOTIFICATION -> dismissNotification(context, intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)) ACTION_DISMISS_NOTIFICATION -> dismissNotification(context, intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1))
// Resume the download service // Resume the download service
ACTION_RESUME_DOWNLOADS -> DownloadService.start(context) ACTION_RESUME_DOWNLOADS -> DownloadService.start(context)
// Pause the download service
ACTION_PAUSE_DOWNLOADS -> {
DownloadService.stop(context)
downloadManager.pauseDownloads()
}
// Clear the download queue // Clear the download queue
ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue(true) ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue(true)
// Show message notification created // Show message notification created
@ -159,6 +164,9 @@ class NotificationReceiver : BroadcastReceiver() {
// Called to resume downloads. // Called to resume downloads.
private const val ACTION_RESUME_DOWNLOADS = "$ID.$NAME.ACTION_RESUME_DOWNLOADS" private const val ACTION_RESUME_DOWNLOADS = "$ID.$NAME.ACTION_RESUME_DOWNLOADS"
// Called to pause downloads.
private const val ACTION_PAUSE_DOWNLOADS = "$ID.$NAME.ACTION_PAUSE_DOWNLOADS"
// Called to clear downloads. // Called to clear downloads.
private const val ACTION_CLEAR_DOWNLOADS = "$ID.$NAME.ACTION_CLEAR_DOWNLOADS" private const val ACTION_CLEAR_DOWNLOADS = "$ID.$NAME.ACTION_CLEAR_DOWNLOADS"
@ -190,6 +198,19 @@ class NotificationReceiver : BroadcastReceiver() {
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
} }
/**
* Returns [PendingIntent] that pauses the download queue
*
* @param context context of application
* @return [PendingIntent]
*/
internal fun pauseDownloadsPendingBroadcast(context: Context): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_PAUSE_DOWNLOADS
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
/** /**
* Returns a [PendingIntent] that clears the download queue * Returns a [PendingIntent] that clears the download queue
* *
@ -203,7 +224,7 @@ class NotificationReceiver : BroadcastReceiver() {
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
} }
internal fun shortcutCreatedBroadcast(context: Context) : PendingIntent { internal fun shortcutCreatedBroadcast(context: Context): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply { val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_SHORTCUT_CREATED action = ACTION_SHORTCUT_CREATED
} }

View File

@ -15,6 +15,8 @@ object PreferenceKeys {
const val showPageNumber = "pref_show_page_number_key" const val showPageNumber = "pref_show_page_number_key"
const val trueColor = "pref_true_color_key"
const val fullscreen = "fullscreen" const val fullscreen = "fullscreen"
const val keepScreenOn = "pref_keep_screen_on_key" const val keepScreenOn = "pref_keep_screen_on_key"
@ -31,8 +33,6 @@ object PreferenceKeys {
const val imageScaleType = "pref_image_scale_type_key" const val imageScaleType = "pref_image_scale_type_key"
const val imageDecoder = "image_decoder"
const val zoomStart = "pref_zoom_start_key" const val zoomStart = "pref_zoom_start_key"
const val readerTheme = "pref_reader_theme_key" const val readerTheme = "pref_reader_theme_key"
@ -43,6 +43,8 @@ object PreferenceKeys {
const val readWithTapping = "reader_tap" const val readWithTapping = "reader_tap"
const val readWithLongTap = "reader_long_tap"
const val readWithVolumeKeys = "reader_volume_keys" const val readWithVolumeKeys = "reader_volume_keys"
const val readWithVolumeKeysInverted = "reader_volume_keys_inverted" const val readWithVolumeKeysInverted = "reader_volume_keys_inverted"
@ -55,8 +57,6 @@ object PreferenceKeys {
const val autoUpdateTrack = "pref_auto_update_manga_sync_key" const val autoUpdateTrack = "pref_auto_update_manga_sync_key"
const val askUpdateTrack = "pref_ask_update_manga_sync_key"
const val lastUsedCatalogueSource = "last_catalogue_source" const val lastUsedCatalogueSource = "last_catalogue_source"
const val lastUsedCategory = "last_used_category" const val lastUsedCategory = "last_used_category"

View File

@ -44,6 +44,8 @@ class PreferencesHelper(val context: Context) {
fun showPageNumber() = rxPrefs.getBoolean(Keys.showPageNumber, true) fun showPageNumber() = rxPrefs.getBoolean(Keys.showPageNumber, true)
fun trueColor() = rxPrefs.getBoolean(Keys.trueColor, false)
fun fullscreen() = rxPrefs.getBoolean(Keys.fullscreen, true) fun fullscreen() = rxPrefs.getBoolean(Keys.fullscreen, true)
fun keepScreenOn() = rxPrefs.getBoolean(Keys.keepScreenOn, true) fun keepScreenOn() = rxPrefs.getBoolean(Keys.keepScreenOn, true)
@ -60,8 +62,6 @@ class PreferencesHelper(val context: Context) {
fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1) fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1)
fun imageDecoder() = rxPrefs.getInteger(Keys.imageDecoder, 0)
fun zoomStart() = rxPrefs.getInteger(Keys.zoomStart, 1) fun zoomStart() = rxPrefs.getInteger(Keys.zoomStart, 1)
fun readerTheme() = rxPrefs.getInteger(Keys.readerTheme, 0) fun readerTheme() = rxPrefs.getInteger(Keys.readerTheme, 0)
@ -72,6 +72,8 @@ class PreferencesHelper(val context: Context) {
fun readWithTapping() = rxPrefs.getBoolean(Keys.readWithTapping, true) fun readWithTapping() = rxPrefs.getBoolean(Keys.readWithTapping, true)
fun readWithLongTap() = rxPrefs.getBoolean(Keys.readWithLongTap, true)
fun readWithVolumeKeys() = rxPrefs.getBoolean(Keys.readWithVolumeKeys, false) fun readWithVolumeKeys() = rxPrefs.getBoolean(Keys.readWithVolumeKeys, false)
fun readWithVolumeKeysInverted() = rxPrefs.getBoolean(Keys.readWithVolumeKeysInverted, false) fun readWithVolumeKeysInverted() = rxPrefs.getBoolean(Keys.readWithVolumeKeysInverted, false)
@ -84,8 +86,6 @@ class PreferencesHelper(val context: Context) {
fun autoUpdateTrack() = prefs.getBoolean(Keys.autoUpdateTrack, true) fun autoUpdateTrack() = prefs.getBoolean(Keys.autoUpdateTrack, true)
fun askUpdateTrack() = prefs.getBoolean(Keys.askUpdateTrack, false)
fun lastUsedCatalogueSource() = rxPrefs.getLong(Keys.lastUsedCatalogueSource, -1) fun lastUsedCatalogueSource() = rxPrefs.getLong(Keys.lastUsedCatalogueSource, -1)
fun lastUsedCategory() = rxPrefs.getInteger(Keys.lastUsedCategory, 0) fun lastUsedCategory() = rxPrefs.getInteger(Keys.lastUsedCategory, 0)

View File

@ -60,12 +60,11 @@ abstract class TrackService(val id: Int) {
get() = !getUsername().isEmpty() && get() = !getUsername().isEmpty() &&
!getPassword().isEmpty() !getPassword().isEmpty()
fun getUsername() = preferences.trackUsername(this) fun getUsername() = preferences.trackUsername(this)!!
fun getPassword() = preferences.trackPassword(this) fun getPassword() = preferences.trackPassword(this)!!
fun saveCredentials(username: String, password: String) { fun saveCredentials(username: String, password: String) {
preferences.setTrackCredentials(this, username, password) preferences.setTrackCredentials(this, username, password)
} }
} }

View File

@ -95,9 +95,15 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
// 100 point // 100 point
POINT_100 -> index.toFloat() POINT_100 -> index.toFloat()
// 5 stars // 5 stars
POINT_5 -> index * 20f POINT_5 -> when {
index == 0 -> 0f
else -> index * 20f - 10f
}
// Smiley // Smiley
POINT_3 -> index * 30f POINT_3 -> when {
index == 0 -> 0f
else -> index * 25f + 10f
}
// 10 point decimal // 10 point decimal
POINT_10_DECIMAL -> index.toFloat() POINT_10_DECIMAL -> index.toFloat()
else -> throw Exception("Unknown score type") else -> throw Exception("Unknown score type")
@ -108,10 +114,13 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
val score = track.score val score = track.score
return when (scorePreference.getOrDefault()) { return when (scorePreference.getOrDefault()) {
POINT_5 -> "${(score / 20).toInt()}" POINT_5 -> when {
score == 0f -> "0 ★"
else -> "${((score + 10) / 20).toInt()}"
}
POINT_3 -> when { POINT_3 -> when {
score == 0f -> "0" score == 0f -> "0"
score <= 30 -> "😦" score <= 35 -> "😦"
score <= 60 -> "😐" score <= 60 -> "😐"
else -> "😊" else -> "😊"
} }

View File

@ -12,6 +12,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import rx.Observable import rx.Observable
import java.util.Calendar
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
@ -90,7 +91,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
fun search(search: String): Observable<List<TrackSearch>> { fun search(search: String): Observable<List<TrackSearch>> {
val query = """ val query = """
query Search(${'$'}query: String) { query Search(${'$'}query: String) {
Page (perPage: 25) { Page (perPage: 50) {
media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) { media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
id id
title { title {
@ -102,6 +103,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
type type
status status
chapters chapters
description
startDate { startDate {
year year
month month
@ -160,6 +162,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
type type
status status
chapters chapters
description
startDate { startDate {
year year
month month
@ -244,10 +247,18 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
} }
fun jsonToALManga(struct: JsonObject): ALManga{ fun jsonToALManga(struct: JsonObject): ALManga{
val date = try {
val date = Calendar.getInstance()
date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1,
struct["startDate"]["day"].nullInt ?: 0)
date.timeInMillis
} catch (_: Exception) {
0L
}
return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString, return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString,
null, struct["type"].asString, struct["status"].asString, struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString,
struct["startDate"]["year"].nullString.orEmpty() + struct["startDate"]["month"].nullString.orEmpty() date, struct["chapters"].nullInt ?: 0)
+ struct["startDate"]["day"].nullString.orEmpty(), struct["chapters"].nullInt ?: 0)
} }
fun jsonToALUserManga(struct: JsonObject): ALUserManga{ fun jsonToALUserManga(struct: JsonObject): ALUserManga{

View File

@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.track.anilist package eu.kanade.tachiyomi.data.track.anilist
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
@ -17,7 +16,7 @@ data class ALManga(
val description: String?, val description: String?,
val type: String, val type: String,
val publishing_status: String, val publishing_status: String,
val start_date_fuzzy: String, val start_date_fuzzy: Long,
val total_chapters: Int) { val total_chapters: Int) {
fun toTrack() = TrackSearch.create(TrackManager.ANILIST).apply { fun toTrack() = TrackSearch.create(TrackManager.ANILIST).apply {
@ -29,14 +28,12 @@ data class ALManga(
tracking_url = AnilistApi.mangaUrl(media_id) tracking_url = AnilistApi.mangaUrl(media_id)
publishing_status = this@ALManga.publishing_status publishing_status = this@ALManga.publishing_status
publishing_type = type publishing_type = type
if (!start_date_fuzzy.isNullOrBlank()) { if (start_date_fuzzy != 0L) {
start_date = try { start_date = try {
val inputDf = SimpleDateFormat("yyyyMMdd", Locale.US)
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
val date = inputDf.parse(BuildConfig.BUILD_TIME) outputDf.format(start_date_fuzzy)
outputDf.format(date)
} catch (e: Exception) { } catch (e: Exception) {
start_date_fuzzy.orEmpty() ""
} }
} }
} }
@ -64,6 +61,7 @@ data class ALUserManga(
"PAUSED" -> Anilist.ON_HOLD "PAUSED" -> Anilist.ON_HOLD
"DROPPED" -> Anilist.DROPPED "DROPPED" -> Anilist.DROPPED
"PLANNING" -> Anilist.PLANNING "PLANNING" -> Anilist.PLANNING
"REPEATING" -> Anilist.REPEATING
else -> throw NotImplementedError("Unknown status") else -> throw NotImplementedError("Unknown status")
} }
} }
@ -97,7 +95,7 @@ fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().getOrD
// Smiley // Smiley
"POINT_3" -> when { "POINT_3" -> when {
score == 0f -> "0" score == 0f -> "0"
score <= 30 -> ":(" score <= 35 -> ":("
score <= 60 -> ":|" score <= 60 -> ":|"
else -> ":)" else -> ":)"
} }

View File

@ -16,9 +16,11 @@ import rx.Observable
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) { class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
private val rest = Retrofit.Builder() private val rest = Retrofit.Builder()
.baseUrl(baseUrl) .baseUrl(baseUrl)
.client(client.newBuilder().addInterceptor(interceptor).build()) .client(authClient)
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create())) .addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create()))
.addCallAdapterFactory(RxJavaCallAdapterFactory.create()) .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build() .build()
@ -26,7 +28,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
private val searchRest = Retrofit.Builder() private val searchRest = Retrofit.Builder()
.baseUrl(algoliaKeyUrl) .baseUrl(algoliaKeyUrl)
.client(client) .client(authClient)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create()) .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build() .build()

View File

@ -14,7 +14,7 @@ class KitsuSearchManga(obj: JsonObject) {
private val canonicalTitle by obj.byString private val canonicalTitle by obj.byString
private val chapterCount = obj.get("chapterCount").nullInt private val chapterCount = obj.get("chapterCount").nullInt
val subType = obj.get("subtype").nullString val subType = obj.get("subtype").nullString
val original by obj["posterImage"].byString val original = obj.get("posterImage").nullObj?.get("original")?.asString
private val synopsis by obj.byString private val synopsis by obj.byString
private var startDate = obj.get("startDate").nullString?.let { private var startDate = obj.get("startDate").nullString?.let {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
@ -28,7 +28,7 @@ class KitsuSearchManga(obj: JsonObject) {
media_id = this@KitsuSearchManga.id media_id = this@KitsuSearchManga.id
title = canonicalTitle title = canonicalTitle
total_chapters = chapterCount ?: 0 total_chapters = chapterCount ?: 0
cover_url = original cover_url = original ?: ""
summary = synopsis summary = synopsis
tracking_url = KitsuApi.mangaUrl(media_id) tracking_url = KitsuApi.mangaUrl(media_id)
if (endDate == null) { if (endDate == null) {

View File

@ -4,10 +4,12 @@ import android.content.Context
import android.graphics.Color import android.graphics.Color
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable import rx.Completable
import rx.Observable import rx.Observable
import java.net.URI
class Myanimelist(private val context: Context, id: Int) : TrackService(id) { class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
@ -21,9 +23,13 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
const val DEFAULT_STATUS = READING const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0 const val DEFAULT_SCORE = 0
const val BASE_URL = "https://myanimelist.net"
const val USER_SESSION_COOKIE = "MALSESSIONID"
const val LOGGED_IN_COOKIE = "is_logged_in"
} }
private val api by lazy { MyanimelistApi(client, getUsername(), getPassword()) } private val api by lazy { MyanimelistApi(client) }
override val name: String override val name: String
get() = "MyAnimeList" get() = "MyAnimeList"
@ -56,7 +62,7 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
} }
override fun add(track: Track): Observable<Track> { override fun add(track: Track): Observable<Track> {
return api.addLibManga(track) return api.addLibManga(track, getCSRF())
} }
override fun update(track: Track): Observable<Track> { override fun update(track: Track): Observable<Track> {
@ -64,11 +70,11 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
track.status = COMPLETED track.status = COMPLETED
} }
return api.updateLibManga(track) return api.updateLibManga(track, getCSRF())
} }
override fun bind(track: Track): Observable<Track> { override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername()) return api.findLibManga(track, getCSRF())
.flatMap { remoteTrack -> .flatMap { remoteTrack ->
if (remoteTrack != null) { if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
@ -83,11 +89,11 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
} }
override fun search(query: String): Observable<List<TrackSearch>> { override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query, getUsername()) return api.search(query)
} }
override fun refresh(track: Track): Observable<Track> { override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track, getUsername()) return api.getLibManga(track, getCSRF())
.map { remoteTrack -> .map { remoteTrack ->
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters track.total_chapters = remoteTrack.total_chapters
@ -96,10 +102,40 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
} }
override fun login(username: String, password: String): Completable { override fun login(username: String, password: String): Completable {
logout()
return api.login(username, password) return api.login(username, password)
.doOnNext { csrf -> saveCSRF(csrf) }
.doOnNext { saveCredentials(username, password) } .doOnNext { saveCredentials(username, password) }
.doOnError { logout() } .doOnError { logout() }
.toCompletable() .toCompletable()
} }
override fun logout() {
super.logout()
preferences.trackToken(this).delete()
networkService.cookies.remove(URI(BASE_URL))
}
override val isLogged: Boolean
get() = !getUsername().isEmpty() &&
!getPassword().isEmpty() &&
checkCookies(URI(BASE_URL)) &&
!getCSRF().isEmpty()
private fun getCSRF(): String = preferences.trackToken(this).getOrDefault()
private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf)
private fun checkCookies(uri: URI): Boolean {
var ckCount = 0
for (ck in networkService.cookies.get(uri)) {
if (ck.name() == USER_SESSION_COOKIE || ck.name() == LOGGED_IN_COOKIE)
ckCount++
}
return ckCount == 2
}
} }

View File

@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.data.track.myanimelist package eu.kanade.tachiyomi.data.track.myanimelist
import android.net.Uri import android.net.Uri
import android.util.Xml
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
@ -12,191 +11,266 @@ import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.util.selectInt import eu.kanade.tachiyomi.util.selectInt
import eu.kanade.tachiyomi.util.selectText import eu.kanade.tachiyomi.util.selectText
import okhttp3.* import okhttp3.*
import org.json.JSONObject
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.parser.Parser import org.jsoup.parser.Parser
import org.xmlpull.v1.XmlSerializer
import rx.Observable import rx.Observable
import java.io.StringWriter import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.zip.GZIPInputStream
class MyanimelistApi(private val client: OkHttpClient, username: String, password: String) {
private var headers = createHeaders(username, password) class MyanimelistApi(private val client: OkHttpClient) {
fun addLibManga(track: Track): Observable<Track> { fun addLibManga(track: Track, csrf: String): Observable<Track> {
return Observable.defer { return Observable.defer {
client.newCall(POST(getAddUrl(track), headers, getMangaPostPayload(track))) client.newCall(POST(url = getAddUrl(), body = getMangaPostPayload(track, csrf)))
.asObservableSuccess() .asObservableSuccess()
.map { track } .map { track }
} }
} }
fun updateLibManga(track: Track): Observable<Track> { fun updateLibManga(track: Track, csrf: String): Observable<Track> {
return Observable.defer { return Observable.defer {
client.newCall(POST(getUpdateUrl(track), headers, getMangaPostPayload(track))) client.newCall(POST(url = getUpdateUrl(), body = getMangaPostPayload(track, csrf)))
.asObservableSuccess() .asObservableSuccess()
.map { track } .map { track }
} }
} }
fun search(query: String, username: String): Observable<List<TrackSearch>> { fun search(query: String): Observable<List<TrackSearch>> {
return if (query.startsWith(PREFIX_MY)) { return client.newCall(GET(getSearchUrl(query)))
val realQuery = query.substring(PREFIX_MY.length).toLowerCase().trim()
getList(username)
.flatMap { Observable.from(it) }
.filter { realQuery in it.title.toLowerCase() }
.toList()
} else {
client.newCall(GET(getSearchUrl(query), headers))
.asObservable()
.map { Jsoup.parse(Parser.unescapeEntities(it.body()!!.string(), false), "", Parser.xmlParser()) }
.flatMap { Observable.from(it.select("entry")) }
.filter { it.select("type").text() != "Novel" }
.map {
TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("title")!!
media_id = it.selectInt("id")
total_chapters = it.selectInt("chapters")
summary = it.selectText("synopsis")!!
cover_url = it.selectText("image")!!
tracking_url = MyanimelistApi.mangaUrl(media_id)
publishing_status = it.selectText("status")!!
publishing_type = it.selectText("type")!!
start_date = it.selectText("start_date")!!
}
}
.toList()
}
}
fun getList(username: String): Observable<List<TrackSearch>> {
return client
.newCall(GET(getListUrl(username), headers))
.asObservable() .asObservable()
.map { Jsoup.parse(Parser.unescapeEntities(it.body()!!.string(), false), "", Parser.xmlParser()) } .flatMap { response ->
.flatMap { Observable.from(it.select("manga")) } Observable.from(Jsoup.parse(response.consumeBody())
.map { .select("div.js-categories-seasonal.js-block-list.list")
.select("table").select("tbody")
.select("tr").drop(1))
}
.filter { row ->
row.select(TD)[2].text() != "Novel"
}
.map { row ->
TrackSearch.create(TrackManager.MYANIMELIST).apply { TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("series_title")!! title = row.searchTitle()
media_id = it.selectInt("series_mangadb_id") media_id = row.searchMediaId()
last_chapter_read = it.selectInt("my_read_chapters") total_chapters = row.searchTotalChapters()
status = it.selectInt("my_status") summary = row.searchSummary()
score = it.selectInt("my_score").toFloat() cover_url = row.searchCoverUrl()
total_chapters = it.selectInt("series_chapters") tracking_url = mangaUrl(media_id)
cover_url = it.selectText("series_image")!! publishing_status = row.searchPublishingStatus()
tracking_url = MyanimelistApi.mangaUrl(media_id) publishing_type = row.searchPublishingType()
start_date = row.searchStartDate()
} }
} }
.toList() .toList()
} }
fun findLibManga(track: Track, username: String): Observable<Track?> { private fun getList(csrf: String): Observable<List<TrackSearch>> {
return getList(username) return getListUrl(csrf)
.flatMap { url ->
getListXml(url)
}
.flatMap { doc ->
Observable.from(doc.select("manga"))
}
.map { it ->
TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("manga_title")!!
media_id = it.selectInt("manga_mangadb_id")
last_chapter_read = it.selectInt("my_read_chapters")
status = getStatus(it.selectText("my_status")!!)
score = it.selectInt("my_score").toFloat()
total_chapters = it.selectInt("manga_chapters")
tracking_url = mangaUrl(media_id)
}
}
.toList()
}
private fun getListXml(url: String): Observable<Document> {
return client.newCall(GET(url))
.asObservable()
.map { response ->
Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
}
}
fun findLibManga(track: Track, csrf: String): Observable<Track?> {
return getList(csrf)
.map { list -> list.find { it.media_id == track.media_id } } .map { list -> list.find { it.media_id == track.media_id } }
} }
fun getLibManga(track: Track, username: String): Observable<Track> { fun getLibManga(track: Track, csrf: String): Observable<Track> {
return findLibManga(track, username) return findLibManga(track, csrf)
.map { it ?: throw Exception("Could not find manga") } .map { it ?: throw Exception("Could not find manga") }
} }
fun login(username: String, password: String): Observable<Response> { fun login(username: String, password: String): Observable<String> {
headers = createHeaders(username, password) return getSessionInfo()
return client.newCall(GET(getLoginUrl(), headers)) .flatMap { csrf ->
.asObservable() login(username, password, csrf)
.doOnNext { response ->
response.close()
if (response.code() != 200) throw Exception("Login error")
} }
} }
private fun getMangaPostPayload(track: Track): RequestBody { private fun getSessionInfo(): Observable<String> {
val data = xml { return client.newCall(GET(getLoginUrl()))
element(ENTRY_TAG) { .asObservable()
if (track.last_chapter_read != 0) { .map { response ->
text(CHAPTER_TAG, track.last_chapter_read.toString()) Jsoup.parse(response.consumeBody())
.select("meta[name=csrf_token]")
.attr("content")
} }
text(STATUS_TAG, track.status.toString()) }
text(SCORE_TAG, track.score.toString())
private fun login(username: String, password: String, csrf: String): Observable<String> {
return client.newCall(POST(url = getLoginUrl(), body = getLoginPostBody(username, password, csrf)))
.asObservable()
.map { response ->
response.use {
if (response.priorResponse()?.code() != 302) throw Exception("Authentication error")
}
csrf
}
}
private fun getLoginPostBody(username: String, password: String, csrf: String): RequestBody {
return FormBody.Builder()
.add("user_name", username)
.add("password", password)
.add("cookie", "1")
.add("sublogin", "Login")
.add("submit", "1")
.add(CSRF, csrf)
.build()
}
private fun getExportPostBody(csrf: String): RequestBody {
return FormBody.Builder()
.add("type", "2")
.add("subexport", "Export My List")
.add(CSRF, csrf)
.build()
}
private fun getMangaPostPayload(track: Track, csrf: String): RequestBody {
val body = JSONObject()
.put("manga_id", track.media_id)
.put("status", track.status)
.put("score", track.score)
.put("num_read_chapters", track.last_chapter_read)
.put(CSRF, csrf)
return RequestBody.create(MediaType.parse("application/json; charset=utf-8"), body.toString())
}
private fun getLoginUrl() = Uri.parse(baseUrl).buildUpon()
.appendPath("login.php")
.toString()
private fun getSearchUrl(query: String): String {
val col = "c[]"
return Uri.parse(baseUrl).buildUpon()
.appendPath("manga.php")
.appendQueryParameter("q", query)
.appendQueryParameter(col, "a")
.appendQueryParameter(col, "b")
.appendQueryParameter(col, "c")
.appendQueryParameter(col, "d")
.appendQueryParameter(col, "e")
.appendQueryParameter(col, "g")
.toString()
}
private fun getExportListUrl() = Uri.parse(baseUrl).buildUpon()
.appendPath("panel.php")
.appendQueryParameter("go", "export")
.toString()
private fun getListUrl(csrf: String): Observable<String> {
return client.newCall(POST(url = getExportListUrl(), body = getExportPostBody(csrf)))
.asObservable()
.map {response ->
baseUrl + Jsoup.parse(response.consumeBody())
.select("div.goodresult")
.select("a")
.attr("href")
}
}
private fun getUpdateUrl() = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath("edit.json")
.toString()
private fun getAddUrl() = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath( "add.json")
.toString()
private fun Response.consumeBody(): String? {
use {
if (it.code() != 200) throw Exception("Login error")
return it.body()?.string()
}
}
private fun Response.consumeXmlBody(): String? {
use { res ->
if (res.code() != 200) throw Exception("Export list error")
BufferedReader(InputStreamReader(GZIPInputStream(res.body()?.source()?.inputStream()))).use { reader ->
val sb = StringBuilder()
reader.forEachLine { line ->
sb.append(line)
}
return sb.toString()
} }
} }
return FormBody.Builder()
.add("data", data)
.build()
}
private inline fun xml(block: XmlSerializer.() -> Unit): String {
val x = Xml.newSerializer()
val writer = StringWriter()
with(x) {
setOutput(writer)
startDocument("UTF-8", false)
block()
endDocument()
}
return writer.toString()
}
private inline fun XmlSerializer.element(tag: String, block: XmlSerializer.() -> Unit) {
startTag("", tag)
block()
endTag("", tag)
}
private fun XmlSerializer.text(tag: String, body: String) {
startTag("", tag)
text(body)
endTag("", tag)
}
fun getLoginUrl() = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/account/verify_credentials.xml")
.toString()
fun getSearchUrl(query: String) = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/manga/search.xml")
.appendQueryParameter("q", query)
.toString()
fun getListUrl(username: String) = Uri.parse(baseUrl).buildUpon()
.appendPath("malappinfo.php")
.appendQueryParameter("u", username)
.appendQueryParameter("status", "all")
.appendQueryParameter("type", "manga")
.toString()
fun getUpdateUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/mangalist/update")
.appendPath("${track.media_id}.xml")
.toString()
fun getAddUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/mangalist/add")
.appendPath("${track.media_id}.xml")
.toString()
fun createHeaders(username: String, password: String): Headers {
return Headers.Builder()
.add("Authorization", Credentials.basic(username, password))
.add("User-Agent", "api-indiv-9F93C52A963974CF674325391990191C")
.build()
} }
companion object { companion object {
const val baseUrl = "https://myanimelist.net" const val baseUrl = "https://myanimelist.net"
const val baseMangaUrl = baseUrl + "/manga/" private const val baseMangaUrl = "$baseUrl/manga/"
private const val baseModifyListUrl = "$baseUrl/ownlist/manga"
fun mangaUrl(remoteId: Int): String { fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId
return baseMangaUrl + remoteId
}
private val ENTRY_TAG = "entry" fun Element.searchTitle() = select("strong").text()!!
private val CHAPTER_TAG = "chapter"
private val SCORE_TAG = "score"
private val STATUS_TAG = "status"
const val PREFIX_MY = "my:" fun Element.searchTotalChapters() = if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt()
fun Element.searchCoverUrl() = select("img")
.attr("data-src")
.split("\\?")[0]
.replace("/r/50x70/", "/")
fun Element.searchMediaId() = select("div.picSurround")
.select("a").attr("id")
.replace("sarea", "")
.toInt()
fun Element.searchSummary() = select("div.pt4")
.first()
.ownText()!!
fun Element.searchPublishingStatus() = if (select(TD).last().text() == "-") PUBLISHING else FINISHED
fun Element.searchPublishingType() = select(TD)[2].text()!!
fun Element.searchStartDate() = select(TD)[6].text()!!
fun getStatus(status: String) = when (status) {
"Reading" -> 1
"Completed" -> 2
"On-Hold" -> 3
"Dropped" -> 4
"Plan to Read" -> 6
else -> 1
}
const val CSRF = "csrf_token"
const val TD = "td"
private const val FINISHED = "Finished"
private const val PUBLISHING = "Publishing"
} }
} }

View File

@ -1,11 +1,8 @@
package eu.kanade.tachiyomi.network package eu.kanade.tachiyomi.network
import com.squareup.duktape.Duktape import com.squareup.duktape.Duktape
import okhttp3.CacheControl import okhttp3.*
import okhttp3.HttpUrl import java.io.IOException
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
class CloudflareInterceptor : Interceptor { class CloudflareInterceptor : Interceptor {
@ -15,15 +12,35 @@ class CloudflareInterceptor : Interceptor {
private val challengePattern = Regex("""name="jschl_vc" value="(\w+)"""") private val challengePattern = Regex("""name="jschl_vc" value="(\w+)"""")
private val sPattern = Regex("""name="s" value="([^"]+)""")
private val kPattern = Regex("""k\s+=\s+'([^']+)';""")
private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare") private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare")
private interface IBase64 {
fun decode(input: String): String
}
private val b64: IBase64 = object : IBase64 {
override fun decode(input: String): String {
return okio.ByteString.decodeBase64(input)!!.utf8()
}
}
@Synchronized @Synchronized
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request()) val response = chain.proceed(chain.request())
// Check if Cloudflare anti-bot is on // Check if Cloudflare anti-bot is on
if (response.code() == 503 && response.header("Server") in serverCheck) { if (response.code() == 503 && response.header("Server") in serverCheck) {
return chain.proceed(resolveChallenge(response)) return try {
chain.proceed(resolveChallenge(response))
} catch (e: Exception) {
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
// we don't crash the entire app
throw IOException(e)
}
} }
return response return response
@ -42,24 +59,37 @@ class CloudflareInterceptor : Interceptor {
val operation = operationPattern.find(content)?.groups?.get(1)?.value val operation = operationPattern.find(content)?.groups?.get(1)?.value
val challenge = challengePattern.find(content)?.groups?.get(1)?.value val challenge = challengePattern.find(content)?.groups?.get(1)?.value
val pass = passPattern.find(content)?.groups?.get(1)?.value val pass = passPattern.find(content)?.groups?.get(1)?.value
val s = sPattern.find(content)?.groups?.get(1)?.value
if (operation == null || challenge == null || pass == null) { // If `k` is null, it uses old methods.
val k = kPattern.find(content)?.groups?.get(1)?.value ?: ""
val innerHTMLValue = Regex("""<div(.*)id="$k"(.*)>(.*)</div>""")
.find(content)?.groups?.get(3)?.value ?: ""
if (operation == null || challenge == null || pass == null || s == null) {
throw Exception("Failed resolving Cloudflare challenge") throw Exception("Failed resolving Cloudflare challenge")
} }
// Export native Base64 decode function to js object.
duktape.set("b64", IBase64::class.java, b64)
// Return simulated innerHTML when call DOM.
val simulatedDocumentJS = """var document = { getElementById: function (x) { return { innerHTML: "$innerHTMLValue" }; } }"""
val js = operation val js = operation
.replace(Regex("""a\.value = (.+ \+ t\.length).+"""), "$1") .replace(Regex("""a\.value = (.+\.toFixed\(10\);).+"""), "$1")
.replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "") .replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "")
.replace("t.length", "${domain.length}") .replace("t.length", "${domain.length}")
.replace("\n", "") .replace("\n", "")
val result = duktape.evaluate(js) as Double val result = duktape.evaluate("""$simulatedDocumentJS;$ATOB_JS;var t="$domain";$js""") as String
val cloudflareUrl = HttpUrl.parse("${url.scheme()}://$domain/cdn-cgi/l/chk_jschl")!! val cloudflareUrl = HttpUrl.parse("${url.scheme()}://$domain/cdn-cgi/l/chk_jschl")!!
.newBuilder() .newBuilder()
.addQueryParameter("jschl_vc", challenge) .addQueryParameter("jschl_vc", challenge)
.addQueryParameter("pass", pass) .addQueryParameter("pass", pass)
.addQueryParameter("jschl_answer", "$result") .addQueryParameter("s", s)
.addQueryParameter("jschl_answer", result)
.toString() .toString()
val cloudflareHeaders = originalRequest.headers() val cloudflareHeaders = originalRequest.headers()
@ -73,4 +103,8 @@ class CloudflareInterceptor : Interceptor {
} }
} }
} companion object {
// atob() is browser API, Using Android's own function. (java.util.Base64 can't be used because of min API level)
private const val ATOB_JS = """var atob = function (input) { return b64.decode(input) }"""
}
}

View File

@ -60,6 +60,11 @@ class PersistentCookieStore(context: Context) {
cookieMap.clear() cookieMap.clear()
} }
fun remove(uri: URI) {
prefs.edit().remove(uri.host).apply()
cookieMap.remove(uri.host)
}
fun get(url: HttpUrl) = get(url.uri().host) fun get(url: HttpUrl) = get(url.uri().host)
fun get(uri: URI) = get(uri.host) fun get(uri: URI) = get(uri.host)

View File

@ -1,24 +1,22 @@
package eu.kanade.tachiyomi.source package eu.kanade.tachiyomi.source
import android.content.Context import android.content.Context
import android.net.Uri
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.* import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.util.ChapterRecognition import eu.kanade.tachiyomi.util.ChapterRecognition
import eu.kanade.tachiyomi.util.DiskUtil import eu.kanade.tachiyomi.util.DiskUtil
import eu.kanade.tachiyomi.util.RarContentProvider import eu.kanade.tachiyomi.util.EpubFile
import eu.kanade.tachiyomi.util.ZipContentProvider import eu.kanade.tachiyomi.util.ImageUtil
import junrar.Archive import junrar.Archive
import junrar.rarfile.FileHeader import junrar.rarfile.FileHeader
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import rx.Observable import rx.Observable
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.InputStream import java.io.InputStream
import java.util.* import java.util.Comparator
import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
@ -107,15 +105,11 @@ class LocalSource(private val context: Context) : CatalogueSource {
if (thumbnail_url == null) { if (thumbnail_url == null) {
val chapters = fetchChapterList(this).toBlocking().first() val chapters = fetchChapterList(this).toBlocking().first()
if (chapters.isNotEmpty()) { if (chapters.isNotEmpty()) {
val uri = fetchPageList(chapters.last()).toBlocking().first().firstOrNull()?.uri try {
if (uri != null) { val dest = updateCover(chapters.last(), this)
val input = context.contentResolver.openInputStream(uri) thumbnail_url = dest?.absolutePath
try { } catch (e: Exception) {
val dest = updateCover(context, this, input) Timber.e(e)
thumbnail_url = dest?.absolutePath
} catch (e: Exception) {
Timber.e(e)
}
} }
} }
} }
@ -135,7 +129,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
val chapters = getBaseDirectories(context) val chapters = getBaseDirectories(context)
.mapNotNull { File(it, manga.url).listFiles()?.toList() } .mapNotNull { File(it, manga.url).listFiles()?.toList() }
.flatten() .flatten()
.filter { it.isDirectory || isSupportedFormat(it.extension) } .filter { it.isDirectory || isSupportedFile(it.extension) }
.map { chapterFile -> .map { chapterFile ->
SChapter.create().apply { SChapter.create().apply {
url = "${manga.url}/${chapterFile.name}" url = "${manga.url}/${chapterFile.name}"
@ -150,7 +144,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
ChapterRecognition.parseChapterNumber(this, manga) ChapterRecognition.parseChapterNumber(this, manga)
} }
} }
.sortedWith(Comparator<SChapter> { c1, c2 -> .sortedWith(Comparator { c1, c2 ->
val c = c2.chapter_number.compareTo(c1.chapter_number) val c = c2.chapter_number.compareTo(c1.chapter_number)
if (c == 0) comparator.compare(c2.name, c1.name) else c if (c == 0) comparator.compare(c2.name, c1.name) else c
}) })
@ -159,160 +153,90 @@ class LocalSource(private val context: Context) : CatalogueSource {
} }
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> { override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return Observable.error(Exception("Unused"))
}
private fun isSupportedFile(extension: String): Boolean {
return extension.toLowerCase() in setOf("zip", "rar", "cbr", "cbz", "epub")
}
fun getFormat(chapter: SChapter): Format {
val baseDirs = getBaseDirectories(context) val baseDirs = getBaseDirectories(context)
for (dir in baseDirs) { for (dir in baseDirs) {
val chapFile = File(dir, chapter.url) val chapFile = File(dir, chapter.url)
if (!chapFile.exists()) continue if (!chapFile.exists()) continue
return Observable.just(getLoader(chapFile).load()) return getFormat(chapFile)
} }
throw Exception("Chapter not found")
return Observable.error(Exception("Chapter not found"))
} }
private fun isSupportedFormat(extension: String): Boolean { private fun getFormat(file: File): Format {
return extension.equals("zip", true) || extension.equals("cbz", true)
|| extension.equals("rar", true) || extension.equals("cbr", true)
|| extension.equals("epub", true)
}
private fun getLoader(file: File): Loader {
val extension = file.extension val extension = file.extension
return if (file.isDirectory) { return if (file.isDirectory) {
DirectoryLoader(file) Format.Directory(file)
} else if (extension.equals("zip", true) || extension.equals("cbz", true)) { } else if (extension.equals("zip", true) || extension.equals("cbz", true)) {
ZipLoader(file) Format.Zip(file)
} else if (extension.equals("epub", true)) {
EpubLoader(file)
} else if (extension.equals("rar", true) || extension.equals("cbr", true)) { } else if (extension.equals("rar", true) || extension.equals("cbr", true)) {
RarLoader(file) Format.Rar(file)
} else if (extension.equals("epub", true)) {
Format.Epub(file)
} else { } else {
throw Exception("Invalid chapter format") throw Exception("Invalid chapter format")
} }
} }
private fun updateCover(chapter: SChapter, manga: SManga): File? {
val format = getFormat(chapter)
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return when (format) {
is Format.Directory -> {
val entry = format.file.listFiles()
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
.find { !it.isDirectory && ImageUtil.isImage(it.name, { FileInputStream(it) }) }
entry?.let { updateCover(context, manga, it.inputStream())}
}
is Format.Zip -> {
ZipFile(format.file).use { zip ->
val entry = zip.entries().toList()
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
.find { !it.isDirectory && ImageUtil.isImage(it.name, { zip.getInputStream(it) }) }
entry?.let { updateCover(context, manga, zip.getInputStream(it) )}
}
}
is Format.Rar -> {
Archive(format.file).use { archive ->
val entry = archive.fileHeaders
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
.find { !it.isDirectory && ImageUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) }
entry?.let { updateCover(context, manga, archive.getInputStream(it) )}
}
}
is Format.Epub -> {
EpubFile(format.file).use { epub ->
val entry = epub.getImagesFromPages()
.firstOrNull()
?.let { epub.getEntry(it) }
entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
}
}
}
}
private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true)) private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true))
override fun getFilterList() = FilterList(OrderBy()) override fun getFilterList() = FilterList(OrderBy())
interface Loader { sealed class Format {
fun load(): List<Page> data class Directory(val file: File) : Format()
data class Zip(val file: File) : Format()
data class Rar(val file: File): Format()
data class Epub(val file: File) : Format()
} }
class DirectoryLoader(val file: File) : Loader { }
override fun load(): List<Page> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return file.listFiles()
.filter { !it.isDirectory && DiskUtil.isImage(it.name, { FileInputStream(it) }) }
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
.map { Uri.fromFile(it) }
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
}
}
class ZipLoader(val file: File) : Loader {
override fun load(): List<Page> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return ZipFile(file).use { zip ->
zip.entries().toList()
.filter { !it.isDirectory && DiskUtil.isImage(it.name, { zip.getInputStream(it) }) }
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
.map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/${it.name}") }
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
}
}
}
class RarLoader(val file: File) : Loader {
override fun load(): List<Page> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return Archive(file).use { archive ->
archive.fileHeaders
.filter { !it.isDirectory && DiskUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) }
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
.map { Uri.parse("content://${RarContentProvider.PROVIDER}${file.absolutePath}!-/${it.fileNameString}") }
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
}
}
}
class EpubLoader(val file: File) : Loader {
override fun load(): List<Page> {
ZipFile(file).use { zip ->
val allEntries = zip.entries().toList()
val ref = getPackageHref(zip)
val doc = getPackageDocument(zip, ref)
val pages = getPagesFromDocument(doc)
val hrefs = getHrefMap(ref, allEntries.map { it.name })
return getImagesFromPages(zip, pages, hrefs)
.map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/$it") }
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
}
}
/**
* Returns the path to the package document.
*/
private fun getPackageHref(zip: ZipFile): String {
val meta = zip.getEntry("META-INF/container.xml")
if (meta != null) {
val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") }
val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path")
if (path != null) {
return path
}
}
return "OEBPS/content.opf"
}
/**
* Returns the package document where all the files are listed.
*/
private fun getPackageDocument(zip: ZipFile, ref: String): Document {
val entry = zip.getEntry(ref)
return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
}
/**
* Returns all the pages from the epub.
*/
private fun getPagesFromDocument(document: Document): List<String> {
val pages = document.select("manifest > item")
.filter { "application/xhtml+xml" == it.attr("media-type") }
.associateBy { it.attr("id") }
val spine = document.select("spine > itemref").map { it.attr("idref") }
return spine.mapNotNull { pages[it] }.map { it.attr("href") }
}
/**
* Returns all the images contained in every page from the epub.
*/
private fun getImagesFromPages(zip: ZipFile, pages: List<String>, hrefs: Map<String, String>): List<String> {
return pages.map { page ->
val entry = zip.getEntry(hrefs[page])
val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
document.getElementsByTag("img").mapNotNull { hrefs[it.attr("src")] }
}.flatten()
}
/**
* Returns a map with a relative url as key and abolute url as path.
*/
private fun getHrefMap(packageHref: String, entries: List<String>): Map<String, String> {
val lastSlashPos = packageHref.lastIndexOf('/')
if (lastSlashPos < 0) {
return entries.associateBy { it }
}
return entries.associateBy { entry ->
if (entry.isNotBlank() && entry.length > lastSlashPos) {
entry.substring(lastSlashPos + 1)
} else {
entry
}
}
}
}
}

View File

@ -2,21 +2,18 @@ package eu.kanade.tachiyomi.source.model
import android.net.Uri import android.net.Uri
import eu.kanade.tachiyomi.network.ProgressListener import eu.kanade.tachiyomi.network.ProgressListener
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
import rx.subjects.Subject import rx.subjects.Subject
class Page( open class Page(
val index: Int, val index: Int,
var url: String = "", var url: String = "",
var imageUrl: String? = null, var imageUrl: String? = null,
@Transient var uri: Uri? = null @Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions
) : ProgressListener { ) : ProgressListener {
val number: Int val number: Int
get() = index + 1 get() = index + 1
@Transient lateinit var chapter: ReaderChapter
@Transient @Volatile var status: Int = 0 @Transient @Volatile var status: Int = 0
set(value) { set(value) {
field = value field = value

View File

@ -1,88 +1,15 @@
package eu.kanade.tachiyomi.source.online package eu.kanade.tachiyomi.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.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy
// TODO: this should be handled with a different approach.
/**
* Chapter cache.
*/
private val chapterCache: ChapterCache by injectLazy()
/**
* Returns an observable with the page list for a chapter. It tries to return the page list from
* the local cache, otherwise fallbacks to network.
*
* @param chapter the chapter whose page list has to be fetched.
*/
fun HttpSource.fetchPageListFromCacheThenNet(chapter: Chapter): Observable<List<Page>> {
return chapterCache
.getPageListFromCache(chapter)
.onErrorResumeNext { fetchPageList(chapter) }
}
/**
* Returns an observable of the page with the downloaded image.
*
* @param page the page whose source image has to be downloaded.
*/
fun HttpSource.fetchImageFromCacheThenNet(page: Page): Observable<Page> {
return if (page.imageUrl.isNullOrEmpty())
getImageUrl(page).flatMap { getCachedImage(it) }
else
getCachedImage(page)
}
fun HttpSource.getImageUrl(page: Page): Observable<Page> { fun HttpSource.getImageUrl(page: Page): Observable<Page> {
page.status = Page.LOAD_PAGE page.status = Page.LOAD_PAGE
return fetchImageUrl(page) return fetchImageUrl(page)
.doOnError { page.status = Page.ERROR } .doOnError { page.status = Page.ERROR }
.onErrorReturn { null } .onErrorReturn { null }
.doOnNext { page.imageUrl = it } .doOnNext { page.imageUrl = it }
.map { page } .map { page }
}
/**
* Returns an observable of the page that gets the image from the chapter or fallbacks to
* network and copies it to the cache calling [cacheImage].
*
* @param page the page.
*/
fun HttpSource.getCachedImage(page: Page): Observable<Page> {
val imageUrl = page.imageUrl ?: return Observable.just(page)
return Observable.just(page)
.flatMap {
if (!chapterCache.isImageInCache(imageUrl)) {
cacheImage(page)
} else {
Observable.just(page)
}
}
.doOnNext {
page.uri = Uri.fromFile(chapterCache.getImageFile(imageUrl))
page.status = Page.READY
}
.doOnError { page.status = Page.ERROR }
.onErrorReturn { page }
}
/**
* Returns an observable of the page that downloads the image to [ChapterCache].
*
* @param page the page.
*/
private fun HttpSource.cacheImage(page: Page): Observable<Page> {
page.status = Page.DOWNLOAD_IMAGE
return fetchImage(page)
.doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) }
.map { page }
} }
fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> { fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> {

View File

@ -18,7 +18,7 @@ class Mangasee : ParsedHttpSource() {
override val name = "Mangasee" override val name = "Mangasee"
override val baseUrl = "http://mangaseeonline.net" override val baseUrl = "http://mangaseeonline.us"
override val lang = "en" override val lang = "en"
@ -246,4 +246,4 @@ class Mangasee : ParsedHttpSource() {
Genre("Yuri") Genre("Yuri")
) )
} }

View File

@ -4,6 +4,7 @@ import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.* import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.source.online.ParsedHttpSource import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import okhttp3.Headers import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
@ -36,7 +37,7 @@ class Mintmanga : ParsedHttpSource() {
override fun popularMangaFromElement(element: Element): SManga { override fun popularMangaFromElement(element: Element): SManga {
val manga = SManga.create() val manga = SManga.create()
manga.thumbnail_url = element.select("img.lazy").first().attr("data-original") manga.thumbnail_url = element.select("img.lazy").first()?.attr("data-original")
element.select("h3 > a").first().let { element.select("h3 > a").first().let {
manga.setUrlWithoutDomain(it.attr("href")) manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.attr("title") manga.title = it.attr("title")
@ -52,8 +53,25 @@ class Mintmanga : ParsedHttpSource() {
override fun latestUpdatesNextPageSelector() = "a.nextLink" override fun latestUpdatesNextPageSelector() = "a.nextLink"
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val genres = filters.filterIsInstance<Genre>().joinToString("&") { it.id + arrayOf("=", "=in", "=ex")[it.state] } val url = HttpUrl.parse("$baseUrl/search/advanced")!!.newBuilder()
return GET("$baseUrl/search/advanced?q=$query&$genres", headers) (if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
when (filter) {
is GenreList -> filter.state.forEach { genre ->
if (genre.state != Filter.TriState.STATE_IGNORE) {
url.addQueryParameter(genre.id, arrayOf("=", "=in", "=ex")[genre.state])
}
}
is Category -> filter.state.forEach { category ->
if (category.state != Filter.TriState.STATE_IGNORE) {
url.addQueryParameter(category.id, arrayOf("=", "=in", "=ex")[category.state])
}
}
}
}
if (!query.isEmpty()) {
url.addQueryParameter("q", query)
}
return GET(url.toString().replace("=%3D", "="), headers)
} }
override fun searchMangaSelector() = popularMangaSelector() override fun searchMangaSelector() = popularMangaSelector()
@ -61,7 +79,7 @@ class Mintmanga : ParsedHttpSource() {
override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element) override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element)
// max 200 results // max 200 results
override fun searchMangaNextPageSelector() = null override fun searchMangaNextPageSelector(): Nothing? = null
override fun mangaDetailsParse(document: Document): SManga { override fun mangaDetailsParse(document: Document): SManga {
val infoElement = document.select("div.leftContent").first() val infoElement = document.select("div.leftContent").first()
@ -133,7 +151,12 @@ class Mintmanga : ParsedHttpSource() {
var i = 0 var i = 0
while (m.find()) { while (m.find()) {
val urlParts = m.group().replace("[\"\']+".toRegex(), "").split(',') val urlParts = m.group().replace("[\"\']+".toRegex(), "").split(',')
pages.add(Page(i++, "", urlParts[1] + urlParts[0] + urlParts[2])) val url = if (urlParts[1].isEmpty() && urlParts[2].startsWith("/static/")) {
baseUrl + urlParts[2]
} else {
urlParts[1] + urlParts[0] + urlParts[2]
}
pages.add(Page(i++, "", url))
} }
return pages return pages
} }
@ -153,13 +176,34 @@ class Mintmanga : ParsedHttpSource() {
} }
private class Genre(name: String, val id: String) : Filter.TriState(name) private class Genre(name: String, val id: String) : Filter.TriState(name)
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
private class Category(categories: List<Genre>) : Filter.Group<Genre>("Category", categories)
/* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")] /* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")]
* .map(el => `Genre("${el.textContent.trim()}", $"{el.getAttribute('onclick') * .map(el => `Genre("${el.textContent.trim()}", "${el.getAttribute('onclick')
* .substr(31,el.getAttribute('onclick').length-33)"})`).join(',\n') * .substr(31,el.getAttribute('onclick').length-33)"})`).join(',\n')
* on http://mintmanga.com/search/advanced * on http://mintmanga.com/search/advanced
*/ */
override fun getFilterList() = FilterList( override fun getFilterList() = FilterList(
Category(getCategoryList()),
GenreList(getGenreList())
)
private fun getCategoryList() = listOf(
Genre("В цвете", "el_4614"),
Genre("Веб", "el_1355"),
Genre("Выпуск приостановлен", "el_5232"),
Genre("Ёнкома", "el_2741"),
Genre("Комикс западный", "el_1903"),
Genre("Комикс русский", "el_2173"),
Genre("Манхва", "el_1873"),
Genre("Маньхуа", "el_1875"),
Genre("Не Яой", "el_1874"),
Genre("Ранобэ", "el_5688"),
Genre("Сборник", "el_1348")
)
private fun getGenreList() = listOf(
Genre("арт", "el_2220"), Genre("арт", "el_2220"),
Genre("бара", "el_1353"), Genre("бара", "el_1353"),
Genre("боевик", "el_1346"), Genre("боевик", "el_1346"),

View File

@ -4,6 +4,7 @@ import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.* import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.source.online.ParsedHttpSource import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import okhttp3.Headers import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
@ -36,7 +37,7 @@ class Readmanga : ParsedHttpSource() {
override fun popularMangaFromElement(element: Element): SManga { override fun popularMangaFromElement(element: Element): SManga {
val manga = SManga.create() val manga = SManga.create()
manga.thumbnail_url = element.select("img.lazy").first().attr("data-original") manga.thumbnail_url = element.select("img.lazy").first()?.attr("data-original")
element.select("h3 > a").first().let { element.select("h3 > a").first().let {
manga.setUrlWithoutDomain(it.attr("href")) manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.attr("title") manga.title = it.attr("title")
@ -52,8 +53,25 @@ class Readmanga : ParsedHttpSource() {
override fun latestUpdatesNextPageSelector() = "a.nextLink" override fun latestUpdatesNextPageSelector() = "a.nextLink"
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val genres = filters.filterIsInstance<Genre>().joinToString("&") { it.id + arrayOf("=", "=in", "=ex")[it.state] } val url = HttpUrl.parse("$baseUrl/search/advanced")!!.newBuilder()
return GET("$baseUrl/search/advanced?q=$query&$genres", headers) (if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
when (filter) {
is GenreList -> filter.state.forEach { genre ->
if (genre.state != Filter.TriState.STATE_IGNORE) {
url.addQueryParameter(genre.id, arrayOf("=", "=in", "=ex")[genre.state])
}
}
is Category -> filter.state.forEach { category ->
if (category.state != Filter.TriState.STATE_IGNORE) {
url.addQueryParameter(category.id, arrayOf("=", "=in", "=ex")[category.state])
}
}
}
}
if (!query.isEmpty()) {
url.addQueryParameter("q", query)
}
return GET(url.toString().replace("=%3D", "="), headers)
} }
override fun searchMangaSelector() = popularMangaSelector() override fun searchMangaSelector() = popularMangaSelector()
@ -61,7 +79,7 @@ class Readmanga : ParsedHttpSource() {
override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element) override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element)
// max 200 results // max 200 results
override fun searchMangaNextPageSelector() = null override fun searchMangaNextPageSelector(): Nothing? = null
override fun mangaDetailsParse(document: Document): SManga { override fun mangaDetailsParse(document: Document): SManga {
val infoElement = document.select("div.leftContent").first() val infoElement = document.select("div.leftContent").first()
@ -133,7 +151,12 @@ class Readmanga : ParsedHttpSource() {
var i = 0 var i = 0
while (m.find()) { while (m.find()) {
val urlParts = m.group().replace("[\"\']+".toRegex(), "").split(',') val urlParts = m.group().replace("[\"\']+".toRegex(), "").split(',')
pages.add(Page(i++, "", urlParts[1] + urlParts[0] + urlParts[2])) val url = if (urlParts[1].isEmpty() && urlParts[2].startsWith("/static/")) {
baseUrl + urlParts[2]
} else {
urlParts[1] + urlParts[0] + urlParts[2]
}
pages.add(Page(i++, "", url))
} }
return pages return pages
} }
@ -153,6 +176,8 @@ class Readmanga : ParsedHttpSource() {
} }
private class Genre(name: String, val id: String) : Filter.TriState(name) private class Genre(name: String, val id: String) : Filter.TriState(name)
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
private class Category(categories: List<Genre>) : Filter.Group<Genre>("Category", categories)
/* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")] /* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")]
* .map(el => `Genre("${el.textContent.trim()}", $"{el.getAttribute('onclick') * .map(el => `Genre("${el.textContent.trim()}", $"{el.getAttribute('onclick')
@ -160,6 +185,23 @@ class Readmanga : ParsedHttpSource() {
* on http://readmanga.me/search/advanced * on http://readmanga.me/search/advanced
*/ */
override fun getFilterList() = FilterList( override fun getFilterList() = FilterList(
Category(getCategoryList()),
GenreList(getGenreList())
)
private fun getCategoryList() = listOf(
Genre("В цвете", "el_7290"),
Genre("Веб", "el_2160"),
Genre("Выпуск приостановлен", "el_8033"),
Genre("Ёнкома", "el_2161"),
Genre("Комикс западный", "el_3515"),
Genre("Манхва", "el_3001"),
Genre("Маньхуа", "el_3002"),
Genre("Ранобэ", "el_8575"),
Genre("Сборник", "el_2157")
)
private fun getGenreList() = listOf(
Genre("арт", "el_5685"), Genre("арт", "el_5685"),
Genre("боевик", "el_2155"), Genre("боевик", "el_2155"),
Genre("боевые искусства", "el_2143"), Genre("боевые искусства", "el_2143"),

View File

@ -42,9 +42,8 @@ class CatalogueGridHolder(private val view: View, private val adapter: FlexibleA
.load(manga) .load(manga)
.diskCacheStrategy(DiskCacheStrategy.DATA) .diskCacheStrategy(DiskCacheStrategy.DATA)
.centerCrop() .centerCrop()
.skipMemoryCache(true)
.placeholder(android.R.color.transparent) .placeholder(android.R.color.transparent)
.into(StateImageViewTarget(thumbnail, progress)) .into(StateImageViewTarget(thumbnail, progress))
} }
} }
} }

View File

@ -45,7 +45,6 @@ class CatalogueListHolder(private val view: View, adapter: FlexibleAdapter<*>) :
.centerCrop() .centerCrop()
.circleCrop() .circleCrop()
.dontAnimate() .dontAnimate()
.skipMemoryCache(true)
.placeholder(android.R.color.transparent) .placeholder(android.R.color.transparent)
.into(thumbnail) .into(thumbnail)
} }

View File

@ -53,8 +53,11 @@ class CatalogueSearchHolder(view: View, val adapter: CatalogueSearchAdapter) :
val source = item.source val source = item.source
val results = item.results val results = item.results
// Set Title witch country code if available. val titlePrefix = if (item.highlighted) "" else ""
title.text = if (!source.lang.isEmpty()) "${source.name} (${source.lang})" else source.name val langSuffix = if (source.lang.isNotEmpty()) " (${source.lang})" else ""
// Set Title with country code if available.
title.text = titlePrefix + source.name + langSuffix
when { when {
results == null -> { results == null -> {
@ -101,5 +104,4 @@ class CatalogueSearchHolder(view: View, val adapter: CatalogueSearchAdapter) :
return null return null
} }
} }

View File

@ -9,9 +9,11 @@ import eu.kanade.tachiyomi.source.CatalogueSource
/** /**
* Item that contains search result information. * Item that contains search result information.
* *
* @param source contains information about search result. * @param source the source for the search results.
* @param results the search results.
* @param highlighted whether this search item should be highlighted/marked in the catalogue search view.
*/ */
class CatalogueSearchItem(val source: CatalogueSource, val results: List<CatalogueSearchCardItem>?) class CatalogueSearchItem(val source: CatalogueSource, val results: List<CatalogueSearchCardItem>?, val highlighted: Boolean = false)
: AbstractFlexibleItem<CatalogueSearchHolder>() { : AbstractFlexibleItem<CatalogueSearchHolder>() {
/** /**
@ -61,4 +63,4 @@ class CatalogueSearchItem(val source: CatalogueSource, val results: List<Catalog
return source.id.toInt() return source.id.toInt()
} }
} }

View File

@ -98,7 +98,14 @@ open class CatalogueSearchPresenter(
} }
/** /**
* Initiates a search for mnaga per catalogue. * Creates a catalogue search item
*/
protected open fun createCatalogueSearchItem(source: CatalogueSource, results: List<CatalogueSearchCardItem>?): CatalogueSearchItem {
return CatalogueSearchItem(source, results)
}
/**
* Initiates a search for manga per catalogue.
* *
* @param query query on which to search. * @param query query on which to search.
*/ */
@ -113,7 +120,7 @@ open class CatalogueSearchPresenter(
initializeFetchImageSubscription() initializeFetchImageSubscription()
// Create items with the initial state // Create items with the initial state
val initialItems = sources.map { CatalogueSearchItem(it, null) } val initialItems = sources.map { createCatalogueSearchItem(it, null) }
var items = initialItems var items = initialItems
fetchSourcesSubscription?.unsubscribe() fetchSourcesSubscription?.unsubscribe()
@ -125,7 +132,7 @@ open class CatalogueSearchPresenter(
.map { it.mangas.take(10) } // Get at most 10 manga from search result. .map { it.mangas.take(10) } // Get at most 10 manga from search result.
.map { it.map { networkToLocalManga(it, source.id) } } // Convert to local manga. .map { it.map { networkToLocalManga(it, source.id) } } // Convert to local manga.
.doOnNext { fetchImage(it, source) } // Load manga covers. .doOnNext { fetchImage(it, source) } // Load manga covers.
.map { CatalogueSearchItem(source, it.map { CatalogueSearchCardItem(it) }) } .map { createCatalogueSearchItem(source, it.map { CatalogueSearchCardItem(it) }) }
}, 5) }, 5)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
// Update matching source with the obtained results // Update matching source with the obtained results
@ -212,4 +219,4 @@ open class CatalogueSearchPresenter(
} }
return localManga return localManga
} }
} }

View File

@ -131,6 +131,10 @@ class LibraryPresenter(
// Filter when there are no downloads. // Filter when there are no downloads.
if (filterDownloaded) { if (filterDownloaded) {
// Local manga are always downloaded
if (item.manga.source == LocalSource.ID) {
return@f true
}
// Don't bother with directory checking if download count has been set. // Don't bother with directory checking if download count has been set.
if (item.downloadCount != -1) { if (item.downloadCount != -1) {
return@f item.downloadCount > 0 return@f item.downloadCount > 0

View File

@ -76,6 +76,7 @@ class MainActivity : BaseActivity() {
setTheme(when (preferences.theme()) { setTheme(when (preferences.theme()) {
2 -> R.style.Theme_Tachiyomi_Dark 2 -> R.style.Theme_Tachiyomi_Dark
3 -> R.style.Theme_Tachiyomi_Amoled 3 -> R.style.Theme_Tachiyomi_Amoled
4 -> R.style.Theme_Tachiyomi_DarkBlue
else -> R.style.Theme_Tachiyomi else -> R.style.Theme_Tachiyomi
}) })
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)

View File

@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.isNullOrUnsubscribed import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
@ -21,7 +22,7 @@ import rx.schedulers.Schedulers
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.util.* import java.util.Date
/** /**
* Presenter of [ChaptersController]. * Presenter of [ChaptersController].
@ -180,7 +181,7 @@ class ChaptersPresenter(
observable = observable.filter { it.read } observable = observable.filter { it.read }
} }
if (onlyDownloaded()) { if (onlyDownloaded()) {
observable = observable.filter { it.isDownloaded } observable = observable.filter { it.isDownloaded || it.manga.source == LocalSource.ID }
} }
if (onlyBookmarked()) { if (onlyBookmarked()) {
observable = observable.filter { it.bookmark } observable = observable.filter { it.bookmark }
@ -274,9 +275,8 @@ class ChaptersPresenter(
* @param chapters the list of chapters to delete. * @param chapters the list of chapters to delete.
*/ */
fun deleteChapters(chapters: List<ChapterItem>) { fun deleteChapters(chapters: List<ChapterItem>) {
Observable.from(chapters) Observable.just(chapters)
.doOnNext { deleteChapter(it) } .doOnNext { deleteChaptersInternal(chapters) }
.toList()
.doOnNext { if (onlyDownloaded()) refreshChapters() } .doOnNext { if (onlyDownloaded()) refreshChapters() }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
@ -286,14 +286,15 @@ class ChaptersPresenter(
} }
/** /**
* Deletes a chapter from disk. This method is called in a background thread. * Deletes a list of chapters from disk. This method is called in a background thread.
* @param chapter the chapter to delete. * @param chapters the chapters to delete.
*/ */
private fun deleteChapter(chapter: ChapterItem) { private fun deleteChaptersInternal(chapters: List<ChapterItem>) {
downloadManager.queue.remove(chapter) downloadManager.deleteChapters(chapters, manga, source)
downloadManager.deleteChapter(chapter, manga, source) chapters.forEach {
chapter.status = Download.NOT_DOWNLOADED it.status = Download.NOT_DOWNLOADED
chapter.download = null it.download = null
}
} }
/** /**

View File

@ -383,8 +383,9 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
/** /**
* Update swipe refresh to start showing refresh in progress spinner. * Update swipe refresh to start showing refresh in progress spinner.
*/ */
fun onFetchMangaError() { fun onFetchMangaError(error: Throwable) {
setRefreshing(false) setRefreshing(false)
activity?.toast(error.message)
} }
/** /**

View File

@ -90,9 +90,7 @@ class MangaInfoPresenter(
.doOnNext { sendMangaToView() } .doOnNext { sendMangaToView() }
.subscribeFirst({ view, _ -> .subscribeFirst({ view, _ ->
view.onFetchMangaDone() view.onFetchMangaDone()
}, { view, _ -> }, MangaInfoController::onFetchMangaError)
view.onFetchMangaError()
})
} }
/** /**

View File

@ -2,6 +2,8 @@ package eu.kanade.tachiyomi.ui.migration
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchCardItem
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchItem
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchPresenter import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchPresenter
class SearchPresenter( class SearchPresenter(
@ -10,8 +12,13 @@ class SearchPresenter(
) : CatalogueSearchPresenter(initialQuery) { ) : CatalogueSearchPresenter(initialQuery) {
override fun getEnabledSources(): List<CatalogueSource> { override fun getEnabledSources(): List<CatalogueSource> {
// Filter out the source of the selected manga // Put the source of the selected manga at the top
return super.getEnabledSources() return super.getEnabledSources()
.filterNot { it.id == manga.source } .sortedByDescending { it.id == manga.source }
} }
}
override fun createCatalogueSearchItem(source: CatalogueSource, results: List<CatalogueSearchCardItem>?): CatalogueSearchItem {
//Set the catalogue search item as highlighted if the source matches that of the selected manga
return CatalogueSearchItem(source, results, source.id == manga.source)
}
}

View File

@ -0,0 +1,36 @@
package eu.kanade.tachiyomi.ui.reader
import eu.kanade.tachiyomi.data.database.models.Chapter
/**
* Load strategy using the source order. This is the default ordering.
*/
class ChapterLoadBySource {
fun get(allChapters: List<Chapter>): List<Chapter> {
return allChapters.sortedByDescending { it.source_order }
}
}
/**
* Load strategy using unique chapter numbers with same scanlator preference.
*/
class ChapterLoadByNumber {
fun get(allChapters: List<Chapter>, selectedChapter: Chapter): List<Chapter> {
val chapters = mutableListOf<Chapter>()
val chaptersByNumber = allChapters.groupBy { it.chapter_number }
for ((number, chaptersForNumber) in chaptersByNumber) {
val preferredChapter = when {
// Make sure the selected chapter is always present
number == selectedChapter.chapter_number -> selectedChapter
// If there is only one chapter for this number, use it
chaptersForNumber.size == 1 -> chaptersForNumber.first()
// Prefer a chapter of the same scanlator as the selected
else -> chaptersForNumber.find { it.scanlator == selectedChapter.scanlator }
?: chaptersForNumber.first()
}
chapters.add(preferredChapter)
}
return chapters.sortedBy { it.chapter_number }
}
}

View File

@ -1,170 +0,0 @@
package eu.kanade.tachiyomi.ui.reader
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.fetchImageFromCacheThenNet
import eu.kanade.tachiyomi.source.online.fetchPageListFromCacheThenNet
import eu.kanade.tachiyomi.util.plusAssign
import rx.Observable
import rx.schedulers.Schedulers
import rx.subscriptions.CompositeSubscription
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import java.util.concurrent.PriorityBlockingQueue
import java.util.concurrent.atomic.AtomicInteger
class ChapterLoader(
private val downloadManager: DownloadManager,
private val manga: Manga,
private val source: Source
) {
private val prefs by injectLazy<PreferencesHelper>()
private val queue = PriorityBlockingQueue<PriorityPage>()
private val subscriptions = CompositeSubscription()
fun init() {
prepareOnlineReading()
}
fun restart() {
cleanup()
init()
}
fun cleanup() {
subscriptions.clear()
queue.clear()
}
private fun prepareOnlineReading() {
if (source !is HttpSource) return
for(i in 1 .. prefs.eh_readerThreads().getOrDefault())
subscriptions += Observable.defer { Observable.just(queue.take().page) }
.filter { it.status == Page.QUEUE }
.concatMap { source.fetchImageFromCacheThenNet(it) }
.repeat()
.subscribeOn(Schedulers.io())
.subscribe({
}, { error ->
if (error !is InterruptedException) {
Timber.e(error)
}
})
}
fun loadChapter(chapter: ReaderChapter) = Observable.just(chapter)
.flatMap {
if (chapter.pages == null)
retrievePageList(chapter)
else
Observable.just(chapter.pages!!)
}
.doOnNext { pages ->
if (pages.isEmpty()) {
throw Exception("Page list is empty")
}
// Now that the number of pages is known, fix the requested page if the last one
// was requested.
if (chapter.requestedPage == -1) {
chapter.requestedPage = pages.lastIndex
}
loadPages(chapter)
}
.map { chapter }
private fun retrievePageList(chapter: ReaderChapter) = Observable.just(chapter)
.flatMap {
// Check if the chapter is downloaded.
chapter.isDownloaded = downloadManager.isChapterDownloaded(chapter, manga, true)
if (chapter.isDownloaded) {
// Fetch the page list from disk.
downloadManager.buildPageList(source, manga, chapter)
} else {
(source as? HttpSource)?.fetchPageListFromCacheThenNet(chapter)
?: source.fetchPageList(chapter)
}
}
.doOnNext { pages ->
chapter.pages = pages
pages.forEach { it.chapter = chapter }
}
private fun loadPages(chapter: ReaderChapter) {
if (!chapter.isDownloaded) {
loadOnlinePages(chapter)
}
}
private fun loadOnlinePages(chapter: ReaderChapter) {
chapter.pages?.let { pages ->
val startPage = chapter.requestedPage
val pagesToLoad = if (startPage == 0)
pages
else
pages.drop(startPage)
pagesToLoad.forEach { queue.offer(PriorityPage(it, 0)) }
}
}
fun loadPage(page: Page) {
queue.offer(PriorityPage(page, 0))
}
fun loadPriorizedPage(page: Page) {
queue.offer(PriorityPage(page, 1))
}
fun retryPage(page: Page) {
// --> EH
if(prefs.eh_readerInstantRetry().getOrDefault())
boostPage(page)
else
// <-- EH
queue.offer(PriorityPage(page, 2))
}
// --> EH
fun boostPage(page: Page) {
if(source is HttpSource && page.status == Page.QUEUE)
subscriptions += Observable.just(page)
.concatMap { source.fetchImageFromCacheThenNet(it) }
.subscribeOn(Schedulers.io())
.subscribe({
}, { error ->
if (error !is InterruptedException) {
Timber.e(error)
}
})
}
// <-- EH
private data class PriorityPage(val page: Page, val priority: Int): Comparable<PriorityPage> {
companion object {
private val idGenerator = AtomicInteger()
}
private val identifier = idGenerator.incrementAndGet()
override fun compareTo(other: PriorityPage): Int {
val p = other.priority.compareTo(priority)
return if (p != 0) p else identifier.compareTo(other.identifier)
}
}
}

View File

@ -12,8 +12,13 @@ import android.text.style.ScaleXSpan
import android.util.AttributeSet import android.util.AttributeSet
import android.widget.TextView import android.widget.TextView
class PageIndicatorTextView(context: Context, attrs: AttributeSet? = null) : /**
AppCompatTextView(context, attrs) { * Page indicator found at the bottom of the reader
*/
class PageIndicatorTextView(
context: Context,
attrs: AttributeSet? = null
) : AppCompatTextView(context, attrs) {
private val fillColor = Color.rgb(235, 235, 235) private val fillColor = Color.rgb(235, 235, 235)
private val strokeColor = Color.rgb(45, 45, 45) private val strokeColor = Color.rgb(45, 45, 45)
@ -53,4 +58,4 @@ class PageIndicatorTextView(context: Context, attrs: AttributeSet? = null) :
isAccessible = true isAccessible = true
}!! }!!
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +0,0 @@
package eu.kanade.tachiyomi.ui.reader
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.source.model.Page
class ReaderChapter(c: Chapter) : Chapter by c {
@Transient var pages: List<Page>? = null
var isDownloaded: Boolean = false
var requestedPage: Int = 0
}

View File

@ -1,19 +1,19 @@
package eu.kanade.tachiyomi.ui.reader package eu.kanade.tachiyomi.ui.reader
import android.app.Dialog
import android.graphics.Color import android.graphics.Color
import android.os.Bundle
import android.support.annotation.ColorInt import android.support.annotation.ColorInt
import android.support.v4.app.DialogFragment import android.support.design.widget.BottomSheetBehavior
import android.support.design.widget.BottomSheetDialog
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.SeekBar import android.widget.SeekBar
import com.afollestad.materialdialogs.MaterialDialog
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.util.plusAssign import eu.kanade.tachiyomi.util.plusAssign
import eu.kanade.tachiyomi.widget.SimpleSeekBarListener import eu.kanade.tachiyomi.widget.SimpleSeekBarListener
import kotlinx.android.synthetic.main.reader_custom_filter_dialog.view.* import kotlinx.android.synthetic.main.reader_color_filter.*
import kotlinx.android.synthetic.main.reader_color_filter_sheet.*
import rx.Subscription import rx.Subscription
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import rx.subscriptions.CompositeSubscription import rx.subscriptions.CompositeSubscription
@ -21,33 +21,18 @@ import uy.kohesive.injekt.injectLazy
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
* Custom dialog which can be used to set overlay value's * Color filter sheet to toggle custom filter and brightness overlay.
*/ */
class ReaderCustomFilterDialog : DialogFragment() { class ReaderColorFilterSheet(activity: ReaderActivity) : BottomSheetDialog(activity) {
companion object {
/** Integer mask of alpha value **/
private const val ALPHA_MASK: Long = 0xFF000000
/** Integer mask of red value **/
private const val RED_MASK: Long = 0x00FF0000
/** Integer mask of green value **/
private const val GREEN_MASK: Long = 0x0000FF00
/** Integer mask of blue value **/
private const val BLUE_MASK: Long = 0x000000FF
}
/**
* Provides operations to manage preferences
*/
private val preferences by injectLazy<PreferencesHelper>() private val preferences by injectLazy<PreferencesHelper>()
private var behavior: BottomSheetBehavior<*>? = null
/** /**
* Subscription used for filter overlay * Subscriptions used for this dialog
*/ */
private lateinit var subscriptions: CompositeSubscription private val subscriptions = CompositeSubscription()
/** /**
* Subscription used for custom brightness overlay * Subscription used for custom brightness overlay
@ -59,34 +44,18 @@ class ReaderCustomFilterDialog : DialogFragment() {
*/ */
private var customFilterColorSubscription: Subscription? = null private var customFilterColorSubscription: Subscription? = null
/** init {
* This method will be called after onCreate(Bundle) val view = activity.layoutInflater.inflate(R.layout.reader_color_filter_sheet, null)
* @param savedState The last saved instance state of the Fragment. setContentView(view)
*/
override fun onCreateDialog(savedState: Bundle?): Dialog {
val dialog = MaterialDialog.Builder(activity!!)
.customView(R.layout.reader_custom_filter_dialog, false)
.positiveText(android.R.string.ok)
.build()
subscriptions = CompositeSubscription() behavior = BottomSheetBehavior.from(view.parent as ViewGroup)
onViewCreated(dialog.view, savedState)
return dialog
}
/**
* Called immediately after onCreateView()
* @param view The View returned by onCreateDialog.
* @param savedInstanceState If non-null, this fragment is being re-constructed
*/
override fun onViewCreated(view: View, savedInstanceState: Bundle?) = with(view) {
// Initialize subscriptions. // Initialize subscriptions.
subscriptions += preferences.colorFilter().asObservable() subscriptions += preferences.colorFilter().asObservable()
.subscribe { setColorFilter(it, view) } .subscribe { setColorFilter(it, view) }
subscriptions += preferences.customBrightness().asObservable() subscriptions += preferences.customBrightness().asObservable()
.subscribe { setCustomBrightness(it, view) } .subscribe { setCustomBrightness(it, view) }
// Get color and update values // Get color and update values
val color = preferences.colorFilterValue().getOrDefault() val color = preferences.colorFilterValue().getOrDefault()
@ -154,7 +123,19 @@ class ReaderCustomFilterDialog : DialogFragment() {
} }
} }
}) })
}
override fun onStart() {
super.onStart()
behavior?.skipCollapsed = true
behavior?.state = BottomSheetBehavior.STATE_EXPANDED
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
subscriptions.unsubscribe()
customBrightnessSubscription = null
customFilterColorSubscription = null
} }
/** /**
@ -210,8 +191,8 @@ class ReaderCustomFilterDialog : DialogFragment() {
private fun setCustomBrightness(enabled: Boolean, view: View) { private fun setCustomBrightness(enabled: Boolean, view: View) {
if (enabled) { if (enabled) {
customBrightnessSubscription = preferences.customBrightnessValue().asObservable() customBrightnessSubscription = preferences.customBrightnessValue().asObservable()
.sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) .sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribe { setCustomBrightnessValue(it, view) } .subscribe { setCustomBrightnessValue(it, view) }
subscriptions.add(customBrightnessSubscription) subscriptions.add(customBrightnessSubscription)
} else { } else {
@ -249,13 +230,13 @@ class ReaderCustomFilterDialog : DialogFragment() {
private fun setColorFilter(enabled: Boolean, view: View) { private fun setColorFilter(enabled: Boolean, view: View) {
if (enabled) { if (enabled) {
customFilterColorSubscription = preferences.colorFilterValue().asObservable() customFilterColorSubscription = preferences.colorFilterValue().asObservable()
.sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) .sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribe { setColorFilterValue(it, view) } .subscribe { setColorFilterValue(it, view) }
subscriptions.add(customFilterColorSubscription) subscriptions.add(customFilterColorSubscription)
} else { } else {
customFilterColorSubscription?.let { subscriptions.remove(it) } customFilterColorSubscription?.let { subscriptions.remove(it) }
view.color_overlay.visibility = View.GONE color_overlay.visibility = View.GONE
} }
setColorFilterSeekBar(enabled, view) setColorFilterSeekBar(enabled, view)
} }
@ -319,12 +300,18 @@ class ReaderCustomFilterDialog : DialogFragment() {
return color and 0xFF return color and 0xFF
} }
/** private companion object {
* Called when dialog is dismissed /** Integer mask of alpha value **/
*/ const val ALPHA_MASK: Long = 0xFF000000
override fun onDestroyView() {
subscriptions.unsubscribe() /** Integer mask of red value **/
super.onDestroyView() const val RED_MASK: Long = 0x00FF0000
/** Integer mask of green value **/
const val GREEN_MASK: Long = 0x0000FF00
/** Integer mask of blue value **/
const val BLUE_MASK: Long = 0x000000FF
} }
} }

View File

@ -1,6 +0,0 @@
package eu.kanade.tachiyomi.ui.reader
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
class ReaderEvent(val manga: Manga, val chapter: Chapter)

View File

@ -0,0 +1,74 @@
package eu.kanade.tachiyomi.ui.reader
import android.os.Bundle
import android.support.design.widget.BottomSheetDialog
import android.view.ViewGroup
import com.afollestad.materialdialogs.MaterialDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import kotlinx.android.synthetic.main.reader_page_sheet.*
/**
* Sheet to show when a page is long clicked.
*/
class ReaderPageSheet(
private val activity: ReaderActivity,
private val page: ReaderPage
) : BottomSheetDialog(activity) {
/**
* View used on this sheet.
*/
private val view = activity.layoutInflater.inflate(R.layout.reader_page_sheet, null)
init {
setContentView(view)
set_as_cover_layout.setOnClickListener { setAsCover() }
share_layout.setOnClickListener { share() }
save_layout.setOnClickListener { save() }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val width = context.resources.getDimensionPixelSize(R.dimen.bottom_sheet_width)
if (width > 0) {
window?.setLayout(width, ViewGroup.LayoutParams.MATCH_PARENT)
}
}
/**
* Sets the image of this page as the cover of the manga.
*/
private fun setAsCover() {
if (page.status != Page.READY) return
MaterialDialog.Builder(activity)
.content(activity.getString(R.string.confirm_set_image_as_cover))
.positiveText(android.R.string.yes)
.negativeText(android.R.string.no)
.onPositive { _, _ ->
activity.setAsCover(page)
dismiss()
}
.show()
}
/**
* Shares the image of this page with external apps.
*/
private fun share() {
activity.shareImage(page)
dismiss()
}
/**
* Saves the image of this page on external storage.
*/
private fun save() {
activity.saveImage(page)
dismiss()
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,45 @@
package eu.kanade.tachiyomi.ui.reader
import android.content.Context
import android.graphics.Canvas
import android.support.v7.widget.AppCompatSeekBar
import android.util.AttributeSet
import android.view.MotionEvent
/**
* Seekbar to show current chapter progress.
*/
class ReaderSeekBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : AppCompatSeekBar(context, attrs) {
/**
* Whether the seekbar should draw from right to left.
*/
var isRTL = false
/**
* Draws the seekbar, translating the canvas if using a right to left reader.
*/
override fun draw(canvas: Canvas) {
if (isRTL) {
val px = width / 2f
val py = height / 2f
canvas.scale(-1f, 1f, px, py)
}
super.draw(canvas)
}
/**
* Handles touch events, translating coordinates if using a right to left reader.
*/
override fun onTouchEvent(event: MotionEvent): Boolean {
if (isRTL) {
event.setLocation(width - event.x, event.y)
}
return super.onTouchEvent(event)
}
}

View File

@ -1,119 +0,0 @@
package eu.kanade.tachiyomi.ui.reader
import android.app.Dialog
import android.os.Bundle
import android.support.v4.app.DialogFragment
import android.view.View
import com.afollestad.materialdialogs.MaterialDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.util.plusAssign
import eu.kanade.tachiyomi.util.visibleIf
import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener
import kotlinx.android.synthetic.main.reader_settings_dialog.view.*
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.injectLazy
import java.util.concurrent.TimeUnit.MILLISECONDS
class ReaderSettingsDialog : DialogFragment() {
private val preferences by injectLazy<PreferencesHelper>()
private lateinit var subscriptions: CompositeSubscription
override fun onCreateDialog(savedState: Bundle?): Dialog {
val dialog = MaterialDialog.Builder(activity!!)
.title(R.string.label_settings)
.customView(R.layout.reader_settings_dialog, true)
.positiveText(android.R.string.ok)
.build()
subscriptions = CompositeSubscription()
onViewCreated(dialog.view, savedState)
return dialog
}
override fun onViewCreated(view: View, savedState: Bundle?) = with(view) {
viewer.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
subscriptions += Observable.timer(250, MILLISECONDS, AndroidSchedulers.mainThread())
.subscribe {
val readerActivity = activity as? ReaderActivity
if (readerActivity != null) {
readerActivity.presenter.updateMangaViewer(position)
readerActivity.recreate()
}
}
}
viewer.setSelection((activity as ReaderActivity).presenter.manga.viewer, false)
rotation_mode.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
subscriptions += Observable.timer(250, MILLISECONDS)
.subscribe {
preferences.rotation().set(position + 1)
}
}
rotation_mode.setSelection(preferences.rotation().getOrDefault() - 1, false)
scale_type.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
preferences.imageScaleType().set(position + 1)
}
scale_type.setSelection(preferences.imageScaleType().getOrDefault() - 1, false)
zoom_start.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
preferences.zoomStart().set(position + 1)
}
zoom_start.setSelection(preferences.zoomStart().getOrDefault() - 1, false)
image_decoder.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
preferences.imageDecoder().set(position)
}
image_decoder.setSelection(preferences.imageDecoder().getOrDefault(), false)
background_color.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
preferences.readerTheme().set(position)
}
background_color.setSelection(preferences.readerTheme().getOrDefault(), false)
show_page_number.isChecked = preferences.showPageNumber().getOrDefault()
show_page_number.setOnCheckedChangeListener { _, isChecked ->
preferences.showPageNumber().set(isChecked)
}
fullscreen.isChecked = preferences.fullscreen().getOrDefault()
fullscreen.setOnCheckedChangeListener { _, isChecked ->
preferences.fullscreen().set(isChecked)
}
crop_borders.isChecked = preferences.cropBorders().getOrDefault()
crop_borders.setOnCheckedChangeListener { _, isChecked ->
preferences.cropBorders().set(isChecked)
}
crop_borders_webtoon.isChecked = preferences.cropBordersWebtoon().getOrDefault()
crop_borders_webtoon.setOnCheckedChangeListener { _, isChecked ->
preferences.cropBordersWebtoon().set(isChecked)
}
val readerActivity = activity as? ReaderActivity
val isWebtoonViewer = if (readerActivity != null) {
val mangaViewer = readerActivity.presenter.manga.viewer
val viewer = if (mangaViewer == 0) preferences.defaultViewer() else mangaViewer
viewer == ReaderActivity.WEBTOON
} else {
false
}
crop_borders.visibleIf { !isWebtoonViewer }
crop_borders_webtoon.visibleIf { isWebtoonViewer }
}
override fun onDestroyView() {
subscriptions.unsubscribe()
super.onDestroyView()
}
}

View File

@ -0,0 +1,105 @@
package eu.kanade.tachiyomi.ui.reader
import android.os.Bundle
import android.support.design.widget.BottomSheetDialog
import android.support.v4.widget.NestedScrollView
import android.widget.CompoundButton
import android.widget.Spinner
import com.f2prateek.rx.preferences.Preference
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerViewer
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
import eu.kanade.tachiyomi.util.visible
import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener
import kotlinx.android.synthetic.main.reader_settings_sheet.*
import uy.kohesive.injekt.injectLazy
/**
* Sheet to show reader and viewer preferences.
*/
class ReaderSettingsSheet(private val activity: ReaderActivity) : BottomSheetDialog(activity) {
/**
* Preferences helper.
*/
private val preferences by injectLazy<PreferencesHelper>()
init {
// Use activity theme for this layout
val view = activity.layoutInflater.inflate(R.layout.reader_settings_sheet, null)
val scroll = NestedScrollView(activity)
scroll.addView(view)
setContentView(scroll)
}
/**
* Called when the sheet is created. It initializes the listeners and values of the preferences.
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
initGeneralPreferences()
when (activity.viewer) {
is PagerViewer -> initPagerPreferences()
is WebtoonViewer -> initWebtoonPreferences()
}
}
/**
* Init general reader preferences.
*/
private fun initGeneralPreferences() {
viewer.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
activity.presenter.setMangaViewer(position)
}
viewer.setSelection(activity.presenter.manga?.viewer ?: 0, false)
rotation_mode.bindToPreference(preferences.rotation(), 1)
background_color.bindToPreference(preferences.readerTheme())
show_page_number.bindToPreference(preferences.showPageNumber())
fullscreen.bindToPreference(preferences.fullscreen())
keepscreen.bindToPreference(preferences.keepScreenOn())
long_tap.bindToPreference(preferences.readWithLongTap())
}
/**
* Init the preferences for the pager reader.
*/
private fun initPagerPreferences() {
pager_prefs_group.visible()
scale_type.bindToPreference(preferences.imageScaleType(), 1)
zoom_start.bindToPreference(preferences.zoomStart(), 1)
crop_borders.bindToPreference(preferences.cropBorders())
page_transitions.bindToPreference(preferences.pageTransitions())
}
/**
* Init the preferences for the webtoon reader.
*/
private fun initWebtoonPreferences() {
webtoon_prefs_group.visible()
crop_borders_webtoon.bindToPreference(preferences.cropBordersWebtoon())
}
/**
* Binds a checkbox or switch view with a boolean preference.
*/
private fun CompoundButton.bindToPreference(pref: Preference<Boolean>) {
isChecked = pref.getOrDefault()
setOnCheckedChangeListener { _, isChecked -> pref.set(isChecked) }
}
/**
* Binds a spinner to an int preference with an optional offset for the value.
*/
private fun Spinner.bindToPreference(pref: Preference<Int>, offset: Int = 0) {
onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
pref.set(position + offset)
}
setSelection(pref.getOrDefault() - offset, false)
}
}

View File

@ -16,6 +16,7 @@ import java.io.File
* Class used to show BigPictureStyle notifications * Class used to show BigPictureStyle notifications
*/ */
class SaveImageNotifier(private val context: Context) { class SaveImageNotifier(private val context: Context) {
/** /**
* Notification builder. * Notification builder.
*/ */
@ -35,12 +36,12 @@ class SaveImageNotifier(private val context: Context) {
*/ */
fun onComplete(file: File) { fun onComplete(file: File) {
val bitmap = GlideApp.with(context) val bitmap = GlideApp.with(context)
.asBitmap() .asBitmap()
.load(file) .load(file)
.diskCacheStrategy(DiskCacheStrategy.NONE) .diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true) .skipMemoryCache(true)
.submit(720, 1280) .submit(720, 1280)
.get() .get()
if (bitmap != null) { if (bitmap != null) {
showCompleteNotification(file, bitmap) showCompleteNotification(file, bitmap)

View File

@ -0,0 +1,84 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import rx.Completable
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import timber.log.Timber
/**
* Loader used to retrieve the [PageLoader] for a given chapter.
*/
class ChapterLoader(
private val downloadManager: DownloadManager,
private val manga: Manga,
private val source: Source
) {
/**
* Returns a completable that assigns the page loader and loads the its pages. It just
* completes if the chapter is already loaded.
*/
fun loadChapter(chapter: ReaderChapter): Completable {
if (chapter.state is ReaderChapter.State.Loaded) {
return Completable.complete()
}
return Observable.just(chapter)
.doOnNext { chapter.state = ReaderChapter.State.Loading }
.observeOn(Schedulers.io())
.flatMap {
Timber.d("Loading pages for ${chapter.chapter.name}")
val loader = getPageLoader(it)
chapter.pageLoader = loader
loader.getPages().take(1).doOnNext { pages ->
pages.forEach { it.chapter = chapter }
}
}
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { pages ->
if (pages.isEmpty()) {
throw Exception("Page list is empty")
}
chapter.state = ReaderChapter.State.Loaded(pages)
// If the chapter is partially read, set the starting page to the last the user read
// otherwise use the requested page.
if (!chapter.chapter.read) {
chapter.requestedPage = chapter.chapter.last_page_read
}
}
.toCompletable()
.doOnError { chapter.state = ReaderChapter.State.Error(it) }
}
/**
* Returns the page loader to use for this [chapter].
*/
private fun getPageLoader(chapter: ReaderChapter): PageLoader {
val isDownloaded = downloadManager.isChapterDownloaded(chapter.chapter, manga, true)
return when {
isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager)
source is HttpSource -> HttpPageLoader(chapter, source)
source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
when (format) {
is LocalSource.Format.Directory -> DirectoryPageLoader(format.file)
is LocalSource.Format.Zip -> ZipPageLoader(format.file)
is LocalSource.Format.Rar -> RarPageLoader(format.file)
is LocalSource.Format.Epub -> EpubPageLoader(format.file)
}
}
else -> error("Loader not implemented")
}
}
}

View File

@ -0,0 +1,43 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.ImageUtil
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import rx.Observable
import java.io.File
import java.io.FileInputStream
/**
* Loader used to load a chapter from a directory given on [file].
*/
class DirectoryPageLoader(val file: File) : PageLoader() {
/**
* Returns an observable containing the pages found on this directory ordered with a natural
* comparator.
*/
override fun getPages(): Observable<List<ReaderPage>> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return file.listFiles()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
.mapIndexed { i, file ->
val streamFn = { FileInputStream(file) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
.let { Observable.just(it) }
}
/**
* Returns an observable that emits a ready state.
*/
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.just(Page.READY)
}
}

View File

@ -0,0 +1,48 @@
package eu.kanade.tachiyomi.ui.reader.loader
import android.app.Application
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import rx.Observable
import uy.kohesive.injekt.injectLazy
/**
* Loader used to load a chapter from the downloaded chapters.
*/
class DownloadPageLoader(
private val chapter: ReaderChapter,
private val manga: Manga,
private val source: Source,
private val downloadManager: DownloadManager
) : PageLoader() {
/**
* The application context. Needed to open input streams.
*/
private val context by injectLazy<Application>()
/**
* Returns an observable containing the pages found on this downloaded chapter.
*/
override fun getPages(): Observable<List<ReaderPage>> {
return downloadManager.buildPageList(source, manga, chapter.chapter)
.map { pages ->
pages.map { page ->
ReaderPage(page.index, page.url, page.imageUrl, {
context.contentResolver.openInputStream(page.uri)
}).apply {
status = Page.READY
}
}
}
}
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.just(Page.READY) // TODO maybe check if file still exists?
}
}

View File

@ -0,0 +1,54 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.EpubFile
import rx.Observable
import java.io.File
/**
* Loader used to load a chapter from a .epub file.
*/
class EpubPageLoader(file: File) : PageLoader() {
/**
* The epub file.
*/
private val epub = EpubFile(file)
/**
* Recycles this loader and the open zip.
*/
override fun recycle() {
super.recycle()
epub.close()
}
/**
* Returns an observable containing the pages found on this zip archive ordered with a natural
* comparator.
*/
override fun getPages(): Observable<List<ReaderPage>> {
return epub.getImagesFromPages()
.mapIndexed { i, path ->
val streamFn = { epub.getInputStream(epub.getEntry(path)!!) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
.let { Observable.just(it) }
}
/**
* Returns an observable that emits a ready state unless the loader was recycled.
*/
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.just(if (isRecycled) {
Page.ERROR
} else {
Page.READY
})
}
}

View File

@ -0,0 +1,226 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.plusAssign
import rx.Completable
import rx.Observable
import rx.schedulers.Schedulers
import rx.subjects.PublishSubject
import rx.subjects.SerializedSubject
import rx.subscriptions.CompositeSubscription
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.PriorityBlockingQueue
import java.util.concurrent.atomic.AtomicInteger
/**
* Loader used to load chapters from an online source.
*/
class HttpPageLoader(
private val chapter: ReaderChapter,
private val source: HttpSource,
private val chapterCache: ChapterCache = Injekt.get()
) : PageLoader() {
/**
* A queue used to manage requests one by one while allowing priorities.
*/
private val queue = PriorityBlockingQueue<PriorityPage>()
/**
* Current active subscriptions.
*/
private val subscriptions = CompositeSubscription()
init {
subscriptions += Observable.defer { Observable.just(queue.take().page) }
.filter { it.status == Page.QUEUE }
.concatMap { source.fetchImageFromCacheThenNet(it) }
.repeat()
.subscribeOn(Schedulers.io())
.subscribe({
}, { error ->
if (error !is InterruptedException) {
Timber.e(error)
}
})
}
/**
* Recycles this loader and the active subscriptions and queue.
*/
override fun recycle() {
super.recycle()
subscriptions.unsubscribe()
queue.clear()
// Cache current page list progress for online chapters to allow a faster reopen
val pages = chapter.pages
if (pages != null) {
Completable
.fromAction {
// Convert to pages without reader information
val pagesToSave = pages.map { Page(it.index, it.url, it.imageUrl) }
chapterCache.putPageListToCache(chapter.chapter, pagesToSave)
}
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
}
}
/**
* Returns an observable with the page list for a chapter. It tries to return the page list from
* the local cache, otherwise fallbacks to network.
*/
override fun getPages(): Observable<List<ReaderPage>> {
return chapterCache
.getPageListFromCache(chapter.chapter)
.onErrorResumeNext { source.fetchPageList(chapter.chapter) }
.map { pages ->
pages.mapIndexed { index, page -> // Don't trust sources and use our own indexing
ReaderPage(index, page.url, page.imageUrl)
}
}
}
/**
* Returns an observable that loads a page through the queue and listens to its result to
* emit new states. It handles re-enqueueing pages if they were evicted from the cache.
*/
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.defer {
val imageUrl = page.imageUrl
// Check if the image has been deleted
if (page.status == Page.READY && imageUrl != null && !chapterCache.isImageInCache(imageUrl)) {
page.status = Page.QUEUE
}
// Automatically retry failed pages when subscribed to this page
if (page.status == Page.ERROR) {
page.status = Page.QUEUE
}
val statusSubject = SerializedSubject(PublishSubject.create<Int>())
page.setStatusSubject(statusSubject)
if (page.status == Page.QUEUE) {
queue.offer(PriorityPage(page, 1))
}
preloadNextPages(page, 4)
statusSubject.startWith(page.status)
}
}
/**
* Preloads the given [amount] of pages after the [currentPage] with a lower priority.
*/
private fun preloadNextPages(currentPage: ReaderPage, amount: Int) {
val pageIndex = currentPage.index
val pages = currentPage.chapter.pages ?: return
if (pageIndex == pages.lastIndex) return
val nextPages = pages.subList(pageIndex + 1, Math.min(pageIndex + 1 + amount, pages.size))
for (nextPage in nextPages) {
if (nextPage.status == Page.QUEUE) {
queue.offer(PriorityPage(nextPage, 0))
}
}
}
/**
* Retries a page. This method is only called from user interaction on the viewer.
*/
override fun retryPage(page: ReaderPage) {
if (page.status == Page.ERROR) {
page.status = Page.QUEUE
}
queue.offer(PriorityPage(page, 2))
}
/**
* Data class used to keep ordering of pages in order to maintain priority.
*/
private data class PriorityPage(
val page: ReaderPage,
val priority: Int
): Comparable<PriorityPage> {
companion object {
private val idGenerator = AtomicInteger()
}
private val identifier = idGenerator.incrementAndGet()
override fun compareTo(other: PriorityPage): Int {
val p = other.priority.compareTo(priority)
return if (p != 0) p else identifier.compareTo(other.identifier)
}
}
/**
* Returns an observable of the page with the downloaded image.
*
* @param page the page whose source image has to be downloaded.
*/
private fun HttpSource.fetchImageFromCacheThenNet(page: ReaderPage): Observable<ReaderPage> {
return if (page.imageUrl.isNullOrEmpty())
getImageUrl(page).flatMap { getCachedImage(it) }
else
getCachedImage(page)
}
private fun HttpSource.getImageUrl(page: ReaderPage): Observable<ReaderPage> {
page.status = Page.LOAD_PAGE
return fetchImageUrl(page)
.doOnError { page.status = Page.ERROR }
.onErrorReturn { null }
.doOnNext { page.imageUrl = it }
.map { page }
}
/**
* Returns an observable of the page that gets the image from the chapter or fallbacks to
* network and copies it to the cache calling [cacheImage].
*
* @param page the page.
*/
private fun HttpSource.getCachedImage(page: ReaderPage): Observable<ReaderPage> {
val imageUrl = page.imageUrl ?: return Observable.just(page)
return Observable.just(page)
.flatMap {
if (!chapterCache.isImageInCache(imageUrl)) {
cacheImage(page)
} else {
Observable.just(page)
}
}
.doOnNext {
page.stream = { chapterCache.getImageFile(imageUrl).inputStream() }
page.status = Page.READY
}
.doOnError { page.status = Page.ERROR }
.onErrorReturn { page }
}
/**
* Returns an observable of the page that downloads the image to [ChapterCache].
*
* @param page the page.
*/
private fun HttpSource.cacheImage(page: ReaderPage): Observable<ReaderPage> {
page.status = Page.DOWNLOAD_IMAGE
return fetchImage(page)
.doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) }
.map { page }
}
}

View File

@ -0,0 +1,46 @@
package eu.kanade.tachiyomi.ui.reader.loader
import android.support.annotation.CallSuper
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import rx.Observable
/**
* A loader used to load pages into the reader. Any open resources must be cleaned up when the
* method [recycle] is called.
*/
abstract class PageLoader {
/**
* Whether this loader has been already recycled.
*/
var isRecycled = false
private set
/**
* Recycles this loader. Implementations must override this method to clean up any active
* resources.
*/
@CallSuper
open fun recycle() {
isRecycled = true
}
/**
* Returns an observable containing the list of pages of a chapter. Only the first emission
* will be used.
*/
abstract fun getPages(): Observable<List<ReaderPage>>
/**
* Returns an observable that should inform of the progress of the page (see the Page class
* for the available states)
*/
abstract fun getPage(page: ReaderPage): Observable<Int>
/**
* Retries the given [page] in case it failed to load. This method only makes sense when an
* online source is used.
*/
open fun retryPage(page: ReaderPage) {}
}

View File

@ -0,0 +1,89 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.ImageUtil
import junrar.Archive
import junrar.rarfile.FileHeader
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import rx.Observable
import java.io.File
import java.io.InputStream
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.util.concurrent.Executors
/**
* Loader used to load a chapter from a .rar or .cbr file.
*/
class RarPageLoader(file: File) : PageLoader() {
/**
* The rar archive to load pages from.
*/
private val archive = Archive(file)
/**
* Pool for copying compressed files to an input stream.
*/
private val pool = Executors.newFixedThreadPool(1)
/**
* Recycles this loader and the open archive.
*/
override fun recycle() {
super.recycle()
archive.close()
pool.shutdown()
}
/**
* Returns an observable containing the pages found on this rar archive ordered with a natural
* comparator.
*/
override fun getPages(): Observable<List<ReaderPage>> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return archive.fileHeaders
.filter { !it.isDirectory && ImageUtil.isImage(it.fileNameString) { archive.getInputStream(it) } }
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
.mapIndexed { i, header ->
val streamFn = { getStream(header) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
.let { Observable.just(it) }
}
/**
* Returns an observable that emits a ready state unless the loader was recycled.
*/
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.just(if (isRecycled) {
Page.ERROR
} else {
Page.READY
})
}
/**
* Returns an input stream for the given [header].
*/
private fun getStream(header: FileHeader): InputStream {
val pipeIn = PipedInputStream()
val pipeOut = PipedOutputStream(pipeIn)
pool.execute {
try {
pipeOut.use {
archive.extractFile(header, it)
}
} catch (e: Exception) {
}
}
return pipeIn
}
}

View File

@ -0,0 +1,60 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.ImageUtil
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import rx.Observable
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
/**
* Loader used to load a chapter from a .zip or .cbz file.
*/
class ZipPageLoader(file: File) : PageLoader() {
/**
* The zip file to load pages from.
*/
private val zip = ZipFile(file)
/**
* Recycles this loader and the open zip.
*/
override fun recycle() {
super.recycle()
zip.close()
}
/**
* Returns an observable containing the pages found on this zip archive ordered with a natural
* comparator.
*/
override fun getPages(): Observable<List<ReaderPage>> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return zip.entries().toList()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
.mapIndexed { i, entry ->
val streamFn = { zip.getInputStream(entry) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
.let { Observable.just(it) }
}
/**
* Returns an observable that emits a ready state unless the loader was recycled.
*/
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.just(if (isRecycled) {
Page.ERROR
} else {
Page.READY
})
}
}

View File

@ -0,0 +1,33 @@
package eu.kanade.tachiyomi.ui.reader.model
sealed class ChapterTransition {
abstract val from: ReaderChapter
abstract val to: ReaderChapter?
class Prev(
override val from: ReaderChapter, override val to: ReaderChapter?
) : ChapterTransition()
class Next(
override val from: ReaderChapter, override val to: ReaderChapter?
) : ChapterTransition()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ChapterTransition) return false
if (from == other.from && to == other.to) return true
if (from == other.to && to == other.from) return true
return false
}
override fun hashCode(): Int {
var result = from.hashCode()
result = 31 * result + (to?.hashCode() ?: 0)
return result
}
override fun toString(): String {
return "${javaClass.simpleName}(from=${from.chapter.url}, to=${to?.chapter?.url})"
}
}

View File

@ -0,0 +1,53 @@
package eu.kanade.tachiyomi.ui.reader.model
import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
import timber.log.Timber
data class ReaderChapter(val chapter: Chapter) {
var state: State =
State.Wait
set(value) {
field = value
stateRelay.call(value)
}
private val stateRelay by lazy { BehaviorRelay.create(state) }
val stateObserver by lazy { stateRelay.asObservable() }
val pages: List<ReaderPage>?
get() = (state as? State.Loaded)?.pages
var pageLoader: PageLoader? = null
var requestedPage: Int = 0
var references = 0
private set
fun ref() {
references++
}
fun unref() {
references--
if (references == 0) {
if (pageLoader != null) {
Timber.d("Recycling chapter ${chapter.name}")
}
pageLoader?.recycle()
pageLoader = null
state = State.Wait
}
}
sealed class State {
object Wait : State()
object Loading : State()
class Error(val error: Throwable) : State()
class Loaded(val pages: List<ReaderPage>) : State()
}
}

View File

@ -0,0 +1,15 @@
package eu.kanade.tachiyomi.ui.reader.model
import eu.kanade.tachiyomi.source.model.Page
import java.io.InputStream
class ReaderPage(
index: Int,
url: String = "",
imageUrl: String? = null,
var stream: (() -> InputStream)? = null
) : Page(index, url, imageUrl, null) {
lateinit var chapter: ReaderChapter
}

View File

@ -0,0 +1,21 @@
package eu.kanade.tachiyomi.ui.reader.model
data class ViewerChapters(
val currChapter: ReaderChapter,
val prevChapter: ReaderChapter?,
val nextChapter: ReaderChapter?
) {
fun ref() {
currChapter.ref()
prevChapter?.ref()
nextChapter?.ref()
}
fun unref() {
currChapter.unref()
prevChapter?.unref()
nextChapter?.unref()
}
}

View File

@ -0,0 +1,46 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
/**
* Interface for implementing a viewer.
*/
interface BaseViewer {
/**
* Returns the view this viewer uses.
*/
fun getView(): View
/**
* Destroys this viewer. Called when leaving the reader or swapping viewers.
*/
fun destroy() {}
/**
* Tells this viewer to set the given [chapters] as active.
*/
fun setChapters(chapters: ViewerChapters)
/**
* Tells this viewer to move to the given [page].
*/
fun moveToPage(page: ReaderPage)
/**
* Called from the containing activity when a key [event] is received. It should return true
* if the event was handled, false otherwise.
*/
fun handleKeyEvent(event: KeyEvent): Boolean
/**
* Called from the containing activity when a generic motion [event] is received. It should
* return true if the event was handled, false otherwise.
*/
fun handleGenericMotionEvent(event: MotionEvent): Boolean
}

View File

@ -0,0 +1,74 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.os.Handler
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ViewConfiguration
/**
* A custom gesture detector that also implements an on long tap confirmed, because the built-in
* one conflicts with the quick scale feature.
*/
open class GestureDetectorWithLongTap(
context: Context,
listener: Listener
) : GestureDetector(context, listener) {
private val handler = Handler()
private val slop = ViewConfiguration.get(context).scaledTouchSlop
private val longTapTime = ViewConfiguration.getLongPressTimeout().toLong()
private val doubleTapTime = ViewConfiguration.getDoubleTapTimeout().toLong()
private var downX = 0f
private var downY = 0f
private var lastUp = 0L
private var lastDownEvent: MotionEvent? = null
/**
* Runnable to execute when a long tap is confirmed.
*/
private val longTapFn = Runnable { listener.onLongTapConfirmed(lastDownEvent!!) }
override fun onTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
lastDownEvent?.recycle()
lastDownEvent = MotionEvent.obtain(ev)
// This is the key difference with the built-in detector. We have to ignore the
// event if the last up and current down are too close in time (double tap).
if (ev.downTime - lastUp > doubleTapTime) {
downX = ev.rawX
downY = ev.rawY
handler.postDelayed(longTapFn, longTapTime)
}
}
MotionEvent.ACTION_MOVE -> {
if (Math.abs(ev.rawX - downX) > slop || Math.abs(ev.rawY - downY) > slop) {
handler.removeCallbacks(longTapFn)
}
}
MotionEvent.ACTION_UP -> {
lastUp = ev.eventTime
handler.removeCallbacks(longTapFn)
}
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_POINTER_DOWN -> {
handler.removeCallbacks(longTapFn)
}
}
return super.onTouchEvent(ev)
}
/**
* Custom listener to also include a long tap confirmed
*/
open class Listener : SimpleOnGestureListener() {
/**
* Notified when a long tap occurs with the initial on down [ev] that triggered it.
*/
open fun onLongTapConfirmed(ev: MotionEvent) {
}
}
}

View File

@ -0,0 +1,212 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.view.View
import android.view.animation.Animation
import android.view.animation.DecelerateInterpolator
import android.view.animation.LinearInterpolator
import android.view.animation.RotateAnimation
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.getResourceColor
/**
* A custom progress bar that always rotates while being determinate. By always rotating we give
* the feedback to the user that the application isn't 'stuck', and by making it determinate the
* user also approximately knows how much the operation will take.
*/
class ReaderProgressBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
/**
* The current sweep angle. It always starts at 10% because otherwise the bar and the rotation
* wouldn't be visible.
*/
private var sweepAngle = 10f
/**
* Whether the parent views are also visible.
*/
private var aggregatedIsVisible = false
/**
* The paint to use to draw the progress bar.
*/
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = context.getResourceColor(R.attr.colorAccent)
isAntiAlias = true
strokeCap = Paint.Cap.ROUND
style = Paint.Style.STROKE
}
/**
* The rectangle of the canvas where the progress bar should be drawn. This is calculated on
* layout.
*/
private val ovalRect = RectF()
/**
* The rotation animation to use while the progress bar is visible.
*/
private val rotationAnimation by lazy {
RotateAnimation(0f, 360f,
Animation.RELATIVE_TO_SELF, 0.5f,
Animation.RELATIVE_TO_SELF, 0.5f
).apply {
interpolator = LinearInterpolator()
repeatCount = Animation.INFINITE
duration = 4000
}
}
/**
* Called when the view is layout. The position and thickness of the progress bar is calculated.
*/
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
val diameter = Math.min(width, height)
val thickness = diameter / 10f
val pad = thickness / 2f
ovalRect.set(pad, pad, diameter - pad, diameter - pad)
paint.strokeWidth = thickness
}
/**
* Called when the view is being drawn. An arc is drawn with the calculated rectangle. The
* animation will take care of rotation.
*/
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawArc(ovalRect, -90f, sweepAngle, false, paint)
}
/**
* Calculates the sweep angle to use from the progress.
*/
private fun calcSweepAngleFromProgress(progress: Int): Float {
return 360f / 100 * progress
}
/**
* Called when this view is attached to window. It starts the rotation animation.
*/
override fun onAttachedToWindow() {
super.onAttachedToWindow()
startAnimation()
}
/**
* Called when this view is detached to window. It stops the rotation animation.
*/
override fun onDetachedFromWindow() {
stopAnimation()
super.onDetachedFromWindow()
}
/**
* Called when the visibility of this view changes.
*/
override fun setVisibility(visibility: Int) {
super.setVisibility(visibility)
val isVisible = visibility == View.VISIBLE
if (isVisible) {
startAnimation()
} else {
stopAnimation()
}
}
/**
* Starts the rotation animation if needed.
*/
private fun startAnimation() {
if (visibility != View.VISIBLE || windowVisibility != View.VISIBLE || animation != null) {
return
}
animation = rotationAnimation
animation.start()
}
/**
* Stops the rotation animation if needed.
*/
private fun stopAnimation() {
clearAnimation()
}
/**
* Hides this progress bar with an optional fade out if [animate] is true.
*/
fun hide(animate: Boolean = false) {
if (visibility == View.GONE) return
if (!animate) {
visibility = View.GONE
} else {
ObjectAnimator.ofFloat(this, "alpha", 1f, 0f).apply {
interpolator = DecelerateInterpolator()
duration = 1000
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
visibility = View.GONE
alpha = 1f
}
override fun onAnimationCancel(animation: Animator?) {
alpha = 1f
}
})
start()
}
}
}
/**
* Completes this progress bar and fades out the view.
*/
fun completeAndFadeOut() {
setRealProgress(100)
hide(true)
}
/**
* Set progress of the circular progress bar ensuring a min max range in order to notice the
* rotation animation.
*/
fun setProgress(progress: Int) {
// Scale progress in [10, 95] range
val scaledProgress = 85 * progress / 100 + 10
setRealProgress(scaledProgress)
}
/**
* Sets the real progress of the circular progress bar. Note that if this progres is 0 or
* 100, the rotation animation won't be noticed by the user because nothing changes in the
* canvas.
*/
private fun setRealProgress(progress: Int) {
ValueAnimator.ofFloat(sweepAngle, calcSweepAngleFromProgress(progress)).apply {
interpolator = DecelerateInterpolator()
duration = 250
addUpdateListener { valueAnimator ->
sweepAngle = valueAnimator.animatedValue as Float
invalidate()
}
start()
}
}
}

View File

@ -1,253 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.base
import android.support.v4.app.Fragment
import com.davemorrissey.labs.subscaleview.decoder.*
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
import java.util.*
/**
* Base reader containing the common data that can be used by its implementations. It does not
* contain any UI related action.
*/
abstract class BaseReader : Fragment() {
companion object {
/**
* Image decoder.
*/
const val IMAGE_DECODER = 0
/**
* Rapid decoder.
*/
const val RAPID_DECODER = 1
/**
* Skia decoder.
*/
const val SKIA_DECODER = 2
}
/**
* List of chapters added in the reader.
*/
val chapters = ArrayList<ReaderChapter>()
/**
* List of pages added in the reader. It can contain pages from more than one chapter.
*/
var pages: MutableList<Page> = ArrayList()
private set
/**
* Current visible position of [pages].
*/
var currentPage: Int = 0
protected set
/**
* Region decoder class to use.
*/
lateinit var regionDecoderClass: Class<out ImageRegionDecoder>
private set
/**
* Bitmap decoder class to use.
*/
lateinit var bitmapDecoderClass: Class<out ImageDecoder>
private set
/**
* Whether tap navigation is enabled or not.
*/
val tappingEnabled by lazy { readerActivity.preferences.readWithTapping().getOrDefault() }
/**
* Whether the reader has requested to append a chapter. Used with seamless mode to avoid
* restarting requests when changing pages.
*/
private var hasRequestedNextChapter: Boolean = false
/**
* Returns the active page.
*/
fun getActivePage(): Page? {
return pages.getOrNull(currentPage)
}
/**
* Called when a page changes. Implementations must call this method.
*
* @param position the new current page.
*/
fun onPageChanged(position: Int) {
val oldPage = pages[currentPage]
val newPage = pages[position]
val oldChapter = oldPage.chapter
val newChapter = newPage.chapter
// Update page indicator and seekbar
readerActivity.onPageChanged(newPage)
// Active chapter has changed.
if (oldChapter.id != newChapter.id) {
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
&& readerActivity.presenter.hasNextChapter() && !hasRequestedNextChapter) {
hasRequestedNextChapter = true
readerActivity.presenter.appendNextChapter()
}
currentPage = position
}
/**
* Sets the active page.
*
* @param page the page to display.
*/
fun setActivePage(page: Page) {
setActivePage(getPageIndex(page))
}
/**
* Searchs for the index of a page in the current list without requiring them to be the same
* object.
*
* @param search the page to search.
* @return the index of the page in [pages] or 0 if it's not found.
*/
fun getPageIndex(search: Page): Int {
for ((index, page) in pages.withIndex()) {
if (page.index == search.index && page.chapter.id == search.chapter.id) {
return index
}
}
return 0
}
/**
* Called from the presenter when the page list of a chapter is ready. This method is called
* on every [onResume], so we add some logic to avoid duplicating chapters.
*
* @param chapter the chapter to set.
* @param currentPage the initial page to display.
*/
fun onPageListReady(chapter: ReaderChapter, currentPage: Page) {
if (!chapters.contains(chapter)) {
// if we reset the loaded page we also need to reset the loaded chapters
chapters.clear()
chapters.add(chapter)
pages = ArrayList(chapter.pages)
onChapterSet(chapter, currentPage)
} else {
setActivePage(currentPage)
}
}
/**
* Called from the presenter when the page list of a chapter to append is ready. This method is
* called on every [onResume], so we add some logic to avoid duplicating chapters.
*
* @param chapter the chapter to append.
*/
fun onPageListAppendReady(chapter: ReaderChapter) {
if (!chapters.contains(chapter)) {
hasRequestedNextChapter = false
chapters.add(chapter)
pages.addAll(chapter.pages!!)
onChapterAppended(chapter)
}
}
/**
* Sets the active page.
*
* @param pageNumber the index of the page from [pages].
*/
abstract fun setActivePage(pageNumber: Int)
/**
* Called when a new chapter is set in [BaseReader].
*
* @param chapter the chapter set.
* @param currentPage the initial page to display.
*/
abstract fun onChapterSet(chapter: ReaderChapter, currentPage: Page)
/**
* Called when a chapter is appended in [BaseReader].
*
* @param chapter the chapter appended.
*/
abstract fun onChapterAppended(chapter: ReaderChapter)
/**
* Moves pages to right. Implementations decide how to move (by a page, by some distance...).
*/
abstract fun moveRight()
/**
* Moves pages to left. Implementations decide how to move (by a page, by some distance...).
*/
abstract fun moveLeft()
/**
* Moves pages down. Implementations decide how to move (by a page, by some distance...).
*/
open fun moveDown() {
moveRight()
}
/**
* Moves pages up. Implementations decide how to move (by a page, by some distance...).
*/
open fun moveUp() {
moveLeft()
}
/**
* Method the implementations can call to show a menu with options for the given page.
*/
fun onLongClick(page: Page?): Boolean {
if (isAdded && page != null) {
readerActivity.onLongClick(page)
}
return true
}
/**
* Sets the active decoder class.
*
* @param value the decoder class to use.
*/
fun setDecoderClass(value: Int) {
when (value) {
IMAGE_DECODER -> {
bitmapDecoderClass = IImageDecoder::class.java
regionDecoderClass = IImageRegionDecoder::class.java
}
RAPID_DECODER -> {
bitmapDecoderClass = RapidImageDecoder::class.java
regionDecoderClass = RapidImageRegionDecoder::class.java
}
SKIA_DECODER -> {
bitmapDecoderClass = SkiaImageDecoder::class.java
regionDecoderClass = SkiaImageRegionDecoder::class.java
}
}
}
/**
* Property to get the reader activity.
*/
val readerActivity: ReaderActivity
get() = activity as ReaderActivity
}

View File

@ -1,40 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.base
import android.net.Uri
import android.support.v4.content.ContextCompat
import android.view.View
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import kotlinx.android.synthetic.main.reader_page_decode_error.view.*
class PageDecodeErrorLayout(
val view: View,
val page: Page,
val theme: Int,
val retryListener: () -> Unit
) {
init {
val textColor = if (theme == ReaderActivity.BLACK_THEME)
ContextCompat.getColor(view.context, R.color.textColorSecondaryDark)
else
ContextCompat.getColor(view.context, R.color.textColorSecondaryLight)
view.decode_error_text.setTextColor(textColor)
view.decode_retry.setOnClickListener {
retryListener()
}
view.decode_open_browser.setOnClickListener {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, Uri.parse(page.imageUrl))
view.context.startActivity(intent)
}
if (page.imageUrl == null) {
view.decode_open_browser.visibility = View.GONE
}
}
}

View File

@ -1,6 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
interface OnChapterBoundariesOutListener {
fun onFirstPageOutEvent()
fun onLastPageOutEvent()
}

View File

@ -1,328 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.content.Context
import android.graphics.PointF
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.source.model.Page
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.viewer.base.PageDecodeErrorLayout
import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.RightToLeftReader
import eu.kanade.tachiyomi.ui.reader.viewer.pager.vertical.VerticalReader
import eu.kanade.tachiyomi.util.gone
import eu.kanade.tachiyomi.util.inflate
import eu.kanade.tachiyomi.util.visible
import kotlinx.android.synthetic.main.reader_pager_item.view.*
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.subjects.PublishSubject
import rx.subjects.SerializedSubject
import java.net.URLConnection
import java.util.concurrent.TimeUnit
class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null)
: FrameLayout(context, attrs) {
/**
* Page of a chapter.
*/
lateinit var page: Page
/**
* Subscription for status changes of the page.
*/
private var statusSubscription: Subscription? = null
/**
* Subscription for progress changes of the page.
*/
private var progressSubscription: Subscription? = null
/**
* Layout of decode error.
*/
private var decodeErrorLayout: View? = null
fun initialize(reader: PagerReader, page: Page) {
val activity = reader.activity as ReaderActivity
when (activity.readerTheme) {
ReaderActivity.BLACK_THEME -> progress_text.setTextColor(reader.whiteColor)
ReaderActivity.WHITE_THEME -> progress_text.setTextColor(reader.blackColor)
}
if (reader is RightToLeftReader) {
rotation = -180f
}
// --> EH
with(gif_view) {
setOnTouchListener { _, motionEvent -> reader.gestureDetector.onTouchEvent(motionEvent) }
setOnLongClickListener { reader.onLongClick(page) }
settings.loadWithOverviewMode = true
settings.useWideViewPort = true
settings.builtInZoomControls = true
settings.displayZoomControls = false
settings.setSupportZoom(true)
gone()
}
// <-- EH
with(image_view) {
setMaxTileSize((reader.activity as ReaderActivity).maxBitmapSize)
setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_FIXED)
setDoubleTapZoomDuration(reader.doubleTapAnimDuration.toInt())
setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
setMinimumScaleType(reader.scaleType)
setMinimumDpi(90)
setMinimumTileDpi(180)
setRegionDecoderClass(reader.regionDecoderClass)
setBitmapDecoderClass(reader.bitmapDecoderClass)
setVerticalScrollingParent(reader is VerticalReader)
setCropBorders(reader.cropBorders)
setOnTouchListener { _, motionEvent -> reader.gestureDetector.onTouchEvent(motionEvent) }
setOnLongClickListener { reader.onLongClick(page) }
setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() {
override fun onReady() {
onImageDecoded(reader)
}
override fun onImageLoadError(e: Exception) {
onImageDecodeError(reader)
}
})
// --> EH
visible()
// <-- EH
}
retry_button.setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_UP) {
activity.presenter.retryPage(page)
}
true
}
this.page = page
observeStatus()
}
override fun onDetachedFromWindow() {
unsubscribeProgress()
unsubscribeStatus()
image_view.setOnTouchListener(null)
image_view.setOnImageEventListener(null)
// --> EH
gif_view.setOnTouchListener(null)
// <-- EH
super.onDetachedFromWindow()
}
/**
* Observes the status of the page and notify the changes.
*
* @see processStatus
*/
private fun observeStatus() {
statusSubscription?.unsubscribe()
val statusSubject = SerializedSubject(PublishSubject.create<Int>())
page.setStatusSubject(statusSubject)
statusSubscription = statusSubject.startWith(page.status)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { processStatus(it) }
}
/**
* Observes the progress of the page and updates view.
*/
private fun observeProgress() {
progressSubscription?.unsubscribe()
progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS)
.map { page.progress }
.distinctUntilChanged()
.onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { progress ->
progress_text.text = if (progress > 0) {
context.getString(R.string.download_progress, progress)
} else {
context.getString(R.string.downloading)
}
}
}
/**
* Called when the status of the page changes.
*
* @param status the new status of the page.
*/
private fun processStatus(status: Int) {
when (status) {
Page.QUEUE -> setQueued()
Page.LOAD_PAGE -> setLoading()
Page.DOWNLOAD_IMAGE -> {
observeProgress()
setDownloading()
}
Page.READY -> {
setImage()
unsubscribeProgress()
}
Page.ERROR -> {
setError()
unsubscribeProgress()
}
}
}
/**
* Unsubscribes from the status subscription.
*/
private fun unsubscribeStatus() {
page.setStatusSubject(null)
statusSubscription?.unsubscribe()
statusSubscription = null
}
/**
* Unsubscribes from the progress subscription.
*/
private fun unsubscribeProgress() {
progressSubscription?.unsubscribe()
progressSubscription = null
}
/**
* Called when the page is queued.
*/
private fun setQueued() {
progress_container.visibility = View.VISIBLE
progress_text.visibility = View.INVISIBLE
retry_button.visibility = View.GONE
// --> EH
gif_view.gone()
// <-- EH
decodeErrorLayout?.let {
removeView(it)
decodeErrorLayout = null
}
}
/**
* Called when the page is loading.
*/
private fun setLoading() {
progress_container.visibility = View.VISIBLE
progress_text.visibility = View.VISIBLE
progress_text.setText(R.string.downloading)
// --> EH
gif_view.gone()
// <-- EH
}
/**
* Called when the page is downloading.
*/
private fun setDownloading() {
progress_container.visibility = View.VISIBLE
progress_text.visibility = View.VISIBLE
// --> EH
gif_view.gone()
// <-- EH
}
/**
* Called when the page is ready.
*/
private fun setImage() {
val uri = page.uri
if (uri == null) {
page.status = Page.ERROR
return
}
val file = UniFile.fromUri(context, uri)
if (!file.exists()) {
page.status = Page.ERROR
return
}
// --> EH
val guessedType = file.openInputStream().buffered().use {
URLConnection.guessContentTypeFromStream(it)
}
// <-- EH
progress_text.visibility = View.INVISIBLE
// --> EH
if(guessedType == "image/gif") {
gif_view.loadUrl(uri.toString())
gif_view.visible()
progress_container.gone()
image_view.gone()
} else {
// <-- EH
image_view.setImage(ImageSource.uri(file.uri))
// --> EH
gif_view.gone()
}
// <-- EH
}
/**
* Called when the page has an error.
*/
private fun setError() {
progress_container.visibility = View.GONE
retry_button.visibility = View.VISIBLE
// --> EH
gif_view.gone()
// <-- EH
}
/**
* Called when the image is decoded and going to be displayed.
*/
private fun onImageDecoded(reader: PagerReader) {
progress_container.visibility = View.GONE
with(image_view) {
when (reader.zoomType) {
PagerReader.ALIGN_LEFT -> setScaleAndCenter(scale, PointF(0f, 0f))
PagerReader.ALIGN_RIGHT -> setScaleAndCenter(scale, PointF(sWidth.toFloat(), 0f))
PagerReader.ALIGN_CENTER -> setScaleAndCenter(scale, center.apply { y = 0f })
}
}
}
/**
* Called when an image fails to decode.
*/
private fun onImageDecodeError(reader: PagerReader) {
progress_container.visibility = View.GONE
if (decodeErrorLayout != null || !reader.isAdded) return
val activity = reader.activity as ReaderActivity
val layout = inflate(R.layout.reader_page_decode_error)
PageDecodeErrorLayout(layout, page, activity.readerTheme, {
if (reader.isAdded) {
activity.presenter.retryPage(page)
}
})
decodeErrorLayout = layout
addView(layout)
}
}

View File

@ -1,28 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager;
import android.support.v4.view.PagerAdapter;
import android.view.ViewGroup;
import rx.functions.Action1;
public interface Pager {
void setId(int id);
void setLayoutParams(ViewGroup.LayoutParams layoutParams);
void setOffscreenPageLimit(int limit);
int getCurrentItem();
void setCurrentItem(int item, boolean smoothScroll);
int getWidth();
int getHeight();
PagerAdapter getAdapter();
void setAdapter(PagerAdapter adapter);
void setOnChapterBoundariesOutListener(OnChapterBoundariesOutListener listener);
void setOnPageChangeListener(Action1<Integer> onPageChanged);
void clearOnPageChangeListeners();
}

View File

@ -0,0 +1,108 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.content.Context
import android.support.v4.view.DirectionalViewPager
import android.view.HapticFeedbackConstants
import android.view.KeyEvent
import android.view.MotionEvent
import eu.kanade.tachiyomi.ui.reader.viewer.GestureDetectorWithLongTap
/**
* Pager implementation that listens for tap and long tap and allows temporarily disabling touch
* events in order to work with child views that need to disable touch events on this parent. The
* pager can also be declared to be vertical by creating it with [isHorizontal] to false.
*/
open class Pager(
context: Context,
isHorizontal: Boolean = true
) : DirectionalViewPager(context, isHorizontal) {
/**
* Tap listener function to execute when a tap is detected.
*/
var tapListener: ((MotionEvent) -> Unit)? = null
/**
* Long tap listener function to execute when a long tap is detected.
*/
var longTapListener: ((MotionEvent) -> Boolean)? = null
/**
* Gesture listener that implements tap and long tap events.
*/
private val gestureListener = object : GestureDetectorWithLongTap.Listener() {
override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
tapListener?.invoke(ev)
return true
}
override fun onLongTapConfirmed(ev: MotionEvent) {
val listener = longTapListener
if (listener != null && listener.invoke(ev)) {
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}
}
}
/**
* Gesture detector which handles motion events.
*/
private val gestureDetector = GestureDetectorWithLongTap(context, gestureListener)
/**
* Whether the gesture detector is currently enabled.
*/
private var isGestureDetectorEnabled = true
/**
* Dispatches a touch event.
*/
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
val handled = super.dispatchTouchEvent(ev)
if (isGestureDetectorEnabled) {
gestureDetector.onTouchEvent(ev)
}
return handled
}
/**
* Whether the given [ev] should be intercepted. Only used to prevent crashes when child
* views manipulate [requestDisallowInterceptTouchEvent].
*/
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
return try {
super.onInterceptTouchEvent(ev)
} catch (e: IllegalArgumentException) {
false
}
}
/**
* Handles a touch event. Only used to prevent crashes when child views manipulate
* [requestDisallowInterceptTouchEvent].
*/
override fun onTouchEvent(ev: MotionEvent): Boolean {
return try {
super.onTouchEvent(ev)
} catch (e: IllegalArgumentException) {
false
}
}
/**
* Executes the given key event when this pager has focus. Just do nothing because the reader
* already dispatches key events to the viewer and has more control than this method.
*/
override fun executeKeyEvent(event: KeyEvent): Boolean {
// Disable viewpager's default key event handling
return false
}
/**
* Enables or disables the gesture detector.
*/
fun setGestureDetectorEnabled(enabled: Boolean) {
isGestureDetectorEnabled = enabled
}
}

View File

@ -0,0 +1,25 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.annotation.SuppressLint
import android.content.Context
import android.support.v7.widget.AppCompatButton
import android.view.MotionEvent
/**
* A button class to be used by child views of the pager viewer. All tap gestures are handled by
* the pager, but this class disables that behavior to allow clickable buttons.
*/
@SuppressLint("ViewConstructor")
class PagerButton(context: Context, viewer: PagerViewer) : AppCompatButton(context) {
init {
setOnTouchListener { _, event ->
viewer.pager.setGestureDetectorEnabled(false)
if (event.actionMasked == MotionEvent.ACTION_UP) {
viewer.pager.setGestureDetectorEnabled(true)
}
false
}
}
}

View File

@ -0,0 +1,113 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import com.f2prateek.rx.preferences.Preference
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.addTo
import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* Configuration used by pager viewers.
*/
class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelper = Injekt.get()) {
private val subscriptions = CompositeSubscription()
var imagePropertyChangedListener: (() -> Unit)? = null
var tappingEnabled = true
private set
var longTapEnabled = true
private set
var volumeKeysEnabled = false
private set
var volumeKeysInverted = false
private set
var usePageTransitions = false
private set
var imageScaleType = 1
private set
var imageZoomType = ZoomType.Left
private set
var imageCropBorders = false
private set
var doubleTapAnimDuration = 500
private set
init {
preferences.readWithTapping()
.register({ tappingEnabled = it })
preferences.readWithLongTap()
.register({ longTapEnabled = it })
preferences.pageTransitions()
.register({ usePageTransitions = it })
preferences.imageScaleType()
.register({ imageScaleType = it }, { imagePropertyChangedListener?.invoke() })
preferences.zoomStart()
.register({ zoomTypeFromPreference(it) }, { imagePropertyChangedListener?.invoke() })
preferences.cropBorders()
.register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() })
preferences.doubleTapAnimSpeed()
.register({ doubleTapAnimDuration = it })
preferences.readWithVolumeKeys()
.register({ volumeKeysEnabled = it })
preferences.readWithVolumeKeysInverted()
.register({ volumeKeysInverted = it })
}
fun unsubscribe() {
subscriptions.unsubscribe()
}
private fun <T> Preference<T>.register(
valueAssignment: (T) -> Unit,
onChanged: (T) -> Unit = {}
) {
asObservable()
.doOnNext(valueAssignment)
.skip(1)
.distinctUntilChanged()
.doOnNext(onChanged)
.subscribe()
.addTo(subscriptions)
}
private fun zoomTypeFromPreference(value: Int) {
imageZoomType = when (value) {
// Auto
1 -> when (viewer) {
is L2RPagerViewer -> ZoomType.Left
is R2LPagerViewer -> ZoomType.Right
else -> ZoomType.Center
}
// Left
2 -> ZoomType.Left
// Right
3 -> ZoomType.Right
// Center
else -> ZoomType.Center
}
}
enum class ZoomType {
Left, Center, Right
}
}

View File

@ -0,0 +1,467 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.PointF
import android.graphics.drawable.Drawable
import android.net.Uri
import android.view.GestureDetector
import android.view.Gravity
import android.view.MotionEvent
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.bumptech.glide.request.transition.NoTransition
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.github.chrisbanes.photoview.PhotoView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressBar
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerConfig.ZoomType
import eu.kanade.tachiyomi.util.ImageUtil
import eu.kanade.tachiyomi.util.dpToPx
import eu.kanade.tachiyomi.util.gone
import eu.kanade.tachiyomi.util.visible
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import java.io.InputStream
import java.util.concurrent.TimeUnit
/**
* View of the ViewPager that contains a page of a chapter.
*/
@SuppressLint("ViewConstructor")
class PagerPageHolder(
val viewer: PagerViewer,
val page: ReaderPage
) : FrameLayout(viewer.activity), ViewPagerAdapter.PositionableView {
/**
* Item that identifies this view. Needed by the adapter to not recreate views.
*/
override val item
get() = page
/**
* Loading progress bar to indicate the current progress.
*/
private val progressBar = createProgressBar()
/**
* Image view that supports subsampling on zoom.
*/
private var subsamplingImageView: SubsamplingScaleImageView? = null
/**
* Simple image view only used on GIFs.
*/
private var imageView: ImageView? = null
/**
* Retry button used to allow retrying.
*/
private var retryButton: PagerButton? = null
/**
* Error layout to show when the image fails to decode.
*/
private var decodeErrorLayout: ViewGroup? = null
/**
* Subscription for status changes of the page.
*/
private var statusSubscription: Subscription? = null
/**
* Subscription for progress changes of the page.
*/
private var progressSubscription: Subscription? = null
/**
* Subscription used to read the header of the image. This is needed in order to instantiate
* the appropiate image view depending if the image is animated (GIF).
*/
private var readImageHeaderSubscription: Subscription? = null
init {
addView(progressBar)
observeStatus()
}
/**
* Called when this view is detached from the window. Unsubscribes any active subscription.
*/
@SuppressLint("ClickableViewAccessibility")
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
unsubscribeProgress()
unsubscribeStatus()
unsubscribeReadImageHeader()
subsamplingImageView?.setOnImageEventListener(null)
}
/**
* Observes the status of the page and notify the changes.
*
* @see processStatus
*/
private fun observeStatus() {
statusSubscription?.unsubscribe()
val loader = page.chapter.pageLoader ?: return
statusSubscription = loader.getPage(page)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { processStatus(it) }
}
/**
* Observes the progress of the page and updates view.
*/
private fun observeProgress() {
progressSubscription?.unsubscribe()
progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS)
.map { page.progress }
.distinctUntilChanged()
.onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { value -> progressBar.setProgress(value) }
}
/**
* Called when the status of the page changes.
*
* @param status the new status of the page.
*/
private fun processStatus(status: Int) {
when (status) {
Page.QUEUE -> setQueued()
Page.LOAD_PAGE -> setLoading()
Page.DOWNLOAD_IMAGE -> {
observeProgress()
setDownloading()
}
Page.READY -> {
setImage()
unsubscribeProgress()
}
Page.ERROR -> {
setError()
unsubscribeProgress()
}
}
}
/**
* Unsubscribes from the status subscription.
*/
private fun unsubscribeStatus() {
statusSubscription?.unsubscribe()
statusSubscription = null
}
/**
* Unsubscribes from the progress subscription.
*/
private fun unsubscribeProgress() {
progressSubscription?.unsubscribe()
progressSubscription = null
}
/**
* Unsubscribes from the read image header subscription.
*/
private fun unsubscribeReadImageHeader() {
readImageHeaderSubscription?.unsubscribe()
readImageHeaderSubscription = null
}
/**
* Called when the page is queued.
*/
private fun setQueued() {
progressBar.visible()
retryButton?.gone()
decodeErrorLayout?.gone()
}
/**
* Called when the page is loading.
*/
private fun setLoading() {
progressBar.visible()
retryButton?.gone()
decodeErrorLayout?.gone()
}
/**
* Called when the page is downloading.
*/
private fun setDownloading() {
progressBar.visible()
retryButton?.gone()
decodeErrorLayout?.gone()
}
/**
* Called when the page is ready.
*/
private fun setImage() {
progressBar.visible()
progressBar.completeAndFadeOut()
retryButton?.gone()
decodeErrorLayout?.gone()
unsubscribeReadImageHeader()
val streamFn = page.stream ?: return
var openStream: InputStream? = null
readImageHeaderSubscription = Observable
.fromCallable {
val stream = streamFn().buffered(16)
openStream = stream
ImageUtil.findImageType(stream) == ImageUtil.ImageType.GIF
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { isAnimated ->
if (!isAnimated) {
initSubsamplingImageView().setImage(ImageSource.inputStream(openStream!!))
} else {
initImageView().setImage(openStream!!)
}
}
// Keep the Rx stream alive to close the input stream only when unsubscribed
.flatMap { Observable.never<Unit>() }
.doOnUnsubscribe { openStream?.close() }
.subscribe({}, {})
}
/**
* Called when the page has an error.
*/
private fun setError() {
progressBar.gone()
initRetryButton().visible()
}
/**
* Called when the image is decoded and going to be displayed.
*/
private fun onImageDecoded() {
progressBar.gone()
}
/**
* Called when an image fails to decode.
*/
private fun onImageDecodeError() {
progressBar.gone()
initDecodeErrorLayout().visible()
}
/**
* Creates a new progress bar.
*/
@SuppressLint("PrivateResource")
private fun createProgressBar(): ReaderProgressBar {
return ReaderProgressBar(context, null).apply {
val size = 48.dpToPx
layoutParams = FrameLayout.LayoutParams(size, size).apply {
gravity = Gravity.CENTER
}
}
}
/**
* Initializes a subsampling scale view.
*/
private fun initSubsamplingImageView(): SubsamplingScaleImageView {
if (subsamplingImageView != null) return subsamplingImageView!!
val config = viewer.config
subsamplingImageView = SubsamplingScaleImageView(context).apply {
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
setMaxTileSize(viewer.activity.maxBitmapSize)
setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_FIXED)
setDoubleTapZoomDuration(config.doubleTapAnimDuration)
setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
setMinimumScaleType(config.imageScaleType)
setMinimumDpi(90)
setMinimumTileDpi(180)
setCropBorders(config.imageCropBorders)
setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() {
override fun onReady() {
when (config.imageZoomType) {
ZoomType.Left -> setScaleAndCenter(scale, PointF(0f, 0f))
ZoomType.Right -> setScaleAndCenter(scale, PointF(sWidth.toFloat(), 0f))
ZoomType.Center -> setScaleAndCenter(scale, center.also { it?.y = 0f })
}
onImageDecoded()
}
override fun onImageLoadError(e: Exception) {
onImageDecodeError()
}
})
}
addView(subsamplingImageView)
return subsamplingImageView!!
}
/**
* Initializes an image view, used for GIFs.
*/
private fun initImageView(): ImageView {
if (imageView != null) return imageView!!
imageView = PhotoView(context, null).apply {
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
adjustViewBounds = true
setZoomTransitionDuration(viewer.config.doubleTapAnimDuration)
setScaleLevels(1f, 2f, 3f)
// Force 2 scale levels on double tap
setOnDoubleTapListener(object : GestureDetector.SimpleOnGestureListener() {
override fun onDoubleTap(e: MotionEvent): Boolean {
if (scale > 1f) {
setScale(1f, e.x, e.y, true)
} else {
setScale(2f, e.x, e.y, true)
}
return true
}
})
}
addView(imageView)
return imageView!!
}
/**
* Initializes a button to retry pages.
*/
private fun initRetryButton(): PagerButton {
if (retryButton != null) return retryButton!!
retryButton = PagerButton(context, viewer).apply {
layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
gravity = Gravity.CENTER
}
setText(R.string.action_retry)
setOnClickListener {
page.chapter.pageLoader?.retryPage(page)
}
}
addView(retryButton)
return retryButton!!
}
/**
* Initializes a decode error layout.
*/
private fun initDecodeErrorLayout(): ViewGroup {
if (decodeErrorLayout != null) return decodeErrorLayout!!
val margins = 8.dpToPx
val decodeLayout = LinearLayout(context).apply {
gravity = Gravity.CENTER
orientation = LinearLayout.VERTICAL
}
decodeErrorLayout = decodeLayout
TextView(context).apply {
layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
setMargins(margins, margins, margins, margins)
}
gravity = Gravity.CENTER
setText(R.string.decode_image_error)
decodeLayout.addView(this)
}
PagerButton(context, viewer).apply {
layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
setMargins(margins, margins, margins, margins)
}
setText(R.string.action_retry)
setOnClickListener {
page.chapter.pageLoader?.retryPage(page)
}
decodeLayout.addView(this)
}
val imageUrl = page.imageUrl
if (imageUrl.orEmpty().startsWith("http")) {
PagerButton(context, viewer).apply {
layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
setMargins(margins, margins, margins, margins)
}
setText(R.string.action_open_in_browser)
setOnClickListener {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(imageUrl))
context.startActivity(intent)
}
decodeLayout.addView(this)
}
}
addView(decodeLayout)
return decodeLayout
}
/**
* Extension method to set a [stream] into this ImageView.
*/
private fun ImageView.setImage(stream: InputStream) {
GlideApp.with(this)
.load(stream)
.skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transition(DrawableTransitionOptions.with(NoTransition.getFactory()))
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean
): Boolean {
onImageDecodeError()
return false
}
override fun onResourceReady(
resource: Drawable?,
model: Any?,
target: Target<Drawable>?,
dataSource: DataSource?,
isFirstResource: Boolean
): Boolean {
onImageDecoded()
return false
}
})
.into(this)
}
}

View File

@ -1,326 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.support.v4.content.ContextCompat
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.viewer.base.BaseReader
import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.LeftToRightReader
import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.RightToLeftReader
import rx.subscriptions.CompositeSubscription
/**
* Implementation of a reader based on a ViewPager.
*/
abstract class PagerReader : BaseReader() {
companion object {
/**
* Zoom automatic alignment.
*/
const val ALIGN_AUTO = 1
/**
* Align to left.
*/
const val ALIGN_LEFT = 2
/**
* Align to right.
*/
const val ALIGN_RIGHT = 3
/**
* Align to right.
*/
const val ALIGN_CENTER = 4
/**
* Left side region of the screen. Used for touch events.
*/
const val LEFT_REGION = 0.33f
/**
* Right side region of the screen. Used for touch events.
*/
const val RIGHT_REGION = 0.66f
}
/**
* Generic interface of a ViewPager.
*/
lateinit var pager: Pager
private set
/**
* Adapter of the pager.
*/
lateinit var adapter: PagerReaderAdapter
private set
/**
* Gesture detector for touch events.
*/
val gestureDetector by lazy { GestureDetector(context, ImageGestureListener()) }
/**
* Subscriptions for reader settings.
*/
var subscriptions: CompositeSubscription? = null
private set
/**
* Whether transitions are enabled or not.
*/
var transitions: Boolean = false
private set
/**
* Whether to crop image borders.
*/
var cropBorders: Boolean = false
private set
/**
* Duration of the double tap animation
*/
var doubleTapAnimDuration = 500
private set
/**
* Scale type (fit width, fit screen, etc).
*/
var scaleType = 1
private set
/**
* Zoom type (start position).
*/
var zoomType = 1
private set
/**
* Text color for black theme.
*/
val whiteColor by lazy { ContextCompat.getColor(context!!, R.color.textColorSecondaryDark) }
/**
* Text color for white theme.
*/
val blackColor by lazy { ContextCompat.getColor(context!!, R.color.textColorSecondaryLight) }
/**
* Initializes the pager.
*
* @param pager the pager to initialize.
*/
protected fun initializePager(pager: Pager) {
adapter = PagerReaderAdapter(this)
this.pager = pager.apply {
setLayoutParams(ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT))
setOffscreenPageLimit(1)
setId(R.id.reader_pager)
setOnChapterBoundariesOutListener(object : OnChapterBoundariesOutListener {
override fun onFirstPageOutEvent() {
readerActivity.requestPreviousChapter()
}
override fun onLastPageOutEvent() {
readerActivity.requestNextChapter()
}
})
setOnPageChangeListener { onPageChanged(it) }
}
pager.adapter = adapter
subscriptions = CompositeSubscription().apply {
val preferences = readerActivity.preferences
add(preferences.imageDecoder()
.asObservable()
.doOnNext { setDecoderClass(it) }
.skip(1)
.distinctUntilChanged()
.subscribe { refreshAdapter() })
add(preferences.zoomStart()
.asObservable()
.doOnNext { setZoomStart(it) }
.skip(1)
.distinctUntilChanged()
.subscribe { refreshAdapter() })
add(preferences.imageScaleType()
.asObservable()
.doOnNext { scaleType = it }
.skip(1)
.distinctUntilChanged()
.subscribe { refreshAdapter() })
add(preferences.pageTransitions()
.asObservable()
.subscribe { transitions = it })
add(preferences.cropBorders()
.asObservable()
.doOnNext { cropBorders = it }
.skip(1)
.distinctUntilChanged()
.subscribe { refreshAdapter() })
add(preferences.doubleTapAnimSpeed()
.asObservable()
.subscribe { doubleTapAnimDuration = it })
}
setPagesOnAdapter()
}
override fun onDestroyView() {
pager.clearOnPageChangeListeners()
subscriptions?.unsubscribe()
super.onDestroyView()
}
/**
* Gesture detector for Subsampling Scale Image View.
*/
inner class ImageGestureListener : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
if (isAdded) {
val positionX = e.x
if (positionX < pager.width * LEFT_REGION) {
if (tappingEnabled) moveLeft()
} else if (positionX > pager.width * RIGHT_REGION) {
if (tappingEnabled) moveRight()
} else {
readerActivity.toggleMenu()
}
}
return true
}
}
/**
* Called when a new chapter is set in [BaseReader].
*
* @param chapter the chapter set.
* @param currentPage the initial page to display.
*/
override fun onChapterSet(chapter: ReaderChapter, currentPage: Page) {
this.currentPage = getPageIndex(currentPage) // we might have a new page object
// Make sure the view is already initialized.
if (view != null) {
setPagesOnAdapter()
}
}
/**
* Called when a chapter is appended in [BaseReader].
*
* @param chapter the chapter appended.
*/
override fun onChapterAppended(chapter: ReaderChapter) {
// Make sure the view is already initialized.
if (view != null) {
adapter.pages = pages
}
}
/**
* Sets the pages on the adapter.
*/
protected fun setPagesOnAdapter() {
if (pages.isNotEmpty()) {
// Prevent a wrong active page when changing chapters with the navigation buttons.
val currPage = currentPage
adapter.pages = pages
currentPage = currPage
if (currentPage == pager.currentItem) {
onPageChanged(currentPage)
} else {
setActivePage(currentPage)
}
}
}
/**
* Sets the active page.
*
* @param pageNumber the index of the page from [pages].
*/
override fun setActivePage(pageNumber: Int) {
pager.setCurrentItem(pageNumber, false)
}
/**
* Refresh the adapter.
*/
private fun refreshAdapter() {
pager.adapter = adapter
pager.setCurrentItem(currentPage, false)
}
/**
* Moves a page to the right.
*/
override fun moveRight() {
moveToNext()
}
/**
* Moves a page to the left.
*/
override fun moveLeft() {
moveToPrevious()
}
/**
* Moves to the next page or requests the next chapter if it's the last one.
*/
protected fun moveToNext() {
if (pager.currentItem != pager.adapter.count - 1) {
pager.setCurrentItem(pager.currentItem + 1, transitions)
} else {
readerActivity.requestNextChapter()
}
}
/**
* Moves to the previous page or requests the previous chapter if it's the first one.
*/
protected fun moveToPrevious() {
if (pager.currentItem != 0) {
pager.setCurrentItem(pager.currentItem - 1, transitions)
} else {
readerActivity.requestPreviousChapter()
}
}
/**
* Sets the zoom start position.
*
* @param zoomStart the value stored in preferences.
*/
private fun setZoomStart(zoomStart: Int) {
if (zoomStart == ALIGN_AUTO) {
if (this is LeftToRightReader)
setZoomStart(ALIGN_LEFT)
else if (this is RightToLeftReader)
setZoomStart(ALIGN_RIGHT)
else
setZoomStart(ALIGN_CENTER)
} else {
zoomType = zoomStart
}
}
}

View File

@ -1,47 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.support.v4.view.PagerAdapter
import android.view.View
import android.view.ViewGroup
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.inflate
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
/**
* Adapter of pages for a ViewPager.
*/
class PagerReaderAdapter(private val reader: PagerReader) : ViewPagerAdapter() {
/**
* Pages stored in the adapter.
*/
var pages: List<Page> = emptyList()
set(value) {
field = value
notifyDataSetChanged()
}
override fun createView(container: ViewGroup, position: Int): View {
val view = container.inflate(R.layout.reader_pager_item) as PageView
view.initialize(reader, pages[position])
return view
}
/**
* Returns the number of pages.
*/
override fun getCount(): Int {
return pages.size
}
override fun getItemPosition(obj: Any): Int {
val view = obj as PageView
return if (view.page in pages) {
PagerAdapter.POSITION_UNCHANGED
} else {
PagerAdapter.POSITION_NONE
}
}
}

View File

@ -0,0 +1,207 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.annotation.SuppressLint
import android.graphics.Typeface
import android.support.v7.widget.AppCompatTextView
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.StyleSpan
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.util.dpToPx
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
/**
* View of the ViewPager that contains a chapter transition.
*/
@SuppressLint("ViewConstructor")
class PagerTransitionHolder(
val viewer: PagerViewer,
val transition: ChapterTransition
) : LinearLayout(viewer.activity), ViewPagerAdapter.PositionableView {
/**
* Item that identifies this view. Needed by the adapter to not recreate views.
*/
override val item: Any
get() = transition
/**
* Subscription for status changes of the transition page.
*/
private var statusSubscription: Subscription? = null
/**
* Text view used to display the text of the current and next/prev chapters.
*/
private var textView = TextView(context).apply {
wrapContent()
}
/**
* View container of the current status of the transition page. Child views will be added
* dynamically.
*/
private var pagesContainer = LinearLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
orientation = VERTICAL
gravity = Gravity.CENTER
}
init {
orientation = VERTICAL
gravity = Gravity.CENTER
val sidePadding = 64.dpToPx
setPadding(sidePadding, 0, sidePadding, 0)
addView(textView)
addView(pagesContainer)
when (transition) {
is ChapterTransition.Prev -> bindPrevChapterTransition()
is ChapterTransition.Next -> bindNextChapterTransition()
}
}
/**
* Called when this view is detached from the window. Unsubscribes any active subscription.
*/
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
statusSubscription?.unsubscribe()
statusSubscription = null
}
/**
* Binds a next chapter transition on this view and subscribes to the load status.
*/
private fun bindNextChapterTransition() {
val nextChapter = transition.to
textView.text = if (nextChapter != null) {
SpannableStringBuilder().apply {
append(context.getString(R.string.transition_finished))
setSpan(StyleSpan(Typeface.BOLD), 0, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
append("\n${transition.from.chapter.name}\n\n")
val currSize = length
append(context.getString(R.string.transition_next))
setSpan(StyleSpan(Typeface.BOLD), currSize, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
append("\n${nextChapter.chapter.name}\n\n")
}
} else {
context.getString(R.string.transition_no_next)
}
if (nextChapter != null) {
observeStatus(nextChapter)
}
}
/**
* Binds a previous chapter transition on this view and subscribes to the page load status.
*/
private fun bindPrevChapterTransition() {
val prevChapter = transition.to
textView.text = if (prevChapter != null) {
SpannableStringBuilder().apply {
append(context.getString(R.string.transition_current))
setSpan(StyleSpan(Typeface.BOLD), 0, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
append("\n${transition.from.chapter.name}\n\n")
val currSize = length
append(context.getString(R.string.transition_previous))
setSpan(StyleSpan(Typeface.BOLD), currSize, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
append("\n${prevChapter.chapter.name}\n\n")
}
} else {
context.getString(R.string.transition_no_previous)
}
if (prevChapter != null) {
observeStatus(prevChapter)
}
}
/**
* Observes the status of the page list of the next/previous chapter. Whenever there's a new
* state, the pages container is cleaned up before setting the new state.
*/
private fun observeStatus(chapter: ReaderChapter) {
statusSubscription?.unsubscribe()
statusSubscription = chapter.stateObserver
.observeOn(AndroidSchedulers.mainThread())
.subscribe { state ->
pagesContainer.removeAllViews()
when (state) {
is ReaderChapter.State.Wait -> {}
is ReaderChapter.State.Loading -> setLoading()
is ReaderChapter.State.Error -> setError(state.error)
is ReaderChapter.State.Loaded -> setLoaded()
}
}
}
/**
* Sets the loading state on the pages container.
*/
private fun setLoading() {
val progress = ProgressBar(context, null, android.R.attr.progressBarStyle)
val textView = AppCompatTextView(context).apply {
wrapContent()
setText(R.string.transition_pages_loading)
}
pagesContainer.addView(progress)
pagesContainer.addView(textView)
}
/**
* Sets the loaded state on the pages container.
*/
private fun setLoaded() {
// No additional view is added
}
/**
* Sets the error state on the pages container.
*/
private fun setError(error: Throwable) {
val textView = AppCompatTextView(context).apply {
wrapContent()
text = context.getString(R.string.transition_pages_error, error.message)
}
val retryBtn = PagerButton(context, viewer).apply {
wrapContent()
setText(R.string.action_retry)
setOnClickListener {
val toChapter = transition.to
if (toChapter != null) {
viewer.activity.requestPreloadChapter(toChapter)
}
}
}
pagesContainer.addView(textView)
pagesContainer.addView(retryBtn)
}
/**
* Extension method to set layout params to wrap content on this view.
*/
private fun View.wrapContent() {
layoutParams = ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
}
}

View File

@ -0,0 +1,316 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.support.v4.view.ViewPager
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup.LayoutParams
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer
import timber.log.Timber
/**
* Implementation of a [BaseViewer] to display pages with a [ViewPager].
*/
@Suppress("LeakingThis")
abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
/**
* View pager used by this viewer. It's abstract to implement L2R, R2L and vertical pagers on
* top of this class.
*/
val pager = createPager()
/**
* Configuration used by the pager, like allow taps, scale mode on images, page transitions...
*/
val config = PagerConfig(this)
/**
* Adapter of the pager.
*/
private val adapter = PagerViewerAdapter(this)
/**
* Currently active item. It can be a chapter page or a chapter transition.
*/
private var currentPage: Any? = null
/**
* Viewer chapters to set when the pager enters idle mode. Otherwise, if the view was settling
* or dragging, there'd be a noticeable and annoying jump.
*/
private var awaitingIdleViewerChapters: ViewerChapters? = null
/**
* Whether the view pager is currently in idle mode. It sets the awaiting chapters if setting
* this field to true.
*/
private var isIdle = true
set(value) {
field = value
if (value) {
awaitingIdleViewerChapters?.let {
setChaptersInternal(it)
awaitingIdleViewerChapters = null
}
}
}
init {
pager.visibility = View.GONE // Don't layout the pager yet
pager.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
pager.offscreenPageLimit = 1
pager.id = R.id.reader_pager
pager.adapter = adapter
pager.addOnPageChangeListener(object : ViewPager.SimpleOnPageChangeListener() {
override fun onPageSelected(position: Int) {
val page = adapter.items.getOrNull(position)
if (page != null && currentPage != page) {
currentPage = page
when (page) {
is ReaderPage -> onPageSelected(page, position)
is ChapterTransition -> onTransitionSelected(page)
}
}
}
override fun onPageScrollStateChanged(state: Int) {
isIdle = state == ViewPager.SCROLL_STATE_IDLE
}
})
pager.tapListener = { event ->
val positionX = event.x
when {
positionX < pager.width * 0.33f -> if (config.tappingEnabled) moveLeft()
positionX > pager.width * 0.66f -> if (config.tappingEnabled) moveRight()
else -> activity.toggleMenu()
}
}
pager.longTapListener = f@ {
if (activity.menuVisible || config.longTapEnabled) {
val item = adapter.items.getOrNull(pager.currentItem)
if (item is ReaderPage) {
activity.onPageLongTap(item)
return@f true
}
}
false
}
config.imagePropertyChangedListener = {
refreshAdapter()
}
}
/**
* Creates a new ViewPager.
*/
abstract fun createPager(): Pager
/**
* Returns the view this viewer uses.
*/
override fun getView(): View {
return pager
}
/**
* Destroys this viewer. Called when leaving the reader or swapping viewers.
*/
override fun destroy() {
super.destroy()
config.unsubscribe()
}
/**
* Called from the ViewPager listener when a [page] is marked as active. It notifies the
* activity of the change and requests the preload of the next chapter if this is the last page.
*/
private fun onPageSelected(page: ReaderPage, position: Int) {
val pages = page.chapter.pages!! // Won't be null because it's the loaded chapter
Timber.d("onPageSelected: ${page.number}/${pages.size}")
activity.onPageSelected(page)
if (page === pages.last()) {
Timber.d("Request preload next chapter because we're at the last page")
val transition = adapter.items.getOrNull(position + 1) as? ChapterTransition.Next
if (transition?.to != null) {
activity.requestPreloadChapter(transition.to)
}
}
}
/**
* Called from the ViewPager listener when a [transition] is marked as active. It request the
* preload of the destination chapter of the transition.
*/
private fun onTransitionSelected(transition: ChapterTransition) {
Timber.d("onTransitionSelected: $transition")
val toChapter = transition.to
if (toChapter != null) {
Timber.d("Request preload destination chapter because we're on the transition")
activity.requestPreloadChapter(toChapter)
} else if (transition is ChapterTransition.Next) {
// No more chapters, show menu because the user is probably going to close the reader
activity.showMenu()
}
}
/**
* Tells this viewer to set the given [chapters] as active. If the pager is currently idle,
* it sets the chapters immediately, otherwise they are saved and set when it becomes idle.
*/
override fun setChapters(chapters: ViewerChapters) {
if (isIdle) {
setChaptersInternal(chapters)
} else {
awaitingIdleViewerChapters = chapters
}
}
/**
* Sets the active [chapters] on this pager.
*/
private fun setChaptersInternal(chapters: ViewerChapters) {
Timber.d("setChaptersInternal")
adapter.setChapters(chapters)
// Layout the pager once a chapter is being set
if (pager.visibility == View.GONE) {
Timber.d("Pager first layout")
val pages = chapters.currChapter.pages ?: return
moveToPage(pages[chapters.currChapter.requestedPage])
pager.visibility = View.VISIBLE
}
}
/**
* Tells this viewer to move to the given [page].
*/
override fun moveToPage(page: ReaderPage) {
Timber.d("moveToPage")
val position = adapter.items.indexOf(page)
if (position != -1) {
pager.setCurrentItem(position, true)
} else {
Timber.d("Page $page not found in adapter")
}
}
/**
* Moves to the next page.
*/
open fun moveToNext() {
moveRight()
}
/**
* Moves to the previous page.
*/
open fun moveToPrevious() {
moveLeft()
}
/**
* Moves to the page at the right.
*/
protected open fun moveRight() {
if (pager.currentItem != adapter.count - 1) {
pager.setCurrentItem(pager.currentItem + 1, config.usePageTransitions)
}
}
/**
* Moves to the page at the left.
*/
protected open fun moveLeft() {
if (pager.currentItem != 0) {
pager.setCurrentItem(pager.currentItem - 1, config.usePageTransitions)
}
}
/**
* Moves to the page at the top (or previous).
*/
protected open fun moveUp() {
moveToPrevious()
}
/**
* Moves to the page at the bottom (or next).
*/
protected open fun moveDown() {
moveToNext()
}
/**
* Resets the adapter in order to recreate all the views. Used when a image configuration is
* changed.
*/
private fun refreshAdapter() {
val currentItem = pager.currentItem
pager.adapter = adapter
pager.setCurrentItem(currentItem, false)
}
/**
* Called from the containing activity when a key [event] is received. It should return true
* if the event was handled, false otherwise.
*/
override fun handleKeyEvent(event: KeyEvent): Boolean {
val isUp = event.action == KeyEvent.ACTION_UP
when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN -> {
if (!config.volumeKeysEnabled || activity.menuVisible) {
return false
} else if (isUp) {
if (!config.volumeKeysInverted) moveDown() else moveUp()
}
}
KeyEvent.KEYCODE_VOLUME_UP -> {
if (!config.volumeKeysEnabled || activity.menuVisible) {
return false
} else if (isUp) {
if (!config.volumeKeysInverted) moveUp() else moveDown()
}
}
KeyEvent.KEYCODE_DPAD_RIGHT -> if (isUp) moveRight()
KeyEvent.KEYCODE_DPAD_LEFT -> if (isUp) moveLeft()
KeyEvent.KEYCODE_DPAD_DOWN -> if (isUp) moveDown()
KeyEvent.KEYCODE_DPAD_UP -> if (isUp) moveUp()
KeyEvent.KEYCODE_PAGE_DOWN -> if (isUp) moveDown()
KeyEvent.KEYCODE_PAGE_UP -> if (isUp) moveUp()
KeyEvent.KEYCODE_MENU -> if (isUp) activity.toggleMenu()
else -> return false
}
return true
}
/**
* Called from the containing activity when a generic motion [event] is received. It should
* return true if the event was handled, false otherwise.
*/
override fun handleGenericMotionEvent(event: MotionEvent): Boolean {
if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
when (event.action) {
MotionEvent.ACTION_SCROLL -> {
if (event.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0.0f) {
moveDown()
} else {
moveUp()
}
return true
}
}
}
return false
}
}

View File

@ -0,0 +1,101 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.support.v4.view.PagerAdapter
import android.view.View
import android.view.ViewGroup
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
import timber.log.Timber
/**
* Pager adapter used by this [viewer] to where [ViewerChapters] updates are posted.
*/
class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
/**
* List of currently set items.
*/
var items: List<Any> = emptyList()
private set
/**
* Updates this adapter with the given [chapters]. It handles setting a few pages of the
* next/previous chapter to allow seamless transitions and inverting the pages if the viewer
* has R2L direction.
*/
fun setChapters(chapters: ViewerChapters) {
val newItems = mutableListOf<Any>()
// Add previous chapter pages and transition.
if (chapters.prevChapter != null) {
// We only need to add the last few pages of the previous chapter, because it'll be
// selected as the current chapter when one of those pages is selected.
val prevPages = chapters.prevChapter.pages
if (prevPages != null) {
newItems.addAll(prevPages.takeLast(2))
}
}
newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
// Add current chapter.
val currPages = chapters.currChapter.pages
if (currPages != null) {
newItems.addAll(currPages)
}
// Add next chapter transition and pages.
newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
if (chapters.nextChapter != null) {
// Add at most two pages, because this chapter will be selected before the user can
// swap more pages.
val nextPages = chapters.nextChapter.pages
if (nextPages != null) {
newItems.addAll(nextPages.take(2))
}
}
if (viewer is R2LPagerViewer) {
newItems.reverse()
}
items = newItems
notifyDataSetChanged()
}
/**
* Returns the amount of items of the adapter.
*/
override fun getCount(): Int {
return items.size
}
/**
* Creates a new view for the item at the given [position].
*/
override fun createView(container: ViewGroup, position: Int): View {
val item = items[position]
return when (item) {
is ReaderPage -> PagerPageHolder(viewer, item)
is ChapterTransition -> PagerTransitionHolder(viewer, item)
else -> throw NotImplementedError("Holder for ${item.javaClass} not implemented")
}
}
/**
* Returns the current position of the given [view] on the adapter.
*/
override fun getItemPosition(view: Any): Int {
if (view is PositionableView) {
val position = items.indexOf(view.item)
if (position != -1) {
return position
} else {
Timber.d("Position for ${view.item} not found")
}
}
return PagerAdapter.POSITION_NONE
}
}

View File

@ -0,0 +1,53 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
/**
* Implementation of a left to right PagerViewer.
*/
class L2RPagerViewer(activity: ReaderActivity) : PagerViewer(activity) {
/**
* Creates a new left to right pager.
*/
override fun createPager(): Pager {
return Pager(activity)
}
}
/**
* Implementation of a right to left PagerViewer.
*/
class R2LPagerViewer(activity: ReaderActivity) : PagerViewer(activity) {
/**
* Creates a new right to left pager.
*/
override fun createPager(): Pager {
return Pager(activity)
}
/**
* Moves to the next page. On a R2L pager the next page is the one at the left.
*/
override fun moveToNext() {
moveLeft()
}
/**
* Moves to the previous page. On a R2L pager the previous page is the one at the right.
*/
override fun moveToPrevious() {
moveRight()
}
}
/**
* Implementation of a vertical (top to bottom) PagerViewer.
*/
class VerticalPagerViewer(activity: ReaderActivity) : PagerViewer(activity) {
/**
* Creates a new vertical pager.
*/
override fun createPager(): Pager {
return Pager(activity, isHorizontal = false)
}
}

View File

@ -1,86 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal
import android.content.Context
import android.support.v4.view.ViewPager
import android.view.MotionEvent
import eu.kanade.tachiyomi.ui.reader.viewer.pager.OnChapterBoundariesOutListener
import eu.kanade.tachiyomi.ui.reader.viewer.pager.Pager
import rx.functions.Action1
/**
* Implementation of a [ViewPager] to add custom behavior on touch events.
*/
class HorizontalPager(context: Context) : ViewPager(context), Pager {
companion object {
const val SWIPE_TOLERANCE = 0.25f
}
private var onChapterBoundariesOutListener: OnChapterBoundariesOutListener? = null
private var startDragX: Float = 0f
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
try {
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_DOWN) {
if (currentItem == 0 || currentItem == adapter!!.count - 1) {
startDragX = ev.x
}
}
return super.onInterceptTouchEvent(ev)
} catch (e: IllegalArgumentException) {
return false
}
}
override fun onTouchEvent(ev: MotionEvent): Boolean {
try {
onChapterBoundariesOutListener?.let { listener ->
if (currentItem == 0) {
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_UP) {
val displacement = ev.x - startDragX
if (ev.x > startDragX && displacement > width * SWIPE_TOLERANCE) {
listener.onFirstPageOutEvent()
return true
}
startDragX = 0f
}
} else if (currentItem == adapter!!.count - 1) {
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_UP) {
val displacement = startDragX - ev.x
if (ev.x < startDragX && displacement > width * SWIPE_TOLERANCE) {
listener.onLastPageOutEvent()
return true
}
startDragX = 0f
}
}
}
return super.onTouchEvent(ev)
} catch (e: IllegalArgumentException) {
return false
}
}
override fun setOnChapterBoundariesOutListener(listener: OnChapterBoundariesOutListener) {
onChapterBoundariesOutListener = listener
}
override fun setOnPageChangeListener(func: Action1<Int>) {
addOnPageChangeListener(object : ViewPager.SimpleOnPageChangeListener() {
override fun onPageSelected(position: Int) {
func.call(position)
}
})
}
}

View File

@ -1,19 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerReader
/**
* Left to Right reader.
*/
class LeftToRightReader : PagerReader() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
return HorizontalPager(activity!!).apply { initializePager(this) }
}
}

View File

@ -1,50 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerReader
/**
* Right to Left reader.
*/
class RightToLeftReader : PagerReader() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
return HorizontalPager(activity!!).apply {
rotation = 180f
initializePager(this)
}
}
/**
* Moves a page to the right.
*/
override fun moveRight() {
moveToPrevious()
}
/**
* Moves a page to the left.
*/
override fun moveLeft() {
moveToNext()
}
/**
* Moves a page down.
*/
override fun moveDown() {
moveToNext()
}
/**
* Moves a page up.
*/
override fun moveUp() {
moveToPrevious()
}
}

View File

@ -1,84 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager.vertical
import android.content.Context
import android.view.MotionEvent
import eu.kanade.tachiyomi.ui.reader.viewer.pager.OnChapterBoundariesOutListener
import eu.kanade.tachiyomi.ui.reader.viewer.pager.Pager
import rx.functions.Action1
/**
* Implementation of a [VerticalViewPagerImpl] to add custom behavior on touch events.
*/
class VerticalPager(context: Context) : VerticalViewPagerImpl(context), Pager {
private var onChapterBoundariesOutListener: OnChapterBoundariesOutListener? = null
private var startDragY: Float = 0.toFloat()
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
try {
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_DOWN) {
if (currentItem == 0 || currentItem == adapter.count - 1) {
startDragY = ev.y
}
}
return super.onInterceptTouchEvent(ev)
} catch (e: IllegalArgumentException) {
return false
}
}
override fun onTouchEvent(ev: MotionEvent): Boolean {
try {
onChapterBoundariesOutListener?.let { listener ->
if (currentItem == 0) {
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_UP) {
val displacement = ev.y - startDragY
if (ev.y > startDragY && displacement > height * SWIPE_TOLERANCE) {
listener.onFirstPageOutEvent()
return true
}
startDragY = 0f
}
} else if (currentItem == adapter.count - 1) {
if (ev.action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_UP) {
val displacement = startDragY - ev.y
if (ev.y < startDragY && displacement > height * SWIPE_TOLERANCE) {
listener.onLastPageOutEvent()
return true
}
startDragY = 0f
}
}
}
return super.onTouchEvent(ev)
} catch (e: IllegalArgumentException) {
return false
}
}
override fun setOnChapterBoundariesOutListener(listener: OnChapterBoundariesOutListener) {
onChapterBoundariesOutListener = listener
}
override fun setOnPageChangeListener(func: Action1<Int>) {
addOnPageChangeListener(object : VerticalViewPagerImpl.SimpleOnPageChangeListener() {
override fun onPageSelected(position: Int) {
func.call(position)
}
})
}
companion object {
private val SWIPE_TOLERANCE = 0.25f
}
}

View File

@ -1,19 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager.vertical
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerReader
/**
* Vertical reader.
*/
class VerticalReader : PagerReader() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
return VerticalPager(activity!!).apply { initializePager(this) }
}
}

Some files were not shown because too many files have changed in this diff Show More