mirror of
https://github.com/mihonapp/mihon.git
synced 2025-06-26 11:07:51 +02:00
Compare commits
100 Commits
Author | SHA1 | Date | |
---|---|---|---|
8e4dd030d0 | |||
4d7b188999 | |||
6de260d73f | |||
2230ad59f1 | |||
e22b1661f4 | |||
bb723076ee | |||
fd9c24413d | |||
91c58640a7 | |||
c8e3375248 | |||
aeef8c02d8 | |||
08f2cd2472 | |||
fe413d52d6 | |||
add2ca0b8f | |||
ad6cdc9017 | |||
dd8cab4562 | |||
f7c791d153 | |||
b66f06d9dc | |||
89940677cc | |||
56e7a1e2a0 | |||
810f5ad531 | |||
495f5d03ac | |||
f63d2cebfc | |||
ae60e8cbd5 | |||
772bef05fc | |||
9320d1f7a4 | |||
e8912c5dc9 | |||
2b73a9d2a4 | |||
e25ce768bb | |||
05c628b33c | |||
fda0aa3ce1 | |||
cdf5bbadea | |||
c6b89a826c | |||
6264e56148 | |||
7f8bfd759f | |||
c3c2013944 | |||
1e4a01399d | |||
03967b67cf | |||
7425478a55 | |||
f807447de1 | |||
706163e7a6 | |||
a4c145c1ef | |||
c445ea90ba | |||
52c50398b8 | |||
fb89f77db7 | |||
f7b94179a4 | |||
e045ca8538 | |||
871e17c2f5 | |||
f86c3c81bf | |||
71ab6d38e4 | |||
e76fb7a524 | |||
90a99dde1f | |||
7b9f5d0e9f | |||
e4d4dbbeb6 | |||
6ef94fb59b | |||
a03dceff7d | |||
f717c57648 | |||
ca8fdad422 | |||
faa61923fb | |||
96a39f5c54 | |||
72f8c4d5e2 | |||
07cae4d684 | |||
dd56d7c0bb | |||
77d986f213 | |||
13bcefe5cd | |||
49d0e06704 | |||
2c8790c545 | |||
d0260acd3d | |||
44ec6184c8 | |||
cfa9729831 | |||
c25af3d5ad | |||
d3e9200a7f | |||
2032ba3ba3 | |||
9bcde69ee0 | |||
beca2b429c | |||
3a1699f0b3 | |||
a7192e866f | |||
dc882b4dce | |||
77b4de3941 | |||
006d17aac7 | |||
1a3a1db4ff | |||
97fa659283 | |||
f1d84ccb49 | |||
1f14240251 | |||
ea6fed6ecf | |||
d09eca7833 | |||
2c6f64c5ae | |||
ec87e4359b | |||
2b63bae989 | |||
82f4e3157a | |||
725ceab00b | |||
ba428c401d | |||
510669ee2c | |||
8d749df290 | |||
091c0c0c71 | |||
7fdd2cacd7 | |||
2241a0b2de | |||
d21a93123b | |||
e542a8d8e2 | |||
94ee4e7fb5 | |||
e3d430eb5e |
4
.github/CONTRIBUTING.md
vendored
4
.github/CONTRIBUTING.md
vendored
@ -1,3 +1,7 @@
|
||||
# Catalogue requests
|
||||
|
||||
* Catalogue requests should be created at https://github.com/inorichi/tachiyomi-extensions/issues, not here
|
||||
|
||||
# Bugs
|
||||
* Include version (Setting > About > Version)
|
||||
* If not latest, try updating, it may have already been solved
|
||||
|
12
.travis.yml
12
.travis.yml
@ -12,11 +12,21 @@ android:
|
||||
- extra-android-support
|
||||
- extra-google-google_play_services
|
||||
|
||||
licenses:
|
||||
- android-sdk-license-.+
|
||||
- '.+'
|
||||
|
||||
jdk:
|
||||
- oraclejdk8
|
||||
|
||||
before_script:
|
||||
- chmod +x gradlew
|
||||
- chmod +x gradlew
|
||||
|
||||
before_install:
|
||||
- mkdir "$ANDROID_HOME/licenses" || true
|
||||
- echo -e "\n8933bad161af4178b1185d1a37fbf41ea5269c55" > "$ANDROID_HOME/licenses/android-sdk-license"
|
||||
- echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_HOME/licenses/android-sdk-preview-license"
|
||||
|
||||
#Build, and run tests
|
||||
script: "./gradlew clean buildStandardDebug"
|
||||
sudo: false
|
||||
|
@ -1,6 +1,6 @@
|
||||
| Build | Download | Auto Update |
|
||||
| Build | Download | F-Droid |
|
||||
|-------|----------|-------------|
|
||||
| [](https://teamcity.kanade.eu/project.html?projectId=tachiyomi) [](https://travis-ci.org/inorichi/tachiyomi) | [](https://github.com/inorichi/tachiyomi/releases) [](http://tachiyomi.kanade.eu/latest/app-debug.apk) | [](https://f-droid.org/repository/browse/?fdid=eu.kanade.tachiyomi) [](//github.com/inorichi/tachiyomi/wiki/FDroid-for-dev-versions) |
|
||||
| [](https://teamcity.kanade.eu/project.html?projectId=tachiyomi) [](https://travis-ci.org/inorichi/tachiyomi) | [](https://github.com/inorichi/tachiyomi/releases) [](http://tachiyomi.kanade.eu/latest/app-debug.apk) | [](https://f-droid.org/repository/browse/?fdid=eu.kanade.tachiyomi) [](//github.com/inorichi/tachiyomi/wiki/FDroid-for-dev-versions) |
|
||||
|
||||
## [Report an issue](https://github.com/inorichi/tachiyomi/blob/master/.github/CONTRIBUTING.md)
|
||||
|
||||
|
@ -30,7 +30,7 @@ ext {
|
||||
|
||||
android {
|
||||
compileSdkVersion 25
|
||||
buildToolsVersion "25.0.1"
|
||||
buildToolsVersion "25.0.2"
|
||||
publishNonDefault true
|
||||
|
||||
defaultConfig {
|
||||
@ -38,8 +38,8 @@ android {
|
||||
minSdkVersion 16
|
||||
targetSdkVersion 25
|
||||
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
||||
versionCode 17
|
||||
versionName "0.4.1"
|
||||
versionCode 20
|
||||
versionName "0.5.0"
|
||||
|
||||
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
|
||||
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
|
||||
@ -48,7 +48,7 @@ android {
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
ndk {
|
||||
abiFilters "armeabi", "armeabi-v7a", "x86"
|
||||
abiFilters "armeabi-v7a", "arm64-v8a", "x86"
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,10 +98,11 @@ android {
|
||||
dependencies {
|
||||
|
||||
// Modified dependencies
|
||||
compile 'com.github.inorichi:subsampling-scale-image-view:f687b74'
|
||||
compile 'com.github.inorichi:subsampling-scale-image-view:4255750'
|
||||
compile 'com.github.inorichi:junrar-android:634c1f5'
|
||||
|
||||
// Android support library
|
||||
final support_library_version = '25.0.1'
|
||||
final support_library_version = '25.2.0'
|
||||
compile "com.android.support:support-v4:$support_library_version"
|
||||
compile "com.android.support:appcompat-v7:$support_library_version"
|
||||
compile "com.android.support:cardview-v7:$support_library_version"
|
||||
@ -110,28 +111,30 @@ dependencies {
|
||||
compile "com.android.support:support-annotations:$support_library_version"
|
||||
compile "com.android.support:customtabs:$support_library_version"
|
||||
|
||||
compile 'com.android.support.constraint:constraint-layout:1.0.0'
|
||||
|
||||
compile 'com.android.support:multidex:1.0.1'
|
||||
|
||||
// ReactiveX
|
||||
compile 'io.reactivex:rxandroid:1.2.1'
|
||||
compile 'io.reactivex:rxjava:1.2.3'
|
||||
compile 'io.reactivex:rxjava:1.2.6'
|
||||
compile 'com.jakewharton.rxrelay:rxrelay:1.2.0'
|
||||
compile 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
|
||||
compile 'com.github.pwittchen:reactivenetwork:0.6.0'
|
||||
compile 'com.github.pwittchen:reactivenetwork:0.7.0'
|
||||
|
||||
// Network client
|
||||
compile "com.squareup.okhttp3:okhttp:3.5.0"
|
||||
compile "com.squareup.okhttp3:okhttp:3.6.0"
|
||||
compile 'com.squareup.okio:okio:1.11.0'
|
||||
|
||||
// REST
|
||||
final retrofit_version = '2.1.0'
|
||||
final retrofit_version = '2.2.0'
|
||||
compile "com.squareup.retrofit2:retrofit:$retrofit_version"
|
||||
compile "com.squareup.retrofit2:converter-gson:$retrofit_version"
|
||||
compile "com.squareup.retrofit2:adapter-rxjava:$retrofit_version"
|
||||
|
||||
// JSON
|
||||
compile 'com.google.code.gson:gson:2.8.0'
|
||||
compile 'com.github.salomonbrys.kotson:kotson:2.4.0'
|
||||
compile 'com.github.salomonbrys.kotson:kotson:2.5.0'
|
||||
|
||||
// YAML
|
||||
compile 'com.github.bmoliveira:snake-yaml:v1.18-android'
|
||||
@ -144,17 +147,17 @@ dependencies {
|
||||
compile 'com.github.seven332:unifile:1.0.0'
|
||||
|
||||
// HTML parser
|
||||
compile 'org.jsoup:jsoup:1.10.1'
|
||||
compile 'org.jsoup:jsoup:1.10.2'
|
||||
|
||||
// Job scheduling
|
||||
compile 'com.evernote:android-job:1.1.3'
|
||||
compile 'com.google.android.gms:play-services-gcm:10.0.1'
|
||||
compile 'com.evernote:android-job:1.1.6'
|
||||
compile 'com.google.android.gms:play-services-gcm:10.2.0'
|
||||
|
||||
// Changelog
|
||||
compile 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
|
||||
|
||||
// Database
|
||||
compile "com.pushtorefresh.storio:sqlite:1.11.0"
|
||||
compile "com.pushtorefresh.storio:sqlite:1.12.3"
|
||||
|
||||
// Model View Presenter
|
||||
final nucleus_version = '3.0.0'
|
||||
@ -172,19 +175,23 @@ dependencies {
|
||||
compile 'jp.wasabeef:glide-transformations:2.0.1'
|
||||
|
||||
// Logging
|
||||
compile 'com.jakewharton.timber:timber:4.3.1'
|
||||
compile 'com.jakewharton.timber:timber:4.5.1'
|
||||
|
||||
// Crash reports
|
||||
compile 'ch.acra:acra:4.9.1'
|
||||
compile 'ch.acra:acra:4.9.2'
|
||||
|
||||
// Sort
|
||||
compile 'com.github.gpanther:java-nat-sort:natural-comparator-1.1'
|
||||
|
||||
// UI
|
||||
compile 'com.dmitrymalkovich.android:material-design-dimens:1.4'
|
||||
compile 'com.github.dmytrodanylyk.android-process-button:library:1.0.4'
|
||||
compile 'eu.davidea:flexible-adapter:4.2.0'
|
||||
compile 'eu.davidea:flexible-adapter:5.0.0-rc1'
|
||||
compile 'com.github.inorichi:FlexibleAdapter:93985fe' // v4.2.0 to be removed
|
||||
compile 'com.nononsenseapps:filepicker:2.5.2'
|
||||
compile 'com.github.amulyakhare:TextDrawable:558677e'
|
||||
compile 'com.afollestad.material-dialogs:core:0.9.1.0'
|
||||
compile 'net.xpece.android:support-preference:1.2.0'
|
||||
compile 'com.afollestad.material-dialogs:core:0.9.3.0'
|
||||
compile 'net.xpece.android:support-preference:1.2.5'
|
||||
compile 'me.zhanghai.android.systemuihelper:library:1.0.0'
|
||||
compile 'de.hdodenhof:circleimageview:2.1.0'
|
||||
|
||||
@ -202,7 +209,7 @@ dependencies {
|
||||
}
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.0.5-2'
|
||||
ext.kotlin_version = '1.0.6'
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
@ -1,16 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="eu.kanade.tachiyomi">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_PHONE_STATE"
|
||||
tools:node="remove" />
|
||||
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
|
||||
|
||||
<application
|
||||
@ -20,9 +21,8 @@
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
android:theme="@style/Theme.Tachiyomi" >
|
||||
<activity
|
||||
android:name=".ui.main.MainActivity">
|
||||
android:theme="@style/Theme.Tachiyomi">
|
||||
<activity android:name=".ui.main.MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@ -31,28 +31,40 @@
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.manga.MangaActivity"
|
||||
android:parentActivityName=".ui.main.MainActivity"
|
||||
android:exported="true">
|
||||
</activity>
|
||||
android:exported="true"
|
||||
android:parentActivityName=".ui.main.MainActivity" />
|
||||
<activity
|
||||
android:name=".ui.reader.ReaderActivity"
|
||||
android:theme="@style/Theme.Reader">
|
||||
</activity>
|
||||
android:theme="@style/Theme.Reader" />
|
||||
<activity
|
||||
android:name=".ui.setting.SettingsActivity"
|
||||
android:label="@string/label_settings"
|
||||
android:parentActivityName=".ui.main.MainActivity" >
|
||||
</activity>
|
||||
android:parentActivityName=".ui.main.MainActivity" />
|
||||
<activity
|
||||
android:name=".ui.category.CategoryActivity"
|
||||
android:label="@string/label_categories"
|
||||
android:parentActivityName=".ui.main.MainActivity">
|
||||
</activity>
|
||||
android:parentActivityName=".ui.main.MainActivity" />
|
||||
<activity
|
||||
android:name=".ui.setting.SettingsDownloadsFragment$CustomLayoutPickerActivity"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/FilePickerTheme">
|
||||
android:theme="@style/FilePickerTheme" />
|
||||
<activity
|
||||
android:name=".ui.setting.AnilistLoginActivity"
|
||||
android:label="Anilist">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="anilist-auth"
|
||||
android:scheme="tachiyomi" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.download.DownloadActivity"
|
||||
android:launchMode="singleTop" />
|
||||
|
||||
<provider
|
||||
android:name="android.support.v4.content.FileProvider"
|
||||
@ -61,26 +73,34 @@
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/provider_paths"/>
|
||||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
|
||||
<service android:name=".data.library.LibraryUpdateService"
|
||||
android:exported="false"/>
|
||||
<provider
|
||||
android:name="eu.kanade.tachiyomi.util.ZipContentProvider"
|
||||
android:authorities="${applicationId}.zip-provider"
|
||||
android:exported="false" />
|
||||
|
||||
<service android:name=".data.download.DownloadService"
|
||||
android:exported="false"/>
|
||||
<provider
|
||||
android:name="eu.kanade.tachiyomi.util.RarContentProvider"
|
||||
android:authorities="${applicationId}.rar-provider"
|
||||
android:exported="false" />
|
||||
|
||||
<service android:name=".data.mangasync.UpdateMangaSyncService"
|
||||
android:exported="false"/>
|
||||
<receiver
|
||||
android:name=".data.notification.NotificationReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<service android:name=".data.updater.UpdateDownloaderService"
|
||||
android:exported="false"/>
|
||||
<service
|
||||
android:name=".data.library.LibraryUpdateService"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver android:name=".data.updater.UpdateNotificationReceiver"/>
|
||||
<service
|
||||
android:name=".data.download.DownloadService"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver android:name=".data.library.LibraryUpdateService$CancelUpdateReceiver" />
|
||||
|
||||
<receiver android:name=".ui.reader.notification.ImageNotificationReceiver" />
|
||||
<service
|
||||
android:name=".data.updater.UpdateDownloaderService"
|
||||
android:exported="false" />
|
||||
|
||||
<meta-data
|
||||
android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule"
|
||||
|
@ -20,7 +20,7 @@ import uy.kohesive.injekt.registry.default.DefaultRegistrar
|
||||
reportType = org.acra.sender.HttpSender.Type.JSON,
|
||||
httpMethod = org.acra.sender.HttpSender.Method.PUT,
|
||||
buildConfigClass = BuildConfig::class,
|
||||
excludeMatchingSharedPreferencesKeys = arrayOf(".*username.*", ".*password.*")
|
||||
excludeMatchingSharedPreferencesKeys = arrayOf(".*username.*", ".*password.*", ".*token.*")
|
||||
)
|
||||
open class App : Application() {
|
||||
|
||||
@ -34,7 +34,7 @@ open class App : Application() {
|
||||
setupAcra()
|
||||
setupJobManager()
|
||||
|
||||
LocaleHelper.updateCfg(this, baseContext.resources.configuration)
|
||||
LocaleHelper.updateConfiguration(this, resources.configuration)
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
@ -46,7 +46,7 @@ open class App : Application() {
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
LocaleHelper.updateCfg(this, newConfig)
|
||||
LocaleHelper.updateConfiguration(this, newConfig, true)
|
||||
}
|
||||
|
||||
protected open fun setupAcra() {
|
||||
|
@ -6,10 +6,10 @@ import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
|
||||
import eu.kanade.tachiyomi.data.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.source.SourceManager
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
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
|
||||
@ -32,7 +32,7 @@ class AppModule(val app: Application) : InjektModule {
|
||||
|
||||
addSingletonFactory { DownloadManager(app) }
|
||||
|
||||
addSingletonFactory { MangaSyncManager(app) }
|
||||
addSingletonFactory { TrackManager(app) }
|
||||
|
||||
addSingletonFactory { Gson() }
|
||||
|
||||
|
@ -39,7 +39,7 @@ class BackupManager(private val db: DatabaseHelper) {
|
||||
private val MANGA = "manga"
|
||||
private val MANGAS = "mangas"
|
||||
private val CHAPTERS = "chapters"
|
||||
private val MANGA_SYNC = "sync"
|
||||
private val TRACK = "sync"
|
||||
private val CATEGORIES = "categories"
|
||||
|
||||
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
|
||||
@ -109,10 +109,10 @@ class BackupManager(private val db: DatabaseHelper) {
|
||||
entry.add(CHAPTERS, gson.toJsonTree(chapters))
|
||||
}
|
||||
|
||||
// Backup manga sync
|
||||
val mangaSync = db.getMangasSync(manga).executeAsBlocking()
|
||||
if (!mangaSync.isEmpty()) {
|
||||
entry.add(MANGA_SYNC, gson.toJsonTree(mangaSync))
|
||||
// Backup tracks
|
||||
val tracks = db.getTracks(manga).executeAsBlocking()
|
||||
if (!tracks.isEmpty()) {
|
||||
entry.add(TRACK, gson.toJsonTree(tracks))
|
||||
}
|
||||
|
||||
// Backup categories for this manga
|
||||
@ -231,13 +231,13 @@ class BackupManager(private val db: DatabaseHelper) {
|
||||
val element = backupManga.asJsonObject
|
||||
val manga = gson.fromJson(element.get(MANGA), MangaImpl::class.java)
|
||||
val chapters = gson.fromJson<List<ChapterImpl>>(element.get(CHAPTERS) ?: JsonArray())
|
||||
val sync = gson.fromJson<List<MangaSyncImpl>>(element.get(MANGA_SYNC) ?: JsonArray())
|
||||
val tracks = gson.fromJson<List<TrackImpl>>(element.get(TRACK) ?: JsonArray())
|
||||
val categories = gson.fromJson<List<String>>(element.get(CATEGORIES) ?: JsonArray())
|
||||
|
||||
// Restore everything related to this manga
|
||||
restoreManga(manga)
|
||||
restoreChaptersForManga(manga, chapters)
|
||||
restoreSyncForManga(manga, sync)
|
||||
restoreSyncForManga(manga, tracks)
|
||||
restoreCategoriesForManga(manga, categories)
|
||||
}
|
||||
}
|
||||
@ -333,35 +333,35 @@ class BackupManager(private val db: DatabaseHelper) {
|
||||
* Restores the sync of a manga.
|
||||
*
|
||||
* @param manga the manga whose sync have to be restored.
|
||||
* @param sync the sync to restore.
|
||||
* @param tracks the track list to restore.
|
||||
*/
|
||||
private fun restoreSyncForManga(manga: Manga, sync: List<MangaSync>) {
|
||||
private fun restoreSyncForManga(manga: Manga, tracks: List<Track>) {
|
||||
// Fix foreign keys with the current manga id
|
||||
for (mangaSync in sync) {
|
||||
mangaSync.manga_id = manga.id!!
|
||||
for (track in tracks) {
|
||||
track.manga_id = manga.id!!
|
||||
}
|
||||
|
||||
val dbSyncs = db.getMangasSync(manga).executeAsBlocking()
|
||||
val syncToUpdate = ArrayList<MangaSync>()
|
||||
for (backupSync in sync) {
|
||||
val dbTracks = db.getTracks(manga).executeAsBlocking()
|
||||
val trackToUpdate = ArrayList<Track>()
|
||||
for (backupTrack in tracks) {
|
||||
// Try to find existing chapter in db
|
||||
val pos = dbSyncs.indexOf(backupSync)
|
||||
val pos = dbTracks.indexOf(backupTrack)
|
||||
if (pos != -1) {
|
||||
// The sync is already in the db, only update its fields
|
||||
val dbSync = dbSyncs[pos]
|
||||
val dbSync = dbTracks[pos]
|
||||
// Mark the max chapter as read and nothing else
|
||||
dbSync.last_chapter_read = Math.max(backupSync.last_chapter_read, dbSync.last_chapter_read)
|
||||
syncToUpdate.add(dbSync)
|
||||
dbSync.last_chapter_read = Math.max(backupTrack.last_chapter_read, dbSync.last_chapter_read)
|
||||
trackToUpdate.add(dbSync)
|
||||
} else {
|
||||
// Insert new sync. Let the db assign the id
|
||||
backupSync.id = null
|
||||
syncToUpdate.add(backupSync)
|
||||
backupTrack.id = null
|
||||
trackToUpdate.add(backupTrack)
|
||||
}
|
||||
}
|
||||
|
||||
// Update database
|
||||
if (!syncToUpdate.isEmpty()) {
|
||||
db.insertMangasSync(syncToUpdate).executeAsBlocking()
|
||||
if (!trackToUpdate.isEmpty()) {
|
||||
db.insertTracks(trackToUpdate).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -5,7 +5,7 @@ import com.google.gson.FieldAttributes
|
||||
import eu.kanade.tachiyomi.data.database.models.CategoryImpl
|
||||
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaImpl
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSyncImpl
|
||||
import eu.kanade.tachiyomi.data.database.models.TrackImpl
|
||||
|
||||
class IdExclusion : ExclusionStrategy {
|
||||
|
||||
@ -17,7 +17,7 @@ class IdExclusion : ExclusionStrategy {
|
||||
override fun shouldSkipField(f: FieldAttributes) = when (f.declaringClass) {
|
||||
MangaImpl::class.java -> mangaExclusions.contains(f.name)
|
||||
ChapterImpl::class.java -> chapterExclusions.contains(f.name)
|
||||
MangaSyncImpl::class.java -> syncExclusions.contains(f.name)
|
||||
TrackImpl::class.java -> syncExclusions.contains(f.name)
|
||||
CategoryImpl::class.java -> categoryExclusions.contains(f.name)
|
||||
else -> false
|
||||
}
|
||||
|
@ -5,7 +5,8 @@ import android.text.format.Formatter
|
||||
import com.github.salomonbrys.kotson.fromJson
|
||||
import com.google.gson.Gson
|
||||
import com.jakewharton.disklrucache.DiskLruCache
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.util.DiskUtil
|
||||
import eu.kanade.tachiyomi.util.saveTo
|
||||
import okhttp3.Response
|
||||
@ -92,13 +93,13 @@ class ChapterCache(private val context: Context) {
|
||||
/**
|
||||
* Get page list from cache.
|
||||
*
|
||||
* @param chapterUrl the url of the chapter.
|
||||
* @param chapter the chapter.
|
||||
* @return an observable of the list of pages.
|
||||
*/
|
||||
fun getPageListFromCache(chapterUrl: String): Observable<List<Page>> {
|
||||
return Observable.fromCallable<List<Page>> {
|
||||
fun getPageListFromCache(chapter: Chapter): Observable<List<Page>> {
|
||||
return Observable.fromCallable {
|
||||
// Get the key for the chapter.
|
||||
val key = DiskUtil.hashKeyForDisk(chapterUrl)
|
||||
val key = DiskUtil.hashKeyForDisk(getKey(chapter))
|
||||
|
||||
// Convert JSON string to list of objects. Throws an exception if snapshot is null
|
||||
diskCache.get(key).use {
|
||||
@ -110,10 +111,10 @@ class ChapterCache(private val context: Context) {
|
||||
/**
|
||||
* Add page list to disk cache.
|
||||
*
|
||||
* @param chapterUrl the url of the chapter.
|
||||
* @param chapter the chapter.
|
||||
* @param pages list of pages.
|
||||
*/
|
||||
fun putPageListToCache(chapterUrl: String, pages: List<Page>) {
|
||||
fun putPageListToCache(chapter: Chapter, pages: List<Page>) {
|
||||
// Convert list of pages to json string.
|
||||
val cachedValue = gson.toJson(pages)
|
||||
|
||||
@ -122,7 +123,7 @@ class ChapterCache(private val context: Context) {
|
||||
|
||||
try {
|
||||
// Get editor from md5 key.
|
||||
val key = DiskUtil.hashKeyForDisk(chapterUrl)
|
||||
val key = DiskUtil.hashKeyForDisk(getKey(chapter))
|
||||
editor = diskCache.edit(key) ?: return
|
||||
|
||||
// Write chapter urls to cache.
|
||||
@ -196,5 +197,8 @@ class ChapterCache(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getKey(chapter: Chapter): String {
|
||||
return "${chapter.manga_id}${chapter.url}"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -20,7 +20,7 @@ class CoverCache(private val context: Context) {
|
||||
/**
|
||||
* Cache directory used for cache management.
|
||||
*/
|
||||
private val cacheDir: File = File(context.externalCacheDir, "cover_disk_cache")
|
||||
private val cacheDir = context.getExternalFilesDir("covers")
|
||||
|
||||
/**
|
||||
* Returns the cover from cache.
|
||||
|
@ -10,13 +10,13 @@ import eu.kanade.tachiyomi.data.database.queries.*
|
||||
* This class provides operations to manage the database through its interfaces.
|
||||
*/
|
||||
open class DatabaseHelper(context: Context)
|
||||
: MangaQueries, ChapterQueries, MangaSyncQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries {
|
||||
: MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries {
|
||||
|
||||
override val db = DefaultStorIOSQLite.builder()
|
||||
.sqliteOpenHelper(DbOpenHelper(context))
|
||||
.addTypeMapping(Manga::class.java, MangaTypeMapping())
|
||||
.addTypeMapping(Chapter::class.java, ChapterTypeMapping())
|
||||
.addTypeMapping(MangaSync::class.java, MangaSyncTypeMapping())
|
||||
.addTypeMapping(Track::class.java, TrackTypeMapping())
|
||||
.addTypeMapping(Category::class.java, CategoryTypeMapping())
|
||||
.addTypeMapping(MangaCategory::class.java, MangaCategoryTypeMapping())
|
||||
.addTypeMapping(History::class.java, HistoryTypeMapping())
|
||||
|
@ -23,7 +23,7 @@ class DbOpenHelper(context: Context)
|
||||
override fun onCreate(db: SQLiteDatabase) = with(db) {
|
||||
execSQL(MangaTable.createTableQuery)
|
||||
execSQL(ChapterTable.createTableQuery)
|
||||
execSQL(MangaSyncTable.createTableQuery)
|
||||
execSQL(TrackTable.createTableQuery)
|
||||
execSQL(CategoryTable.createTableQuery)
|
||||
execSQL(MangaCategoryTable.createTableQuery)
|
||||
execSQL(HistoryTable.createTableQuery)
|
||||
|
@ -69,7 +69,7 @@ open class MangaGetResolver : DefaultGetResolver<Manga>() {
|
||||
|
||||
override fun mapFromCursor(cursor: Cursor): Manga = MangaImpl().apply {
|
||||
id = cursor.getLong(cursor.getColumnIndex(COL_ID))
|
||||
source = cursor.getInt(cursor.getColumnIndex(COL_SOURCE))
|
||||
source = cursor.getLong(cursor.getColumnIndex(COL_SOURCE))
|
||||
url = cursor.getString(cursor.getColumnIndex(COL_URL))
|
||||
artist = cursor.getString(cursor.getColumnIndex(COL_ARTIST))
|
||||
author = cursor.getString(cursor.getColumnIndex(COL_AUTHOR))
|
||||
|
@ -9,38 +9,38 @@ import com.pushtorefresh.storio.sqlite.operations.put.DefaultPutResolver
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSync
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSyncImpl
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_ID
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_LAST_CHAPTER_READ
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_MANGA_ID
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_REMOTE_ID
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_SCORE
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_STATUS
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_SYNC_ID
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_TITLE
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_TOTAL_CHAPTERS
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.TABLE
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.database.models.TrackImpl
|
||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_ID
|
||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_LAST_CHAPTER_READ
|
||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MANGA_ID
|
||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_REMOTE_ID
|
||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SCORE
|
||||
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.TABLE
|
||||
|
||||
class MangaSyncTypeMapping : SQLiteTypeMapping<MangaSync>(
|
||||
MangaSyncPutResolver(),
|
||||
MangaSyncGetResolver(),
|
||||
MangaSyncDeleteResolver()
|
||||
class TrackTypeMapping : SQLiteTypeMapping<Track>(
|
||||
TrackPutResolver(),
|
||||
TrackGetResolver(),
|
||||
TrackDeleteResolver()
|
||||
)
|
||||
|
||||
class MangaSyncPutResolver : DefaultPutResolver<MangaSync>() {
|
||||
class TrackPutResolver : DefaultPutResolver<Track>() {
|
||||
|
||||
override fun mapToInsertQuery(obj: MangaSync) = InsertQuery.builder()
|
||||
override fun mapToInsertQuery(obj: Track) = InsertQuery.builder()
|
||||
.table(TABLE)
|
||||
.build()
|
||||
|
||||
override fun mapToUpdateQuery(obj: MangaSync) = UpdateQuery.builder()
|
||||
override fun mapToUpdateQuery(obj: Track) = UpdateQuery.builder()
|
||||
.table(TABLE)
|
||||
.where("$COL_ID = ?")
|
||||
.whereArgs(obj.id)
|
||||
.build()
|
||||
|
||||
override fun mapToContentValues(obj: MangaSync) = ContentValues(9).apply {
|
||||
override fun mapToContentValues(obj: Track) = ContentValues(9).apply {
|
||||
put(COL_ID, obj.id)
|
||||
put(COL_MANGA_ID, obj.manga_id)
|
||||
put(COL_SYNC_ID, obj.sync_id)
|
||||
@ -53,9 +53,9 @@ class MangaSyncPutResolver : DefaultPutResolver<MangaSync>() {
|
||||
}
|
||||
}
|
||||
|
||||
class MangaSyncGetResolver : DefaultGetResolver<MangaSync>() {
|
||||
class TrackGetResolver : DefaultGetResolver<Track>() {
|
||||
|
||||
override fun mapFromCursor(cursor: Cursor): MangaSync = MangaSyncImpl().apply {
|
||||
override fun mapFromCursor(cursor: Cursor): Track = TrackImpl().apply {
|
||||
id = cursor.getLong(cursor.getColumnIndex(COL_ID))
|
||||
manga_id = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID))
|
||||
sync_id = cursor.getInt(cursor.getColumnIndex(COL_SYNC_ID))
|
||||
@ -68,9 +68,9 @@ class MangaSyncGetResolver : DefaultGetResolver<MangaSync>() {
|
||||
}
|
||||
}
|
||||
|
||||
class MangaSyncDeleteResolver : DefaultDeleteResolver<MangaSync>() {
|
||||
class TrackDeleteResolver : DefaultDeleteResolver<Track>() {
|
||||
|
||||
override fun mapToDeleteQuery(obj: MangaSync) = DeleteQuery.builder()
|
||||
override fun mapToDeleteQuery(obj: Track) = DeleteQuery.builder()
|
||||
.table(TABLE)
|
||||
.where("$COL_ID = ?")
|
||||
.whereArgs(obj.id)
|
@ -1,17 +1,14 @@
|
||||
package eu.kanade.tachiyomi.data.database.models
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import java.io.Serializable
|
||||
|
||||
interface Chapter : Serializable {
|
||||
interface Chapter : SChapter, Serializable {
|
||||
|
||||
var id: Long?
|
||||
|
||||
var manga_id: Long?
|
||||
|
||||
var url: String
|
||||
|
||||
var name: String
|
||||
|
||||
var read: Boolean
|
||||
|
||||
var bookmark: Boolean
|
||||
@ -20,10 +17,6 @@ interface Chapter : Serializable {
|
||||
|
||||
var date_fetch: Long
|
||||
|
||||
var date_upload: Long
|
||||
|
||||
var chapter_number: Float
|
||||
|
||||
var source_order: Int
|
||||
|
||||
val isRecognizedNumber: Boolean
|
||||
|
@ -1,35 +1,17 @@
|
||||
package eu.kanade.tachiyomi.data.database.models
|
||||
|
||||
import java.io.Serializable
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
|
||||
interface Manga : Serializable {
|
||||
interface Manga : SManga {
|
||||
|
||||
var id: Long?
|
||||
|
||||
var source: Int
|
||||
|
||||
var url: String
|
||||
|
||||
var title: String
|
||||
|
||||
var artist: String?
|
||||
|
||||
var author: String?
|
||||
|
||||
var description: String?
|
||||
|
||||
var genre: String?
|
||||
|
||||
var status: Int
|
||||
|
||||
var thumbnail_url: String?
|
||||
var source: Long
|
||||
|
||||
var favorite: Boolean
|
||||
|
||||
var last_update: Long
|
||||
|
||||
var initialized: Boolean
|
||||
|
||||
var viewer: Int
|
||||
|
||||
var chapter_flags: Int
|
||||
@ -38,27 +20,6 @@ interface Manga : Serializable {
|
||||
|
||||
var category: Int
|
||||
|
||||
fun copyFrom(other: Manga) {
|
||||
if (other.author != null)
|
||||
author = other.author
|
||||
|
||||
if (other.artist != null)
|
||||
artist = other.artist
|
||||
|
||||
if (other.description != null)
|
||||
description = other.description
|
||||
|
||||
if (other.genre != null)
|
||||
genre = other.genre
|
||||
|
||||
if (other.thumbnail_url != null)
|
||||
thumbnail_url = other.thumbnail_url
|
||||
|
||||
status = other.status
|
||||
|
||||
initialized = true
|
||||
}
|
||||
|
||||
fun setChapterOrder(order: Int) {
|
||||
setFlags(order, SORT_MASK)
|
||||
}
|
||||
@ -94,11 +55,6 @@ interface Manga : Serializable {
|
||||
|
||||
companion object {
|
||||
|
||||
const val UNKNOWN = 0
|
||||
const val ONGOING = 1
|
||||
const val COMPLETED = 2
|
||||
const val LICENSED = 3
|
||||
|
||||
const val SORT_DESC = 0x00000000
|
||||
const val SORT_ASC = 0x00000001
|
||||
const val SORT_MASK = 0x00000001
|
||||
@ -126,12 +82,13 @@ interface Manga : Serializable {
|
||||
const val DISPLAY_NUMBER = 0x00100000
|
||||
const val DISPLAY_MASK = 0x00100000
|
||||
|
||||
fun create(source: Int): Manga = MangaImpl().apply {
|
||||
fun create(source: Long): Manga = MangaImpl().apply {
|
||||
this.source = source
|
||||
}
|
||||
|
||||
fun create(pathUrl: String, source: Int = 0): Manga = MangaImpl().apply {
|
||||
fun create(pathUrl: String, title: String, source: Long = 0): Manga = MangaImpl().apply {
|
||||
url = pathUrl
|
||||
this.title = title
|
||||
this.source = source
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ class MangaImpl : Manga {
|
||||
|
||||
override var id: Long? = null
|
||||
|
||||
override var source: Int = 0
|
||||
override var source: Long = -1
|
||||
|
||||
override lateinit var url: String
|
||||
|
||||
|
@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.data.database.models
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
interface MangaSync : Serializable {
|
||||
interface Track : Serializable {
|
||||
|
||||
var id: Long?
|
||||
|
||||
@ -22,9 +22,7 @@ interface MangaSync : Serializable {
|
||||
|
||||
var status: Int
|
||||
|
||||
var update: Boolean
|
||||
|
||||
fun copyPersonalFrom(other: MangaSync) {
|
||||
fun copyPersonalFrom(other: Track) {
|
||||
last_chapter_read = other.last_chapter_read
|
||||
score = other.score
|
||||
status = other.status
|
||||
@ -32,7 +30,7 @@ interface MangaSync : Serializable {
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(serviceId: Int): MangaSync = MangaSyncImpl().apply {
|
||||
fun create(serviceId: Int): Track = TrackImpl().apply {
|
||||
sync_id = serviceId
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
package eu.kanade.tachiyomi.data.database.models
|
||||
|
||||
class MangaSyncImpl : MangaSync {
|
||||
class TrackImpl : Track {
|
||||
|
||||
override var id: Long? = null
|
||||
|
||||
@ -20,17 +20,15 @@ class MangaSyncImpl : MangaSync {
|
||||
|
||||
override var status: Int = 0
|
||||
|
||||
override var update: Boolean = false
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || javaClass != other.javaClass) return false
|
||||
|
||||
val mangaSync = other as MangaSync
|
||||
other as Track
|
||||
|
||||
if (manga_id != mangaSync.manga_id) return false
|
||||
if (sync_id != mangaSync.sync_id) return false
|
||||
return remote_id == mangaSync.remote_id
|
||||
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 {
|
@ -40,7 +40,7 @@ interface MangaQueries : DbProvider {
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun getManga(url: String, sourceId: Int) = db.get()
|
||||
fun getManga(url: String, sourceId: Long) = db.get()
|
||||
.`object`(Manga::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(MangaTable.TABLE)
|
||||
|
@ -1,46 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.database.queries
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.Query
|
||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSync
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable
|
||||
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
|
||||
|
||||
interface MangaSyncQueries : DbProvider {
|
||||
|
||||
fun getMangaSync(manga: Manga, sync: MangaSyncService) = db.get()
|
||||
.`object`(MangaSync::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(MangaSyncTable.TABLE)
|
||||
.where("${MangaSyncTable.COL_MANGA_ID} = ? AND " +
|
||||
"${MangaSyncTable.COL_SYNC_ID} = ?")
|
||||
.whereArgs(manga.id, sync.id)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun getMangasSync(manga: Manga) = db.get()
|
||||
.listOfObjects(MangaSync::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(MangaSyncTable.TABLE)
|
||||
.where("${MangaSyncTable.COL_MANGA_ID} = ?")
|
||||
.whereArgs(manga.id)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun insertMangaSync(manga: MangaSync) = db.put().`object`(manga).prepare()
|
||||
|
||||
fun insertMangasSync(mangas: List<MangaSync>) = db.put().objects(mangas).prepare()
|
||||
|
||||
fun deleteMangaSync(manga: MangaSync) = db.delete().`object`(manga).prepare()
|
||||
|
||||
fun deleteMangaSyncForManga(manga: Manga) = db.delete()
|
||||
.byQuery(DeleteQuery.builder()
|
||||
.table(MangaSyncTable.TABLE)
|
||||
.where("${MangaSyncTable.COL_MANGA_ID} = ?")
|
||||
.whereArgs(manga.id)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
package eu.kanade.tachiyomi.data.database.queries
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.Query
|
||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
|
||||
interface TrackQueries : DbProvider {
|
||||
|
||||
fun getTracks(manga: Manga) = db.get()
|
||||
.listOfObjects(Track::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(TrackTable.TABLE)
|
||||
.where("${TrackTable.COL_MANGA_ID} = ?")
|
||||
.whereArgs(manga.id)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun insertTrack(track: Track) = db.put().`object`(track).prepare()
|
||||
|
||||
fun insertTracks(tracks: List<Track>) = db.put().objects(tracks).prepare()
|
||||
|
||||
fun deleteTrackForManga(manga: Manga, sync: TrackService) = db.delete()
|
||||
.byQuery(DeleteQuery.builder()
|
||||
.table(TrackTable.TABLE)
|
||||
.where("${TrackTable.COL_MANGA_ID} = ? AND ${TrackTable.COL_SYNC_ID} = ?")
|
||||
.whereArgs(manga.id, sync.id)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
package eu.kanade.tachiyomi.data.database.tables
|
||||
|
||||
object MangaSyncTable {
|
||||
object TrackTable {
|
||||
|
||||
const val TABLE = "manga_sync"
|
||||
|
@ -6,8 +6,8 @@ import com.jakewharton.rxrelay.BehaviorRelay
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
||||
import eu.kanade.tachiyomi.data.source.Source
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import rx.Observable
|
||||
|
||||
/**
|
||||
@ -60,10 +60,19 @@ class DownloadManager(context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Empties the download queue.
|
||||
* Tells the downloader to pause downloads.
|
||||
*/
|
||||
fun clearQueue() {
|
||||
downloader.clearQueue()
|
||||
fun pauseDownloads() {
|
||||
downloader.pause()
|
||||
}
|
||||
|
||||
/**
|
||||
* Empties the download queue.
|
||||
*
|
||||
* @param isNotification value that determines if status is set (needed for view updates)
|
||||
*/
|
||||
fun clearQueue(isNotification: Boolean = false) {
|
||||
downloader.clearQueue(isNotification)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -168,5 +177,4 @@ class DownloadManager(context: Context) {
|
||||
fun deleteChapter(source: Source, manga: Manga, chapter: Chapter) {
|
||||
provider.findChapterDir(source, manga, chapter)?.delete()
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -7,6 +7,8 @@ import eu.kanade.tachiyomi.Constants
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.download.model.Download
|
||||
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationHandler
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
||||
import eu.kanade.tachiyomi.util.chop
|
||||
import eu.kanade.tachiyomi.util.notificationManager
|
||||
|
||||
@ -33,12 +35,34 @@ 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)
|
||||
}
|
||||
field = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Simultaneous download setting > 1.
|
||||
*/
|
||||
var multipleDownloadThreads = false
|
||||
|
||||
/**
|
||||
* Updated when error is thrown
|
||||
*/
|
||||
var errorThrown = false
|
||||
|
||||
/**
|
||||
* Updated when only single page is downloaded
|
||||
*/
|
||||
var isSingleChapter = false
|
||||
|
||||
/**
|
||||
* Updated when paused
|
||||
*/
|
||||
var paused = false
|
||||
|
||||
/**
|
||||
* Shows a notification from this builder.
|
||||
*
|
||||
@ -48,6 +72,14 @@ internal class DownloadNotifier(private val context: Context) {
|
||||
context.notificationManager.notify(id, build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear old actions if they exist.
|
||||
*/
|
||||
private fun clearActions() = with(notification) {
|
||||
if (!mActions.isEmpty())
|
||||
mActions.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the downloader's notification. Downloader error notifications use a different id, so
|
||||
* those can only be dismissed by the user.
|
||||
@ -88,24 +120,15 @@ internal class DownloadNotifier(private val context: Context) {
|
||||
* @param queue the queue containing downloads.
|
||||
*/
|
||||
private fun doOnProgressChange(download: Download?, queue: DownloadQueue) {
|
||||
// Check if download is completed
|
||||
if (multipleDownloadThreads) {
|
||||
if (queue.isEmpty()) {
|
||||
onChapterCompleted(null)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (download != null && download.pages!!.size == download.downloadedImages) {
|
||||
onChapterCompleted(download)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Create notification
|
||||
with(notification) {
|
||||
// Check if icon needs refresh
|
||||
// Check if first call.
|
||||
if (!isDownloading) {
|
||||
setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
setAutoCancel(false)
|
||||
clearActions()
|
||||
// Open download manager when clicked
|
||||
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
|
||||
isDownloading = true
|
||||
}
|
||||
|
||||
@ -121,7 +144,9 @@ internal class DownloadNotifier(private val context: Context) {
|
||||
setProgress(initialQueueSize, initialQueueSize - queue.size, false)
|
||||
} else {
|
||||
download?.let {
|
||||
setContentTitle(it.chapter.name.chop(30))
|
||||
val title = it.manga.title.chop(15)
|
||||
val chapter = download.chapter.name.replaceFirst("$title[\\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)
|
||||
@ -133,17 +158,57 @@ internal class DownloadNotifier(private val context: Context) {
|
||||
notification.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Show notification when download is paused.
|
||||
*/
|
||||
fun onDownloadPaused() {
|
||||
with(notification) {
|
||||
setContentTitle(context.getString(R.string.chapter_paused))
|
||||
setContentText(context.getString(R.string.download_notifier_download_paused))
|
||||
setSmallIcon(R.drawable.ic_av_pause_grey_24dp_img)
|
||||
setAutoCancel(false)
|
||||
setProgress(0, 0, false)
|
||||
clearActions()
|
||||
// Open download manager when clicked
|
||||
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
|
||||
// Resume action
|
||||
addAction(R.drawable.ic_av_play_arrow_grey_img,
|
||||
context.getString(R.string.action_resume),
|
||||
NotificationReceiver.resumeDownloadsPendingBroadcast(context))
|
||||
//Clear action
|
||||
addAction(R.drawable.ic_clear_grey_24dp_img,
|
||||
context.getString(R.string.action_clear),
|
||||
NotificationReceiver.clearDownloadsPendingBroadcast(context))
|
||||
}
|
||||
|
||||
// Show notification.
|
||||
notification.show()
|
||||
|
||||
// Reset initial values
|
||||
isDownloading = false
|
||||
initialQueueSize = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when chapter is downloaded.
|
||||
*
|
||||
* @param download download object containing download information.
|
||||
*/
|
||||
private fun onChapterCompleted(download: Download?) {
|
||||
fun onDownloadCompleted(download: Download, queue: DownloadQueue) {
|
||||
// Check if last download
|
||||
if (!queue.isEmpty()) {
|
||||
return
|
||||
}
|
||||
// Create notification.
|
||||
with(notification) {
|
||||
setContentTitle(download?.chapter?.name ?: context.getString(R.string.app_name))
|
||||
val title = download.manga.title.chop(15)
|
||||
val chapter = download.chapter.name.replaceFirst("$title[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "")
|
||||
setContentTitle("$title - $chapter".chop(30))
|
||||
setContentText(context.getString(R.string.update_check_notification_download_complete))
|
||||
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
setAutoCancel(true)
|
||||
clearActions()
|
||||
setContentIntent(NotificationReceiver.openChapterPendingBroadcast(context, download.manga, download.chapter))
|
||||
setProgress(0, 0, false)
|
||||
}
|
||||
|
||||
@ -165,9 +230,15 @@ internal class DownloadNotifier(private val context: Context) {
|
||||
setContentTitle(context.getString(R.string.download_notifier_downloader_title))
|
||||
setContentText(reason)
|
||||
setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||
setAutoCancel(true)
|
||||
clearActions()
|
||||
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
|
||||
setProgress(0, 0, false)
|
||||
}
|
||||
notification.show()
|
||||
|
||||
// Reset download information
|
||||
isDownloading = false
|
||||
}
|
||||
|
||||
/**
|
||||
@ -183,11 +254,15 @@ internal class DownloadNotifier(private val context: Context) {
|
||||
setContentTitle(chapter ?: context.getString(R.string.download_notifier_downloader_title))
|
||||
setContentText(error ?: context.getString(R.string.download_notifier_unkown_error))
|
||||
setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||
clearActions()
|
||||
setAutoCancel(false)
|
||||
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
|
||||
setProgress(0, 0, false)
|
||||
}
|
||||
notification.show(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID)
|
||||
|
||||
// Reset download information
|
||||
errorThrown = true
|
||||
isDownloading = false
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.data.source.Source
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.util.DiskUtil
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
|
@ -5,8 +5,8 @@ import com.google.gson.Gson
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.download.model.Download
|
||||
import eu.kanade.tachiyomi.data.source.SourceManager
|
||||
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
@ -93,7 +93,7 @@ class DownloadStore(context: Context) {
|
||||
val manga = cachedManga.getOrPut(mangaId) {
|
||||
db.getManga(mangaId).executeAsBlocking()
|
||||
} ?: continue
|
||||
val source = sourceManager.get(manga.source) as? OnlineSource ?: continue
|
||||
val source = sourceManager.get(manga.source) as? HttpSource ?: continue
|
||||
val chapter = db.getChapter(chapterId).executeAsBlocking() ?: continue
|
||||
downloads.add(Download(source, manga, chapter))
|
||||
}
|
||||
|
@ -10,13 +10,12 @@ import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.download.model.Download
|
||||
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.source.SourceManager
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
||||
import eu.kanade.tachiyomi.util.DynamicConcurrentMergeOperator
|
||||
import eu.kanade.tachiyomi.util.RetryWithDelay
|
||||
import eu.kanade.tachiyomi.util.plusAssign
|
||||
import eu.kanade.tachiyomi.util.saveTo
|
||||
import 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
|
||||
import eu.kanade.tachiyomi.source.online.fetchAllImageUrlsFromPageList
|
||||
import eu.kanade.tachiyomi.util.*
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
@ -25,7 +24,6 @@ import rx.subjects.BehaviorSubject
|
||||
import rx.subscriptions.CompositeSubscription
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.net.URLConnection
|
||||
|
||||
/**
|
||||
* This class is the one in charge of downloading chapters.
|
||||
@ -132,15 +130,42 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
if (reason != null) {
|
||||
notifier.onWarning(reason)
|
||||
} else {
|
||||
notifier.dismiss()
|
||||
if (notifier.paused) {
|
||||
notifier.paused = false
|
||||
notifier.onDownloadPaused()
|
||||
} else if (notifier.isSingleChapter && !notifier.errorThrown) {
|
||||
notifier.isSingleChapter = false
|
||||
} else {
|
||||
notifier.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes everything from the queue.
|
||||
* Pauses the downloader
|
||||
*/
|
||||
fun clearQueue() {
|
||||
fun pause() {
|
||||
destroySubscriptions()
|
||||
queue
|
||||
.filter { it.status == Download.DOWNLOADING }
|
||||
.forEach { it.status = Download.QUEUE }
|
||||
notifier.paused = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes everything from the queue.
|
||||
*
|
||||
* @param isNotification value that determines if status is set (needed for view updates)
|
||||
*/
|
||||
fun clearQueue(isNotification: Boolean = false) {
|
||||
destroySubscriptions()
|
||||
|
||||
//Needed to update the chapter view
|
||||
if (isNotification) {
|
||||
queue
|
||||
.filter { it.status == Download.QUEUE }
|
||||
.forEach { it.status = Download.NOT_DOWNLOADED }
|
||||
}
|
||||
queue.clear()
|
||||
notifier.dismiss()
|
||||
}
|
||||
@ -192,7 +217,7 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
* @param chapters the list of chapters to download.
|
||||
*/
|
||||
fun queueChapters(manga: Manga, chapters: List<Chapter>) {
|
||||
val source = sourceManager.get(manga.source) as? OnlineSource ?: return
|
||||
val source = sourceManager.get(manga.source) as? HttpSource ?: return
|
||||
|
||||
val chaptersToQueue = chapters
|
||||
// Avoid downloading chapters with the same name.
|
||||
@ -213,6 +238,9 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
// 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)
|
||||
@ -251,8 +279,11 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
|
||||
val pageListObservable = if (download.pages == null) {
|
||||
// Pull page list from network and add them to download object
|
||||
download.source.fetchPageListFromNetwork(download.chapter)
|
||||
download.source.fetchPageList(download.chapter)
|
||||
.doOnNext { pages ->
|
||||
if (pages.isEmpty()) {
|
||||
throw Exception("Page list is empty")
|
||||
}
|
||||
download.pages = pages
|
||||
}
|
||||
} else {
|
||||
@ -309,7 +340,7 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
tmpFile?.delete()
|
||||
|
||||
// Try to find the image file.
|
||||
val imageFile = tmpDir.listFiles()!!.find { it.name!!.startsWith("$filename.")}
|
||||
val imageFile = tmpDir.listFiles()!!.find { it.name!!.startsWith("$filename.") }
|
||||
|
||||
// If the image is already downloaded, do nothing. Otherwise download from network
|
||||
val pageObservable = if (imageFile != null)
|
||||
@ -342,10 +373,10 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
* @param tmpDir the temporary directory of the download.
|
||||
* @param filename the filename of the image.
|
||||
*/
|
||||
private fun downloadImage(page: Page, source: OnlineSource, tmpDir: UniFile, filename: String): Observable<UniFile> {
|
||||
private fun downloadImage(page: Page, source: HttpSource, tmpDir: UniFile, filename: String): Observable<UniFile> {
|
||||
page.status = Page.DOWNLOAD_IMAGE
|
||||
page.progress = 0
|
||||
return source.imageResponse(page)
|
||||
return source.fetchImage(page)
|
||||
.map { response ->
|
||||
val file = tmpDir.createFile("$filename.tmp")
|
||||
try {
|
||||
@ -373,12 +404,10 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
private fun getImageExtension(response: Response, file: UniFile): String {
|
||||
// Read content type if available.
|
||||
val mime = response.body().contentType()?.let { ct -> "${ct.type()}/${ct.subtype()}" }
|
||||
// Else guess from the uri.
|
||||
?: context.contentResolver.getType(file.uri)
|
||||
// Else read magic numbers.
|
||||
?: file.openInputStream().buffered().use {
|
||||
URLConnection.guessContentTypeFromStream(it)
|
||||
}
|
||||
// Else guess from the uri.
|
||||
?: context.contentResolver.getType(file.uri)
|
||||
// Else read magic numbers.
|
||||
?: DiskUtil.findImageMime { file.openInputStream() }
|
||||
|
||||
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
|
||||
}
|
||||
@ -417,6 +446,9 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
notifier.onProgressChange(queue)
|
||||
}
|
||||
if (areAllDownloadsFinished()) {
|
||||
if (notifier.isSingleChapter && !notifier.errorThrown) {
|
||||
notifier.onDownloadCompleted(download, queue)
|
||||
}
|
||||
DownloadService.stop(context)
|
||||
}
|
||||
}
|
||||
|
@ -2,11 +2,11 @@ package eu.kanade.tachiyomi.data.download.model
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import rx.subjects.PublishSubject
|
||||
|
||||
class Download(val source: OnlineSource, val manga: Manga, val chapter: Chapter) {
|
||||
class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) {
|
||||
|
||||
var pages: List<Page>? = null
|
||||
|
||||
|
@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.data.download.model
|
||||
import com.jakewharton.rxrelay.PublishRelay
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.download.DownloadStore
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import rx.Observable
|
||||
import rx.subjects.PublishSubject
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
@ -8,7 +8,7 @@ import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
|
||||
import com.bumptech.glide.load.model.GlideUrl
|
||||
import com.bumptech.glide.module.GlideModule
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.InputStream
|
||||
|
@ -0,0 +1,35 @@
|
||||
package eu.kanade.tachiyomi.data.glide
|
||||
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
open class FileFetcher(private val file: File) : DataFetcher<InputStream> {
|
||||
|
||||
private var data: InputStream? = null
|
||||
|
||||
override fun loadData(priority: Priority): InputStream {
|
||||
data = file.inputStream()
|
||||
return data!!
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
data?.let { data ->
|
||||
try {
|
||||
data.close()
|
||||
} catch (e: IOException) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
override fun getId(): String {
|
||||
return file.toString()
|
||||
}
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
package eu.kanade.tachiyomi.data.glide
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import java.io.File
|
||||
|
||||
open class MangaFileFetcher(private val file: File, private val manga: Manga) : FileFetcher(file) {
|
||||
|
||||
/**
|
||||
* Returns the id for this manga's cover.
|
||||
*
|
||||
* Appending the file's modified date to the url, we can force Glide to skip its memory and disk
|
||||
* lookup step and fetch from our custom cache. This allows us to invalidate Glide's cache when
|
||||
* the file has changed. If the file doesn't exist it will append a 0.
|
||||
*/
|
||||
override fun getId(): String {
|
||||
return manga.thumbnail_url + file.lastModified()
|
||||
}
|
||||
}
|
@ -3,20 +3,21 @@ package eu.kanade.tachiyomi.data.glide
|
||||
import android.content.Context
|
||||
import android.util.LruCache
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.integration.okhttp3.OkHttpStreamFetcher
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import com.bumptech.glide.load.model.*
|
||||
import com.bumptech.glide.load.model.stream.StreamModelLoader
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.source.SourceManager
|
||||
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* A class for loading a cover associated with a [Manga] that can be present in our own cache.
|
||||
* Coupled with [MangaDataFetcher], this class allows to implement the following flow:
|
||||
* Coupled with [MangaUrlFetcher], this class allows to implement the following flow:
|
||||
*
|
||||
* - Check in RAM LRU.
|
||||
* - Check in disk LRU.
|
||||
@ -30,17 +31,17 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
|
||||
/**
|
||||
* Cover cache where persistent covers are stored.
|
||||
*/
|
||||
val coverCache: CoverCache by injectLazy()
|
||||
private val coverCache: CoverCache by injectLazy()
|
||||
|
||||
/**
|
||||
* Source manager.
|
||||
*/
|
||||
val sourceManager: SourceManager by injectLazy()
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
|
||||
/**
|
||||
* Base network loader.
|
||||
*/
|
||||
private val baseLoader = Glide.buildModelLoader(GlideUrl::class.java,
|
||||
private val baseUrlLoader = Glide.buildModelLoader(GlideUrl::class.java,
|
||||
InputStream::class.java, context)
|
||||
|
||||
/**
|
||||
@ -52,7 +53,7 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
|
||||
/**
|
||||
* Map where request headers are stored for a source.
|
||||
*/
|
||||
private val cachedHeaders = hashMapOf<Int, LazyHeaders>()
|
||||
private val cachedHeaders = hashMapOf<Long, LazyHeaders>()
|
||||
|
||||
/**
|
||||
* Factory class for creating [MangaModelLoader] instances.
|
||||
@ -66,7 +67,7 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a [MangaDataFetcher] for the given manga or null if the url is empty.
|
||||
* Returns a fetcher for the given manga or null if the url is empty.
|
||||
*
|
||||
* @param manga the model.
|
||||
* @param width the width of the view where the resource will be loaded.
|
||||
@ -78,22 +79,33 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
|
||||
|
||||
// Check thumbnail is not null or empty
|
||||
val url = manga.thumbnail_url
|
||||
if (url.isNullOrEmpty()) {
|
||||
if (url == null || url.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Obtain the request url and the file for this url from the LRU cache, or calculate it
|
||||
// and add them to the cache.
|
||||
val (glideUrl, file) = lruCache.get(url) ?:
|
||||
Pair(GlideUrl(url, getHeaders(manga)), coverCache.getCoverFile(url!!)).apply {
|
||||
lruCache.put(url, this)
|
||||
}
|
||||
if (url.startsWith("http")) {
|
||||
val source = sourceManager.get(manga.source) as? HttpSource
|
||||
|
||||
// Get the network fetcher for this request url.
|
||||
val networkFetcher = baseLoader.getResourceFetcher(glideUrl, width, height)
|
||||
// Obtain the request url and the file for this url from the LRU cache, or calculate it
|
||||
// and add them to the cache.
|
||||
val (glideUrl, file) = lruCache.get(url) ?:
|
||||
Pair(GlideUrl(url, getHeaders(manga, source)), coverCache.getCoverFile(url)).apply {
|
||||
lruCache.put(url, this)
|
||||
}
|
||||
|
||||
// Return an instance of our fetcher providing the needed elements.
|
||||
return MangaDataFetcher(networkFetcher, file, manga)
|
||||
// Get the resource fetcher for this request url.
|
||||
val networkFetcher = source?.let { OkHttpStreamFetcher(it.client, glideUrl) }
|
||||
?: baseUrlLoader.getResourceFetcher(glideUrl, width, height)
|
||||
|
||||
// Return an instance of the fetcher providing the needed elements.
|
||||
return MangaUrlFetcher(networkFetcher, file, manga)
|
||||
} else {
|
||||
// Get the file from the url, removing the scheme if present.
|
||||
val file = File(url.substringAfter("file://"))
|
||||
|
||||
// Return an instance of the fetcher providing the needed elements.
|
||||
return MangaFileFetcher(file, manga)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -101,8 +113,9 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
|
||||
*
|
||||
* @param manga the model.
|
||||
*/
|
||||
fun getHeaders(manga: Manga): Headers {
|
||||
val source = sourceManager.get(manga.source) as? OnlineSource ?: return LazyHeaders.DEFAULT
|
||||
fun getHeaders(manga: Manga, source: HttpSource?): Headers {
|
||||
if (source == null) return LazyHeaders.DEFAULT
|
||||
|
||||
return cachedHeaders.getOrPut(manga.source) {
|
||||
LazyHeaders.Builder().apply {
|
||||
val nullStr: String? = null
|
||||
|
@ -18,13 +18,12 @@ import java.io.InputStream
|
||||
* @param file the file where this cover should be. It may exists or not.
|
||||
* @param manga the manga of the cover to load.
|
||||
*/
|
||||
class MangaDataFetcher(private val networkFetcher: DataFetcher<InputStream>,
|
||||
private val file: File,
|
||||
private val manga: Manga)
|
||||
: DataFetcher<InputStream> {
|
||||
class MangaUrlFetcher(private val networkFetcher: DataFetcher<InputStream>,
|
||||
private val file: File,
|
||||
private val manga: Manga)
|
||||
: MangaFileFetcher(file, manga) {
|
||||
|
||||
@Throws(Exception::class)
|
||||
override fun loadData(priority: Priority): InputStream? {
|
||||
override fun loadData(priority: Priority): InputStream {
|
||||
if (manga.favorite) {
|
||||
synchronized(file) {
|
||||
if (!file.exists()) {
|
||||
@ -51,7 +50,7 @@ class MangaDataFetcher(private val networkFetcher: DataFetcher<InputStream>,
|
||||
}
|
||||
}
|
||||
}
|
||||
return file.inputStream()
|
||||
return super.loadData(priority)
|
||||
} else {
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
@ -60,22 +59,12 @@ class MangaDataFetcher(private val networkFetcher: DataFetcher<InputStream>,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the id for this manga's cover.
|
||||
*
|
||||
* Appending the file's modified date to the url, we can force Glide to skip its memory and disk
|
||||
* lookup step and fetch from our custom cache. This allows us to invalidate Glide's cache when
|
||||
* the file has changed. If the file doesn't exist it will append a 0.
|
||||
*/
|
||||
override fun getId(): String {
|
||||
return manga.thumbnail_url + file.lastModified()
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
networkFetcher.cancel()
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
super.cleanup()
|
||||
networkFetcher.cleanup()
|
||||
}
|
||||
|
@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.data.library
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
@ -18,10 +17,12 @@ import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.data.download.DownloadService
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.data.source.SourceManager
|
||||
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||
import eu.kanade.tachiyomi.util.*
|
||||
import rx.Observable
|
||||
@ -68,6 +69,11 @@ class LibraryUpdateService : Service() {
|
||||
*/
|
||||
private var subscription: Subscription? = null
|
||||
|
||||
/**
|
||||
* Pending intent of action that cancels the library update
|
||||
*/
|
||||
private val cancelPendingIntent by lazy {NotificationReceiver.cancelLibraryUpdatePendingBroadcast(this)}
|
||||
|
||||
/**
|
||||
* Id of the library update notification.
|
||||
*/
|
||||
@ -214,7 +220,7 @@ class LibraryUpdateService : Service() {
|
||||
}
|
||||
|
||||
if (!intent.getBooleanExtra(UPDATE_DETAILS, false) && preferences.updateOnlyNonCompleted()) {
|
||||
listToUpdate = listToUpdate.filter { it.status != Manga.COMPLETED }
|
||||
listToUpdate = listToUpdate.filter { it.status != SManga.COMPLETED }
|
||||
}
|
||||
|
||||
return listToUpdate
|
||||
@ -235,13 +241,10 @@ class LibraryUpdateService : Service() {
|
||||
val newUpdates = ArrayList<Manga>()
|
||||
val failedUpdates = ArrayList<Manga>()
|
||||
|
||||
val cancelIntent = PendingIntent.getBroadcast(this, 0,
|
||||
Intent(this, CancelUpdateReceiver::class.java), 0)
|
||||
|
||||
// Emit each manga and update it sequentially.
|
||||
return Observable.from(mangaToUpdate)
|
||||
// Notify manga that will update.
|
||||
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size, cancelIntent) }
|
||||
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size, cancelPendingIntent) }
|
||||
// Update the chapters of the manga.
|
||||
.concatMap { manga ->
|
||||
updateManga(manga)
|
||||
@ -297,7 +300,7 @@ class LibraryUpdateService : Service() {
|
||||
* @return a pair of the inserted and removed chapters.
|
||||
*/
|
||||
fun updateManga(manga: Manga): Observable<Pair<List<Chapter>, List<Chapter>>> {
|
||||
val source = sourceManager.get(manga.source) as? OnlineSource ?: return Observable.empty()
|
||||
val source = sourceManager.get(manga.source) as? HttpSource ?: return Observable.empty()
|
||||
return source.fetchChapterList(manga)
|
||||
.map { syncChaptersWithSource(db, it, manga, source) }
|
||||
}
|
||||
@ -315,22 +318,20 @@ class LibraryUpdateService : Service() {
|
||||
// Initialize the variables holding the progress of the updates.
|
||||
val count = AtomicInteger(0)
|
||||
|
||||
val cancelIntent = PendingIntent.getBroadcast(this, 0,
|
||||
Intent(this, CancelUpdateReceiver::class.java), 0)
|
||||
|
||||
// Emit each manga and update it sequentially.
|
||||
return Observable.from(mangaToUpdate)
|
||||
// Notify manga that will update.
|
||||
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size, cancelIntent) }
|
||||
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size, cancelPendingIntent) }
|
||||
// Update the details of the manga.
|
||||
.concatMap { manga ->
|
||||
val source = sourceManager.get(manga.source) as? OnlineSource
|
||||
val source = sourceManager.get(manga.source) as? HttpSource
|
||||
?: return@concatMap Observable.empty<Manga>()
|
||||
|
||||
source.fetchMangaDetails(manga)
|
||||
.doOnNext { networkManga ->
|
||||
.map { networkManga ->
|
||||
manga.copyFrom(networkManga)
|
||||
db.insertManga(manga).executeAsBlocking()
|
||||
manga
|
||||
}
|
||||
.onErrorReturn { manga }
|
||||
}
|
||||
@ -457,19 +458,4 @@ class LibraryUpdateService : Service() {
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Class that stops updating the library.
|
||||
*/
|
||||
class CancelUpdateReceiver : BroadcastReceiver() {
|
||||
/**
|
||||
* Method called when user wants a library update.
|
||||
* @param context the application context.
|
||||
* @param intent the intent received.
|
||||
*/
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
LibraryUpdateService.stop(context)
|
||||
context.notificationManager.cancel(Constants.NOTIFICATION_LIBRARY_ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,23 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.mangasync
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.data.mangasync.anilist.Anilist
|
||||
import eu.kanade.tachiyomi.data.mangasync.myanimelist.MyAnimeList
|
||||
|
||||
class MangaSyncManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
const val MYANIMELIST = 1
|
||||
const val ANILIST = 2
|
||||
}
|
||||
|
||||
val myAnimeList = MyAnimeList(context, MYANIMELIST)
|
||||
|
||||
val aniList = Anilist(context, ANILIST)
|
||||
|
||||
// TODO enable anilist
|
||||
val services = listOf(myAnimeList)
|
||||
|
||||
fun getService(id: Int) = services.find { it.id == id }
|
||||
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.mangasync
|
||||
|
||||
import android.content.Context
|
||||
import android.support.annotation.CallSuper
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSync
|
||||
import eu.kanade.tachiyomi.data.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import okhttp3.OkHttpClient
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
abstract class MangaSyncService(private val context: Context, val id: Int) {
|
||||
|
||||
val preferences: PreferencesHelper by injectLazy()
|
||||
val networkService: NetworkHelper by injectLazy()
|
||||
|
||||
open val client: OkHttpClient
|
||||
get() = networkService.client
|
||||
|
||||
// Name of the manga sync service to display
|
||||
abstract val name: String
|
||||
|
||||
abstract fun login(username: String, password: String): Completable
|
||||
|
||||
open val isLogged: Boolean
|
||||
get() = !getUsername().isEmpty() &&
|
||||
!getPassword().isEmpty()
|
||||
|
||||
abstract fun add(manga: MangaSync): Observable<MangaSync>
|
||||
|
||||
abstract fun update(manga: MangaSync): Observable<MangaSync>
|
||||
|
||||
abstract fun bind(manga: MangaSync): Observable<MangaSync>
|
||||
|
||||
abstract fun getStatus(status: Int): String
|
||||
|
||||
fun saveCredentials(username: String, password: String) {
|
||||
preferences.setMangaSyncCredentials(this, username, password)
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
open fun logout() {
|
||||
preferences.setMangaSyncCredentials(this, "", "")
|
||||
}
|
||||
|
||||
fun getUsername() = preferences.mangaSyncUsername(this)
|
||||
|
||||
fun getPassword() = preferences.mangaSyncPassword(this)
|
||||
|
||||
}
|
@ -1,74 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.mangasync
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSync
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import rx.subscriptions.CompositeSubscription
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class UpdateMangaSyncService : Service() {
|
||||
|
||||
val syncManager: MangaSyncManager by injectLazy()
|
||||
val db: DatabaseHelper by injectLazy()
|
||||
|
||||
private lateinit var subscriptions: CompositeSubscription
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
subscriptions = CompositeSubscription()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
subscriptions.unsubscribe()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
val manga = intent.getSerializableExtra(EXTRA_MANGASYNC)
|
||||
if (manga != null) {
|
||||
updateLastChapterRead(manga as MangaSync, startId)
|
||||
return Service.START_REDELIVER_INTENT
|
||||
} else {
|
||||
stopSelf(startId)
|
||||
return Service.START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun updateLastChapterRead(mangaSync: MangaSync, startId: Int) {
|
||||
val sync = syncManager.getService(mangaSync.sync_id)
|
||||
if (sync == null) {
|
||||
stopSelf(startId)
|
||||
return
|
||||
}
|
||||
|
||||
subscriptions.add(Observable.defer { sync.update(mangaSync) }
|
||||
.flatMap { db.insertMangaSync(mangaSync).asRxObservable() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({ stopSelf(startId) },
|
||||
{ stopSelf(startId) }))
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val EXTRA_MANGASYNC = "extra_mangasync"
|
||||
|
||||
@JvmStatic
|
||||
fun start(context: Context, mangaSync: MangaSync) {
|
||||
val intent = Intent(context, UpdateMangaSyncService::class.java)
|
||||
intent.putExtra(EXTRA_MANGASYNC, mangaSync)
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,132 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.mangasync.anilist
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSync
|
||||
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
import timber.log.Timber
|
||||
|
||||
class Anilist(private val context: Context, id: Int) : MangaSyncService(context, id) {
|
||||
|
||||
companion object {
|
||||
const val READING = 1
|
||||
const val COMPLETED = 2
|
||||
const val ON_HOLD = 3
|
||||
const val DROPPED = 4
|
||||
const val PLAN_TO_READ = 5
|
||||
|
||||
const val DEFAULT_STATUS = READING
|
||||
const val DEFAULT_SCORE = 0
|
||||
}
|
||||
|
||||
override val name = "AniList"
|
||||
|
||||
private val interceptor by lazy { AnilistInterceptor(getPassword()) }
|
||||
|
||||
private val api by lazy {
|
||||
AnilistApi.createService(networkService.client.newBuilder()
|
||||
.addInterceptor(interceptor)
|
||||
.build())
|
||||
}
|
||||
|
||||
override fun login(username: String, password: String) = login(password)
|
||||
|
||||
fun login(authCode: String): Completable {
|
||||
// Create a new api with the default client to avoid request interceptions.
|
||||
return AnilistApi.createService(client)
|
||||
// Request the access token from the API with the authorization code.
|
||||
.requestAccessToken(authCode)
|
||||
// Save the token in the interceptor.
|
||||
.doOnNext { interceptor.setAuth(it) }
|
||||
// Obtain the authenticated user from the API.
|
||||
.zipWith(api.getCurrentUser().map { it["id"].toString() })
|
||||
{ oauth, user -> Pair(user, oauth.refresh_token!!) }
|
||||
// Save service credentials (username and refresh token).
|
||||
.doOnNext { saveCredentials(it.first, it.second) }
|
||||
// Logout on any error.
|
||||
.doOnError { logout() }
|
||||
.toCompletable()
|
||||
}
|
||||
|
||||
override fun logout() {
|
||||
super.logout()
|
||||
interceptor.setAuth(null)
|
||||
}
|
||||
|
||||
fun search(query: String): Observable<List<MangaSync>> {
|
||||
return api.search(query, 1)
|
||||
.flatMap { Observable.from(it) }
|
||||
.filter { it.type != "Novel" }
|
||||
.map { it.toMangaSync() }
|
||||
.toList()
|
||||
}
|
||||
|
||||
fun getList(): Observable<List<MangaSync>> {
|
||||
return api.getList(getUsername())
|
||||
.flatMap { Observable.from(it.flatten()) }
|
||||
.map { it.toMangaSync() }
|
||||
.toList()
|
||||
}
|
||||
|
||||
override fun add(manga: MangaSync): Observable<MangaSync> {
|
||||
return api.addManga(manga.remote_id, manga.last_chapter_read, manga.getAnilistStatus(),
|
||||
manga.score.toInt())
|
||||
.doOnNext { it.body().close() }
|
||||
.doOnNext { if (!it.isSuccessful) throw Exception("Could not add manga") }
|
||||
.doOnError { Timber.e(it, it.message) }
|
||||
.map { manga }
|
||||
}
|
||||
|
||||
override fun update(manga: MangaSync): Observable<MangaSync> {
|
||||
if (manga.total_chapters != 0 && manga.last_chapter_read == manga.total_chapters) {
|
||||
manga.status = COMPLETED
|
||||
}
|
||||
return api.updateManga(manga.remote_id, manga.last_chapter_read, manga.getAnilistStatus(),
|
||||
manga.score.toInt())
|
||||
.doOnNext { it.body().close() }
|
||||
.doOnNext { if (!it.isSuccessful) throw Exception("Could not update manga") }
|
||||
.doOnError { Timber.e(it, it.message) }
|
||||
.map { manga }
|
||||
}
|
||||
|
||||
override fun bind(manga: MangaSync): Observable<MangaSync> {
|
||||
return getList()
|
||||
.flatMap { userlist ->
|
||||
manga.sync_id = id
|
||||
val mangaFromList = userlist.find { it.remote_id == manga.remote_id }
|
||||
if (mangaFromList != null) {
|
||||
manga.copyPersonalFrom(mangaFromList)
|
||||
update(manga)
|
||||
} else {
|
||||
// Set default fields if it's not found in the list
|
||||
manga.score = DEFAULT_SCORE.toFloat()
|
||||
manga.status = DEFAULT_STATUS
|
||||
add(manga)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getStatus(status: Int): String = with(context) {
|
||||
when (status) {
|
||||
READING -> getString(R.string.reading)
|
||||
COMPLETED -> getString(R.string.completed)
|
||||
ON_HOLD -> getString(R.string.on_hold)
|
||||
DROPPED -> getString(R.string.dropped)
|
||||
PLAN_TO_READ -> getString(R.string.plan_to_read)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun MangaSync.getAnilistStatus() = when (status) {
|
||||
READING -> "reading"
|
||||
COMPLETED -> "completed"
|
||||
ON_HOLD -> "on-hold"
|
||||
DROPPED -> "dropped"
|
||||
PLAN_TO_READ -> "plan to read"
|
||||
else -> throw NotImplementedError("Unknown status")
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,89 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.mangasync.anilist
|
||||
|
||||
import android.net.Uri
|
||||
import com.google.gson.JsonObject
|
||||
import eu.kanade.tachiyomi.data.mangasync.anilist.model.ALManga
|
||||
import eu.kanade.tachiyomi.data.mangasync.anilist.model.ALUserLists
|
||||
import eu.kanade.tachiyomi.data.mangasync.anilist.model.OAuth
|
||||
import eu.kanade.tachiyomi.data.network.POST
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import retrofit2.http.*
|
||||
import rx.Observable
|
||||
|
||||
interface AnilistApi {
|
||||
|
||||
companion object {
|
||||
private const val clientId = "tachiyomi-hrtje"
|
||||
private const val clientSecret = "nlGB5OmgE9YWq5dr3gIDbTQV0C"
|
||||
private const val clientUrl = "tachiyomi://anilist-auth"
|
||||
private const val baseUrl = "https://anilist.co/api/"
|
||||
|
||||
fun authUrl() = Uri.parse("${baseUrl}auth/authorize").buildUpon()
|
||||
.appendQueryParameter("grant_type", "authorization_code")
|
||||
.appendQueryParameter("client_id", clientId)
|
||||
.appendQueryParameter("redirect_uri", clientUrl)
|
||||
.appendQueryParameter("response_type", "code")
|
||||
.build()
|
||||
|
||||
fun refreshTokenRequest(token: String) = POST("${baseUrl}auth/access_token",
|
||||
body = FormBody.Builder()
|
||||
.add("grant_type", "refresh_token")
|
||||
.add("client_id", clientId)
|
||||
.add("client_secret", clientSecret)
|
||||
.add("refresh_token", token)
|
||||
.build())
|
||||
|
||||
fun createService(client: OkHttpClient) = Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||
.build()
|
||||
.create(AnilistApi::class.java)
|
||||
|
||||
}
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("auth/access_token")
|
||||
fun requestAccessToken(
|
||||
@Field("code") code: String,
|
||||
@Field("grant_type") grant_type: String = "authorization_code",
|
||||
@Field("client_id") client_id: String = clientId,
|
||||
@Field("client_secret") client_secret: String = clientSecret,
|
||||
@Field("redirect_uri") redirect_uri: String = clientUrl)
|
||||
: Observable<OAuth>
|
||||
|
||||
@GET("user")
|
||||
fun getCurrentUser(): Observable<JsonObject>
|
||||
|
||||
@GET("manga/search/{query}")
|
||||
fun search(@Path("query") query: String, @Query("page") page: Int): Observable<List<ALManga>>
|
||||
|
||||
@GET("user/{username}/mangalist")
|
||||
fun getList(@Path("username") username: String): Observable<ALUserLists>
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT("mangalist")
|
||||
fun addManga(
|
||||
@Field("id") id: Int,
|
||||
@Field("chapters_read") chapters_read: Int,
|
||||
@Field("list_status") list_status: String,
|
||||
@Field("score_raw") score_raw: Int)
|
||||
: Observable<Response<ResponseBody>>
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT("mangalist")
|
||||
fun updateManga(
|
||||
@Field("id") id: Int,
|
||||
@Field("chapters_read") chapters_read: Int,
|
||||
@Field("list_status") list_status: String,
|
||||
@Field("score_raw") score_raw: Int)
|
||||
: Observable<Response<ResponseBody>>
|
||||
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.mangasync.anilist.model
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSync
|
||||
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
|
||||
|
||||
data class ALManga(
|
||||
val id: Int,
|
||||
val title_romaji: String,
|
||||
val type: String,
|
||||
val total_chapters: Int) {
|
||||
|
||||
fun toMangaSync() = MangaSync.create(MangaSyncManager.ANILIST).apply {
|
||||
remote_id = this@ALManga.id
|
||||
title = title_romaji
|
||||
total_chapters = this@ALManga.total_chapters
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.mangasync.anilist.model
|
||||
|
||||
data class ALUserLists(val lists: Map<String, List<ALUserManga>>) {
|
||||
|
||||
fun flatten() = lists.values.flatten()
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.mangasync.anilist.model
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSync
|
||||
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
|
||||
import eu.kanade.tachiyomi.data.mangasync.anilist.Anilist
|
||||
|
||||
data class ALUserManga(
|
||||
val id: Int,
|
||||
val list_status: String,
|
||||
val score_raw: Int,
|
||||
val chapters_read: Int,
|
||||
val manga: ALManga) {
|
||||
|
||||
fun toMangaSync() = MangaSync.create(MangaSyncManager.ANILIST).apply {
|
||||
remote_id = manga.id
|
||||
status = getMangaSyncStatus()
|
||||
score = score_raw.toFloat()
|
||||
last_chapter_read = chapters_read
|
||||
}
|
||||
|
||||
fun getMangaSyncStatus() = when (list_status) {
|
||||
"reading" -> Anilist.READING
|
||||
"completed" -> Anilist.COMPLETED
|
||||
"on-hold" -> Anilist.ON_HOLD
|
||||
"dropped" -> Anilist.DROPPED
|
||||
"plan to read" -> Anilist.PLAN_TO_READ
|
||||
else -> throw NotImplementedError("Unknown status")
|
||||
}
|
||||
}
|
@ -1,222 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.mangasync.myanimelist
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Xml
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSync
|
||||
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
|
||||
import eu.kanade.tachiyomi.data.network.GET
|
||||
import eu.kanade.tachiyomi.data.network.POST
|
||||
import eu.kanade.tachiyomi.data.network.asObservable
|
||||
import eu.kanade.tachiyomi.util.selectInt
|
||||
import eu.kanade.tachiyomi.util.selectText
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Headers
|
||||
import okhttp3.RequestBody
|
||||
import org.jsoup.Jsoup
|
||||
import org.xmlpull.v1.XmlSerializer
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
import java.io.StringWriter
|
||||
|
||||
class MyAnimeList(private val context: Context, id: Int) : MangaSyncService(context, id) {
|
||||
|
||||
private lateinit var headers: Headers
|
||||
|
||||
companion object {
|
||||
val BASE_URL = "https://myanimelist.net"
|
||||
|
||||
private val ENTRY_TAG = "entry"
|
||||
private val CHAPTER_TAG = "chapter"
|
||||
private val SCORE_TAG = "score"
|
||||
private val STATUS_TAG = "status"
|
||||
|
||||
val READING = 1
|
||||
val COMPLETED = 2
|
||||
val ON_HOLD = 3
|
||||
val DROPPED = 4
|
||||
val PLAN_TO_READ = 6
|
||||
|
||||
val DEFAULT_STATUS = READING
|
||||
val DEFAULT_SCORE = 0
|
||||
}
|
||||
|
||||
init {
|
||||
val username = getUsername()
|
||||
val password = getPassword()
|
||||
|
||||
if (!username.isEmpty() && !password.isEmpty()) {
|
||||
createHeaders(username, password)
|
||||
}
|
||||
}
|
||||
|
||||
override val name: String
|
||||
get() = "MyAnimeList"
|
||||
|
||||
fun getLoginUrl() = Uri.parse(BASE_URL).buildUpon()
|
||||
.appendEncodedPath("api/account/verify_credentials.xml")
|
||||
.toString()
|
||||
|
||||
fun getSearchUrl(query: String) = Uri.parse(BASE_URL).buildUpon()
|
||||
.appendEncodedPath("api/manga/search.xml")
|
||||
.appendQueryParameter("q", query)
|
||||
.toString()
|
||||
|
||||
fun getListUrl(username: String) = Uri.parse(BASE_URL).buildUpon()
|
||||
.appendPath("malappinfo.php")
|
||||
.appendQueryParameter("u", username)
|
||||
.appendQueryParameter("status", "all")
|
||||
.appendQueryParameter("type", "manga")
|
||||
.toString()
|
||||
|
||||
fun getUpdateUrl(manga: MangaSync) = Uri.parse(BASE_URL).buildUpon()
|
||||
.appendEncodedPath("api/mangalist/update")
|
||||
.appendPath("${manga.remote_id}.xml")
|
||||
.toString()
|
||||
|
||||
fun getAddUrl(manga: MangaSync) = Uri.parse(BASE_URL).buildUpon()
|
||||
.appendEncodedPath("api/mangalist/add")
|
||||
.appendPath("${manga.remote_id}.xml")
|
||||
.toString()
|
||||
|
||||
override fun login(username: String, password: String): Completable {
|
||||
createHeaders(username, password)
|
||||
return client.newCall(GET(getLoginUrl(), headers))
|
||||
.asObservable()
|
||||
.doOnNext { it.close() }
|
||||
.doOnNext { if (it.code() != 200) throw Exception("Login error") }
|
||||
.toCompletable()
|
||||
}
|
||||
|
||||
fun search(query: String): Observable<List<MangaSync>> {
|
||||
return client.newCall(GET(getSearchUrl(query), headers))
|
||||
.asObservable()
|
||||
.map { Jsoup.parse(it.body().string()) }
|
||||
.flatMap { Observable.from(it.select("entry")) }
|
||||
.filter { it.select("type").text() != "Novel" }
|
||||
.map {
|
||||
MangaSync.create(id).apply {
|
||||
title = it.selectText("title")!!
|
||||
remote_id = it.selectInt("id")
|
||||
total_chapters = it.selectInt("chapters")
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
// MAL doesn't support score with decimals
|
||||
fun getList(): Observable<List<MangaSync>> {
|
||||
return networkService.forceCacheClient
|
||||
.newCall(GET(getListUrl(getUsername()), headers))
|
||||
.asObservable()
|
||||
.map { Jsoup.parse(it.body().string()) }
|
||||
.flatMap { Observable.from(it.select("manga")) }
|
||||
.map {
|
||||
MangaSync.create(id).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")
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
override fun update(manga: MangaSync): Observable<MangaSync> {
|
||||
return Observable.defer {
|
||||
if (manga.total_chapters != 0 && manga.last_chapter_read == manga.total_chapters) {
|
||||
manga.status = COMPLETED
|
||||
}
|
||||
client.newCall(POST(getUpdateUrl(manga), headers, getMangaPostPayload(manga)))
|
||||
.asObservable()
|
||||
.doOnNext { it.close() }
|
||||
.doOnNext { if (!it.isSuccessful) throw Exception("Could not update manga") }
|
||||
.map { manga }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun add(manga: MangaSync): Observable<MangaSync> {
|
||||
return Observable.defer {
|
||||
client.newCall(POST(getAddUrl(manga), headers, getMangaPostPayload(manga)))
|
||||
.asObservable()
|
||||
.doOnNext { it.close() }
|
||||
.doOnNext { if (!it.isSuccessful) throw Exception("Could not add manga") }
|
||||
.map { manga }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMangaPostPayload(manga: MangaSync): RequestBody {
|
||||
val xml = Xml.newSerializer()
|
||||
val writer = StringWriter()
|
||||
|
||||
with(xml) {
|
||||
setOutput(writer)
|
||||
startDocument("UTF-8", false)
|
||||
startTag("", ENTRY_TAG)
|
||||
|
||||
// Last chapter read
|
||||
if (manga.last_chapter_read != 0) {
|
||||
inTag(CHAPTER_TAG, manga.last_chapter_read.toString())
|
||||
}
|
||||
// Manga status in the list
|
||||
inTag(STATUS_TAG, manga.status.toString())
|
||||
|
||||
// Manga score
|
||||
inTag(SCORE_TAG, manga.score.toString())
|
||||
|
||||
endTag("", ENTRY_TAG)
|
||||
endDocument()
|
||||
}
|
||||
|
||||
val form = FormBody.Builder()
|
||||
form.add("data", writer.toString())
|
||||
return form.build()
|
||||
}
|
||||
|
||||
fun XmlSerializer.inTag(tag: String, body: String, namespace: String = "") {
|
||||
startTag(namespace, tag)
|
||||
text(body)
|
||||
endTag(namespace, tag)
|
||||
}
|
||||
|
||||
override fun bind(manga: MangaSync): Observable<MangaSync> {
|
||||
return getList()
|
||||
.flatMap { userlist ->
|
||||
manga.sync_id = id
|
||||
val mangaFromList = userlist.find { it.remote_id == manga.remote_id }
|
||||
if (mangaFromList != null) {
|
||||
manga.copyPersonalFrom(mangaFromList)
|
||||
update(manga)
|
||||
} else {
|
||||
// Set default fields if it's not found in the list
|
||||
manga.score = DEFAULT_SCORE.toFloat()
|
||||
manga.status = DEFAULT_STATUS
|
||||
add(manga)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getStatus(status: Int): String = with(context) {
|
||||
when (status) {
|
||||
READING -> getString(R.string.reading)
|
||||
COMPLETED -> getString(R.string.completed)
|
||||
ON_HOLD -> getString(R.string.on_hold)
|
||||
DROPPED -> getString(R.string.dropped)
|
||||
PLAN_TO_READ -> getString(R.string.plan_to_read)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
fun createHeaders(username: String, password: String) {
|
||||
val builder = Headers.Builder()
|
||||
builder.add("Authorization", Credentials.basic(username, password))
|
||||
builder.add("User-Agent", "api-indiv-9F93C52A963974CF674325391990191C")
|
||||
headers = builder.build()
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
package eu.kanade.tachiyomi.data.notification
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.support.v4.content.FileProvider
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.ui.download.DownloadActivity
|
||||
import eu.kanade.tachiyomi.util.getUriCompat
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Class that manages [PendingIntent] of activity's
|
||||
*/
|
||||
object NotificationHandler {
|
||||
/**
|
||||
* Returns [PendingIntent] that starts a download activity.
|
||||
*
|
||||
* @param context context of application
|
||||
*/
|
||||
internal fun openDownloadManagerPendingActivity(context: Context): PendingIntent {
|
||||
val intent = Intent(context, DownloadActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
|
||||
}
|
||||
return PendingIntent.getActivity(context, 0, intent, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [PendingIntent] that starts a gallery activity
|
||||
*
|
||||
* @param context context of application
|
||||
* @param file file containing image
|
||||
*/
|
||||
internal fun openImagePendingActivity(context: Context, file: File): PendingIntent {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
val uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file)
|
||||
setDataAndType(uri, "image/*")
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [PendingIntent] that prompts user with apk install intent
|
||||
*
|
||||
* @param context context
|
||||
* @param file file of apk that is installed
|
||||
*/
|
||||
fun installApkPendingActivity(context: Context, file: File): PendingIntent {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
val uri = file.getUriCompat(context)
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
return PendingIntent.getActivity(context, 0, intent, 0)
|
||||
}
|
||||
}
|
@ -0,0 +1,277 @@
|
||||
package eu.kanade.tachiyomi.data.notification
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import eu.kanade.tachiyomi.Constants
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.data.download.DownloadService
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import eu.kanade.tachiyomi.util.deleteIfExists
|
||||
import eu.kanade.tachiyomi.util.getUriCompat
|
||||
import eu.kanade.tachiyomi.util.notificationManager
|
||||
import eu.kanade.tachiyomi.util.toast
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
||||
|
||||
/**
|
||||
* Global [BroadcastReceiver] that runs on UI thread
|
||||
* Pending Broadcasts should be made from here.
|
||||
* NOTE: Use local broadcasts if possible.
|
||||
*/
|
||||
class NotificationReceiver : BroadcastReceiver() {
|
||||
/**
|
||||
* Download manager.
|
||||
*/
|
||||
private val downloadManager: DownloadManager by injectLazy()
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
// Dismiss notification
|
||||
ACTION_DISMISS_NOTIFICATION -> dismissNotification(context, intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1))
|
||||
// Resume the download service
|
||||
ACTION_RESUME_DOWNLOADS -> DownloadService.start(context)
|
||||
// Clear the download queue
|
||||
ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue(true)
|
||||
// Launch share activity and dismiss notification
|
||||
ACTION_SHARE_IMAGE -> shareImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION),
|
||||
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1))
|
||||
// Delete image from path and dismiss notification
|
||||
ACTION_DELETE_IMAGE -> deleteImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION),
|
||||
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1))
|
||||
// Cancel library update and dismiss notification
|
||||
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context, Constants.NOTIFICATION_LIBRARY_ID)
|
||||
// Open reader activity
|
||||
ACTION_OPEN_CHAPTER -> {
|
||||
openChapter(context, intent.getLongExtra(EXTRA_MANGA_ID, -1),
|
||||
intent.getLongExtra(EXTRA_CHAPTER_ID, -1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the notification
|
||||
*
|
||||
* @param notificationId the id of the notification
|
||||
*/
|
||||
private fun dismissNotification(context: Context, notificationId: Int) {
|
||||
context.notificationManager.cancel(notificationId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to start share intent to share image
|
||||
*
|
||||
* @param context context of application
|
||||
* @param path path of file
|
||||
* @param notificationId id of notification
|
||||
*/
|
||||
private fun shareImage(context: Context, path: String, notificationId: Int) {
|
||||
// Create intent
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
val uri = File(path).getUriCompat(context)
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
type = "image/*"
|
||||
}
|
||||
// Dismiss notification
|
||||
dismissNotification(context, notificationId)
|
||||
// Launch share activity
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts reader activity
|
||||
*
|
||||
* @param context context of application
|
||||
* @param mangaId id of manga
|
||||
* @param chapterId id of chapter
|
||||
*/
|
||||
internal fun openChapter(context: Context, mangaId: Long, chapterId: Long) {
|
||||
val db = DatabaseHelper(context)
|
||||
val manga = db.getManga(mangaId).executeAsBlocking()
|
||||
val chapter = db.getChapter(chapterId).executeAsBlocking()
|
||||
|
||||
if (manga != null && chapter != null) {
|
||||
val intent = ReaderActivity.newIntent(context, manga, chapter).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} else {
|
||||
context.toast(context.getString(R.string.chapter_error))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to delete image
|
||||
*
|
||||
* @param path path of file
|
||||
* @param notificationId id of notification
|
||||
*/
|
||||
private fun deleteImage(context: Context, path: String, notificationId: Int) {
|
||||
// Dismiss notification
|
||||
dismissNotification(context, notificationId)
|
||||
|
||||
// Delete file
|
||||
File(path).deleteIfExists()
|
||||
}
|
||||
|
||||
/**
|
||||
* Method called when user wants to stop a library update
|
||||
*
|
||||
* @param context context of application
|
||||
* @param notificationId id of notification
|
||||
*/
|
||||
private fun cancelLibraryUpdate(context: Context, notificationId: Int) {
|
||||
LibraryUpdateService.stop(context)
|
||||
Handler().post { dismissNotification(context, notificationId) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val NAME = "NotificationReceiver"
|
||||
|
||||
// Called to launch share intent.
|
||||
private const val ACTION_SHARE_IMAGE = "$ID.$NAME.SHARE_IMAGE"
|
||||
|
||||
// Called to delete image.
|
||||
private const val ACTION_DELETE_IMAGE = "$ID.$NAME.DELETE_IMAGE"
|
||||
|
||||
// Called to cancel library update.
|
||||
private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE"
|
||||
|
||||
// Called to open chapter
|
||||
private const val ACTION_OPEN_CHAPTER = "$ID.$NAME.ACTION_OPEN_CHAPTER"
|
||||
|
||||
// Value containing file location.
|
||||
private const val EXTRA_FILE_LOCATION = "$ID.$NAME.FILE_LOCATION"
|
||||
|
||||
// Called to resume downloads.
|
||||
private const val ACTION_RESUME_DOWNLOADS = "$ID.$NAME.ACTION_RESUME_DOWNLOADS"
|
||||
|
||||
// Called to clear downloads.
|
||||
private const val ACTION_CLEAR_DOWNLOADS = "$ID.$NAME.ACTION_CLEAR_DOWNLOADS"
|
||||
|
||||
// Called to dismiss notification.
|
||||
private const val ACTION_DISMISS_NOTIFICATION = "$ID.$NAME.ACTION_DISMISS_NOTIFICATION"
|
||||
|
||||
// Value containing notification id.
|
||||
private const val EXTRA_NOTIFICATION_ID = "$ID.$NAME.NOTIFICATION_ID"
|
||||
|
||||
// Value containing manga id.
|
||||
private const val EXTRA_MANGA_ID = "$ID.$NAME.EXTRA_MANGA_ID"
|
||||
|
||||
// Value containing chapter id.
|
||||
private const val EXTRA_CHAPTER_ID = "$ID.$NAME.EXTRA_CHAPTER_ID"
|
||||
|
||||
/**
|
||||
* Returns a [PendingIntent] that resumes the download of a chapter
|
||||
*
|
||||
* @param context context of application
|
||||
* @return [PendingIntent]
|
||||
*/
|
||||
internal fun resumeDownloadsPendingBroadcast(context: Context): PendingIntent {
|
||||
val intent = Intent(context, NotificationReceiver::class.java).apply {
|
||||
action = ACTION_RESUME_DOWNLOADS
|
||||
}
|
||||
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a [PendingIntent] that clears the download queue
|
||||
*
|
||||
* @param context context of application
|
||||
* @return [PendingIntent]
|
||||
*/
|
||||
internal fun clearDownloadsPendingBroadcast(context: Context): PendingIntent {
|
||||
val intent = Intent(context, NotificationReceiver::class.java).apply {
|
||||
action = ACTION_CLEAR_DOWNLOADS
|
||||
}
|
||||
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [PendingIntent] that starts a service which dismissed the notification
|
||||
*
|
||||
* @param context context of application
|
||||
* @param notificationId id of notification
|
||||
* @return [PendingIntent]
|
||||
*/
|
||||
internal fun dismissNotificationPendingBroadcast(context: Context, notificationId: Int): PendingIntent {
|
||||
val intent = Intent(context, NotificationReceiver::class.java).apply {
|
||||
action = ACTION_DISMISS_NOTIFICATION
|
||||
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
}
|
||||
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [PendingIntent] that starts a service which cancels the notification and starts a share activity
|
||||
*
|
||||
* @param context context of application
|
||||
* @param path location path of file
|
||||
* @param notificationId id of notification
|
||||
* @return [PendingIntent]
|
||||
*/
|
||||
internal fun shareImagePendingBroadcast(context: Context, path: String, notificationId: Int): PendingIntent {
|
||||
val intent = Intent(context, NotificationReceiver::class.java).apply {
|
||||
action = ACTION_SHARE_IMAGE
|
||||
putExtra(EXTRA_FILE_LOCATION, path)
|
||||
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
}
|
||||
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [PendingIntent] that starts a service which removes an image from disk
|
||||
*
|
||||
* @param context context of application
|
||||
* @param path location path of file
|
||||
* @param notificationId id of notification
|
||||
* @return [PendingIntent]
|
||||
*/
|
||||
internal fun deleteImagePendingBroadcast(context: Context, path: String, notificationId: Int): PendingIntent {
|
||||
val intent = Intent(context, NotificationReceiver::class.java).apply {
|
||||
action = ACTION_DELETE_IMAGE
|
||||
putExtra(EXTRA_FILE_LOCATION, path)
|
||||
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
}
|
||||
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [PendingIntent] that start a reader activity containing chapter.
|
||||
*
|
||||
* @param context context of application
|
||||
* @param manga manga of chapter
|
||||
* @param chapter chapter that needs to be opened
|
||||
*/
|
||||
internal fun openChapterPendingBroadcast(context: Context, manga: Manga, chapter: Chapter): PendingIntent {
|
||||
val intent = Intent(context, NotificationReceiver::class.java).apply {
|
||||
action = ACTION_OPEN_CHAPTER
|
||||
putExtra(EXTRA_MANGA_ID, manga.id)
|
||||
putExtra(EXTRA_CHAPTER_ID, chapter.id)
|
||||
}
|
||||
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [PendingIntent] that starts a service which stops the library update
|
||||
*
|
||||
* @param context context of application
|
||||
* @return [PendingIntent]
|
||||
*/
|
||||
internal fun cancelLibraryUpdatePendingBroadcast(context: Context): PendingIntent {
|
||||
val intent = Intent(context, NotificationReceiver::class.java).apply {
|
||||
action = ACTION_CANCEL_LIBRARY_UPDATE
|
||||
}
|
||||
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT)
|
||||
}
|
||||
}
|
||||
}
|
@ -41,6 +41,8 @@ class PreferenceKeys(context: Context) {
|
||||
|
||||
val readerTheme = context.getString(R.string.pref_reader_theme_key)
|
||||
|
||||
val cropBorders = context.getString(R.string.pref_crop_borders_key)
|
||||
|
||||
val readWithTapping = context.getString(R.string.pref_read_with_tapping_key)
|
||||
|
||||
val readWithVolumeKeys = context.getString(R.string.pref_read_with_volume_keys_key)
|
||||
@ -51,9 +53,9 @@ class PreferenceKeys(context: Context) {
|
||||
|
||||
val updateOnlyNonCompleted = context.getString(R.string.pref_update_only_non_completed_key)
|
||||
|
||||
val autoUpdateMangaSync = context.getString(R.string.pref_auto_update_manga_sync_key)
|
||||
val autoUpdateTrack = context.getString(R.string.pref_auto_update_manga_sync_key)
|
||||
|
||||
val askUpdateMangaSync = context.getString(R.string.pref_ask_update_manga_sync_key)
|
||||
val askUpdateTrack = context.getString(R.string.pref_ask_update_manga_sync_key)
|
||||
|
||||
val lastUsedCatalogueSource = context.getString(R.string.pref_last_catalogue_source_key)
|
||||
|
||||
@ -91,13 +93,15 @@ class PreferenceKeys(context: Context) {
|
||||
|
||||
val downloadNew = context.getString(R.string.pref_download_new_key)
|
||||
|
||||
fun sourceUsername(sourceId: Int) = "pref_source_username_$sourceId"
|
||||
fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId"
|
||||
|
||||
fun sourcePassword(sourceId: Int) = "pref_source_password_$sourceId"
|
||||
fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId"
|
||||
|
||||
fun syncUsername(syncId: Int) = "pref_mangasync_username_$syncId"
|
||||
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
|
||||
|
||||
fun syncPassword(syncId: Int) = "pref_mangasync_password_$syncId"
|
||||
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"
|
||||
|
||||
fun trackToken(syncId: Int) = "track_token_$syncId"
|
||||
|
||||
val libraryAsList = context.getString(R.string.pref_display_library_as_list)
|
||||
|
||||
|
@ -7,15 +7,15 @@ import android.preference.PreferenceManager
|
||||
import com.f2prateek.rx.preferences.Preference
|
||||
import com.f2prateek.rx.preferences.RxSharedPreferences
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
|
||||
import eu.kanade.tachiyomi.data.source.Source
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import java.io.File
|
||||
|
||||
fun <T> Preference<T>.getOrDefault(): T = get() ?: defaultValue()!!
|
||||
|
||||
fun Preference<Boolean>.invert(): Boolean = getOrDefault().let { set(!it); !it }
|
||||
|
||||
class PreferencesHelper(context: Context) {
|
||||
class PreferencesHelper(val context: Context) {
|
||||
|
||||
val keys = PreferenceKeys(context)
|
||||
|
||||
@ -34,7 +34,7 @@ class PreferencesHelper(context: Context) {
|
||||
|
||||
fun rotation() = rxPrefs.getInteger(keys.rotation, 1)
|
||||
|
||||
fun enableTransitions() = rxPrefs.getBoolean(keys.enableTransitions, true)
|
||||
fun pageTransitions() = rxPrefs.getBoolean(keys.enableTransitions, true)
|
||||
|
||||
fun showPageNumber() = rxPrefs.getBoolean(keys.showPageNumber, true)
|
||||
|
||||
@ -60,6 +60,8 @@ class PreferencesHelper(context: Context) {
|
||||
|
||||
fun readerTheme() = rxPrefs.getInteger(keys.readerTheme, 0)
|
||||
|
||||
fun cropBorders() = rxPrefs.getBoolean(keys.cropBorders, false)
|
||||
|
||||
fun readWithTapping() = rxPrefs.getBoolean(keys.readWithTapping, true)
|
||||
|
||||
fun readWithVolumeKeys() = rxPrefs.getBoolean(keys.readWithVolumeKeys, false)
|
||||
@ -70,11 +72,11 @@ class PreferencesHelper(context: Context) {
|
||||
|
||||
fun updateOnlyNonCompleted() = prefs.getBoolean(keys.updateOnlyNonCompleted, false)
|
||||
|
||||
fun autoUpdateMangaSync() = prefs.getBoolean(keys.autoUpdateMangaSync, true)
|
||||
fun autoUpdateTrack() = prefs.getBoolean(keys.autoUpdateTrack, true)
|
||||
|
||||
fun askUpdateMangaSync() = prefs.getBoolean(keys.askUpdateMangaSync, false)
|
||||
fun askUpdateTrack() = prefs.getBoolean(keys.askUpdateTrack, false)
|
||||
|
||||
fun lastUsedCatalogueSource() = rxPrefs.getInteger(keys.lastUsedCatalogueSource, -1)
|
||||
fun lastUsedCatalogueSource() = rxPrefs.getLong(keys.lastUsedCatalogueSource, -1)
|
||||
|
||||
fun lastUsedCategory() = rxPrefs.getInteger(keys.lastUsedCategory, 0)
|
||||
|
||||
@ -82,7 +84,7 @@ class PreferencesHelper(context: Context) {
|
||||
|
||||
fun catalogueAsList() = rxPrefs.getBoolean(keys.catalogueAsList, false)
|
||||
|
||||
fun enabledLanguages() = rxPrefs.getStringSet(keys.enabledLanguages, setOf("EN"))
|
||||
fun enabledLanguages() = rxPrefs.getStringSet(keys.enabledLanguages, setOf("en"))
|
||||
|
||||
fun sourceUsername(source: Source) = prefs.getString(keys.sourceUsername(source.id), "")
|
||||
|
||||
@ -95,17 +97,21 @@ class PreferencesHelper(context: Context) {
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun mangaSyncUsername(sync: MangaSyncService) = prefs.getString(keys.syncUsername(sync.id), "")
|
||||
fun trackUsername(sync: TrackService) = prefs.getString(keys.trackUsername(sync.id), "")
|
||||
|
||||
fun mangaSyncPassword(sync: MangaSyncService) = prefs.getString(keys.syncPassword(sync.id), "")
|
||||
fun trackPassword(sync: TrackService) = prefs.getString(keys.trackPassword(sync.id), "")
|
||||
|
||||
fun setMangaSyncCredentials(sync: MangaSyncService, username: String, password: String) {
|
||||
fun setTrackCredentials(sync: TrackService, username: String, password: String) {
|
||||
prefs.edit()
|
||||
.putString(keys.syncUsername(sync.id), username)
|
||||
.putString(keys.syncPassword(sync.id), password)
|
||||
.putString(keys.trackUsername(sync.id), username)
|
||||
.putString(keys.trackPassword(sync.id), password)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun trackToken(sync: TrackService) = rxPrefs.getString(keys.trackToken(sync.id), "")
|
||||
|
||||
fun anilistScoreType() = rxPrefs.getInteger("anilist_score_type", 0)
|
||||
|
||||
fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.toString())
|
||||
|
||||
fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1)
|
||||
@ -138,6 +144,6 @@ class PreferencesHelper(context: Context) {
|
||||
|
||||
fun downloadNew() = prefs.getBoolean(keys.downloadNew, false)
|
||||
|
||||
fun lang() = prefs.getInt(keys.lang, 0)
|
||||
fun lang() = prefs.getString(keys.lang, "")
|
||||
|
||||
}
|
||||
|
@ -1,9 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.source
|
||||
|
||||
class Language(val code: String, val lang: String)
|
||||
|
||||
val DE = Language("DE", "German")
|
||||
val EN = Language("EN", "English")
|
||||
val RU = Language("RU", "Russian")
|
||||
|
||||
fun getLanguages() = listOf(DE, EN, RU)
|
@ -1,61 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.source
|
||||
|
||||
import android.Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
||||
import eu.kanade.tachiyomi.data.source.online.YamlOnlineSource
|
||||
import eu.kanade.tachiyomi.data.source.online.english.*
|
||||
import eu.kanade.tachiyomi.data.source.online.german.WieManga
|
||||
import eu.kanade.tachiyomi.data.source.online.russian.Mangachan
|
||||
import eu.kanade.tachiyomi.data.source.online.russian.Mintmanga
|
||||
import eu.kanade.tachiyomi.data.source.online.russian.Readmanga
|
||||
import eu.kanade.tachiyomi.util.hasPermission
|
||||
import org.yaml.snakeyaml.Yaml
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
|
||||
open class SourceManager(private val context: Context) {
|
||||
|
||||
private val sourcesMap = createSources()
|
||||
|
||||
open fun get(sourceKey: Int): Source? {
|
||||
return sourcesMap[sourceKey]
|
||||
}
|
||||
|
||||
fun getOnlineSources() = sourcesMap.values.filterIsInstance(OnlineSource::class.java)
|
||||
|
||||
private fun createOnlineSourceList(): List<Source> = listOf(
|
||||
Batoto(1),
|
||||
Mangahere(2),
|
||||
Mangafox(3),
|
||||
Kissmanga(4),
|
||||
Readmanga(5),
|
||||
Mintmanga(6),
|
||||
Mangachan(7),
|
||||
Readmangatoday(8),
|
||||
Mangasee(9),
|
||||
WieManga(10)
|
||||
)
|
||||
|
||||
private fun createSources(): Map<Int, Source> = hashMapOf<Int, Source>().apply {
|
||||
createOnlineSourceList().forEach { put(it.id, it) }
|
||||
|
||||
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) }
|
||||
YamlOnlineSource(map).let { put(it.id, it) }
|
||||
} catch (e: Exception) {
|
||||
Timber.e("Error loading source from file. Bad format?")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.source.model
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
|
||||
class MangasPage(val page: Int) {
|
||||
|
||||
val mangas: MutableList<Manga> = mutableListOf()
|
||||
|
||||
lateinit var url: String
|
||||
|
||||
var nextPageUrl: String? = null
|
||||
|
||||
}
|
@ -1,470 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.source.online
|
||||
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.network.GET
|
||||
import eu.kanade.tachiyomi.data.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.data.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.data.network.newCallWithProgress
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.source.Language
|
||||
import eu.kanade.tachiyomi.data.source.Source
|
||||
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.util.UrlUtil
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
* A simple implementation for sources from a website.
|
||||
*/
|
||||
abstract class OnlineSource() : Source {
|
||||
|
||||
/**
|
||||
* Network service.
|
||||
*/
|
||||
val network: NetworkHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* Chapter cache.
|
||||
*/
|
||||
val chapterCache: ChapterCache by injectLazy()
|
||||
|
||||
/**
|
||||
* Preferences helper.
|
||||
*/
|
||||
val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* Base url of the website without the trailing slash, like: http://mysite.com
|
||||
*/
|
||||
abstract val baseUrl: String
|
||||
|
||||
/**
|
||||
* Language of the source.
|
||||
*/
|
||||
abstract val lang: Language
|
||||
|
||||
/**
|
||||
* Whether the source has support for latest updates.
|
||||
*/
|
||||
abstract val supportsLatest : Boolean
|
||||
|
||||
/**
|
||||
* Headers used for requests.
|
||||
*/
|
||||
val headers by lazy { headersBuilder().build() }
|
||||
|
||||
/**
|
||||
* Genre filters.
|
||||
*/
|
||||
val filters by lazy { getFilterList() }
|
||||
|
||||
/**
|
||||
* Default network client for doing requests.
|
||||
*/
|
||||
open val client: OkHttpClient
|
||||
get() = network.client
|
||||
|
||||
/**
|
||||
* Headers builder for requests. Implementations can override this method for custom headers.
|
||||
*/
|
||||
open protected fun headersBuilder() = Headers.Builder().apply {
|
||||
add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Visible name of the source.
|
||||
*/
|
||||
override fun toString() = "$name (${lang.code})"
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of manga. Normally it's not needed to
|
||||
* override this method.
|
||||
*
|
||||
* @param page the page object where the information will be saved, like the list of manga,
|
||||
* the current page and the next page url.
|
||||
*/
|
||||
open fun fetchPopularManga(page: MangasPage): Observable<MangasPage> = client
|
||||
.newCall(popularMangaRequest(page))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
popularMangaParse(response, page)
|
||||
page
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request for the popular manga given the page. Override only if it's needed to
|
||||
* send different headers or request method like POST.
|
||||
*
|
||||
* @param page the page object.
|
||||
*/
|
||||
open protected fun popularMangaRequest(page: MangasPage): Request {
|
||||
if (page.page == 1) {
|
||||
page.url = popularMangaInitialUrl()
|
||||
}
|
||||
return GET(page.url, headers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute url of the first page to popular manga.
|
||||
*/
|
||||
abstract protected fun popularMangaInitialUrl(): String
|
||||
|
||||
/**
|
||||
* Parse the response from the site. It should add a list of manga and the absolute url to the
|
||||
* next page (if it has a next one) to [page].
|
||||
*
|
||||
* @param response the response from the site.
|
||||
* @param page the page object to be filled.
|
||||
*/
|
||||
abstract protected fun popularMangaParse(response: Response, page: MangasPage)
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of manga. Normally it's not needed to
|
||||
* override this method.
|
||||
*
|
||||
* @param page the page object where the information will be saved, like the list of manga,
|
||||
* the current page and the next page url.
|
||||
* @param query the search query.
|
||||
*/
|
||||
open fun fetchSearchManga(page: MangasPage, query: String, filters: List<Filter>): Observable<MangasPage> = client
|
||||
.newCall(searchMangaRequest(page, query, filters))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
searchMangaParse(response, page, query, filters)
|
||||
page
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request for the search manga given the page. Override only if it's needed to
|
||||
* send different headers or request method like POST.
|
||||
*
|
||||
* @param page the page object.
|
||||
* @param query the search query.
|
||||
*/
|
||||
open protected fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
|
||||
if (page.page == 1) {
|
||||
page.url = searchMangaInitialUrl(query, filters)
|
||||
}
|
||||
return GET(page.url, headers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute url of the first page to popular manga.
|
||||
*
|
||||
* @param query the search query.
|
||||
*/
|
||||
abstract protected fun searchMangaInitialUrl(query: String, filters: List<Filter>): String
|
||||
|
||||
/**
|
||||
* Parse the response from the site. It should add a list of manga and the absolute url to the
|
||||
* next page (if it has a next one) to [page].
|
||||
*
|
||||
* @param response the response from the site.
|
||||
* @param page the page object to be filled.
|
||||
* @param query the search query.
|
||||
*/
|
||||
abstract protected fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>)
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of latest manga.
|
||||
*/
|
||||
open fun fetchLatestUpdates(page: MangasPage): Observable<MangasPage> = client
|
||||
.newCall(latestUpdatesRequest(page))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
latestUpdatesParse(response, page)
|
||||
page
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request for latest manga given the page.
|
||||
*/
|
||||
open protected fun latestUpdatesRequest(page: MangasPage): Request {
|
||||
if (page.page == 1) {
|
||||
page.url = latestUpdatesInitialUrl()
|
||||
}
|
||||
return GET(page.url, headers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute url of the first page to latest manga.
|
||||
*/
|
||||
abstract protected fun latestUpdatesInitialUrl(): String
|
||||
|
||||
/**
|
||||
* Same as [popularMangaParse], but for latest manga.
|
||||
*/
|
||||
abstract protected fun latestUpdatesParse(response: Response, page: MangasPage)
|
||||
|
||||
/**
|
||||
* Returns an observable with the updated details for a manga. Normally it's not needed to
|
||||
* override this method.
|
||||
*
|
||||
* @param manga the manga to be updated.
|
||||
*/
|
||||
override fun fetchMangaDetails(manga: Manga): Observable<Manga> = client
|
||||
.newCall(mangaDetailsRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
Manga.create(manga.url, id).apply {
|
||||
mangaDetailsParse(response, this)
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request for updating a manga. Override only if it's needed to override the url,
|
||||
* send different headers or request method like POST.
|
||||
*
|
||||
* @param manga the manga to be updated.
|
||||
*/
|
||||
open fun mangaDetailsRequest(manga: Manga): Request {
|
||||
return GET(baseUrl + manga.url, headers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the response from the site. It should fill [manga].
|
||||
*
|
||||
* @param response the response from the site.
|
||||
* @param manga the manga whose fields have to be filled.
|
||||
*/
|
||||
abstract protected fun mangaDetailsParse(response: Response, manga: Manga)
|
||||
|
||||
/**
|
||||
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to
|
||||
* override this method.
|
||||
*
|
||||
* @param manga the manga to look for chapters.
|
||||
*/
|
||||
override fun fetchChapterList(manga: Manga): Observable<List<Chapter>> = client
|
||||
.newCall(chapterListRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
mutableListOf<Chapter>().apply {
|
||||
chapterListParse(response, this)
|
||||
if (isEmpty()) {
|
||||
throw Exception("No chapters found")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request for updating the chapter list. Override only if it's needed to override
|
||||
* the url, send different headers or request method like POST.
|
||||
*
|
||||
* @param manga the manga to look for chapters.
|
||||
*/
|
||||
open protected fun chapterListRequest(manga: Manga): Request {
|
||||
return GET(baseUrl + manga.url, headers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the response from the site. It should fill [chapters].
|
||||
*
|
||||
* @param response the response from the site.
|
||||
* @param chapters the chapter list to be filled.
|
||||
*/
|
||||
abstract protected fun chapterListParse(response: Response, chapters: MutableList<Chapter>)
|
||||
|
||||
/**
|
||||
* Returns an observable with the page list for a chapter. It tries to return the page list from
|
||||
* the local cache, otherwise fallbacks to network calling [fetchPageListFromNetwork].
|
||||
*
|
||||
* @param chapter the chapter whose page list has to be fetched.
|
||||
*/
|
||||
final override fun fetchPageList(chapter: Chapter): Observable<List<Page>> = chapterCache
|
||||
.getPageListFromCache(getChapterCacheKey(chapter))
|
||||
.onErrorResumeNext { fetchPageListFromNetwork(chapter) }
|
||||
|
||||
/**
|
||||
* Returns an observable with the page list for a chapter. Normally it's not needed to override
|
||||
* this method.
|
||||
*
|
||||
* @param chapter the chapter whose page list has to be fetched.
|
||||
*/
|
||||
open fun fetchPageListFromNetwork(chapter: Chapter): Observable<List<Page>> = client
|
||||
.newCall(pageListRequest(chapter))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
mutableListOf<Page>().apply {
|
||||
pageListParse(response, this)
|
||||
if (isEmpty()) {
|
||||
throw Exception("Page list is empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request for getting the page list. Override only if it's needed to override the
|
||||
* url, send different headers or request method like POST.
|
||||
*
|
||||
* @param chapter the chapter whose page list has to be fetched
|
||||
*/
|
||||
open protected fun pageListRequest(chapter: Chapter): Request {
|
||||
return GET(baseUrl + chapter.url, headers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the response from the site. It should fill [pages].
|
||||
*
|
||||
* @param response the response from the site.
|
||||
* @param pages the page list to be filled.
|
||||
*/
|
||||
abstract protected fun pageListParse(response: Response, pages: MutableList<Page>)
|
||||
|
||||
/**
|
||||
* Returns the key for the page list to be stored in [ChapterCache].
|
||||
*/
|
||||
private fun getChapterCacheKey(chapter: Chapter) = "$id${chapter.url}"
|
||||
|
||||
/**
|
||||
* Returns an observable with the page containing the source url of the image. If there's any
|
||||
* error, it will return null instead of throwing an exception.
|
||||
*
|
||||
* @param page the page whose source image has to be fetched.
|
||||
*/
|
||||
open protected fun fetchImageUrl(page: Page): Observable<Page> {
|
||||
page.status = Page.LOAD_PAGE
|
||||
return client
|
||||
.newCall(imageUrlRequest(page))
|
||||
.asObservableSuccess()
|
||||
.map { imageUrlParse(it) }
|
||||
.doOnError { page.status = Page.ERROR }
|
||||
.onErrorReturn { null }
|
||||
.doOnNext { page.imageUrl = it }
|
||||
.map { page }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request for getting the url to the source image. Override only if it's needed to
|
||||
* override the url, send different headers or request method like POST.
|
||||
*
|
||||
* @param page the chapter whose page list has to be fetched
|
||||
*/
|
||||
open protected fun imageUrlRequest(page: Page): Request {
|
||||
return GET(page.url, headers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the response from the site. It should return the absolute url to the source image.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
abstract protected fun imageUrlParse(response: Response): String
|
||||
|
||||
/**
|
||||
* Returns an observable of the page with the downloaded image.
|
||||
*
|
||||
* @param page the page whose source image has to be downloaded.
|
||||
*/
|
||||
final override fun fetchImage(page: Page): Observable<Page> =
|
||||
if (page.imageUrl.isNullOrEmpty())
|
||||
fetchImageUrl(page).flatMap { getCachedImage(it) }
|
||||
else
|
||||
getCachedImage(page)
|
||||
|
||||
/**
|
||||
* Returns an observable with the response of the source image.
|
||||
*
|
||||
* @param page the page whose source image has to be downloaded.
|
||||
*/
|
||||
fun imageResponse(page: Page): Observable<Response> = client
|
||||
.newCallWithProgress(imageRequest(page), page)
|
||||
.asObservableSuccess()
|
||||
|
||||
/**
|
||||
* Returns the request for getting the source image. Override only if it's needed to override
|
||||
* the url, send different headers or request method like POST.
|
||||
*
|
||||
* @param page the chapter whose page list has to be fetched
|
||||
*/
|
||||
open protected fun imageRequest(page: Page): Request {
|
||||
return GET(page.imageUrl!!, headers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of the page that gets the image from the chapter or fallbacks to
|
||||
* network and copies it to the cache calling [cacheImage].
|
||||
*
|
||||
* @param page the page.
|
||||
*/
|
||||
fun getCachedImage(page: Page): Observable<Page> {
|
||||
val imageUrl = page.imageUrl ?: return Observable.just(page)
|
||||
|
||||
return Observable.just(page)
|
||||
.flatMap {
|
||||
if (!chapterCache.isImageInCache(imageUrl)) {
|
||||
cacheImage(page)
|
||||
} else {
|
||||
Observable.just(page)
|
||||
}
|
||||
}
|
||||
.doOnNext {
|
||||
page.uri = Uri.fromFile(chapterCache.getImageFile(imageUrl))
|
||||
page.status = Page.READY
|
||||
}
|
||||
.doOnError { page.status = Page.ERROR }
|
||||
.onErrorReturn { page }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable of the page that downloads the image to [ChapterCache].
|
||||
*
|
||||
* @param page the page.
|
||||
*/
|
||||
private fun cacheImage(page: Page): Observable<Page> {
|
||||
page.status = Page.DOWNLOAD_IMAGE
|
||||
return imageResponse(page)
|
||||
.doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) }
|
||||
.map { page }
|
||||
}
|
||||
|
||||
|
||||
// Utility methods
|
||||
|
||||
fun fetchAllImageUrlsFromPageList(pages: List<Page>) = Observable.from(pages)
|
||||
.filter { !it.imageUrl.isNullOrEmpty() }
|
||||
.mergeWith(fetchRemainingImageUrlsFromPageList(pages))
|
||||
|
||||
fun fetchRemainingImageUrlsFromPageList(pages: List<Page>) = Observable.from(pages)
|
||||
.filter { it.imageUrl.isNullOrEmpty() }
|
||||
.concatMap { fetchImageUrl(it) }
|
||||
|
||||
fun savePageList(chapter: Chapter, pages: List<Page>?) {
|
||||
if (pages != null) {
|
||||
chapterCache.putPageListToCache(getChapterCacheKey(chapter), pages)
|
||||
}
|
||||
}
|
||||
|
||||
fun Chapter.setUrlWithoutDomain(url: String) {
|
||||
this.url = UrlUtil.getPath(url)
|
||||
}
|
||||
|
||||
fun Manga.setUrlWithoutDomain(url: String) {
|
||||
this.url = UrlUtil.getPath(url)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Called before inserting a new chapter into database. Use it if you need to override chapter
|
||||
* fields, like the title or the chapter number. Do not change anything to [manga].
|
||||
*
|
||||
* @param chapter the chapter to be added.
|
||||
* @param manga the manga of the chapter.
|
||||
*/
|
||||
open fun prepareNewChapter(chapter: Chapter, manga: Manga) {
|
||||
|
||||
}
|
||||
|
||||
data class Filter(val id: String, val name: String)
|
||||
|
||||
open fun getFilterList(): List<Filter> = emptyList()
|
||||
}
|
@ -1,211 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.source.online
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
|
||||
/**
|
||||
* A simple implementation for sources from a website using Jsoup, an HTML parser.
|
||||
*/
|
||||
abstract class ParsedOnlineSource() : OnlineSource() {
|
||||
|
||||
/**
|
||||
* Parse the response from the site and fills [page].
|
||||
*
|
||||
* @param response the response from the site.
|
||||
* @param page the page object to be filled.
|
||||
*/
|
||||
override fun popularMangaParse(response: Response, page: MangasPage) {
|
||||
val document = response.asJsoup()
|
||||
for (element in document.select(popularMangaSelector())) {
|
||||
Manga.create(id).apply {
|
||||
popularMangaFromElement(element, this)
|
||||
page.mangas.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
popularMangaNextPageSelector()?.let { selector ->
|
||||
page.nextPageUrl = document.select(selector).first()?.absUrl("href")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
|
||||
*/
|
||||
abstract protected fun popularMangaSelector(): String
|
||||
|
||||
/**
|
||||
* Fills [manga] with the given [element]. Most sites only show the title and the url, it's
|
||||
* totally safe to fill only those two values.
|
||||
*
|
||||
* @param element an element obtained from [popularMangaSelector].
|
||||
* @param manga the manga to fill.
|
||||
*/
|
||||
abstract protected fun popularMangaFromElement(element: Element, manga: Manga)
|
||||
|
||||
/**
|
||||
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
|
||||
* there's no next page.
|
||||
*/
|
||||
abstract protected fun popularMangaNextPageSelector(): String?
|
||||
|
||||
/**
|
||||
* Parse the response from the site and fills [page].
|
||||
*
|
||||
* @param response the response from the site.
|
||||
* @param page the page object to be filled.
|
||||
* @param query the search query.
|
||||
*/
|
||||
override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>) {
|
||||
val document = response.asJsoup()
|
||||
for (element in document.select(searchMangaSelector())) {
|
||||
Manga.create(id).apply {
|
||||
searchMangaFromElement(element, this)
|
||||
page.mangas.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
searchMangaNextPageSelector()?.let { selector ->
|
||||
page.nextPageUrl = document.select(selector).first()?.absUrl("href")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
|
||||
*/
|
||||
abstract protected fun searchMangaSelector(): String
|
||||
|
||||
/**
|
||||
* Fills [manga] with the given [element]. Most sites only show the title and the url, it's
|
||||
* totally safe to fill only those two values.
|
||||
*
|
||||
* @param element an element obtained from [searchMangaSelector].
|
||||
* @param manga the manga to fill.
|
||||
*/
|
||||
abstract protected fun searchMangaFromElement(element: Element, manga: Manga)
|
||||
|
||||
/**
|
||||
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
|
||||
* there's no next page.
|
||||
*/
|
||||
abstract protected fun searchMangaNextPageSelector(): String?
|
||||
|
||||
/**
|
||||
* Parse the response from the site for latest updates and fills [page].
|
||||
*/
|
||||
override fun latestUpdatesParse(response: Response, page: MangasPage) {
|
||||
val document = response.asJsoup()
|
||||
for (element in document.select(latestUpdatesSelector())) {
|
||||
Manga.create(id).apply {
|
||||
latestUpdatesFromElement(element, this)
|
||||
page.mangas.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
latestUpdatesNextPageSelector()?.let { selector ->
|
||||
page.nextPageUrl = document.select(selector).first()?.absUrl("href")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Jsoup selector similar to [popularMangaSelector], but for latest updates.
|
||||
*/
|
||||
abstract protected fun latestUpdatesSelector(): String
|
||||
|
||||
/**
|
||||
* Fills [manga] with the given [element]. For latest updates.
|
||||
*/
|
||||
abstract protected fun latestUpdatesFromElement(element: Element, manga: Manga)
|
||||
|
||||
/**
|
||||
* Returns the Jsoup selector that returns the <a> tag, like [popularMangaNextPageSelector].
|
||||
*/
|
||||
abstract protected fun latestUpdatesNextPageSelector(): String?
|
||||
|
||||
/**
|
||||
* Parse the response from the site and fills the details of [manga].
|
||||
*
|
||||
* @param response the response from the site.
|
||||
* @param manga the manga to fill.
|
||||
*/
|
||||
override fun mangaDetailsParse(response: Response, manga: Manga) {
|
||||
mangaDetailsParse(response.asJsoup(), manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the details of [manga] from the given [document].
|
||||
*
|
||||
* @param document the parsed document.
|
||||
* @param manga the manga to fill.
|
||||
*/
|
||||
abstract protected fun mangaDetailsParse(document: Document, manga: Manga)
|
||||
|
||||
/**
|
||||
* Parse the response from the site and fills the chapter list.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
* @param chapters the list of chapters to fill.
|
||||
*/
|
||||
override fun chapterListParse(response: Response, chapters: MutableList<Chapter>) {
|
||||
val document = response.asJsoup()
|
||||
|
||||
for (element in document.select(chapterListSelector())) {
|
||||
Chapter.create().apply {
|
||||
chapterFromElement(element, this)
|
||||
chapters.add(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter.
|
||||
*/
|
||||
abstract protected fun chapterListSelector(): String
|
||||
|
||||
/**
|
||||
* Fills [chapter] with the given [element].
|
||||
*
|
||||
* @param element an element obtained from [chapterListSelector].
|
||||
* @param chapter the chapter to fill.
|
||||
*/
|
||||
abstract protected fun chapterFromElement(element: Element, chapter: Chapter)
|
||||
|
||||
/**
|
||||
* Parse the response from the site and fills the page list.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
* @param pages the list of pages to fill.
|
||||
*/
|
||||
override fun pageListParse(response: Response, pages: MutableList<Page>) {
|
||||
pageListParse(response.asJsoup(), pages)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills [pages] from the given [document].
|
||||
*
|
||||
* @param document the parsed document.
|
||||
* @param pages the list of pages to fill.
|
||||
*/
|
||||
abstract protected fun pageListParse(document: Document, pages: MutableList<Page>)
|
||||
|
||||
/**
|
||||
* Parse the response from the site and returns the absolute url to the source image.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
return imageUrlParse(response.asJsoup())
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute url to the source image from the document.
|
||||
*
|
||||
* @param document the parsed document.
|
||||
*/
|
||||
abstract protected fun imageUrlParse(document: Document): String
|
||||
}
|
@ -1,349 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.source.online.english
|
||||
|
||||
import android.net.Uri
|
||||
import android.text.Html
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.network.GET
|
||||
import eu.kanade.tachiyomi.data.network.POST
|
||||
import eu.kanade.tachiyomi.data.network.asObservable
|
||||
import eu.kanade.tachiyomi.data.source.EN
|
||||
import eu.kanade.tachiyomi.data.source.Language
|
||||
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.data.source.online.LoginSource
|
||||
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import eu.kanade.tachiyomi.util.selectText
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
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(override val id: Int) : ParsedOnlineSource(), LoginSource {
|
||||
|
||||
override val name = "Batoto"
|
||||
|
||||
override val baseUrl = "http://bato.to"
|
||||
|
||||
override val lang: Language get() = 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 fun headersBuilder() = super.headersBuilder()
|
||||
.add("Cookie", "lang_option=English")
|
||||
|
||||
private val pageHeaders = super.headersBuilder()
|
||||
.add("Referer", "http://bato.to/reader")
|
||||
.build()
|
||||
|
||||
override fun popularMangaInitialUrl() = "$baseUrl/search_ajax?order_cond=views&order=desc&p=1"
|
||||
|
||||
override fun latestUpdatesInitialUrl() = "$baseUrl/search_ajax?order_cond=update&order=desc&p=1"
|
||||
|
||||
override fun popularMangaParse(response: Response, page: MangasPage) {
|
||||
val document = response.asJsoup()
|
||||
for (element in document.select(popularMangaSelector())) {
|
||||
Manga.create(id).apply {
|
||||
popularMangaFromElement(element, this)
|
||||
page.mangas.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
page.nextPageUrl = document.select(popularMangaNextPageSelector()).first()?.let {
|
||||
"$baseUrl/search_ajax?order_cond=views&order=desc&p=${page.page + 1}"
|
||||
}
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response, page: MangasPage) {
|
||||
val document = response.asJsoup()
|
||||
for (element in document.select(latestUpdatesSelector())) {
|
||||
Manga.create(id).apply {
|
||||
latestUpdatesFromElement(element, this)
|
||||
page.mangas.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
page.nextPageUrl = document.select(latestUpdatesNextPageSelector()).first()?.let {
|
||||
"$baseUrl/search_ajax?order_cond=update&order=desc&p=${page.page + 1}"
|
||||
}
|
||||
}
|
||||
|
||||
override fun popularMangaSelector() = "tr:has(a)"
|
||||
|
||||
override fun latestUpdatesSelector() = "tr:has(a)"
|
||||
|
||||
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
||||
element.select("a[href^=http://bato.to]").first().let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.text().trim()
|
||||
}
|
||||
}
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
||||
popularMangaFromElement(element, manga)
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector() = "#show_more_row"
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = "#show_more_row"
|
||||
|
||||
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/search_ajax?name=${Uri.encode(query)}&order_cond=views&order=desc&p=1&genre_cond=and&genres=${getFilterParams(filters)}"
|
||||
|
||||
private fun getFilterParams(filters: List<Filter>): String = filters
|
||||
.map {
|
||||
";i" + it.id
|
||||
}.joinToString()
|
||||
|
||||
override fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
|
||||
if (page.page == 1) {
|
||||
page.url = searchMangaInitialUrl(query, filters)
|
||||
}
|
||||
return GET(page.url, headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>) {
|
||||
val document = response.asJsoup()
|
||||
for (element in document.select(searchMangaSelector())) {
|
||||
Manga.create(id).apply {
|
||||
searchMangaFromElement(element, this)
|
||||
page.mangas.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
page.nextPageUrl = document.select(searchMangaNextPageSelector()).first()?.let {
|
||||
"$baseUrl/search_ajax?name=${Uri.encode(query)}&p=${page.page + 1}&order_cond=views&order=desc&genre_cond=and&genres=" + getFilterParams(filters)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = popularMangaSelector()
|
||||
|
||||
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
||||
popularMangaFromElement(element, manga)
|
||||
}
|
||||
|
||||
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
|
||||
|
||||
override fun mangaDetailsRequest(manga: Manga): Request {
|
||||
val mangaId = manga.url.substringAfterLast("r")
|
||||
return GET("$baseUrl/comic_pop?id=$mangaId", headers)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document, manga: Manga) {
|
||||
val tbody = document.select("tbody").first()
|
||||
val artistElement = tbody.select("tr:contains(Author/Artist:)").first()
|
||||
|
||||
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(", ")
|
||||
}
|
||||
|
||||
private fun parseStatus(status: String?) = when (status) {
|
||||
"Ongoing" -> Manga.ONGOING
|
||||
"Complete" -> Manga.COMPLETED
|
||||
else -> Manga.UNKNOWN
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response, chapters: MutableList<Chapter>) {
|
||||
val body = response.body().string()
|
||||
val matcher = staffNotice.matcher(body)
|
||||
if (matcher.find()) {
|
||||
val notice = Html.fromHtml(matcher.group(1)).toString().trim()
|
||||
throw Exception(notice)
|
||||
}
|
||||
|
||||
val document = response.asJsoup(body)
|
||||
|
||||
for (element in document.select(chapterListSelector())) {
|
||||
Chapter.create().apply {
|
||||
chapterFromElement(element, this)
|
||||
chapters.add(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "tr.row.lang_English.chapter_row"
|
||||
|
||||
override fun chapterFromElement(element: Element, chapter: Chapter) {
|
||||
val urlElement = element.select("a[href^=http://bato.to/reader").first()
|
||||
|
||||
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
||||
chapter.name = urlElement.text()
|
||||
chapter.date_upload = element.select("td").getOrNull(4)?.let {
|
||||
parseDateFromElement(it)
|
||||
} ?: 0
|
||||
}
|
||||
|
||||
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: Chapter): Request {
|
||||
val id = chapter.url.substringAfterLast("#")
|
||||
return GET("$baseUrl/areader?id=$id&p=1", pageHeaders)
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document, pages: MutableList<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")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 fetchChapterList(manga: Manga): Observable<List<Chapter>> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// [...document.querySelectorAll("#advanced_options div.genre_buttons")].map((el,i) => {
|
||||
// const onClick=el.getAttribute('onclick');const id=onClick.substr(14,onClick.length-16);return `Filter("${id}", "${el.textContent.trim()}")`
|
||||
// }).join(',\n')
|
||||
// on https://bato.to/search
|
||||
override fun getFilterList(): List<Filter> = listOf(
|
||||
Filter("40", "4-Koma"),
|
||||
Filter("1", "Action"),
|
||||
Filter("2", "Adventure"),
|
||||
Filter("39", "Award Winning"),
|
||||
Filter("3", "Comedy"),
|
||||
Filter("41", "Cooking"),
|
||||
Filter("9", "Doujinshi"),
|
||||
Filter("10", "Drama"),
|
||||
Filter("12", "Ecchi"),
|
||||
Filter("13", "Fantasy"),
|
||||
Filter("15", "Gender Bender"),
|
||||
Filter("17", "Harem"),
|
||||
Filter("20", "Historical"),
|
||||
Filter("22", "Horror"),
|
||||
Filter("34", "Josei"),
|
||||
Filter("27", "Martial Arts"),
|
||||
Filter("30", "Mecha"),
|
||||
Filter("42", "Medical"),
|
||||
Filter("37", "Music"),
|
||||
Filter("4", "Mystery"),
|
||||
Filter("38", "Oneshot"),
|
||||
Filter("5", "Psychological"),
|
||||
Filter("6", "Romance"),
|
||||
Filter("7", "School Life"),
|
||||
Filter("8", "Sci-fi"),
|
||||
Filter("32", "Seinen"),
|
||||
Filter("35", "Shoujo"),
|
||||
Filter("16", "Shoujo Ai"),
|
||||
Filter("33", "Shounen"),
|
||||
Filter("19", "Shounen Ai"),
|
||||
Filter("21", "Slice of Life"),
|
||||
Filter("23", "Smut"),
|
||||
Filter("25", "Sports"),
|
||||
Filter("26", "Supernatural"),
|
||||
Filter("28", "Tragedy"),
|
||||
Filter("36", "Webtoon"),
|
||||
Filter("29", "Yaoi"),
|
||||
Filter("31", "Yuri")
|
||||
)
|
||||
|
||||
}
|
@ -1,182 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.source.online.english
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.network.GET
|
||||
import eu.kanade.tachiyomi.data.network.POST
|
||||
import eu.kanade.tachiyomi.data.source.EN
|
||||
import eu.kanade.tachiyomi.data.source.Language
|
||||
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class Kissmanga(override val id: Int) : ParsedOnlineSource() {
|
||||
|
||||
override val name = "Kissmanga"
|
||||
|
||||
override val baseUrl = "http://kissmanga.com"
|
||||
|
||||
override val lang: Language get() = EN
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient
|
||||
|
||||
override fun popularMangaInitialUrl() = "$baseUrl/MangaList/MostPopular"
|
||||
|
||||
override fun latestUpdatesInitialUrl() = "http://kissmanga.com/MangaList/LatestUpdate"
|
||||
|
||||
override fun popularMangaSelector() = "table.listing tr:gt(1)"
|
||||
|
||||
override fun latestUpdatesSelector() = "table.listing tr:gt(1)"
|
||||
|
||||
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
||||
element.select("td a:eq(0)").first().let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.text()
|
||||
}
|
||||
}
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
||||
popularMangaFromElement(element, manga)
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector() = "li > a:contains(› Next)"
|
||||
|
||||
override fun latestUpdatesNextPageSelector(): String = "ul.pager > li > a:contains(Next)"
|
||||
|
||||
override fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
|
||||
if (page.page == 1) {
|
||||
page.url = searchMangaInitialUrl(query, filters)
|
||||
}
|
||||
|
||||
val form = FormBody.Builder().apply {
|
||||
add("authorArtist", "")
|
||||
add("mangaName", query)
|
||||
add("status", "")
|
||||
|
||||
this@Kissmanga.filters.forEach { filter ->
|
||||
add("genres", if (filter in filters) "1" else "0")
|
||||
}
|
||||
}
|
||||
|
||||
return POST(page.url, headers, form.build())
|
||||
}
|
||||
|
||||
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/AdvanceSearch"
|
||||
|
||||
override fun searchMangaSelector() = popularMangaSelector()
|
||||
|
||||
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
||||
popularMangaFromElement(element, manga)
|
||||
}
|
||||
|
||||
override fun searchMangaNextPageSelector() = null
|
||||
|
||||
override fun mangaDetailsParse(document: Document, manga: Manga) {
|
||||
val infoElement = document.select("div.barContent").first()
|
||||
|
||||
manga.author = infoElement.select("p:has(span:contains(Author:)) > a").first()?.text()
|
||||
manga.genre = infoElement.select("p:has(span:contains(Genres:)) > *:gt(0)").text()
|
||||
manga.description = infoElement.select("p:has(span:contains(Summary:)) ~ p").text()
|
||||
manga.status = infoElement.select("p:has(span:contains(Status:))").first()?.text().orEmpty().let { parseStatus(it) }
|
||||
manga.thumbnail_url = document.select(".rightBox:eq(0) img").first()?.attr("src")
|
||||
}
|
||||
|
||||
fun parseStatus(status: String) = when {
|
||||
status.contains("Ongoing") -> Manga.ONGOING
|
||||
status.contains("Completed") -> Manga.COMPLETED
|
||||
else -> Manga.UNKNOWN
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "table.listing tr:gt(1)"
|
||||
|
||||
override fun chapterFromElement(element: Element, chapter: Chapter) {
|
||||
val urlElement = element.select("a").first()
|
||||
|
||||
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
||||
chapter.name = urlElement.text()
|
||||
chapter.date_upload = element.select("td:eq(1)").first()?.text()?.let {
|
||||
SimpleDateFormat("MM/dd/yyyy").parse(it).time
|
||||
} ?: 0
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: Chapter) = POST(baseUrl + chapter.url, headers)
|
||||
|
||||
override fun pageListParse(response: Response, pages: MutableList<Page>) {
|
||||
//language=RegExp
|
||||
val p = Pattern.compile("""lstImages.push\("(.+?)"""")
|
||||
val m = p.matcher(response.body().string())
|
||||
|
||||
var i = 0
|
||||
while (m.find()) {
|
||||
pages.add(Page(i++, "", m.group(1)))
|
||||
}
|
||||
}
|
||||
|
||||
// Not used
|
||||
override fun pageListParse(document: Document, pages: MutableList<Page>) {
|
||||
}
|
||||
|
||||
override fun imageUrlRequest(page: Page) = GET(page.url)
|
||||
|
||||
override fun imageUrlParse(document: Document) = ""
|
||||
|
||||
// $("select[name=\"genres\"]").map((i,el) => `Filter("${i}", "${$(el).next().text().trim()}")`).get().join(',\n')
|
||||
// on http://kissmanga.com/AdvanceSearch
|
||||
override fun getFilterList(): List<Filter> = listOf(
|
||||
Filter("0", "Action"),
|
||||
Filter("1", "Adult"),
|
||||
Filter("2", "Adventure"),
|
||||
Filter("3", "Comedy"),
|
||||
Filter("4", "Comic"),
|
||||
Filter("5", "Cooking"),
|
||||
Filter("6", "Doujinshi"),
|
||||
Filter("7", "Drama"),
|
||||
Filter("8", "Ecchi"),
|
||||
Filter("9", "Fantasy"),
|
||||
Filter("10", "Gender Bender"),
|
||||
Filter("11", "Harem"),
|
||||
Filter("12", "Historical"),
|
||||
Filter("13", "Horror"),
|
||||
Filter("14", "Josei"),
|
||||
Filter("15", "Lolicon"),
|
||||
Filter("16", "Manga"),
|
||||
Filter("17", "Manhua"),
|
||||
Filter("18", "Manhwa"),
|
||||
Filter("19", "Martial Arts"),
|
||||
Filter("20", "Mature"),
|
||||
Filter("21", "Mecha"),
|
||||
Filter("22", "Medical"),
|
||||
Filter("23", "Music"),
|
||||
Filter("24", "Mystery"),
|
||||
Filter("25", "One shot"),
|
||||
Filter("26", "Psychological"),
|
||||
Filter("27", "Romance"),
|
||||
Filter("28", "School Life"),
|
||||
Filter("29", "Sci-fi"),
|
||||
Filter("30", "Seinen"),
|
||||
Filter("31", "Shotacon"),
|
||||
Filter("32", "Shoujo"),
|
||||
Filter("33", "Shoujo Ai"),
|
||||
Filter("34", "Shounen"),
|
||||
Filter("35", "Shounen Ai"),
|
||||
Filter("36", "Slice of Life"),
|
||||
Filter("37", "Smut"),
|
||||
Filter("38", "Sports"),
|
||||
Filter("39", "Supernatural"),
|
||||
Filter("40", "Tragedy"),
|
||||
Filter("41", "Webtoon"),
|
||||
Filter("42", "Yaoi"),
|
||||
Filter("43", "Yuri")
|
||||
)
|
||||
}
|
@ -1,172 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.source.online.english
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.source.EN
|
||||
import eu.kanade.tachiyomi.data.source.Language
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class Mangafox(override val id: Int) : ParsedOnlineSource() {
|
||||
|
||||
override val name = "Mangafox"
|
||||
|
||||
override val baseUrl = "http://mangafox.me"
|
||||
|
||||
override val lang: Language get() = EN
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override fun popularMangaInitialUrl() = "$baseUrl/directory/"
|
||||
|
||||
override fun latestUpdatesInitialUrl() = "$baseUrl/directory/?latest"
|
||||
|
||||
override fun popularMangaSelector() = "div#mangalist > ul.list > li"
|
||||
|
||||
override fun latestUpdatesSelector() = "div#mangalist > ul.list > li"
|
||||
|
||||
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
||||
element.select("a.title").first().let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.text()
|
||||
}
|
||||
}
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
||||
popularMangaFromElement(element, manga)
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector() = "a:has(span.next)"
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = "a:has(span.next)"
|
||||
|
||||
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
|
||||
"$baseUrl/search.php?name_method=cw&advopts=1&order=za&sort=views&name=$query&page=1&${filters.map { it.id + "=1" }.joinToString("&")}"
|
||||
|
||||
override fun searchMangaSelector() = "table#listing > tbody > tr:gt(0)"
|
||||
|
||||
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
||||
element.select("a.series_preview").first().let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.text()
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaNextPageSelector() = "a:has(span.next)"
|
||||
|
||||
override fun mangaDetailsParse(document: Document, manga: Manga) {
|
||||
val infoElement = document.select("div#title").first()
|
||||
val rowElement = infoElement.select("table > tbody > tr:eq(1)").first()
|
||||
val sideInfoElement = document.select("#series_info").first()
|
||||
|
||||
manga.author = rowElement.select("td:eq(1)").first()?.text()
|
||||
manga.artist = rowElement.select("td:eq(2)").first()?.text()
|
||||
manga.genre = rowElement.select("td:eq(3)").first()?.text()
|
||||
manga.description = infoElement.select("p.summary").first()?.text()
|
||||
manga.status = sideInfoElement.select(".data").first()?.text().orEmpty().let { parseStatus(it) }
|
||||
manga.thumbnail_url = sideInfoElement.select("div.cover > img").first()?.attr("src")
|
||||
}
|
||||
|
||||
private fun parseStatus(status: String) = when {
|
||||
status.contains("Ongoing") -> Manga.ONGOING
|
||||
status.contains("Completed") -> Manga.COMPLETED
|
||||
else -> Manga.UNKNOWN
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "div#chapters li div"
|
||||
|
||||
override fun chapterFromElement(element: Element, chapter: Chapter) {
|
||||
val urlElement = element.select("a.tips").first()
|
||||
|
||||
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
||||
chapter.name = urlElement.text()
|
||||
chapter.date_upload = element.select("span.date").first()?.text()?.let { parseChapterDate(it) } ?: 0
|
||||
}
|
||||
|
||||
private fun parseChapterDate(date: String): Long {
|
||||
return if ("Today" in date || " ago" in date) {
|
||||
Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
} else if ("Yesterday" in date) {
|
||||
Calendar.getInstance().apply {
|
||||
add(Calendar.DATE, -1)
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
} else {
|
||||
try {
|
||||
SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).parse(date).time
|
||||
} catch (e: ParseException) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response, pages: MutableList<Page>) {
|
||||
val document = response.asJsoup()
|
||||
|
||||
val url = response.request().url().toString().substringBeforeLast('/')
|
||||
document.select("select.m").first()?.select("option:not([value=0])")?.forEach {
|
||||
pages.add(Page(pages.size, "$url/${it.attr("value")}.html"))
|
||||
}
|
||||
}
|
||||
|
||||
// Not used, overrides parent.
|
||||
override fun pageListParse(document: Document, pages: MutableList<Page>) {}
|
||||
|
||||
override fun imageUrlParse(document: Document) = document.getElementById("image").attr("src")
|
||||
|
||||
// $('select.genres').map((i,el)=>`Filter("${$(el).attr('name')}", "${$(el).next().text().trim()}")`).get().join(',\n')
|
||||
// on http://kissmanga.com/AdvanceSearch
|
||||
override fun getFilterList(): List<Filter> = listOf(
|
||||
Filter("genres[Action]", "Action"),
|
||||
Filter("genres[Adult]", "Adult"),
|
||||
Filter("genres[Adventure]", "Adventure"),
|
||||
Filter("genres[Comedy]", "Comedy"),
|
||||
Filter("genres[Doujinshi]", "Doujinshi"),
|
||||
Filter("genres[Drama]", "Drama"),
|
||||
Filter("genres[Ecchi]", "Ecchi"),
|
||||
Filter("genres[Fantasy]", "Fantasy"),
|
||||
Filter("genres[Gender Bender]", "Gender Bender"),
|
||||
Filter("genres[Harem]", "Harem"),
|
||||
Filter("genres[Historical]", "Historical"),
|
||||
Filter("genres[Horror]", "Horror"),
|
||||
Filter("genres[Josei]", "Josei"),
|
||||
Filter("genres[Martial Arts]", "Martial Arts"),
|
||||
Filter("genres[Mature]", "Mature"),
|
||||
Filter("genres[Mecha]", "Mecha"),
|
||||
Filter("genres[Mystery]", "Mystery"),
|
||||
Filter("genres[One Shot]", "One Shot"),
|
||||
Filter("genres[Psychological]", "Psychological"),
|
||||
Filter("genres[Romance]", "Romance"),
|
||||
Filter("genres[School Life]", "School Life"),
|
||||
Filter("genres[Sci-fi]", "Sci-fi"),
|
||||
Filter("genres[Seinen]", "Seinen"),
|
||||
Filter("genres[Shoujo]", "Shoujo"),
|
||||
Filter("genres[Shoujo Ai]", "Shoujo Ai"),
|
||||
Filter("genres[Shounen]", "Shounen"),
|
||||
Filter("genres[Shounen Ai]", "Shounen Ai"),
|
||||
Filter("genres[Slice of Life]", "Slice of Life"),
|
||||
Filter("genres[Smut]", "Smut"),
|
||||
Filter("genres[Sports]", "Sports"),
|
||||
Filter("genres[Supernatural]", "Supernatural"),
|
||||
Filter("genres[Tragedy]", "Tragedy"),
|
||||
Filter("genres[Webtoons]", "Webtoons"),
|
||||
Filter("genres[Yaoi]", "Yaoi"),
|
||||
Filter("genres[Yuri]", "Yuri")
|
||||
)
|
||||
|
||||
}
|
@ -1,173 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.source.online.english
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.source.EN
|
||||
import eu.kanade.tachiyomi.data.source.Language
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class Mangahere(override val id: Int) : ParsedOnlineSource() {
|
||||
|
||||
override val name = "Mangahere"
|
||||
|
||||
override val baseUrl = "http://www.mangahere.co"
|
||||
|
||||
override val lang: Language get() = EN
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override fun popularMangaInitialUrl() = "$baseUrl/directory/?views.za"
|
||||
|
||||
override fun latestUpdatesInitialUrl() = "$baseUrl/directory/?last_chapter_time.za"
|
||||
|
||||
override fun popularMangaSelector() = "div.directory_list > ul > li"
|
||||
|
||||
override fun latestUpdatesSelector() = "div.directory_list > ul > li"
|
||||
|
||||
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
||||
element.select("div.title > a").first().let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.text()
|
||||
}
|
||||
}
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
||||
popularMangaFromElement(element, manga)
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector() = "div.next-page > a.next"
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = "div.next-page > a.next"
|
||||
|
||||
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/search.php?name=$query&page=1&sort=views&order=za&${filters.map { it.id + "=1" }.joinToString("&")}&advopts=1"
|
||||
|
||||
override fun searchMangaSelector() = "div.result_search > dl:has(dt)"
|
||||
|
||||
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
||||
element.select("a.manga_info").first().let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.text()
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaNextPageSelector() = "div.next-page > a.next"
|
||||
|
||||
override fun mangaDetailsParse(document: Document, manga: Manga) {
|
||||
val detailElement = document.select(".manga_detail_top").first()
|
||||
val infoElement = detailElement.select(".detail_topText").first()
|
||||
|
||||
manga.author = infoElement.select("a[href^=http://www.mangahere.co/author/]").first()?.text()
|
||||
manga.artist = infoElement.select("a[href^=http://www.mangahere.co/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.status = infoElement.select("li:eq(6)").first()?.text().orEmpty().let { parseStatus(it) }
|
||||
manga.thumbnail_url = detailElement.select("img.img").first()?.attr("src")
|
||||
}
|
||||
|
||||
private fun parseStatus(status: String) = when {
|
||||
status.contains("Ongoing") -> Manga.ONGOING
|
||||
status.contains("Completed") -> Manga.COMPLETED
|
||||
else -> Manga.UNKNOWN
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = ".detail_list > ul:not([class]) > li"
|
||||
|
||||
override fun chapterFromElement(element: Element, chapter: Chapter) {
|
||||
val parentEl = element.select("span.left").first()
|
||||
|
||||
val urlElement = parentEl.select("a").first()
|
||||
|
||||
var volume = parentEl.select("span.mr6")?.first()?.text()?.trim()?:""
|
||||
if (volume.length > 0) {
|
||||
volume = " - " + volume
|
||||
}
|
||||
|
||||
var title = parentEl?.textNodes()?.last()?.text()?.trim()?:""
|
||||
if (title.length > 0) {
|
||||
title = " - " + title
|
||||
}
|
||||
|
||||
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
||||
chapter.name = urlElement.text() + volume + title
|
||||
chapter.date_upload = element.select("span.right").first()?.text()?.let { parseChapterDate(it) } ?: 0
|
||||
}
|
||||
|
||||
private fun parseChapterDate(date: String): Long {
|
||||
return if ("Today" in date) {
|
||||
Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
} else if ("Yesterday" in date) {
|
||||
Calendar.getInstance().apply {
|
||||
add(Calendar.DATE, -1)
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
} else {
|
||||
try {
|
||||
SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).parse(date).time
|
||||
} catch (e: ParseException) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document, pages: MutableList<Page>) {
|
||||
document.select("select.wid60").first()?.getElementsByTag("option")?.forEach {
|
||||
pages.add(Page(pages.size, it.attr("value")))
|
||||
}
|
||||
pages.getOrNull(0)?.imageUrl = imageUrlParse(document)
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document) = document.getElementById("image").attr("src")
|
||||
|
||||
// [...document.querySelectorAll("select[id^='genres'")].map((el,i) => `Filter("${el.getAttribute('name')}", "${el.nextSibling.nextSibling.textContent.trim()}")`).join(',\n')
|
||||
// http://www.mangahere.co/advsearch.htm
|
||||
override fun getFilterList(): List<Filter> = listOf(
|
||||
Filter("genres[Action]", "Action"),
|
||||
Filter("genres[Adventure]", "Adventure"),
|
||||
Filter("genres[Comedy]", "Comedy"),
|
||||
Filter("genres[Doujinshi]", "Doujinshi"),
|
||||
Filter("genres[Drama]", "Drama"),
|
||||
Filter("genres[Ecchi]", "Ecchi"),
|
||||
Filter("genres[Fantasy]", "Fantasy"),
|
||||
Filter("genres[Gender Bender]", "Gender Bender"),
|
||||
Filter("genres[Harem]", "Harem"),
|
||||
Filter("genres[Historical]", "Historical"),
|
||||
Filter("genres[Horror]", "Horror"),
|
||||
Filter("genres[Josei]", "Josei"),
|
||||
Filter("genres[Martial Arts]", "Martial Arts"),
|
||||
Filter("genres[Mature]", "Mature"),
|
||||
Filter("genres[Mecha]", "Mecha"),
|
||||
Filter("genres[Mystery]", "Mystery"),
|
||||
Filter("genres[One Shot]", "One Shot"),
|
||||
Filter("genres[Psychological]", "Psychological"),
|
||||
Filter("genres[Romance]", "Romance"),
|
||||
Filter("genres[School Life]", "School Life"),
|
||||
Filter("genres[Sci-fi]", "Sci-fi"),
|
||||
Filter("genres[Seinen]", "Seinen"),
|
||||
Filter("genres[Shoujo]", "Shoujo"),
|
||||
Filter("genres[Shoujo Ai]", "Shoujo Ai"),
|
||||
Filter("genres[Shounen]", "Shounen"),
|
||||
Filter("genres[Shounen Ai]", "Shounen Ai"),
|
||||
Filter("genres[Slice of Life]", "Slice of Life"),
|
||||
Filter("genres[Sports]", "Sports"),
|
||||
Filter("genres[Supernatural]", "Supernatural"),
|
||||
Filter("genres[Tragedy]", "Tragedy"),
|
||||
Filter("genres[Yaoi]", "Yaoi"),
|
||||
Filter("genres[Yuri]", "Yuri")
|
||||
)
|
||||
|
||||
}
|
@ -1,253 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.source.online.english
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.network.POST
|
||||
import eu.kanade.tachiyomi.data.source.EN
|
||||
import eu.kanade.tachiyomi.data.source.Language
|
||||
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class Mangasee(override val id: Int) : ParsedOnlineSource() {
|
||||
|
||||
override val name = "Mangasee"
|
||||
|
||||
override val baseUrl = "http://mangaseeonline.net"
|
||||
|
||||
override val lang: Language get() = EN
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
private val recentUpdatesPattern = Pattern.compile("(.*?)\\s(\\d+\\.?\\d*)\\s?(Completed)?")
|
||||
|
||||
private val indexPattern = Pattern.compile("-index-(.*?)-")
|
||||
|
||||
override fun popularMangaInitialUrl() = "$baseUrl/search/request.php?sortBy=popularity&sortOrder=descending"
|
||||
|
||||
override fun popularMangaSelector() = "div.requested > div.row"
|
||||
|
||||
override fun popularMangaRequest(page: MangasPage): Request {
|
||||
if (page.page == 1) {
|
||||
page.url = popularMangaInitialUrl()
|
||||
}
|
||||
val (body, requestUrl) = convertQueryToPost(page)
|
||||
return POST(requestUrl, headers, body.build())
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response, page: MangasPage) {
|
||||
val document = response.asJsoup()
|
||||
for (element in document.select(popularMangaSelector())) {
|
||||
Manga.create(id).apply {
|
||||
popularMangaFromElement(element, this)
|
||||
page.mangas.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
page.nextPageUrl = page.url
|
||||
}
|
||||
|
||||
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
||||
element.select("a.resultLink").first().let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.text()
|
||||
}
|
||||
}
|
||||
|
||||
// Not used, overrides parent.
|
||||
override fun popularMangaNextPageSelector() = ""
|
||||
|
||||
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
|
||||
"$baseUrl/search/request.php?sortBy=popularity&sortOrder=descending&keyword=$query&genre=${filters.map { it.id }.joinToString(",")}"
|
||||
|
||||
override fun searchMangaSelector() = "div.searchResults > div.requested > div.row"
|
||||
|
||||
override fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
|
||||
if (page.page == 1) {
|
||||
page.url = searchMangaInitialUrl(query, filters)
|
||||
}
|
||||
val (body, requestUrl) = convertQueryToPost(page)
|
||||
return POST(requestUrl, headers, body.build())
|
||||
}
|
||||
|
||||
private fun convertQueryToPost(page: MangasPage): Pair<FormBody.Builder, String> {
|
||||
val url = HttpUrl.parse(page.url)
|
||||
val body = FormBody.Builder().add("page", page.page.toString())
|
||||
for (i in 0..url.querySize() - 1) {
|
||||
body.add(url.queryParameterName(i), url.queryParameterValue(i))
|
||||
}
|
||||
val requestUrl = url.scheme() + "://" + url.host() + url.encodedPath()
|
||||
return Pair(body, requestUrl)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>) {
|
||||
val document = response.asJsoup()
|
||||
for (element in document.select(popularMangaSelector())) {
|
||||
Manga.create(id).apply {
|
||||
popularMangaFromElement(element, this)
|
||||
page.mangas.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
page.nextPageUrl = page.url
|
||||
}
|
||||
|
||||
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
||||
element.select("a.resultLink").first().let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.text()
|
||||
}
|
||||
}
|
||||
|
||||
// Not used, overrides parent.
|
||||
override fun searchMangaNextPageSelector() = ""
|
||||
|
||||
override fun mangaDetailsParse(document: Document, manga: Manga) {
|
||||
val detailElement = document.select("div.well > div.row").first()
|
||||
|
||||
manga.author = detailElement.select("a[href^=/search/?author=]").first()?.text()
|
||||
manga.genre = detailElement.select("span.details > div.row > div:has(b:contains(Genre(s))) > a").map { it.text() }.joinToString()
|
||||
manga.description = detailElement.select("strong:contains(Description:) + div").first()?.text()
|
||||
manga.status = detailElement.select("a[href^=/search/?status=]").first()?.text().orEmpty().let { parseStatus(it) }
|
||||
manga.thumbnail_url = detailElement.select("div > img").first()?.absUrl("src")
|
||||
}
|
||||
|
||||
private fun parseStatus(status: String) = when {
|
||||
status.contains("Ongoing (Scan)") -> Manga.ONGOING
|
||||
status.contains("Complete (Scan)") -> Manga.COMPLETED
|
||||
else -> Manga.UNKNOWN
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "div.chapter-list > a"
|
||||
|
||||
override fun chapterFromElement(element: Element, chapter: Chapter) {
|
||||
val urlElement = element.select("a").first()
|
||||
|
||||
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
||||
chapter.name = element.select("span.chapterLabel").first().text()?.let { it } ?: ""
|
||||
chapter.date_upload = element.select("time").first()?.attr("datetime")?.let { parseChapterDate(it) } ?: 0
|
||||
}
|
||||
|
||||
private fun parseChapterDate(dateAsString: String): Long {
|
||||
return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse(dateAsString).time
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response, pages: MutableList<Page>) {
|
||||
val document = response.asJsoup()
|
||||
val fullUrl = response.request().url().toString()
|
||||
val url = fullUrl.substringBeforeLast('/')
|
||||
|
||||
val series = document.select("input.IndexName").first().attr("value")
|
||||
val chapter = document.select("span.CurChapter").first().text()
|
||||
var index = ""
|
||||
|
||||
val m = indexPattern.matcher(fullUrl)
|
||||
if (m.find()) {
|
||||
val indexNumber = m.group(1)
|
||||
index = "-index-$indexNumber"
|
||||
}
|
||||
|
||||
document.select("div.ContainerNav").first().select("select.PageSelect > option").forEach {
|
||||
pages.add(Page(pages.size, "$url/$series-chapter-$chapter$index-page-${pages.size + 1}.html"))
|
||||
}
|
||||
pages.getOrNull(0)?.imageUrl = imageUrlParse(document)
|
||||
}
|
||||
|
||||
// Not used, overrides parent.
|
||||
override fun pageListParse(document: Document, pages: MutableList<Page>) {
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String = document.select("img.CurImage").attr("src")
|
||||
|
||||
// [...document.querySelectorAll("label.triStateCheckBox input")].map(el => `Filter("${el.getAttribute('name')}", "${el.nextSibling.textContent.trim()}")`).join(',\n')
|
||||
// http://mangasee.co/advanced-search/
|
||||
override fun getFilterList(): List<Filter> = listOf(
|
||||
Filter("Action", "Action"),
|
||||
Filter("Adult", "Adult"),
|
||||
Filter("Adventure", "Adventure"),
|
||||
Filter("Comedy", "Comedy"),
|
||||
Filter("Doujinshi", "Doujinshi"),
|
||||
Filter("Drama", "Drama"),
|
||||
Filter("Ecchi", "Ecchi"),
|
||||
Filter("Fantasy", "Fantasy"),
|
||||
Filter("Gender_Bender", "Gender Bender"),
|
||||
Filter("Harem", "Harem"),
|
||||
Filter("Hentai", "Hentai"),
|
||||
Filter("Historical", "Historical"),
|
||||
Filter("Horror", "Horror"),
|
||||
Filter("Josei", "Josei"),
|
||||
Filter("Lolicon", "Lolicon"),
|
||||
Filter("Martial_Arts", "Martial Arts"),
|
||||
Filter("Mature", "Mature"),
|
||||
Filter("Mecha", "Mecha"),
|
||||
Filter("Mystery", "Mystery"),
|
||||
Filter("Psychological", "Psychological"),
|
||||
Filter("Romance", "Romance"),
|
||||
Filter("School_Life", "School Life"),
|
||||
Filter("Sci-fi", "Sci-fi"),
|
||||
Filter("Seinen", "Seinen"),
|
||||
Filter("Shotacon", "Shotacon"),
|
||||
Filter("Shoujo", "Shoujo"),
|
||||
Filter("Shoujo_Ai", "Shoujo Ai"),
|
||||
Filter("Shounen", "Shounen"),
|
||||
Filter("Shounen_Ai", "Shounen Ai"),
|
||||
Filter("Slice_of_Life", "Slice of Life"),
|
||||
Filter("Smut", "Smut"),
|
||||
Filter("Sports", "Sports"),
|
||||
Filter("Supernatural", "Supernatural"),
|
||||
Filter("Tragedy", "Tragedy"),
|
||||
Filter("Yaoi", "Yaoi"),
|
||||
Filter("Yuri", "Yuri")
|
||||
)
|
||||
|
||||
override fun latestUpdatesInitialUrl(): String = "http://mangaseeonline.net/home/latest.request.php"
|
||||
|
||||
// Not used, overrides parent.
|
||||
override fun latestUpdatesNextPageSelector(): String = ""
|
||||
|
||||
override fun latestUpdatesSelector(): String = "a.latestSeries"
|
||||
|
||||
override fun latestUpdatesRequest(page: MangasPage): Request {
|
||||
if (page.page == 1) {
|
||||
page.url = latestUpdatesInitialUrl()
|
||||
}
|
||||
val (body, requestUrl) = convertQueryToPost(page)
|
||||
return POST(requestUrl, headers, body.build())
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response, page: MangasPage) {
|
||||
val document = response.asJsoup()
|
||||
for (element in document.select(latestUpdatesSelector())) {
|
||||
Manga.create(id).apply {
|
||||
latestUpdatesFromElement(element, this)
|
||||
page.mangas.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
page.nextPageUrl = page.url
|
||||
}
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
||||
element.select("a.latestSeries").first().let {
|
||||
val chapterUrl = it.attr("href")
|
||||
val indexOfMangaUrl = chapterUrl.indexOf("-chapter-")
|
||||
val indexOfLastPath = chapterUrl.lastIndexOf("/")
|
||||
val mangaUrl = chapterUrl.substring(indexOfLastPath, indexOfMangaUrl)
|
||||
val defaultText = it.select("p.clamp2").text()
|
||||
val m = recentUpdatesPattern.matcher(defaultText)
|
||||
val title = if (m.matches()) m.group(1) else defaultText
|
||||
manga.setUrlWithoutDomain("/manga" + mangaUrl)
|
||||
manga.title = title
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,196 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.source.online.english
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.network.POST
|
||||
import eu.kanade.tachiyomi.data.source.EN
|
||||
import eu.kanade.tachiyomi.data.source.Language
|
||||
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
||||
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.util.*
|
||||
|
||||
class Readmangatoday(override val id: Int) : ParsedOnlineSource() {
|
||||
|
||||
override val name = "ReadMangaToday"
|
||||
|
||||
override val baseUrl = "http://www.readmanga.today"
|
||||
|
||||
override val lang: Language get() = EN
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client: OkHttpClient get() = network.cloudflareClient
|
||||
|
||||
/**
|
||||
* Search only returns data with this set
|
||||
*/
|
||||
override fun headersBuilder() = Headers.Builder().apply {
|
||||
add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)")
|
||||
add("X-Requested-With", "XMLHttpRequest")
|
||||
}
|
||||
|
||||
override fun popularMangaInitialUrl() = "$baseUrl/hot-manga/"
|
||||
|
||||
override fun latestUpdatesInitialUrl() = "$baseUrl/latest-releases/"
|
||||
|
||||
override fun popularMangaSelector() = "div.hot-manga > div.style-list > div.box"
|
||||
|
||||
override fun latestUpdatesSelector() = "div.hot-manga > div.style-grid > div.box"
|
||||
|
||||
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
||||
element.select("div.title > h2 > a").first().let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.attr("title")
|
||||
}
|
||||
}
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
||||
popularMangaFromElement(element, manga)
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector() = "div.hot-manga > ul.pagination > li > a:contains(»)"
|
||||
|
||||
override fun latestUpdatesNextPageSelector(): String = "div.hot-manga > ul.pagination > li > a:contains(»)"
|
||||
|
||||
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
|
||||
"$baseUrl/service/advanced_search"
|
||||
|
||||
|
||||
override fun searchMangaRequest(page: MangasPage, query: String, filters: List<OnlineSource.Filter>): Request {
|
||||
if (page.page == 1) {
|
||||
page.url = searchMangaInitialUrl(query, filters)
|
||||
}
|
||||
|
||||
val builder = okhttp3.FormBody.Builder()
|
||||
builder.add("manga-name", query)
|
||||
builder.add("type", "all")
|
||||
builder.add("status", "both")
|
||||
for (filter in filters) {
|
||||
builder.add("include[]", filter.id)
|
||||
}
|
||||
|
||||
return POST(page.url, headers, builder.build())
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = "div.style-list > div.box"
|
||||
|
||||
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
||||
element.select("div.title > h2 > a").first().let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.attr("title")
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaNextPageSelector() = "div.next-page > a.next"
|
||||
|
||||
override fun mangaDetailsParse(document: Document, manga: Manga) {
|
||||
val detailElement = document.select("div.movie-meta").first()
|
||||
|
||||
manga.author = document.select("ul.cast-list li.director > ul a").first()?.text()
|
||||
manga.artist = document.select("ul.cast-list li:not(.director) > ul a").first()?.text()
|
||||
manga.genre = detailElement.select("dl.dl-horizontal > dd:eq(5)").first()?.text()
|
||||
manga.description = detailElement.select("li.movie-detail").first()?.text()
|
||||
manga.status = detailElement.select("dl.dl-horizontal > dd:eq(3)").first()?.text().orEmpty().let { parseStatus(it) }
|
||||
manga.thumbnail_url = detailElement.select("img.img-responsive").first()?.attr("src")
|
||||
}
|
||||
|
||||
private fun parseStatus(status: String) = when {
|
||||
status.contains("Ongoing") -> Manga.ONGOING
|
||||
status.contains("Completed") -> Manga.COMPLETED
|
||||
else -> Manga.UNKNOWN
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "ul.chp_lst > li"
|
||||
|
||||
override fun chapterFromElement(element: Element, chapter: Chapter) {
|
||||
val urlElement = element.select("a").first()
|
||||
|
||||
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
||||
chapter.name = urlElement.select("span.val").text()
|
||||
chapter.date_upload = element.select("span.dte").first()?.text()?.let { parseChapterDate(it) } ?: 0
|
||||
}
|
||||
|
||||
private fun parseChapterDate(date: String): Long {
|
||||
val dateWords : List<String> = date.split(" ")
|
||||
|
||||
if (dateWords.size == 3) {
|
||||
val timeAgo = Integer.parseInt(dateWords[0])
|
||||
var date : Calendar = Calendar.getInstance()
|
||||
|
||||
if (dateWords[1].contains("Minute")) {
|
||||
date.add(Calendar.MINUTE, - timeAgo)
|
||||
} else if (dateWords[1].contains("Hour")) {
|
||||
date.add(Calendar.HOUR_OF_DAY, - timeAgo)
|
||||
} else if (dateWords[1].contains("Day")) {
|
||||
date.add(Calendar.DAY_OF_YEAR, -timeAgo)
|
||||
} else if (dateWords[1].contains("Week")) {
|
||||
date.add(Calendar.WEEK_OF_YEAR, -timeAgo)
|
||||
} else if (dateWords[1].contains("Month")) {
|
||||
date.add(Calendar.MONTH, -timeAgo)
|
||||
} else if (dateWords[1].contains("Year")) {
|
||||
date.add(Calendar.YEAR, -timeAgo)
|
||||
}
|
||||
|
||||
return date.getTimeInMillis()
|
||||
}
|
||||
|
||||
return 0L
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document, pages: MutableList<Page>) {
|
||||
document.select("ul.list-switcher-2 > li > select.jump-menu").first().getElementsByTag("option").forEach {
|
||||
pages.add(Page(pages.size, it.attr("value")))
|
||||
}
|
||||
pages.getOrNull(0)?.imageUrl = imageUrlParse(document)
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document) = document.select("img.img-responsive-2").first().attr("src")
|
||||
|
||||
// [...document.querySelectorAll("ul.manga-cat span")].map(el => `Filter("${el.getAttribute('data-id')}", "${el.nextSibling.textContent.trim()}")`).join(',\n')
|
||||
// http://www.readmanga.today/advanced-search
|
||||
override fun getFilterList(): List<Filter> = listOf(
|
||||
Filter("2", "Action"),
|
||||
Filter("4", "Adventure"),
|
||||
Filter("5", "Comedy"),
|
||||
Filter("6", "Doujinshi"),
|
||||
Filter("7", "Drama"),
|
||||
Filter("8", "Ecchi"),
|
||||
Filter("9", "Fantasy"),
|
||||
Filter("10", "Gender Bender"),
|
||||
Filter("11", "Harem"),
|
||||
Filter("12", "Historical"),
|
||||
Filter("13", "Horror"),
|
||||
Filter("14", "Josei"),
|
||||
Filter("15", "Lolicon"),
|
||||
Filter("16", "Martial Arts"),
|
||||
Filter("17", "Mature"),
|
||||
Filter("18", "Mecha"),
|
||||
Filter("19", "Mystery"),
|
||||
Filter("20", "One shot"),
|
||||
Filter("21", "Psychological"),
|
||||
Filter("22", "Romance"),
|
||||
Filter("23", "School Life"),
|
||||
Filter("24", "Sci-fi"),
|
||||
Filter("25", "Seinen"),
|
||||
Filter("26", "Shotacon"),
|
||||
Filter("27", "Shoujo"),
|
||||
Filter("28", "Shoujo Ai"),
|
||||
Filter("29", "Shounen"),
|
||||
Filter("30", "Shounen Ai"),
|
||||
Filter("31", "Slice of Life"),
|
||||
Filter("32", "Smut"),
|
||||
Filter("33", "Sports"),
|
||||
Filter("34", "Supernatural"),
|
||||
Filter("35", "Tragedy"),
|
||||
Filter("36", "Yaoi"),
|
||||
Filter("37", "Yuri")
|
||||
)
|
||||
}
|
@ -1,209 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.source.online.russian
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.source.Language
|
||||
import eu.kanade.tachiyomi.data.source.RU
|
||||
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class Mangachan(override val id: Int) : ParsedOnlineSource() {
|
||||
|
||||
override val name = "Mangachan"
|
||||
|
||||
override val baseUrl = "http://mangachan.me"
|
||||
|
||||
override val lang: Language get() = RU
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override fun popularMangaInitialUrl() = "$baseUrl/mostfavorites"
|
||||
|
||||
override fun latestUpdatesInitialUrl() = "$baseUrl/newestch"
|
||||
|
||||
override fun searchMangaInitialUrl(query: String, filters: List<Filter>): String {
|
||||
if (query.isNotEmpty()) {
|
||||
return "$baseUrl/?do=search&subaction=search&story=$query"
|
||||
} else if (filters.isNotEmpty()) {
|
||||
var genres = ""
|
||||
filters.forEach { genres = genres + it.name + '+' }
|
||||
return "$baseUrl/tags/${genres.dropLast(1)}"
|
||||
} else {
|
||||
return "$baseUrl/?do=search&subaction=search&story=$query"
|
||||
}
|
||||
}
|
||||
|
||||
override fun popularMangaSelector() = "div.content_row"
|
||||
|
||||
override fun latestUpdatesSelector() = "ul.area_rightNews li"
|
||||
|
||||
override fun searchMangaSelector() = popularMangaSelector()
|
||||
|
||||
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
||||
element.select("h2 > a").first().let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.text()
|
||||
}
|
||||
}
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
||||
element.select("a:nth-child(1)").first().let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.text()
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
||||
popularMangaFromElement(element, manga)
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector() = "a:contains(Вперед)"
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
|
||||
|
||||
override fun searchMangaNextPageSelector() = "a:contains(Далее)"
|
||||
|
||||
private fun searchGenresNextPageSelector() = popularMangaNextPageSelector()
|
||||
|
||||
override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>) {
|
||||
val document = response.asJsoup()
|
||||
for (element in document.select(searchMangaSelector())) {
|
||||
Manga.create(id).apply {
|
||||
searchMangaFromElement(element, this)
|
||||
page.mangas.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
searchMangaNextPageSelector().let { selector ->
|
||||
if (page.nextPageUrl.isNullOrEmpty() && filters.isEmpty()) {
|
||||
val onClick = document.select(selector).first()?.attr("onclick")
|
||||
val pageNum = onClick?.substring(23, onClick.indexOf("); return(false)"))
|
||||
page.nextPageUrl = searchMangaInitialUrl(query, emptyList()) + "&search_start=" + pageNum
|
||||
}
|
||||
}
|
||||
|
||||
searchGenresNextPageSelector().let { selector ->
|
||||
if (page.nextPageUrl.isNullOrEmpty() && filters.isNotEmpty()) {
|
||||
val url = document.select(selector).first()?.attr("href")
|
||||
page.nextPageUrl = searchMangaInitialUrl(query, filters) + url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document, manga: Manga) {
|
||||
val infoElement = document.select("table.mangatitle").first()
|
||||
val descElement = document.select("div#description").first()
|
||||
val imgElement = document.select("img#cover").first()
|
||||
|
||||
manga.author = infoElement.select("tr:eq(2) > td:eq(1)").text()
|
||||
manga.genre = infoElement.select("tr:eq(5) > td:eq(1)").text()
|
||||
manga.status = parseStatus(infoElement.select("tr:eq(4) > td:eq(1)").text())
|
||||
manga.description = descElement.textNodes().first().text()
|
||||
manga.thumbnail_url = baseUrl + imgElement.attr("src")
|
||||
}
|
||||
|
||||
private fun parseStatus(element: String): Int {
|
||||
when {
|
||||
element.contains("перевод завершен") -> return Manga.COMPLETED
|
||||
element.contains("перевод продолжается") -> return Manga.ONGOING
|
||||
else -> return Manga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "table.table_cha tr:gt(1)"
|
||||
|
||||
override fun chapterFromElement(element: Element, chapter: Chapter) {
|
||||
val urlElement = element.select("a").first()
|
||||
|
||||
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
||||
chapter.name = urlElement.text()
|
||||
chapter.date_upload = element.select("div.date").first()?.text()?.let {
|
||||
SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(it).time
|
||||
} ?: 0
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response, pages: MutableList<Page>) {
|
||||
val html = response.body().string()
|
||||
val beginIndex = html.indexOf("fullimg\":[") + 10
|
||||
val endIndex = html.indexOf(",]", beginIndex)
|
||||
val trimmedHtml = html.substring(beginIndex, endIndex).replace("\"", "")
|
||||
val pageUrls = trimmedHtml.split(',')
|
||||
|
||||
pageUrls.mapIndexedTo(pages) { i, url -> Page(i, "", url) }
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document, pages: MutableList<Page>) { }
|
||||
|
||||
override fun imageUrlParse(document: Document) = ""
|
||||
|
||||
/* [...document.querySelectorAll("li.sidetag > a:nth-child(1)")].map((el,i) =>
|
||||
* { const link=el.getAttribute('href');const id=link.substr(6,link.length);
|
||||
* return `Filter("${id}", "${id}")` }).join(',\n')
|
||||
* on http://mangachan.me/
|
||||
*/
|
||||
override fun getFilterList(): List<Filter> = listOf(
|
||||
Filter("18_плюс", "18_плюс"),
|
||||
Filter("bdsm", "bdsm"),
|
||||
Filter("арт", "арт"),
|
||||
Filter("биография", "биография"),
|
||||
Filter("боевик", "боевик"),
|
||||
Filter("боевые_искусства", "боевые_искусства"),
|
||||
Filter("вампиры", "вампиры"),
|
||||
Filter("веб", "веб"),
|
||||
Filter("гарем", "гарем"),
|
||||
Filter("гендерная_интрига", "гендерная_интрига"),
|
||||
Filter("героическое_фэнтези", "героическое_фэнтези"),
|
||||
Filter("детектив", "детектив"),
|
||||
Filter("дзёсэй", "дзёсэй"),
|
||||
Filter("додзинси", "додзинси"),
|
||||
Filter("драма", "драма"),
|
||||
Filter("игра", "игра"),
|
||||
Filter("инцест", "инцест"),
|
||||
Filter("искусство", "искусство"),
|
||||
Filter("история", "история"),
|
||||
Filter("киберпанк", "киберпанк"),
|
||||
Filter("кодомо", "кодомо"),
|
||||
Filter("комедия", "комедия"),
|
||||
Filter("литРПГ", "литРПГ"),
|
||||
Filter("махо-сёдзё", "махо-сёдзё"),
|
||||
Filter("меха", "меха"),
|
||||
Filter("мистика", "мистика"),
|
||||
Filter("музыка", "музыка"),
|
||||
Filter("научная_фантастика", "научная_фантастика"),
|
||||
Filter("повседневность", "повседневность"),
|
||||
Filter("постапокалиптика", "постапокалиптика"),
|
||||
Filter("приключения", "приключения"),
|
||||
Filter("психология", "психология"),
|
||||
Filter("романтика", "романтика"),
|
||||
Filter("самурайский_боевик", "самурайский_боевик"),
|
||||
Filter("сборник", "сборник"),
|
||||
Filter("сверхъестественное", "сверхъестественное"),
|
||||
Filter("сказка", "сказка"),
|
||||
Filter("спорт", "спорт"),
|
||||
Filter("супергерои", "супергерои"),
|
||||
Filter("сэйнэн", "сэйнэн"),
|
||||
Filter("сёдзё", "сёдзё"),
|
||||
Filter("сёдзё-ай", "сёдзё-ай"),
|
||||
Filter("сёнэн", "сёнэн"),
|
||||
Filter("сёнэн-ай", "сёнэн-ай"),
|
||||
Filter("тентакли", "тентакли"),
|
||||
Filter("трагедия", "трагедия"),
|
||||
Filter("триллер", "триллер"),
|
||||
Filter("ужасы", "ужасы"),
|
||||
Filter("фантастика", "фантастика"),
|
||||
Filter("фурри", "фурри"),
|
||||
Filter("фэнтези", "фэнтези"),
|
||||
Filter("школа", "школа"),
|
||||
Filter("эротика", "эротика"),
|
||||
Filter("юри", "юри"),
|
||||
Filter("яой", "яой"),
|
||||
Filter("ёнкома", "ёнкома")
|
||||
)
|
||||
}
|
@ -1,165 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.source.online.russian
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.source.Language
|
||||
import eu.kanade.tachiyomi.data.source.RU
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class Mintmanga(override val id: Int) : ParsedOnlineSource() {
|
||||
|
||||
override val name = "Mintmanga"
|
||||
|
||||
override val baseUrl = "http://mintmanga.com"
|
||||
|
||||
override val lang: Language get() = RU
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override fun popularMangaInitialUrl() = "$baseUrl/list?sortType=rate"
|
||||
|
||||
override fun latestUpdatesInitialUrl() = "$baseUrl/list?sortType=updated"
|
||||
|
||||
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
|
||||
"$baseUrl/search?q=$query&${filters.map { it.id + "=in" }.joinToString("&")}"
|
||||
|
||||
override fun popularMangaSelector() = "div.desc"
|
||||
|
||||
override fun latestUpdatesSelector() = "div.desc"
|
||||
|
||||
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
||||
element.select("h3 > a").first().let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.attr("title")
|
||||
}
|
||||
}
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
||||
popularMangaFromElement(element, manga)
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector() = "a.nextLink"
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = "a.nextLink"
|
||||
|
||||
override fun searchMangaSelector() = popularMangaSelector()
|
||||
|
||||
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
||||
popularMangaFromElement(element, manga)
|
||||
}
|
||||
|
||||
// max 200 results
|
||||
override fun searchMangaNextPageSelector() = null
|
||||
|
||||
override fun mangaDetailsParse(document: Document, manga: Manga) {
|
||||
val infoElement = document.select("div.leftContent").first()
|
||||
|
||||
manga.author = infoElement.select("span.elem_author").first()?.text()
|
||||
manga.genre = infoElement.select("span.elem_genre").text().replace(" ,", ",")
|
||||
manga.description = infoElement.select("div.manga-description").text()
|
||||
manga.status = parseStatus(infoElement.html())
|
||||
manga.thumbnail_url = infoElement.select("img").attr("data-full")
|
||||
}
|
||||
|
||||
private fun parseStatus(element: String): Int {
|
||||
when {
|
||||
element.contains("<h3>Запрещена публикация произведения по копирайту</h3>") -> return Manga.LICENSED
|
||||
element.contains("<h1 class=\"names\"> Сингл") || element.contains("<b>Перевод:</b> завершен") -> return Manga.COMPLETED
|
||||
element.contains("<b>Перевод:</b> продолжается") -> return Manga.ONGOING
|
||||
else -> return Manga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "div.chapters-link tbody tr"
|
||||
|
||||
override fun chapterFromElement(element: Element, chapter: Chapter) {
|
||||
val urlElement = element.select("a").first()
|
||||
|
||||
chapter.setUrlWithoutDomain(urlElement.attr("href") + "?mature=1")
|
||||
chapter.name = urlElement.text().replace(" новое", "")
|
||||
chapter.date_upload = element.select("td:eq(1)").first()?.text()?.let {
|
||||
SimpleDateFormat("dd/MM/yy", Locale.US).parse(it).time
|
||||
} ?: 0
|
||||
}
|
||||
|
||||
override fun prepareNewChapter(chapter: Chapter, manga: Manga) {
|
||||
chapter.chapter_number = -2f
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response, pages: MutableList<Page>) {
|
||||
val html = response.body().string()
|
||||
val beginIndex = html.indexOf("rm_h.init( [")
|
||||
val endIndex = html.indexOf("], 0, false);", beginIndex)
|
||||
val trimmedHtml = html.substring(beginIndex, endIndex)
|
||||
|
||||
val p = Pattern.compile("'.+?','.+?',\".+?\"")
|
||||
val m = p.matcher(trimmedHtml)
|
||||
|
||||
var i = 0
|
||||
while (m.find()) {
|
||||
val urlParts = m.group().replace("[\"\']+".toRegex(), "").split(',')
|
||||
pages.add(Page(i++, "", urlParts[1] + urlParts[0] + urlParts[2]))
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document, pages: MutableList<Page>) { }
|
||||
|
||||
override fun imageUrlParse(document: Document) = ""
|
||||
|
||||
/* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")].map((el,i) => {
|
||||
* const onClick=el.getAttribute('onclick');const id=onClick.substr(31,onClick.length-33);
|
||||
* return `Filter("${id}", "${el.textContent.trim()}")` }).join(',\n')
|
||||
* on http://mintmanga.com/search
|
||||
*/
|
||||
override fun getFilterList(): List<Filter> = listOf(
|
||||
Filter("el_2220", "арт"),
|
||||
Filter("el_1353", "бара"),
|
||||
Filter("el_1346", "боевик"),
|
||||
Filter("el_1334", "боевые искусства"),
|
||||
Filter("el_1339", "вампиры"),
|
||||
Filter("el_1333", "гарем"),
|
||||
Filter("el_1347", "гендерная интрига"),
|
||||
Filter("el_1337", "героическое фэнтези"),
|
||||
Filter("el_1343", "детектив"),
|
||||
Filter("el_1349", "дзёсэй"),
|
||||
Filter("el_1332", "додзинси"),
|
||||
Filter("el_1310", "драма"),
|
||||
Filter("el_5229", "игра"),
|
||||
Filter("el_1311", "история"),
|
||||
Filter("el_1351", "киберпанк"),
|
||||
Filter("el_1328", "комедия"),
|
||||
Filter("el_1318", "меха"),
|
||||
Filter("el_1324", "мистика"),
|
||||
Filter("el_1325", "научная фантастика"),
|
||||
Filter("el_1327", "повседневность"),
|
||||
Filter("el_1342", "постапокалиптика"),
|
||||
Filter("el_1322", "приключения"),
|
||||
Filter("el_1335", "психология"),
|
||||
Filter("el_1313", "романтика"),
|
||||
Filter("el_1316", "самурайский боевик"),
|
||||
Filter("el_1350", "сверхъестественное"),
|
||||
Filter("el_1314", "сёдзё"),
|
||||
Filter("el_1320", "сёдзё-ай"),
|
||||
Filter("el_1326", "сёнэн"),
|
||||
Filter("el_1330", "сёнэн-ай"),
|
||||
Filter("el_1321", "спорт"),
|
||||
Filter("el_1329", "сэйнэн"),
|
||||
Filter("el_1344", "трагедия"),
|
||||
Filter("el_1341", "триллер"),
|
||||
Filter("el_1317", "ужасы"),
|
||||
Filter("el_1331", "фантастика"),
|
||||
Filter("el_1323", "фэнтези"),
|
||||
Filter("el_1319", "школа"),
|
||||
Filter("el_1340", "эротика"),
|
||||
Filter("el_1354", "этти"),
|
||||
Filter("el_1315", "юри"),
|
||||
Filter("el_1336", "яой")
|
||||
)
|
||||
}
|
@ -1,164 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.source.online.russian
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.source.Language
|
||||
import eu.kanade.tachiyomi.data.source.RU
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class Readmanga(override val id: Int) : ParsedOnlineSource() {
|
||||
|
||||
override val name = "Readmanga"
|
||||
|
||||
override val baseUrl = "http://readmanga.me"
|
||||
|
||||
override val lang: Language get() = RU
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override fun popularMangaInitialUrl() = "$baseUrl/list?sortType=rate"
|
||||
|
||||
override fun latestUpdatesInitialUrl() = "$baseUrl/list?sortType=updated"
|
||||
|
||||
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
|
||||
"$baseUrl/search?q=$query&${filters.map { it.id + "=in" }.joinToString("&")}"
|
||||
|
||||
override fun popularMangaSelector() = "div.desc"
|
||||
|
||||
override fun latestUpdatesSelector() = "div.desc"
|
||||
|
||||
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
||||
element.select("h3 > a").first().let {
|
||||
manga.setUrlWithoutDomain(it.attr("href"))
|
||||
manga.title = it.attr("title")
|
||||
}
|
||||
}
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
||||
popularMangaFromElement(element, manga)
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector() = "a.nextLink"
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = "a.nextLink"
|
||||
|
||||
override fun searchMangaSelector() = popularMangaSelector()
|
||||
|
||||
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
||||
popularMangaFromElement(element, manga)
|
||||
}
|
||||
|
||||
// max 200 results
|
||||
override fun searchMangaNextPageSelector() = null
|
||||
|
||||
override fun mangaDetailsParse(document: Document, manga: Manga) {
|
||||
val infoElement = document.select("div.leftContent").first()
|
||||
|
||||
manga.author = infoElement.select("span.elem_author").first()?.text()
|
||||
manga.genre = infoElement.select("span.elem_genre").text().replace(" ,", ",")
|
||||
manga.description = infoElement.select("div.manga-description").text()
|
||||
manga.status = parseStatus(infoElement.html())
|
||||
manga.thumbnail_url = infoElement.select("img").attr("data-full")
|
||||
}
|
||||
|
||||
private fun parseStatus(element: String): Int {
|
||||
when {
|
||||
element.contains("<h3>Запрещена публикация произведения по копирайту</h3>") -> return Manga.LICENSED
|
||||
element.contains("<h1 class=\"names\"> Сингл") || element.contains("<b>Перевод:</b> завершен") -> return Manga.COMPLETED
|
||||
element.contains("<b>Перевод:</b> продолжается") -> return Manga.ONGOING
|
||||
else -> return Manga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "div.chapters-link tbody tr"
|
||||
|
||||
override fun chapterFromElement(element: Element, chapter: Chapter) {
|
||||
val urlElement = element.select("a").first()
|
||||
|
||||
chapter.setUrlWithoutDomain(urlElement.attr("href") + "?mature=1")
|
||||
chapter.name = urlElement.text().replace(" новое", "")
|
||||
chapter.date_upload = element.select("td:eq(1)").first()?.text()?.let {
|
||||
SimpleDateFormat("dd/MM/yy", Locale.US).parse(it).time
|
||||
} ?: 0
|
||||
}
|
||||
|
||||
override fun prepareNewChapter(chapter: Chapter, manga: Manga) {
|
||||
chapter.chapter_number = -2f
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response, pages: MutableList<Page>) {
|
||||
val html = response.body().string()
|
||||
val beginIndex = html.indexOf("rm_h.init( [")
|
||||
val endIndex = html.indexOf("], 0, false);", beginIndex)
|
||||
val trimmedHtml = html.substring(beginIndex, endIndex)
|
||||
|
||||
val p = Pattern.compile("'.+?','.+?',\".+?\"")
|
||||
val m = p.matcher(trimmedHtml)
|
||||
|
||||
var i = 0
|
||||
while (m.find()) {
|
||||
val urlParts = m.group().replace("[\"\']+".toRegex(), "").split(',')
|
||||
pages.add(Page(i++, "", urlParts[1] + urlParts[0] + urlParts[2]))
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document, pages: MutableList<Page>) { }
|
||||
|
||||
override fun imageUrlParse(document: Document) = ""
|
||||
|
||||
/* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")].map((el,i) => {
|
||||
* const onClick=el.getAttribute('onclick');const id=onClick.substr(31,onClick.length-33);
|
||||
* return `Filter("${id}", "${el.textContent.trim()}")` }).join(',\n')
|
||||
* on http://readmanga.me/search
|
||||
*/
|
||||
override fun getFilterList(): List<Filter> = listOf(
|
||||
Filter("el_5685", "арт"),
|
||||
Filter("el_2155", "боевик"),
|
||||
Filter("el_2143", "боевые искусства"),
|
||||
Filter("el_2148", "вампиры"),
|
||||
Filter("el_2142", "гарем"),
|
||||
Filter("el_2156", "гендерная интрига"),
|
||||
Filter("el_2146", "героическое фэнтези"),
|
||||
Filter("el_2152", "детектив"),
|
||||
Filter("el_2158", "дзёсэй"),
|
||||
Filter("el_2141", "додзинси"),
|
||||
Filter("el_2118", "драма"),
|
||||
Filter("el_2154", "игра"),
|
||||
Filter("el_2119", "история"),
|
||||
Filter("el_8032", "киберпанк"),
|
||||
Filter("el_2137", "кодомо"),
|
||||
Filter("el_2136", "комедия"),
|
||||
Filter("el_2147", "махо-сёдзё"),
|
||||
Filter("el_2126", "меха"),
|
||||
Filter("el_2132", "мистика"),
|
||||
Filter("el_2133", "научная фантастика"),
|
||||
Filter("el_2135", "повседневность"),
|
||||
Filter("el_2151", "постапокалиптика"),
|
||||
Filter("el_2130", "приключения"),
|
||||
Filter("el_2144", "психология"),
|
||||
Filter("el_2121", "романтика"),
|
||||
Filter("el_2124", "самурайский боевик"),
|
||||
Filter("el_2159", "сверхъестественное"),
|
||||
Filter("el_2122", "сёдзё"),
|
||||
Filter("el_2128", "сёдзё-ай"),
|
||||
Filter("el_2134", "сёнэн"),
|
||||
Filter("el_2139", "сёнэн-ай"),
|
||||
Filter("el_2129", "спорт"),
|
||||
Filter("el_2138", "сэйнэн"),
|
||||
Filter("el_2153", "трагедия"),
|
||||
Filter("el_2150", "триллер"),
|
||||
Filter("el_2125", "ужасы"),
|
||||
Filter("el_2140", "фантастика"),
|
||||
Filter("el_2131", "фэнтези"),
|
||||
Filter("el_2127", "школа"),
|
||||
Filter("el_2149", "этти"),
|
||||
Filter("el_2123", "юри")
|
||||
)
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
package eu.kanade.tachiyomi.data.track
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.data.track.anilist.Anilist
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
|
||||
import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist
|
||||
|
||||
class TrackManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
const val MYANIMELIST = 1
|
||||
const val ANILIST = 2
|
||||
const val KITSU = 3
|
||||
}
|
||||
|
||||
val myAnimeList = Myanimelist(context, MYANIMELIST)
|
||||
|
||||
val aniList = Anilist(context, ANILIST)
|
||||
|
||||
val kitsu = Kitsu(context, KITSU)
|
||||
|
||||
val services = listOf(myAnimeList, aniList, kitsu)
|
||||
|
||||
fun getService(id: Int) = services.find { it.id == id }
|
||||
|
||||
fun hasLoggedServices() = services.any { it.isLogged }
|
||||
|
||||
}
|
@ -0,0 +1,70 @@
|
||||
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.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import okhttp3.OkHttpClient
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
abstract class TrackService(val id: Int) {
|
||||
|
||||
val preferences: PreferencesHelper by injectLazy()
|
||||
val networkService: NetworkHelper by injectLazy()
|
||||
|
||||
open val client: OkHttpClient
|
||||
get() = networkService.client
|
||||
|
||||
// Name of the manga sync service to display
|
||||
abstract val name: String
|
||||
|
||||
@DrawableRes
|
||||
abstract fun getLogo(): Int
|
||||
|
||||
abstract fun getLogoColor(): Int
|
||||
|
||||
abstract fun getStatusList(): List<Int>
|
||||
|
||||
abstract fun getStatus(status: Int): String
|
||||
|
||||
abstract fun getScoreList(): List<String>
|
||||
|
||||
open fun indexToScore(index: Int): Float {
|
||||
return index.toFloat()
|
||||
}
|
||||
|
||||
abstract fun displayScore(track: Track): String
|
||||
|
||||
abstract fun add(track: Track): Observable<Track>
|
||||
|
||||
abstract fun update(track: Track): Observable<Track>
|
||||
|
||||
abstract fun bind(track: Track): Observable<Track>
|
||||
|
||||
abstract fun search(query: String): Observable<List<Track>>
|
||||
|
||||
abstract fun refresh(track: Track): Observable<Track>
|
||||
|
||||
abstract fun login(username: String, password: String): Completable
|
||||
|
||||
@CallSuper
|
||||
open fun logout() {
|
||||
preferences.setTrackCredentials(this, "", "")
|
||||
}
|
||||
|
||||
open val isLogged: Boolean
|
||||
get() = !getUsername().isEmpty() &&
|
||||
!getPassword().isEmpty()
|
||||
|
||||
fun getUsername() = preferences.trackUsername(this)
|
||||
|
||||
fun getPassword() = preferences.trackPassword(this)
|
||||
|
||||
fun saveCredentials(username: String, password: String) {
|
||||
preferences.setTrackCredentials(this, username, password)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,160 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist
|
||||
|
||||
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.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
|
||||
class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
||||
|
||||
companion object {
|
||||
const val READING = 1
|
||||
const val COMPLETED = 2
|
||||
const val ON_HOLD = 3
|
||||
const val DROPPED = 4
|
||||
const val PLAN_TO_READ = 5
|
||||
|
||||
const val DEFAULT_STATUS = READING
|
||||
const val DEFAULT_SCORE = 0
|
||||
}
|
||||
|
||||
override val name = "AniList"
|
||||
|
||||
private val interceptor by lazy { AnilistInterceptor(getPassword()) }
|
||||
|
||||
private val api by lazy { AnilistApi(client, interceptor) }
|
||||
|
||||
override fun getLogo() = R.drawable.al
|
||||
|
||||
override fun getLogoColor() = Color.rgb(18, 25, 35)
|
||||
|
||||
override fun getStatusList(): List<Int> {
|
||||
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
|
||||
}
|
||||
|
||||
override fun getStatus(status: Int): String = with(context) {
|
||||
when (status) {
|
||||
READING -> getString(R.string.reading)
|
||||
COMPLETED -> getString(R.string.completed)
|
||||
ON_HOLD -> getString(R.string.on_hold)
|
||||
DROPPED -> getString(R.string.dropped)
|
||||
PLAN_TO_READ -> getString(R.string.plan_to_read)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
override fun getScoreList(): List<String> {
|
||||
return when (preferences.anilistScoreType().getOrDefault()) {
|
||||
// 10 point
|
||||
0 -> IntRange(0, 10).map(Int::toString)
|
||||
// 100 point
|
||||
1 -> IntRange(0, 100).map(Int::toString)
|
||||
// 5 stars
|
||||
2 -> IntRange(0, 5).map { "$it ★" }
|
||||
// Smiley
|
||||
3 -> listOf("-", "😦", "😐", "😊")
|
||||
// 10 point decimal
|
||||
4 -> IntRange(0, 100).map { (it / 10f).toString() }
|
||||
else -> throw Exception("Unknown score type")
|
||||
}
|
||||
}
|
||||
|
||||
override fun indexToScore(index: Int): Float {
|
||||
return when (preferences.anilistScoreType().getOrDefault()) {
|
||||
// 10 point
|
||||
0 -> index * 10f
|
||||
// 100 point
|
||||
1 -> index.toFloat()
|
||||
// 5 stars
|
||||
2 -> index * 20f
|
||||
// Smiley
|
||||
3 -> index * 30f
|
||||
// 10 point decimal
|
||||
4 -> index.toFloat()
|
||||
else -> throw Exception("Unknown score type")
|
||||
}
|
||||
}
|
||||
|
||||
override fun displayScore(track: Track): String {
|
||||
val score = track.score
|
||||
return when (preferences.anilistScoreType().getOrDefault()) {
|
||||
2 -> "${(score / 20).toInt()} ★"
|
||||
3 -> when {
|
||||
score == 0f -> "0"
|
||||
score <= 30 -> "😦"
|
||||
score <= 60 -> "😐"
|
||||
else -> "😊"
|
||||
}
|
||||
else -> track.toAnilistScore()
|
||||
}
|
||||
}
|
||||
|
||||
override fun add(track: Track): Observable<Track> {
|
||||
return api.addLibManga(track)
|
||||
}
|
||||
|
||||
override fun update(track: Track): Observable<Track> {
|
||||
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
|
||||
track.status = COMPLETED
|
||||
}
|
||||
|
||||
return api.updateLibManga(track)
|
||||
}
|
||||
|
||||
override fun bind(track: Track): Observable<Track> {
|
||||
return api.findLibManga(track, getUsername())
|
||||
.flatMap { remoteTrack ->
|
||||
if (remoteTrack != null) {
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
update(track)
|
||||
} else {
|
||||
// Set default fields if it's not found in the list
|
||||
track.score = DEFAULT_SCORE.toFloat()
|
||||
track.status = DEFAULT_STATUS
|
||||
add(track)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun search(query: String): Observable<List<Track>> {
|
||||
return api.search(query)
|
||||
}
|
||||
|
||||
override fun refresh(track: Track): Observable<Track> {
|
||||
return api.getLibManga(track, getUsername())
|
||||
.map { remoteTrack ->
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.total_chapters = remoteTrack.total_chapters
|
||||
track
|
||||
}
|
||||
}
|
||||
|
||||
override fun login(username: String, password: String) = login(password)
|
||||
|
||||
fun login(authCode: String): Completable {
|
||||
return api.login(authCode)
|
||||
// Save the token in the interceptor.
|
||||
.doOnNext { interceptor.setAuth(it) }
|
||||
// Obtain the authenticated user from the API.
|
||||
.zipWith(api.getCurrentUser().map { pair ->
|
||||
preferences.anilistScoreType().set(pair.second)
|
||||
pair.first
|
||||
}, { oauth, user -> Pair(user, oauth.refresh_token!!) })
|
||||
// Save service credentials (username and refresh token).
|
||||
.doOnNext { saveCredentials(it.first, it.second) }
|
||||
// Logout on any error.
|
||||
.doOnError { logout() }
|
||||
.toCompletable()
|
||||
}
|
||||
|
||||
override fun logout() {
|
||||
super.logout()
|
||||
interceptor.setAuth(null)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -0,0 +1,161 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist
|
||||
|
||||
import android.net.Uri
|
||||
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.network.POST
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import retrofit2.http.*
|
||||
import rx.Observable
|
||||
|
||||
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
|
||||
private val rest = restBuilder()
|
||||
.client(client.newBuilder().addInterceptor(interceptor).build())
|
||||
.build()
|
||||
.create(Rest::class.java)
|
||||
|
||||
fun addLibManga(track: Track): Observable<Track> {
|
||||
return rest.addLibManga(track.remote_id, track.last_chapter_read, track.toAnilistStatus())
|
||||
.map { response ->
|
||||
response.body().close()
|
||||
if (!response.isSuccessful) {
|
||||
throw Exception("Could not add manga")
|
||||
}
|
||||
track
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLibManga(track: Track): Observable<Track> {
|
||||
return rest.updateLibManga(track.remote_id, track.last_chapter_read, track.toAnilistStatus(),
|
||||
track.toAnilistScore())
|
||||
.map { response ->
|
||||
response.body().close()
|
||||
if (!response.isSuccessful) {
|
||||
throw Exception("Could not update manga")
|
||||
}
|
||||
track
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String): Observable<List<Track>> {
|
||||
return rest.search(query, 1)
|
||||
.map { list ->
|
||||
list.filter { it.type != "Novel" }.map { it.toTrack() }
|
||||
}
|
||||
.onErrorReturn { emptyList() }
|
||||
}
|
||||
|
||||
fun getList(username: String): Observable<List<Track>> {
|
||||
return rest.getLib(username)
|
||||
.map { lib ->
|
||||
lib.flatten().map { it.toTrack() }
|
||||
}
|
||||
}
|
||||
|
||||
fun findLibManga(track: Track, username: String) : Observable<Track?> {
|
||||
// TODO avoid getting the entire list
|
||||
return getList(username)
|
||||
.map { list -> list.find { it.remote_id == track.remote_id } }
|
||||
}
|
||||
|
||||
fun getLibManga(track: Track, username: String): Observable<Track> {
|
||||
return findLibManga(track, username)
|
||||
.map { it ?: throw Exception("Could not find manga") }
|
||||
}
|
||||
|
||||
fun login(authCode: String): Observable<OAuth> {
|
||||
return restBuilder()
|
||||
.client(client)
|
||||
.build()
|
||||
.create(Rest::class.java)
|
||||
.requestAccessToken(authCode)
|
||||
}
|
||||
|
||||
fun getCurrentUser(): Observable<Pair<String, Int>> {
|
||||
return rest.getCurrentUser()
|
||||
.map { it["id"].string to it["score_type"].int }
|
||||
}
|
||||
|
||||
private fun restBuilder() = Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||
|
||||
private interface Rest {
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("auth/access_token")
|
||||
fun requestAccessToken(
|
||||
@Field("code") code: String,
|
||||
@Field("grant_type") grant_type: String = "authorization_code",
|
||||
@Field("client_id") client_id: String = clientId,
|
||||
@Field("client_secret") client_secret: String = clientSecret,
|
||||
@Field("redirect_uri") redirect_uri: String = clientUrl
|
||||
) : Observable<OAuth>
|
||||
|
||||
@GET("user")
|
||||
fun getCurrentUser(): Observable<JsonObject>
|
||||
|
||||
@GET("manga/search/{query}")
|
||||
fun search(
|
||||
@Path("query") query: String,
|
||||
@Query("page") page: Int
|
||||
): Observable<List<ALManga>>
|
||||
|
||||
@GET("user/{username}/mangalist")
|
||||
fun getLib(
|
||||
@Path("username") username: String
|
||||
): Observable<ALUserLists>
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT("mangalist")
|
||||
fun addLibManga(
|
||||
@Field("id") id: Int,
|
||||
@Field("chapters_read") chapters_read: Int,
|
||||
@Field("list_status") list_status: String
|
||||
) : Observable<Response<ResponseBody>>
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT("mangalist")
|
||||
fun updateLibManga(
|
||||
@Field("id") id: Int,
|
||||
@Field("chapters_read") chapters_read: Int,
|
||||
@Field("list_status") list_status: String,
|
||||
@Field("score") score_raw: String
|
||||
) : Observable<Response<ResponseBody>>
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val clientId = "tachiyomi-hrtje"
|
||||
private const val clientSecret = "nlGB5OmgE9YWq5dr3gIDbTQV0C"
|
||||
private const val clientUrl = "tachiyomi://anilist-auth"
|
||||
private const val baseUrl = "https://anilist.co/api/"
|
||||
|
||||
fun authUrl() = Uri.parse("${baseUrl}auth/authorize").buildUpon()
|
||||
.appendQueryParameter("grant_type", "authorization_code")
|
||||
.appendQueryParameter("client_id", clientId)
|
||||
.appendQueryParameter("redirect_uri", clientUrl)
|
||||
.appendQueryParameter("response_type", "code")
|
||||
.build()
|
||||
|
||||
fun refreshTokenRequest(token: String) = POST("${baseUrl}auth/access_token",
|
||||
body = FormBody.Builder()
|
||||
.add("grant_type", "refresh_token")
|
||||
.add("client_id", clientId)
|
||||
.add("client_secret", clientSecret)
|
||||
.add("refresh_token", token)
|
||||
.build())
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -1,61 +1,60 @@
|
||||
package eu.kanade.tachiyomi.data.mangasync.anilist
|
||||
|
||||
import com.google.gson.Gson
|
||||
import eu.kanade.tachiyomi.data.mangasync.anilist.model.OAuth
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class AnilistInterceptor(private var refreshToken: String?) : Interceptor {
|
||||
|
||||
/**
|
||||
* OAuth object used for authenticated requests.
|
||||
*
|
||||
* Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute
|
||||
* before its original expiration date.
|
||||
*/
|
||||
private var oauth: OAuth? = null
|
||||
set(value) {
|
||||
field = value?.copy(expires = value.expires * 1000 - 60 * 1000)
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
if (refreshToken.isNullOrEmpty()) {
|
||||
throw Exception("Not authenticated with Anilist")
|
||||
}
|
||||
|
||||
// Refresh access token if null or expired.
|
||||
if (oauth == null || oauth!!.isExpired()) {
|
||||
val response = chain.proceed(AnilistApi.refreshTokenRequest(refreshToken!!))
|
||||
oauth = if (response.isSuccessful) {
|
||||
Gson().fromJson(response.body().string(), OAuth::class.java)
|
||||
} else {
|
||||
response.close()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Throw on null auth.
|
||||
if (oauth == null) {
|
||||
throw Exception("Access token wasn't refreshed")
|
||||
}
|
||||
|
||||
// Add the authorization header to the original request.
|
||||
val authRequest = originalRequest.newBuilder()
|
||||
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
|
||||
.build()
|
||||
|
||||
return chain.proceed(authRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the user authenticates with Anilist for the first time. Sets the refresh token
|
||||
* and the oauth object.
|
||||
*/
|
||||
fun setAuth(oauth: OAuth?) {
|
||||
refreshToken = oauth?.refresh_token
|
||||
this.oauth = oauth
|
||||
}
|
||||
|
||||
package eu.kanade.tachiyomi.data.track.anilist
|
||||
|
||||
import com.google.gson.Gson
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class AnilistInterceptor(private var refreshToken: String?) : Interceptor {
|
||||
|
||||
/**
|
||||
* OAuth object used for authenticated requests.
|
||||
*
|
||||
* Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute
|
||||
* before its original expiration date.
|
||||
*/
|
||||
private var oauth: OAuth? = null
|
||||
set(value) {
|
||||
field = value?.copy(expires = value.expires * 1000 - 60 * 1000)
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
if (refreshToken.isNullOrEmpty()) {
|
||||
throw Exception("Not authenticated with Anilist")
|
||||
}
|
||||
|
||||
// Refresh access token if null or expired.
|
||||
if (oauth == null || oauth!!.isExpired()) {
|
||||
val response = chain.proceed(AnilistApi.refreshTokenRequest(refreshToken!!))
|
||||
oauth = if (response.isSuccessful) {
|
||||
Gson().fromJson(response.body().string(), OAuth::class.java)
|
||||
} else {
|
||||
response.close()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Throw on null auth.
|
||||
if (oauth == null) {
|
||||
throw Exception("Access token wasn't refreshed")
|
||||
}
|
||||
|
||||
// Add the authorization header to the original request.
|
||||
val authRequest = originalRequest.newBuilder()
|
||||
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
|
||||
.build()
|
||||
|
||||
return chain.proceed(authRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the user authenticates with Anilist for the first time. Sets the refresh token
|
||||
* and the oauth object.
|
||||
*/
|
||||
fun setAuth(oauth: OAuth?) {
|
||||
refreshToken = oauth?.refresh_token
|
||||
this.oauth = oauth
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,86 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist
|
||||
|
||||
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 uy.kohesive.injekt.injectLazy
|
||||
|
||||
data class ALManga(
|
||||
val id: Int,
|
||||
val title_romaji: String,
|
||||
val type: String,
|
||||
val total_chapters: Int) {
|
||||
|
||||
fun toTrack() = Track.create(TrackManager.ANILIST).apply {
|
||||
remote_id = this@ALManga.id
|
||||
title = title_romaji
|
||||
total_chapters = this@ALManga.total_chapters
|
||||
}
|
||||
}
|
||||
|
||||
data class ALUserManga(
|
||||
val id: Int,
|
||||
val list_status: String,
|
||||
val score_raw: Int,
|
||||
val chapters_read: Int,
|
||||
val manga: ALManga) {
|
||||
|
||||
fun toTrack() = Track.create(TrackManager.ANILIST).apply {
|
||||
remote_id = manga.id
|
||||
status = toTrackStatus()
|
||||
score = score_raw.toFloat()
|
||||
last_chapter_read = chapters_read
|
||||
}
|
||||
|
||||
fun toTrackStatus() = when (list_status) {
|
||||
"reading" -> Anilist.READING
|
||||
"completed" -> Anilist.COMPLETED
|
||||
"on-hold" -> Anilist.ON_HOLD
|
||||
"dropped" -> Anilist.DROPPED
|
||||
"plan to read" -> Anilist.PLAN_TO_READ
|
||||
else -> throw NotImplementedError("Unknown status")
|
||||
}
|
||||
}
|
||||
|
||||
data class ALUserLists(val lists: Map<String, List<ALUserManga>>) {
|
||||
|
||||
fun flatten() = lists.values.flatten()
|
||||
}
|
||||
|
||||
fun Track.toAnilistStatus() = when (status) {
|
||||
Anilist.READING -> "reading"
|
||||
Anilist.COMPLETED -> "completed"
|
||||
Anilist.ON_HOLD -> "on-hold"
|
||||
Anilist.DROPPED -> "dropped"
|
||||
Anilist.PLAN_TO_READ -> "plan to read"
|
||||
else -> throw NotImplementedError("Unknown status")
|
||||
}
|
||||
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().getOrDefault()) {
|
||||
// 10 point
|
||||
0 -> (score.toInt() / 10).toString()
|
||||
// 100 point
|
||||
1 -> score.toInt().toString()
|
||||
// 5 stars
|
||||
2 -> when {
|
||||
score == 0f -> "0"
|
||||
score < 30 -> "1"
|
||||
score < 50 -> "2"
|
||||
score < 70 -> "3"
|
||||
score < 90 -> "4"
|
||||
else -> "5"
|
||||
}
|
||||
// Smiley
|
||||
3 -> when {
|
||||
score == 0f -> "0"
|
||||
score <= 30 -> ":("
|
||||
score <= 60 -> ":|"
|
||||
else -> ":)"
|
||||
}
|
||||
// 10 point decimal
|
||||
4 -> (score / 10).toString()
|
||||
else -> throw Exception("Unknown score type")
|
||||
}
|
@ -1,11 +1,11 @@
|
||||
package eu.kanade.tachiyomi.data.mangasync.anilist.model
|
||||
|
||||
data class OAuth(
|
||||
val access_token: String,
|
||||
val token_type: String,
|
||||
val expires: Long,
|
||||
val expires_in: Long,
|
||||
val refresh_token: String?) {
|
||||
|
||||
fun isExpired() = System.currentTimeMillis() > expires
|
||||
package eu.kanade.tachiyomi.data.track.anilist
|
||||
|
||||
data class OAuth(
|
||||
val access_token: String,
|
||||
val token_type: String,
|
||||
val expires: Long,
|
||||
val expires_in: Long,
|
||||
val refresh_token: String?) {
|
||||
|
||||
fun isExpired() = System.currentTimeMillis() > expires
|
||||
}
|
136
app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt
Normal file
136
app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt
Normal file
@ -0,0 +1,136 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
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 rx.Completable
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class Kitsu(private val context: Context, id: Int) : TrackService(id) {
|
||||
|
||||
companion object {
|
||||
const val READING = 1
|
||||
const val COMPLETED = 2
|
||||
const val ON_HOLD = 3
|
||||
const val DROPPED = 4
|
||||
const val PLAN_TO_READ = 5
|
||||
|
||||
const val DEFAULT_STATUS = READING
|
||||
const val DEFAULT_SCORE = 0f
|
||||
}
|
||||
|
||||
override val name = "Kitsu"
|
||||
|
||||
private val gson: Gson by injectLazy()
|
||||
|
||||
private val interceptor by lazy { KitsuInterceptor(this, gson) }
|
||||
|
||||
private val api by lazy { KitsuApi(client, interceptor) }
|
||||
|
||||
override fun getLogo(): Int {
|
||||
return R.drawable.kitsu
|
||||
}
|
||||
|
||||
override fun getLogoColor(): Int {
|
||||
return Color.rgb(51, 37, 50)
|
||||
}
|
||||
|
||||
override fun getStatusList(): List<Int> {
|
||||
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
|
||||
}
|
||||
|
||||
override fun getStatus(status: Int): String = with(context) {
|
||||
when (status) {
|
||||
READING -> getString(R.string.reading)
|
||||
COMPLETED -> getString(R.string.completed)
|
||||
ON_HOLD -> getString(R.string.on_hold)
|
||||
DROPPED -> getString(R.string.dropped)
|
||||
PLAN_TO_READ -> getString(R.string.plan_to_read)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
override fun getScoreList(): List<String> {
|
||||
return IntRange(0, 10).map { (it.toFloat() / 2).toString() }
|
||||
}
|
||||
|
||||
override fun displayScore(track: Track): String {
|
||||
return track.toKitsuScore()
|
||||
}
|
||||
|
||||
override fun add(track: Track): Observable<Track> {
|
||||
return api.addLibManga(track, getUserId())
|
||||
}
|
||||
|
||||
override fun update(track: Track): Observable<Track> {
|
||||
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
|
||||
track.status = COMPLETED
|
||||
}
|
||||
|
||||
return api.updateLibManga(track)
|
||||
}
|
||||
|
||||
override fun bind(track: Track): Observable<Track> {
|
||||
return api.findLibManga(track, getUserId())
|
||||
.flatMap { remoteTrack ->
|
||||
if (remoteTrack != null) {
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.remote_id = remoteTrack.remote_id
|
||||
update(track)
|
||||
} else {
|
||||
track.score = DEFAULT_SCORE
|
||||
track.status = DEFAULT_STATUS
|
||||
add(track)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun search(query: String): Observable<List<Track>> {
|
||||
return api.search(query)
|
||||
}
|
||||
|
||||
override fun refresh(track: Track): Observable<Track> {
|
||||
return api.getLibManga(track)
|
||||
.map { remoteTrack ->
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.total_chapters = remoteTrack.total_chapters
|
||||
track
|
||||
}
|
||||
}
|
||||
|
||||
override fun login(username: String, password: String): Completable {
|
||||
return api.login(username, password)
|
||||
.doOnNext { interceptor.newAuth(it) }
|
||||
.flatMap { api.getCurrentUser() }
|
||||
.doOnNext { userId -> saveCredentials(username, userId) }
|
||||
.doOnError { logout() }
|
||||
.toCompletable()
|
||||
}
|
||||
|
||||
override fun logout() {
|
||||
super.logout()
|
||||
interceptor.newAuth(null)
|
||||
}
|
||||
|
||||
private fun getUserId(): String {
|
||||
return getPassword()
|
||||
}
|
||||
|
||||
fun saveToken(oauth: OAuth?) {
|
||||
val json = gson.toJson(oauth)
|
||||
preferences.trackToken(this).set(json)
|
||||
}
|
||||
|
||||
fun restoreToken(): OAuth? {
|
||||
return try {
|
||||
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,201 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu
|
||||
|
||||
import com.github.salomonbrys.kotson.*
|
||||
import com.google.gson.JsonObject
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import retrofit2.http.*
|
||||
import rx.Observable
|
||||
|
||||
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
|
||||
|
||||
private val rest = Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(client.newBuilder().addInterceptor(interceptor).build())
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||
.build()
|
||||
.create(KitsuApi.Rest::class.java)
|
||||
|
||||
fun addLibManga(track: Track, userId: String): Observable<Track> {
|
||||
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"
|
||||
)
|
||||
),
|
||||
"media" to jsonObject(
|
||||
"data" to jsonObject(
|
||||
"id" to track.remote_id,
|
||||
"type" to "manga"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
// @formatter:on
|
||||
|
||||
rest.addLibManga(jsonObject("data" to data))
|
||||
.map { json ->
|
||||
track.remote_id = json["data"]["id"].int
|
||||
track
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLibManga(track: Track): Observable<Track> {
|
||||
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,
|
||||
"rating" to track.toKitsuScore()
|
||||
)
|
||||
)
|
||||
// @formatter:on
|
||||
|
||||
rest.updateLibManga(track.remote_id, jsonObject("data" to data))
|
||||
.map { track }
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String): Observable<List<Track>> {
|
||||
return rest.search(query)
|
||||
.map { json ->
|
||||
val data = json["data"].array
|
||||
data.map { KitsuManga(it.obj) }
|
||||
.filter { it.type != "novel" }
|
||||
.map { it.toTrack() }
|
||||
}
|
||||
}
|
||||
|
||||
fun findLibManga(track: Track, userId: String): Observable<Track?> {
|
||||
return rest.findLibManga(track.remote_id, userId)
|
||||
.map { json ->
|
||||
val data = json["data"].array
|
||||
if (data.size() > 0) {
|
||||
val manga = json["included"].array[0].obj
|
||||
KitsuLibManga(data[0].obj, manga).toTrack()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getLibManga(track: Track): Observable<Track> {
|
||||
return rest.getLibManga(track.remote_id)
|
||||
.map { json ->
|
||||
val data = json["data"].array
|
||||
if (data.size() > 0) {
|
||||
val manga = json["included"].array[0].obj
|
||||
KitsuLibManga(data[0].obj, manga).toTrack()
|
||||
} else {
|
||||
throw Exception("Could not find manga")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun login(username: String, password: String): Observable<OAuth> {
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(loginUrl)
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||
.build()
|
||||
.create(KitsuApi.LoginRest::class.java)
|
||||
.requestAccessToken(username, password)
|
||||
}
|
||||
|
||||
fun getCurrentUser(): Observable<String> {
|
||||
return rest.getCurrentUser().map { it["data"].array[0]["id"].string }
|
||||
}
|
||||
|
||||
private interface Rest {
|
||||
|
||||
@Headers("Content-Type: application/vnd.api+json")
|
||||
@POST("library-entries")
|
||||
fun addLibManga(
|
||||
@Body data: JsonObject
|
||||
): Observable<JsonObject>
|
||||
|
||||
@Headers("Content-Type: application/vnd.api+json")
|
||||
@PATCH("library-entries/{id}")
|
||||
fun updateLibManga(
|
||||
@Path("id") remoteId: Int,
|
||||
@Body data: JsonObject
|
||||
): Observable<JsonObject>
|
||||
|
||||
@GET("manga")
|
||||
fun search(
|
||||
@Query("filter[text]", encoded = true) query: String
|
||||
): Observable<JsonObject>
|
||||
|
||||
@GET("library-entries")
|
||||
fun findLibManga(
|
||||
@Query("filter[media_id]", encoded = true) remoteId: Int,
|
||||
@Query("filter[user_id]", encoded = true) userId: String,
|
||||
@Query("page[limit]", encoded = true) limit: Int = 10000,
|
||||
@Query("include") includes: String = "manga"
|
||||
): Observable<JsonObject>
|
||||
|
||||
@GET("library-entries")
|
||||
fun getLibManga(
|
||||
@Query("filter[id]", encoded = true) remoteId: Int,
|
||||
@Query("include") includes: String = "manga"
|
||||
): Observable<JsonObject>
|
||||
|
||||
@GET("users")
|
||||
fun getCurrentUser(
|
||||
@Query("filter[self]", encoded = true) self: Boolean = true
|
||||
): Observable<JsonObject>
|
||||
|
||||
}
|
||||
|
||||
private interface LoginRest {
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("oauth/token")
|
||||
fun requestAccessToken(
|
||||
@Field("username") username: String,
|
||||
@Field("password") password: String,
|
||||
@Field("grant_type") grantType: String = "password",
|
||||
@Field("client_id") client_id: String = clientId,
|
||||
@Field("client_secret") client_secret: String = clientSecret
|
||||
): Observable<OAuth>
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val clientId = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
|
||||
private const val clientSecret = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
|
||||
private const val baseUrl = "https://kitsu.io/api/edge/"
|
||||
private const val loginUrl = "https://kitsu.io/api/"
|
||||
|
||||
|
||||
fun refreshTokenRequest(token: String) = POST("${loginUrl}oauth/token",
|
||||
body = FormBody.Builder()
|
||||
.add("grant_type", "refresh_token")
|
||||
.add("client_id", clientId)
|
||||
.add("client_secret", clientSecret)
|
||||
.add("refresh_token", token)
|
||||
.build())
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu
|
||||
|
||||
import com.google.gson.Gson
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class KitsuInterceptor(val kitsu: Kitsu, val gson: Gson) : Interceptor {
|
||||
|
||||
/**
|
||||
* OAuth object used for authenticated requests.
|
||||
*/
|
||||
private var oauth: OAuth? = kitsu.restoreToken()
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
val currAuth = oauth ?: throw Exception("Not authenticated with Kitsu")
|
||||
|
||||
val refreshToken = currAuth.refresh_token!!
|
||||
|
||||
// Refresh access token if expired.
|
||||
if (currAuth.isExpired()) {
|
||||
val response = chain.proceed(KitsuApi.refreshTokenRequest(refreshToken))
|
||||
if (response.isSuccessful) {
|
||||
newAuth(gson.fromJson(response.body().string(), OAuth::class.java))
|
||||
} else {
|
||||
response.close()
|
||||
}
|
||||
}
|
||||
|
||||
// Add the authorization header to the original request.
|
||||
val authRequest = originalRequest.newBuilder()
|
||||
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
|
||||
.header("Accept", "application/vnd.api+json")
|
||||
.header("Content-Type", "application/vnd.api+json")
|
||||
.build()
|
||||
|
||||
return chain.proceed(authRequest)
|
||||
}
|
||||
|
||||
fun newAuth(oauth: OAuth?) {
|
||||
this.oauth = oauth
|
||||
kitsu.saveToken(oauth)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu
|
||||
|
||||
import android.support.annotation.CallSuper
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@CallSuper
|
||||
open fun toTrack() = Track.create(TrackManager.KITSU).apply {
|
||||
remote_id = this@KitsuManga.id
|
||||
title = canonicalTitle
|
||||
total_chapters = chapterCount ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
class KitsuLibManga(obj: JsonObject, manga: JsonObject) : KitsuManga(manga) {
|
||||
val remoteId by obj.byInt("id")
|
||||
val status by obj["attributes"].byString
|
||||
val rating = obj["attributes"].obj.get("rating").nullString
|
||||
val progress by obj["attributes"].byInt
|
||||
|
||||
override fun toTrack() = super.toTrack().apply {
|
||||
remote_id = remoteId
|
||||
status = toTrackStatus()
|
||||
score = rating?.let { it.toFloat() * 2 } ?: 0f
|
||||
last_chapter_read = progress
|
||||
}
|
||||
|
||||
private fun toTrackStatus() = when (status) {
|
||||
"current" -> Kitsu.READING
|
||||
"completed" -> Kitsu.COMPLETED
|
||||
"on_hold" -> Kitsu.ON_HOLD
|
||||
"dropped" -> Kitsu.DROPPED
|
||||
"planned" -> Kitsu.PLAN_TO_READ
|
||||
else -> throw Exception("Unknown status")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun Track.toKitsuStatus() = when (status) {
|
||||
Kitsu.READING -> "current"
|
||||
Kitsu.COMPLETED -> "completed"
|
||||
Kitsu.ON_HOLD -> "on_hold"
|
||||
Kitsu.DROPPED -> "dropped"
|
||||
Kitsu.PLAN_TO_READ -> "planned"
|
||||
else -> throw Exception("Unknown status")
|
||||
}
|
||||
|
||||
fun Track.toKitsuScore(): String {
|
||||
return if (score > 0) (score / 2).toString() else ""
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu
|
||||
|
||||
data class OAuth(
|
||||
val access_token: String,
|
||||
val token_type: String,
|
||||
val created_at: Long,
|
||||
val expires_in: Long,
|
||||
val refresh_token: String?) {
|
||||
|
||||
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
|
||||
}
|
@ -0,0 +1,104 @@
|
||||
package eu.kanade.tachiyomi.data.track.myanimelist
|
||||
|
||||
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.TrackService
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
|
||||
class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
|
||||
|
||||
companion object {
|
||||
|
||||
const val READING = 1
|
||||
const val COMPLETED = 2
|
||||
const val ON_HOLD = 3
|
||||
const val DROPPED = 4
|
||||
const val PLAN_TO_READ = 6
|
||||
|
||||
const val DEFAULT_STATUS = READING
|
||||
const val DEFAULT_SCORE = 0
|
||||
}
|
||||
|
||||
private val api by lazy { MyanimelistApi(client, getUsername(), getPassword()) }
|
||||
|
||||
override val name: String
|
||||
get() = "MyAnimeList"
|
||||
|
||||
override fun getLogo() = R.drawable.mal
|
||||
|
||||
override fun getLogoColor() = Color.rgb(46, 81, 162)
|
||||
|
||||
override fun getStatus(status: Int): String = with(context) {
|
||||
when (status) {
|
||||
READING -> getString(R.string.reading)
|
||||
COMPLETED -> getString(R.string.completed)
|
||||
ON_HOLD -> getString(R.string.on_hold)
|
||||
DROPPED -> getString(R.string.dropped)
|
||||
PLAN_TO_READ -> getString(R.string.plan_to_read)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
override fun getStatusList(): List<Int> {
|
||||
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
|
||||
}
|
||||
|
||||
override fun getScoreList(): List<String> {
|
||||
return IntRange(0, 10).map(Int::toString)
|
||||
}
|
||||
|
||||
override fun displayScore(track: Track): String {
|
||||
return track.score.toInt().toString()
|
||||
}
|
||||
|
||||
override fun add(track: Track): Observable<Track> {
|
||||
return api.addLibManga(track)
|
||||
}
|
||||
|
||||
override fun update(track: Track): Observable<Track> {
|
||||
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
|
||||
track.status = COMPLETED
|
||||
}
|
||||
|
||||
return api.updateLibManga(track)
|
||||
}
|
||||
|
||||
override fun bind(track: Track): Observable<Track> {
|
||||
return api.findLibManga(track, getUsername())
|
||||
.flatMap { remoteTrack ->
|
||||
if (remoteTrack != null) {
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
update(track)
|
||||
} else {
|
||||
// Set default fields if it's not found in the list
|
||||
track.score = DEFAULT_SCORE.toFloat()
|
||||
track.status = DEFAULT_STATUS
|
||||
add(track)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun search(query: String): Observable<List<Track>> {
|
||||
return api.search(query, getUsername())
|
||||
}
|
||||
|
||||
override fun refresh(track: Track): Observable<Track> {
|
||||
return api.getLibManga(track, getUsername())
|
||||
.map { remoteTrack ->
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.total_chapters = remoteTrack.total_chapters
|
||||
track
|
||||
}
|
||||
}
|
||||
|
||||
override fun login(username: String, password: String): Completable {
|
||||
return api.login(username, password)
|
||||
.doOnNext { saveCredentials(username, password) }
|
||||
.doOnError { logout() }
|
||||
.toCompletable()
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,187 @@
|
||||
package eu.kanade.tachiyomi.data.track.myanimelist
|
||||
|
||||
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.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.asObservable
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.util.selectInt
|
||||
import eu.kanade.tachiyomi.util.selectText
|
||||
import okhttp3.*
|
||||
import org.jsoup.Jsoup
|
||||
import org.xmlpull.v1.XmlSerializer
|
||||
import rx.Observable
|
||||
import java.io.StringWriter
|
||||
|
||||
class MyanimelistApi(private val client: OkHttpClient, username: String, password: String) {
|
||||
|
||||
private var headers = createHeaders(username, password)
|
||||
|
||||
fun addLibManga(track: Track): Observable<Track> {
|
||||
return Observable.defer {
|
||||
client.newCall(POST(getAddUrl(track), headers, getMangaPostPayload(track)))
|
||||
.asObservableSuccess()
|
||||
.map { track }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLibManga(track: Track): Observable<Track> {
|
||||
return Observable.defer {
|
||||
client.newCall(POST(getUpdateUrl(track), headers, getMangaPostPayload(track)))
|
||||
.asObservableSuccess()
|
||||
.map { track }
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String, username: String): Observable<List<Track>> {
|
||||
return if (query.startsWith(PREFIX_MY)) {
|
||||
val realQuery = query.substring(PREFIX_MY.length).toLowerCase().trim()
|
||||
getList(username)
|
||||
.flatMap { Observable.from(it) }
|
||||
.filter { realQuery in it.title.toLowerCase() }
|
||||
.toList()
|
||||
} else {
|
||||
client.newCall(GET(getSearchUrl(query), headers))
|
||||
.asObservable()
|
||||
.map { Jsoup.parse(it.body().string()) }
|
||||
.flatMap { Observable.from(it.select("entry")) }
|
||||
.filter { it.select("type").text() != "Novel" }
|
||||
.map {
|
||||
Track.create(TrackManager.MYANIMELIST).apply {
|
||||
title = it.selectText("title")!!
|
||||
remote_id = it.selectInt("id")
|
||||
total_chapters = it.selectInt("chapters")
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
fun getList(username: String): Observable<List<Track>> {
|
||||
return client
|
||||
.newCall(GET(getListUrl(username), headers))
|
||||
.asObservable()
|
||||
.map { Jsoup.parse(it.body().string()) }
|
||||
.flatMap { Observable.from(it.select("manga")) }
|
||||
.map {
|
||||
Track.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")
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
fun findLibManga(track: Track, username: String): Observable<Track?> {
|
||||
return getList(username)
|
||||
.map { list -> list.find { it.remote_id == track.remote_id } }
|
||||
}
|
||||
|
||||
fun getLibManga(track: Track, username: String): Observable<Track> {
|
||||
return findLibManga(track, username)
|
||||
.map { it ?: throw Exception("Could not find manga") }
|
||||
}
|
||||
|
||||
fun login(username: String, password: String): Observable<Response> {
|
||||
headers = createHeaders(username, password)
|
||||
return client.newCall(GET(getLoginUrl(), headers))
|
||||
.asObservable()
|
||||
.doOnNext { response ->
|
||||
response.close()
|
||||
if (response.code() != 200) throw Exception("Login error")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMangaPostPayload(track: Track): RequestBody {
|
||||
val data = xml {
|
||||
element(ENTRY_TAG) {
|
||||
if (track.last_chapter_read != 0) {
|
||||
text(CHAPTER_TAG, track.last_chapter_read.toString())
|
||||
}
|
||||
text(STATUS_TAG, track.status.toString())
|
||||
text(SCORE_TAG, track.score.toString())
|
||||
}
|
||||
}
|
||||
|
||||
return FormBody.Builder()
|
||||
.add("data", data)
|
||||
.build()
|
||||
}
|
||||
|
||||
private inline fun xml(block: XmlSerializer.() -> Unit): String {
|
||||
val x = Xml.newSerializer()
|
||||
val writer = StringWriter()
|
||||
|
||||
with(x) {
|
||||
setOutput(writer)
|
||||
startDocument("UTF-8", false)
|
||||
block()
|
||||
endDocument()
|
||||
}
|
||||
|
||||
return writer.toString()
|
||||
}
|
||||
|
||||
private inline fun XmlSerializer.element(tag: String, block: XmlSerializer.() -> Unit) {
|
||||
startTag("", tag)
|
||||
block()
|
||||
endTag("", tag)
|
||||
}
|
||||
|
||||
private fun XmlSerializer.text(tag: String, body: String) {
|
||||
startTag("", tag)
|
||||
text(body)
|
||||
endTag("", tag)
|
||||
}
|
||||
|
||||
fun getLoginUrl() = Uri.parse(baseUrl).buildUpon()
|
||||
.appendEncodedPath("api/account/verify_credentials.xml")
|
||||
.toString()
|
||||
|
||||
fun getSearchUrl(query: String) = Uri.parse(baseUrl).buildUpon()
|
||||
.appendEncodedPath("api/manga/search.xml")
|
||||
.appendQueryParameter("q", query)
|
||||
.toString()
|
||||
|
||||
fun getListUrl(username: String) = Uri.parse(baseUrl).buildUpon()
|
||||
.appendPath("malappinfo.php")
|
||||
.appendQueryParameter("u", username)
|
||||
.appendQueryParameter("status", "all")
|
||||
.appendQueryParameter("type", "manga")
|
||||
.toString()
|
||||
|
||||
fun getUpdateUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
|
||||
.appendEncodedPath("api/mangalist/update")
|
||||
.appendPath("${track.remote_id}.xml")
|
||||
.toString()
|
||||
|
||||
fun getAddUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
|
||||
.appendEncodedPath("api/mangalist/add")
|
||||
.appendPath("${track.remote_id}.xml")
|
||||
.toString()
|
||||
|
||||
fun createHeaders(username: String, password: String): Headers {
|
||||
return Headers.Builder()
|
||||
.add("Authorization", Credentials.basic(username, password))
|
||||
.add("User-Agent", "api-indiv-9F93C52A963974CF674325391990191C")
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val baseUrl = "https://myanimelist.net"
|
||||
|
||||
private val ENTRY_TAG = "entry"
|
||||
private val CHAPTER_TAG = "chapter"
|
||||
private val SCORE_TAG = "score"
|
||||
private val STATUS_TAG = "status"
|
||||
|
||||
const val PREFIX_MY = "my:"
|
||||
}
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
package eu.kanade.tachiyomi.data.updater
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import com.evernote.android.job.Job
|
||||
import com.evernote.android.job.JobManager
|
||||
@ -17,6 +19,10 @@ class UpdateCheckerJob : Job() {
|
||||
if (result is GithubUpdateResult.NewUpdate) {
|
||||
val url = result.release.downloadLink
|
||||
|
||||
val intent = Intent(context, UpdateDownloaderService::class.java).apply {
|
||||
putExtra(UpdateDownloaderService.EXTRA_DOWNLOAD_URL, url)
|
||||
}
|
||||
|
||||
NotificationCompat.Builder(context).update {
|
||||
setContentTitle(context.getString(R.string.app_name))
|
||||
setContentText(context.getString(R.string.update_check_notification_update_available))
|
||||
@ -24,7 +30,7 @@ class UpdateCheckerJob : Job() {
|
||||
// Download action
|
||||
addAction(android.R.drawable.stat_sys_download_done,
|
||||
context.getString(R.string.action_download),
|
||||
UpdateNotificationReceiver.downloadApkIntent(context, url))
|
||||
PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
}
|
||||
}
|
||||
Job.Result.SUCCESS
|
||||
|
@ -0,0 +1,144 @@
|
||||
package eu.kanade.tachiyomi.data.updater
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import eu.kanade.tachiyomi.Constants
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationHandler
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
||||
import eu.kanade.tachiyomi.util.notificationManager
|
||||
import java.io.File
|
||||
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
||||
|
||||
/**
|
||||
* Local [BroadcastReceiver] that runs on UI thread
|
||||
* Notification calls from [UpdateDownloaderService] should be made from here.
|
||||
*/
|
||||
internal class UpdateDownloaderReceiver(val context: Context) : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
private const val NAME = "UpdateDownloaderReceiver"
|
||||
|
||||
// Called to show initial notification.
|
||||
internal const val NOTIFICATION_UPDATER_INITIAL = "$ID.$NAME.UPDATER_INITIAL"
|
||||
|
||||
// Called to show progress notification.
|
||||
internal const val NOTIFICATION_UPDATER_PROGRESS = "$ID.$NAME.UPDATER_PROGRESS"
|
||||
|
||||
// Called to show install notification.
|
||||
internal const val NOTIFICATION_UPDATER_INSTALL = "$ID.$NAME.UPDATER_INSTALL"
|
||||
|
||||
// Called to show error notification
|
||||
internal const val NOTIFICATION_UPDATER_ERROR = "$ID.$NAME.UPDATER_ERROR"
|
||||
|
||||
// Value containing action of BroadcastReceiver
|
||||
internal const val EXTRA_ACTION = "$ID.$NAME.ACTION"
|
||||
|
||||
// Value containing progress
|
||||
internal const val EXTRA_PROGRESS = "$ID.$NAME.PROGRESS"
|
||||
|
||||
// Value containing apk path
|
||||
internal const val EXTRA_APK_PATH = "$ID.$NAME.APK_PATH"
|
||||
|
||||
// Value containing apk url
|
||||
internal const val EXTRA_APK_URL = "$ID.$NAME.APK_URL"
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification shown to user
|
||||
*/
|
||||
private val notification = NotificationCompat.Builder(context)
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.getStringExtra(EXTRA_ACTION)) {
|
||||
NOTIFICATION_UPDATER_INITIAL -> basicNotification()
|
||||
NOTIFICATION_UPDATER_PROGRESS -> updateProgress(intent.getIntExtra(EXTRA_PROGRESS, 0))
|
||||
NOTIFICATION_UPDATER_INSTALL -> installNotification(intent.getStringExtra(EXTRA_APK_PATH))
|
||||
NOTIFICATION_UPDATER_ERROR -> errorNotification(intent.getStringExtra(EXTRA_APK_URL))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to show basic notification
|
||||
*/
|
||||
private fun basicNotification() {
|
||||
// Create notification
|
||||
with(notification) {
|
||||
setContentTitle(context.getString(R.string.app_name))
|
||||
setContentText(context.getString(R.string.update_check_notification_download_in_progress))
|
||||
setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
setOngoing(true)
|
||||
}
|
||||
notification.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to show progress notification
|
||||
*
|
||||
* @param progress progress of download
|
||||
*/
|
||||
private fun updateProgress(progress: Int) {
|
||||
with(notification) {
|
||||
setProgress(100, progress, false)
|
||||
}
|
||||
notification.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to show install notification
|
||||
*
|
||||
* @param path path of file
|
||||
*/
|
||||
private fun installNotification(path: String) {
|
||||
// Prompt the user to install the new update.
|
||||
with(notification) {
|
||||
setContentText(context.getString(R.string.update_check_notification_download_complete))
|
||||
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
setProgress(0, 0, false)
|
||||
// Install action
|
||||
setContentIntent(NotificationHandler.installApkPendingActivity(context, File(path)))
|
||||
addAction(R.drawable.ic_system_update_grey_24dp_img,
|
||||
context.getString(R.string.action_install),
|
||||
NotificationHandler.installApkPendingActivity(context, File(path)))
|
||||
// Cancel action
|
||||
addAction(R.drawable.ic_clear_grey_24dp_img,
|
||||
context.getString(R.string.action_cancel),
|
||||
NotificationReceiver.dismissNotificationPendingBroadcast(context, Constants.NOTIFICATION_UPDATER_ID))
|
||||
}
|
||||
notification.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to show error notification
|
||||
*
|
||||
* @param url url of apk
|
||||
*/
|
||||
private fun errorNotification(url: String) {
|
||||
// Prompt the user to retry the download.
|
||||
with(notification) {
|
||||
setContentText(context.getString(R.string.update_check_notification_download_error))
|
||||
setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||
setProgress(0, 0, false)
|
||||
// Retry action
|
||||
addAction(R.drawable.ic_refresh_grey_24dp_img,
|
||||
context.getString(R.string.action_retry),
|
||||
UpdateDownloaderService.downloadApkPendingService(context, url))
|
||||
// Cancel action
|
||||
addAction(R.drawable.ic_clear_grey_24dp_img,
|
||||
context.getString(R.string.action_cancel),
|
||||
NotificationReceiver.dismissNotificationPendingBroadcast(context, Constants.NOTIFICATION_UPDATER_ID))
|
||||
}
|
||||
notification.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a notification from this builder.
|
||||
*
|
||||
* @param id the id of the notification.
|
||||
*/
|
||||
private fun NotificationCompat.Builder.show(id: Int = Constants.NOTIFICATION_UPDATER_ID) {
|
||||
context.notificationManager.notify(id, build())
|
||||
}
|
||||
}
|
@ -1,29 +1,160 @@
|
||||
package eu.kanade.tachiyomi.data.updater
|
||||
|
||||
import android.app.IntentService
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import eu.kanade.tachiyomi.Constants.NOTIFICATION_UPDATER_ID
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.network.GET
|
||||
import eu.kanade.tachiyomi.data.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.data.network.ProgressListener
|
||||
import eu.kanade.tachiyomi.data.network.newCallWithProgress
|
||||
import eu.kanade.tachiyomi.util.notificationManager
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.network.ProgressListener
|
||||
import eu.kanade.tachiyomi.network.newCallWithProgress
|
||||
import eu.kanade.tachiyomi.util.registerLocalReceiver
|
||||
import eu.kanade.tachiyomi.util.saveTo
|
||||
import eu.kanade.tachiyomi.util.sendLocalBroadcastSync
|
||||
import eu.kanade.tachiyomi.util.unregisterLocalReceiver
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
|
||||
class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.java.name) {
|
||||
/**
|
||||
* Network helper
|
||||
*/
|
||||
private val network: NetworkHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* Local [BroadcastReceiver] that runs on UI thread
|
||||
*/
|
||||
private val updaterNotificationReceiver = UpdateDownloaderReceiver(this)
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Register receiver
|
||||
registerLocalReceiver(updaterNotificationReceiver, IntentFilter(INTENT_FILTER_NAME))
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
// Unregister receiver
|
||||
unregisterLocalReceiver(updaterNotificationReceiver)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onHandleIntent(intent: Intent?) {
|
||||
if (intent == null) return
|
||||
|
||||
val url = intent.getStringExtra(EXTRA_DOWNLOAD_URL) ?: return
|
||||
downloadApk(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to start downloading apk of new update
|
||||
*
|
||||
* @param url url location of file
|
||||
*/
|
||||
fun downloadApk(url: String) {
|
||||
// Show notification download starting.
|
||||
sendInitialBroadcast()
|
||||
// Progress of the download
|
||||
var savedProgress = 0
|
||||
|
||||
val progressListener = object : ProgressListener {
|
||||
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
|
||||
val progress = (100 * bytesRead / contentLength).toInt()
|
||||
if (progress > savedProgress) {
|
||||
savedProgress = progress
|
||||
sendProgressBroadcast(progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Download the new update.
|
||||
val response = network.client.newCallWithProgress(GET(url), progressListener).execute()
|
||||
|
||||
// File where the apk will be saved.
|
||||
val apkFile = File(externalCacheDir, "update.apk")
|
||||
|
||||
if (response.isSuccessful) {
|
||||
response.body().source().saveTo(apkFile)
|
||||
} else {
|
||||
response.close()
|
||||
throw Exception("Unsuccessful response")
|
||||
}
|
||||
sendInstallBroadcast(apkFile.absolutePath)
|
||||
} catch (error: Exception) {
|
||||
Timber.e(error)
|
||||
sendErrorBroadcast(url)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show notification download starting.
|
||||
*/
|
||||
private fun sendInitialBroadcast() {
|
||||
val intent = Intent(INTENT_FILTER_NAME).apply {
|
||||
putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_INITIAL)
|
||||
}
|
||||
sendLocalBroadcastSync(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show notification progress changed
|
||||
*
|
||||
* @param progress progress of download
|
||||
*/
|
||||
private fun sendProgressBroadcast(progress: Int) {
|
||||
val intent = Intent(INTENT_FILTER_NAME).apply {
|
||||
putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_PROGRESS)
|
||||
putExtra(UpdateDownloaderReceiver.EXTRA_PROGRESS, progress)
|
||||
}
|
||||
// Prevents not showing of install notification TODO weird Android N bug. Find out what goes wrong
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || progress <= 95) {
|
||||
// Show download progress notification.
|
||||
sendLocalBroadcastSync(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show install notification.
|
||||
*
|
||||
* @param path location of file
|
||||
*/
|
||||
private fun sendInstallBroadcast(path: String){
|
||||
val intent = Intent(INTENT_FILTER_NAME).apply {
|
||||
putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_INSTALL)
|
||||
putExtra(UpdateDownloaderReceiver.EXTRA_APK_PATH, path)
|
||||
}
|
||||
sendLocalBroadcastSync(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error notification.
|
||||
*
|
||||
* @param url url of file
|
||||
*/
|
||||
private fun sendErrorBroadcast(url: String){
|
||||
val intent = Intent(INTENT_FILTER_NAME).apply {
|
||||
putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_ERROR)
|
||||
putExtra(UpdateDownloaderReceiver.EXTRA_APK_URL, url)
|
||||
}
|
||||
sendLocalBroadcastSync(intent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Name of Local BroadCastReceiver.
|
||||
*/
|
||||
private val INTENT_FILTER_NAME = UpdateDownloaderService::class.java.name
|
||||
|
||||
/**
|
||||
* Download url.
|
||||
*/
|
||||
const val EXTRA_DOWNLOAD_URL = "eu.kanade.APP_DOWNLOAD_URL"
|
||||
internal const val EXTRA_DOWNLOAD_URL = "${BuildConfig.APPLICATION_ID}.UpdateDownloaderService.DOWNLOAD_URL"
|
||||
|
||||
/**
|
||||
* Downloads a new update and let the user install the new version from a notification.
|
||||
@ -38,115 +169,18 @@ class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.jav
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user with apk install intent
|
||||
* @param context context
|
||||
* @param file file of apk that is installed
|
||||
* Returns [PendingIntent] that starts a service which downloads the apk specified in url.
|
||||
*
|
||||
* @param url the url to the new update.
|
||||
* @return [PendingIntent]
|
||||
*/
|
||||
fun installAPK(context: Context, file: File) {
|
||||
// Prompt install interface
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(Uri.fromFile(file), "application/vnd.android.package-archive")
|
||||
// Without this flag android returned a intent error!
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
internal fun downloadApkPendingService(context: Context, url: String): PendingIntent {
|
||||
val intent = Intent(context, UpdateDownloaderService::class.java).apply {
|
||||
putExtra(EXTRA_DOWNLOAD_URL, url)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Network helper
|
||||
*/
|
||||
private val network: NetworkHelper by injectLazy()
|
||||
|
||||
override fun onHandleIntent(intent: Intent?) {
|
||||
if (intent == null) return
|
||||
|
||||
val url = intent.getStringExtra(EXTRA_DOWNLOAD_URL) ?: return
|
||||
downloadApk(url)
|
||||
}
|
||||
|
||||
fun downloadApk(url: String) {
|
||||
val progressNotification = NotificationCompat.Builder(this)
|
||||
|
||||
progressNotification.update {
|
||||
setContentTitle(getString(R.string.app_name))
|
||||
setContentText(getString(R.string.update_check_notification_download_in_progress))
|
||||
setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
setOngoing(true)
|
||||
}
|
||||
|
||||
// Progress of the download
|
||||
var savedProgress = 0
|
||||
|
||||
val progressListener = object : ProgressListener {
|
||||
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
|
||||
val progress = (100 * bytesRead / contentLength).toInt()
|
||||
if (progress > savedProgress) {
|
||||
savedProgress = progress
|
||||
|
||||
progressNotification.update { setProgress(100, progress, false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reference the context for later usage inside apply blocks.
|
||||
val ctx = this
|
||||
|
||||
try {
|
||||
// Download the new update.
|
||||
val response = network.client.newCallWithProgress(GET(url), progressListener).execute()
|
||||
|
||||
// File where the apk will be saved
|
||||
val apkFile = File(externalCacheDir, "update.apk")
|
||||
|
||||
if (response.isSuccessful) {
|
||||
response.body().source().saveTo(apkFile)
|
||||
} else {
|
||||
response.close()
|
||||
throw Exception("Unsuccessful response")
|
||||
}
|
||||
|
||||
val installIntent = UpdateNotificationReceiver.installApkIntent(ctx, apkFile.absolutePath)
|
||||
|
||||
// Prompt the user to install the new update.
|
||||
NotificationCompat.Builder(this).update {
|
||||
setContentTitle(getString(R.string.app_name))
|
||||
setContentText(getString(R.string.update_check_notification_download_complete))
|
||||
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
// Install action
|
||||
setContentIntent(installIntent)
|
||||
addAction(R.drawable.ic_system_update_grey_24dp_img,
|
||||
getString(R.string.action_install),
|
||||
installIntent)
|
||||
// Cancel action
|
||||
addAction(R.drawable.ic_clear_grey_24dp_img,
|
||||
getString(R.string.action_cancel),
|
||||
UpdateNotificationReceiver.cancelNotificationIntent(ctx))
|
||||
}
|
||||
|
||||
} catch (error: Exception) {
|
||||
Timber.e(error)
|
||||
|
||||
// Prompt the user to retry the download.
|
||||
NotificationCompat.Builder(this).update {
|
||||
setContentTitle(getString(R.string.app_name))
|
||||
setContentText(getString(R.string.update_check_notification_download_error))
|
||||
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
// Retry action
|
||||
addAction(R.drawable.ic_refresh_grey_24dp_img,
|
||||
getString(R.string.action_retry),
|
||||
UpdateNotificationReceiver.downloadApkIntent(ctx, url))
|
||||
// Cancel action
|
||||
addAction(R.drawable.ic_clear_grey_24dp_img,
|
||||
getString(R.string.action_cancel),
|
||||
UpdateNotificationReceiver.cancelNotificationIntent(ctx))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun NotificationCompat.Builder.update(block: NotificationCompat.Builder.() -> Unit) {
|
||||
block()
|
||||
notificationManager.notify(NOTIFICATION_UPDATER_ID, build())
|
||||
}
|
||||
|
||||
}
|
@ -1,67 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.updater
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import eu.kanade.tachiyomi.Constants.NOTIFICATION_UPDATER_ID
|
||||
import eu.kanade.tachiyomi.util.notificationManager
|
||||
import java.io.File
|
||||
|
||||
class UpdateNotificationReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
ACTION_INSTALL_APK -> {
|
||||
UpdateDownloaderService.installAPK(context,
|
||||
File(intent.getStringExtra(EXTRA_FILE_LOCATION)))
|
||||
cancelNotification(context)
|
||||
}
|
||||
ACTION_DOWNLOAD_UPDATE -> UpdateDownloaderService.downloadUpdate(context,
|
||||
intent.getStringExtra(UpdateDownloaderService.EXTRA_DOWNLOAD_URL))
|
||||
ACTION_CANCEL_NOTIFICATION -> cancelNotification(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelNotification(context: Context) {
|
||||
context.notificationManager.cancel(NOTIFICATION_UPDATER_ID)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Install apk action
|
||||
const val ACTION_INSTALL_APK = "eu.kanade.INSTALL_APK"
|
||||
|
||||
// Download apk action
|
||||
const val ACTION_DOWNLOAD_UPDATE = "eu.kanade.RETRY_DOWNLOAD"
|
||||
|
||||
// Cancel notification action
|
||||
const val ACTION_CANCEL_NOTIFICATION = "eu.kanade.CANCEL_NOTIFICATION"
|
||||
|
||||
// Absolute path of apk file
|
||||
const val EXTRA_FILE_LOCATION = "file_location"
|
||||
|
||||
fun cancelNotificationIntent(context: Context): PendingIntent {
|
||||
val intent = Intent(context, UpdateNotificationReceiver::class.java).apply {
|
||||
action = ACTION_CANCEL_NOTIFICATION
|
||||
}
|
||||
return PendingIntent.getBroadcast(context, 0, intent, 0)
|
||||
}
|
||||
|
||||
fun installApkIntent(context: Context, path: String): PendingIntent {
|
||||
val intent = Intent(context, UpdateNotificationReceiver::class.java).apply {
|
||||
action = ACTION_INSTALL_APK
|
||||
putExtra(EXTRA_FILE_LOCATION, path)
|
||||
}
|
||||
return PendingIntent.getBroadcast(context, 0, intent, 0)
|
||||
}
|
||||
|
||||
fun downloadApkIntent(context: Context, url: String): PendingIntent {
|
||||
val intent = Intent(context, UpdateNotificationReceiver::class.java).apply {
|
||||
action = ACTION_DOWNLOAD_UPDATE
|
||||
putExtra(UpdateDownloaderService.EXTRA_DOWNLOAD_URL, url)
|
||||
}
|
||||
return PendingIntent.getBroadcast(context, 0, intent, 0)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,80 +1,77 @@
|
||||
package eu.kanade.tachiyomi.data.network
|
||||
|
||||
import com.squareup.duktape.Duktape
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
class CloudflareInterceptor(private val cookies: PersistentCookieStore) : Interceptor {
|
||||
|
||||
//language=RegExp
|
||||
private val operationPattern = Regex("""setTimeout\(function\(\)\{\s+(var (?:\w,)+f.+?\r?\n[\s\S]+?a\.value =.+?)\r?\n""")
|
||||
|
||||
//language=RegExp
|
||||
private val passPattern = Regex("""name="pass" value="(.+?)"""")
|
||||
|
||||
//language=RegExp
|
||||
private val challengePattern = Regex("""name="jschl_vc" value="(\w+)"""")
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val response = chain.proceed(chain.request())
|
||||
|
||||
// Check if we already solved a challenge
|
||||
if (response.code() != 503 &&
|
||||
cookies.get(response.request().url()).any { it.name() == "cf_clearance" }) {
|
||||
return response
|
||||
}
|
||||
|
||||
// Check if Cloudflare anti-bot is on
|
||||
if ("URL=/cdn-cgi/" in response.header("Refresh", "")
|
||||
&& response.header("Server", "") == "cloudflare-nginx") {
|
||||
return chain.proceed(resolveChallenge(response))
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private fun resolveChallenge(response: Response): Request {
|
||||
val duktape = Duktape.create()
|
||||
try {
|
||||
val originalRequest = response.request()
|
||||
val domain = originalRequest.url().host()
|
||||
val content = response.body().string()
|
||||
|
||||
// CloudFlare requires waiting 4 seconds before resolving the challenge
|
||||
Thread.sleep(4000)
|
||||
|
||||
val operation = operationPattern.find(content)?.groups?.get(1)?.value
|
||||
val challenge = challengePattern.find(content)?.groups?.get(1)?.value
|
||||
val pass = passPattern.find(content)?.groups?.get(1)?.value
|
||||
|
||||
if (operation == null || challenge == null || pass == null) {
|
||||
throw RuntimeException("Failed resolving Cloudflare challenge")
|
||||
}
|
||||
|
||||
val js = operation
|
||||
//language=RegExp
|
||||
.replace(Regex("""a\.value =(.+?) \+.*"""), "$1")
|
||||
//language=RegExp
|
||||
.replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "")
|
||||
.replace("\n", "")
|
||||
|
||||
val result = (duktape.evaluate(js) as Double).toInt()
|
||||
|
||||
val answer = "${result + domain.length}"
|
||||
|
||||
val url = HttpUrl.parse("http://$domain/cdn-cgi/l/chk_jschl").newBuilder()
|
||||
.addQueryParameter("jschl_vc", challenge)
|
||||
.addQueryParameter("pass", pass)
|
||||
.addQueryParameter("jschl_answer", answer)
|
||||
.toString()
|
||||
|
||||
val referer = originalRequest.url().toString()
|
||||
return GET(url, originalRequest.headers().newBuilder().add("Referer", referer).build())
|
||||
} finally {
|
||||
duktape.close()
|
||||
}
|
||||
}
|
||||
|
||||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import com.squareup.duktape.Duktape
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
class CloudflareInterceptor : Interceptor {
|
||||
|
||||
//language=RegExp
|
||||
private val operationPattern = Regex("""setTimeout\(function\(\)\{\s+(var (?:\w,)+f.+?\r?\n[\s\S]+?a\.value =.+?)\r?\n""")
|
||||
|
||||
//language=RegExp
|
||||
private val passPattern = Regex("""name="pass" value="(.+?)"""")
|
||||
|
||||
//language=RegExp
|
||||
private val challengePattern = Regex("""name="jschl_vc" value="(\w+)"""")
|
||||
|
||||
@Synchronized
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val response = chain.proceed(chain.request())
|
||||
|
||||
// Check if Cloudflare anti-bot is on
|
||||
if (response.code() == 503 && "cloudflare-nginx" == response.header("Server")) {
|
||||
return chain.proceed(resolveChallenge(response))
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private fun resolveChallenge(response: Response): Request {
|
||||
Duktape.create().use { duktape ->
|
||||
val originalRequest = response.request()
|
||||
val url = originalRequest.url()
|
||||
val domain = url.host()
|
||||
val content = response.body().string()
|
||||
|
||||
// CloudFlare requires waiting 4 seconds before resolving the challenge
|
||||
Thread.sleep(4000)
|
||||
|
||||
val operation = operationPattern.find(content)?.groups?.get(1)?.value
|
||||
val challenge = challengePattern.find(content)?.groups?.get(1)?.value
|
||||
val pass = passPattern.find(content)?.groups?.get(1)?.value
|
||||
|
||||
if (operation == null || challenge == null || pass == null) {
|
||||
throw RuntimeException("Failed resolving Cloudflare challenge")
|
||||
}
|
||||
|
||||
val js = operation
|
||||
//language=RegExp
|
||||
.replace(Regex("""a\.value =(.+?) \+.*"""), "$1")
|
||||
//language=RegExp
|
||||
.replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "")
|
||||
.replace("\n", "")
|
||||
|
||||
val result = (duktape.evaluate(js) as Double).toInt()
|
||||
|
||||
val answer = "${result + domain.length}"
|
||||
|
||||
val cloudflareUrl = HttpUrl.parse("${url.scheme()}://$domain/cdn-cgi/l/chk_jschl")
|
||||
.newBuilder()
|
||||
.addQueryParameter("jschl_vc", challenge)
|
||||
.addQueryParameter("pass", pass)
|
||||
.addQueryParameter("jschl_answer", answer)
|
||||
.toString()
|
||||
|
||||
val cloudflareHeaders = originalRequest.headers()
|
||||
.newBuilder()
|
||||
.add("Referer", url.toString())
|
||||
.build()
|
||||
|
||||
return GET(cloudflareUrl, cloudflareHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,38 +1,38 @@
|
||||
package eu.kanade.tachiyomi.data.network
|
||||
|
||||
import android.content.Context
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
|
||||
class NetworkHelper(context: Context) {
|
||||
|
||||
private val cacheDir = File(context.cacheDir, "network_cache")
|
||||
|
||||
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
|
||||
|
||||
private val cookieManager = PersistentCookieJar(context)
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
.cookieJar(cookieManager)
|
||||
.cache(Cache(cacheDir, cacheSize))
|
||||
.build()
|
||||
|
||||
val forceCacheClient = client.newBuilder()
|
||||
.addNetworkInterceptor { chain ->
|
||||
val originalResponse = chain.proceed(chain.request())
|
||||
originalResponse.newBuilder()
|
||||
.removeHeader("Pragma")
|
||||
.header("Cache-Control", "max-age=600")
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
|
||||
val cloudflareClient = client.newBuilder()
|
||||
.addInterceptor(CloudflareInterceptor(cookies))
|
||||
.build()
|
||||
|
||||
val cookies: PersistentCookieStore
|
||||
get() = cookieManager.store
|
||||
|
||||
}
|
||||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import android.content.Context
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
|
||||
class NetworkHelper(context: Context) {
|
||||
|
||||
private val cacheDir = File(context.cacheDir, "network_cache")
|
||||
|
||||
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
|
||||
|
||||
private val cookieManager = PersistentCookieJar(context)
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
.cookieJar(cookieManager)
|
||||
.cache(Cache(cacheDir, cacheSize))
|
||||
.build()
|
||||
|
||||
val forceCacheClient = client.newBuilder()
|
||||
.addNetworkInterceptor { chain ->
|
||||
val originalResponse = chain.proceed(chain.request())
|
||||
originalResponse.newBuilder()
|
||||
.removeHeader("Pragma")
|
||||
.header("Cache-Control", "max-age=600")
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
|
||||
val cloudflareClient = client.newBuilder()
|
||||
.addInterceptor(CloudflareInterceptor())
|
||||
.build()
|
||||
|
||||
val cookies: PersistentCookieStore
|
||||
get() = cookieManager.store
|
||||
|
||||
}
|
@ -1,70 +1,70 @@
|
||||
package eu.kanade.tachiyomi.data.network
|
||||
|
||||
import okhttp3.Call
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import rx.Producer
|
||||
import rx.Subscription
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
fun Call.asObservable(): Observable<Response> {
|
||||
return Observable.create { subscriber ->
|
||||
// Since Call is a one-shot type, clone it for each new subscriber.
|
||||
val call = clone()
|
||||
|
||||
// Wrap the call in a helper which handles both unsubscription and backpressure.
|
||||
val requestArbiter = object : AtomicBoolean(), Producer, Subscription {
|
||||
override fun request(n: Long) {
|
||||
if (n == 0L || !compareAndSet(false, true)) return
|
||||
|
||||
try {
|
||||
val response = call.execute()
|
||||
if (!subscriber.isUnsubscribed) {
|
||||
subscriber.onNext(response)
|
||||
subscriber.onCompleted()
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
if (!subscriber.isUnsubscribed) {
|
||||
subscriber.onError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun unsubscribe() {
|
||||
call.cancel()
|
||||
}
|
||||
|
||||
override fun isUnsubscribed(): Boolean {
|
||||
return call.isCanceled
|
||||
}
|
||||
}
|
||||
|
||||
subscriber.add(requestArbiter)
|
||||
subscriber.setProducer(requestArbiter)
|
||||
}
|
||||
}
|
||||
|
||||
fun Call.asObservableSuccess(): Observable<Response> {
|
||||
return asObservable().doOnNext { response ->
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
throw Exception("Unsuccessful code ${response.code()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call {
|
||||
val progressClient = newBuilder()
|
||||
.cache(null)
|
||||
.addNetworkInterceptor { chain ->
|
||||
val originalResponse = chain.proceed(chain.request())
|
||||
originalResponse.newBuilder()
|
||||
.body(ProgressResponseBody(originalResponse.body(), listener))
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
|
||||
return progressClient.newCall(request)
|
||||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import okhttp3.Call
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import rx.Producer
|
||||
import rx.Subscription
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
fun Call.asObservable(): Observable<Response> {
|
||||
return Observable.create { subscriber ->
|
||||
// Since Call is a one-shot type, clone it for each new subscriber.
|
||||
val call = clone()
|
||||
|
||||
// Wrap the call in a helper which handles both unsubscription and backpressure.
|
||||
val requestArbiter = object : AtomicBoolean(), Producer, Subscription {
|
||||
override fun request(n: Long) {
|
||||
if (n == 0L || !compareAndSet(false, true)) return
|
||||
|
||||
try {
|
||||
val response = call.execute()
|
||||
if (!subscriber.isUnsubscribed) {
|
||||
subscriber.onNext(response)
|
||||
subscriber.onCompleted()
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
if (!subscriber.isUnsubscribed) {
|
||||
subscriber.onError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun unsubscribe() {
|
||||
call.cancel()
|
||||
}
|
||||
|
||||
override fun isUnsubscribed(): Boolean {
|
||||
return call.isCanceled
|
||||
}
|
||||
}
|
||||
|
||||
subscriber.add(requestArbiter)
|
||||
subscriber.setProducer(requestArbiter)
|
||||
}
|
||||
}
|
||||
|
||||
fun Call.asObservableSuccess(): Observable<Response> {
|
||||
return asObservable().doOnNext { response ->
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
throw Exception("HTTP error ${response.code()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call {
|
||||
val progressClient = newBuilder()
|
||||
.cache(null)
|
||||
.addNetworkInterceptor { chain ->
|
||||
val originalResponse = chain.proceed(chain.request())
|
||||
originalResponse.newBuilder()
|
||||
.body(ProgressResponseBody(originalResponse.body(), listener))
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
|
||||
return progressClient.newCall(request)
|
||||
}
|
@ -1,19 +1,19 @@
|
||||
package eu.kanade.tachiyomi.data.network
|
||||
|
||||
import android.content.Context
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
class PersistentCookieJar(context: Context) : CookieJar {
|
||||
|
||||
val store = PersistentCookieStore(context)
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
store.addAll(url, cookies)
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
return store.get(url)
|
||||
}
|
||||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import android.content.Context
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
class PersistentCookieJar(context: Context) : CookieJar {
|
||||
|
||||
val store = PersistentCookieStore(context)
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
store.addAll(url, cookies)
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
return store.get(url)
|
||||
}
|
||||
}
|
@ -1,75 +1,73 @@
|
||||
package eu.kanade.tachiyomi.data.network
|
||||
|
||||
import android.content.Context
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl
|
||||
import java.net.URI
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class PersistentCookieStore(context: Context) {
|
||||
|
||||
private val cookieMap = ConcurrentHashMap<String, List<Cookie>>()
|
||||
private val prefs = context.getSharedPreferences("cookie_store", Context.MODE_PRIVATE)
|
||||
|
||||
init {
|
||||
for ((key, value) in prefs.all) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val cookies = value as? Set<String>
|
||||
if (cookies != null) {
|
||||
try {
|
||||
val url = HttpUrl.parse("http://$key")
|
||||
val nonExpiredCookies = cookies.map { Cookie.parse(url, it) }
|
||||
.filter { !it.hasExpired() }
|
||||
cookieMap.put(key, nonExpiredCookies)
|
||||
} catch (e: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addAll(url: HttpUrl, cookies: List<Cookie>) {
|
||||
synchronized(this) {
|
||||
val key = url.uri().host
|
||||
|
||||
// Append or replace the cookies for this domain.
|
||||
val cookiesForDomain = cookieMap[key].orEmpty().toMutableList()
|
||||
for (cookie in cookies) {
|
||||
// Find a cookie with the same name. Replace it if found, otherwise add a new one.
|
||||
val pos = cookiesForDomain.indexOfFirst { it.name() == cookie.name() }
|
||||
if (pos == -1) {
|
||||
cookiesForDomain.add(cookie)
|
||||
} else {
|
||||
cookiesForDomain[pos] = cookie
|
||||
}
|
||||
}
|
||||
cookieMap.put(key, cookiesForDomain)
|
||||
|
||||
// Get cookies to be stored in disk
|
||||
val newValues = cookiesForDomain.asSequence()
|
||||
.filter { it.persistent() && !it.hasExpired() }
|
||||
.map { it.toString() }
|
||||
.toSet()
|
||||
|
||||
prefs.edit().putStringSet(key, newValues).apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAll() {
|
||||
synchronized(this) {
|
||||
prefs.edit().clear().apply()
|
||||
cookieMap.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun get(url: HttpUrl) = get(url.uri().host)
|
||||
|
||||
fun get(uri: URI) = get(uri.host)
|
||||
|
||||
private fun get(url: String): List<Cookie> {
|
||||
return cookieMap[url].orEmpty().filter { !it.hasExpired() }
|
||||
}
|
||||
|
||||
private fun Cookie.hasExpired() = System.currentTimeMillis() >= expiresAt()
|
||||
|
||||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import android.content.Context
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl
|
||||
import java.net.URI
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class PersistentCookieStore(context: Context) {
|
||||
|
||||
private val cookieMap = ConcurrentHashMap<String, List<Cookie>>()
|
||||
private val prefs = context.getSharedPreferences("cookie_store", Context.MODE_PRIVATE)
|
||||
|
||||
init {
|
||||
for ((key, value) in prefs.all) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val cookies = value as? Set<String>
|
||||
if (cookies != null) {
|
||||
try {
|
||||
val url = HttpUrl.parse("http://$key")
|
||||
val nonExpiredCookies = cookies.map { Cookie.parse(url, it) }
|
||||
.filter { !it.hasExpired() }
|
||||
cookieMap.put(key, nonExpiredCookies)
|
||||
} catch (e: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addAll(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val key = url.uri().host
|
||||
|
||||
// Append or replace the cookies for this domain.
|
||||
val cookiesForDomain = cookieMap[key].orEmpty().toMutableList()
|
||||
for (cookie in cookies) {
|
||||
// Find a cookie with the same name. Replace it if found, otherwise add a new one.
|
||||
val pos = cookiesForDomain.indexOfFirst { it.name() == cookie.name() }
|
||||
if (pos == -1) {
|
||||
cookiesForDomain.add(cookie)
|
||||
} else {
|
||||
cookiesForDomain[pos] = cookie
|
||||
}
|
||||
}
|
||||
cookieMap.put(key, cookiesForDomain)
|
||||
|
||||
// Get cookies to be stored in disk
|
||||
val newValues = cookiesForDomain.asSequence()
|
||||
.filter { it.persistent() && !it.hasExpired() }
|
||||
.map(Cookie::toString)
|
||||
.toSet()
|
||||
|
||||
prefs.edit().putStringSet(key, newValues).apply()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeAll() {
|
||||
prefs.edit().clear().apply()
|
||||
cookieMap.clear()
|
||||
}
|
||||
|
||||
fun get(url: HttpUrl) = get(url.uri().host)
|
||||
|
||||
fun get(uri: URI) = get(uri.host)
|
||||
|
||||
private fun get(url: String): List<Cookie> {
|
||||
return cookieMap[url].orEmpty().filter { !it.hasExpired() }
|
||||
}
|
||||
|
||||
private fun Cookie.hasExpired() = System.currentTimeMillis() >= expiresAt()
|
||||
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
package eu.kanade.tachiyomi.data.network
|
||||
|
||||
interface ProgressListener {
|
||||
fun update(bytesRead: Long, contentLength: Long, done: Boolean)
|
||||
package eu.kanade.tachiyomi.network
|
||||
|
||||
interface ProgressListener {
|
||||
fun update(bytesRead: Long, contentLength: Long, done: Boolean)
|
||||
}
|
@ -1,40 +1,40 @@
|
||||
package eu.kanade.tachiyomi.data.network
|
||||
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.ResponseBody
|
||||
import okio.*
|
||||
import java.io.IOException
|
||||
|
||||
class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() {
|
||||
|
||||
private val bufferedSource: BufferedSource by lazy {
|
||||
Okio.buffer(source(responseBody.source()))
|
||||
}
|
||||
|
||||
override fun contentType(): MediaType {
|
||||
return responseBody.contentType()
|
||||
}
|
||||
|
||||
override fun contentLength(): Long {
|
||||
return responseBody.contentLength()
|
||||
}
|
||||
|
||||
override fun source(): BufferedSource {
|
||||
return bufferedSource
|
||||
}
|
||||
|
||||
private fun source(source: Source): Source {
|
||||
return object : ForwardingSource(source) {
|
||||
internal var totalBytesRead = 0L
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(sink: Buffer, byteCount: Long): Long {
|
||||
val bytesRead = super.read(sink, byteCount)
|
||||
// read() returns the number of bytes read, or -1 if this source is exhausted.
|
||||
totalBytesRead += if (bytesRead != -1L) bytesRead else 0
|
||||
progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
|
||||
return bytesRead
|
||||
}
|
||||
}
|
||||
}
|
||||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.ResponseBody
|
||||
import okio.*
|
||||
import java.io.IOException
|
||||
|
||||
class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() {
|
||||
|
||||
private val bufferedSource: BufferedSource by lazy {
|
||||
Okio.buffer(source(responseBody.source()))
|
||||
}
|
||||
|
||||
override fun contentType(): MediaType {
|
||||
return responseBody.contentType()
|
||||
}
|
||||
|
||||
override fun contentLength(): Long {
|
||||
return responseBody.contentLength()
|
||||
}
|
||||
|
||||
override fun source(): BufferedSource {
|
||||
return bufferedSource
|
||||
}
|
||||
|
||||
private fun source(source: Source): Source {
|
||||
return object : ForwardingSource(source) {
|
||||
internal var totalBytesRead = 0L
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(sink: Buffer, byteCount: Long): Long {
|
||||
val bytesRead = super.read(sink, byteCount)
|
||||
// read() returns the number of bytes read, or -1 if this source is exhausted.
|
||||
totalBytesRead += if (bytesRead != -1L) bytesRead else 0
|
||||
progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
|
||||
return bytesRead
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,32 +1,32 @@
|
||||
package eu.kanade.tachiyomi.data.network
|
||||
|
||||
import okhttp3.*
|
||||
import java.util.concurrent.TimeUnit.MINUTES
|
||||
|
||||
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build()
|
||||
private val DEFAULT_HEADERS = Headers.Builder().build()
|
||||
private val DEFAULT_BODY: RequestBody = FormBody.Builder().build()
|
||||
|
||||
fun GET(url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL): Request {
|
||||
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun POST(url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
body: RequestBody = DEFAULT_BODY,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL): Request {
|
||||
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.post(body)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
||||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import okhttp3.*
|
||||
import java.util.concurrent.TimeUnit.MINUTES
|
||||
|
||||
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build()
|
||||
private val DEFAULT_HEADERS = Headers.Builder().build()
|
||||
private val DEFAULT_BODY: RequestBody = FormBody.Builder().build()
|
||||
|
||||
fun GET(url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL): Request {
|
||||
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun POST(url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
body: RequestBody = DEFAULT_BODY,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL): Request {
|
||||
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.post(body)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
package eu.kanade.tachiyomi.source
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import rx.Observable
|
||||
|
||||
interface CatalogueSource : Source {
|
||||
|
||||
/**
|
||||
* An ISO 639-1 compliant language code (two letters in lower case).
|
||||
*/
|
||||
val lang: String
|
||||
|
||||
/**
|
||||
* Whether the source has support for latest updates.
|
||||
*/
|
||||
val supportsLatest: Boolean
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of manga.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
fun fetchPopularManga(page: Int): Observable<MangasPage>
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of manga.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
* @param query the search query.
|
||||
* @param filters the list of filters to apply.
|
||||
*/
|
||||
fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage>
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of latest manga updates.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
fun fetchLatestUpdates(page: Int): Observable<MangasPage>
|
||||
|
||||
/**
|
||||
* Returns the list of filters for the source.
|
||||
*/
|
||||
fun getFilterList(): FilterList
|
||||
}
|
318
app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt
Normal file
318
app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt
Normal file
@ -0,0 +1,318 @@
|
||||
package eu.kanade.tachiyomi.source
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.model.*
|
||||
import eu.kanade.tachiyomi.util.ChapterRecognition
|
||||
import eu.kanade.tachiyomi.util.DiskUtil
|
||||
import eu.kanade.tachiyomi.util.RarContentProvider
|
||||
import eu.kanade.tachiyomi.util.ZipContentProvider
|
||||
import junrar.Archive
|
||||
import junrar.rarfile.FileHeader
|
||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import rx.Observable
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.InputStream
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class LocalSource(private val context: Context) : CatalogueSource {
|
||||
companion object {
|
||||
private val COVER_NAME = "cover.jpg"
|
||||
private val POPULAR_FILTERS = FilterList(OrderBy())
|
||||
private val LATEST_FILTERS = FilterList(OrderBy().apply { state = Filter.Sort.Selection(1, false) })
|
||||
private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
|
||||
val ID = 0L
|
||||
|
||||
fun updateCover(context: Context, manga: SManga, input: InputStream): File? {
|
||||
val dir = getBaseDirectories(context).firstOrNull()
|
||||
if (dir == null) {
|
||||
input.close()
|
||||
return null
|
||||
}
|
||||
val cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME)
|
||||
|
||||
// It might not exist if using the external SD card
|
||||
cover.parentFile.mkdirs()
|
||||
input.use {
|
||||
cover.outputStream().use {
|
||||
input.copyTo(it)
|
||||
}
|
||||
}
|
||||
return cover
|
||||
}
|
||||
|
||||
private fun getBaseDirectories(context: Context): List<File> {
|
||||
val c = context.getString(R.string.app_name) + File.separator + "local"
|
||||
return DiskUtil.getExternalStorages(context).map { File(it.absolutePath, c) }
|
||||
}
|
||||
}
|
||||
|
||||
override val id = ID
|
||||
override val name = "LocalSource"
|
||||
override val lang = "en"
|
||||
override val supportsLatest = true
|
||||
|
||||
override fun toString() = context.getString(R.string.local_source)
|
||||
|
||||
override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS)
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
val baseDirs = getBaseDirectories(context)
|
||||
|
||||
val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
|
||||
var mangaDirs = baseDirs.mapNotNull { it.listFiles()?.toList() }
|
||||
.flatten()
|
||||
.filter { it.isDirectory && if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time }
|
||||
.distinctBy { it.name }
|
||||
|
||||
val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state
|
||||
when (state?.index) {
|
||||
0 -> {
|
||||
if (state!!.ascending)
|
||||
mangaDirs = mangaDirs.sortedBy { it.name.toLowerCase(Locale.ENGLISH) }
|
||||
else
|
||||
mangaDirs = mangaDirs.sortedByDescending { it.name.toLowerCase(Locale.ENGLISH) }
|
||||
}
|
||||
1 -> {
|
||||
if (state!!.ascending)
|
||||
mangaDirs = mangaDirs.sortedBy(File::lastModified)
|
||||
else
|
||||
mangaDirs = mangaDirs.sortedByDescending(File::lastModified)
|
||||
}
|
||||
}
|
||||
|
||||
val mangas = mangaDirs.map { mangaDir ->
|
||||
SManga.create().apply {
|
||||
title = mangaDir.name
|
||||
url = mangaDir.name
|
||||
|
||||
// Try to find the cover
|
||||
for (dir in baseDirs) {
|
||||
val cover = File("${dir.absolutePath}/$url", COVER_NAME)
|
||||
if (cover.exists()) {
|
||||
thumbnail_url = cover.absolutePath
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Copy the cover from the first chapter found.
|
||||
if (thumbnail_url == null) {
|
||||
val chapters = fetchChapterList(this).toBlocking().first()
|
||||
if (chapters.isNotEmpty()) {
|
||||
val uri = fetchPageList(chapters.last()).toBlocking().first().firstOrNull()?.uri
|
||||
if (uri != null) {
|
||||
val input = context.contentResolver.openInputStream(uri)
|
||||
try {
|
||||
val dest = updateCover(context, this, input)
|
||||
thumbnail_url = dest?.absolutePath
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
return Observable.just(MangasPage(mangas, false))
|
||||
}
|
||||
|
||||
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga) = Observable.just(manga)
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
val chapters = getBaseDirectories(context)
|
||||
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
|
||||
.flatten()
|
||||
.filter { it.isDirectory || isSupportedFormat(it.extension) }
|
||||
.map { chapterFile ->
|
||||
SChapter.create().apply {
|
||||
url = "${manga.url}/${chapterFile.name}"
|
||||
val chapName = if (chapterFile.isDirectory) {
|
||||
chapterFile.name
|
||||
} else {
|
||||
chapterFile.nameWithoutExtension
|
||||
}
|
||||
val chapNameCut = chapName.replace(manga.title, "", true).trim()
|
||||
name = if (chapNameCut.isEmpty()) chapName else chapNameCut
|
||||
date_upload = chapterFile.lastModified()
|
||||
ChapterRecognition.parseChapterNumber(this, manga)
|
||||
}
|
||||
}
|
||||
.sortedWith(Comparator<SChapter> { c1, c2 ->
|
||||
val c = c2.chapter_number.compareTo(c1.chapter_number)
|
||||
if (c == 0) comparator.compare(c2.name, c1.name) else c
|
||||
})
|
||||
|
||||
return Observable.just(chapters)
|
||||
}
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
val baseDirs = getBaseDirectories(context)
|
||||
|
||||
for (dir in baseDirs) {
|
||||
val chapFile = File(dir, chapter.url)
|
||||
if (!chapFile.exists()) continue
|
||||
|
||||
return Observable.just(getLoader(chapFile).load())
|
||||
}
|
||||
|
||||
return Observable.error(Exception("Chapter not found"))
|
||||
}
|
||||
|
||||
private fun isSupportedFormat(extension: String): Boolean {
|
||||
return extension.equals("zip", true) || extension.equals("cbz", true)
|
||||
|| extension.equals("rar", true) || extension.equals("cbr", true)
|
||||
|| extension.equals("epub", true)
|
||||
}
|
||||
|
||||
private fun getLoader(file: File): Loader {
|
||||
val extension = file.extension
|
||||
return if (file.isDirectory) {
|
||||
DirectoryLoader(file)
|
||||
} else if (extension.equals("zip", true) || extension.equals("cbz", true)) {
|
||||
ZipLoader(file)
|
||||
} else if (extension.equals("epub", true)) {
|
||||
EpubLoader(file)
|
||||
} else if (extension.equals("rar", true) || extension.equals("cbr", true)) {
|
||||
RarLoader(file)
|
||||
} else {
|
||||
throw Exception("Invalid chapter format")
|
||||
}
|
||||
}
|
||||
|
||||
private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true))
|
||||
|
||||
override fun getFilterList() = FilterList(OrderBy())
|
||||
|
||||
interface Loader {
|
||||
fun load(): List<Page>
|
||||
}
|
||||
|
||||
class DirectoryLoader(val file: File) : Loader {
|
||||
override fun load(): List<Page> {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
return file.listFiles()
|
||||
.filter { !it.isDirectory && DiskUtil.isImage(it.name, { FileInputStream(it) }) }
|
||||
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
||||
.map { Uri.fromFile(it) }
|
||||
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
|
||||
}
|
||||
}
|
||||
|
||||
class ZipLoader(val file: File) : Loader {
|
||||
override fun load(): List<Page> {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
return ZipFile(file).use { zip ->
|
||||
zip.entries().toList()
|
||||
.filter { !it.isDirectory && DiskUtil.isImage(it.name, { zip.getInputStream(it) }) }
|
||||
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
||||
.map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/${it.name}") }
|
||||
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RarLoader(val file: File) : Loader {
|
||||
override fun load(): List<Page> {
|
||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||
return Archive(file).use { archive ->
|
||||
archive.fileHeaders
|
||||
.filter { !it.isDirectory && DiskUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) }
|
||||
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
|
||||
.map { Uri.parse("content://${RarContentProvider.PROVIDER}${file.absolutePath}!-/${it.fileNameString}") }
|
||||
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EpubLoader(val file: File) : Loader {
|
||||
|
||||
override fun load(): List<Page> {
|
||||
ZipFile(file).use { zip ->
|
||||
val allEntries = zip.entries().toList()
|
||||
val ref = getPackageHref(zip)
|
||||
val doc = getPackageDocument(zip, ref)
|
||||
val pages = getPagesFromDocument(doc)
|
||||
val hrefs = getHrefMap(ref, allEntries.map { it.name })
|
||||
return getImagesFromPages(zip, pages, hrefs)
|
||||
.map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/$it") }
|
||||
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the package document.
|
||||
*/
|
||||
private fun getPackageHref(zip: ZipFile): String {
|
||||
val meta = zip.getEntry("META-INF/container.xml")
|
||||
if (meta != null) {
|
||||
val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") }
|
||||
val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path")
|
||||
if (path != null) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return "OEBPS/content.opf"
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the package document where all the files are listed.
|
||||
*/
|
||||
private fun getPackageDocument(zip: ZipFile, ref: String): Document {
|
||||
val entry = zip.getEntry(ref)
|
||||
return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the pages from the epub.
|
||||
*/
|
||||
private fun getPagesFromDocument(document: Document): List<String> {
|
||||
val pages = document.select("manifest > item")
|
||||
.filter { "application/xhtml+xml" == it.attr("media-type") }
|
||||
.associateBy { it.attr("id") }
|
||||
|
||||
val spine = document.select("spine > itemref").map { it.attr("idref") }
|
||||
return spine.mapNotNull { pages[it] }.map { it.attr("href") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the images contained in every page from the epub.
|
||||
*/
|
||||
private fun getImagesFromPages(zip: ZipFile, pages: List<String>, hrefs: Map<String, String>): List<String> {
|
||||
return pages.map { page ->
|
||||
val entry = zip.getEntry(hrefs[page])
|
||||
val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
|
||||
document.getElementsByTag("img").mapNotNull { hrefs[it.attr("src")] }
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map with a relative url as key and abolute url as path.
|
||||
*/
|
||||
private fun getHrefMap(packageHref: String, entries: List<String>): Map<String, String> {
|
||||
val lastSlashPos = packageHref.lastIndexOf('/')
|
||||
if (lastSlashPos < 0) {
|
||||
return entries.associateBy { it }
|
||||
}
|
||||
return entries.associateBy { entry ->
|
||||
if (entry.isNotBlank() && entry.length > lastSlashPos) {
|
||||
entry.substring(lastSlashPos + 1)
|
||||
} else {
|
||||
entry
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,50 +1,44 @@
|
||||
package eu.kanade.tachiyomi.data.source
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import rx.Observable
|
||||
|
||||
/**
|
||||
* A basic interface for creating a source. It could be an online source, a local source, etc...
|
||||
*/
|
||||
interface Source {
|
||||
|
||||
/**
|
||||
* Id for the source. Must be unique.
|
||||
*/
|
||||
val id: Int
|
||||
|
||||
/**
|
||||
* Name of the source.
|
||||
*/
|
||||
val name: String
|
||||
|
||||
/**
|
||||
* Returns an observable with the updated details for a manga.
|
||||
*
|
||||
* @param manga the manga to update.
|
||||
*/
|
||||
fun fetchMangaDetails(manga: Manga): Observable<Manga>
|
||||
|
||||
/**
|
||||
* Returns an observable with all the available chapters for a manga.
|
||||
*
|
||||
* @param manga the manga to update.
|
||||
*/
|
||||
fun fetchChapterList(manga: Manga): Observable<List<Chapter>>
|
||||
|
||||
/**
|
||||
* Returns an observable with the list of pages a chapter has.
|
||||
*
|
||||
* @param chapter the chapter.
|
||||
*/
|
||||
fun fetchPageList(chapter: Chapter): Observable<List<Page>>
|
||||
|
||||
/**
|
||||
* Returns an observable with the path of the image.
|
||||
*
|
||||
* @param page the page.
|
||||
*/
|
||||
fun fetchImage(page: Page): Observable<Page>
|
||||
package eu.kanade.tachiyomi.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
|
||||
|
||||
/**
|
||||
* A basic interface for creating a source. It could be an online source, a local source, etc...
|
||||
*/
|
||||
interface Source {
|
||||
|
||||
/**
|
||||
* Id for the source. Must be unique.
|
||||
*/
|
||||
val id: Long
|
||||
|
||||
/**
|
||||
* Name of the source.
|
||||
*/
|
||||
val name: String
|
||||
|
||||
/**
|
||||
* Returns an observable with the updated details for a manga.
|
||||
*
|
||||
* @param manga the manga to update.
|
||||
*/
|
||||
fun fetchMangaDetails(manga: SManga): Observable<SManga>
|
||||
|
||||
/**
|
||||
* Returns an observable with all the available chapters for a manga.
|
||||
*
|
||||
* @param manga the manga to update.
|
||||
*/
|
||||
fun fetchChapterList(manga: SManga): Observable<List<SChapter>>
|
||||
|
||||
/**
|
||||
* Returns an observable with the list of pages a chapter has.
|
||||
*
|
||||
* @param chapter the chapter.
|
||||
*/
|
||||
fun fetchPageList(chapter: SChapter): Observable<List<Page>>
|
||||
|
||||
}
|
140
app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt
Normal file
140
app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt
Normal file
@ -0,0 +1,140 @@
|
||||
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.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.source.online.YamlHttpSource
|
||||
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 org.yaml.snakeyaml.Yaml
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
|
||||
open class SourceManager(private val context: Context) {
|
||||
|
||||
private val sourcesMap = mutableMapOf<Long, Source>()
|
||||
|
||||
init {
|
||||
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) }
|
||||
}
|
||||
|
||||
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 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?")
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
var sourceClass = appInfo.metaData.getString(METADATA_SOURCE_CLASS)
|
||||
if (sourceClass.startsWith(".")) {
|
||||
sourceClass = pkgInfo.packageName + sourceClass
|
||||
}
|
||||
|
||||
val extension = Extension(extName, appInfo, version, sourceClass)
|
||||
try {
|
||||
val instance = loadExtension(extension)
|
||||
sources.add(instance)
|
||||
} catch (e: Exception) {
|
||||
Timber.e("Extension load error: $extName. Reason: ${e.message}")
|
||||
} catch (e: LinkageError) {
|
||||
Timber.e("Extension load error: $extName. Reason: ${e.message}")
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
private fun loadExtension(ext: Extension): 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 Class.forName(ext.sourceClass, false, classLoader).newInstance() as Source
|
||||
}
|
||||
|
||||
class Extension(val name: String,
|
||||
val appInfo: ApplicationInfo,
|
||||
val version: String,
|
||||
val sourceClass: 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
|
||||
}
|
||||
|
||||
}
|
40
app/src/main/java/eu/kanade/tachiyomi/source/model/Filter.kt
Normal file
40
app/src/main/java/eu/kanade/tachiyomi/source/model/Filter.kt
Normal file
@ -0,0 +1,40 @@
|
||||
package eu.kanade.tachiyomi.source.model
|
||||
|
||||
sealed class Filter<T>(val name: String, var state: T) {
|
||||
open class Header(name: String) : Filter<Any>(name, 0)
|
||||
open class Separator(name: String = "") : Filter<Any>(name, 0)
|
||||
abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(name, state)
|
||||
abstract class Text(name: String, state: String = "") : Filter<String>(name, state)
|
||||
abstract class CheckBox(name: String, state: Boolean = false) : Filter<Boolean>(name, state)
|
||||
abstract class TriState(name: String, state: Int = STATE_IGNORE) : Filter<Int>(name, state) {
|
||||
fun isIgnored() = state == STATE_IGNORE
|
||||
fun isIncluded() = state == STATE_INCLUDE
|
||||
fun isExcluded() = state == STATE_EXCLUDE
|
||||
|
||||
companion object {
|
||||
const val STATE_IGNORE = 0
|
||||
const val STATE_INCLUDE = 1
|
||||
const val STATE_EXCLUDE = 2
|
||||
}
|
||||
}
|
||||
abstract class Group<V>(name: String, state: List<V>): Filter<List<V>>(name, state)
|
||||
|
||||
abstract class Sort(name: String, val values: Array<String>, state: Selection? = null)
|
||||
: Filter<Sort.Selection?>(name, state) {
|
||||
data class Selection(val index: Int, val ascending: Boolean)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Filter<*>) return false
|
||||
|
||||
return name == other.name && state == other.state
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = name.hashCode()
|
||||
result = 31 * result + (state?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package eu.kanade.tachiyomi.source.model
|
||||
|
||||
data class FilterList(val list: List<Filter<*>>) : List<Filter<*>> by list {
|
||||
|
||||
constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList())
|
||||
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
package eu.kanade.tachiyomi.source.model
|
||||
|
||||
data class MangasPage(val mangas: List<SManga>, val hasNextPage: Boolean)
|
@ -1,47 +1,47 @@
|
||||
package eu.kanade.tachiyomi.data.source.model
|
||||
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.data.network.ProgressListener
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
|
||||
import rx.subjects.Subject
|
||||
|
||||
class Page(
|
||||
val index: Int,
|
||||
val url: String = "",
|
||||
var imageUrl: String? = null,
|
||||
@Transient var uri: Uri? = null
|
||||
) : ProgressListener {
|
||||
|
||||
val number: Int
|
||||
get() = index + 1
|
||||
|
||||
@Transient lateinit var chapter: ReaderChapter
|
||||
|
||||
@Transient @Volatile var status: Int = 0
|
||||
set(value) {
|
||||
field = value
|
||||
statusSubject?.onNext(value)
|
||||
}
|
||||
|
||||
@Transient @Volatile var progress: Int = 0
|
||||
|
||||
@Transient private var statusSubject: Subject<Int, Int>? = null
|
||||
|
||||
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
|
||||
progress = (100 * bytesRead / contentLength).toInt()
|
||||
}
|
||||
|
||||
fun setStatusSubject(subject: Subject<Int, Int>?) {
|
||||
this.statusSubject = subject
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val QUEUE = 0
|
||||
const val LOAD_PAGE = 1
|
||||
const val DOWNLOAD_IMAGE = 2
|
||||
const val READY = 3
|
||||
const val ERROR = 4
|
||||
}
|
||||
|
||||
}
|
||||
package eu.kanade.tachiyomi.source.model
|
||||
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.network.ProgressListener
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
|
||||
import rx.subjects.Subject
|
||||
|
||||
class Page(
|
||||
val index: Int,
|
||||
val url: String = "",
|
||||
var imageUrl: String? = null,
|
||||
@Transient var uri: Uri? = null
|
||||
) : ProgressListener {
|
||||
|
||||
val number: Int
|
||||
get() = index + 1
|
||||
|
||||
@Transient lateinit var chapter: ReaderChapter
|
||||
|
||||
@Transient @Volatile var status: Int = 0
|
||||
set(value) {
|
||||
field = value
|
||||
statusSubject?.onNext(value)
|
||||
}
|
||||
|
||||
@Transient @Volatile var progress: Int = 0
|
||||
|
||||
@Transient private var statusSubject: Subject<Int, Int>? = null
|
||||
|
||||
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
|
||||
progress = (100 * bytesRead / contentLength).toInt()
|
||||
}
|
||||
|
||||
fun setStatusSubject(subject: Subject<Int, Int>?) {
|
||||
this.statusSubject = subject
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val QUEUE = 0
|
||||
const val LOAD_PAGE = 1
|
||||
const val DOWNLOAD_IMAGE = 2
|
||||
const val READY = 3
|
||||
const val ERROR = 4
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
package eu.kanade.tachiyomi.source.model
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
interface SChapter : Serializable {
|
||||
|
||||
var url: String
|
||||
|
||||
var name: String
|
||||
|
||||
var date_upload: Long
|
||||
|
||||
var chapter_number: Float
|
||||
|
||||
fun copyFrom(other: SChapter) {
|
||||
name = other.name
|
||||
url = other.url
|
||||
date_upload = other.date_upload
|
||||
chapter_number = other.chapter_number
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(): SChapter {
|
||||
return SChapterImpl()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
package eu.kanade.tachiyomi.source.model
|
||||
|
||||
class SChapterImpl : SChapter {
|
||||
|
||||
override lateinit var url: String
|
||||
|
||||
override lateinit var name: String
|
||||
|
||||
override var date_upload: Long = 0
|
||||
|
||||
override var chapter_number: Float = -1f
|
||||
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user