Add Tsumino captcha display and merge branch 'master' of upstream
# Conflicts: # .github/readme-images/app-icon.png # .github/readme-images/screens.png # .travis.yml # README.md # app/build.gradle # app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt # app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt # app/src/main/java/eu/kanade/tachiyomi/source/online/YamlHttpSource.kt # app/src/main/java/eu/kanade/tachiyomi/source/online/YamlHttpSourceMappings.kt # app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt # app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt # app/src/main/java/eu/kanade/tachiyomi/util/DynamicConcurrentMergeOperator.java
This commit is contained in:
commit
a71ae29c98
14
.github/ISSUE_TEMPLATE.md
vendored
14
.github/ISSUE_TEMPLATE.md
vendored
@ -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:**
|
BIN
.github/readme-images/app-icon.png
vendored
Normal file
BIN
.github/readme-images/app-icon.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
@ -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
|
||||
}
|
||||
|
@ -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<ExtensionManager>().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<PreferencesHelper>() }
|
||||
|
||||
rxAsync { get<NetworkHelper>() }
|
||||
|
||||
rxAsync { get<SourceManager>() }
|
||||
|
||||
rxAsync { get<DatabaseHelper>() }
|
||||
|
||||
rxAsync { get<DownloadManager>() }
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
private fun rxAsync(block: () -> Unit) {
|
||||
Observable.fromCallable { block() }.subscribeOn(Schedulers.computation()).subscribe()
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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<Uri>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -13,9 +13,10 @@ class BackupCreatorJob : Job() {
|
||||
|
||||
override fun onRunJob(params: Params): Result {
|
||||
val preferences = Injekt.get<PreferencesHelper>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
*
|
||||
|
@ -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<TrackImpl> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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<Track>(
|
||||
@ -40,7 +41,7 @@ class TrackPutResolver : DefaultPutResolver<Track>() {
|
||||
.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<Track>() {
|
||||
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<Track>() {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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 ''"
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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<Int>()
|
||||
|
||||
/**
|
||||
* 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<Download, Download>({ 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<Download> {
|
||||
private fun downloadChapter(download: Download): Observable<Download> = 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 }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -79,7 +79,7 @@ class MangaModelLoader : ModelLoader<Manga, InputStream> {
|
||||
* @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<InputStream>? {
|
||||
options: Options): ModelLoader.LoadData<InputStream>? {
|
||||
// Check thumbnail is not null or empty
|
||||
val url = manga.thumbnail_url
|
||||
if (url == null || url.isEmpty()) {
|
||||
|
@ -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<String>?): Set<String>? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun putStringSet(key: String?, values: Set<String>?) {
|
||||
}
|
||||
}
|
@ -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"
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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<String>?): MutableSet<String> {
|
||||
return prefs.getStringSet(key, defValues)
|
||||
}
|
||||
|
||||
override fun putStringSet(key: String?, values: MutableSet<String>?) {
|
||||
prefs.edit().putStringSet(key, values).apply()
|
||||
}
|
||||
}
|
@ -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<Track>
|
||||
|
||||
abstract fun search(query: String): Observable<List<Track>>
|
||||
abstract fun search(query: String): Observable<List<TrackSearch>>
|
||||
|
||||
abstract fun refresh(track: Track): Observable<Track>
|
||||
|
||||
|
@ -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<List<Track>> {
|
||||
override fun search(query: String): Observable<List<TrackSearch>> {
|
||||
return api.search(query)
|
||||
}
|
||||
|
||||
|
@ -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<List<Track>> {
|
||||
fun search(query: String): Observable<List<TrackSearch>> {
|
||||
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")
|
||||
|
@ -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")
|
||||
}
|
@ -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<List<Track>> {
|
||||
override fun search(query: String): Observable<List<TrackSearch>> {
|
||||
return api.search(query)
|
||||
}
|
||||
|
||||
|
@ -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<List<Track>> {
|
||||
fun search(query: String): Observable<List<TrackSearch>> {
|
||||
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",
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -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<List<Track>> {
|
||||
override fun search(query: String): Observable<List<TrackSearch>> {
|
||||
return api.search(query, getUsername())
|
||||
}
|
||||
|
||||
|
@ -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<List<Track>> {
|
||||
fun search(query: String, username: String): Observable<List<TrackSearch>> {
|
||||
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<List<Track>> {
|
||||
fun getList(username: String): Observable<List<TrackSearch>> {
|
||||
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"
|
||||
|
@ -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<Extension.Installed>>()
|
||||
|
||||
/**
|
||||
* List of the currently installed extensions.
|
||||
*/
|
||||
var installedExtensions = emptyList<Extension.Installed>()
|
||||
private set(value) {
|
||||
field = value
|
||||
installedExtensionsRelay.call(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Relay used to notify the available extensions.
|
||||
*/
|
||||
private val availableExtensionsRelay = BehaviorRelay.create<List<Extension.Available>>()
|
||||
|
||||
/**
|
||||
* List of the currently available extensions.
|
||||
*/
|
||||
var availableExtensions = emptyList<Extension.Available>()
|
||||
private set(value) {
|
||||
field = value
|
||||
availableExtensionsRelay.call(value)
|
||||
setUpdateFieldOfInstalledExtensions(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Relay used to notify the untrusted extensions.
|
||||
*/
|
||||
private val untrustedExtensionsRelay = BehaviorRelay.create<List<Extension.Untrusted>>()
|
||||
|
||||
/**
|
||||
* List of the currently untrusted extensions.
|
||||
*/
|
||||
var untrustedExtensions = emptyList<Extension.Untrusted>()
|
||||
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<LoadResult.Success>()
|
||||
.map { it.extension }
|
||||
installedExtensions
|
||||
.flatMap { it.sources }
|
||||
// overwrite is needed until the bundled sources are removed
|
||||
.forEach { sourceManager.registerSource(it, true) }
|
||||
|
||||
untrustedExtensions = extensions
|
||||
.filterIsInstance<LoadResult.Untrusted>()
|
||||
.map { it.extension }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the relay of the installed extensions as an observable.
|
||||
*/
|
||||
fun getInstalledExtensionsObservable(): Observable<List<Extension.Installed>> {
|
||||
return installedExtensionsRelay.asObservable()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the relay of the available extensions as an observable.
|
||||
*/
|
||||
fun getAvailableExtensionsObservable(): Observable<List<Extension.Available>> {
|
||||
if (!availableExtensionsRelay.hasValue()) {
|
||||
findAvailableExtensions()
|
||||
}
|
||||
return availableExtensionsRelay.asObservable()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the relay of the untrusted extensions as an observable.
|
||||
*/
|
||||
fun getUntrustedExtensionsObservable(): Observable<List<Extension.Untrusted>> {
|
||||
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<Extension.Available>) {
|
||||
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<InstallStep> {
|
||||
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<InstallStep> {
|
||||
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
|
||||
}
|
||||
|
||||
}
|
@ -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<List<Extension.Available>> {
|
||||
val call = GET("$repoUrl/index.json")
|
||||
|
||||
return client.newCall(call).asObservableSuccess()
|
||||
.map(::parseResponse)
|
||||
}
|
||||
|
||||
private fun parseResponse(response: Response): List<Extension.Available> {
|
||||
val text = response.body()?.use { it.string() } ?: return emptyList()
|
||||
|
||||
val json = gson.fromJson<JsonArray>(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}"
|
||||
}
|
||||
}
|
@ -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<Source>,
|
||||
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()
|
||||
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -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<String, Long>()
|
||||
|
||||
/**
|
||||
* Relay used to notify the installation step of every download.
|
||||
*/
|
||||
private val downloadsRelay = PublishRelay.create<Pair<Long, InstallStep>>()
|
||||
|
||||
/**
|
||||
* 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<InstallStep> {
|
||||
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<InstallStep> {
|
||||
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"
|
||||
}
|
||||
|
||||
}
|
@ -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<String>() +
|
||||
Injekt.get<PreferencesHelper>().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<LoadResult> {
|
||||
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<CatalogueSource>()
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package eu.kanade.tachiyomi.source
|
||||
|
||||
import android.support.v7.preference.PreferenceScreen
|
||||
|
||||
interface ConfigurableSource : Source {
|
||||
|
||||
fun setupPreferenceScreen(screen: PreferenceScreen)
|
||||
}
|
@ -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<Long, Source>()
|
||||
|
||||
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<HttpSource>()
|
||||
|
||||
fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>()
|
||||
|
||||
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<Source> = listOf(
|
||||
LocalSource(context),
|
||||
Batoto(),
|
||||
Mangahere(),
|
||||
Mangafox(),
|
||||
Kissmanga(),
|
||||
Readmanga(),
|
||||
Mintmanga(),
|
||||
Mangachan(),
|
||||
Readmangatoday(),
|
||||
Mangasee(),
|
||||
WieManga()
|
||||
)
|
||||
|
||||
private fun createEHSources(): List<Source> {
|
||||
val exSrcs = mutableListOf<HttpSource>(
|
||||
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<Source> {
|
||||
val sources = mutableListOf<Source>()
|
||||
|
||||
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<Source> {
|
||||
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<Source>()
|
||||
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<Source> {
|
||||
// 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<String>)
|
||||
|
||||
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<Long, Source>()
|
||||
|
||||
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<HttpSource>()
|
||||
|
||||
fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>()
|
||||
|
||||
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<Source> = listOf(
|
||||
LocalSource(context),
|
||||
Batoto(),
|
||||
Mangahere(),
|
||||
Mangafox(),
|
||||
Kissmanga(),
|
||||
Readmanga(),
|
||||
Mintmanga(),
|
||||
Mangachan(),
|
||||
Readmangatoday(),
|
||||
Mangasee(),
|
||||
WieManga()
|
||||
)
|
||||
|
||||
private fun createEHSources(): List<Source> {
|
||||
val exSrcs = mutableListOf<HttpSource>(
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -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<Application>().getSharedPreferences("source_$id", Context.MODE_PRIVATE)
|
||||
// }
|
||||
|
||||
/**
|
||||
* Base url of the website without the trailing slash, like: http://mysite.com
|
||||
|
@ -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<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
|
||||
val chapters = mutableListOf<SChapter>()
|
||||
with(map.chapters) {
|
||||
val pool = emptyMap<String, Element>()
|
||||
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<Page> {
|
||||
val body = response.body()!!.string()
|
||||
val url = response.request().url().toString()
|
||||
|
||||
val pages = mutableListOf<Page>()
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
@ -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<String, Any?>
|
||||
|
||||
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<String, Any?>
|
||||
|
||||
val url: String
|
||||
get() = map["url"] as String
|
||||
|
||||
val method: String?
|
||||
get() = map["method"] as? String
|
||||
|
||||
val payload: Map<String, String>?
|
||||
get() = map["payload"] as? Map<String, String>
|
||||
|
||||
fun createForm(): RequestBody {
|
||||
return FormBody.Builder().apply {
|
||||
payload?.let {
|
||||
for ((key, value) in it) {
|
||||
add(key, value)
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class PopularNode(override val map: Map<String, Any?>): 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<String, Any?>): 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<String, Any?>): 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<String, Any?>) {
|
||||
|
||||
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<String, Any?>) {
|
||||
|
||||
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<String, Any?>) {
|
||||
|
||||
fun get(document: Document) = map.mapValues { document.select(it.value as String).first() }
|
||||
}
|
||||
|
||||
open class SelectableNode(private val map: Map<String, Any?>) {
|
||||
|
||||
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, Element>): 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<String, Any?>) : 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<String, Element>): 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<String, Any?>) : SelectableNode(map) {
|
||||
|
||||
override val attr: String?
|
||||
get() = map["attr"] as? String ?: "src"
|
||||
}
|
||||
|
||||
class DateNode(private val map: Map<String, Any?>) : SelectableNode(map) {
|
||||
|
||||
val format: String by map
|
||||
|
||||
fun getDate(document: Element, cache: Map<String, Element>, 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<String>)?.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<String, Any?>) {
|
||||
|
||||
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"
|
||||
|
||||
}
|
@ -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<String, Int>().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<SChapter> {
|
||||
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<Page> {
|
||||
val pages = mutableListOf<Page>()
|
||||
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<Response> {
|
||||
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<SManga> {
|
||||
return Observable.error(Exception("RIP Batoto"))
|
||||
}
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
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<List<Page>> {
|
||||
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<ListValue>, state: Int = 0) : Filter.Select<ListValue>(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<*>>) : Filter.Group<Filter<*>>("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)
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -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")
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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<IFlexible<*>>(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)
|
||||
}
|
||||
}
|
@ -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<ExtensionPresenter>(),
|
||||
ExtensionAdapter.OnButtonClickListener,
|
||||
FlexibleAdapter.OnItemClickListener,
|
||||
FlexibleAdapter.OnItemLongClickListener,
|
||||
ExtensionTrustDialog.Listener {
|
||||
|
||||
/**
|
||||
* Adapter containing the list of manga from the catalogue.
|
||||
*/
|
||||
private var adapter: FlexibleAdapter<IFlexible<*>>? = 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<ExtensionItem>) {
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
@ -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<ExtensionDetailsPresenter>(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"
|
||||
}
|
||||
|
||||
}
|
@ -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<ExtensionDetailsController>() {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -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 + ")"
|
||||
}
|
||||
}
|
@ -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<ExtensionGroupHolder>() {
|
||||
|
||||
/**
|
||||
* 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<Any?>?) {
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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<ExtensionHolder, ExtensionGroupItem>(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<Any?>?) {
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
}
|
@ -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<Extension.Installed>, List<Extension.Untrusted>, List<Extension.Available>>
|
||||
|
||||
/**
|
||||
* Presenter of [ExtensionController].
|
||||
*/
|
||||
open class ExtensionPresenter(
|
||||
private val extensionManager: ExtensionManager = Injekt.get()
|
||||
) : BasePresenter<ExtensionController>() {
|
||||
|
||||
private var extensions = emptyList<ExtensionItem>()
|
||||
|
||||
private var currentDownloads = hashMapOf<String, InstallStep>()
|
||||
|
||||
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<Extension.Available>())
|
||||
|
||||
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<ExtensionItem> {
|
||||
val (installed, untrusted, available) = tuple
|
||||
|
||||
val items = mutableListOf<ExtensionItem>()
|
||||
|
||||
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<InstallStep>.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)
|
||||
}
|
||||
|
||||
}
|
@ -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<T>(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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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()
|
||||
|
@ -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) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
|
@ -460,7 +460,8 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
|
||||
* @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<MangaInfoPresenter>(),
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -15,7 +15,7 @@ class TrackAdapter(controller: TrackController) : RecyclerView.Adapter<TrackHold
|
||||
}
|
||||
}
|
||||
|
||||
val rowClickListener: OnRowClickListener = controller
|
||||
val rowClickListener: OnClickListener = controller
|
||||
|
||||
fun getItem(index: Int): TrackItem? {
|
||||
return items.getOrNull(index)
|
||||
@ -34,7 +34,8 @@ class TrackAdapter(controller: TrackController) : RecyclerView.Adapter<TrackHold
|
||||
holder.bind(items[position])
|
||||
}
|
||||
|
||||
interface OnRowClickListener {
|
||||
interface OnClickListener {
|
||||
fun onLogoClick(position: Int)
|
||||
fun onTitleClick(position: Int)
|
||||
fun onStatusClick(position: Int)
|
||||
fun onChaptersClick(position: Int)
|
||||
|
@ -1,19 +1,22 @@
|
||||
package eu.kanade.tachiyomi.ui.manga.track
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
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.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaController
|
||||
import eu.kanade.tachiyomi.util.toast
|
||||
import kotlinx.android.synthetic.main.track_controller.*
|
||||
import timber.log.Timber
|
||||
|
||||
class TrackController : NucleusController<TrackPresenter>(),
|
||||
TrackAdapter.OnRowClickListener,
|
||||
TrackAdapter.OnClickListener,
|
||||
SetTrackStatusDialog.Listener,
|
||||
SetTrackChaptersDialog.Listener,
|
||||
SetTrackScoreDialog.Listener {
|
||||
@ -58,12 +61,13 @@ class TrackController : NucleusController<TrackPresenter>(),
|
||||
(parentController as? MangaController)?.setTrackingIcon(atLeastOneLink)
|
||||
}
|
||||
|
||||
fun onSearchResults(results: List<Track>) {
|
||||
fun onSearchResults(results: List<TrackSearch>) {
|
||||
getSearchDialog()?.onSearchResults(results)
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun onSearchResultsError(error: Throwable) {
|
||||
Timber.e(error)
|
||||
getSearchDialog()?.onSearchResultsError()
|
||||
}
|
||||
|
||||
@ -80,6 +84,16 @@ class TrackController : NucleusController<TrackPresenter>(),
|
||||
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)
|
||||
|
@ -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)
|
||||
|
@ -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(),
|
||||
|
@ -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<Track>(context, R.layout.track_search_item, ArrayList<Track>()) {
|
||||
: ArrayAdapter<TrackSearch>(context, R.layout.track_search_item, ArrayList<TrackSearch>()) {
|
||||
|
||||
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<Track>) {
|
||||
fun setItems(syncs: List<TrackSearch>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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<Track>) {
|
||||
fun onSearchResults(results: List<TrackSearch>) {
|
||||
selectedItem = null
|
||||
val view = dialogView ?: return
|
||||
view.progress.visibility = View.GONE
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
@ -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)
|
||||
launch(UI, CoroutineStart.UNDISPATCHED, null, block)
|
||||
|
@ -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 {
|
||||
|
@ -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<T, R> implements Operator<R, T> {
|
||||
private final Func1<? super T, ? extends Observable<? extends R>> mapper;
|
||||
private final Observable<Integer> workerCount;
|
||||
|
||||
public DynamicConcurrentMergeOperator(
|
||||
Func1<? super T, ? extends Observable<? extends R>> mapper,
|
||||
Observable<Integer> workerCount) {
|
||||
this.mapper = mapper;
|
||||
this.workerCount = workerCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Subscriber<? super T> call(Subscriber<? super R> t) {
|
||||
DynamicConcurrentMerge<T, R> parent = new DynamicConcurrentMerge<>(t, mapper);
|
||||
t.add(parent);
|
||||
parent.init(workerCount);
|
||||
|
||||
return parent;
|
||||
}
|
||||
|
||||
static final class DynamicConcurrentMerge<T, R> extends Subscriber<T> {
|
||||
private final Subscriber<? super R> actual;
|
||||
private final Func1<? super T, ? extends Observable<? extends R>> mapper;
|
||||
private final Queue<T> queue;
|
||||
private final CopyOnWriteArrayList<DynamicWorker<T, R>> workers;
|
||||
private final CompositeSubscription composite;
|
||||
private final AtomicInteger wipActive;
|
||||
private final AtomicBoolean once;
|
||||
private long id;
|
||||
|
||||
public DynamicConcurrentMerge(Subscriber<? super R> actual,
|
||||
Func1<? super T, ? extends Observable<? extends R>> 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<Integer> workerCount) {
|
||||
Subscription wc = workerCount.subscribe(new Action1<Integer>() {
|
||||
@Override
|
||||
public void call(Integer n) {
|
||||
int n0 = workers.size();
|
||||
if (n0 < n) {
|
||||
for (int i = n0; i < n; i++) {
|
||||
DynamicWorker<T, R> 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<Throwable>() {
|
||||
@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<T, R> 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<T, R> {
|
||||
private final long id;
|
||||
private final AtomicBoolean running;
|
||||
private final DynamicConcurrentMerge<T, R> parent;
|
||||
private final AtomicBoolean stop;
|
||||
|
||||
public DynamicWorker(long id, DynamicConcurrentMerge<T, R> 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<? extends R> out = parent.mapper.call(t);
|
||||
|
||||
final Subscriber<R> s = new Subscriber<R>() {
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
42
app/src/main/java/eu/kanade/tachiyomi/util/Hash.kt
Normal file
42
app/src/main/java/eu/kanade/tachiyomi/util/Hash.kt
Normal file
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -55,10 +55,10 @@ class StateImageViewTarget(view: ImageView,
|
||||
super.onLoadCleared(placeholder)
|
||||
}
|
||||
|
||||
override fun onResourceReady(resource: Drawable?, transition: Transition<in Drawable>?) {
|
||||
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
|
||||
progress?.gone()
|
||||
view.scaleType = imageScaleType
|
||||
super.onResourceReady(resource, transition)
|
||||
this.resource = resource
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
23
app/src/main/java/exh/captcha/SolveCaptchaActivity.kt
Normal file
23
app/src/main/java/exh/captcha/SolveCaptchaActivity.kt
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
|
26
app/src/main/res/drawable/button_bg_transparent.xml
Normal file
26
app/src/main/res/drawable/button_bg_transparent.xml
Normal file
@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_activated="true" android:color="@color/md_white_1000">
|
||||
<shape android:shape="rectangle">
|
||||
<corners android:radius="2dp" />
|
||||
<solid android:color="@color/colorAccentLight" />
|
||||
<padding android:left="8dp" android:right="8dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_enabled="false" android:color="@color/textColorHintLight">
|
||||
<shape android:shape="rectangle">
|
||||
<corners android:radius="2dp" />
|
||||
<solid android:color="@android:color/transparent" />
|
||||
<stroke android:color="@color/textColorHintLight" android:width="1dp"/>
|
||||
<padding android:left="8dp" android:right="8dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:color="@color/colorAccentLight">
|
||||
<shape android:shape="rectangle" android:color="@color/colorAccentLight">
|
||||
<corners android:radius="2dp" />
|
||||
<solid android:color="@android:color/transparent" />
|
||||
<stroke android:color="@color/colorAccentLight" android:width="1dp"/>
|
||||
<padding android:left="8dp" android:right="8dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
9
app/src/main/res/drawable/ic_extension_black_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_extension_black_24dp.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M20.5,11H19V7c0,-1.1 -0.9,-2 -2,-2h-4V3.5C13,2.12 11.88,1 10.5,1S8,2.12 8,3.5V5H4c-1.1,0 -1.99,0.9 -1.99,2v3.8H3.5c1.49,0 2.7,1.21 2.7,2.7s-1.21,2.7 -2.7,2.7H2V20c0,1.1 0.9,2 2,2h3.8v-1.5c0,-1.49 1.21,-2.7 2.7,-2.7 1.49,0 2.7,1.21 2.7,2.7V22H17c1.1,0 2,-0.9 2,-2v-4h1.5c1.38,0 2.5,-1.12 2.5,-2.5S21.88,11 20.5,11z"/>
|
||||
</vector>
|
9
app/src/main/res/drawable/ic_no_settings.xml
Normal file
9
app/src/main/res/drawable/ic_no_settings.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M9.165,11.554C9.124,11.767 9.103,11.984 9.103,12.201 9.102,13.233 9.57,14.21 10.374,14.857 11.179,15.504 12.234,15.751 13.242,15.528ZM20.732,17.246L15.89,12.537C15.902,12.426 15.907,12.315 15.907,12.203 15.908,11.3 15.55,10.434 14.912,9.796 14.273,9.158 13.408,8.799 12.505,8.799c-0.142,0 -0.285,0.009 -0.426,0.026L8.866,5.698C9.157,5.523 9.462,5.372 9.778,5.247L10.137,2.671C10.175,2.432 10.381,2.256 10.623,2.256l3.881,0c0.24,-0.001 0.445,0.171 0.486,0.407L15.348,5.247c0.588,0.242 1.14,0.563 1.641,0.953l2.415,-0.972c0.221,-0.089 0.474,-0.001 0.593,0.206l1.94,3.365c0.118,0.209 0.065,0.473 -0.123,0.621L19.775,11.005c0.041,0.322 0.063,0.647 0.067,0.972 -0.004,0.313 -0.026,0.625 -0.067,0.935L21.814,14.534c0.186,0.15 0.235,0.413 0.116,0.621zM3.432,3.191L10.468,10.048 15.763,15.201 21.066,20.355 19.627,21.744 16.126,18.332c-0.251,0.142 -0.51,0.267 -0.778,0.374l-0.359,2.576c-0.041,0.236 -0.246,0.408 -0.486,0.407L10.623,21.689c-0.24,0.001 -0.445,-0.171 -0.486,-0.407L9.778,18.706C9.189,18.464 8.637,18.14 8.139,17.743L5.722,18.725C5.5,18.809 5.251,18.722 5.129,18.519L3.189,15.154C3.066,14.948 3.116,14.682 3.305,14.534l2.056,-1.615c-0.043,-0.31 -0.066,-0.622 -0.069,-0.935 0.004,-0.325 0.027,-0.65 0.069,-0.972l-2.056,-1.585C3.112,9.278 3.062,9.007 3.189,8.799l1.122,-1.961L2,4.58Z"/>
|
||||
</vector>
|
22
app/src/main/res/layout/eh_activity_captcha.xml
Normal file
22
app/src/main/res/layout/eh_activity_captcha.xml
Normal file
@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<android.support.v7.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_alignParentTop="true"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:minHeight="?attr/actionBarSize"
|
||||
android:theme="?attr/actionBarTheme" />
|
||||
|
||||
<WebView
|
||||
android:id="@+id/webview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_below="@+id/toolbar"
|
||||
android:layout_centerHorizontal="true" />
|
||||
</RelativeLayout>
|
24
app/src/main/res/layout/extension_card_header.xml
Normal file
24
app/src/main/res/layout/extension_card_header.xml
Normal file
@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<android.support.constraint.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
style="@style/TextAppearance.Regular.SubHeading"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingBottom="8dp"
|
||||
android:paddingLeft="@dimen/material_component_text_fields_padding_above_and_below_label"
|
||||
android:paddingTop="8dp"
|
||||
tools:text="Title"/>
|
||||
|
||||
</android.support.constraint.ConstraintLayout>
|
||||
</FrameLayout>
|
87
app/src/main/res/layout/extension_card_item.xml
Normal file
87
app/src/main/res/layout/extension_card_item.xml
Normal file
@ -0,0 +1,87 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<android.support.constraint.ConstraintLayout
|
||||
android:id="@+id/card"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp"
|
||||
android:background="?attr/selectable_list_drawable">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:src="@mipmap/ic_launcher_round"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/ext_title"
|
||||
style="@style/TextAppearance.Regular"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="@style/TextAppearance.Regular.SubHeading"
|
||||
android:textSize="14sp"
|
||||
app:layout_constraintStart_toEndOf="@id/image"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@id/lang"
|
||||
app:layout_constraintVertical_chainStyle="packed"
|
||||
tools:text="Batoto"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/lang"
|
||||
style="@style/TextAppearance.Regular.Body1.Secondary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxLines="1"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintStart_toEndOf="@id/image"
|
||||
app:layout_constraintTop_toBottomOf="@+id/ext_title"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
tools:text="English"
|
||||
tools:visibility="visible"/>
|
||||
|
||||
|
||||
<TextView
|
||||
android:id="@+id/version"
|
||||
style="@style/TextAppearance.Regular.Body1.Secondary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxLines="1"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintBaseline_toBaselineOf="@id/lang"
|
||||
app:layout_constraintLeft_toRightOf="@id/lang"
|
||||
android:layout_marginLeft="4dp"
|
||||
tools:text="Version" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/ext_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginRight="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
android:background="@drawable/button_bg_transparent"
|
||||
android:textColor="@drawable/button_bg_transparent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="Details"/>
|
||||
|
||||
|
||||
</android.support.constraint.ConstraintLayout>
|
||||
|
||||
</FrameLayout>
|
||||
|
15
app/src/main/res/layout/extension_controller.xml
Normal file
15
app/src/main/res/layout/extension_controller.xml
Normal file
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<android.support.v4.widget.SwipeRefreshLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:id="@+id/ext_swipe_refresh">
|
||||
|
||||
<android.support.v7.widget.RecyclerView
|
||||
android:id="@+id/ext_recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:listitem="@layout/extension_card_header"/>
|
||||
|
||||
</android.support.v4.widget.SwipeRefreshLayout>
|
104
app/src/main/res/layout/extension_detail_controller.xml
Normal file
104
app/src/main/res/layout/extension_detail_controller.xml
Normal file
@ -0,0 +1,104 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/extension_icon"
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_marginLeft="16dp"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/extension_title"
|
||||
app:layout_constraintBottom_toBottomOf="@id/extension_pkg"
|
||||
android:src="@mipmap/ic_launcher"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/extension_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginLeft="16dp"
|
||||
style="@style/TextAppearance.Regular.SubHeading"
|
||||
app:layout_constraintLeft_toRightOf="@id/extension_icon"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="Tachiyomi: Extension"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/extension_version"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
app:layout_constraintTop_toBottomOf="@id/extension_title"
|
||||
app:layout_constraintLeft_toLeftOf="@id/extension_title"
|
||||
tools:text="Version: 1.0.0" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/extension_lang"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
app:layout_constraintTop_toBottomOf="@id/extension_version"
|
||||
app:layout_constraintLeft_toLeftOf="@id/extension_title"
|
||||
tools:text="Language: English" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/extension_pkg"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginRight="16dp"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="middle"
|
||||
app:layout_constraintTop_toBottomOf="@id/extension_lang"
|
||||
app:layout_constraintLeft_toLeftOf="@id/extension_title"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
tools:text="eu.kanade.tachiyomi.extension.en.myext"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/extension_uninstall_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/ext_uninstall"
|
||||
style="@style/Theme.Widget.Button.Colored"
|
||||
app:layout_constraintLeft_toLeftOf="@id/guideline"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/extension_pkg" />
|
||||
|
||||
<android.support.v7.widget.RecyclerView
|
||||
android:id="@+id/extension_prefs_recycler"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="16dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/extension_uninstall_button"/>
|
||||
|
||||
<eu.kanade.tachiyomi.widget.EmptyView
|
||||
android:id="@+id/extension_prefs_empty_view"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:visibility="gone"
|
||||
android:gravity="center"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/extension_uninstall_button"/>
|
||||
|
||||
<android.support.constraint.Guideline
|
||||
android:id="@+id/guideline"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintGuide_percent="0.5" />
|
||||
|
||||
</android.support.constraint.ConstraintLayout>
|
@ -11,12 +11,13 @@
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/logo"
|
||||
android:id="@+id/logo_container"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:clickable="true"
|
||||
tools:background="#2E51A2">
|
||||
|
||||
<ImageView
|
||||
@ -35,7 +36,7 @@
|
||||
android:background="?attr/selectable_list_drawable"
|
||||
android:clickable="true"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
@ -68,7 +69,7 @@
|
||||
android:layout_marginRight="16dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:background="?android:attr/divider"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/title_container" />
|
||||
|
||||
@ -79,7 +80,7 @@
|
||||
android:background="?attr/selectable_list_drawable"
|
||||
android:clickable="true"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/divider1">
|
||||
|
||||
@ -110,7 +111,7 @@
|
||||
android:layout_marginRight="16dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:background="?android:attr/divider"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/status_container" />
|
||||
|
||||
@ -121,7 +122,7 @@
|
||||
android:background="?attr/selectable_list_drawable"
|
||||
android:clickable="true"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/divider2">
|
||||
|
||||
@ -152,7 +153,7 @@
|
||||
android:layout_marginRight="16dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:background="?android:attr/divider"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/chapters_container" />
|
||||
|
||||
@ -163,7 +164,7 @@
|
||||
android:background="?attr/selectable_list_drawable"
|
||||
android:clickable="true"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/divider3">
|
||||
|
||||
|
@ -1,47 +1,65 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
<android.support.constraint.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
<EditText
|
||||
android:id="@+id/track_search"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="@dimen/margin_left"
|
||||
android:paddingRight="@dimen/margin_right">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/track_search"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="@string/title"/>
|
||||
|
||||
</LinearLayout>
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/title"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:maxLines="1"
|
||||
app:layout_constraintTop_toTopOf="parent"/>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress"
|
||||
style="?android:attr/progressBarStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="fill_parent"
|
||||
android:layout_gravity="center_vertical|center_horizontal"
|
||||
android:paddingBottom="32dp"
|
||||
android:paddingTop="32dp"
|
||||
android:visibility="gone"/>
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="32dp"
|
||||
android:layout_marginTop="32dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@id/divider1"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/track_search"
|
||||
tools:visibility="visible"/>
|
||||
|
||||
<ListView
|
||||
android:id="@+id/track_search_list"
|
||||
style="@style/Theme.Widget.CardView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginTop="40dp"
|
||||
android:choiceMode="singleChoice"
|
||||
android:clipToPadding="false"
|
||||
android:divider="@null"
|
||||
android:dividerHeight="0dp"
|
||||
android:dividerHeight="10dp"
|
||||
android:footerDividersEnabled="true"
|
||||
android:headerDividersEnabled="true"
|
||||
android:listSelector="?attr/selectable_list_drawable"
|
||||
android:scrollbarStyle="outsideOverlay"
|
||||
android:visibility="gone"/>
|
||||
android:paddingBottom="4dp"
|
||||
android:paddingTop="4dp"
|
||||
android:scrollbars="none"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@+id/track_search"
|
||||
tools:listitem="@layout/track_search_item"
|
||||
tools:visibility="visible"/>
|
||||
|
||||
</LinearLayout>
|
||||
<View
|
||||
android:id="@+id/divider1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="?android:attr/divider"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/track_search_list"/>
|
||||
|
||||
</android.support.constraint.ConstraintLayout>
|
@ -1,14 +1,164 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="?attr/selectable_list_drawable">
|
||||
<android.support.v7.widget.CardView android:id="@+id/cv_manga"
|
||||
style="@style/Theme.Widget.CardView.Item"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:padding="0dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_search_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="10dp"/>
|
||||
<android.support.constraint.ConstraintLayout
|
||||
android:id="@+id/linearLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="216dp"
|
||||
android:background="?attr/selectable_list_drawable"
|
||||
android:orientation="horizontal">
|
||||
>
|
||||
|
||||
</LinearLayout>
|
||||
<ImageView
|
||||
android:id="@+id/track_search_cover"
|
||||
android:layout_width="135dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:contentDescription="@string/description_cover"
|
||||
android:scaleType="centerCrop"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_bias="0.0"
|
||||
tools:src="@drawable/branded_logo_icon"/>
|
||||
<TextView
|
||||
android:id="@+id/track_search_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:maxLines="3"
|
||||
android:textAppearance="@style/TextAppearance.Regular.Body1.Bold"
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/track_search_cover"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="One Piece"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_search_type"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:maxLines="1"
|
||||
android:text="@string/track_type"
|
||||
android:textAppearance="@style/TextAppearance.Regular.Body1.Bold"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintStart_toEndOf="@id/track_search_cover"
|
||||
app:layout_constraintTop_toBottomOf="@id/track_search_title"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_search_type_result"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="@style/TextAppearance.Regular.Body1.Secondary"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintStart_toEndOf="@id/track_search_type"
|
||||
app:layout_constraintTop_toBottomOf="@id/track_search_title"
|
||||
tools:text="Manga"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_search_start"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:maxLines="1"
|
||||
android:text="@string/track_start_date"
|
||||
android:textAppearance="@style/TextAppearance.Regular.Body1.Bold"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintStart_toEndOf="@id/track_search_cover"
|
||||
app:layout_constraintTop_toBottomOf="@id/track_search_type"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_search_start_result"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="@style/TextAppearance.Regular.Body1.Secondary"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintStart_toEndOf="@id/track_search_start"
|
||||
app:layout_constraintTop_toBottomOf="@id/track_search_type"
|
||||
tools:text="2018-10-01"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_search_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:maxLines="1"
|
||||
android:text="@string/track_status"
|
||||
android:textAppearance="@style/TextAppearance.Regular.Body1.Bold"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintStart_toEndOf="@id/track_search_cover"
|
||||
app:layout_constraintTop_toBottomOf="@id/track_search_start"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_search_status_result"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="@style/TextAppearance.Regular.Body1.Secondary"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintStart_toEndOf="@id/track_search_status"
|
||||
app:layout_constraintTop_toBottomOf="@id/track_search_start"
|
||||
tools:text="Ongoing"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_search_summary"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="7"
|
||||
android:textAppearance="@style/TextAppearance.Regular.Body1.Secondary"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toEndOf="@id/track_search_cover"
|
||||
app:layout_constraintTop_toBottomOf="@+id/track_search_status"
|
||||
app:layout_constraintVertical_bias="0.333"
|
||||
tools:text="This is the summary of the manga that fits This is the summary of the manga that fits This is the summary of the manga that fits This is the summary of the manga that fits This is the summary of the manga that fits This is the summary of the manga that fits This is the summary of the manga that fits "/>
|
||||
|
||||
<android.support.constraint.Guideline
|
||||
android:id="@+id/guideline"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintGuide_begin="150dp"/>
|
||||
|
||||
</android.support.constraint.ConstraintLayout>
|
||||
</android.support.v7.widget.CardView>
|
@ -35,7 +35,7 @@
|
||||
|
||||
<item
|
||||
android:id="@+id/action_source_migration"
|
||||
android:title="Source migration"
|
||||
android:title="@string/label_migration"
|
||||
app:showAsAction="never"/>
|
||||
|
||||
</menu>
|
||||
|
@ -23,6 +23,10 @@
|
||||
android:id="@+id/nav_drawer_batch_add"
|
||||
android:icon="@drawable/ic_playlist_add_black_24dp"
|
||||
android:title="Batch Add" />
|
||||
<item
|
||||
android:id="@+id/nav_drawer_extensions"
|
||||
android:icon="@drawable/ic_extension_black_24dp"
|
||||
android:title="@string/label_extensions"/>
|
||||
<item
|
||||
android:id="@+id/nav_drawer_downloads"
|
||||
android:icon="@drawable/ic_file_download_black_24dp"
|
||||
|
@ -2,6 +2,14 @@
|
||||
<changelog bulletedList="false">
|
||||
<!--
|
||||
|
||||
<changelogversion changeDate="" versionName="r1340">
|
||||
<changelogtext>A new screen for managing extensions was added. If you previously installed extensions from FDroid,
|
||||
you will have to uninstall all of them first (tap on the extension then uninstall), otherwise you won't be able
|
||||
to update them due to signature mismatch. You won't lose anything in this process as the extensions themselves
|
||||
don't store anything.
|
||||
</changelogtext>
|
||||
</changelogversion>
|
||||
|
||||
<changelogversion changeDate="" versionName="r959">
|
||||
<changelogtext>The download manager has been rewritten and it's possible some of your downloads
|
||||
aren't recognized anymore. You may have to check your downloads folder and manually delete those.
|
||||
@ -29,4 +37,4 @@
|
||||
</changelogtext>
|
||||
</changelogversion>-->
|
||||
|
||||
</changelog>
|
||||
</changelog>
|
||||
|
@ -21,6 +21,9 @@
|
||||
<string name="label_selected">Selected: %1$d</string>
|
||||
<string name="label_backup">Backup</string>
|
||||
<string name="label_migration">Source migration</string>
|
||||
<string name="label_extensions">Extensions</string>
|
||||
<string name="label_extension_info">Extension info</string>
|
||||
|
||||
|
||||
<!-- Actions -->
|
||||
<string name="action_settings">Settings</string>
|
||||
@ -144,6 +147,26 @@
|
||||
<string name="default_category">Default category</string>
|
||||
<string name="default_category_summary">Always ask</string>
|
||||
|
||||
<!-- Extension section -->
|
||||
<string name="all_lang">All</string>
|
||||
<string name="ext_details">Details</string>
|
||||
<string name="ext_update">Update</string>
|
||||
<string name="ext_install">Install</string>
|
||||
<string name="ext_pending">Pending</string>
|
||||
<string name="ext_downloading">Downloading</string>
|
||||
<string name="ext_installing">Installing</string>
|
||||
<string name="ext_installed">Installed</string>
|
||||
<string name="ext_trust">Trust</string>
|
||||
<string name="ext_untrusted">Untrusted</string>
|
||||
<string name="ext_uninstall">Uninstall</string>
|
||||
<string name="ext_preferences">Preferences</string>
|
||||
<string name="ext_available">Available</string>
|
||||
<string name="untrusted_extension">Untrusted extension</string>
|
||||
<string name="untrusted_extension_message">This extension was signed with an untrusted certificate and it wasn\'t activated.\n\nA malicious extension could read any login credentials stored in Tachiyomi or execute arbitrary code.\n\nBy trusting this certificate you accept these risks.</string>
|
||||
<string name="ext_version_info">Version: %1$s</string>
|
||||
<string name="ext_language_info">Language: %1$s</string>
|
||||
<string name="ext_empty_preferences">There are no preferences to edit for this extension</string>
|
||||
|
||||
<!-- Reader section -->
|
||||
<string name="pref_fullscreen">Fullscreen</string>
|
||||
<string name="pref_lock_orientation">Lock orientation</string>
|
||||
@ -196,7 +219,6 @@
|
||||
|
||||
<!-- Downloads section -->
|
||||
<string name="pref_download_directory">Downloads directory</string>
|
||||
<string name="pref_download_slots">Simultaneous downloads</string>
|
||||
<string name="pref_download_only_over_wifi">Only download over Wi-Fi</string>
|
||||
<string name="pref_remove_after_marked_as_read">Remove when marked as read</string>
|
||||
<string name="pref_remove_after_read">Remove after read</string>
|
||||
@ -355,7 +377,7 @@
|
||||
<string name="download_unread">Download unread</string>
|
||||
<string name="confirm_delete_chapters">Are you sure you want to delete selected chapters?</string>
|
||||
|
||||
<!-- MyAnimeList fragment -->
|
||||
<!-- Tracking Screen -->
|
||||
<string name="manga_tracking_tab">Tracking</string>
|
||||
<string name="reading">Reading</string>
|
||||
<string name="completed">Completed</string>
|
||||
@ -365,6 +387,11 @@
|
||||
<string name="score">Score</string>
|
||||
<string name="title">Title</string>
|
||||
<string name="status">Status</string>
|
||||
<string name="track_status">Status</string>
|
||||
<string name="track_start_date">Started</string>
|
||||
<string name="track_type">Type</string>
|
||||
<string name="track_author">Author</string>
|
||||
<string name="url_not_set">Manga url is not set please click title and select manga again</string>
|
||||
|
||||
<!-- Category activity -->
|
||||
<string name="error_category_exists">A category with this name already exists!</string>
|
||||
|
@ -9,7 +9,7 @@ buildscript {
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:3.0.1'
|
||||
classpath 'com.github.ben-manes:gradle-versions-plugin:0.17.0'
|
||||
classpath 'com.github.zellius:android-shortcut-gradle-plugin:0.1.1'
|
||||
classpath 'com.github.zellius:android-shortcut-gradle-plugin:0.1.2'
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user