diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index c2b4ab5a7..77c61aeea 100755 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1 +1,13 @@ -**Please read https://github.com/inorichi/tachiyomi/blob/master/.github/CONTRIBUTING.md before posting** +**Please fill out this form and remove the first two lines before posting.** +**If your issue is a request for a catalogue it belongs here https://github.com/inorichi/tachiyomi-extensions/** +**App version:** + +**Issue/Request:** + +**Steps to reproduce (if applicable)** + + 1. + 2. + 3. + +**Other details:** \ No newline at end of file diff --git a/.github/readme-images/app-icon.png b/.github/readme-images/app-icon.png new file mode 100644 index 000000000..bac068b91 Binary files /dev/null and b/.github/readme-images/app-icon.png differ diff --git a/app/build.gradle b/app/build.gradle index 68d477016..cf27c9179 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,14 +31,14 @@ ext { } android { - compileSdkVersion 26 - buildToolsVersion "27.0.1" + compileSdkVersion 27 + buildToolsVersion "27.0.2" publishNonDefault true defaultConfig { applicationId "eu.kanade.tachiyomi.eh2" minSdkVersion 16 - targetSdkVersion 26 + targetSdkVersion 27 testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" versionCode 6802 versionName "v6.8.2-EH" @@ -127,14 +127,14 @@ dependencies { // ReactiveX implementation 'io.reactivex:rxandroid:1.2.1' - implementation 'io.reactivex:rxjava:1.3.4' + implementation 'io.reactivex:rxjava:1.3.6' implementation 'com.jakewharton.rxrelay:rxrelay:1.2.0' implementation 'com.f2prateek.rx.preferences:rx-preferences:1.0.2' implementation 'com.github.pwittchen:reactivenetwork:0.7.0' // Network client implementation "com.squareup.okhttp3:okhttp:3.9.1" - implementation 'com.squareup.okio:okio:1.13.0' + implementation 'com.squareup.okio:okio:1.14.0' // REST final retrofit_version = '2.3.0' @@ -146,9 +146,6 @@ dependencies { implementation 'com.google.code.gson:gson:2.8.2' implementation 'com.github.salomonbrys.kotson:kotson:2.5.0' - // YAML - implementation 'com.github.bmoliveira:snake-yaml:v1.18-android' - // JavaScript engine implementation 'com.squareup.duktape:duktape-android:1.2.0' @@ -160,8 +157,8 @@ dependencies { implementation 'org.jsoup:jsoup:1.10.2' // Job scheduling - implementation 'com.evernote:android-job:1.2.1' - implementation 'com.google.android.gms:play-services-gcm:11.6.2' + implementation 'com.evernote:android-job:1.2.4' + implementation 'com.google.android.gms:play-services-gcm:11.8.0' // Changelog implementation 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0' @@ -175,19 +172,19 @@ dependencies { implementation "info.android15.nucleus:nucleus-support-v7:$nucleus_version" // Dependency injection - implementation "uy.kohesive.injekt:injekt-core:1.16.1" + implementation "com.github.inorichi.injekt:injekt-core:65b0440" // Image library - final glide_version = '4.3.1' + final glide_version = '4.6.1' implementation "com.github.bumptech.glide:glide:$glide_version" implementation "com.github.bumptech.glide:okhttp3-integration:$glide_version" kapt "com.github.bumptech.glide:compiler:$glide_version" // Transformations - implementation 'jp.wasabeef:glide-transformations:3.0.1' + implementation 'jp.wasabeef:glide-transformations:3.1.1' // Logging - implementation 'com.jakewharton.timber:timber:4.6.0' + implementation 'com.jakewharton.timber:timber:4.6.1' // Crash reports implementation 'ch.acra:acra:4.9.2' @@ -202,9 +199,7 @@ dependencies { implementation 'eu.davidea:flexible-adapter-ui:1.0.0-b1' implementation 'com.nononsenseapps:filepicker:2.5.2' implementation 'com.github.amulyakhare:TextDrawable:558677e' - implementation('com.afollestad.material-dialogs:core:0.9.4.7') { - exclude group: "com.android.support", module: "support-v13" - } + implementation 'com.afollestad.material-dialogs:core:0.9.6.0' implementation 'me.zhanghai.android.systemuihelper:library:1.0.0' implementation 'com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.0.4' implementation 'com.github.mthli:Slice:v1.2' @@ -212,7 +207,7 @@ dependencies { // Conductor implementation "com.bluelinelabs:conductor:2.1.4" - implementation 'com.github.inorichi:conductor-support-preference:26.0.2' + implementation 'com.github.inorichi:conductor-support-preference:27.0.2' // RxBindings final rxbindings_version = '1.0.1' @@ -233,7 +228,7 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - final coroutines_version = '0.19.1' + final coroutines_version = '0.22.2' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" @@ -255,7 +250,7 @@ dependencies { } buildscript { - ext.kotlin_version = '1.2.0' + ext.kotlin_version = '1.2.21' repositories { mavenCentral() } @@ -273,6 +268,7 @@ kotlin { coroutines 'enable' } } + androidExtensions { experimental = true } diff --git a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt index 71552f39f..114f231b6 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt @@ -8,34 +8,55 @@ import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.source.SourceManager -import uy.kohesive.injekt.api.InjektModule -import uy.kohesive.injekt.api.InjektRegistrar -import uy.kohesive.injekt.api.addSingletonFactory +import rx.Observable +import rx.schedulers.Schedulers +import uy.kohesive.injekt.api.* class AppModule(val app: Application) : InjektModule { override fun InjektRegistrar.registerInjectables() { - addSingletonFactory { PreferencesHelper(app) } + addSingleton(app) - addSingletonFactory { DatabaseHelper(app) } + addSingletonFactory { PreferencesHelper(app) } - addSingletonFactory { ChapterCache(app) } + addSingletonFactory { DatabaseHelper(app) } - addSingletonFactory { CoverCache(app) } + addSingletonFactory { ChapterCache(app) } - addSingletonFactory { NetworkHelper(app) } + addSingletonFactory { CoverCache(app) } - addSingletonFactory { SourceManager(app) } + addSingletonFactory { NetworkHelper(app) } - addSingletonFactory { DownloadManager(app) } + addSingletonFactory { SourceManager(app).also { get().init(it) } } - addSingletonFactory { TrackManager(app) } + addSingletonFactory { ExtensionManager(app) } - addSingletonFactory { Gson() } + addSingletonFactory { DownloadManager(app) } + + addSingletonFactory { TrackManager(app) } + + addSingletonFactory { Gson() } + + // Asynchronously init expensive components for a faster cold start + + rxAsync { get() } + + rxAsync { get() } + + rxAsync { get() } + + rxAsync { get() } + + rxAsync { get() } } -} \ No newline at end of file + private fun rxAsync(block: () -> Unit) { + Observable.fromCallable { block() }.subscribeOn(Schedulers.computation()).subscribe() + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt index cf3d2535c..0d993a701 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt @@ -4,17 +4,8 @@ import android.app.IntentService import android.content.Context import android.content.Intent import android.net.Uri -import com.github.salomonbrys.kotson.set import com.google.gson.JsonArray -import com.google.gson.JsonObject -import com.hippo.unifile.UniFile -import eu.kanade.tachiyomi.data.backup.models.Backup -import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES -import eu.kanade.tachiyomi.data.backup.models.Backup.MANGAS -import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.util.sendLocalBroadcast -import timber.log.Timber import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID /** @@ -26,8 +17,6 @@ class BackupCreateService : IntentService(NAME) { // Name of class private const val NAME = "BackupCreateService" - // Backup called from job - private const val EXTRA_IS_JOB = "$ID.$NAME.EXTRA_IS_JOB" // Options for backup private const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS" @@ -48,12 +37,10 @@ class BackupCreateService : IntentService(NAME) { * @param context context of application * @param uri path of Uri * @param flags determines what to backup - * @param isJob backup called from job */ - fun makeBackup(context: Context, uri: Uri, flags: Int, isJob: Boolean = false) { + fun makeBackup(context: Context, uri: Uri, flags: Int) { val intent = Intent(context, BackupCreateService::class.java).apply { putExtra(BackupConst.EXTRA_URI, uri) - putExtra(EXTRA_IS_JOB, isJob) putExtra(EXTRA_FLAGS, flags) } context.startService(intent) @@ -68,95 +55,9 @@ class BackupCreateService : IntentService(NAME) { // Get values val uri = intent.getParcelableExtra(BackupConst.EXTRA_URI) - val isJob = intent.getBooleanExtra(EXTRA_IS_JOB, false) val flags = intent.getIntExtra(EXTRA_FLAGS, 0) // Create backup - createBackupFromApp(uri, flags, isJob) + backupManager.createBackup(uri, flags, false) } - /** - * Create backup Json file from database - * - * @param uri path of Uri - * @param isJob backup called from job - */ - private fun createBackupFromApp(uri: Uri, flags: Int, isJob: Boolean) { - // Create root object - val root = JsonObject() - - // Create manga array - val mangaEntries = JsonArray() - - // Create category array - val categoryEntries = JsonArray() - - // Add value's to root - root[VERSION] = Backup.CURRENT_VERSION - root[MANGAS] = mangaEntries - root[CATEGORIES] = categoryEntries - - backupManager.databaseHelper.inTransaction { - // Get manga from database - val mangas = backupManager.getFavoriteManga() - - // Backup library manga and its dependencies - mangas.forEach { manga -> - mangaEntries.add(backupManager.backupMangaObject(manga, flags)) - } - - // Backup categories - if ((flags and BACKUP_CATEGORY_MASK) == BACKUP_CATEGORY) { - backupManager.backupCategories(categoryEntries) - } - } - - try { - // When BackupCreatorJob - if (isJob) { - // Get dir of file and create - var dir = UniFile.fromUri(this, uri) - dir = dir.createDirectory("automatic") - - // Delete older backups - val numberOfBackups = backupManager.numberOfBackups() - val backupRegex = Regex("""tachiyomi_\d+-\d+-\d+_\d+-\d+.json""") - dir.listFiles { _, filename -> backupRegex.matches(filename) } - .orEmpty() - .sortedByDescending { it.name } - .drop(numberOfBackups - 1) - .forEach { it.delete() } - - // Create new file to place backup - val newFile = dir.createFile(Backup.getDefaultFilename()) - ?: throw Exception("Couldn't create backup file") - - newFile.openOutputStream().bufferedWriter().use { - backupManager.parser.toJson(root, it) - } - } else { - val file = UniFile.fromUri(this, uri) - ?: throw Exception("Couldn't create backup file") - file.openOutputStream().bufferedWriter().use { - backupManager.parser.toJson(root, it) - } - - // Show completed dialog - val intent = Intent(BackupConst.INTENT_FILTER).apply { - putExtra(BackupConst.ACTION, BackupConst.ACTION_BACKUP_COMPLETED_DIALOG) - putExtra(BackupConst.EXTRA_URI, file.uri.toString()) - } - sendLocalBroadcast(intent) - } - } catch (e: Exception) { - Timber.e(e) - if (!isJob) { - // Show error dialog - val intent = Intent(BackupConst.INTENT_FILTER).apply { - putExtra(BackupConst.ACTION, BackupConst.ACTION_ERROR_BACKUP_DIALOG) - putExtra(BackupConst.EXTRA_ERROR_MESSAGE, e.message) - } - sendLocalBroadcast(intent) - } - } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt index 49c9aaecf..a1f5aca92 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt @@ -13,9 +13,10 @@ class BackupCreatorJob : Job() { override fun onRunJob(params: Params): Result { val preferences = Injekt.get() + val backupManager = BackupManager(context) val uri = Uri.parse(preferences.backupsDirectory().getOrDefault()) val flags = BackupCreateService.BACKUP_ALL - BackupCreateService.makeBackup(context, uri, flags, true) + backupManager.createBackup(uri, flags, true) return Result.SUCCESS } @@ -38,4 +39,4 @@ class BackupCreatorJob : Job() { JobManager.instance().cancelAllForTag(TAG) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt index 33b37b429..4fcbafc2f 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt @@ -1,8 +1,11 @@ package eu.kanade.tachiyomi.data.backup import android.content.Context +import android.content.Intent +import android.net.Uri import com.github.salomonbrys.kotson.* import com.google.gson.* +import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY_MASK import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER @@ -11,6 +14,7 @@ import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HIST import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY_MASK import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK_MASK +import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS import eu.kanade.tachiyomi.data.backup.models.Backup.CURRENT_VERSION @@ -26,8 +30,10 @@ import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.util.sendLocalBroadcast import eu.kanade.tachiyomi.util.syncChaptersWithSource import rx.Observable +import timber.log.Timber import uy.kohesive.injekt.injectLazy class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { @@ -85,6 +91,92 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { else -> throw Exception("Json version unknown") } + /** + * Create backup Json file from database + * + * @param uri path of Uri + * @param isJob backup called from job + */ + fun createBackup(uri: Uri, flags: Int, isJob: Boolean) { + // Create root object + val root = JsonObject() + + // Create manga array + val mangaEntries = JsonArray() + + // Create category array + val categoryEntries = JsonArray() + + // Add value's to root + root[Backup.VERSION] = Backup.CURRENT_VERSION + root[Backup.MANGAS] = mangaEntries + root[CATEGORIES] = categoryEntries + + databaseHelper.inTransaction { + // Get manga from database + val mangas = getFavoriteManga() + + // Backup library manga and its dependencies + mangas.forEach { manga -> + mangaEntries.add(backupMangaObject(manga, flags)) + } + + // Backup categories + if ((flags and BACKUP_CATEGORY_MASK) == BACKUP_CATEGORY) { + backupCategories(categoryEntries) + } + } + + try { + // When BackupCreatorJob + if (isJob) { + // Get dir of file and create + var dir = UniFile.fromUri(context, uri) + dir = dir.createDirectory("automatic") + + // Delete older backups + val numberOfBackups = numberOfBackups() + val backupRegex = Regex("""tachiyomi_\d+-\d+-\d+_\d+-\d+.json""") + dir.listFiles { _, filename -> backupRegex.matches(filename) } + .orEmpty() + .sortedByDescending { it.name } + .drop(numberOfBackups - 1) + .forEach { it.delete() } + + // Create new file to place backup + val newFile = dir.createFile(Backup.getDefaultFilename()) + ?: throw Exception("Couldn't create backup file") + + newFile.openOutputStream().bufferedWriter().use { + parser.toJson(root, it) + } + } else { + val file = UniFile.fromUri(context, uri) + ?: throw Exception("Couldn't create backup file") + file.openOutputStream().bufferedWriter().use { + parser.toJson(root, it) + } + + // Show completed dialog + val intent = Intent(BackupConst.INTENT_FILTER).apply { + putExtra(BackupConst.ACTION, BackupConst.ACTION_BACKUP_COMPLETED_DIALOG) + putExtra(BackupConst.EXTRA_URI, file.uri.toString()) + } + context.sendLocalBroadcast(intent) + } + } catch (e: Exception) { + Timber.e(e) + if (!isJob) { + // Show error dialog + val intent = Intent(BackupConst.INTENT_FILTER).apply { + putExtra(BackupConst.ACTION, BackupConst.ACTION_ERROR_BACKUP_DIALOG) + putExtra(BackupConst.EXTRA_ERROR_MESSAGE, e.message) + } + context.sendLocalBroadcast(intent) + } + } + } + /** * Backup the categories of library * diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt index a5f43c3db..6f01fa023 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt @@ -14,6 +14,7 @@ object TrackTypeAdapter { private const val REMOTE = "r" private const val TITLE = "t" private const val LAST_READ = "l" + private const val TRACKING_URL = "u" fun build(): TypeAdapter { return typeAdapter { @@ -27,6 +28,8 @@ object TrackTypeAdapter { value(it.remote_id) name(LAST_READ) value(it.last_chapter_read) + name(TRACKING_URL) + value(it.tracking_url) endObject() } @@ -42,6 +45,7 @@ object TrackTypeAdapter { SYNC -> track.sync_id = nextInt() REMOTE -> track.remote_id = nextInt() LAST_READ -> track.last_chapter_read = nextInt() + TRACKING_URL -> track.tracking_url = nextString() } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenHelper.kt index f8fd268cf..5330e1ab5 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenHelper.kt @@ -17,7 +17,7 @@ class DbOpenHelper(context: Context) /** * Version of the database. */ - const val DATABASE_VERSION = 5 + const val DATABASE_VERSION = 6 } override fun onCreate(db: SQLiteDatabase) = with(db) { @@ -54,6 +54,9 @@ class DbOpenHelper(context: Context) if (oldVersion < 5) { db.execSQL(ChapterTable.addScanlator) } + if (oldVersion < 6) { + db.execSQL(TrackTable.addTrackingUrl) + } } override fun onConfigure(db: SQLiteDatabase) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt index 3d66b104c..aaf64f23a 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt @@ -20,6 +20,7 @@ import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_STATUS import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SYNC_ID import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TITLE import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TOTAL_CHAPTERS +import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TRACKING_URL import eu.kanade.tachiyomi.data.database.tables.TrackTable.TABLE class TrackTypeMapping : SQLiteTypeMapping( @@ -40,7 +41,7 @@ class TrackPutResolver : DefaultPutResolver() { .whereArgs(obj.id) .build() - override fun mapToContentValues(obj: Track) = ContentValues(9).apply { + override fun mapToContentValues(obj: Track) = ContentValues(10).apply { put(COL_ID, obj.id) put(COL_MANGA_ID, obj.manga_id) put(COL_SYNC_ID, obj.sync_id) @@ -49,7 +50,9 @@ class TrackPutResolver : DefaultPutResolver() { put(COL_LAST_CHAPTER_READ, obj.last_chapter_read) put(COL_TOTAL_CHAPTERS, obj.total_chapters) put(COL_STATUS, obj.status) + put(COL_TRACKING_URL, obj.tracking_url) put(COL_SCORE, obj.score) + } } @@ -65,6 +68,7 @@ class TrackGetResolver : DefaultGetResolver() { total_chapters = cursor.getInt(cursor.getColumnIndex(COL_TOTAL_CHAPTERS)) status = cursor.getInt(cursor.getColumnIndex(COL_STATUS)) score = cursor.getFloat(cursor.getColumnIndex(COL_SCORE)) + tracking_url = cursor.getString(cursor.getColumnIndex(COL_TRACKING_URL)) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt index 5068f899e..3b883a874 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt @@ -22,6 +22,8 @@ interface Track : Serializable { var status: Int + var tracking_url: String + fun copyPersonalFrom(other: Track) { last_chapter_read = other.last_chapter_read score = other.score @@ -29,7 +31,6 @@ interface Track : Serializable { } companion object { - fun create(serviceId: Int): Track = TrackImpl().apply { sync_id = serviceId } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt index 4ae4723aa..b7c445168 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt @@ -20,6 +20,8 @@ class TrackImpl : Track { override var status: Int = 0 + override var tracking_url: String = "" + override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || javaClass != other.javaClass) return false diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt index 94622cc33..79aba5523 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt @@ -22,6 +22,8 @@ object TrackTable { const val COL_TOTAL_CHAPTERS = "total_chapters" + const val COL_TRACKING_URL = "remote_url" + val createTableQuery: String get() = """CREATE TABLE $TABLE( $COL_ID INTEGER NOT NULL PRIMARY KEY, @@ -33,9 +35,12 @@ object TrackTable { $COL_TOTAL_CHAPTERS INTEGER NOT NULL, $COL_STATUS INTEGER NOT NULL, $COL_SCORE FLOAT NOT NULL, + $COL_TRACKING_URL TEXT NOT NULL, UNIQUE ($COL_MANGA_ID, $COL_SYNC_ID) ON CONFLICT REPLACE, FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID}) ON DELETE CASCADE )""" + val addTrackingUrl: String + get() = "ALTER TABLE $TABLE ADD COLUMN $COL_TRACKING_URL TEXT DEFAULT ''" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt index aeec8e33c..cc896a978 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt @@ -36,7 +36,6 @@ internal class DownloadNotifier(private val context: Context) { * The size of queue on start download. */ var initialQueueSize = 0 - get() = field set(value) { if (value != 0){ isSingleChapter = (value == 1) @@ -44,11 +43,6 @@ internal class DownloadNotifier(private val context: Context) { field = value } - /** - * Simultaneous download setting > 1. - */ - var multipleDownloadThreads = false - /** * Updated when error is thrown */ @@ -91,36 +85,10 @@ internal class DownloadNotifier(private val context: Context) { /** * Called when download progress changes. - * Note: Only accepted when multi download active. - * - * @param queue the queue containing downloads. - */ - fun onProgressChange(queue: DownloadQueue) { - if (multipleDownloadThreads) { - doOnProgressChange(null, queue) - } - } - - /** - * Called when download progress changes. - * Note: Only accepted when single download active. * * @param download download object containing download information. - * @param queue the queue containing downloads. */ - fun onProgressChange(download: Download, queue: DownloadQueue) { - if (!multipleDownloadThreads) { - doOnProgressChange(download, queue) - } - } - - /** - * Show notification progress of chapter. - * - * @param download download object containing download information. - * @param queue the queue containing downloads. - */ - private fun doOnProgressChange(download: Download?, queue: DownloadQueue) { + fun onProgressChange(download: Download) { // Create notification with(notification) { // Check if first call. @@ -133,28 +101,13 @@ internal class DownloadNotifier(private val context: Context) { isDownloading = true } - if (multipleDownloadThreads) { - setContentTitle(context.getString(R.string.app_name)) - - // Reset the queue size if the download progress is negative - if ((initialQueueSize - queue.size) < 0) - initialQueueSize = queue.size - - setContentText(context.getString(R.string.chapter_downloading_progress) - .format(initialQueueSize - queue.size, initialQueueSize)) - setProgress(initialQueueSize, initialQueueSize - queue.size, false) - } else { - download?.let { - val title = it.manga.title.chop(15) - val quotedTitle = Pattern.quote(title) - val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "") - setContentTitle("$title - $chapter".chop(30)) - setContentText(context.getString(R.string.chapter_downloading_progress) - .format(it.downloadedImages, it.pages!!.size)) - setProgress(it.pages!!.size, it.downloadedImages, false) - - } - } + val title = download.manga.title.chop(15) + val quotedTitle = Pattern.quote(title) + val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "") + setContentTitle("$title - $chapter".chop(30)) + setContentText(context.getString(R.string.chapter_downloading_progress) + .format(download.downloadedImages, download.pages!!.size)) + setProgress(download.pages!!.size, download.downloadedImages, false) } // Displays the progress bar on notification notification.show() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt index 88f32553b..a95d252b3 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt @@ -9,8 +9,6 @@ import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.DownloadQueue -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.online.HttpSource @@ -21,7 +19,6 @@ import okhttp3.Response import rx.Observable import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers -import rx.subjects.BehaviorSubject import rx.subscriptions.CompositeSubscription import timber.log.Timber import uy.kohesive.injekt.injectLazy @@ -39,9 +36,11 @@ import uy.kohesive.injekt.injectLazy * @param provider the downloads directory provider. * @param cache the downloads cache, used to add the downloads to the cache after their completion. */ -class Downloader(private val context: Context, - private val provider: DownloadProvider, - private val cache: DownloadCache) { +class Downloader( + private val context: Context, + private val provider: DownloadProvider, + private val cache: DownloadCache +) { /** * Store for persisting downloads across restarts. @@ -58,11 +57,6 @@ class Downloader(private val context: Context, */ private val sourceManager: SourceManager by injectLazy() - /** - * Preferences. - */ - private val preferences: PreferencesHelper by injectLazy() - /** * Notifier for the downloader state and progress. */ @@ -73,11 +67,6 @@ class Downloader(private val context: Context, */ private val subscriptions = CompositeSubscription() - /** - * Subject to do a live update of the number of simultaneous downloads. - */ - private val threadsSubject = BehaviorSubject.create() - /** * Relay to send a list of downloads to the downloader. */ @@ -116,9 +105,6 @@ class Downloader(private val context: Context, val pending = queue.filter { it.status != Download.DOWNLOADED } pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE } - // Show download notification when simultaneous download > 1. - notifier.onProgressChange(queue) - downloadsRelay.call(pending) return !pending.isEmpty() } @@ -185,14 +171,8 @@ class Downloader(private val context: Context, subscriptions.clear() - subscriptions += preferences.downloadThreads().asObservable() - .subscribe { - threadsSubject.onNext(it) - notifier.multipleDownloadThreads = it > 1 - } - - subscriptions += downloadsRelay.flatMap { Observable.from(it) } - .lift(DynamicConcurrentMergeOperator({ downloadChapter(it) }, threadsSubject)) + subscriptions += downloadsRelay.concatMapIterable { it } + .concatMap { downloadChapter(it).subscribeOn(Schedulers.io()) } .onBackpressureBuffer() .observeOn(AndroidSchedulers.mainThread()) .subscribe({ completeDownload(it) @@ -250,15 +230,9 @@ class Downloader(private val context: Context, // Initialize queue size. notifier.initialQueueSize = queue.size - // Initial multi-thread - notifier.multipleDownloadThreads = preferences.downloadThreads().getOrDefault() > 1 - if (isRunning) { // Send the list of downloads to the downloader. downloadsRelay.call(chaptersToQueue) - } else { - // Show initial notification. - notifier.onProgressChange(queue) } // Start downloader if needed @@ -273,7 +247,7 @@ class Downloader(private val context: Context, * * @param download the chapter to be downloaded. */ - private fun downloadChapter(download: Download): Observable { + private fun downloadChapter(download: Download): Observable = Observable.defer { val chapterDirname = provider.getChapterDirName(download.chapter) val mangaDir = provider.getMangaDir(download.manga, download.source) val tmpDir = mangaDir.createDirectory("${chapterDirname}_tmp") @@ -292,7 +266,7 @@ class Downloader(private val context: Context, Observable.just(download.pages!!) } - return pageListObservable + pageListObservable .doOnNext { _ -> // Delete all temporary (unfinished) files tmpDir.listFiles() @@ -307,7 +281,7 @@ class Downloader(private val context: Context, // Start downloading images, consider we can have downloaded images already .concatMap { page -> getOrDownloadImage(page, download, tmpDir) } // Do when page is downloaded. - .doOnNext { notifier.onProgressChange(download, queue) } + .doOnNext { notifier.onProgressChange(download) } .toList() .map { _ -> download } // Do after download completes @@ -318,7 +292,7 @@ class Downloader(private val context: Context, notifier.onError(error.message, download.chapter.name) download } - .subscribeOn(Schedulers.io()) + } /** @@ -448,7 +422,6 @@ class Downloader(private val context: Context, if (download.status == Download.DOWNLOADED) { // remove downloaded chapter from queue queue.remove(download) - notifier.onProgressChange(queue) } if (areAllDownloadsFinished()) { if (notifier.isSingleChapter && !notifier.errorThrown) { @@ -465,4 +438,4 @@ class Downloader(private val context: Context, return queue.none { it.status <= Download.DOWNLOADING } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaModelLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaModelLoader.kt index 682a2b39c..23d98cf47 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaModelLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/glide/MangaModelLoader.kt @@ -79,7 +79,7 @@ class MangaModelLoader : ModelLoader { * @param height the height of the view where the resource will be loaded. */ override fun buildLoadData(manga: Manga, width: Int, height: Int, - options: Options?): ModelLoader.LoadData? { + options: Options): ModelLoader.LoadData? { // Check thumbnail is not null or empty val url = manga.thumbnail_url if (url == null || url.isEmpty()) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/EmptyPreferenceDataStore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/EmptyPreferenceDataStore.kt new file mode 100644 index 000000000..10e83b84e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/EmptyPreferenceDataStore.kt @@ -0,0 +1,48 @@ +package eu.kanade.tachiyomi.data.preference + +import android.support.v7.preference.PreferenceDataStore + +class EmptyPreferenceDataStore : PreferenceDataStore() { + + override fun getBoolean(key: String?, defValue: Boolean): Boolean { + return false + } + + override fun putBoolean(key: String?, value: Boolean) { + } + + override fun getInt(key: String?, defValue: Int): Int { + return 0 + } + + override fun putInt(key: String?, value: Int) { + } + + override fun getLong(key: String?, defValue: Long): Long { + return 0 + } + + override fun putLong(key: String?, value: Long) { + } + + override fun getFloat(key: String?, defValue: Float): Float { + return 0f + } + + override fun putFloat(key: String?, value: Float) { + } + + override fun getString(key: String?, defValue: String?): String? { + return null + } + + override fun putString(key: String?, value: String?) { + } + + override fun getStringSet(key: String?, defValues: Set?): Set? { + return null + } + + override fun putStringSet(key: String?, values: Set?) { + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt index 53f5f40c1..3a6f36d07 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt @@ -67,8 +67,6 @@ object PreferenceKeys { const val downloadsDirectory = "download_directory" - const val downloadThreads = "pref_download_slots_key" - const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key" const val numberOfBackups = "backup_slots" @@ -109,10 +107,14 @@ object PreferenceKeys { const val downloadBadge = "display_download_badge" + @Deprecated("Use the preferences of the source") fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId" + @Deprecated("Use the preferences of the source") fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId" + fun sourceSharedPref(sourceId: Long) = "source_$sourceId" + fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index b6ff3feca..7c8810866 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -124,8 +124,6 @@ class PreferencesHelper(val context: Context) { fun downloadsDirectory() = rxPrefs.getString(Keys.downloadsDirectory, defaultDownloadsDir.toString()) - fun downloadThreads() = rxPrefs.getInteger(Keys.downloadThreads, 1) - fun downloadOnlyOverWifi() = prefs.getBoolean(Keys.downloadOnlyOverWifi, true) fun numberOfBackups() = rxPrefs.getInteger(Keys.numberOfBackups, 1) @@ -170,6 +168,8 @@ class PreferencesHelper(val context: Context) { fun migrateFlags() = rxPrefs.getInteger("migrate_flags", Int.MAX_VALUE) + fun trustedSignatures() = rxPrefs.getStringSet("trusted_signatures", emptySet()) + // --> EH fun enableExhentai() = rxPrefs.getBoolean(Keys.eh_enableExHentai, false) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/SharedPreferencesDataStore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/SharedPreferencesDataStore.kt new file mode 100644 index 000000000..bb07bb0a5 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/SharedPreferencesDataStore.kt @@ -0,0 +1,55 @@ +package eu.kanade.tachiyomi.data.preference + +import android.content.SharedPreferences +import android.support.v7.preference.PreferenceDataStore + +class SharedPreferencesDataStore(private val prefs: SharedPreferences) : PreferenceDataStore() { + + override fun getBoolean(key: String?, defValue: Boolean): Boolean { + return prefs.getBoolean(key, defValue) + } + + override fun putBoolean(key: String?, value: Boolean) { + prefs.edit().putBoolean(key, value).apply() + } + + override fun getInt(key: String?, defValue: Int): Int { + return prefs.getInt(key, defValue) + } + + override fun putInt(key: String?, value: Int) { + prefs.edit().putInt(key, value).apply() + } + + override fun getLong(key: String?, defValue: Long): Long { + return prefs.getLong(key, defValue) + } + + override fun putLong(key: String?, value: Long) { + prefs.edit().putLong(key, value).apply() + } + + override fun getFloat(key: String?, defValue: Float): Float { + return prefs.getFloat(key, defValue) + } + + override fun putFloat(key: String?, value: Float) { + prefs.edit().putFloat(key, value).apply() + } + + override fun getString(key: String?, defValue: String?): String? { + return prefs.getString(key, defValue) + } + + override fun putString(key: String?, value: String?) { + prefs.edit().putString(key, value).apply() + } + + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet { + return prefs.getStringSet(key, defValues) + } + + override fun putStringSet(key: String?, values: MutableSet?) { + prefs.edit().putStringSet(key, values).apply() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt index 1b91e812a..d3d02517d 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.track import android.support.annotation.CallSuper import android.support.annotation.DrawableRes import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.network.NetworkHelper import okhttp3.OkHttpClient @@ -44,7 +45,7 @@ abstract class TrackService(val id: Int) { abstract fun bind(track: Track): Observable - abstract fun search(query: String): Observable> + abstract fun search(query: String): Observable> abstract fun refresh(track: Track): Observable diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt index 25fafc6a5..0b71e3e85 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt @@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.data.track.model.TrackSearch import rx.Completable import rx.Observable @@ -120,7 +121,7 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) { } } - override fun search(query: String): Observable> { + override fun search(query: String): Observable> { return api.search(query) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt index 8f67ddbe8..ee2864352 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt @@ -5,6 +5,7 @@ import com.github.salomonbrys.kotson.int import com.github.salomonbrys.kotson.string import com.google.gson.JsonObject import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.network.POST import okhttp3.FormBody import okhttp3.OkHttpClient @@ -46,7 +47,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { } } - fun search(query: String): Observable> { + fun search(query: String): Observable> { return rest.search(query, 1) .map { list -> list.filter { it.type != "Novel" }.map { it.toTrack() } @@ -140,6 +141,11 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { private const val clientSecret = "nlGB5OmgE9YWq5dr3gIDbTQV0C" private const val clientUrl = "tachiyomi://anilist-auth" private const val baseUrl = "https://anilist.co/api/" + private const val baseMangaUrl = "https://anilist.co/manga/" + + fun mangaUrl(remoteId: Int): String { + return baseMangaUrl + remoteId + } fun authUrl() = Uri.parse("${baseUrl}auth/authorize").buildUpon() .appendQueryParameter("grant_type", "authorization_code") diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt index c4623308f..38d805ace 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt @@ -1,21 +1,45 @@ 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.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.data.track.model.TrackSearch import uy.kohesive.injekt.injectLazy +import java.text.SimpleDateFormat +import java.util.* data class ALManga( val id: Int, val title_romaji: String, + val image_url_lge: String, + val description: String, val type: String, + val publishing_status: String, + val start_date_fuzzy: String, val total_chapters: Int) { - fun toTrack() = Track.create(TrackManager.ANILIST).apply { + fun toTrack() = TrackSearch.create(TrackManager.ANILIST).apply { remote_id = this@ALManga.id title = title_romaji total_chapters = this@ALManga.total_chapters + cover_url = image_url_lge + summary = description + tracking_url = AnilistApi.mangaUrl(remote_id) + publishing_status = this@ALManga.publishing_status + publishing_type = type + if (!start_date_fuzzy.isNullOrBlank()) { + start_date = try { + val inputDf = SimpleDateFormat("yyyyMMdd", Locale.US) + val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) + val date = inputDf.parse(BuildConfig.BUILD_TIME) + outputDf.format(date) + } catch (e: Exception) { + start_date_fuzzy.orEmpty() + } + } + } } @@ -60,11 +84,11 @@ fun Track.toAnilistStatus() = when (status) { private val preferences: PreferencesHelper by injectLazy() fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().getOrDefault()) { - // 10 point +// 10 point 0 -> (score.toInt() / 10).toString() - // 100 point +// 100 point 1 -> score.toInt().toString() - // 5 stars +// 5 stars 2 -> when { score == 0f -> "0" score < 30 -> "1" @@ -73,14 +97,14 @@ fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().getOrD score < 90 -> "4" else -> "5" } - // Smiley +// Smiley 3 -> when { score == 0f -> "0" score <= 30 -> ":(" score <= 60 -> ":|" else -> ":)" } - // 10 point decimal +// 10 point decimal 4 -> (score / 10).toString() else -> throw Exception("Unknown score type") } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt index 5195f5fc0..c7f4f94dd 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt @@ -6,6 +6,7 @@ import com.google.gson.Gson import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.data.track.model.TrackSearch import rx.Completable import rx.Observable import uy.kohesive.injekt.injectLazy @@ -96,7 +97,7 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) { } } - override fun search(query: String): Observable> { + override fun search(query: String): Observable> { return api.search(query) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt index 895eca98d..03226896e 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt @@ -4,6 +4,7 @@ import com.github.salomonbrys.kotson.* import com.google.gson.GsonBuilder import com.google.gson.JsonObject import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.network.POST import okhttp3.FormBody import okhttp3.OkHttpClient @@ -27,25 +28,25 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) return Observable.defer { // @formatter:off val data = jsonObject( - "type" to "libraryEntries", - "attributes" to jsonObject( - "status" to track.toKitsuStatus(), - "progress" to track.last_chapter_read - ), - "relationships" to jsonObject( - "user" to jsonObject( - "data" to jsonObject( - "id" to userId, - "type" to "users" - ) + "type" to "libraryEntries", + "attributes" to jsonObject( + "status" to track.toKitsuStatus(), + "progress" to track.last_chapter_read ), - "media" to jsonObject( - "data" to jsonObject( - "id" to track.remote_id, - "type" to "manga" - ) + "relationships" to jsonObject( + "user" to jsonObject( + "data" to jsonObject( + "id" to userId, + "type" to "users" + ) + ), + "media" to jsonObject( + "data" to jsonObject( + "id" to track.remote_id, + "type" to "manga" + ) + ) ) - ) ) // @formatter:on @@ -61,13 +62,13 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) return Observable.defer { // @formatter:off val data = jsonObject( - "type" to "libraryEntries", - "id" to track.remote_id, - "attributes" to jsonObject( - "status" to track.toKitsuStatus(), - "progress" to track.last_chapter_read, - "ratingTwenty" to track.toKitsuScore() - ) + "type" to "libraryEntries", + "id" to track.remote_id, + "attributes" to jsonObject( + "status" to track.toKitsuStatus(), + "progress" to track.last_chapter_read, + "ratingTwenty" to track.toKitsuScore() + ) ) // @formatter:on @@ -76,7 +77,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) } } - fun search(query: String): Observable> { + fun search(query: String): Observable> { return rest.search(query) .map { json -> val data = json["data"].array @@ -186,6 +187,11 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) private const val clientSecret = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151" private const val baseUrl = "https://kitsu.io/api/edge/" private const val loginUrl = "https://kitsu.io/api/" + private const val baseMangaUrl = "https://kitsu.io/manga/" + + fun mangaUrl(remoteId: Int): String { + return baseMangaUrl + remoteId + } fun refreshTokenRequest(token: String) = POST("${loginUrl}oauth/token", diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt index 6498c995f..be4ca5034 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt @@ -5,24 +5,35 @@ import com.github.salomonbrys.kotson.* import com.google.gson.JsonObject import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.data.track.model.TrackSearch open class KitsuManga(obj: JsonObject) { val id by obj.byInt val canonicalTitle by obj["attributes"].byString val chapterCount = obj["attributes"].obj.get("chapterCount").nullInt - val type = obj["attributes"].obj.get("mangaType").nullString + val type = obj["attributes"].obj.get("mangaType").nullString.orEmpty() + val original by obj["attributes"].obj["posterImage"].byString + val synopsis by obj["attributes"].byString + val startDate = obj["attributes"].obj.get("startDate").nullString.orEmpty() + open val status = obj["attributes"].obj.get("status").nullString.orEmpty() @CallSuper - open fun toTrack() = Track.create(TrackManager.KITSU).apply { + open fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply { remote_id = this@KitsuManga.id title = canonicalTitle total_chapters = chapterCount ?: 0 + cover_url = original + summary = synopsis + tracking_url = KitsuApi.mangaUrl(remote_id) + publishing_status = this@KitsuManga.status + publishing_type = type + start_date = startDate.orEmpty() } } class KitsuLibManga(obj: JsonObject, manga: JsonObject) : KitsuManga(manga) { val remoteId by obj.byInt("id") - val status by obj["attributes"].byString + override val status by obj["attributes"].byString val ratingTwenty = obj["attributes"].obj.get("ratingTwenty").nullString val progress by obj["attributes"].byInt diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt new file mode 100644 index 000000000..0e701730f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt @@ -0,0 +1,62 @@ +package eu.kanade.tachiyomi.data.track.model + +import eu.kanade.tachiyomi.data.database.models.Track + +class TrackSearch : Track { + + override var id: Long? = null + + override var manga_id: Long = 0 + + override var sync_id: Int = 0 + + override var remote_id: Int = 0 + + override lateinit var title: String + + override var last_chapter_read: Int = 0 + + override var total_chapters: Int = 0 + + override var score: Float = 0f + + override var status: Int = 0 + + override lateinit var tracking_url: String + + var cover_url: String = "" + + var summary: String = "" + + var publishing_status: String = "" + + var publishing_type: String = "" + + var start_date: String = "" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + + other as Track + + if (manga_id != other.manga_id) return false + if (sync_id != other.sync_id) return false + return remote_id == other.remote_id + } + + override fun hashCode(): Int { + var result = (manga_id xor manga_id.ushr(32)).toInt() + result = 31 * result + sync_id + result = 31 * result + remote_id + return result + } + companion object { + + fun create(serviceId: Int): TrackSearch = TrackSearch().apply { + sync_id = serviceId + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt index a3a131e5a..e58bafcf7 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.Color import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.TrackService import rx.Completable import rx.Observable @@ -81,7 +82,7 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) { } } - override fun search(query: String): Observable> { + override fun search(query: String): Observable> { return api.search(query, getUsername()) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyanimelistApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyanimelistApi.kt index 85f85807e..392ff220b 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyanimelistApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyanimelistApi.kt @@ -4,6 +4,7 @@ import android.net.Uri import android.util.Xml import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.asObservable @@ -12,6 +13,7 @@ import eu.kanade.tachiyomi.util.selectInt import eu.kanade.tachiyomi.util.selectText import okhttp3.* import org.jsoup.Jsoup +import org.jsoup.parser.Parser import org.xmlpull.v1.XmlSerializer import rx.Observable import java.io.StringWriter @@ -36,7 +38,7 @@ class MyanimelistApi(private val client: OkHttpClient, username: String, passwor } } - fun search(query: String, username: String): Observable> { + fun search(query: String, username: String): Observable> { return if (query.startsWith(PREFIX_MY)) { val realQuery = query.substring(PREFIX_MY.length).toLowerCase().trim() getList(username) @@ -46,34 +48,42 @@ class MyanimelistApi(private val client: OkHttpClient, username: String, passwor } else { client.newCall(GET(getSearchUrl(query), headers)) .asObservable() - .map { Jsoup.parse(it.body()!!.string()) } + .map { Jsoup.parse(Parser.unescapeEntities(it.body()!!.string(), false), "", Parser.xmlParser()) } .flatMap { Observable.from(it.select("entry")) } .filter { it.select("type").text() != "Novel" } .map { - Track.create(TrackManager.MYANIMELIST).apply { + TrackSearch.create(TrackManager.MYANIMELIST).apply { title = it.selectText("title")!! remote_id = it.selectInt("id") total_chapters = it.selectInt("chapters") + summary = it.selectText("synopsis")!! + cover_url = it.selectText("image")!! + tracking_url = MyanimelistApi.mangaUrl(remote_id) + publishing_status = it.selectText("status")!! + publishing_type = it.selectText("type")!! + start_date = it.selectText("start_date")!! } } .toList() } } - fun getList(username: String): Observable> { + fun getList(username: String): Observable> { return client .newCall(GET(getListUrl(username), headers)) .asObservable() - .map { Jsoup.parse(it.body()!!.string()) } + .map { Jsoup.parse(Parser.unescapeEntities(it.body()!!.string(), false), "", Parser.xmlParser()) } .flatMap { Observable.from(it.select("manga")) } .map { - Track.create(TrackManager.MYANIMELIST).apply { + TrackSearch.create(TrackManager.MYANIMELIST).apply { title = it.selectText("series_title")!! remote_id = it.selectInt("series_mangadb_id") last_chapter_read = it.selectInt("my_read_chapters") status = it.selectInt("my_status") score = it.selectInt("my_score").toFloat() total_chapters = it.selectInt("series_chapters") + cover_url = it.selectText("series_image")!! + tracking_url = MyanimelistApi.mangaUrl(remote_id) } } .toList() @@ -176,6 +186,11 @@ class MyanimelistApi(private val client: OkHttpClient, username: String, passwor companion object { const val baseUrl = "https://myanimelist.net" + const val baseMangaUrl = baseUrl + "/manga/" + + fun mangaUrl(remoteId: Int): String { + return baseMangaUrl + remoteId + } private val ENTRY_TAG = "entry" private val CHAPTER_TAG = "chapter" diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt new file mode 100644 index 000000000..b2918a972 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt @@ -0,0 +1,330 @@ +package eu.kanade.tachiyomi.extension + +import android.content.Context +import com.jakewharton.rxrelay.BehaviorRelay +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi +import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.extension.model.InstallStep +import eu.kanade.tachiyomi.extension.model.LoadResult +import eu.kanade.tachiyomi.extension.util.ExtensionInstallReceiver +import eu.kanade.tachiyomi.extension.util.ExtensionInstaller +import eu.kanade.tachiyomi.extension.util.ExtensionLoader +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.util.launchNow +import kotlinx.coroutines.experimental.async +import rx.Observable +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +/** + * The manager of extensions installed as another apk which extend the available sources. It handles + * the retrieval of remotely available extensions as well as installing, updating and removing them. + * To avoid malicious distribution, every extension must be signed and it will only be loaded if its + * signature is trusted, otherwise the user will be prompted with a warning to trust it before being + * loaded. + * + * @param context The application context. + * @param preferences The application preferences. + */ +class ExtensionManager( + private val context: Context, + private val preferences: PreferencesHelper = Injekt.get() +) { + + /** + * API where all the available extensions can be found. + */ + private val api = ExtensionGithubApi() + + /** + * The installer which installs, updates and uninstalls the extensions. + */ + private val installer by lazy { ExtensionInstaller(context) } + + /** + * Relay used to notify the installed extensions. + */ + private val installedExtensionsRelay = BehaviorRelay.create>() + + /** + * List of the currently installed extensions. + */ + var installedExtensions = emptyList() + private set(value) { + field = value + installedExtensionsRelay.call(value) + } + + /** + * Relay used to notify the available extensions. + */ + private val availableExtensionsRelay = BehaviorRelay.create>() + + /** + * List of the currently available extensions. + */ + var availableExtensions = emptyList() + private set(value) { + field = value + availableExtensionsRelay.call(value) + setUpdateFieldOfInstalledExtensions(value) + } + + /** + * Relay used to notify the untrusted extensions. + */ + private val untrustedExtensionsRelay = BehaviorRelay.create>() + + /** + * List of the currently untrusted extensions. + */ + var untrustedExtensions = emptyList() + private set(value) { + field = value + untrustedExtensionsRelay.call(value) + } + + /** + * The source manager where the sources of the extensions are added. + */ + private lateinit var sourceManager: SourceManager + + /** + * Initializes this manager with the given source manager. + */ + fun init(sourceManager: SourceManager) { + this.sourceManager = sourceManager + initExtensions() + ExtensionInstallReceiver(InstallationListener()).register(context) + } + + /** + * Loads and registers the installed extensions. + */ + private fun initExtensions() { + val extensions = ExtensionLoader.loadExtensions(context) + + installedExtensions = extensions + .filterIsInstance() + .map { it.extension } + installedExtensions + .flatMap { it.sources } + // overwrite is needed until the bundled sources are removed + .forEach { sourceManager.registerSource(it, true) } + + untrustedExtensions = extensions + .filterIsInstance() + .map { it.extension } + } + + /** + * Returns the relay of the installed extensions as an observable. + */ + fun getInstalledExtensionsObservable(): Observable> { + return installedExtensionsRelay.asObservable() + } + + /** + * Returns the relay of the available extensions as an observable. + */ + fun getAvailableExtensionsObservable(): Observable> { + if (!availableExtensionsRelay.hasValue()) { + findAvailableExtensions() + } + return availableExtensionsRelay.asObservable() + } + + /** + * Returns the relay of the untrusted extensions as an observable. + */ + fun getUntrustedExtensionsObservable(): Observable> { + return untrustedExtensionsRelay.asObservable() + } + + /** + * Finds the available extensions in the [api] and updates [availableExtensions]. + */ + fun findAvailableExtensions() { + api.findExtensions() + .onErrorReturn { emptyList() } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { availableExtensions = it } + } + + /** + * Sets the update field of the installed extensions with the given [availableExtensions]. + * + * @param availableExtensions The list of extensions given by the [api]. + */ + private fun setUpdateFieldOfInstalledExtensions(availableExtensions: List) { + val mutInstalledExtensions = installedExtensions.toMutableList() + var changed = false + + for ((index, installedExt) in mutInstalledExtensions.withIndex()) { + val pkgName = installedExt.pkgName + val availableExt = availableExtensions.find { it.pkgName == pkgName } ?: continue + + val hasUpdate = availableExt.versionCode > installedExt.versionCode + if (installedExt.hasUpdate != hasUpdate) { + mutInstalledExtensions[index] = installedExt.copy(hasUpdate = hasUpdate) + changed = true + } + } + if (changed) { + installedExtensions = mutInstalledExtensions + } + } + + /** + * Returns an observable of the installation process for the given extension. It will complete + * once the extension is installed or throws an error. The process will be canceled if + * unsubscribed before its completion. + * + * @param extension The extension to be installed. + */ + fun installExtension(extension: Extension.Available): Observable { + return installer.downloadAndInstall(api.getApkUrl(extension), extension) + } + + /** + * Returns an observable of the installation process for the given extension. It will complete + * once the extension is updated or throws an error. The process will be canceled if + * unsubscribed before its completion. + * + * @param extension The extension to be updated. + */ + fun updateExtension(extension: Extension.Installed): Observable { + val availableExt = availableExtensions.find { it.pkgName == extension.pkgName } + ?: return Observable.empty() + return installExtension(availableExt) + } + + /** + * Uninstalls the extension that matches the given package name. + * + * @param pkgName The package name of the application to uninstall. + */ + fun uninstallExtension(pkgName: String) { + installer.uninstallApk(pkgName) + } + + /** + * Adds the given signature to the list of trusted signatures. It also loads in background the + * extensions that match this signature. + * + * @param signature The signature to whitelist. + */ + fun trustSignature(signature: String) { + val untrustedSignatures = untrustedExtensions.map { it.signatureHash }.toSet() + if (signature !in untrustedSignatures) return + + ExtensionLoader.trustedSignatures += signature + val preference = preferences.trustedSignatures() + preference.set(preference.getOrDefault() + signature) + + val nowTrustedExtensions = untrustedExtensions.filter { it.signatureHash == signature } + untrustedExtensions -= nowTrustedExtensions + + val ctx = context + launchNow { + nowTrustedExtensions + .map { extension -> + async { ExtensionLoader.loadExtensionFromPkgName(ctx, extension.pkgName) } + } + .map { it.await() } + .forEach { result -> + if (result is LoadResult.Success) { + registerNewExtension(result.extension) + } + } + } + } + + /** + * Registers the given extension in this and the source managers. + * + * @param extension The extension to be registered. + */ + private fun registerNewExtension(extension: Extension.Installed) { + installedExtensions += extension + extension.sources.forEach { sourceManager.registerSource(it) } + } + + /** + * Registers the given updated extension in this and the source managers previously removing + * the outdated ones. + * + * @param extension The extension to be registered. + */ + private fun registerUpdatedExtension(extension: Extension.Installed) { + val mutInstalledExtensions = installedExtensions.toMutableList() + val oldExtension = mutInstalledExtensions.find { it.pkgName == extension.pkgName } + if (oldExtension != null) { + mutInstalledExtensions -= oldExtension + extension.sources.forEach { sourceManager.unregisterSource(it) } + } + mutInstalledExtensions += extension + installedExtensions = mutInstalledExtensions + extension.sources.forEach { sourceManager.registerSource(it) } + } + + /** + * Unregisters the extension in this and the source managers given its package name. Note this + * method is called for every uninstalled application in the system. + * + * @param pkgName The package name of the uninstalled application. + */ + private fun unregisterExtension(pkgName: String) { + val installedExtension = installedExtensions.find { it.pkgName == pkgName } + if (installedExtension != null) { + installedExtensions -= installedExtension + installedExtension.sources.forEach { sourceManager.unregisterSource(it) } + } + val untrustedExtension = untrustedExtensions.find { it.pkgName == pkgName } + if (untrustedExtension != null) { + untrustedExtensions -= untrustedExtension + } + } + + /** + * Listener which receives events of the extensions being installed, updated or removed. + */ + private inner class InstallationListener : ExtensionInstallReceiver.Listener { + + override fun onExtensionInstalled(extension: Extension.Installed) { + registerNewExtension(extension.withUpdateCheck()) + installer.onApkInstalled(extension.pkgName) + } + + override fun onExtensionUpdated(extension: Extension.Installed) { + registerUpdatedExtension(extension.withUpdateCheck()) + installer.onApkInstalled(extension.pkgName) + } + + override fun onExtensionUntrusted(extension: Extension.Untrusted) { + untrustedExtensions += extension + installer.onApkInstalled(extension.pkgName) + } + + override fun onPackageUninstalled(pkgName: String) { + unregisterExtension(pkgName) + } + } + + /** + * Extension method to set the update field of an installed extension. + */ + private fun Extension.Installed.withUpdateCheck(): Extension.Installed { + val availableExt = availableExtensions.find { it.pkgName == pkgName } + if (availableExt != null && availableExt.versionCode > versionCode) { + return copy(hasUpdate = true) + } + return this + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt new file mode 100644 index 000000000..f8ef81b89 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt @@ -0,0 +1,55 @@ +package eu.kanade.tachiyomi.extension.api + +import com.github.salomonbrys.kotson.fromJson +import com.github.salomonbrys.kotson.get +import com.github.salomonbrys.kotson.int +import com.github.salomonbrys.kotson.string +import com.google.gson.Gson +import com.google.gson.JsonArray +import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.asObservableSuccess +import okhttp3.Response +import rx.Observable +import uy.kohesive.injekt.injectLazy + +internal class ExtensionGithubApi { + + private val network: NetworkHelper by injectLazy() + + private val client get() = network.client + + private val gson: Gson by injectLazy() + + private val repoUrl = "https://raw.githubusercontent.com/inorichi/tachiyomi-extensions/repo" + + fun findExtensions(): Observable> { + val call = GET("$repoUrl/index.json") + + return client.newCall(call).asObservableSuccess() + .map(::parseResponse) + } + + private fun parseResponse(response: Response): List { + val text = response.body()?.use { it.string() } ?: return emptyList() + + val json = gson.fromJson(text) + + return json.map { element -> + val name = element["name"].string.substringAfter("Tachiyomi: ") + val pkgName = element["pkg"].string + val apkName = element["apk"].string + val versionName = element["version"].string + val versionCode = element["code"].int + val lang = element["lang"].string + val icon = "$repoUrl/icon/${apkName.replace(".apk", ".png")}" + + Extension.Available(name, pkgName, versionName, versionCode, lang, apkName, icon) + } + } + + fun getApkUrl(extension: Extension.Available): String { + return "$repoUrl/apk/${extension.apkName}" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt new file mode 100644 index 000000000..ef4c24568 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt @@ -0,0 +1,36 @@ +package eu.kanade.tachiyomi.extension.model + +import eu.kanade.tachiyomi.source.Source + +sealed class Extension { + + abstract val name: String + abstract val pkgName: String + abstract val versionName: String + abstract val versionCode: Int + abstract val lang: String? + + data class Installed(override val name: String, + override val pkgName: String, + override val versionName: String, + override val versionCode: Int, + val sources: List, + override val lang: String, + val hasUpdate: Boolean = false) : Extension() + + data class Available(override val name: String, + override val pkgName: String, + override val versionName: String, + override val versionCode: Int, + override val lang: String, + val apkName: String, + val iconUrl: String) : Extension() + + data class Untrusted(override val name: String, + override val pkgName: String, + override val versionName: String, + override val versionCode: Int, + val signatureHash: String, + override val lang: String? = null) : Extension() + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/InstallStep.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/model/InstallStep.kt new file mode 100644 index 000000000..43bb5198d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/model/InstallStep.kt @@ -0,0 +1,9 @@ +package eu.kanade.tachiyomi.extension.model + +enum class InstallStep { + Pending, Downloading, Installing, Installed, Error; + + fun isCompleted(): Boolean { + return this == Installed || this == Error + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/LoadResult.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/model/LoadResult.kt new file mode 100644 index 000000000..0cf470fe8 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/model/LoadResult.kt @@ -0,0 +1,10 @@ +package eu.kanade.tachiyomi.extension.model + +sealed class LoadResult { + + class Success(val extension: Extension.Installed) : LoadResult() + class Untrusted(val extension: Extension.Untrusted) : LoadResult() + class Error(val message: String? = null) : LoadResult() { + constructor(exception: Throwable) : this(exception.message) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt new file mode 100644 index 000000000..5067aa936 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallReceiver.kt @@ -0,0 +1,114 @@ +package eu.kanade.tachiyomi.extension.util + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.extension.model.LoadResult +import eu.kanade.tachiyomi.util.launchNow +import kotlinx.coroutines.experimental.async + +/** + * Broadcast receiver that listens for the system's packages installed, updated or removed, and only + * notifies the given [listener] when the package is an extension. + * + * @param listener The listener that should be notified of extension installation events. + */ +internal class ExtensionInstallReceiver(private val listener: Listener) : + BroadcastReceiver() { + + /** + * Registers this broadcast receiver + */ + fun register(context: Context) { + context.registerReceiver(this, filter) + } + + /** + * Returns the intent filter this receiver should subscribe to. + */ + private val filter get() = IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REPLACED) + addAction(Intent.ACTION_PACKAGE_REMOVED) + addDataScheme("package") + } + + /** + * Called when one of the events of the [filter] is received. When the package is an extension, + * it's loaded in background and it notifies the [listener] when finished. + */ + override fun onReceive(context: Context, intent: Intent?) { + if (intent == null) return + + when (intent.action) { + Intent.ACTION_PACKAGE_ADDED -> { + if (!isReplacing(intent)) launchNow { + val result = getExtensionFromIntent(context, intent) + when (result) { + is LoadResult.Success -> listener.onExtensionInstalled(result.extension) + is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension) + } + } + } + Intent.ACTION_PACKAGE_REPLACED -> { + launchNow { + val result = getExtensionFromIntent(context, intent) + when (result) { + is LoadResult.Success -> listener.onExtensionUpdated(result.extension) + // Not needed as a package can't be upgraded if the signature is different + is LoadResult.Untrusted -> {} + } + } + } + Intent.ACTION_PACKAGE_REMOVED -> { + if (!isReplacing(intent)) { + val pkgName = getPackageNameFromIntent(intent) + if (pkgName != null) { + listener.onPackageUninstalled(pkgName) + } + } + } + } + } + + /** + * Returns true if this package is performing an update. + * + * @param intent The intent that triggered the event. + */ + private fun isReplacing(intent: Intent): Boolean { + return intent.getBooleanExtra(Intent.EXTRA_REPLACING, false) + } + + /** + * Returns the extension triggered by the given intent. + * + * @param context The application context. + * @param intent The intent containing the package name of the extension. + */ + private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): LoadResult { + val pkgName = getPackageNameFromIntent(intent) ?: + return LoadResult.Error("Package name not found") + return async { ExtensionLoader.loadExtensionFromPkgName(context, pkgName) }.await() + } + + /** + * Returns the package name of the installed, updated or removed application. + */ + private fun getPackageNameFromIntent(intent: Intent?): String? { + return intent?.data?.encodedSchemeSpecificPart ?: return null + } + + /** + * Listener that receives extension installation events. + */ + interface Listener { + fun onExtensionInstalled(extension: Extension.Installed) + fun onExtensionUpdated(extension: Extension.Installed) + fun onExtensionUntrusted(extension: Extension.Untrusted) + fun onPackageUninstalled(pkgName: String) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt new file mode 100644 index 000000000..93638ee61 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt @@ -0,0 +1,259 @@ +package eu.kanade.tachiyomi.extension.util + +import android.app.DownloadManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import android.os.Build +import com.jakewharton.rxrelay.PublishRelay +import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.extension.model.InstallStep +import eu.kanade.tachiyomi.util.getUriCompat +import rx.Observable +import rx.android.schedulers.AndroidSchedulers +import timber.log.Timber +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * The installer which installs, updates and uninstalls the extensions. + * + * @param context The application context. + */ +internal class ExtensionInstaller(private val context: Context) { + + /** + * The system's download manager + */ + private val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + + /** + * The broadcast receiver which listens to download completion events. + */ + private val downloadReceiver = DownloadCompletionReceiver() + + /** + * The currently requested downloads, with the package name (unique id) as key, and the id + * returned by the download manager. + */ + private val activeDownloads = hashMapOf() + + /** + * Relay used to notify the installation step of every download. + */ + private val downloadsRelay = PublishRelay.create>() + + /** + * Adds the given extension to the downloads queue and returns an observable containing its + * step in the installation process. + * + * @param url The url of the apk. + * @param extension The extension to install. + */ + fun downloadAndInstall(url: String, extension: Extension) = Observable.defer { + val pkgName = extension.pkgName + + val oldDownload = activeDownloads[pkgName] + if (oldDownload != null) { + deleteDownload(pkgName) + } + + // Register the receiver after removing (and unregistering) the previous download + downloadReceiver.register() + + val request = DownloadManager.Request(Uri.parse(url)) + .setTitle(extension.name) + .setMimeType(APK_MIME) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + + val id = downloadManager.enqueue(request) + activeDownloads[pkgName] = id + + downloadsRelay.filter { it.first == id } + .map { it.second } + // Poll download status + .mergeWith(pollStatus(id)) + // Force an error if the download takes more than 3 minutes + .mergeWith(Observable.timer(3, TimeUnit.MINUTES).map { InstallStep.Error }) + // Force an error if the install process takes more than 10 seconds + .flatMap { Observable.just(it).mergeWith(timeoutWhenInstalling(it)) } + // Stop when the application is installed or errors + .takeUntil { it.isCompleted() } + // Always notify on main thread + .observeOn(AndroidSchedulers.mainThread()) + // Always remove the download when unsubscribed + .doOnUnsubscribe { deleteDownload(pkgName) } + } + + /** + * Returns an observable that polls the given download id for its status every second, as the + * manager doesn't have any notification system. It'll stop once the download finishes. + * + * @param id The id of the download to poll. + */ + private fun pollStatus(id: Long): Observable { + val query = DownloadManager.Query().setFilterById(id) + + return Observable.interval(0, 1, TimeUnit.SECONDS) + // Get the current download status + .map { + downloadManager.query(query).use { cursor -> + cursor.moveToFirst() + cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) + } + } + // Ignore duplicate results + .distinctUntilChanged() + // Stop polling when the download fails or finishes + .takeUntil { it == DownloadManager.STATUS_SUCCESSFUL || it == DownloadManager.STATUS_FAILED } + // Map to our model + .flatMap { status -> + when (status) { + DownloadManager.STATUS_PENDING -> Observable.just(InstallStep.Pending) + DownloadManager.STATUS_RUNNING -> Observable.just(InstallStep.Downloading) + else -> Observable.empty() + } + } + } + + /** + * Returns an observable that timeouts the installation after a specified time when the apk has + * been downloaded. + * + * @param currentStep The current step of the installation process. + */ + private fun timeoutWhenInstalling(currentStep: InstallStep): Observable { + return Observable.just(currentStep) + .filter { it == InstallStep.Installing } + .delay(10, TimeUnit.SECONDS) + .map { InstallStep.Error } + } + + /** + * Starts an intent to install the extension at the given uri. + * + * @param uri The uri of the extension to install. + */ + fun installApk(uri: Uri) { + val intent = Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, APK_MIME) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) + + context.startActivity(intent) + } + + /** + * Starts an intent to uninstall the extension by the given package name. + * + * @param pkgName The package name of the extension to uninstall + */ + fun uninstallApk(pkgName: String) { + val packageUri = Uri.parse("package:$pkgName") + val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, packageUri) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + context.startActivity(intent) + } + + /** + * Called when an extension is installed, allowing to update its installation step. + * + * @param pkgName The package name of the installed application. + */ + fun onApkInstalled(pkgName: String) { + val id = activeDownloads[pkgName] ?: return + downloadsRelay.call(id to InstallStep.Installed) + } + + /** + * Deletes the download for the given package name. + * + * @param pkgName The package name of the download to delete. + */ + fun deleteDownload(pkgName: String) { + val downloadId = activeDownloads.remove(pkgName) + if (downloadId != null) { + downloadManager.remove(downloadId) + } + if (activeDownloads.isEmpty()) { + downloadReceiver.unregister() + } + } + + /** + * Receiver that listens to download status events. + */ + private inner class DownloadCompletionReceiver : BroadcastReceiver() { + + /** + * Whether this receiver is currently registered. + */ + private var isRegistered = false + + /** + * Registers this receiver if it's not already. + */ + fun register() { + if (isRegistered) return + isRegistered = true + + val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) + context.registerReceiver(this, filter) + } + + /** + * Unregisters this receiver if it's not already. + */ + fun unregister() { + if (!isRegistered) return + isRegistered = false + + context.unregisterReceiver(this) + } + + /** + * Called when a download event is received. It looks for the download in the current active + * downloads and notifies its installation step. + */ + override fun onReceive(context: Context, intent: Intent?) { + val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0) ?: return + + // Avoid events for downloads we didn't request + if (id !in activeDownloads.values) return + + val uri = downloadManager.getUriForDownloadedFile(id) + + // Set next installation step + if (uri != null) { + downloadsRelay.call(id to InstallStep.Installing) + } else { + Timber.e("Couldn't locate downloaded APK") + downloadsRelay.call(id to InstallStep.Error) + return + } + + // Due to a bug in Android versions prior to N, the installer can't open files that do + // not contain the extension in the path, even if you specify the correct MIME. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + val query = DownloadManager.Query().setFilterById(id) + downloadManager.query(query).use { cursor -> + if (cursor.moveToFirst()) { + @Suppress("DEPRECATION") + val uriCompat = File(cursor.getString(cursor.getColumnIndex( + DownloadManager.COLUMN_LOCAL_FILENAME))).getUriCompat(context) + installApk(uriCompat) + } + } + } else { + installApk(uri) + } + } + } + + private companion object { + const val APK_MIME = "application/vnd.android.package-archive" + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt new file mode 100644 index 000000000..06cfa58c5 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt @@ -0,0 +1,172 @@ +package eu.kanade.tachiyomi.extension.util + +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import dalvik.system.PathClassLoader +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.extension.model.LoadResult +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceFactory +import eu.kanade.tachiyomi.util.Hash +import kotlinx.coroutines.experimental.async +import kotlinx.coroutines.experimental.runBlocking +import timber.log.Timber +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +/** + * Class that handles the loading of the extensions installed in the system. + */ +@SuppressLint("PackageManagerGetSignatures") +internal object ExtensionLoader { + + private const val EXTENSION_FEATURE = "tachiyomi.extension" + private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class" + private const val LIB_VERSION_MIN = 1 + private const val LIB_VERSION_MAX = 1 + + private const val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES + + /** + * List of the trusted signatures. + */ + var trustedSignatures = mutableSetOf() + + Injekt.get().trustedSignatures().getOrDefault() + + // inorichi's key + "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23" + + /** + * Return a list of all the installed extensions initialized concurrently. + * + * @param context The application context. + */ + fun loadExtensions(context: Context): List { + val pkgManager = context.packageManager + val installedPkgs = pkgManager.getInstalledPackages(PACKAGE_FLAGS) + val extPkgs = installedPkgs.filter { isPackageAnExtension(it) } + + if (extPkgs.isEmpty()) return emptyList() + + // Load each extension concurrently and wait for completion + return runBlocking { + val deferred = extPkgs.map { + async { loadExtension(context, it.packageName, it) } + } + deferred.map { it.await() } + } + } + + /** + * Attempts to load an extension from the given package name. It checks if the extension + * contains the required feature flag before trying to load it. + */ + fun loadExtensionFromPkgName(context: Context, pkgName: String): LoadResult { + val pkgInfo = context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS) + if (!isPackageAnExtension(pkgInfo)) { + return LoadResult.Error("Tried to load a package that wasn't a extension") + } + return loadExtension(context, pkgName, pkgInfo) + } + + /** + * Loads an extension given its package name. + * + * @param context The application context. + * @param pkgName The package name of the extension to load. + * @param pkgInfo The package info of the extension. + */ + private fun loadExtension(context: Context, pkgName: String, pkgInfo: PackageInfo): LoadResult { + val pkgManager = context.packageManager + + val appInfo = pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA) + + val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ") + val versionName = pkgInfo.versionName + val versionCode = pkgInfo.versionCode + + // Validate lib version + val majorLibVersion = versionName.substringBefore('.').toInt() + if (majorLibVersion < LIB_VERSION_MIN || majorLibVersion > LIB_VERSION_MAX) { + val exception = Exception("Lib version is $majorLibVersion, while only versions " + + "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed") + Timber.w(exception) + return LoadResult.Error(exception) + } + + val signatureHash = getSignatureHash(pkgInfo) + + if (signatureHash == null) { + return LoadResult.Error("Package $pkgName isn't signed") + } else if (signatureHash !in trustedSignatures) { + val extension = Extension.Untrusted(extName, pkgName, versionName, versionCode, signatureHash) + Timber.w("Extension $pkgName isn't trusted") + return LoadResult.Untrusted(extension) + } + + val classLoader = PathClassLoader(appInfo.sourceDir, null, context.classLoader) + + val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS) + .split(";") + .map { + val sourceClass = it.trim() + if (sourceClass.startsWith(".")) + pkgInfo.packageName + sourceClass + else + sourceClass + } + .flatMap { + try { + val obj = Class.forName(it, false, classLoader).newInstance() + when (obj) { + is Source -> listOf(obj) + is SourceFactory -> obj.createSources() + else -> throw Exception("Unknown source class type! ${obj.javaClass}") + } + } catch (e: Throwable) { + Timber.e(e, "Extension load error: $extName.") + return LoadResult.Error(e) + } + } + val langs = sources.filterIsInstance() + .map { it.lang } + .toSet() + + val lang = when (langs.size) { + 0 -> "" + 1 -> langs.first() + else -> "all" + } + + val extension = Extension.Installed(extName, pkgName, versionName, versionCode, sources, lang) + return LoadResult.Success(extension) + } + + /** + * Returns true if the given package is an extension. + * + * @param pkgInfo The package info of the application. + */ + private fun isPackageAnExtension(pkgInfo: PackageInfo): Boolean { + return pkgInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE } + } + + /** + * Returns the signature hash of the package or null if it's not signed. + * + * @param pkgInfo The package info of the application. + */ + private fun getSignatureHash(pkgInfo: PackageInfo): String? { + val signatures = pkgInfo.signatures + return if (signatures != null && !signatures.isEmpty()) { + Hash.sha256(signatures.first().toByteArray()) + } else { + null + } + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/ConfigurableSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/ConfigurableSource.kt new file mode 100644 index 000000000..6b3f99ace --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/ConfigurableSource.kt @@ -0,0 +1,8 @@ +package eu.kanade.tachiyomi.source + +import android.support.v7.preference.PreferenceScreen + +interface ConfigurableSource : Source { + + fun setupPreferenceScreen(screen: PreferenceScreen) +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt index 9a29b560d..c999aabcf 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt @@ -1,195 +1,93 @@ -package eu.kanade.tachiyomi.source - -import android.Manifest.permission.READ_EXTERNAL_STORAGE -import android.content.Context -import android.content.pm.ApplicationInfo -import android.content.pm.PackageManager -import android.os.Environment -import dalvik.system.PathClassLoader -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.source.online.YamlHttpSource -import eu.kanade.tachiyomi.source.online.all.EHentai -import eu.kanade.tachiyomi.source.online.all.NHentai -import eu.kanade.tachiyomi.source.online.all.PervEden -import eu.kanade.tachiyomi.source.online.english.* -import eu.kanade.tachiyomi.source.online.german.WieManga -import eu.kanade.tachiyomi.source.online.russian.Mangachan -import eu.kanade.tachiyomi.source.online.russian.Mintmanga -import eu.kanade.tachiyomi.source.online.russian.Readmanga -import eu.kanade.tachiyomi.util.hasPermission -import exh.EH_SOURCE_ID -import exh.EXH_SOURCE_ID -import exh.PERV_EDEN_EN_SOURCE_ID -import exh.PERV_EDEN_IT_SOURCE_ID -import exh.metadata.models.PervEdenLang -import org.yaml.snakeyaml.Yaml -import rx.Observable -import timber.log.Timber -import uy.kohesive.injekt.injectLazy -import java.io.File - -open class SourceManager(private val context: Context) { - - private val prefs: PreferencesHelper by injectLazy() - - private val sourcesMap = mutableMapOf() - - init { - //Recreate sources when they change - val prefEntries = arrayOf( - prefs.enableExhentai(), - prefs.imageQuality(), - prefs.useHentaiAtHome(), - prefs.useJapaneseTitle(), - prefs.ehSearchSize(), - prefs.thumbnailRows() - ).map { it.asObservable() } - - Observable.merge(prefEntries).skip(prefEntries.size - 1).subscribe { - sourcesMap.clear() - createSources() - } - } - - open fun get(sourceKey: Long): Source? { - return sourcesMap[sourceKey] - } - - fun getOnlineSources() = sourcesMap.values.filterIsInstance() - - fun getCatalogueSources() = sourcesMap.values.filterIsInstance() - - private fun createSources() { - createExtensionSources().forEach { registerSource(it) } - createYamlSources().forEach { registerSource(it) } - createInternalSources().forEach { registerSource(it) } - //EH - createEHSources().forEach { registerSource(it) } - } - - private fun registerSource(source: Source, overwrite: Boolean = false) { - if (overwrite || !sourcesMap.containsKey(source.id)) { - sourcesMap.put(source.id, source) - } - } - - private fun createInternalSources(): List = listOf( - LocalSource(context), - Batoto(), - Mangahere(), - Mangafox(), - Kissmanga(), - Readmanga(), - Mintmanga(), - Mangachan(), - Readmangatoday(), - Mangasee(), - WieManga() - ) - - private fun createEHSources(): List { - val exSrcs = mutableListOf( - EHentai(EH_SOURCE_ID, false, context) - ) - if(prefs.enableExhentai().getOrDefault()) { - exSrcs += EHentai(EXH_SOURCE_ID, true, context) - } - exSrcs += PervEden(PERV_EDEN_EN_SOURCE_ID, PervEdenLang.en) - exSrcs += PervEden(PERV_EDEN_IT_SOURCE_ID, PervEdenLang.it) - exSrcs += NHentai(context) - exSrcs += HentaiCafe() - exSrcs += Tsumino() - return exSrcs - } - - private fun createYamlSources(): List { - val sources = mutableListOf() - - val parsersDir = File(Environment.getExternalStorageDirectory().absolutePath + - File.separator + context.getString(R.string.app_name), "parsers") - - if (parsersDir.exists() && context.hasPermission(READ_EXTERNAL_STORAGE)) { - val yaml = Yaml() - for (file in parsersDir.listFiles().filter { it.extension == "yml" }) { - try { - val map = file.inputStream().use { yaml.loadAs(it, Map::class.java) } - sources.add(YamlHttpSource(map)) - } catch (e: Exception) { - Timber.e("Error loading source from file. Bad format?", e) - } - } - } - return sources - } - - private fun createExtensionSources(): List { - val pkgManager = context.packageManager - val flags = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES - val installedPkgs = pkgManager.getInstalledPackages(flags) - val extPkgs = installedPkgs.filter { it.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE } } - - val sources = mutableListOf() - for (pkgInfo in extPkgs) { - val appInfo = pkgManager.getApplicationInfo(pkgInfo.packageName, - PackageManager.GET_META_DATA) ?: continue - - val extName = pkgManager.getApplicationLabel(appInfo).toString() - .substringAfter("Tachiyomi: ") - val version = pkgInfo.versionName - val sourceClasses = appInfo.metaData.getString(METADATA_SOURCE_CLASS) - .split(";") - .map { - val sourceClass = it.trim() - if(sourceClass.startsWith(".")) - pkgInfo.packageName + sourceClass - else - sourceClass - } - - val extension = Extension(extName, appInfo, version, sourceClasses) - try { - sources += loadExtension(extension) - } catch (e: Exception) { - Timber.e("Extension load error: $extName.", e) - } catch (e: LinkageError) { - Timber.e("Extension load error: $extName.", e) - } - } - return sources - } - - private fun loadExtension(ext: Extension): List { - // Validate lib version - val majorLibVersion = ext.version.substringBefore('.').toInt() - if (majorLibVersion < LIB_VERSION_MIN || majorLibVersion > LIB_VERSION_MAX) { - throw Exception("Lib version is $majorLibVersion, while only versions " - + "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed") - } - - val classLoader = PathClassLoader(ext.appInfo.sourceDir, null, context.classLoader) - return ext.sourceClasses.flatMap { - val obj = Class.forName(it, false, classLoader).newInstance() - when(obj) { - is Source -> listOf(obj) - is SourceFactory -> obj.createSources() - else -> throw Exception("Unknown source class type!") - } - } - } - - class Extension(val name: String, - val appInfo: ApplicationInfo, - val version: String, - val sourceClasses: List) - - private companion object { - const val EXTENSION_FEATURE = "tachiyomi.extension" - const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class" - const val LIB_VERSION_MIN = 1 - const val LIB_VERSION_MAX = 1 - } - -} +package eu.kanade.tachiyomi.source + +import android.content.Context +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.source.online.all.EHentai +import eu.kanade.tachiyomi.source.online.all.NHentai +import eu.kanade.tachiyomi.source.online.all.PervEden +import eu.kanade.tachiyomi.source.online.english.* +import eu.kanade.tachiyomi.source.online.german.WieManga +import eu.kanade.tachiyomi.source.online.russian.Mangachan +import eu.kanade.tachiyomi.source.online.russian.Mintmanga +import eu.kanade.tachiyomi.source.online.russian.Readmanga +import exh.EH_SOURCE_ID +import exh.EXH_SOURCE_ID +import exh.PERV_EDEN_EN_SOURCE_ID +import exh.PERV_EDEN_IT_SOURCE_ID +import exh.metadata.models.PervEdenLang +import rx.Observable +import uy.kohesive.injekt.injectLazy + +open class SourceManager(private val context: Context) { + + private val prefs: PreferencesHelper by injectLazy() + + private val sourcesMap = mutableMapOf() + + init { + createInternalSources().forEach { registerSource(it) } + //Recreate sources when they change + val prefEntries = arrayOf( + prefs.enableExhentai(), + prefs.imageQuality(), + prefs.useHentaiAtHome(), + prefs.useJapaneseTitle(), + prefs.ehSearchSize(), + prefs.thumbnailRows() + ).map { it.asObservable() } + + Observable.merge(prefEntries).skip(prefEntries.size - 1).subscribe { + sourcesMap.clear() + createSources() + } + } + + open fun get(sourceKey: Long): Source? { + return sourcesMap[sourceKey] + } + + fun getOnlineSources() = sourcesMap.values.filterIsInstance() + + fun getCatalogueSources() = sourcesMap.values.filterIsInstance() + + internal fun registerSource(source: Source, overwrite: Boolean = false) { + if (overwrite || !sourcesMap.containsKey(source.id)) { + sourcesMap.put(source.id, source) + } + } + + internal fun unregisterSource(source: Source) { + sourcesMap.remove(source.id) + } + + private fun createInternalSources(): List = listOf( + LocalSource(context), + Batoto(), + Mangahere(), + Mangafox(), + Kissmanga(), + Readmanga(), + Mintmanga(), + Mangachan(), + Readmangatoday(), + Mangasee(), + WieManga() + ) + + private fun createEHSources(): List { + val exSrcs = mutableListOf( + EHentai(EH_SOURCE_ID, false, context) + ) + if(prefs.enableExhentai().getOrDefault()) { + exSrcs += EHentai(EXH_SOURCE_ID, true, context) + } + exSrcs += PervEden(PERV_EDEN_EN_SOURCE_ID, PervEdenLang.en) + exSrcs += PervEden(PERV_EDEN_IT_SOURCE_ID, PervEdenLang.it) + exSrcs += NHentai(context) + exSrcs += HentaiCafe() + exSrcs += Tsumino() + return exSrcs + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt index 7b57d10c1..cb76f1162 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt @@ -1,6 +1,5 @@ package eu.kanade.tachiyomi.source.online -import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.asObservableSuccess @@ -28,10 +27,12 @@ abstract class HttpSource : CatalogueSource { */ protected val network: NetworkHelper by injectLazy() - /** - * Preferences helper. - */ - protected val preferences: PreferencesHelper by injectLazy() +// /** +// * Preferences that a source may need. +// */ +// val preferences: SharedPreferences by lazy { +// Injekt.get().getSharedPreferences("source_$id", Context.MODE_PRIVATE) +// } /** * Base url of the website without the trailing slash, like: http://mysite.com diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/YamlHttpSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/YamlHttpSource.kt deleted file mode 100755 index 582a7c3d3..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/YamlHttpSource.kt +++ /dev/null @@ -1,231 +0,0 @@ -package eu.kanade.tachiyomi.source.online - -import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.POST -import eu.kanade.tachiyomi.source.model.* -import eu.kanade.tachiyomi.util.asJsoup -import eu.kanade.tachiyomi.util.attrOrText -import okhttp3.Request -import okhttp3.Response -import org.jsoup.Jsoup -import org.jsoup.nodes.Element -import java.text.SimpleDateFormat -import java.util.* - -class YamlHttpSource(mappings: Map<*, *>) : HttpSource() { - - val map = YamlSourceNode(mappings) - - override val name: String - get() = map.name - - override val baseUrl = map.host.let { - if (it.endsWith("/")) it.dropLast(1) else it - } - - override val lang = map.lang.toLowerCase() - - override val supportsLatest = map.latestupdates != null - - override val client = when (map.client) { - "cloudflare" -> network.cloudflareClient - else -> network.client - } - - override val id = map.id.let { - (it as? Int ?: (lang.toUpperCase().hashCode() + 31 * it.hashCode()) and 0x7fffffff).toLong() - } - - // Ugly, but needed after the changes - var popularNextPage: String? = null - var searchNextPage: String? = null - var latestNextPage: String? = null - - override fun popularMangaRequest(page: Int): Request { - val url = if (page == 1) { - popularNextPage = null - map.popular.url - } else { - popularNextPage!! - } - return when (map.popular.method?.toLowerCase()) { - "post" -> POST(url, headers, map.popular.createForm()) - else -> GET(url, headers) - } - } - - override fun popularMangaParse(response: Response): MangasPage { - val document = response.asJsoup() - - val mangas = document.select(map.popular.manga_css).map { element -> - SManga.create().apply { - title = element.text() - setUrlWithoutDomain(element.attr("href")) - } - } - - popularNextPage = map.popular.next_url_css?.let { selector -> - document.select(selector).first()?.absUrl("href") - } - - return MangasPage(mangas, popularNextPage != null) - } - - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - val url = if (page == 1) { - searchNextPage = null - map.search.url.replace("\$query", query) - } else { - searchNextPage!! - } - return when (map.search.method?.toLowerCase()) { - "post" -> POST(url, headers, map.search.createForm()) - else -> GET(url, headers) - } - } - - override fun searchMangaParse(response: Response): MangasPage { - val document = response.asJsoup() - - val mangas = document.select(map.search.manga_css).map { element -> - SManga.create().apply { - title = element.text() - setUrlWithoutDomain(element.attr("href")) - } - } - - searchNextPage = map.search.next_url_css?.let { selector -> - document.select(selector).first()?.absUrl("href") - } - - return MangasPage(mangas, searchNextPage != null) - } - - override fun latestUpdatesRequest(page: Int): Request { - val url = if (page == 1) { - latestNextPage = null - map.latestupdates!!.url - } else { - latestNextPage!! - } - return when (map.latestupdates!!.method?.toLowerCase()) { - "post" -> POST(url, headers, map.latestupdates.createForm()) - else -> GET(url, headers) - } - } - - override fun latestUpdatesParse(response: Response): MangasPage { - val document = response.asJsoup() - - val mangas = document.select(map.latestupdates!!.manga_css).map { element -> - SManga.create().apply { - title = element.text() - setUrlWithoutDomain(element.attr("href")) - } - } - - popularNextPage = map.latestupdates.next_url_css?.let { selector -> - document.select(selector).first()?.absUrl("href") - } - - return MangasPage(mangas, popularNextPage != null) - } - - override fun mangaDetailsParse(response: Response): SManga { - val document = response.asJsoup() - - val manga = SManga.create() - with(map.manga) { - val pool = parts.get(document) - - manga.author = author?.process(document, pool) - manga.artist = artist?.process(document, pool) - manga.description = summary?.process(document, pool) - manga.thumbnail_url = cover?.process(document, pool) - manga.genre = genres?.process(document, pool) - manga.status = status?.getStatus(document, pool) ?: SManga.UNKNOWN - } - return manga - } - - override fun chapterListParse(response: Response): List { - val document = response.asJsoup() - - val chapters = mutableListOf() - with(map.chapters) { - val pool = emptyMap() - val dateFormat = SimpleDateFormat(date?.format, Locale.ENGLISH) - - for (element in document.select(chapter_css)) { - val chapter = SChapter.create() - element.select(title).first().let { - chapter.name = it.text() - chapter.setUrlWithoutDomain(it.attr("href")) - } - val dateElement = element.select(date?.select).first() - chapter.date_upload = date?.getDate(dateElement, pool, dateFormat)?.time ?: 0 - chapters.add(chapter) - } - } - return chapters - } - - override fun pageListParse(response: Response): List { - val body = response.body()!!.string() - val url = response.request().url().toString() - - val pages = mutableListOf() - - val document by lazy { Jsoup.parse(body, url) } - - with(map.pages) { - // Capture a list of values where page urls will be resolved. - val capturedPages = if (pages_regex != null) - pages_regex!!.toRegex().findAll(body).map { it.value }.toList() - else if (pages_css != null) - document.select(pages_css).map { it.attrOrText(pages_attr!!) } - else - null - - // For each captured value, obtain the url and create a new page. - capturedPages?.forEach { value -> - // If the captured value isn't an url, we have to use replaces with the chapter url. - val pageUrl = if (replace != null && replacement != null) - url.replace(replace!!.toRegex(), replacement!!.replace("\$value", value)) - else - value - - pages.add(Page(pages.size, pageUrl)) - } - - // Capture a list of images. - val capturedImages = if (image_regex != null) - image_regex!!.toRegex().findAll(body).map { it.groups[1]?.value }.toList() - else if (image_css != null) - document.select(image_css).map { it.absUrl(image_attr) } - else - null - - // Assign the image url to each page - capturedImages?.forEachIndexed { i, url -> - val page = pages.getOrElse(i) { Page(i, "").apply { pages.add(this) } } - page.imageUrl = url - } - } - return pages - } - - override fun imageUrlParse(response: Response): String { - val body = response.body()!!.string() - val url = response.request().url().toString() - - with(map.pages) { - return if (image_regex != null) - image_regex!!.toRegex().find(body)!!.groups[1]!!.value - else if (image_css != null) - Jsoup.parse(body, url).select(image_css).first().absUrl(image_attr) - else - throw Exception("image_regex and image_css are null") - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/YamlHttpSourceMappings.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/YamlHttpSourceMappings.kt deleted file mode 100755 index ba07594c3..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/YamlHttpSourceMappings.kt +++ /dev/null @@ -1,234 +0,0 @@ -@file:Suppress("UNCHECKED_CAST") - -package eu.kanade.tachiyomi.source.online - -import eu.kanade.tachiyomi.source.model.SManga -import okhttp3.FormBody -import okhttp3.RequestBody -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import java.text.ParseException -import java.text.SimpleDateFormat -import java.util.* - -private fun toMap(map: Any?) = map as? Map - -class YamlSourceNode(uncheckedMap: Map<*, *>) { - - val map = toMap(uncheckedMap)!! - - val id: Any by map - - val name: String by map - - val host: String by map - - val lang: String by map - - val client: String? - get() = map["client"] as? String - - val popular = PopularNode(toMap(map["popular"])!!) - - val latestupdates = toMap(map["latest_updates"])?.let { LatestUpdatesNode(it) } - - val search = SearchNode(toMap(map["search"])!!) - - val manga = MangaNode(toMap(map["manga"])!!) - - val chapters = ChaptersNode(toMap(map["chapters"])!!) - - val pages = PagesNode(toMap(map["pages"])!!) -} - -interface RequestableNode { - - val map: Map - - val url: String - get() = map["url"] as String - - val method: String? - get() = map["method"] as? String - - val payload: Map? - get() = map["payload"] as? Map - - fun createForm(): RequestBody { - return FormBody.Builder().apply { - payload?.let { - for ((key, value) in it) { - add(key, value) - } - } - }.build() - } - -} - -class PopularNode(override val map: Map): RequestableNode { - - val manga_css: String by map - - val next_url_css: String? - get() = map["next_url_css"] as? String - -} - - -class LatestUpdatesNode(override val map: Map): RequestableNode { - - val manga_css: String by map - - val next_url_css: String? - get() = map["next_url_css"] as? String - -} - - -class SearchNode(override val map: Map): RequestableNode { - - val manga_css: String by map - - val next_url_css: String? - get() = map["next_url_css"] as? String -} - -class MangaNode(private val map: Map) { - - val parts = CacheNode(toMap(map["parts"]) ?: emptyMap()) - - val artist = toMap(map["artist"])?.let { SelectableNode(it) } - - val author = toMap(map["author"])?.let { SelectableNode(it) } - - val summary = toMap(map["summary"])?.let { SelectableNode(it) } - - val status = toMap(map["status"])?.let { StatusNode(it) } - - val genres = toMap(map["genres"])?.let { SelectableNode(it) } - - val cover = toMap(map["cover"])?.let { CoverNode(it) } - -} - -class ChaptersNode(private val map: Map) { - - val chapter_css: String by map - - val title: String by map - - val date = toMap(toMap(map["date"]))?.let { DateNode(it) } -} - -class CacheNode(private val map: Map) { - - fun get(document: Document) = map.mapValues { document.select(it.value as String).first() } -} - -open class SelectableNode(private val map: Map) { - - val select: String by map - - val from: String? - get() = map["from"] as? String - - open val attr: String? - get() = map["attr"] as? String - - val capture: String? - get() = map["capture"] as? String - - fun process(document: Element, cache: Map): String { - val parent = from?.let { cache[it] } ?: document - val node = parent.select(select).first() - var text = attr?.let { node.attr(it) } ?: node.text() - capture?.let { - text = Regex(it).find(text)?.groupValues?.get(1) ?: text - } - return text - } -} - -class StatusNode(private val map: Map) : SelectableNode(map) { - - val complete: String? - get() = map["complete"] as? String - - val ongoing: String? - get() = map["ongoing"] as? String - - val licensed: String? - get() = map["licensed"] as? String - - fun getStatus(document: Element, cache: Map): Int { - val text = process(document, cache) - complete?.let { - if (text.contains(it)) return SManga.COMPLETED - } - ongoing?.let { - if (text.contains(it)) return SManga.ONGOING - } - licensed?.let { - if (text.contains(it)) return SManga.LICENSED - } - return SManga.UNKNOWN - } -} - -class CoverNode(private val map: Map) : SelectableNode(map) { - - override val attr: String? - get() = map["attr"] as? String ?: "src" -} - -class DateNode(private val map: Map) : SelectableNode(map) { - - val format: String by map - - fun getDate(document: Element, cache: Map, formatter: SimpleDateFormat): Date { - val text = process(document, cache) - try { - return formatter.parse(text) - } catch (exception: ParseException) {} - - for (i in 0..7) { - (map["day$i"] as? List)?.let { - it.find { it.toRegex().containsMatchIn(text) }?.let { - return Calendar.getInstance().apply { add(Calendar.DATE, -i) }.time - } - } - } - - return Date(0) - } - -} - -class PagesNode(private val map: Map) { - - val pages_regex: String? - get() = map["pages_regex"] as? String - - val pages_css: String? - get() = map["pages_css"] as? String - - val pages_attr: String? - get() = map["pages_attr"] as? String ?: "value" - - val replace: String? - get() = map["url_replace"] as? String - - val replacement: String? - get() = map["url_replacement"] as? String - - val image_regex: String? - get() = map["image_regex"] as? String - - val image_css: String? - get() = map["image_css"] as? String - - val image_attr: String - get() = map["image_attr"] as? String ?: "src" - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Batoto.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Batoto.kt index 047393f99..415b1b8d5 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Batoto.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Batoto.kt @@ -1,382 +1,27 @@ package eu.kanade.tachiyomi.source.online.english -import android.text.Html -import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.POST -import eu.kanade.tachiyomi.network.asObservable -import eu.kanade.tachiyomi.source.model.* -import eu.kanade.tachiyomi.source.online.LoginSource -import eu.kanade.tachiyomi.source.online.ParsedHttpSource -import eu.kanade.tachiyomi.util.asJsoup -import eu.kanade.tachiyomi.util.selectText -import okhttp3.FormBody -import okhttp3.HttpUrl -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga import rx.Observable -import java.net.URI -import java.text.ParseException -import java.text.SimpleDateFormat -import java.util.* -import java.util.regex.Pattern -class Batoto : ParsedHttpSource(), LoginSource { +class Batoto : Source { override val id: Long = 1 override val name = "Batoto" - override val baseUrl = "https://bato.to" - - override val lang = "en" - - override val supportsLatest = true - - private val datePattern = Pattern.compile("(\\d+|A|An)\\s+(.*?)s? ago.*") - - private val dateFields = HashMap().apply { - put("second", Calendar.SECOND) - put("minute", Calendar.MINUTE) - put("hour", Calendar.HOUR) - put("day", Calendar.DATE) - put("week", Calendar.WEEK_OF_YEAR) - put("month", Calendar.MONTH) - put("year", Calendar.YEAR) - } - - private val staffNotice = Pattern.compile("=+Batoto Staff Notice=+([^=]+)==+", Pattern.CASE_INSENSITIVE) - - override val client: OkHttpClient = network.cloudflareClient - - override fun headersBuilder() = super.headersBuilder() - .add("Cookie", "lang_option=English") - - private val pageHeaders = super.headersBuilder() - .add("Referer", "$baseUrl/reader") - .build() - - override fun popularMangaRequest(page: Int): Request { - return GET("$baseUrl/search_ajax?order_cond=views&order=desc&p=$page", headers) - } - - override fun latestUpdatesRequest(page: Int): Request { - return GET("$baseUrl/search_ajax?order_cond=update&order=desc&p=$page", headers) - } - - override fun popularMangaSelector() = "tr:has(a)" - - override fun latestUpdatesSelector() = "tr:has(a)" - - override fun popularMangaFromElement(element: Element): SManga { - val manga = SManga.create() - element.select("a[href*=bato.to]").first().let { - manga.setUrlWithoutDomain(it.attr("href")) - manga.title = it.text().trim() - } - return manga - } - - override fun latestUpdatesFromElement(element: Element): SManga { - return popularMangaFromElement(element) - } - - override fun popularMangaNextPageSelector() = "#show_more_row" - - override fun latestUpdatesNextPageSelector() = "#show_more_row" - - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - val url = HttpUrl.parse("$baseUrl/search_ajax")!!.newBuilder() - if (!query.isEmpty()) url.addQueryParameter("name", query).addQueryParameter("name_cond", "c") - var genres = "" - filters.forEach { filter -> - when (filter) { - is Status -> if (!filter.isIgnored()) { - url.addQueryParameter("completed", if (filter.isExcluded()) "i" else "c") - } - is GenreList -> { - filter.state.forEach { filter -> - when (filter) { - is Genre -> if (!filter.isIgnored()) { - genres += (if (filter.isExcluded()) ";e" else ";i") + filter.id - } - is SelectField -> { - val sel = filter.values[filter.state].value - if (!sel.isEmpty()) url.addQueryParameter(filter.key, sel) - } - } - } - } - is TextField -> { - if (!filter.state.isEmpty()) url.addQueryParameter(filter.key, filter.state) - } - is SelectField -> { - val sel = filter.values[filter.state].value - if (!sel.isEmpty()) url.addQueryParameter(filter.key, sel) - } - is Flag -> { - val sel = if (filter.state) filter.valTrue else filter.valFalse - if (!sel.isEmpty()) url.addQueryParameter(filter.key, sel) - } - is OrderBy -> { - url.addQueryParameter("order_cond", arrayOf("title", "author", "artist", "rating", "views", "update")[filter.state!!.index]) - url.addQueryParameter("order", if (filter.state?.ascending == true) "asc" else "desc") - } - } - } - if (!genres.isEmpty()) url.addQueryParameter("genres", genres) - url.addQueryParameter("p", page.toString()) - return GET(url.toString(), headers) - } - - override fun searchMangaSelector() = popularMangaSelector() - - override fun searchMangaFromElement(element: Element): SManga { - return popularMangaFromElement(element) - } - - override fun searchMangaNextPageSelector() = popularMangaNextPageSelector() - - override fun mangaDetailsRequest(manga: SManga): Request { - val mangaId = manga.url.substringAfterLast("r") - return GET("$baseUrl/comic_pop?id=$mangaId", headers) - } - - override fun mangaDetailsParse(document: Document): SManga { - val tbody = document.select("tbody").first() - val artistElement = tbody.select("tr:contains(Author/Artist:)").first() - - val manga = SManga.create() - manga.author = artistElement.selectText("td:eq(1)") - manga.artist = artistElement.selectText("td:eq(2)") ?: manga.author - manga.description = tbody.selectText("tr:contains(Description:) > td:eq(1)") - manga.thumbnail_url = document.select("img[src^=http://img.bato.to/forums/uploads/]").first()?.attr("src") - manga.status = parseStatus(document.selectText("tr:contains(Status:) > td:eq(1)")) - manga.genre = tbody.select("tr:contains(Genres:) img").map { it.attr("alt") }.joinToString(", ") - return manga - } - - private fun parseStatus(status: String?) = when (status) { - "Ongoing" -> SManga.ONGOING - "Complete" -> SManga.COMPLETED - else -> SManga.UNKNOWN - } - - override fun chapterListRequest(manga: SManga): Request { - // Https is currently very slow. The replace also saves a redirection. - var newUrl = "http://bato.to" + manga.url - if ("/comic/_/comics/" !in newUrl) { - newUrl = newUrl.replace("/comic/_/", "/comic/_/comics/") - } - - return super.chapterListRequest(manga).newBuilder() - .url(newUrl) - .build() - } - - override fun chapterListParse(response: Response): List { - val body = response.body()!!.string() - val matcher = staffNotice.matcher(body) - if (matcher.find()) { - @Suppress("DEPRECATION") - val notice = Html.fromHtml(matcher.group(1)).toString().trim() - throw Exception(notice) - } - - val document = response.asJsoup(body) - return document.select(chapterListSelector()).map { chapterFromElement(it) } - } - - override fun chapterListSelector() = "tr.row.lang_English.chapter_row" - - override fun chapterFromElement(element: Element): SChapter { - val urlElement = element.select("a[href*=bato.to/reader").first() - - val chapter = SChapter.create() - chapter.setUrlWithoutDomain(urlElement.attr("href")) - chapter.name = urlElement.text() - chapter.date_upload = element.select("td").getOrNull(4)?.let { - parseDateFromElement(it) - } ?: 0 - chapter.scanlator = element.select("td").getOrNull(2)?.text() - return chapter - } - - private fun parseDateFromElement(dateElement: Element): Long { - val dateAsString = dateElement.text() - - var date: Date - try { - date = SimpleDateFormat("dd MMMMM yyyy - hh:mm a", Locale.ENGLISH).parse(dateAsString) - } catch (e: ParseException) { - val m = datePattern.matcher(dateAsString) - - if (m.matches()) { - val number = m.group(1) - val amount = if (number.contains("A")) 1 else Integer.parseInt(m.group(1)) - val unit = m.group(2) - - date = Calendar.getInstance().apply { - add(dateFields[unit]!!, -amount) - }.time - } else { - return 0 - } - } - - return date.time - } - - override fun pageListRequest(chapter: SChapter): Request { - val id = chapter.url.substringAfterLast("#") - return GET("$baseUrl/areader?id=$id&p=1", pageHeaders) - } - - override fun pageListParse(document: Document): List { - val pages = mutableListOf() - val selectElement = document.select("#page_select").first() - if (selectElement != null) { - for ((i, element) in selectElement.select("option").withIndex()) { - pages.add(Page(i, element.attr("value"))) - } - pages.getOrNull(0)?.imageUrl = imageUrlParse(document) - } else { - // For webtoons in one page - for ((i, element) in document.select("div > img").withIndex()) { - pages.add(Page(i, "", element.attr("src"))) - } - } - return pages - } - - override fun imageUrlRequest(page: Page): Request { - val pageUrl = page.url - val start = pageUrl.indexOf("#") + 1 - val end = pageUrl.indexOf("_", start) - val id = pageUrl.substring(start, end) - return GET("$baseUrl/areader?id=$id&p=${pageUrl.substring(end + 1)}", pageHeaders) - } - - override fun imageUrlParse(document: Document): String { - return document.select("#comic_page").first().attr("src") - } - - override fun login(username: String, password: String) = - client.newCall(GET("$baseUrl/forums/index.php?app=core&module=global§ion=login", headers)) - .asObservable() - .flatMap { doLogin(it, username, password) } - .map { isAuthenticationSuccessful(it) } - - private fun doLogin(response: Response, username: String, password: String): Observable { - val doc = response.asJsoup() - val form = doc.select("#login").first() - val url = form.attr("action") - val authKey = form.select("input[name=auth_key]").first() - - val payload = FormBody.Builder().apply { - add(authKey.attr("name"), authKey.attr("value")) - add("ips_username", username) - add("ips_password", password) - add("invisible", "1") - add("rememberMe", "1") - }.build() - - return client.newCall(POST(url, headers, payload)).asObservable() - } - - override fun isAuthenticationSuccessful(response: Response) = - response.priorResponse() != null && response.priorResponse()!!.code() == 302 - - override fun isLogged(): Boolean { - return network.cookies.get(URI(baseUrl)).any { it.name() == "pass_hash" } + override fun fetchMangaDetails(manga: SManga): Observable { + return Observable.error(Exception("RIP Batoto")) } override fun fetchChapterList(manga: SManga): Observable> { - if (!isLogged()) { - val username = preferences.sourceUsername(this) - val password = preferences.sourcePassword(this) - - if (username.isNullOrEmpty() || password.isNullOrEmpty()) { - return Observable.error(Exception("User not logged")) - } else { - return login(username, password).flatMap { super.fetchChapterList(manga) } - } - - } else { - return super.fetchChapterList(manga) - } + return Observable.error(Exception("RIP Batoto")) } - private data class ListValue(val name: String, val value: String) { - override fun toString(): String = name + override fun fetchPageList(chapter: SChapter): Observable> { + return Observable.error(Exception("RIP Batoto")) } - private class Status : Filter.TriState("Completed") - private class Genre(name: String, val id: Int) : Filter.TriState(name) - private class TextField(name: String, val key: String) : Filter.Text(name) - private class SelectField(name: String, val key: String, values: Array, state: Int = 0) : Filter.Select(name, values, state) - private class Flag(name: String, val key: String, val valTrue: String, val valFalse: String) : Filter.CheckBox(name) - private class GenreList(genres: List>) : Filter.Group>("Genres", genres) - private class OrderBy : Filter.Sort("Order by", - arrayOf("Title", "Author", "Artist", "Rating", "Views", "Last Update"), - Filter.Sort.Selection(4, false)) - - override fun getFilterList() = FilterList( - TextField("Author", "artist_name"), - SelectField("Type", "type", arrayOf(ListValue("Any", ""), ListValue("Manga (Jp)", "jp"), ListValue("Manhwa (Kr)", "kr"), ListValue("Manhua (Cn)", "cn"), ListValue("Artbook", "ar"), ListValue("Other", "ot"))), - Status(), - Flag("Exclude mature", "mature", "m", ""), - OrderBy(), - GenreList(getGenreList()) - ) - - // [...document.querySelectorAll("#advanced_options div.genre_buttons")].map((el,i) => { - // const onClick=el.getAttribute('onclick');const id=onClick.substr(14,onClick.length-16);return `Genre("${el.textContent.trim()}", ${id})` - // }).join(',\n') - // on https://bato.to/search - private fun getGenreList() = listOf( - SelectField("Inclusion mode", "genre_cond", arrayOf(ListValue("And (all selected genres)", "and"), ListValue("Or (any selected genres) ", "or"))), - Genre("4-Koma", 40), - Genre("Action", 1), - Genre("Adventure", 2), - Genre("Award Winning", 39), - Genre("Comedy", 3), - Genre("Cooking", 41), - Genre("Doujinshi", 9), - Genre("Drama", 10), - Genre("Ecchi", 12), - Genre("Fantasy", 13), - Genre("Gender Bender", 15), - Genre("Harem", 17), - Genre("Historical", 20), - Genre("Horror", 22), - Genre("Josei", 34), - Genre("Martial Arts", 27), - Genre("Mecha", 30), - Genre("Medical", 42), - Genre("Music", 37), - Genre("Mystery", 4), - Genre("Oneshot", 38), - Genre("Psychological", 5), - Genre("Romance", 6), - Genre("School Life", 7), - Genre("Sci-fi", 8), - Genre("Seinen", 32), - Genre("Shoujo", 35), - Genre("Shoujo Ai", 16), - Genre("Shounen", 33), - Genre("Shounen Ai", 19), - Genre("Slice of Life", 21), - Genre("Smut", 23), - Genre("Sports", 25), - Genre("Supernatural", 26), - Genre("Tragedy", 28), - Genre("Webtoon", 36), - Genre("Yaoi", 29), - Genre("Yuri", 31), - Genre("[no chapters]", 44) - ) - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Mangahere.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Mangahere.kt index 82fda97db..a9ec1f083 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Mangahere.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/Mangahere.kt @@ -112,8 +112,8 @@ class Mangahere : ParsedHttpSource() { val licensedElement = document.select(".mt10.color_ff00.mb10").first() val manga = SManga.create() - manga.author = infoElement.select("a[href^=//www.mangahere.co/author/]").first()?.text() - manga.artist = infoElement.select("a[href^=//www.mangahere.co/artist/]").first()?.text() + manga.author = infoElement.select("a[href*=author/]").first()?.text() + manga.artist = infoElement.select("a[href*=artist/]").first()?.text() manga.genre = infoElement.select("li:eq(3)").first()?.text()?.substringAfter("Genre(s):") manga.description = infoElement.select("#show").first()?.text()?.substringBeforeLast("Show less") manga.thumbnail_url = detailElement.select("img.img").first()?.attr("src") diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceDividerItemDecoration.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceDividerItemDecoration.kt index b5ac65018..a4c33beb7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceDividerItemDecoration.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/SourceDividerItemDecoration.kt @@ -28,7 +28,7 @@ class SourceDividerItemDecoration(context: Context) : RecyclerView.ItemDecoratio val top = child.bottom + params.bottomMargin val bottom = top + divider.intrinsicHeight val left = parent.paddingLeft + holder.margin - val right = parent.paddingRight + holder.margin + val right = parent.width - parent.paddingRight - holder.margin divider.setBounds(left, top, right, bottom) divider.draw(c) @@ -41,4 +41,4 @@ class SourceDividerItemDecoration(context: Context) : RecyclerView.ItemDecoratio outRect.set(0, 0, 0, divider.intrinsicHeight) } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SortItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SortItem.kt index a23a7980d..87d0a501b 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SortItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/SortItem.kt @@ -33,9 +33,9 @@ class SortItem(val name: String, val group: SortGroup) : AbstractSectionableItem val i = filter.values.indexOf(name) fun getIcon() = when (filter.state) { - Filter.Sort.Selection(i, false) -> VectorDrawableCompat.create(view.resources, R.drawable.ic_arrow_down_32dp, null) + Filter.Sort.Selection(i, false) -> VectorDrawableCompat.create(view.resources, R.drawable.ic_arrow_down_white_32dp, null) ?.apply { setTint(view.context.getResourceColor(R.attr.colorAccent)) } - Filter.Sort.Selection(i, true) -> VectorDrawableCompat.create(view.resources, R.drawable.ic_arrow_up_32dp, null) + Filter.Sort.Selection(i, true) -> VectorDrawableCompat.create(view.resources, R.drawable.ic_arrow_up_white_32dp, null) ?.apply { setTint(view.context.getResourceColor(R.attr.colorAccent)) } else -> ContextCompat.getDrawable(view.context, R.drawable.empty_drawable_32dp) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt new file mode 100644 index 000000000..e8addfbae --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt @@ -0,0 +1,30 @@ +package eu.kanade.tachiyomi.ui.extension + +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.IFlexible +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.getResourceColor + +/** + * Adapter that holds the catalogue cards. + * + * @param controller instance of [ExtensionController]. + */ +class ExtensionAdapter(val controller: ExtensionController) : + FlexibleAdapter>(null, controller, true) { + + val cardBackground = controller.activity!!.getResourceColor(R.attr.background_card) + + init { + setDisplayHeadersAtStartUp(true) + } + + /** + * Listener for browse item clicks. + */ + val buttonClickListener: ExtensionAdapter.OnButtonClickListener = controller + + interface OnButtonClickListener { + fun onButtonClick(position: Int) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionController.kt new file mode 100644 index 000000000..0079c6be0 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionController.kt @@ -0,0 +1,132 @@ +package eu.kanade.tachiyomi.ui.extension + +import android.support.v7.widget.LinearLayoutManager +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.jakewharton.rxbinding.support.v4.widget.refreshes +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.IFlexible +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction +import kotlinx.android.synthetic.main.extension_controller.* + + +/** + * Controller to manage the catalogues available in the app. + */ +open class ExtensionController : NucleusController(), + ExtensionAdapter.OnButtonClickListener, + FlexibleAdapter.OnItemClickListener, + FlexibleAdapter.OnItemLongClickListener, + ExtensionTrustDialog.Listener { + + /** + * Adapter containing the list of manga from the catalogue. + */ + private var adapter: FlexibleAdapter>? = null + + init { + setHasOptionsMenu(true) + } + + override fun getTitle(): String? { + return applicationContext?.getString(R.string.label_extensions) + } + + override fun createPresenter(): ExtensionPresenter { + return ExtensionPresenter() + } + + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + return inflater.inflate(R.layout.extension_controller, container, false) + } + + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + ext_swipe_refresh.isRefreshing = true + ext_swipe_refresh.refreshes().subscribeUntilDestroy { + presenter.findAvailableExtensions() + } + + // Initialize adapter, scroll listener and recycler views + adapter = ExtensionAdapter(this) + // Create recycler and set adapter. + ext_recycler.layoutManager = LinearLayoutManager(view.context) + ext_recycler.adapter = adapter + ext_recycler.addItemDecoration(ExtensionDividerItemDecoration(view.context)) + } + + override fun onDestroyView(view: View) { + adapter = null + super.onDestroyView(view) + } + + override fun onButtonClick(position: Int) { + val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return + when (extension) { + is Extension.Installed -> { + if (!extension.hasUpdate) { + openDetails(extension) + } else { + presenter.updateExtension(extension) + } + } + is Extension.Available -> { + presenter.installExtension(extension) + } + is Extension.Untrusted -> { + openTrustDialog(extension) + } + } + } + + override fun onItemClick(position: Int): Boolean { + val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return false + if (extension is Extension.Installed) { + openDetails(extension) + } else if (extension is Extension.Untrusted) { + openTrustDialog(extension) + } + + return false + } + + override fun onItemLongClick(position: Int) { + val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return + if (extension is Extension.Installed || extension is Extension.Untrusted) { + uninstallExtension(extension.pkgName) + } + } + + private fun openDetails(extension: Extension.Installed) { + val controller = ExtensionDetailsController(extension.pkgName) + router.pushController(controller.withFadeTransaction()) + } + + private fun openTrustDialog(extension: Extension.Untrusted) { + ExtensionTrustDialog(this, extension.signatureHash, extension.pkgName) + .showDialog(router) + } + + fun setExtensions(extensions: List) { + ext_swipe_refresh?.isRefreshing = false + adapter?.updateDataSet(extensions) + } + + fun downloadUpdate(item: ExtensionItem) { + adapter?.updateItem(item, item.installStep) + } + + override fun trustSignature(signatureHash: String) { + presenter.trustSignature(signatureHash) + } + + override fun uninstallExtension(pkgName: String) { + presenter.uninstallExtension(pkgName) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDetailsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDetailsController.kt new file mode 100644 index 000000000..cd79f11d1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDetailsController.kt @@ -0,0 +1,190 @@ +package eu.kanade.tachiyomi.ui.extension + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Bundle +import android.support.v7.preference.* +import android.support.v7.preference.internal.AbstractMultiSelectListPreference +import android.support.v7.widget.DividerItemDecoration +import android.support.v7.widget.DividerItemDecoration.VERTICAL +import android.support.v7.widget.LinearLayoutManager +import android.util.TypedValue +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.jakewharton.rxbinding.view.clicks +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.EmptyPreferenceDataStore +import eu.kanade.tachiyomi.data.preference.SharedPreferencesDataStore +import eu.kanade.tachiyomi.source.ConfigurableSource +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.online.LoginSource +import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.setting.preferenceCategory +import eu.kanade.tachiyomi.widget.preference.LoginPreference +import eu.kanade.tachiyomi.widget.preference.SourceLoginDialog +import kotlinx.android.synthetic.main.extension_detail_controller.* + +@SuppressLint("RestrictedApi") +class ExtensionDetailsController(bundle: Bundle? = null) : + NucleusController(bundle), + PreferenceManager.OnDisplayPreferenceDialogListener, + DialogPreference.TargetFragment, + SourceLoginDialog.Listener { + + private var lastOpenPreferencePosition: Int? = null + + private var preferenceScreen: PreferenceScreen? = null + + constructor(pkgName: String) : this(Bundle().apply { + putString(PKGNAME_KEY, pkgName) + }) + + override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { + return inflater.inflate(R.layout.extension_detail_controller, container, false) + } + + override fun createPresenter(): ExtensionDetailsPresenter { + return ExtensionDetailsPresenter(args.getString(PKGNAME_KEY)) + } + + override fun getTitle(): String? { + return resources?.getString(R.string.label_extension_info) + } + + @SuppressLint("PrivateResource") + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + val extension = presenter.extension + val context = view.context + + extension_title.text = extension.name + extension_version.text = context.getString(R.string.ext_version_info, extension.versionName) + extension_lang.text = context.getString(R.string.ext_language_info, extension.getLocalizedLang(context)) + extension_pkg.text = extension.pkgName + extension.getApplicationIcon(context)?.let { extension_icon.setImageDrawable(it) } + extension_uninstall_button.clicks().subscribeUntilDestroy { + presenter.uninstallExtension() + } + + val themedContext by lazy { getPreferenceThemeContext() } + val manager = PreferenceManager(themedContext) + manager.preferenceDataStore = EmptyPreferenceDataStore() + manager.onDisplayPreferenceDialogListener = this + val screen = manager.createPreferenceScreen(themedContext) + preferenceScreen = screen + + val multiSource = extension.sources.size > 1 + + for (source in extension.sources) { + if (source is ConfigurableSource) { + addPreferencesForSource(screen, source, multiSource) + } + } + + manager.setPreferences(screen) + + extension_prefs_recycler.layoutManager = LinearLayoutManager(context) + extension_prefs_recycler.adapter = PreferenceGroupAdapter(screen) + extension_prefs_recycler.addItemDecoration(DividerItemDecoration(context, VERTICAL)) + + if (screen.preferenceCount == 0) { + extension_prefs_empty_view.show(R.drawable.ic_no_settings, + R.string.ext_empty_preferences) + } + } + + override fun onDestroyView(view: View) { + preferenceScreen = null + super.onDestroyView(view) + } + + fun onExtensionUninstalled() { + router.popCurrentController() + } + + override fun onSaveInstanceState(outState: Bundle) { + lastOpenPreferencePosition?.let { outState.putInt(LASTOPENPREFERENCE_KEY, it) } + super.onSaveInstanceState(outState) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + lastOpenPreferencePosition = savedInstanceState.get(LASTOPENPREFERENCE_KEY) as? Int + } + + private fun addPreferencesForSource(screen: PreferenceScreen, source: Source, multiSource: Boolean) { + val context = screen.context + + // TODO + val dataStore = SharedPreferencesDataStore(/*if (source is HttpSource) { + source.preferences + } else {*/ + context.getSharedPreferences("source_${source.id}", Context.MODE_PRIVATE) + /*}*/) + + if (source is ConfigurableSource) { + if (multiSource) { + screen.preferenceCategory { + title = source.toString() + } + } + + val newScreen = screen.preferenceManager.createPreferenceScreen(context) + source.setupPreferenceScreen(newScreen) + + for (i in 0 until newScreen.preferenceCount) { + val pref = newScreen.getPreference(i) + pref.preferenceDataStore = dataStore + pref.order = Int.MAX_VALUE // reset to default order + screen.addPreference(pref) + } + } + } + + private fun getPreferenceThemeContext(): Context { + val tv = TypedValue() + activity!!.theme.resolveAttribute(R.attr.preferenceTheme, tv, true) + return ContextThemeWrapper(activity, tv.resourceId) + } + + override fun onDisplayPreferenceDialog(preference: Preference) { + if (!isAttached) return + + val screen = preference.parent!! + + lastOpenPreferencePosition = (0 until screen.preferenceCount).indexOfFirst { + screen.getPreference(it) === preference + } + + val f = when (preference) { + is EditTextPreference -> EditTextPreferenceDialogController + .newInstance(preference.getKey()) + is ListPreference -> ListPreferenceDialogController + .newInstance(preference.getKey()) + is AbstractMultiSelectListPreference -> MultiSelectListPreferenceDialogController + .newInstance(preference.getKey()) + else -> throw IllegalArgumentException("Tried to display dialog for unknown " + + "preference type. Did you forget to override onDisplayPreferenceDialog()?") + } + f.targetController = this + f.showDialog(router) + } + + override fun findPreference(key: CharSequence?): Preference { + return preferenceScreen!!.getPreference(lastOpenPreferencePosition!!) + } + + override fun loginDialogClosed(source: LoginSource) { + val lastOpen = lastOpenPreferencePosition ?: return + (preferenceScreen?.getPreference(lastOpen) as? LoginPreference)?.notifyChanged() + } + + private companion object { + const val PKGNAME_KEY = "pkg_name" + const val LASTOPENPREFERENCE_KEY = "last_open_preference" + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDetailsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDetailsPresenter.kt new file mode 100644 index 000000000..f6a6d4d9a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDetailsPresenter.kt @@ -0,0 +1,38 @@ +package eu.kanade.tachiyomi.ui.extension + +import android.os.Bundle +import eu.kanade.tachiyomi.extension.ExtensionManager +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import rx.android.schedulers.AndroidSchedulers +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class ExtensionDetailsPresenter( + val pkgName: String, + private val extensionManager: ExtensionManager = Injekt.get() +) : BasePresenter() { + + val extension = extensionManager.installedExtensions.first { it.pkgName == pkgName } + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + + bindToUninstalledExtension() + } + + private fun bindToUninstalledExtension() { + extensionManager.getInstalledExtensionsObservable() + .skip(1) + .filter { extensions -> extensions.none { it.pkgName == pkgName } } + .map { Unit } + .take(1) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst({ view, _ -> + view.onExtensionUninstalled() + }) + } + + fun uninstallExtension() { + extensionManager.uninstallExtension(extension.pkgName) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDividerItemDecoration.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDividerItemDecoration.kt new file mode 100644 index 000000000..40fe44505 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionDividerItemDecoration.kt @@ -0,0 +1,44 @@ +package eu.kanade.tachiyomi.ui.extension + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.support.v7.widget.RecyclerView +import android.view.View + +class ExtensionDividerItemDecoration(context: Context) : RecyclerView.ItemDecoration() { + + private val divider: Drawable + + init { + val a = context.obtainStyledAttributes(intArrayOf(android.R.attr.listDivider)) + divider = a.getDrawable(0) + a.recycle() + } + + override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { + val childCount = parent.childCount + for (i in 0 until childCount - 1) { + val child = parent.getChildAt(i) + val holder = parent.getChildViewHolder(child) + if (holder is ExtensionHolder && + parent.getChildViewHolder(parent.getChildAt(i + 1)) is ExtensionHolder) { + val params = child.layoutParams as RecyclerView.LayoutParams + val top = child.bottom + params.bottomMargin + val bottom = top + divider.intrinsicHeight + val left = parent.paddingLeft + holder.margin + val right = parent.width - parent.paddingRight - holder.margin + + divider.setBounds(left, top, right, bottom) + divider.draw(c) + } + } + } + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, + state: RecyclerView.State) { + outRect.set(0, 0, 0, divider.intrinsicHeight) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupHolder.kt new file mode 100644 index 000000000..7edc3bd69 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupHolder.kt @@ -0,0 +1,20 @@ +package eu.kanade.tachiyomi.ui.extension + +import android.annotation.SuppressLint +import android.view.View +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder +import kotlinx.android.synthetic.main.extension_card_header.* + +class ExtensionGroupHolder(view: View, adapter: FlexibleAdapter<*>) : + BaseFlexibleViewHolder(view, adapter, true) { + + @SuppressLint("SetTextI18n") + fun bind(item: ExtensionGroupItem) { + title.text = when { + item.installed -> itemView.context.getString(R.string.ext_installed) + else -> itemView.context.getString(R.string.ext_available) + } + " (" + item.size + ")" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupItem.kt new file mode 100644 index 000000000..2c45f894e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupItem.kt @@ -0,0 +1,50 @@ +package eu.kanade.tachiyomi.ui.extension + +import android.view.View +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractHeaderItem +import eu.kanade.tachiyomi.R + +/** + * Item that contains the language header. + * + * @param code The lang code. + */ +data class ExtensionGroupItem(val installed: Boolean, val size: Int) : AbstractHeaderItem() { + + /** + * Returns the layout resource of this item. + */ + override fun getLayoutRes(): Int { + return R.layout.extension_card_header + } + + /** + * Creates a new view holder for this item. + */ + override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): ExtensionGroupHolder { + return ExtensionGroupHolder(view, adapter) + } + + /** + * Binds this item to the given view holder. + */ + override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: ExtensionGroupHolder, + position: Int, payloads: List?) { + + holder.bind(this) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other is ExtensionGroupItem) { + return installed == other.installed + } + return false + } + + override fun hashCode(): Int { + return installed.hashCode() + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt new file mode 100644 index 000000000..889c84a9e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt @@ -0,0 +1,88 @@ +package eu.kanade.tachiyomi.ui.extension + +import android.view.View +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.extension.model.InstallStep +import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder +import eu.kanade.tachiyomi.ui.base.holder.SlicedHolder +import io.github.mthli.slice.Slice +import kotlinx.android.synthetic.main.extension_card_item.* + +class ExtensionHolder(view: View, override val adapter: ExtensionAdapter) : + BaseFlexibleViewHolder(view, adapter), + SlicedHolder { + + override val slice = Slice(card).apply { + setColor(adapter.cardBackground) + } + + override val viewToSlice: View + get() = card + + init { + ext_button.setOnClickListener { + adapter.buttonClickListener.onButtonClick(adapterPosition) + } + } + + fun bind(item: ExtensionItem) { + val extension = item.extension + setCardEdges(item) + + // Set source name + ext_title.text = extension.name + version.text = extension.versionName + lang.text = if (extension !is Extension.Untrusted) { + extension.getLocalizedLang(itemView.context) + } else { + itemView.context.getString(R.string.ext_untrusted).toUpperCase() + } + + GlideApp.with(itemView.context).clear(image) + if (extension is Extension.Available) { + GlideApp.with(itemView.context) + .load(extension.iconUrl) + .into(image) + } else { + extension.getApplicationIcon(itemView.context)?.let { image.setImageDrawable(it) } + } + bindButton(item) + } + + fun bindButton(item: ExtensionItem) = with(ext_button) { + isEnabled = true + isClickable = true + isActivated = false + + val extension = item.extension + + val installStep = item.installStep + if (installStep != null) { + setText(when (installStep) { + InstallStep.Pending -> R.string.ext_pending + InstallStep.Downloading -> R.string.ext_downloading + InstallStep.Installing -> R.string.ext_installing + InstallStep.Installed -> R.string.ext_installed + InstallStep.Error -> R.string.action_retry + }) + if (installStep != InstallStep.Error) { + isEnabled = false + isClickable = false + } + } else if (extension is Extension.Installed) { + if (extension.hasUpdate) { + isActivated = true + setText(R.string.ext_update) + } else { + setText(R.string.ext_details) + } + } else if (extension is Extension.Untrusted) { + setText(R.string.ext_trust) + } else { + setText(R.string.ext_install) + } + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionItem.kt new file mode 100644 index 000000000..2ed363e97 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionItem.kt @@ -0,0 +1,59 @@ +package eu.kanade.tachiyomi.ui.extension + +import android.view.View +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractSectionableItem +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.extension.model.InstallStep +import eu.kanade.tachiyomi.source.CatalogueSource + +/** + * Item that contains source information. + * + * @param source Instance of [CatalogueSource] containing source information. + * @param header The header for this item. + */ +data class ExtensionItem(val extension: Extension, + val header: ExtensionGroupItem? = null, + val installStep: InstallStep? = null) : + AbstractSectionableItem(header) { + + /** + * Returns the layout resource of this item. + */ + override fun getLayoutRes(): Int { + return R.layout.extension_card_item + } + + /** + * Creates a new view holder for this item. + */ + override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): ExtensionHolder { + return ExtensionHolder(view, adapter as ExtensionAdapter) + } + + /** + * Binds this item to the given view holder. + */ + override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: ExtensionHolder, + position: Int, payloads: List?) { + + if (payloads == null || payloads.isEmpty()) { + holder.bind(this) + } else { + holder.bindButton(this) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + return extension.pkgName == (other as ExtensionItem).extension.pkgName + } + + override fun hashCode(): Int { + return extension.pkgName.hashCode() + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionPresenter.kt new file mode 100644 index 000000000..54edce30f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionPresenter.kt @@ -0,0 +1,130 @@ +package eu.kanade.tachiyomi.ui.extension + +import android.os.Bundle +import eu.kanade.tachiyomi.extension.ExtensionManager +import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.extension.model.InstallStep +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import rx.Observable +import rx.Subscription +import rx.android.schedulers.AndroidSchedulers +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.concurrent.TimeUnit + +private typealias ExtensionTuple + = Triple, List, List> + +/** + * Presenter of [ExtensionController]. + */ +open class ExtensionPresenter( + private val extensionManager: ExtensionManager = Injekt.get() +) : BasePresenter() { + + private var extensions = emptyList() + + private var currentDownloads = hashMapOf() + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + + bindToExtensionsObservable() + } + + private fun bindToExtensionsObservable(): Subscription { + val installedObservable = extensionManager.getInstalledExtensionsObservable() + val untrustedObservable = extensionManager.getUntrustedExtensionsObservable() + val availableObservable = extensionManager.getAvailableExtensionsObservable() + .startWith(emptyList()) + + return Observable.combineLatest(installedObservable, untrustedObservable, availableObservable) + { installed, untrusted, available -> Triple(installed, untrusted, available) } + .debounce(100, TimeUnit.MILLISECONDS) + .map(::toItems) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeLatestCache({ view, _ -> view.setExtensions(extensions) }) + } + + @Synchronized + private fun toItems(tuple: ExtensionTuple): List { + val (installed, untrusted, available) = tuple + + val items = mutableListOf() + + val installedSorted = installed.sortedWith(compareBy({ !it.hasUpdate }, { it.pkgName })) + val untrustedSorted = untrusted.sortedBy { it.pkgName } + val availableSorted = available + // Filter out already installed extensions + .filter { avail -> installed.none { it.pkgName == avail.pkgName } + && untrusted.none { it.pkgName == avail.pkgName } } + .sortedBy { it.pkgName } + + if (installedSorted.isNotEmpty() || untrustedSorted.isNotEmpty()) { + val header = ExtensionGroupItem(true, installedSorted.size + untrustedSorted.size) + items += installedSorted.map { extension -> + ExtensionItem(extension, header, currentDownloads[extension.pkgName]) + } + items += untrustedSorted.map { extension -> + ExtensionItem(extension, header) + } + } + if (availableSorted.isNotEmpty()) { + val header = ExtensionGroupItem(false, availableSorted.size) + items += availableSorted.map { extension -> + ExtensionItem(extension, header, currentDownloads[extension.pkgName]) + } + } + + this.extensions = items + return items + } + + @Synchronized + private fun updateInstallStep(extension: Extension, state: InstallStep): ExtensionItem? { + val extensions = extensions.toMutableList() + val position = extensions.indexOfFirst { it.extension.pkgName == extension.pkgName } + + return if (position != -1) { + val item = extensions[position].copy(installStep = state) + extensions[position] = item + + this.extensions = extensions + item + } else { + null + } + } + + fun installExtension(extension: Extension.Available) { + extensionManager.installExtension(extension).subscribeToInstallUpdate(extension) + } + + fun updateExtension(extension: Extension.Installed) { + extensionManager.updateExtension(extension).subscribeToInstallUpdate(extension) + } + + private fun Observable.subscribeToInstallUpdate(extension: Extension) { + this.doOnNext { currentDownloads[extension.pkgName] = it } + .doOnUnsubscribe { currentDownloads.remove(extension.pkgName) } + .map { state -> updateInstallStep(extension, state) } + .subscribeWithView({ view, item -> + if (item != null) { + view.downloadUpdate(item) + } + }) + } + + fun uninstallExtension(pkgName: String) { + extensionManager.uninstallExtension(pkgName) + } + + fun findAvailableExtensions() { + extensionManager.findAvailableExtensions() + } + + fun trustSignature(signatureHash: String) { + extensionManager.trustSignature(signatureHash) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionTrustDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionTrustDialog.kt new file mode 100644 index 000000000..3094e9062 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionTrustDialog.kt @@ -0,0 +1,44 @@ +package eu.kanade.tachiyomi.ui.extension + +import android.app.Dialog +import android.os.Bundle +import com.afollestad.materialdialogs.MaterialDialog +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.base.controller.DialogController + +class ExtensionTrustDialog(bundle: Bundle? = null) : DialogController(bundle) + where T : Controller, T: ExtensionTrustDialog.Listener { + + constructor(target: T, signatureHash: String, pkgName: String) : this(Bundle().apply { + putString(SIGNATURE_KEY, signatureHash) + putString(PKGNAME_KEY, pkgName) + }) { + targetController = target + } + + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + return MaterialDialog.Builder(activity!!) + .title(R.string.untrusted_extension) + .content(R.string.untrusted_extension_message) + .positiveText(R.string.ext_trust) + .negativeText(R.string.ext_uninstall) + .onPositive { _, _ -> + (targetController as? Listener)?.trustSignature(args.getString(SIGNATURE_KEY)) + } + .onNegative { _, _ -> + (targetController as? Listener)?.uninstallExtension(args.getString(PKGNAME_KEY)) + } + .build() + } + + private companion object { + const val SIGNATURE_KEY = "signature_key" + const val PKGNAME_KEY = "pkgname_key" + } + + interface Listener { + fun trustSignature(signatureHash: String) + fun uninstallExtension(pkgName: String) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionViewUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionViewUtils.kt new file mode 100644 index 000000000..f05c9e5a9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionViewUtils.kt @@ -0,0 +1,28 @@ +package eu.kanade.tachiyomi.ui.extension + +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.extension.model.Extension +import java.util.* + +fun Extension.getLocalizedLang(context: Context): String { + return when (lang) { + null -> "" + "" -> context.getString(R.string.other_source) + "all" -> context.getString(R.string.all_lang) + else -> { + val locale = Locale(lang) + locale.getDisplayName(locale).capitalize() + } + } +} + +fun Extension.getApplicationIcon(context: Context): Drawable? { + return try { + context.packageManager.getApplicationIcon(pkgName) + } catch (e: PackageManager.NameNotFoundException) { + null + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt index 50df61fc2..2502565c7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt @@ -36,7 +36,6 @@ import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.migration.MigrationController import eu.kanade.tachiyomi.util.inflate import eu.kanade.tachiyomi.util.toast -import eu.kanade.tachiyomi.widget.DrawerSwipeCloseListener import exh.favorites.FavoritesIntroDialog import exh.favorites.FavoritesSyncStatus import exh.metadata.loadAllMetadata @@ -216,11 +215,8 @@ class LibraryController( override fun createSecondaryDrawer(drawer: DrawerLayout): ViewGroup { val view = drawer.inflate(R.layout.library_drawer) as LibraryNavigationView - drawerListener = DrawerSwipeCloseListener(drawer, view).also { - drawer.addDrawerListener(it) - } navView = view - drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, Gravity.END) + drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, Gravity.END) navView?.onGroupClicked = { group -> when (group) { @@ -235,8 +231,6 @@ class LibraryController( } override fun cleanupSecondaryDrawer(drawer: DrawerLayout) { - drawerListener?.let { drawer.removeDrawerListener(it) } - drawerListener = null navView = null } @@ -317,7 +311,7 @@ class LibraryController( activity?.invalidateOptionsMenu() } - private fun onDownloadBadgeChanged(){ + private fun onDownloadBadgeChanged() { presenter.requestDownloadBadgesUpdate() } @@ -579,7 +573,7 @@ class LibraryController( private fun buildDialog() = activity?.let { MaterialDialog.Builder(it) } - + private fun showSyncProgressDialog() { favSyncDialog?.dismiss() favSyncDialog = buildDialog() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryNavigationView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryNavigationView.kt index e79503a7d..aa9f0b666 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryNavigationView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryNavigationView.kt @@ -15,7 +15,7 @@ import uy.kohesive.injekt.injectLazy * The navigation view shown in a drawer with the different options to show the library. */ class LibraryNavigationView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) -: ExtendedNavigationView(context, attrs) { + : ExtendedNavigationView(context, attrs) { /** * Preferences helper. @@ -25,7 +25,7 @@ class LibraryNavigationView @JvmOverloads constructor(context: Context, attrs: A /** * List of groups shown in the view. */ - private val groups = listOf(FilterGroup(), SortGroup(), DisplayGroup(), BadgeGroup()) + private val groups = listOf(FilterGroup(), SortGroup(), DisplayGroup(), BadgeGroup()) /** * Adapter instance. @@ -62,7 +62,6 @@ class LibraryNavigationView @JvmOverloads constructor(context: Context, attrs: A onGroupClicked(item.group) } } - } /** @@ -99,7 +98,6 @@ class LibraryNavigationView @JvmOverloads constructor(context: Context, attrs: A adapter.notifyItemChanged(item) } - } /** @@ -169,7 +167,7 @@ class LibraryNavigationView @JvmOverloads constructor(context: Context, attrs: A inner class BadgeGroup : Group { private val downloadBadge = Item.CheckboxGroup(R.string.action_display_download_badge, this) override val header = null - override val footer= null + override val footer = null override val items = listOf(downloadBadge) override fun initModels() { downloadBadge.checked = preferences.downloadBadge().getOrDefault() @@ -215,7 +213,5 @@ class LibraryNavigationView @JvmOverloads constructor(context: Context, attrs: A item.group.items.forEach { adapter.notifyItemChanged(it) } } - } - } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 0e172dba7..7f821827d 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -22,6 +22,7 @@ import eu.kanade.tachiyomi.ui.base.activity.BaseActivity import eu.kanade.tachiyomi.ui.base.controller.* import eu.kanade.tachiyomi.ui.catalogue.CatalogueController import eu.kanade.tachiyomi.ui.download.DownloadController +import eu.kanade.tachiyomi.ui.extension.ExtensionController import eu.kanade.tachiyomi.ui.library.LibraryController import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.recent_updates.RecentChaptersController @@ -95,6 +96,7 @@ class MainActivity : BaseActivity() { R.id.nav_drawer_recent_updates -> setRoot(RecentChaptersController(), id) R.id.nav_drawer_recently_read -> setRoot(RecentlyReadController(), id) R.id.nav_drawer_catalogues -> setRoot(CatalogueController(), id) + R.id.nav_drawer_extensions -> setRoot(ExtensionController(), id) // --> EXH R.id.nav_drawer_batch_add -> setRoot(BatchAddController(), id) // <-- EHX diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt index 5b4c3ba29..46a8aa72c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoController.kt @@ -460,7 +460,8 @@ class MangaInfoController : NucleusController(), * @param i The shape index to apply. Defaults to circle crop transformation. */ private fun createShortcutForShape(i: Int = 0) { - GlideApp.with(activity) + if (activity == null) return + GlideApp.with(activity!!) .asBitmap() .load(presenter.manga) .diskCacheStrategy(DiskCacheStrategy.NONE) @@ -581,4 +582,4 @@ class MangaInfoController : NucleusController(), } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt index bc0b5ec5b..bb8871097 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt @@ -15,7 +15,7 @@ class TrackAdapter(controller: TrackController) : RecyclerView.Adapter(), - TrackAdapter.OnRowClickListener, + TrackAdapter.OnClickListener, SetTrackStatusDialog.Listener, SetTrackChaptersDialog.Listener, SetTrackScoreDialog.Listener { @@ -58,12 +61,13 @@ class TrackController : NucleusController(), (parentController as? MangaController)?.setTrackingIcon(atLeastOneLink) } - fun onSearchResults(results: List) { + fun onSearchResults(results: List) { getSearchDialog()?.onSearchResults(results) } @Suppress("UNUSED_PARAMETER") fun onSearchResultsError(error: Throwable) { + Timber.e(error) getSearchDialog()?.onSearchResultsError() } @@ -80,6 +84,16 @@ class TrackController : NucleusController(), activity?.toast(error.message) } + override fun onLogoClick(position: Int) { + val track = adapter?.getItem(position)?.track ?: return + + if (track.tracking_url.isNullOrBlank()) { + activity?.toast(R.string.url_not_set) + } else { + activity?.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(track.tracking_url))) + } + } + override fun onTitleClick(position: Int) { val item = adapter?.getItem(position) ?: return TrackSearchDialog(this, item.service).showDialog(router, TAG_SEARCH_CONTROLLER) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt index 2996bc929..2f018f19d 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt @@ -7,9 +7,10 @@ import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder import kotlinx.android.synthetic.main.track_item.* class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) { - + init { val listener = adapter.rowClickListener + logo_container.setOnClickListener { listener.onLogoClick(adapterPosition) } title_container.setOnClickListener { listener.onTitleClick(adapterPosition) } status_container.setOnClickListener { listener.onStatusClick(adapterPosition) } chapters_container.setOnClickListener { listener.onChaptersClick(adapterPosition) } @@ -21,7 +22,7 @@ class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) { fun bind(item: TrackItem) { val track = item.track track_logo.setImageResource(item.service.getLogo()) - logo.setBackgroundColor(item.service.getLogoColor()) + logo_container.setBackgroundColor(item.service.getLogoColor()) if (track != null) { track_title.setTextAppearance(itemView.context, R.style.TextAppearance_Regular_Body1_Secondary) track_title.setAllCaps(false) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackPresenter.kt index bac625de3..ac8592ed9 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackPresenter.kt @@ -16,6 +16,7 @@ import rx.schedulers.Schedulers import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get + class TrackPresenter( val manga: Manga, preferences: PreferencesHelper = Injekt.get(), diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt index 31b8bf89d..c11a9bdd0 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt @@ -4,14 +4,17 @@ import android.content.Context import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter +import com.bumptech.glide.load.engine.DiskCacheStrategy import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.glide.GlideApp +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.util.gone import eu.kanade.tachiyomi.util.inflate import kotlinx.android.synthetic.main.track_search_item.view.* import java.util.* class TrackSearchAdapter(context: Context) -: ArrayAdapter(context, R.layout.track_search_item, ArrayList()) { + : ArrayAdapter(context, R.layout.track_search_item, ArrayList()) { override fun getView(position: Int, view: View?, parent: ViewGroup): View { var v = view @@ -30,7 +33,7 @@ class TrackSearchAdapter(context: Context) return v } - fun setItems(syncs: List) { + fun setItems(syncs: List) { setNotifyOnChange(false) clear() addAll(syncs) @@ -39,9 +42,38 @@ class TrackSearchAdapter(context: Context) class TrackSearchHolder(private val view: View) { - fun onSetValues(track: Track) { + fun onSetValues(track: TrackSearch) { view.track_search_title.text = track.title + view.track_search_summary.text = track.summary + GlideApp.with(view.context).clear(view.track_search_cover) + if (!track.cover_url.isNullOrEmpty()) { + GlideApp.with(view.context) + .load(track.cover_url) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .centerCrop() + .into(view.track_search_cover) + + if (track.publishing_status.isNullOrBlank()) { + view.track_search_status.gone() + view.track_search_status_result.gone() + } else { + view.track_search_status_result.text = track.publishing_status.capitalize() + } + + if (track.publishing_type.isNullOrBlank()) { + view.track_search_type.gone() + view.track_search_type_result.gone() + } else { + view.track_search_type_result.text = track.publishing_type.capitalize() + } + + if (track.start_date.isNullOrBlank()) { + view.track_search_start.gone() + view.track_search_start_result.gone() + } else { + view.track_search_start_result.text = track.start_date + } + } } } - } \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt index c9ce8cd66..691800058 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt @@ -8,6 +8,7 @@ import com.jakewharton.rxbinding.widget.itemClicks import com.jakewharton.rxbinding.widget.textChanges import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.ui.base.controller.DialogController @@ -114,11 +115,10 @@ class TrackSearchDialog : DialogController { val view = dialogView ?: return view.progress.visibility = View.VISIBLE view.track_search_list.visibility = View.GONE - trackController.presenter.search(query, service) } - fun onSearchResults(results: List) { + fun onSearchResults(results: List) { selectedItem = null val view = dialogView ?: return view.progress.visibility = View.GONE diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationPresenter.kt index b8b5f7155..8a21d009d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/migration/MigrationPresenter.kt @@ -37,7 +37,7 @@ class MigrationPresenter( override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) - db.getLibraryMangas() + db.getFavoriteMangas() .asRxObservable() .observeOn(AndroidSchedulers.mainThread()) .doOnNext { state = state.copy(sourcesWithManga = findSourcesWithManga(it)) } @@ -148,4 +148,4 @@ class MigrationPresenter( db.updateMangaFavorite(manga).executeAsBlocking() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt index 840aa50cf..597cc4eef 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt @@ -3,11 +3,7 @@ package eu.kanade.tachiyomi.ui.setting import android.Manifest.permission.WRITE_EXTERNAL_STORAGE import android.app.Activity import android.app.Dialog -import android.content.ActivityNotFoundException -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter +import android.content.* import android.net.Uri import android.os.Build import android.os.Bundle @@ -15,22 +11,17 @@ import android.support.v7.preference.PreferenceScreen import android.view.View import com.afollestad.materialdialogs.MaterialDialog import com.hippo.unifile.UniFile -import com.nononsenseapps.filepicker.FilePickerActivity import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.backup.BackupConst import eu.kanade.tachiyomi.data.backup.BackupCreateService import eu.kanade.tachiyomi.data.backup.BackupCreatorJob import eu.kanade.tachiyomi.data.backup.BackupRestoreService import eu.kanade.tachiyomi.data.backup.models.Backup -import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe import eu.kanade.tachiyomi.util.* -import eu.kanade.tachiyomi.widget.CustomLayoutPickerActivity -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get import java.io.File import java.util.concurrent.TimeUnit import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys @@ -464,4 +455,4 @@ class SettingsBackupController : SettingsController() { const val TAG_RESTORING_BACKUP_DIALOG = "RestoringBackupDialog" } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt index 0fc58ec00..1b6119b13 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt @@ -12,7 +12,6 @@ import android.support.v4.content.ContextCompat import android.support.v7.preference.PreferenceScreen import com.afollestad.materialdialogs.MaterialDialog import com.hippo.unifile.UniFile -import com.nononsenseapps.filepicker.FilePickerActivity import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper @@ -20,7 +19,6 @@ import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.util.DiskUtil import eu.kanade.tachiyomi.util.getFilePicker -import eu.kanade.tachiyomi.widget.CustomLayoutPickerActivity import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy @@ -63,14 +61,6 @@ class SettingsDownloadController : SettingsController() { titleRes = R.string.pref_download_only_over_wifi defaultValue = true } - intListPreference { - key = Keys.downloadThreads - titleRes = R.string.pref_download_slots - entries = arrayOf("1", "2", "3") - entryValues = arrayOf("1", "2", "3") - defaultValue = "1" - summary = "%s" - } preferenceCategory { titleRes = R.string.pref_remove_after_read @@ -206,4 +196,4 @@ class SettingsDownloadController : SettingsController() { const val DOWNLOAD_DIR_PRE_L = 103 const val DOWNLOAD_DIR_L = 104 } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ChapterSourceSync.kt b/app/src/main/java/eu/kanade/tachiyomi/util/ChapterSourceSync.kt index 36252f610..ccd9f14cc 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/util/ChapterSourceSync.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/ChapterSourceSync.kt @@ -49,13 +49,16 @@ fun syncChaptersWithSource(db: DatabaseHelper, // Add the chapter if not in db already, or update if the metadata changed. if (dbChapter == null) { toAdd.add(sourceChapter) - } else if (dbChapter.scanlator != sourceChapter.scanlator || - dbChapter.name != sourceChapter.name) { - - dbChapter.scanlator = sourceChapter.scanlator - dbChapter.name = sourceChapter.name - - toChange.add(dbChapter) + } else { + //this forces metadata update for the main viewable things in the chapter list + ChapterRecognition.parseChapterNumber(sourceChapter, manga) + if (shouldUpdateDbChapter(dbChapter, sourceChapter)) { + dbChapter.scanlator = sourceChapter.scanlator + dbChapter.name = sourceChapter.name + dbChapter.date_upload = sourceChapter.date_upload + dbChapter.chapter_number = sourceChapter.chapter_number + toChange.add(dbChapter) + } } } @@ -123,3 +126,10 @@ fun syncChaptersWithSource(db: DatabaseHelper, return Pair(toAdd.subtract(readded).toList(), toDelete.subtract(readded).toList()) } + +//checks if the chapter in db needs updated +private fun shouldUpdateDbChapter(dbChapter: Chapter, sourceChapter: SChapter): Boolean { + return dbChapter.scanlator != sourceChapter.scanlator || dbChapter.name != sourceChapter.name || + dbChapter.date_upload != sourceChapter.date_upload || + dbChapter.chapter_number != sourceChapter.chapter_number +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/CoroutinesExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/CoroutinesExtensions.kt index 8959794a1..a5313ac1f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/CoroutinesExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/CoroutinesExtensions.kt @@ -7,7 +7,7 @@ import kotlinx.coroutines.experimental.android.UI import kotlinx.coroutines.experimental.launch fun launchUI(block: suspend CoroutineScope.() -> Unit): Job = - launch(UI, CoroutineStart.DEFAULT, block) + launch(UI, CoroutineStart.DEFAULT, null, block) fun launchNow(block: suspend CoroutineScope.() -> Unit): Job = - launch(UI, CoroutineStart.UNDISPATCHED, block) \ No newline at end of file + launch(UI, CoroutineStart.UNDISPATCHED, null, block) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt index ce231756c..eb90b38a1 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt @@ -10,8 +10,6 @@ import android.support.v4.os.EnvironmentCompat import java.io.File import java.io.InputStream import java.net.URLConnection -import java.security.MessageDigest -import java.security.NoSuchAlgorithmException object DiskUtil { @@ -52,16 +50,7 @@ object DiskUtil { } fun hashKeyForDisk(key: String): String { - return try { - val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) - val sb = StringBuilder() - bytes.forEach { byte -> - sb.append(Integer.toHexString(byte.toInt() and 0xFF or 0x100).substring(1, 3)) - } - sb.toString() - } catch (e: NoSuchAlgorithmException) { - key.hashCode().toString() - } + return Hash.md5(key) } fun getDirectorySize(f: File): Long { diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/DynamicConcurrentMergeOperator.java b/app/src/main/java/eu/kanade/tachiyomi/util/DynamicConcurrentMergeOperator.java deleted file mode 100755 index 2b18e417b..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/util/DynamicConcurrentMergeOperator.java +++ /dev/null @@ -1,196 +0,0 @@ -package eu.kanade.tachiyomi.util; - -import java.util.Queue; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; - -import rx.Observable; -import rx.Observable.Operator; -import rx.Subscriber; -import rx.Subscription; -import rx.functions.Action0; -import rx.functions.Action1; -import rx.functions.Func1; -import rx.subscriptions.CompositeSubscription; -import rx.subscriptions.Subscriptions; - -public class DynamicConcurrentMergeOperator implements Operator { - private final Func1> mapper; - private final Observable workerCount; - - public DynamicConcurrentMergeOperator( - Func1> mapper, - Observable workerCount) { - this.mapper = mapper; - this.workerCount = workerCount; - } - - @Override - public Subscriber call(Subscriber t) { - DynamicConcurrentMerge parent = new DynamicConcurrentMerge<>(t, mapper); - t.add(parent); - parent.init(workerCount); - - return parent; - } - - static final class DynamicConcurrentMerge extends Subscriber { - private final Subscriber actual; - private final Func1> mapper; - private final Queue queue; - private final CopyOnWriteArrayList> workers; - private final CompositeSubscription composite; - private final AtomicInteger wipActive; - private final AtomicBoolean once; - private long id; - - public DynamicConcurrentMerge(Subscriber actual, - Func1> mapper) { - this.actual = actual; - this.mapper = mapper; - this.queue = new ConcurrentLinkedQueue<>(); - this.workers = new CopyOnWriteArrayList<>(); - this.composite = new CompositeSubscription(); - this.wipActive = new AtomicInteger(1); - this.once = new AtomicBoolean(); - this.add(composite); - this.request(0); - } - - public void init(Observable workerCount) { - Subscription wc = workerCount.subscribe(new Action1() { - @Override - public void call(Integer n) { - int n0 = workers.size(); - if (n0 < n) { - for (int i = n0; i < n; i++) { - DynamicWorker dw = new DynamicWorker<>(++id, DynamicConcurrentMerge.this); - workers.add(dw); - DynamicConcurrentMerge.this.request(1); - dw.tryNext(); - } - } else if (n0 > n) { - for (int i = 0; i < n; i++) { - workers.get(i).start(); - } - - for (int i = n0 - 1; i >= n; i--) { - workers.get(i).stop(); - } - } - - if (!once.get() && once.compareAndSet(false, true)) { - DynamicConcurrentMerge.this.request(n); - } - } - }, new Action1() { - @Override - public void call(Throwable e) {DynamicConcurrentMerge.this.onError(e);} - }); - - composite.add(wc); - } - - void requestMore(long n) { - request(n); - } - - @Override - public void onNext(T t) { - queue.offer(t); - wipActive.getAndIncrement(); - for (DynamicWorker w : workers) { - w.tryNext(); - } - } - - @Override - public void onError(Throwable e) { - composite.unsubscribe(); - actual.onError(e); - } - - @Override - public void onCompleted() { - if (wipActive.decrementAndGet() == 0) { - actual.onCompleted(); - } - } - } - - static final class DynamicWorker { - private final long id; - private final AtomicBoolean running; - private final DynamicConcurrentMerge parent; - private final AtomicBoolean stop; - - public DynamicWorker(long id, DynamicConcurrentMerge parent) { - this.id = id; - this.parent = parent; - this.stop = new AtomicBoolean(); - this.running = new AtomicBoolean(); - } - - public void tryNext() { - if (!running.get() && running.compareAndSet(false, true)) { - T t; - if (stop.get()) { - parent.workers.remove(this); - return; - } - t = parent.queue.poll(); - if (t == null) { - running.set(false); - return; - } - - Observable out = parent.mapper.call(t); - - final Subscriber s = new Subscriber() { - @Override - public void onNext(R t) { - parent.actual.onNext(t); - } - - @Override - public void onError(Throwable e) { - parent.onError(e); - } - - @Override - public void onCompleted() { - parent.onCompleted(); - if (parent.wipActive.get() != 0) { - running.set(false); - parent.requestMore(1); - tryNext(); - } - } - }; - - parent.composite.add(s); - s.add(Subscriptions.create(new Action0() { - @Override - public void call() {parent.composite.remove(s);} - })); - - out.subscribe(s); - } - } - - public void start() { - stop.set(false); - tryNext(); - } - - public void stop() { - stop.set(true); - if (running.compareAndSet(false, true)) { - parent.workers.remove(this); - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/Hash.kt b/app/src/main/java/eu/kanade/tachiyomi/util/Hash.kt new file mode 100644 index 000000000..eb89b0431 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/Hash.kt @@ -0,0 +1,42 @@ +package eu.kanade.tachiyomi.util + +import java.security.MessageDigest + +object Hash { + + private val chars = charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'a', 'b', 'c', 'd', 'e', 'f') + + private val MD5 get() = MessageDigest.getInstance("MD5") + + private val SHA256 get() = MessageDigest.getInstance("SHA-256") + + fun sha256(bytes: ByteArray): String { + return encodeHex(SHA256.digest(bytes)) + } + + fun sha256(string: String): String { + return sha256(string.toByteArray()) + } + + fun md5(bytes: ByteArray): String { + return encodeHex(MD5.digest(bytes)) + } + + fun md5(string: String): String { + return md5(string.toByteArray()) + } + + private fun encodeHex(data: ByteArray): String { + val l = data.size + val out = CharArray(l shl 1) + var i = 0 + var j = 0 + while (i < l) { + out[j++] = chars[(240 and data[i].toInt()).ushr(4)] + out[j++] = chars[15 and data[i].toInt()] + i++ + } + return String(out) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/ExtendedNavigationView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/ExtendedNavigationView.kt index baa26946c..27db7bfbb 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/ExtendedNavigationView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/ExtendedNavigationView.kt @@ -9,7 +9,6 @@ import android.support.v7.widget.RecyclerView import android.util.AttributeSet import android.view.View import android.view.ViewGroup -import android.widget.TextView import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.util.getResourceColor @@ -21,7 +20,7 @@ open class ExtendedNavigationView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) -: SimpleNavigationView(context, attrs, defStyleAttr) { + : SimpleNavigationView(context, attrs, defStyleAttr) { /** * Every item of the nav view. Generic items must belong to this list, custom items could be @@ -100,8 +99,8 @@ open class ExtendedNavigationView @JvmOverloads constructor( override fun getStateDrawable(context: Context): Drawable? { return when (state) { - SORT_ASC -> tintVector(context, R.drawable.ic_keyboard_arrow_up_black_32dp) - SORT_DESC -> tintVector(context, R.drawable.ic_keyboard_arrow_down_black_32dp) + SORT_ASC -> tintVector(context, R.drawable.ic_arrow_up_white_32dp) + SORT_DESC -> tintVector(context, R.drawable.ic_arrow_down_white_32dp) SORT_NONE -> ContextCompat.getDrawable(context, R.drawable.empty_drawable_32dp) else -> null } @@ -206,9 +205,8 @@ open class ExtendedNavigationView @JvmOverloads constructor( override fun onBindViewHolder(holder: Holder, position: Int) { when (holder) { is HeaderHolder -> { - val view = holder.itemView as TextView val item = items[position] as Item.Header - view.setText(item.resTitle) + holder.title.setText(item.resTitle) } is SeparatorHolder -> { val view = holder.itemView diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleNavigationView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleNavigationView.kt index efae6f62f..31fcec901 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleNavigationView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleNavigationView.kt @@ -22,7 +22,7 @@ open class SimpleNavigationView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) -: ScrimInsetsFrameLayout(context, attrs, defStyleAttr) { + : ScrimInsetsFrameLayout(context, attrs, defStyleAttr) { /** * Max width of the navigation view. @@ -89,7 +89,11 @@ open class SimpleNavigationView @JvmOverloads constructor( * Header view holder. */ class HeaderHolder(parent: ViewGroup) - : Holder(parent.inflate(R.layout.design_navigation_item_subheader)) + : Holder(parent.inflate(TR.layout.navigation_view_group)){ + + val title: TextView = itemView.findViewById(TR.id.title) + } + /** * Clickable view holder. diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/StateImageViewTarget.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/StateImageViewTarget.kt index 75d6e74c8..2917879b9 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/StateImageViewTarget.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/StateImageViewTarget.kt @@ -55,10 +55,10 @@ class StateImageViewTarget(view: ImageView, super.onLoadCleared(placeholder) } - override fun onResourceReady(resource: Drawable?, transition: Transition?) { + override fun onResourceReady(resource: Drawable, transition: Transition?) { progress?.gone() view.scaleType = imageScaleType super.onResourceReady(resource, transition) this.resource = resource } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/IntListPreference.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/IntListPreference.kt index 2eb95e2ec..085cfd004 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/IntListPreference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/IntListPreference.kt @@ -12,10 +12,15 @@ class IntListPreference @JvmOverloads constructor(context: Context, attrs: Attri } override fun getPersistedString(defaultReturnValue: String?): String? { - if (sharedPreferences.contains(key)) { - return getPersistedInt(0).toString() + // When the underlying preference is using a PreferenceDataStore, there's no way (for now) + // to check if a value is in the store, so we use a most likely unused value as workaround + val defaultIntValue = Int.MIN_VALUE + 1 + + val value = getPersistedInt(defaultIntValue) + return if (value != defaultIntValue) { + value.toString() } else { - return defaultReturnValue + defaultReturnValue } } } \ No newline at end of file diff --git a/app/src/main/java/exh/captcha/SolveCaptchaActivity.kt b/app/src/main/java/exh/captcha/SolveCaptchaActivity.kt new file mode 100644 index 000000000..113f94d64 --- /dev/null +++ b/app/src/main/java/exh/captcha/SolveCaptchaActivity.kt @@ -0,0 +1,23 @@ +package exh.captcha + +import android.os.Bundle +import android.support.v7.app.AppCompatActivity + +class SolveCaptchaActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val sourceId = intent.getIntExtra(SOURCE_ID_EXTRA, -1) + val source = sourc + + if(sourceId == -1) { + finish() + return + } + } + + companion object { + const val SOURCE_ID_EXTRA = "source_id_extra" + } +} + diff --git a/app/src/main/res/drawable/button_bg_transparent.xml b/app/src/main/res/drawable/button_bg_transparent.xml new file mode 100644 index 000000000..84577aa47 --- /dev/null +++ b/app/src/main/res/drawable/button_bg_transparent.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_down_32dp.xml b/app/src/main/res/drawable/ic_arrow_down_white_32dp.xml similarity index 100% rename from app/src/main/res/drawable/ic_arrow_down_32dp.xml rename to app/src/main/res/drawable/ic_arrow_down_white_32dp.xml diff --git a/app/src/main/res/drawable/ic_arrow_up_32dp.xml b/app/src/main/res/drawable/ic_arrow_up_white_32dp.xml similarity index 100% rename from app/src/main/res/drawable/ic_arrow_up_32dp.xml rename to app/src/main/res/drawable/ic_arrow_up_white_32dp.xml diff --git a/app/src/main/res/drawable/ic_extension_black_24dp.xml b/app/src/main/res/drawable/ic_extension_black_24dp.xml new file mode 100644 index 000000000..d3dd09481 --- /dev/null +++ b/app/src/main/res/drawable/ic_extension_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_no_settings.xml b/app/src/main/res/drawable/ic_no_settings.xml new file mode 100644 index 000000000..71acd27e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_no_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/eh_activity_captcha.xml b/app/src/main/res/layout/eh_activity_captcha.xml new file mode 100644 index 000000000..26fa8d825 --- /dev/null +++ b/app/src/main/res/layout/eh_activity_captcha.xml @@ -0,0 +1,22 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/extension_card_header.xml b/app/src/main/res/layout/extension_card_header.xml new file mode 100644 index 000000000..2c2e11fc9 --- /dev/null +++ b/app/src/main/res/layout/extension_card_header.xml @@ -0,0 +1,24 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/extension_card_item.xml b/app/src/main/res/layout/extension_card_item.xml new file mode 100644 index 000000000..6acbe21ba --- /dev/null +++ b/app/src/main/res/layout/extension_card_item.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + +