mirror of
https://github.com/mihonapp/mihon.git
synced 2025-06-26 19:17:51 +02:00
Compare commits
277 Commits
Author | SHA1 | Date | |
---|---|---|---|
fab7967018 | |||
bb40a4d6b8 | |||
90d27147e6 | |||
634247c590 | |||
fd8f7ea693 | |||
5eeb497f2b | |||
505e642691 | |||
74a7e2a17e | |||
5fec956ce6 | |||
1794782323 | |||
0210ee8747 | |||
ca412832ef | |||
1089c25b8f | |||
e85841784c | |||
ca2236958a | |||
c7686323b7 | |||
73d1a1a05e | |||
211f7b591b | |||
72ea256906 | |||
f521622d4d | |||
dc5283ce9a | |||
256a4197c9 | |||
a5a12f8b3a | |||
bbe180ecd1 | |||
67678cd49e | |||
097d4fe34c | |||
5914346ace | |||
062788f222 | |||
55be9b9ca5 | |||
0da2f91771 | |||
ff190e02d4 | |||
29fd5747eb | |||
fa8f5bc0d8 | |||
2118434823 | |||
2eeac0bf8b | |||
89b293fecd | |||
3f758d5981 | |||
e838bb43d2 | |||
b7b83305b2 | |||
8df3080e0d | |||
f88794c752 | |||
cc9e2cee1f | |||
91cb892c74 | |||
a26f908370 | |||
4d14f56fa8 | |||
d9a2255be9 | |||
5e3d71c6c5 | |||
619d94bf36 | |||
6069659e0f | |||
f6a79bde6f | |||
bb9e230b35 | |||
bc9417e16b | |||
a4313d388d | |||
4ebb3a894d | |||
0642889b64 | |||
3094d084d6 | |||
f9fec74ffd | |||
8ef3ab0d49 | |||
e9a6f8ef46 | |||
68724752f8 | |||
de8fa09366 | |||
e619870eec | |||
4be5f0dab3 | |||
abe1929b49 | |||
68c4116327 | |||
3be9881997 | |||
2e44f29882 | |||
a5520c1936 | |||
112cdd54e3 | |||
b512c67b5d | |||
d8fa7bc9d2 | |||
41397ab41d | |||
c437f1473c | |||
6020cd011d | |||
582bb3e2ca | |||
5c67161dce | |||
c00eaae62b | |||
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 | |||
fd76255cf6 | |||
d180631877 | |||
1977e21363 | |||
e1a3ee1b81 | |||
cc43d9daed | |||
79705df499 | |||
36bbb906c1 | |||
816cc17ed3 | |||
97e3b5d2ab | |||
79ab9d80f2 | |||
32511149d1 | |||
cc9fd53abb | |||
4061c7450b | |||
9ad535bde6 | |||
b067096fc7 | |||
2dd58e5f7d | |||
7c42ab885b | |||
26b283d44d | |||
8c1b07c4ba | |||
f98e0858a7 | |||
8b60d5bfcb | |||
30b4c6e755 | |||
3d2a98451b | |||
aba528b227 | |||
d971768056 | |||
2e39be6625 | |||
f514d466a6 | |||
d10bf45283 | |||
a0064a1699 | |||
907472403d | |||
a9b6db9ee9 | |||
3e1dc9f400 | |||
d30c019b89 | |||
86b8712dd1 | |||
44241e03da | |||
12dcc2c31f | |||
bb89b72a81 | |||
ea790faeb3 | |||
4ef7b16925 | |||
93e244b4c4 | |||
87281d34c1 | |||
20041701cd | |||
f9c5379400 | |||
2a531f1a1e | |||
4d4b9c0d6d | |||
dc592e92b5 | |||
0db1a3167d | |||
830f792824 | |||
a13ebc3975 | |||
b28ef61618 | |||
6f297161de | |||
59c626b4a8 | |||
1d014a5a94 | |||
2dc8159d96 | |||
453f742732 | |||
5e6cf9fb02 | |||
83349fc72d | |||
979a5c8c16 | |||
9f625835ec | |||
5fd379e71b | |||
9c5b497751 | |||
4dc5f3e7d9 | |||
13954ffe01 | |||
36d4e1f7ef | |||
b716a2f8ac | |||
f98095e6cb | |||
d183aca810 | |||
52f4bddbce | |||
b837424f29 | |||
ba2a8c82f8 | |||
2856d9d6a3 | |||
71fac76e3d | |||
125f1ae34c | |||
b418169c20 | |||
f4d12ba622 | |||
c64d8c8b6b | |||
10a1ba95d6 | |||
27d3daf918 | |||
dcbd72e64d | |||
52e1e93f9d | |||
7d3d0999f3 | |||
93f90b5a62 | |||
c2b113ac0a | |||
8ff8ab4f27 | |||
414b8c9f21 | |||
4975787afa | |||
1210691fdd | |||
2a4527a8d6 | |||
2991906a85 | |||
5b1f4f189b | |||
d77a1e6925 | |||
19c713ebb2 | |||
90e0e0b72a | |||
22bbcaeed0 | |||
d7b8015df7 | |||
c1ac47e1ce | |||
e375101132 | |||
05b14bae7b | |||
eb15fe3898 | |||
4f5518bdd8 |
7
.github/CONTRIBUTING.md
vendored
7
.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
|
# Bugs
|
||||||
* Include version (Setting > About > Version)
|
* Include version (Setting > About > Version)
|
||||||
* If not latest, try updating, it may have already been solved
|
* If not latest, try updating, it may have already been solved
|
||||||
@ -27,5 +31,4 @@ DON'T: https://github.com/inorichi/tachiyomi/issues/75
|
|||||||
|
|
||||||
# Translations
|
# Translations
|
||||||
|
|
||||||
File `app/src/main/res/values/strings.xml` should be copied over to appropriate directories and then translated.
|
[Wiki](https://github.com/inorichi/tachiyomi/wiki/Translation)
|
||||||
Consult [Android.com](http://developer.android.com/training/basics/supporting-devices/languages.html#CreateDirs)
|
|
||||||
|
18
.travis.yml
18
.travis.yml
@ -5,20 +5,30 @@ android:
|
|||||||
- tools
|
- tools
|
||||||
|
|
||||||
# The BuildTools version used by your project
|
# The BuildTools version used by your project
|
||||||
- build-tools-24.0.2
|
- build-tools-25.0.1
|
||||||
- android-24
|
- android-25
|
||||||
- extra-android-m2repository
|
- extra-android-m2repository
|
||||||
- extra-google-m2repository
|
- extra-google-m2repository
|
||||||
- extra-android-support
|
- extra-android-support
|
||||||
- extra-google-google_play_services
|
- extra-google-google_play_services
|
||||||
|
|
||||||
|
licenses:
|
||||||
|
- android-sdk-license-.+
|
||||||
|
- '.+'
|
||||||
|
|
||||||
jdk:
|
jdk:
|
||||||
- oraclejdk8
|
- oraclejdk8
|
||||||
|
|
||||||
before_script:
|
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
|
#Build, and run tests
|
||||||
script: "./gradlew clean buildDebug"
|
script: "./gradlew clean buildStandardDebug"
|
||||||
sudo: false
|
sudo: false
|
||||||
|
|
||||||
before_cache:
|
before_cache:
|
||||||
|
@ -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)
|
## [Report an issue](https://github.com/inorichi/tachiyomi/blob/master/.github/CONTRIBUTING.md)
|
||||||
|
|
||||||
|
163
app/build.gradle
163
app/build.gradle
@ -28,32 +28,28 @@ ext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def includeUpdater() {
|
|
||||||
return hasProperty("include_updater")
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdkVersion 24
|
compileSdkVersion 25
|
||||||
buildToolsVersion "24.0.2"
|
buildToolsVersion "25.0.2"
|
||||||
publishNonDefault true
|
publishNonDefault true
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "eu.kanade.tachiyomi"
|
applicationId "eu.kanade.tachiyomi"
|
||||||
minSdkVersion 16
|
minSdkVersion 16
|
||||||
targetSdkVersion 24
|
targetSdkVersion 25
|
||||||
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
||||||
versionCode 12
|
versionCode 23
|
||||||
versionName "0.3.1"
|
versionName "0.6.0"
|
||||||
|
|
||||||
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
|
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
|
||||||
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
|
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
|
||||||
buildConfigField "String", "BUILD_TIME", "\"${getBuildTime()}\""
|
buildConfigField "String", "BUILD_TIME", "\"${getBuildTime()}\""
|
||||||
buildConfigField "boolean", "INCLUDE_UPDATER", "${includeUpdater()}"
|
buildConfigField "boolean", "INCLUDE_UPDATER", "false"
|
||||||
|
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
|
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters "armeabi", "armeabi-v7a", "x86"
|
abiFilters "armeabi-v7a", "arm64-v8a", "x86"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,6 +67,18 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
productFlavors {
|
||||||
|
standard {
|
||||||
|
buildConfigField "boolean", "INCLUDE_UPDATER", "true"
|
||||||
|
}
|
||||||
|
fdroid {
|
||||||
|
}
|
||||||
|
dev {
|
||||||
|
minSdkVersion 21
|
||||||
|
resConfigs "en", "xxhdpi"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
packagingOptions {
|
packagingOptions {
|
||||||
exclude 'META-INF/DEPENDENCIES'
|
exclude 'META-INF/DEPENDENCIES'
|
||||||
exclude 'LICENSE.txt'
|
exclude 'LICENSE.txt'
|
||||||
@ -84,20 +92,16 @@ android {
|
|||||||
checkReleaseBuilds false
|
checkReleaseBuilds false
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
main.java.srcDirs += 'src/main/kotlin'
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
||||||
// Modified dependencies
|
// Modified dependencies
|
||||||
compile 'com.github.inorichi:subsampling-scale-image-view:2d9c854'
|
compile 'com.github.inorichi:subsampling-scale-image-view:01e5385'
|
||||||
compile 'com.github.inorichi:ReactiveNetwork:69092ed'
|
compile 'com.github.inorichi:junrar-android:634c1f5'
|
||||||
|
|
||||||
// Android support library
|
// Android support library
|
||||||
final support_library_version = '24.2.1'
|
final support_library_version = '25.4.0'
|
||||||
compile "com.android.support:support-v4:$support_library_version"
|
compile "com.android.support:support-v4:$support_library_version"
|
||||||
compile "com.android.support:appcompat-v7:$support_library_version"
|
compile "com.android.support:appcompat-v7:$support_library_version"
|
||||||
compile "com.android.support:cardview-v7:$support_library_version"
|
compile "com.android.support:cardview-v7:$support_library_version"
|
||||||
@ -106,94 +110,114 @@ dependencies {
|
|||||||
compile "com.android.support:support-annotations:$support_library_version"
|
compile "com.android.support:support-annotations:$support_library_version"
|
||||||
compile "com.android.support:customtabs:$support_library_version"
|
compile "com.android.support:customtabs:$support_library_version"
|
||||||
|
|
||||||
compile 'com.android.support:multidex:1.0.1'
|
compile 'com.android.support.constraint:constraint-layout:1.0.2'
|
||||||
|
|
||||||
compile 'com.google.android.gms:play-services-gcm:9.6.1'
|
compile 'com.android.support:multidex:1.0.1'
|
||||||
|
|
||||||
// ReactiveX
|
// ReactiveX
|
||||||
compile 'io.reactivex:rxandroid:1.2.1'
|
compile 'io.reactivex:rxandroid:1.2.1'
|
||||||
compile 'io.reactivex:rxjava:1.2.1'
|
compile 'io.reactivex:rxjava:1.3.0'
|
||||||
|
compile 'com.jakewharton.rxrelay:rxrelay:1.2.0'
|
||||||
compile 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
|
compile 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
|
||||||
|
compile 'com.github.pwittchen:reactivenetwork:0.7.0'
|
||||||
|
|
||||||
// Network client
|
// Network client
|
||||||
compile "com.squareup.okhttp3:okhttp:3.4.1"
|
compile "com.squareup.okhttp3:okhttp:3.8.1"
|
||||||
|
compile 'com.squareup.okio:okio:1.13.0'
|
||||||
|
|
||||||
// REST
|
// REST
|
||||||
final retrofit_version = '2.1.0'
|
final retrofit_version = '2.3.0'
|
||||||
compile "com.squareup.retrofit2:retrofit:$retrofit_version"
|
compile "com.squareup.retrofit2:retrofit:$retrofit_version"
|
||||||
compile "com.squareup.retrofit2:converter-gson:$retrofit_version"
|
compile "com.squareup.retrofit2:converter-gson:$retrofit_version"
|
||||||
compile "com.squareup.retrofit2:adapter-rxjava:$retrofit_version"
|
compile "com.squareup.retrofit2:adapter-rxjava:$retrofit_version"
|
||||||
|
|
||||||
// IO
|
|
||||||
compile 'com.squareup.okio:okio:1.10.0'
|
|
||||||
|
|
||||||
// JSON
|
// JSON
|
||||||
compile 'com.google.code.gson:gson:2.7'
|
compile 'com.google.code.gson:gson:2.8.1'
|
||||||
compile 'com.github.salomonbrys.kotson:kotson:2.4.0'
|
compile 'com.github.salomonbrys.kotson:kotson:2.5.0'
|
||||||
|
|
||||||
// YAML
|
// YAML
|
||||||
compile 'com.github.bmoliveira:snake-yaml:v1.18-android'
|
compile 'com.github.bmoliveira:snake-yaml:v1.18-android'
|
||||||
|
|
||||||
// JavaScript engine
|
// JavaScript engine
|
||||||
compile 'com.squareup.duktape:duktape-android:1.0.0'
|
compile 'com.squareup.duktape:duktape-android:1.1.0'
|
||||||
|
|
||||||
// Disk cache
|
// Disk
|
||||||
compile 'com.jakewharton:disklrucache:2.0.2'
|
compile 'com.jakewharton:disklrucache:2.0.2'
|
||||||
|
compile 'com.github.seven332:unifile:1.0.0'
|
||||||
|
|
||||||
// Parse HTML
|
// HTML parser
|
||||||
compile 'org.jsoup:jsoup:1.9.2'
|
compile 'org.jsoup:jsoup:1.10.3'
|
||||||
|
|
||||||
|
// Job scheduling
|
||||||
|
compile 'com.evernote:android-job:1.1.11'
|
||||||
|
compile 'com.google.android.gms:play-services-gcm:11.0.1'
|
||||||
|
|
||||||
// Changelog
|
// Changelog
|
||||||
compile 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
|
compile 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
compile "com.pushtorefresh.storio:sqlite:1.11.0"
|
compile "com.pushtorefresh.storio:sqlite:1.13.0"
|
||||||
|
|
||||||
// Model View Presenter
|
// Model View Presenter
|
||||||
final nucleus_version = '3.0.0'
|
final nucleus_version = '3.0.0'
|
||||||
compile "info.android15.nucleus:nucleus:$nucleus_version"
|
compile "info.android15.nucleus:nucleus:$nucleus_version"
|
||||||
compile "info.android15.nucleus:nucleus-support-v4:$nucleus_version"
|
|
||||||
compile "info.android15.nucleus:nucleus-support-v7:$nucleus_version"
|
compile "info.android15.nucleus:nucleus-support-v7:$nucleus_version"
|
||||||
|
|
||||||
// Dependency injection
|
// Dependency injection
|
||||||
compile "uy.kohesive.injekt:injekt-core:1.16.1"
|
compile "uy.kohesive.injekt:injekt-core:1.16.1"
|
||||||
|
|
||||||
// Image library
|
// Image library
|
||||||
compile 'com.github.bumptech.glide:glide:3.7.0'
|
compile 'com.github.bumptech.glide:glide:3.8.0'
|
||||||
compile 'com.github.bumptech.glide:okhttp3-integration:1.4.0@aar'
|
compile 'com.github.bumptech.glide:okhttp3-integration:1.5.0@aar'
|
||||||
// Transformations
|
// Transformations
|
||||||
compile 'jp.wasabeef:glide-transformations:2.0.1'
|
compile 'jp.wasabeef:glide-transformations:2.0.2'
|
||||||
|
|
||||||
// Logging
|
// Logging
|
||||||
compile 'com.jakewharton.timber:timber:4.3.1'
|
compile 'com.jakewharton.timber:timber:4.5.1'
|
||||||
|
|
||||||
// Crash reports
|
// 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
|
// UI
|
||||||
compile 'com.dmitrymalkovich.android:material-design-dimens:1.4'
|
compile 'com.dmitrymalkovich.android:material-design-dimens:1.4'
|
||||||
compile 'com.github.dmytrodanylyk.android-process-button:library:1.0.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.nononsenseapps:filepicker:2.5.2'
|
compile 'com.nononsenseapps:filepicker:2.5.2'
|
||||||
compile 'com.github.amulyakhare:TextDrawable:558677e'
|
compile 'com.github.amulyakhare:TextDrawable:558677e'
|
||||||
compile 'com.afollestad.material-dialogs:core:0.9.0.2'
|
compile 'com.afollestad.material-dialogs:core:0.9.4.5'
|
||||||
compile 'net.xpece.android:support-preference:1.0.3'
|
|
||||||
compile 'me.zhanghai.android.systemuihelper:library:1.0.0'
|
compile 'me.zhanghai.android.systemuihelper:library:1.0.0'
|
||||||
compile 'de.hdodenhof:circleimageview:2.1.0'
|
compile 'de.hdodenhof:circleimageview:2.1.0'
|
||||||
|
compile 'com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.0.4'
|
||||||
|
|
||||||
|
// Conductor
|
||||||
|
compile "com.bluelinelabs:conductor:2.1.4"
|
||||||
|
compile 'com.github.inorichi:conductor-support-preference:9e36460'
|
||||||
|
|
||||||
|
// RxBindings
|
||||||
|
final rxbindings_version = '1.0.1'
|
||||||
|
compile "com.jakewharton.rxbinding:rxbinding-kotlin:$rxbindings_version"
|
||||||
|
compile "com.jakewharton.rxbinding:rxbinding-appcompat-v7-kotlin:$rxbindings_version"
|
||||||
|
compile "com.jakewharton.rxbinding:rxbinding-support-v4-kotlin:$rxbindings_version"
|
||||||
|
compile "com.jakewharton.rxbinding:rxbinding-recyclerview-v7-kotlin:$rxbindings_version"
|
||||||
|
|
||||||
// Tests
|
// Tests
|
||||||
testCompile 'junit:junit:4.12'
|
testCompile 'junit:junit:4.12'
|
||||||
testCompile 'org.assertj:assertj-core:1.7.1'
|
testCompile 'org.assertj:assertj-core:1.7.1'
|
||||||
testCompile 'org.mockito:mockito-core:1.10.19'
|
testCompile 'org.mockito:mockito-core:1.10.19'
|
||||||
testCompile 'org.robolectric:robolectric:3.1.2'
|
|
||||||
testCompile 'org.robolectric:shadows-multidex:3.1.2'
|
final robolectric_version = '3.1.4'
|
||||||
testCompile 'org.robolectric:shadows-play-services:3.1.2'
|
testCompile "org.robolectric:robolectric:$robolectric_version"
|
||||||
|
testCompile "org.robolectric:shadows-multidex:$robolectric_version"
|
||||||
|
testCompile "org.robolectric:shadows-play-services:$robolectric_version"
|
||||||
|
|
||||||
compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.0.4'
|
ext.kotlin_version = '1.1.3'
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
@ -205,3 +229,50 @@ buildscript {
|
|||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Workaround to force a support lib version
|
||||||
|
configurations.all {
|
||||||
|
resolutionStrategy.eachDependency { details ->
|
||||||
|
def requested = details.requested
|
||||||
|
if (requested.group == 'com.android.support') {
|
||||||
|
if (!requested.name.startsWith("multidex")) {
|
||||||
|
details.useVersion '25.4.0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add support for placeholders in resource files
|
||||||
|
//https://code.google.com/p/android/issues/detail?id=69224
|
||||||
|
def replacePlaceholdersInFile(basePath, fileName, placeholders) {
|
||||||
|
def file = new File(basePath, fileName);
|
||||||
|
|
||||||
|
if (!file.exists()) {
|
||||||
|
logger.quiet("Unable to replace placeholders in " + file.toString() + ". File cannot be found.")
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("Replacing placeholders in " + file.toString())
|
||||||
|
logger.debug("Placeholders: " + placeholders.toString())
|
||||||
|
|
||||||
|
def content = file.getText('UTF-8')
|
||||||
|
|
||||||
|
placeholders.each { entry ->
|
||||||
|
content = content.replaceAll("\\\$\\{${entry.key}\\}", entry.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
file.write(content, 'UTF-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEvaluate {
|
||||||
|
android.applicationVariants.all { variant ->
|
||||||
|
variant.outputs.each { output ->
|
||||||
|
output.processResources.doFirst {
|
||||||
|
// prepare placeholder map from manifestPlaceholders including applicationId placeholder
|
||||||
|
def placeholders = variant.mergedFlavor.manifestPlaceholders + [applicationId: variant.applicationId]
|
||||||
|
|
||||||
|
replacePlaceholdersInFile(resDir, 'xml-v25/shortcuts.xml', placeholders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
100
app/proguard-rules.pro
vendored
100
app/proguard-rules.pro
vendored
@ -1,20 +1,17 @@
|
|||||||
-dontobfuscate
|
-dontobfuscate
|
||||||
|
|
||||||
|
-dontwarn eu.kanade.tachiyomi.**
|
||||||
-keep class eu.kanade.tachiyomi.**
|
-keep class eu.kanade.tachiyomi.**
|
||||||
|
-keep class eu.kanade.tachiyomi.source.model.** { *; }
|
||||||
|
|
||||||
|
-keep class com.hippo.image.** { *; }
|
||||||
|
-keep interface com.hippo.image.** { *; }
|
||||||
|
|
||||||
# OkHttp
|
# OkHttp
|
||||||
-keepattributes Signature
|
|
||||||
-keepattributes *Annotation*
|
|
||||||
-keep class okhttp3.** { *; }
|
|
||||||
-keep interface okhttp3.** { *; }
|
|
||||||
-dontwarn okhttp3.**
|
-dontwarn okhttp3.**
|
||||||
-dontwarn okio.**
|
-dontwarn okio.**
|
||||||
|
-dontwarn javax.annotation.**
|
||||||
# Okio
|
-dontwarn retrofit2.Platform$Java8
|
||||||
-keep class sun.misc.Unsafe { *; }
|
|
||||||
-dontwarn java.nio.file.*
|
|
||||||
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
|
|
||||||
-dontwarn okio.**
|
|
||||||
|
|
||||||
# Glide specific rules #
|
# Glide specific rules #
|
||||||
# https://github.com/bumptech/glide
|
# https://github.com/bumptech/glide
|
||||||
@ -40,89 +37,46 @@
|
|||||||
rx.internal.util.atomic.LinkedQueueNode consumerNode;
|
rx.internal.util.atomic.LinkedQueueNode consumerNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Retrofit 2.X
|
### Support v7, Design
|
||||||
## https://square.github.io/retrofit/ ##
|
# http://stackoverflow.com/questions/29679177/cardview-shadow-not-appearing-in-lollipop-after-obfuscate-with-proguard/29698051
|
||||||
|
-keep class android.support.v7.widget.RoundRectDrawable { *; }
|
||||||
|
|
||||||
-dontwarn retrofit2.**
|
|
||||||
-keep class retrofit2.** { *; }
|
|
||||||
-keepattributes Signature
|
|
||||||
-keepattributes Exceptions
|
|
||||||
|
|
||||||
-keepclasseswithmembers class * {
|
|
||||||
@retrofit2.http.* <methods>;
|
|
||||||
}
|
|
||||||
|
|
||||||
# AppCombat
|
|
||||||
-keep public class android.support.v7.widget.** { *; }
|
-keep public class android.support.v7.widget.** { *; }
|
||||||
-keep public class android.support.v7.internal.widget.** { *; }
|
-keep public class android.support.v7.internal.widget.** { *; }
|
||||||
-keep public class android.support.v7.internal.view.menu.** { *; }
|
-keep public class android.support.v7.internal.view.menu.** { *; }
|
||||||
|
-keep public class android.support.v7.graphics.drawable.** { *; }
|
||||||
|
|
||||||
-keep public class * extends android.support.v4.view.ActionProvider {
|
-keep public class * extends android.support.v4.view.ActionProvider {
|
||||||
public <init>(android.content.Context);
|
public <init>(android.content.Context);
|
||||||
}
|
}
|
||||||
|
|
||||||
## GSON 2.2.4 specific rules ##
|
-dontwarn android.support.**
|
||||||
|
-dontwarn android.support.design.**
|
||||||
|
-keep class android.support.design.** { *; }
|
||||||
|
-keep interface android.support.design.** { *; }
|
||||||
|
-keep public class android.support.design.R$* { *; }
|
||||||
|
|
||||||
|
|
||||||
|
# ReactiveNetwork
|
||||||
|
-dontwarn com.github.pwittchen.reactivenetwork.**
|
||||||
|
|
||||||
|
## GSON ##
|
||||||
|
|
||||||
# Gson uses generic type information stored in a class file when working with fields. Proguard
|
# Gson uses generic type information stored in a class file when working with fields. Proguard
|
||||||
# removes such information by default, so configure it to keep all of it.
|
# removes such information by default, so configure it to keep all of it.
|
||||||
-keepattributes Signature
|
-keepattributes Signature
|
||||||
|
|
||||||
# For using GSON @Expose annotation
|
|
||||||
-keepattributes *Annotation*
|
|
||||||
|
|
||||||
-keepattributes EnclosingMethod
|
|
||||||
|
|
||||||
# Gson specific classes
|
# Gson specific classes
|
||||||
-keep class sun.misc.Unsafe { *; }
|
-keep class sun.misc.Unsafe { *; }
|
||||||
-keep class com.google.gson.stream.** { *; }
|
|
||||||
|
|
||||||
## ACRA 4.5.0 specific rules ##
|
# Prevent proguard from stripping interface information from TypeAdapterFactory,
|
||||||
|
# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
|
||||||
# we need line numbers in our stack traces otherwise they are pretty useless
|
-keep class * implements com.google.gson.TypeAdapterFactory
|
||||||
-renamesourcefileattribute SourceFile
|
-keep class * implements com.google.gson.JsonSerializer
|
||||||
-keepattributes SourceFile,LineNumberTable
|
-keep class * implements com.google.gson.JsonDeserializer
|
||||||
|
|
||||||
# ACRA needs "annotations" so add this...
|
|
||||||
-keepattributes *Annotation*
|
|
||||||
|
|
||||||
# keep this class so that logging will show 'ACRA' and not a obfuscated name like 'a'.
|
|
||||||
# Note: if you are removing log messages elsewhere in this file then this isn't necessary
|
|
||||||
-keep class org.acra.ACRA {
|
|
||||||
*;
|
|
||||||
}
|
|
||||||
|
|
||||||
# keep this around for some enums that ACRA needs
|
|
||||||
-keep class org.acra.ReportingInteractionMode {
|
|
||||||
*;
|
|
||||||
}
|
|
||||||
|
|
||||||
-keepnames class org.acra.sender.HttpSender$** {
|
|
||||||
*;
|
|
||||||
}
|
|
||||||
|
|
||||||
-keepnames class org.acra.ReportField {
|
|
||||||
*;
|
|
||||||
}
|
|
||||||
|
|
||||||
# keep this otherwise it is removed by ProGuard
|
|
||||||
-keep public class org.acra.ErrorReporter {
|
|
||||||
public void addCustomData(java.lang.String,java.lang.String);
|
|
||||||
public void putCustomData(java.lang.String,java.lang.String);
|
|
||||||
public void removeCustomData(java.lang.String);
|
|
||||||
}
|
|
||||||
|
|
||||||
# keep this otherwise it is removed by ProGuard
|
|
||||||
-keep public class org.acra.ErrorReporter {
|
|
||||||
public void handleSilentException(java.lang.Throwable);
|
|
||||||
}
|
|
||||||
|
|
||||||
# Keep the support library
|
|
||||||
-keep class org.acra.** { *; }
|
|
||||||
-keep interface org.acra.** { *; }
|
|
||||||
|
|
||||||
# SnakeYaml
|
# SnakeYaml
|
||||||
-keep class org.yaml.snakeyaml.** { public protected private *; }
|
-keep class org.yaml.snakeyaml.** { public protected private *; }
|
||||||
-keep class org.yaml.snakeyaml.** { public protected private *; }
|
|
||||||
-dontwarn org.yaml.snakeyaml.**
|
-dontwarn org.yaml.snakeyaml.**
|
||||||
|
|
||||||
# Duktape
|
# Duktape
|
||||||
|
@ -1,16 +1,13 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
package="eu.kanade.tachiyomi">
|
package="eu.kanade.tachiyomi">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<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.WRITE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
<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" />
|
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
@ -18,77 +15,86 @@
|
|||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:largeHeap="true"
|
android:largeHeap="true"
|
||||||
android:theme="@style/Theme.Tachiyomi" >
|
android:theme="@style/Theme.Tachiyomi">
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.main.MainActivity">
|
android:name=".ui.main.MainActivity"
|
||||||
|
android:launchMode="singleTask">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
<meta-data android:name="android.app.shortcuts"
|
||||||
<activity
|
android:resource="@xml/shortcuts"/>
|
||||||
android:name=".ui.manga.MangaActivity"
|
|
||||||
android:parentActivityName=".ui.main.MainActivity"
|
|
||||||
android:exported="true">
|
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.reader.ReaderActivity"
|
android:name=".ui.reader.ReaderActivity"
|
||||||
android:theme="@style/Theme.Reader">
|
android:theme="@style/Theme.Reader" />
|
||||||
</activity>
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.setting.SettingsActivity"
|
android:name=".widget.CustomLayoutPickerActivity"
|
||||||
android:label="@string/label_settings"
|
|
||||||
android:parentActivityName=".ui.main.MainActivity" >
|
|
||||||
</activity>
|
|
||||||
<activity
|
|
||||||
android:name=".ui.category.CategoryActivity"
|
|
||||||
android:label="@string/label_categories"
|
|
||||||
android:parentActivityName=".ui.main.MainActivity">
|
|
||||||
</activity>
|
|
||||||
<activity
|
|
||||||
android:name=".ui.setting.SettingsDownloadsFragment$CustomLayoutPickerActivity"
|
|
||||||
android:label="@string/app_name"
|
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>
|
||||||
|
|
||||||
<service android:name=".data.library.LibraryUpdateService"
|
<provider
|
||||||
android:exported="false"/>
|
android:name="android.support.v4.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.provider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/provider_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
<service android:name=".data.download.DownloadService"
|
<provider
|
||||||
android:exported="false"/>
|
android:name="eu.kanade.tachiyomi.util.ZipContentProvider"
|
||||||
|
android:authorities="${applicationId}.zip-provider"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<service android:name=".data.mangasync.UpdateMangaSyncService"
|
<provider
|
||||||
android:exported="false"/>
|
android:name="eu.kanade.tachiyomi.util.RarContentProvider"
|
||||||
|
android:authorities="${applicationId}.rar-provider"
|
||||||
<service
|
android:exported="false" />
|
||||||
android:name=".data.library.LibraryUpdateTrigger"
|
|
||||||
android:exported="true"
|
|
||||||
android:permission="com.google.android.gms.permission.BIND_NETWORK_TASK_SERVICE">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="com.google.android.gms.gcm.ACTION_TASK_READY" />
|
|
||||||
</intent-filter>
|
|
||||||
</service>
|
|
||||||
|
|
||||||
<service
|
|
||||||
android:name=".data.updater.UpdateCheckerService"
|
|
||||||
android:exported="true"
|
|
||||||
android:permission="com.google.android.gms.permission.BIND_NETWORK_TASK_SERVICE">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="com.google.android.gms.gcm.ACTION_TASK_READY" />
|
|
||||||
</intent-filter>
|
|
||||||
</service>
|
|
||||||
|
|
||||||
<service android:name=".data.updater.UpdateDownloaderService"
|
|
||||||
android:exported="false"/>
|
|
||||||
|
|
||||||
<receiver android:name=".data.updater.UpdateNotificationReceiver"/>
|
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".data.library.LibraryUpdateService$CancelUpdateReceiver">
|
android:name=".data.notification.NotificationReceiver"
|
||||||
</receiver>
|
android:exported="false" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".data.library.LibraryUpdateService"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".data.download.DownloadService"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".data.updater.UpdateDownloaderService"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".data.backup.BackupCreateService"
|
||||||
|
android:exported="false"/>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".data.backup.BackupRestoreService"
|
||||||
|
android:exported="false"/>
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule"
|
android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule"
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 22 KiB |
@ -2,7 +2,13 @@ package eu.kanade.tachiyomi
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.support.multidex.MultiDex
|
import android.support.multidex.MultiDex
|
||||||
|
import com.evernote.android.job.JobManager
|
||||||
|
import eu.kanade.tachiyomi.data.backup.BackupCreatorJob
|
||||||
|
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
||||||
|
import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob
|
||||||
|
import eu.kanade.tachiyomi.util.LocaleHelper
|
||||||
import org.acra.ACRA
|
import org.acra.ACRA
|
||||||
import org.acra.annotation.ReportsCrashes
|
import org.acra.annotation.ReportsCrashes
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@ -15,7 +21,7 @@ import uy.kohesive.injekt.registry.default.DefaultRegistrar
|
|||||||
reportType = org.acra.sender.HttpSender.Type.JSON,
|
reportType = org.acra.sender.HttpSender.Type.JSON,
|
||||||
httpMethod = org.acra.sender.HttpSender.Method.PUT,
|
httpMethod = org.acra.sender.HttpSender.Method.PUT,
|
||||||
buildConfigClass = BuildConfig::class,
|
buildConfigClass = BuildConfig::class,
|
||||||
excludeMatchingSharedPreferencesKeys = arrayOf(".*username.*", ".*password.*")
|
excludeMatchingSharedPreferencesKeys = arrayOf(".*username.*", ".*password.*", ".*token.*")
|
||||||
)
|
)
|
||||||
open class App : Application() {
|
open class App : Application() {
|
||||||
|
|
||||||
@ -27,6 +33,9 @@ open class App : Application() {
|
|||||||
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
|
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
|
||||||
|
|
||||||
setupAcra()
|
setupAcra()
|
||||||
|
setupJobManager()
|
||||||
|
|
||||||
|
LocaleHelper.updateConfiguration(this, resources.configuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun attachBaseContext(base: Context) {
|
override fun attachBaseContext(base: Context) {
|
||||||
@ -36,8 +45,24 @@ open class App : Application() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||||
|
super.onConfigurationChanged(newConfig)
|
||||||
|
LocaleHelper.updateConfiguration(this, newConfig, true)
|
||||||
|
}
|
||||||
|
|
||||||
protected open fun setupAcra() {
|
protected open fun setupAcra() {
|
||||||
ACRA.init(this)
|
ACRA.init(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected open fun setupJobManager() {
|
||||||
|
JobManager.create(this).addJobCreator { tag ->
|
||||||
|
when (tag) {
|
||||||
|
LibraryUpdateJob.TAG -> LibraryUpdateJob()
|
||||||
|
UpdateCheckerJob.TAG -> UpdateCheckerJob()
|
||||||
|
BackupCreatorJob.TAG -> BackupCreatorJob()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -6,10 +6,10 @@ import eu.kanade.tachiyomi.data.cache.ChapterCache
|
|||||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
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.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.InjektModule
|
||||||
import uy.kohesive.injekt.api.InjektRegistrar
|
import uy.kohesive.injekt.api.InjektRegistrar
|
||||||
import uy.kohesive.injekt.api.addSingletonFactory
|
import uy.kohesive.injekt.api.addSingletonFactory
|
||||||
@ -32,7 +32,7 @@ class AppModule(val app: Application) : InjektModule {
|
|||||||
|
|
||||||
addSingletonFactory { DownloadManager(app) }
|
addSingletonFactory { DownloadManager(app) }
|
||||||
|
|
||||||
addSingletonFactory { MangaSyncManager(app) }
|
addSingletonFactory { TrackManager(app) }
|
||||||
|
|
||||||
addSingletonFactory { Gson() }
|
addSingletonFactory { Gson() }
|
||||||
|
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
package eu.kanade.tachiyomi
|
package eu.kanade.tachiyomi
|
||||||
|
|
||||||
object Constants {
|
object Constants {
|
||||||
const val NOTIFICATION_LIBRARY_ID = 1
|
const val NOTIFICATION_LIBRARY_PROGRESS_ID = 1
|
||||||
const val NOTIFICATION_UPDATER_ID = 2
|
const val NOTIFICATION_LIBRARY_RESULT_ID = 2
|
||||||
const val NOTIFICATION_DOWNLOAD_CHAPTER_ID = 3
|
const val NOTIFICATION_UPDATER_ID = 3
|
||||||
const val NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID = 4
|
const val NOTIFICATION_DOWNLOAD_CHAPTER_ID = 4
|
||||||
|
const val NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID = 5
|
||||||
|
const val NOTIFICATION_DOWNLOAD_IMAGE_ID = 6
|
||||||
}
|
}
|
||||||
|
53
app/src/main/java/eu/kanade/tachiyomi/Migrations.kt
Normal file
53
app/src/main/java/eu/kanade/tachiyomi/Migrations.kt
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
package eu.kanade.tachiyomi
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
|
import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object Migrations {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a migration when the application is updated.
|
||||||
|
*
|
||||||
|
* @param preferences Preferences of the application.
|
||||||
|
* @return true if a migration is performed, false otherwise.
|
||||||
|
*/
|
||||||
|
fun upgrade(preferences: PreferencesHelper): Boolean {
|
||||||
|
val context = preferences.context
|
||||||
|
val oldVersion = preferences.lastVersionCode().getOrDefault()
|
||||||
|
if (oldVersion < BuildConfig.VERSION_CODE) {
|
||||||
|
preferences.lastVersionCode().set(BuildConfig.VERSION_CODE)
|
||||||
|
|
||||||
|
if (oldVersion == 0) return false
|
||||||
|
|
||||||
|
if (oldVersion < 14) {
|
||||||
|
// Restore jobs after upgrading to evernote's job scheduler.
|
||||||
|
if (BuildConfig.INCLUDE_UPDATER && preferences.automaticUpdates()) {
|
||||||
|
UpdateCheckerJob.setupTask()
|
||||||
|
}
|
||||||
|
LibraryUpdateJob.setupTask()
|
||||||
|
}
|
||||||
|
if (oldVersion < 15) {
|
||||||
|
// Delete internal chapter cache dir.
|
||||||
|
File(context.cacheDir, "chapter_disk_cache").deleteRecursively()
|
||||||
|
}
|
||||||
|
if (oldVersion < 19) {
|
||||||
|
// Move covers to external files dir.
|
||||||
|
val oldDir = File(context.externalCacheDir, "cover_disk_cache")
|
||||||
|
if (oldDir.exists()) {
|
||||||
|
val destDir = context.getExternalFilesDir("covers")
|
||||||
|
if (destDir != null) {
|
||||||
|
oldDir.listFiles().forEach {
|
||||||
|
it.renameTo(File(destDir, it.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.backup
|
||||||
|
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
||||||
|
|
||||||
|
|
||||||
|
object BackupConst {
|
||||||
|
|
||||||
|
const val INTENT_FILTER = "SettingsBackupFragment"
|
||||||
|
const val ACTION_BACKUP_COMPLETED_DIALOG = "$ID.$INTENT_FILTER.ACTION_BACKUP_COMPLETED_DIALOG"
|
||||||
|
const val ACTION_SET_PROGRESS_DIALOG = "$ID.$INTENT_FILTER.ACTION_SET_PROGRESS_DIALOG"
|
||||||
|
const val ACTION_ERROR_BACKUP_DIALOG = "$ID.$INTENT_FILTER.ACTION_ERROR_BACKUP_DIALOG"
|
||||||
|
const val ACTION_ERROR_RESTORE_DIALOG = "$ID.$INTENT_FILTER.ACTION_ERROR_RESTORE_DIALOG"
|
||||||
|
const val ACTION_RESTORE_COMPLETED_DIALOG = "$ID.$INTENT_FILTER.ACTION_RESTORE_COMPLETED_DIALOG"
|
||||||
|
const val ACTION = "$ID.$INTENT_FILTER.ACTION"
|
||||||
|
const val EXTRA_PROGRESS = "$ID.$INTENT_FILTER.EXTRA_PROGRESS"
|
||||||
|
const val EXTRA_AMOUNT = "$ID.$INTENT_FILTER.EXTRA_AMOUNT"
|
||||||
|
const val EXTRA_ERRORS = "$ID.$INTENT_FILTER.EXTRA_ERRORS"
|
||||||
|
const val EXTRA_CONTENT = "$ID.$INTENT_FILTER.EXTRA_CONTENT"
|
||||||
|
const val EXTRA_ERROR_MESSAGE = "$ID.$INTENT_FILTER.EXTRA_ERROR_MESSAGE"
|
||||||
|
const val EXTRA_URI = "$ID.$INTENT_FILTER.EXTRA_URI"
|
||||||
|
const val EXTRA_TIME = "$ID.$INTENT_FILTER.EXTRA_TIME"
|
||||||
|
const val EXTRA_ERROR_FILE_PATH = "$ID.$INTENT_FILTER.EXTRA_ERROR_FILE_PATH"
|
||||||
|
const val EXTRA_ERROR_FILE = "$ID.$INTENT_FILTER.EXTRA_ERROR_FILE"
|
||||||
|
}
|
@ -0,0 +1,164 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.backup
|
||||||
|
|
||||||
|
import android.app.IntentService
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import com.github.salomonbrys.kotson.set
|
||||||
|
import com.google.gson.JsonArray
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
import com.hippo.unifile.UniFile
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGAS
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
import eu.kanade.tachiyomi.util.sendLocalBroadcast
|
||||||
|
import timber.log.Timber
|
||||||
|
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [IntentService] used to backup [Manga] information to [JsonArray]
|
||||||
|
*/
|
||||||
|
class BackupCreateService : IntentService(NAME) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Name of class
|
||||||
|
private const val NAME = "BackupCreateService"
|
||||||
|
|
||||||
|
// Backup called from job
|
||||||
|
private const val EXTRA_IS_JOB = "$ID.$NAME.EXTRA_IS_JOB"
|
||||||
|
// Options for backup
|
||||||
|
private const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS"
|
||||||
|
|
||||||
|
// Filter options
|
||||||
|
internal const val BACKUP_CATEGORY = 0x1
|
||||||
|
internal const val BACKUP_CATEGORY_MASK = 0x1
|
||||||
|
internal const val BACKUP_CHAPTER = 0x2
|
||||||
|
internal const val BACKUP_CHAPTER_MASK = 0x2
|
||||||
|
internal const val BACKUP_HISTORY = 0x4
|
||||||
|
internal const val BACKUP_HISTORY_MASK = 0x4
|
||||||
|
internal const val BACKUP_TRACK = 0x8
|
||||||
|
internal const val BACKUP_TRACK_MASK = 0x8
|
||||||
|
internal const val BACKUP_ALL = 0xF
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a backup from library
|
||||||
|
*
|
||||||
|
* @param context context of application
|
||||||
|
* @param path path of Uri
|
||||||
|
* @param flags determines what to backup
|
||||||
|
* @param isJob backup called from job
|
||||||
|
*/
|
||||||
|
fun makeBackup(context: Context, uri: Uri, flags: Int, isJob: Boolean = false) {
|
||||||
|
val intent = Intent(context, BackupCreateService::class.java).apply {
|
||||||
|
putExtra(BackupConst.EXTRA_URI, uri)
|
||||||
|
putExtra(EXTRA_IS_JOB, isJob)
|
||||||
|
putExtra(EXTRA_FLAGS, flags)
|
||||||
|
}
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private val backupManager by lazy { BackupManager(this) }
|
||||||
|
|
||||||
|
override fun onHandleIntent(intent: Intent?) {
|
||||||
|
if (intent == null) return
|
||||||
|
|
||||||
|
// Get values
|
||||||
|
val uri = intent.getParcelableExtra<Uri>(BackupConst.EXTRA_URI)
|
||||||
|
val isJob = intent.getBooleanExtra(EXTRA_IS_JOB, false)
|
||||||
|
val flags = intent.getIntExtra(EXTRA_FLAGS, 0)
|
||||||
|
// Create backup
|
||||||
|
createBackupFromApp(uri, flags, isJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create backup Json file from database
|
||||||
|
*
|
||||||
|
* @param uri path of Uri
|
||||||
|
* @param isJob backup called from job
|
||||||
|
*/
|
||||||
|
fun createBackupFromApp(uri: Uri, flags: Int, isJob: Boolean) {
|
||||||
|
// Create root object
|
||||||
|
val root = JsonObject()
|
||||||
|
|
||||||
|
// Create information object
|
||||||
|
val information = JsonObject()
|
||||||
|
|
||||||
|
// Create manga array
|
||||||
|
val mangaEntries = JsonArray()
|
||||||
|
|
||||||
|
// Create category array
|
||||||
|
val categoryEntries = JsonArray()
|
||||||
|
|
||||||
|
// Add value's to root
|
||||||
|
root[VERSION] = Backup.CURRENT_VERSION
|
||||||
|
root[MANGAS] = mangaEntries
|
||||||
|
root[CATEGORIES] = categoryEntries
|
||||||
|
|
||||||
|
backupManager.databaseHelper.inTransaction {
|
||||||
|
// Get manga from database
|
||||||
|
val mangas = backupManager.getFavoriteManga()
|
||||||
|
|
||||||
|
// Backup library manga and its dependencies
|
||||||
|
mangas.forEach { manga ->
|
||||||
|
mangaEntries.add(backupManager.backupMangaObject(manga, flags))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup categories
|
||||||
|
if ((flags and BACKUP_CATEGORY_MASK) == BACKUP_CATEGORY) {
|
||||||
|
backupManager.backupCategories(categoryEntries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// When BackupCreatorJob
|
||||||
|
if (isJob) {
|
||||||
|
// Get dir of file
|
||||||
|
val dir = UniFile.fromUri(this, uri)
|
||||||
|
|
||||||
|
// Delete older backups
|
||||||
|
val numberOfBackups = backupManager.numberOfBackups()
|
||||||
|
val backupRegex = Regex("""tachiyomi_\d+-\d+-\d+_\d+-\d+.json""")
|
||||||
|
dir.listFiles { _, filename -> backupRegex.matches(filename) }
|
||||||
|
.orEmpty()
|
||||||
|
.sortedByDescending { it.name }
|
||||||
|
.drop(numberOfBackups - 1)
|
||||||
|
.forEach { it.delete() }
|
||||||
|
|
||||||
|
// Create new file to place backup
|
||||||
|
val newFile = dir.createFile(Backup.getDefaultFilename())
|
||||||
|
?: throw Exception("Couldn't create backup file")
|
||||||
|
|
||||||
|
newFile.openOutputStream().bufferedWriter().use {
|
||||||
|
backupManager.parser.toJson(root, it)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val file = UniFile.fromUri(this, uri)
|
||||||
|
?: throw Exception("Couldn't create backup file")
|
||||||
|
file.openOutputStream().bufferedWriter().use {
|
||||||
|
backupManager.parser.toJson(root, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show completed dialog
|
||||||
|
val intent = Intent(BackupConst.INTENT_FILTER).apply {
|
||||||
|
putExtra(BackupConst.ACTION, BackupConst.ACTION_BACKUP_COMPLETED_DIALOG)
|
||||||
|
putExtra(BackupConst.EXTRA_URI, file.uri.toString())
|
||||||
|
}
|
||||||
|
sendLocalBroadcast(intent)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e)
|
||||||
|
if (!isJob) {
|
||||||
|
// Show error dialog
|
||||||
|
val intent = Intent(BackupConst.INTENT_FILTER).apply {
|
||||||
|
putExtra(BackupConst.ACTION, BackupConst.ACTION_ERROR_BACKUP_DIALOG)
|
||||||
|
putExtra(BackupConst.EXTRA_ERROR_MESSAGE, e.message)
|
||||||
|
}
|
||||||
|
sendLocalBroadcast(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,43 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.backup
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.evernote.android.job.Job
|
||||||
|
import com.evernote.android.job.JobManager
|
||||||
|
import com.evernote.android.job.JobRequest
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class BackupCreatorJob : Job() {
|
||||||
|
|
||||||
|
override fun onRunJob(params: Params): Result {
|
||||||
|
val preferences = Injekt.get<PreferencesHelper>()
|
||||||
|
val uri = Uri.fromFile(File(preferences.backupsDirectory().getOrDefault()))
|
||||||
|
val flags = BackupCreateService.BACKUP_ALL
|
||||||
|
BackupCreateService.makeBackup(context, uri, flags, true)
|
||||||
|
return Result.SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "BackupCreator"
|
||||||
|
|
||||||
|
fun setupTask(prefInterval: Int? = null) {
|
||||||
|
val preferences = Injekt.get<PreferencesHelper>()
|
||||||
|
val interval = prefInterval ?: preferences.backupInterval().getOrDefault()
|
||||||
|
if (interval > 0) {
|
||||||
|
JobRequest.Builder(TAG)
|
||||||
|
.setPeriodic(interval * 60 * 60 * 1000L, 10 * 60 * 1000)
|
||||||
|
.setPersisted(true)
|
||||||
|
.setUpdateCurrent(true)
|
||||||
|
.build()
|
||||||
|
.schedule()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelTask() {
|
||||||
|
JobManager.instance().cancelAllForTag(TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,199 +1,212 @@
|
|||||||
package eu.kanade.tachiyomi.data.backup
|
package eu.kanade.tachiyomi.data.backup
|
||||||
|
|
||||||
import com.github.salomonbrys.kotson.fromJson
|
import android.content.Context
|
||||||
|
import com.github.salomonbrys.kotson.*
|
||||||
import com.google.gson.*
|
import com.google.gson.*
|
||||||
import com.google.gson.stream.JsonReader
|
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY
|
||||||
import eu.kanade.tachiyomi.data.backup.serializer.IdExclusion
|
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY_MASK
|
||||||
import eu.kanade.tachiyomi.data.backup.serializer.IntegerSerializer
|
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER
|
||||||
|
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER_MASK
|
||||||
|
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY
|
||||||
|
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY_MASK
|
||||||
|
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK
|
||||||
|
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK_MASK
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup.CURRENT_VERSION
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.DHistory
|
||||||
|
import eu.kanade.tachiyomi.data.backup.serializer.*
|
||||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||||
import eu.kanade.tachiyomi.data.database.models.*
|
import eu.kanade.tachiyomi.data.database.models.*
|
||||||
import java.io.*
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import java.util.*
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
|
import eu.kanade.tachiyomi.source.Source
|
||||||
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
|
import eu.kanade.tachiyomi.util.syncChaptersWithSource
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
/**
|
class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
|
||||||
* This class provides the necessary methods to create and restore backups for the data of the
|
|
||||||
* application. The backup follows a JSON structure, with the following scheme:
|
|
||||||
*
|
|
||||||
* {
|
|
||||||
* "mangas": [
|
|
||||||
* {
|
|
||||||
* "manga": {"id": 1, ...},
|
|
||||||
* "chapters": [{"id": 1, ...}, {...}],
|
|
||||||
* "sync": [{"id": 1, ...}, {...}],
|
|
||||||
* "categories": ["cat1", "cat2", ...]
|
|
||||||
* },
|
|
||||||
* { ... }
|
|
||||||
* ],
|
|
||||||
* "categories": [
|
|
||||||
* {"id": 1, ...},
|
|
||||||
* {"id": 2, ...}
|
|
||||||
* ]
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* @param db the database helper.
|
|
||||||
*/
|
|
||||||
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 CATEGORIES = "categories"
|
|
||||||
|
|
||||||
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
|
|
||||||
private val gson = GsonBuilder()
|
|
||||||
.registerTypeAdapter(Integer::class.java, IntegerSerializer())
|
|
||||||
.setExclusionStrategies(IdExclusion())
|
|
||||||
.create()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backups the data of the application to a file.
|
* Database.
|
||||||
*
|
|
||||||
* @param file the file where the backup will be saved.
|
|
||||||
* @throws IOException if there's any IO error.
|
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
internal val databaseHelper: DatabaseHelper by injectLazy()
|
||||||
fun backupToFile(file: File) {
|
|
||||||
val root = backupToJson()
|
|
||||||
|
|
||||||
FileWriter(file).use {
|
/**
|
||||||
gson.toJson(root, it)
|
* Source manager.
|
||||||
|
*/
|
||||||
|
internal val sourceManager: SourceManager by injectLazy()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Version of parser
|
||||||
|
*/
|
||||||
|
var version: Int = version
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Json Parser
|
||||||
|
*/
|
||||||
|
var parser: Gson = initParser()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preferences
|
||||||
|
*/
|
||||||
|
private val preferences: PreferencesHelper by injectLazy()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set version of parser
|
||||||
|
*
|
||||||
|
* @param version version of parser
|
||||||
|
*/
|
||||||
|
internal fun setVersion(version: Int) {
|
||||||
|
this.version = version
|
||||||
|
parser = initParser()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initParser(): Gson {
|
||||||
|
return when (version) {
|
||||||
|
1 -> GsonBuilder().create()
|
||||||
|
2 -> GsonBuilder()
|
||||||
|
.registerTypeAdapter<MangaImpl>(MangaTypeAdapter.build())
|
||||||
|
.registerTypeHierarchyAdapter<ChapterImpl>(ChapterTypeAdapter.build())
|
||||||
|
.registerTypeAdapter<CategoryImpl>(CategoryTypeAdapter.build())
|
||||||
|
.registerTypeAdapter<DHistory>(HistoryTypeAdapter.build())
|
||||||
|
.registerTypeHierarchyAdapter<TrackImpl>(TrackTypeAdapter.build())
|
||||||
|
.create()
|
||||||
|
else -> throw Exception("Json version unknown")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a JSON object containing the backup of the app's data.
|
* Backup the categories of library
|
||||||
*
|
*
|
||||||
* @return the backup as a JSON object.
|
* @param root root of categories json
|
||||||
*/
|
*/
|
||||||
fun backupToJson(): JsonObject {
|
internal fun backupCategories(root: JsonArray) {
|
||||||
val root = JsonObject()
|
val categories = databaseHelper.getCategories().executeAsBlocking()
|
||||||
|
categories.forEach { root.add(parser.toJsonTree(it)) }
|
||||||
// Backup library mangas and its dependencies
|
|
||||||
val mangaEntries = JsonArray()
|
|
||||||
root.add(MANGAS, mangaEntries)
|
|
||||||
for (manga in db.getFavoriteMangas().executeAsBlocking()) {
|
|
||||||
mangaEntries.add(backupManga(manga))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backup categories
|
|
||||||
val categoryEntries = JsonArray()
|
|
||||||
root.add(CATEGORIES, categoryEntries)
|
|
||||||
for (category in db.getCategories().executeAsBlocking()) {
|
|
||||||
categoryEntries.add(backupCategory(category))
|
|
||||||
}
|
|
||||||
|
|
||||||
return root
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backups a manga and its related data (chapters, categories this manga is in, sync...).
|
* Convert a manga to Json
|
||||||
*
|
*
|
||||||
* @param manga the manga to backup.
|
* @param manga manga that gets converted
|
||||||
* @return a JSON object containing all the data of the manga.
|
* @return [JsonElement] containing manga information
|
||||||
*/
|
*/
|
||||||
private fun backupManga(manga: Manga): JsonObject {
|
internal fun backupMangaObject(manga: Manga, options: Int): JsonElement {
|
||||||
// Entry for this manga
|
// Entry for this manga
|
||||||
val entry = JsonObject()
|
val entry = JsonObject()
|
||||||
|
|
||||||
// Backup manga fields
|
// Backup manga fields
|
||||||
entry.add(MANGA, gson.toJsonTree(manga))
|
entry[MANGA] = parser.toJsonTree(manga)
|
||||||
|
|
||||||
// Backup all the chapters
|
// Check if user wants chapter information in backup
|
||||||
val chapters = db.getChapters(manga).executeAsBlocking()
|
if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) {
|
||||||
if (!chapters.isEmpty()) {
|
// Backup all the chapters
|
||||||
entry.add(CHAPTERS, gson.toJsonTree(chapters))
|
val chapters = databaseHelper.getChapters(manga).executeAsBlocking()
|
||||||
}
|
if (!chapters.isEmpty()) {
|
||||||
|
val chaptersJson = parser.toJsonTree(chapters)
|
||||||
// Backup manga sync
|
if (chaptersJson.asJsonArray.size() > 0) {
|
||||||
val mangaSync = db.getMangasSync(manga).executeAsBlocking()
|
entry[CHAPTERS] = chaptersJson
|
||||||
if (!mangaSync.isEmpty()) {
|
}
|
||||||
entry.add(MANGA_SYNC, gson.toJsonTree(mangaSync))
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backup categories for this manga
|
// Check if user wants category information in backup
|
||||||
val categoriesForManga = db.getCategoriesForManga(manga).executeAsBlocking()
|
if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
|
||||||
if (!categoriesForManga.isEmpty()) {
|
// Backup categories for this manga
|
||||||
val categoriesNames = ArrayList<String>()
|
val categoriesForManga = databaseHelper.getCategoriesForManga(manga).executeAsBlocking()
|
||||||
for (category in categoriesForManga) {
|
if (!categoriesForManga.isEmpty()) {
|
||||||
categoriesNames.add(category.name)
|
val categoriesNames = categoriesForManga.map { it.name }
|
||||||
|
entry[CATEGORIES] = parser.toJsonTree(categoriesNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user wants track information in backup
|
||||||
|
if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) {
|
||||||
|
val tracks = databaseHelper.getTracks(manga).executeAsBlocking()
|
||||||
|
if (!tracks.isEmpty()) {
|
||||||
|
entry[TRACK] = parser.toJsonTree(tracks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user wants history information in backup
|
||||||
|
if (options and BACKUP_HISTORY_MASK == BACKUP_HISTORY) {
|
||||||
|
val historyForManga = databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking()
|
||||||
|
if (!historyForManga.isEmpty()) {
|
||||||
|
val historyData = historyForManga.mapNotNull { history ->
|
||||||
|
val url = databaseHelper.getChapter(history.chapter_id).executeAsBlocking()?.url
|
||||||
|
url?.let { DHistory(url, history.last_read) }
|
||||||
|
}
|
||||||
|
val historyJson = parser.toJsonTree(historyData)
|
||||||
|
if (historyJson.asJsonArray.size() > 0) {
|
||||||
|
entry[HISTORY] = historyJson
|
||||||
|
}
|
||||||
}
|
}
|
||||||
entry.add(CATEGORIES, gson.toJsonTree(categoriesNames))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun restoreMangaNoFetch(manga: Manga, dbManga: Manga) {
|
||||||
* Backups a category.
|
manga.id = dbManga.id
|
||||||
*
|
manga.copyFrom(dbManga)
|
||||||
* @param category the category to backup.
|
manga.favorite = true
|
||||||
* @return a JSON object containing the data of the category.
|
insertManga(manga)
|
||||||
*/
|
|
||||||
private fun backupCategory(category: Category): JsonElement {
|
|
||||||
return gson.toJsonTree(category)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restores a backup from a file.
|
* [Observable] that fetches manga information
|
||||||
*
|
*
|
||||||
* @param file the file containing the backup.
|
* @param source source of manga
|
||||||
* @throws IOException if there's any IO error.
|
* @param manga manga that needs updating
|
||||||
|
* @return [Observable] that contains manga
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
fun restoreMangaFetchObservable(source: Source, manga: Manga): Observable<Manga> {
|
||||||
fun restoreFromFile(file: File) {
|
return source.fetchMangaDetails(manga)
|
||||||
JsonReader(FileReader(file)).use {
|
.map { networkManga ->
|
||||||
val root = JsonParser().parse(it).asJsonObject
|
manga.copyFrom(networkManga)
|
||||||
restoreFromJson(root)
|
manga.favorite = true
|
||||||
}
|
manga.initialized = true
|
||||||
|
manga.id = insertManga(manga)
|
||||||
|
manga
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restores a backup from an input stream.
|
* [Observable] that fetches chapter information
|
||||||
*
|
*
|
||||||
* @param stream the stream containing the backup.
|
* @param source source of manga
|
||||||
* @throws IOException if there's any IO error.
|
* @param manga manga that needs updating
|
||||||
|
* @return [Observable] that contains manga
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
fun restoreChapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
|
||||||
fun restoreFromStream(stream: InputStream) {
|
return source.fetchChapterList(manga)
|
||||||
JsonReader(InputStreamReader(stream)).use {
|
.map { syncChaptersWithSource(databaseHelper, it, manga, source) }
|
||||||
val root = JsonParser().parse(it).asJsonObject
|
.doOnNext {
|
||||||
restoreFromJson(root)
|
if (it.first.isNotEmpty()) {
|
||||||
}
|
chapters.forEach { it.manga_id = manga.id }
|
||||||
|
insertChapters(chapters)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restores a backup from a JSON object. Everything executes in a single transaction so that
|
* Restore the categories from Json
|
||||||
* nothing is modified if there's an error.
|
|
||||||
*
|
*
|
||||||
* @param root the root of the JSON.
|
* @param jsonCategories array containing categories
|
||||||
*/
|
*/
|
||||||
fun restoreFromJson(root: JsonObject) {
|
internal fun restoreCategories(jsonCategories: JsonArray) {
|
||||||
db.inTransaction {
|
|
||||||
// Restore categories
|
|
||||||
root.get(CATEGORIES)?.let {
|
|
||||||
restoreCategories(it.asJsonArray)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore mangas
|
|
||||||
root.get(MANGAS)?.let {
|
|
||||||
restoreMangas(it.asJsonArray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restores the categories.
|
|
||||||
*
|
|
||||||
* @param jsonCategories the categories of the json.
|
|
||||||
*/
|
|
||||||
private fun restoreCategories(jsonCategories: JsonArray) {
|
|
||||||
// Get categories from file and from db
|
// Get categories from file and from db
|
||||||
val dbCategories = db.getCategories().executeAsBlocking()
|
val dbCategories = databaseHelper.getCategories().executeAsBlocking()
|
||||||
val backupCategories = gson.fromJson<List<CategoryImpl>>(jsonCategories)
|
val backupCategories = parser.fromJson<List<CategoryImpl>>(jsonCategories)
|
||||||
|
|
||||||
// Iterate over them
|
// Iterate over them
|
||||||
for (category in backupCategories) {
|
backupCategories.forEach { category ->
|
||||||
// Used to know if the category is already in the db
|
// Used to know if the category is already in the db
|
||||||
var found = false
|
var found = false
|
||||||
for (dbCategory in dbCategories) {
|
for (dbCategory in dbCategories) {
|
||||||
@ -210,102 +223,20 @@ class BackupManager(private val db: DatabaseHelper) {
|
|||||||
if (!found) {
|
if (!found) {
|
||||||
// Let the db assign the id
|
// Let the db assign the id
|
||||||
category.id = null
|
category.id = null
|
||||||
val result = db.insertCategory(category).executeAsBlocking()
|
val result = databaseHelper.insertCategory(category).executeAsBlocking()
|
||||||
category.id = result.insertedId()?.toInt()
|
category.id = result.insertedId()?.toInt()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Restores all the mangas and its related data.
|
|
||||||
*
|
|
||||||
* @param jsonMangas the mangas and its related data (chapters, sync, categories) from the json.
|
|
||||||
*/
|
|
||||||
private fun restoreMangas(jsonMangas: JsonArray) {
|
|
||||||
for (backupManga in jsonMangas) {
|
|
||||||
// Map every entry to objects
|
|
||||||
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 categories = gson.fromJson<List<String>>(element.get(CATEGORIES) ?: JsonArray())
|
|
||||||
|
|
||||||
// Restore everything related to this manga
|
|
||||||
restoreManga(manga)
|
|
||||||
restoreChaptersForManga(manga, chapters)
|
|
||||||
restoreSyncForManga(manga, sync)
|
|
||||||
restoreCategoriesForManga(manga, categories)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restores a manga.
|
|
||||||
*
|
|
||||||
* @param manga the manga to restore.
|
|
||||||
*/
|
|
||||||
private fun restoreManga(manga: Manga) {
|
|
||||||
// Try to find existing manga in db
|
|
||||||
val dbManga = db.getManga(manga.url, manga.source).executeAsBlocking()
|
|
||||||
if (dbManga == null) {
|
|
||||||
// Let the db assign the id
|
|
||||||
manga.id = null
|
|
||||||
val result = db.insertManga(manga).executeAsBlocking()
|
|
||||||
manga.id = result.insertedId()
|
|
||||||
} else {
|
|
||||||
// If it exists already, we copy only the values related to the source from the db
|
|
||||||
// (they can be up to date). Local values (flags) are kept from the backup.
|
|
||||||
manga.id = dbManga.id
|
|
||||||
manga.copyFrom(dbManga)
|
|
||||||
manga.favorite = true
|
|
||||||
db.insertManga(manga).executeAsBlocking()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restores the chapters of a manga.
|
|
||||||
*
|
|
||||||
* @param manga the manga whose chapters have to be restored.
|
|
||||||
* @param chapters the chapters to restore.
|
|
||||||
*/
|
|
||||||
private fun restoreChaptersForManga(manga: Manga, chapters: List<Chapter>) {
|
|
||||||
// Fix foreign keys with the current manga id
|
|
||||||
for (chapter in chapters) {
|
|
||||||
chapter.manga_id = manga.id
|
|
||||||
}
|
|
||||||
|
|
||||||
val dbChapters = db.getChapters(manga).executeAsBlocking()
|
|
||||||
val chaptersToUpdate = ArrayList<Chapter>()
|
|
||||||
for (backupChapter in chapters) {
|
|
||||||
// Try to find existing chapter in db
|
|
||||||
val pos = dbChapters.indexOf(backupChapter)
|
|
||||||
if (pos != -1) {
|
|
||||||
// The chapter is already in the db, only update its fields
|
|
||||||
val dbChapter = dbChapters[pos]
|
|
||||||
// If one of them was read, the chapter will be marked as read
|
|
||||||
dbChapter.read = backupChapter.read || dbChapter.read
|
|
||||||
dbChapter.last_page_read = Math.max(backupChapter.last_page_read, dbChapter.last_page_read)
|
|
||||||
chaptersToUpdate.add(dbChapter)
|
|
||||||
} else {
|
|
||||||
// Insert new chapter. Let the db assign the id
|
|
||||||
backupChapter.id = null
|
|
||||||
chaptersToUpdate.add(backupChapter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update database
|
|
||||||
if (!chaptersToUpdate.isEmpty()) {
|
|
||||||
db.insertChapters(chaptersToUpdate).executeAsBlocking()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restores the categories a manga is in.
|
* Restores the categories a manga is in.
|
||||||
*
|
*
|
||||||
* @param manga the manga whose categories have to be restored.
|
* @param manga the manga whose categories have to be restored.
|
||||||
* @param categories the categories to restore.
|
* @param categories the categories to restore.
|
||||||
*/
|
*/
|
||||||
private fun restoreCategoriesForManga(manga: Manga, categories: List<String>) {
|
internal fun restoreCategoriesForManga(manga: Manga, categories: List<String>) {
|
||||||
val dbCategories = db.getCategories().executeAsBlocking()
|
val dbCategories = databaseHelper.getCategories().executeAsBlocking()
|
||||||
val mangaCategoriesToUpdate = ArrayList<MangaCategory>()
|
val mangaCategoriesToUpdate = ArrayList<MangaCategory>()
|
||||||
for (backupCategoryStr in categories) {
|
for (backupCategoryStr in categories) {
|
||||||
for (dbCategory in dbCategories) {
|
for (dbCategory in dbCategories) {
|
||||||
@ -320,45 +251,151 @@ class BackupManager(private val db: DatabaseHelper) {
|
|||||||
if (!mangaCategoriesToUpdate.isEmpty()) {
|
if (!mangaCategoriesToUpdate.isEmpty()) {
|
||||||
val mangaAsList = ArrayList<Manga>()
|
val mangaAsList = ArrayList<Manga>()
|
||||||
mangaAsList.add(manga)
|
mangaAsList.add(manga)
|
||||||
db.deleteOldMangasCategories(mangaAsList).executeAsBlocking()
|
databaseHelper.deleteOldMangasCategories(mangaAsList).executeAsBlocking()
|
||||||
db.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking()
|
databaseHelper.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore history from Json
|
||||||
|
*
|
||||||
|
* @param history list containing history to be restored
|
||||||
|
*/
|
||||||
|
internal fun restoreHistoryForManga(history: List<DHistory>) {
|
||||||
|
// List containing history to be updated
|
||||||
|
val historyToBeUpdated = ArrayList<History>()
|
||||||
|
for ((url, lastRead) in history) {
|
||||||
|
val dbHistory = databaseHelper.getHistoryByChapterUrl(url).executeAsBlocking()
|
||||||
|
// Check if history already in database and update
|
||||||
|
if (dbHistory != null) {
|
||||||
|
dbHistory.apply {
|
||||||
|
last_read = Math.max(lastRead, dbHistory.last_read)
|
||||||
|
}
|
||||||
|
historyToBeUpdated.add(dbHistory)
|
||||||
|
} else {
|
||||||
|
// If not in database create
|
||||||
|
databaseHelper.getChapter(url).executeAsBlocking()?.let {
|
||||||
|
val historyToAdd = History.create(it).apply {
|
||||||
|
last_read = lastRead
|
||||||
|
}
|
||||||
|
historyToBeUpdated.add(historyToAdd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
databaseHelper.updateHistoryLastRead(historyToBeUpdated).executeAsBlocking()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restores the sync of a manga.
|
* Restores the sync of a manga.
|
||||||
*
|
*
|
||||||
* @param manga the manga whose sync have to be restored.
|
* @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>) {
|
internal fun restoreTrackForManga(manga: Manga, tracks: List<Track>) {
|
||||||
// Fix foreign keys with the current manga id
|
// Fix foreign keys with the current manga id
|
||||||
for (mangaSync in sync) {
|
tracks.map { it.manga_id = manga.id!! }
|
||||||
mangaSync.manga_id = manga.id!!
|
|
||||||
}
|
|
||||||
|
|
||||||
val dbSyncs = db.getMangasSync(manga).executeAsBlocking()
|
// Get tracks from database
|
||||||
val syncToUpdate = ArrayList<MangaSync>()
|
val dbTracks = databaseHelper.getTracks(manga).executeAsBlocking()
|
||||||
for (backupSync in sync) {
|
val trackToUpdate = ArrayList<Track>()
|
||||||
// Try to find existing chapter in db
|
|
||||||
val pos = dbSyncs.indexOf(backupSync)
|
for (track in tracks) {
|
||||||
if (pos != -1) {
|
var isInDatabase = false
|
||||||
// The sync is already in the db, only update its fields
|
for (dbTrack in dbTracks) {
|
||||||
val dbSync = dbSyncs[pos]
|
if (track.sync_id == dbTrack.sync_id) {
|
||||||
// Mark the max chapter as read and nothing else
|
// The sync is already in the db, only update its fields
|
||||||
dbSync.last_chapter_read = Math.max(backupSync.last_chapter_read, dbSync.last_chapter_read)
|
if (track.remote_id != dbTrack.remote_id) {
|
||||||
syncToUpdate.add(dbSync)
|
dbTrack.remote_id = track.remote_id
|
||||||
} else {
|
}
|
||||||
|
dbTrack.last_chapter_read = Math.max(dbTrack.last_chapter_read, track.last_chapter_read)
|
||||||
|
isInDatabase = true
|
||||||
|
trackToUpdate.add(dbTrack)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isInDatabase) {
|
||||||
// Insert new sync. Let the db assign the id
|
// Insert new sync. Let the db assign the id
|
||||||
backupSync.id = null
|
track.id = null
|
||||||
syncToUpdate.add(backupSync)
|
trackToUpdate.add(track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update database
|
// Update database
|
||||||
if (!syncToUpdate.isEmpty()) {
|
if (!trackToUpdate.isEmpty()) {
|
||||||
db.insertMangasSync(syncToUpdate).executeAsBlocking()
|
databaseHelper.insertTracks(trackToUpdate).executeAsBlocking()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore the chapters for manga if chapters already in database
|
||||||
|
*
|
||||||
|
* @param manga manga of chapters
|
||||||
|
* @param chapters list containing chapters that get restored
|
||||||
|
* @return boolean answering if chapter fetch is not needed
|
||||||
|
*/
|
||||||
|
internal fun restoreChaptersForManga(manga: Manga, chapters: List<Chapter>): Boolean {
|
||||||
|
val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking()
|
||||||
|
|
||||||
|
// Return if fetch is needed
|
||||||
|
if (dbChapters.isEmpty() || dbChapters.size < chapters.size)
|
||||||
|
return false
|
||||||
|
|
||||||
|
for (chapter in chapters) {
|
||||||
|
val pos = dbChapters.indexOf(chapter)
|
||||||
|
if (pos != -1) {
|
||||||
|
val dbChapter = dbChapters[pos]
|
||||||
|
chapter.id = dbChapter.id
|
||||||
|
chapter.copyFrom(dbChapter)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Filter the chapters that couldn't be found.
|
||||||
|
chapters.filter { it.id != null }
|
||||||
|
chapters.map { it.manga_id = manga.id }
|
||||||
|
|
||||||
|
insertChapters(chapters)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns manga
|
||||||
|
*
|
||||||
|
* @return [Manga], null if not found
|
||||||
|
*/
|
||||||
|
internal fun getMangaFromDatabase(manga: Manga): Manga? {
|
||||||
|
return databaseHelper.getManga(manga.url, manga.source).executeAsBlocking()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns list containing manga from library
|
||||||
|
*
|
||||||
|
* @return [Manga] from library
|
||||||
|
*/
|
||||||
|
internal fun getFavoriteManga(): List<Manga> {
|
||||||
|
return databaseHelper.getFavoriteMangas().executeAsBlocking()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts manga and returns id
|
||||||
|
*
|
||||||
|
* @return id of [Manga], null if not found
|
||||||
|
*/
|
||||||
|
internal fun insertManga(manga: Manga): Long? {
|
||||||
|
return databaseHelper.insertManga(manga).executeAsBlocking().insertedId()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts list of chapters
|
||||||
|
*/
|
||||||
|
internal fun insertChapters(chapters: List<Chapter>) {
|
||||||
|
databaseHelper.updateChaptersBackup(chapters).executeAsBlocking()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return number of backups.
|
||||||
|
*
|
||||||
|
* @return number of backups selected by user
|
||||||
|
*/
|
||||||
|
fun numberOfBackups(): Int {
|
||||||
|
return preferences.numberOfBackups().getOrDefault()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,406 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.backup
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.PowerManager
|
||||||
|
import com.github.salomonbrys.kotson.fromJson
|
||||||
|
import com.google.gson.JsonArray
|
||||||
|
import com.google.gson.JsonParser
|
||||||
|
import com.google.gson.stream.JsonReader
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGAS
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.DHistory
|
||||||
|
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.*
|
||||||
|
import eu.kanade.tachiyomi.source.Source
|
||||||
|
import eu.kanade.tachiyomi.util.chop
|
||||||
|
import eu.kanade.tachiyomi.util.isServiceRunning
|
||||||
|
import eu.kanade.tachiyomi.util.sendLocalBroadcast
|
||||||
|
import rx.Observable
|
||||||
|
import rx.Subscription
|
||||||
|
import rx.schedulers.Schedulers
|
||||||
|
import timber.log.Timber
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.ExecutorService
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores backup from json file
|
||||||
|
*/
|
||||||
|
class BackupRestoreService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the status of the service.
|
||||||
|
*
|
||||||
|
* @param context the application context.
|
||||||
|
* @return true if the service is running, false otherwise.
|
||||||
|
*/
|
||||||
|
fun isRunning(context: Context): Boolean {
|
||||||
|
return context.isServiceRunning(BackupRestoreService::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a service to restore a backup from Json
|
||||||
|
*
|
||||||
|
* @param context context of application
|
||||||
|
* @param uri path of Uri
|
||||||
|
*/
|
||||||
|
fun start(context: Context, uri: Uri) {
|
||||||
|
if (!isRunning(context)) {
|
||||||
|
val intent = Intent(context, BackupRestoreService::class.java).apply {
|
||||||
|
putExtra(BackupConst.EXTRA_URI, uri)
|
||||||
|
}
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the service.
|
||||||
|
*
|
||||||
|
* @param context the application context.
|
||||||
|
*/
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.stopService(Intent(context, BackupRestoreService::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wake lock that will be held until the service is destroyed.
|
||||||
|
*/
|
||||||
|
private lateinit var wakeLock: PowerManager.WakeLock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription where the update is done.
|
||||||
|
*/
|
||||||
|
private var subscription: Subscription? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The progress of a backup restore
|
||||||
|
*/
|
||||||
|
private var restoreProgress = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Amount of manga in Json file (needed for restore)
|
||||||
|
*/
|
||||||
|
private var restoreAmount = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List containing errors
|
||||||
|
*/
|
||||||
|
private val errors = mutableListOf<Pair<Date, String>>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup manager
|
||||||
|
*/
|
||||||
|
private lateinit var backupManager: BackupManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database
|
||||||
|
*/
|
||||||
|
private val db: DatabaseHelper by injectLazy()
|
||||||
|
|
||||||
|
lateinit var executor: ExecutorService
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method called when the service is created. It injects dependencies and acquire the wake lock.
|
||||||
|
*/
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
|
||||||
|
PowerManager.PARTIAL_WAKE_LOCK, "BackupRestoreService:WakeLock")
|
||||||
|
wakeLock.acquire()
|
||||||
|
executor = Executors.newSingleThreadExecutor()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method called when the service is destroyed. It destroys the running subscription and
|
||||||
|
* releases the wake lock.
|
||||||
|
*/
|
||||||
|
override fun onDestroy() {
|
||||||
|
subscription?.unsubscribe()
|
||||||
|
executor.shutdown() // must be called after unsubscribe
|
||||||
|
if (wakeLock.isHeld) {
|
||||||
|
wakeLock.release()
|
||||||
|
}
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method needs to be implemented, but it's not used/needed.
|
||||||
|
*/
|
||||||
|
override fun onBind(intent: Intent): IBinder? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method called when the service receives an intent.
|
||||||
|
*
|
||||||
|
* @param intent the start intent from.
|
||||||
|
* @param flags the flags of the command.
|
||||||
|
* @param startId the start id of this command.
|
||||||
|
* @return the start value of the command.
|
||||||
|
*/
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent == null) return Service.START_NOT_STICKY
|
||||||
|
|
||||||
|
val uri = intent.getParcelableExtra<Uri>(BackupConst.EXTRA_URI)
|
||||||
|
|
||||||
|
// Unsubscribe from any previous subscription if needed.
|
||||||
|
subscription?.unsubscribe()
|
||||||
|
|
||||||
|
subscription = Observable.using(
|
||||||
|
{ db.lowLevel().beginTransaction() },
|
||||||
|
{ getRestoreObservable(uri).doOnNext{ db.lowLevel().setTransactionSuccessful() } },
|
||||||
|
{ executor.execute { db.lowLevel().endTransaction() } })
|
||||||
|
.doAfterTerminate { stopSelf(startId) }
|
||||||
|
.subscribeOn(Schedulers.from(executor))
|
||||||
|
.subscribe()
|
||||||
|
|
||||||
|
return Service.START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an [Observable] containing restore process.
|
||||||
|
*
|
||||||
|
* @param uri restore file
|
||||||
|
* @return [Observable<Manga>]
|
||||||
|
*/
|
||||||
|
private fun getRestoreObservable(uri: Uri): Observable<List<Manga>> {
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
val reader = JsonReader(contentResolver.openInputStream(uri).bufferedReader())
|
||||||
|
val json = JsonParser().parse(reader).asJsonObject
|
||||||
|
|
||||||
|
// Get parser version
|
||||||
|
val version = json.get(VERSION)?.asInt ?: 1
|
||||||
|
|
||||||
|
// Initialize manager
|
||||||
|
backupManager = BackupManager(this, version)
|
||||||
|
|
||||||
|
val mangasJson = json.get(MANGAS).asJsonArray
|
||||||
|
|
||||||
|
restoreAmount = mangasJson.size() + 1 // +1 for categories
|
||||||
|
restoreProgress = 0
|
||||||
|
errors.clear()
|
||||||
|
|
||||||
|
// Restore categories
|
||||||
|
json.get(CATEGORIES)?.let {
|
||||||
|
backupManager.restoreCategories(it.asJsonArray)
|
||||||
|
restoreProgress += 1
|
||||||
|
showRestoreProgress(restoreProgress, restoreAmount, "Categories added", errors.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Observable.from(mangasJson)
|
||||||
|
.concatMap {
|
||||||
|
val obj = it.asJsonObject
|
||||||
|
val manga = backupManager.parser.fromJson<MangaImpl>(obj.get(MANGA))
|
||||||
|
val chapters = backupManager.parser.fromJson<List<ChapterImpl>>(obj.get(CHAPTERS) ?: JsonArray())
|
||||||
|
val categories = backupManager.parser.fromJson<List<String>>(obj.get(CATEGORIES) ?: JsonArray())
|
||||||
|
val history = backupManager.parser.fromJson<List<DHistory>>(obj.get(HISTORY) ?: JsonArray())
|
||||||
|
val tracks = backupManager.parser.fromJson<List<TrackImpl>>(obj.get(TRACK) ?: JsonArray())
|
||||||
|
|
||||||
|
val observable = getMangaRestoreObservable(manga, chapters, categories, history, tracks)
|
||||||
|
if (observable != null) {
|
||||||
|
observable
|
||||||
|
} else {
|
||||||
|
errors.add(Date() to "${manga.title} - ${getString(R.string.source_not_found)}")
|
||||||
|
restoreProgress += 1
|
||||||
|
val content = getString(R.string.dialog_restoring_source_not_found, manga.title.chop(15))
|
||||||
|
showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size, content)
|
||||||
|
Observable.just(manga)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
.doOnNext {
|
||||||
|
val endTime = System.currentTimeMillis()
|
||||||
|
val time = endTime - startTime
|
||||||
|
val logFile = writeErrorLog()
|
||||||
|
val completeIntent = Intent(BackupConst.INTENT_FILTER).apply {
|
||||||
|
putExtra(BackupConst.EXTRA_TIME, time)
|
||||||
|
putExtra(BackupConst.EXTRA_ERRORS, errors.size)
|
||||||
|
putExtra(BackupConst.EXTRA_ERROR_FILE_PATH, logFile.parent)
|
||||||
|
putExtra(BackupConst.EXTRA_ERROR_FILE, logFile.name)
|
||||||
|
putExtra(BackupConst.ACTION, BackupConst.ACTION_RESTORE_COMPLETED_DIALOG)
|
||||||
|
}
|
||||||
|
sendLocalBroadcast(completeIntent)
|
||||||
|
|
||||||
|
}
|
||||||
|
.doOnError { error ->
|
||||||
|
Timber.e(error)
|
||||||
|
writeErrorLog()
|
||||||
|
val errorIntent = Intent(BackupConst.INTENT_FILTER).apply {
|
||||||
|
putExtra(BackupConst.ACTION, BackupConst.ACTION_ERROR_RESTORE_DIALOG)
|
||||||
|
putExtra(BackupConst.EXTRA_ERROR_MESSAGE, error.message)
|
||||||
|
}
|
||||||
|
sendLocalBroadcast(errorIntent)
|
||||||
|
}
|
||||||
|
.onErrorReturn { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write errors to error log
|
||||||
|
*/
|
||||||
|
private fun writeErrorLog(): File {
|
||||||
|
try {
|
||||||
|
if (errors.isNotEmpty()) {
|
||||||
|
val destFile = File(externalCacheDir, "tachiyomi_restore.log")
|
||||||
|
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
|
||||||
|
|
||||||
|
destFile.bufferedWriter().use { out ->
|
||||||
|
errors.forEach { (date, message) ->
|
||||||
|
out.write("[${sdf.format(date)}] $message\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return destFile
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Empty
|
||||||
|
}
|
||||||
|
return File("")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a manga restore observable
|
||||||
|
*
|
||||||
|
* @param manga manga data from json
|
||||||
|
* @param chapters chapters data from json
|
||||||
|
* @param categories categories data from json
|
||||||
|
* @param history history data from json
|
||||||
|
* @param tracks tracking data from json
|
||||||
|
* @return [Observable] containing manga restore information
|
||||||
|
*/
|
||||||
|
private fun getMangaRestoreObservable(manga: Manga, chapters: List<Chapter>,
|
||||||
|
categories: List<String>, history: List<DHistory>,
|
||||||
|
tracks: List<Track>): Observable<Manga>? {
|
||||||
|
// Get source
|
||||||
|
val source = backupManager.sourceManager.get(manga.source) ?: return null
|
||||||
|
val dbManga = backupManager.getMangaFromDatabase(manga)
|
||||||
|
|
||||||
|
if (dbManga == null) {
|
||||||
|
// Manga not in database
|
||||||
|
return mangaFetchObservable(source, manga, chapters, categories, history, tracks)
|
||||||
|
} else { // Manga in database
|
||||||
|
// Copy information from manga already in database
|
||||||
|
backupManager.restoreMangaNoFetch(manga, dbManga)
|
||||||
|
// Fetch rest of manga information
|
||||||
|
return mangaNoFetchObservable(source, manga, chapters, categories, history, tracks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Observable] that fetches manga information
|
||||||
|
*
|
||||||
|
* @param manga manga that needs updating
|
||||||
|
* @param chapters chapters of manga that needs updating
|
||||||
|
* @param categories categories that need updating
|
||||||
|
*/
|
||||||
|
private fun mangaFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>,
|
||||||
|
categories: List<String>, history: List<DHistory>,
|
||||||
|
tracks: List<Track>): Observable<Manga> {
|
||||||
|
return backupManager.restoreMangaFetchObservable(source, manga)
|
||||||
|
.onErrorReturn {
|
||||||
|
errors.add(Date() to "${manga.title} - ${it.message}")
|
||||||
|
manga
|
||||||
|
}
|
||||||
|
.filter { it.id != null }
|
||||||
|
.flatMap { manga ->
|
||||||
|
chapterFetchObservable(source, manga, chapters)
|
||||||
|
// Convert to the manga that contains new chapters.
|
||||||
|
.map { manga }
|
||||||
|
}
|
||||||
|
.doOnNext {
|
||||||
|
// Restore categories
|
||||||
|
backupManager.restoreCategoriesForManga(it, categories)
|
||||||
|
|
||||||
|
// Restore history
|
||||||
|
backupManager.restoreHistoryForManga(history)
|
||||||
|
|
||||||
|
// Restore tracking
|
||||||
|
backupManager.restoreTrackForManga(it, tracks)
|
||||||
|
}
|
||||||
|
.doOnCompleted {
|
||||||
|
restoreProgress += 1
|
||||||
|
showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mangaNoFetchObservable(source: Source, backupManga: Manga, chapters: List<Chapter>,
|
||||||
|
categories: List<String>, history: List<DHistory>,
|
||||||
|
tracks: List<Track>): Observable<Manga> {
|
||||||
|
|
||||||
|
return Observable.just(backupManga)
|
||||||
|
.flatMap { manga ->
|
||||||
|
if (!backupManager.restoreChaptersForManga(manga, chapters)) {
|
||||||
|
chapterFetchObservable(source, manga, chapters)
|
||||||
|
.map { manga }
|
||||||
|
} else {
|
||||||
|
Observable.just(manga)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.doOnNext {
|
||||||
|
// Restore categories
|
||||||
|
backupManager.restoreCategoriesForManga(it, categories)
|
||||||
|
|
||||||
|
// Restore history
|
||||||
|
backupManager.restoreHistoryForManga(history)
|
||||||
|
|
||||||
|
// Restore tracking
|
||||||
|
backupManager.restoreTrackForManga(it, tracks)
|
||||||
|
}
|
||||||
|
.doOnCompleted {
|
||||||
|
restoreProgress += 1
|
||||||
|
showRestoreProgress(restoreProgress, restoreAmount, backupManga.title, errors.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Observable] that fetches chapter information
|
||||||
|
*
|
||||||
|
* @param source source of manga
|
||||||
|
* @param manga manga that needs updating
|
||||||
|
* @return [Observable] that contains manga
|
||||||
|
*/
|
||||||
|
private fun chapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
|
||||||
|
return backupManager.restoreChapterFetchObservable(source, manga, chapters)
|
||||||
|
// If there's any error, return empty update and continue.
|
||||||
|
.onErrorReturn {
|
||||||
|
errors.add(Date() to "${manga.title} - ${it.message}")
|
||||||
|
Pair(emptyList<Chapter>(), emptyList<Chapter>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called to update dialog in [BackupConst]
|
||||||
|
*
|
||||||
|
* @param progress restore progress
|
||||||
|
* @param amount total restoreAmount of manga
|
||||||
|
* @param title title of restored manga
|
||||||
|
*/
|
||||||
|
private fun showRestoreProgress(progress: Int, amount: Int, title: String, errors: Int,
|
||||||
|
content: String = getString(R.string.dialog_restoring_backup, title.chop(15))) {
|
||||||
|
val intent = Intent(BackupConst.INTENT_FILTER).apply {
|
||||||
|
putExtra(BackupConst.EXTRA_PROGRESS, progress)
|
||||||
|
putExtra(BackupConst.EXTRA_AMOUNT, amount)
|
||||||
|
putExtra(BackupConst.EXTRA_CONTENT, content)
|
||||||
|
putExtra(BackupConst.EXTRA_ERRORS, errors)
|
||||||
|
putExtra(BackupConst.ACTION, BackupConst.ACTION_SET_PROGRESS_DIALOG)
|
||||||
|
}
|
||||||
|
sendLocalBroadcast(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.backup.models
|
||||||
|
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Json values
|
||||||
|
*/
|
||||||
|
object Backup {
|
||||||
|
const val CURRENT_VERSION = 2
|
||||||
|
const val MANGA = "manga"
|
||||||
|
const val MANGAS = "mangas"
|
||||||
|
const val TRACK = "track"
|
||||||
|
const val CHAPTERS = "chapters"
|
||||||
|
const val CATEGORIES = "categories"
|
||||||
|
const val HISTORY = "history"
|
||||||
|
const val VERSION = "version"
|
||||||
|
|
||||||
|
fun getDefaultFilename(): String {
|
||||||
|
val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date())
|
||||||
|
return "tachiyomi_$date.json"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,3 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.backup.models
|
||||||
|
|
||||||
|
data class DHistory(val url: String,val lastRead: Long)
|
@ -0,0 +1,31 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.backup.serializer
|
||||||
|
|
||||||
|
import com.github.salomonbrys.kotson.typeAdapter
|
||||||
|
import com.google.gson.TypeAdapter
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.CategoryImpl
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON Serializer used to write / read [CategoryImpl] to / from json
|
||||||
|
*/
|
||||||
|
object CategoryTypeAdapter {
|
||||||
|
|
||||||
|
fun build(): TypeAdapter<CategoryImpl> {
|
||||||
|
return typeAdapter {
|
||||||
|
write {
|
||||||
|
beginArray()
|
||||||
|
value(it.name)
|
||||||
|
value(it.order)
|
||||||
|
endArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
read {
|
||||||
|
beginArray()
|
||||||
|
val category = CategoryImpl()
|
||||||
|
category.name = nextString()
|
||||||
|
category.order = nextInt()
|
||||||
|
endArray()
|
||||||
|
category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,61 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.backup.serializer
|
||||||
|
|
||||||
|
import com.github.salomonbrys.kotson.typeAdapter
|
||||||
|
import com.google.gson.TypeAdapter
|
||||||
|
import com.google.gson.stream.JsonToken
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON Serializer used to write / read [ChapterImpl] to / from json
|
||||||
|
*/
|
||||||
|
object ChapterTypeAdapter {
|
||||||
|
|
||||||
|
private const val URL = "u"
|
||||||
|
private const val READ = "r"
|
||||||
|
private const val BOOKMARK = "b"
|
||||||
|
private const val LAST_READ = "l"
|
||||||
|
|
||||||
|
fun build(): TypeAdapter<ChapterImpl> {
|
||||||
|
return typeAdapter {
|
||||||
|
write {
|
||||||
|
if (it.read || it.bookmark || it.last_page_read != 0) {
|
||||||
|
beginObject()
|
||||||
|
name(URL)
|
||||||
|
value(it.url)
|
||||||
|
if (it.read) {
|
||||||
|
name(READ)
|
||||||
|
value(1)
|
||||||
|
}
|
||||||
|
if (it.bookmark) {
|
||||||
|
name(BOOKMARK)
|
||||||
|
value(1)
|
||||||
|
}
|
||||||
|
if (it.last_page_read != 0) {
|
||||||
|
name(LAST_READ)
|
||||||
|
value(it.last_page_read)
|
||||||
|
}
|
||||||
|
endObject()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
read {
|
||||||
|
val chapter = ChapterImpl()
|
||||||
|
beginObject()
|
||||||
|
while (hasNext()) {
|
||||||
|
if (peek() == JsonToken.NAME) {
|
||||||
|
val name = nextName()
|
||||||
|
|
||||||
|
when (name) {
|
||||||
|
URL -> chapter.url = nextString()
|
||||||
|
READ -> chapter.read = nextInt() == 1
|
||||||
|
BOOKMARK -> chapter.bookmark = nextInt() == 1
|
||||||
|
LAST_READ -> chapter.last_page_read = nextInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
endObject()
|
||||||
|
chapter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,32 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.backup.serializer
|
||||||
|
|
||||||
|
import com.github.salomonbrys.kotson.typeAdapter
|
||||||
|
import com.google.gson.TypeAdapter
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.DHistory
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON Serializer used to write / read [DHistory] to / from json
|
||||||
|
*/
|
||||||
|
object HistoryTypeAdapter {
|
||||||
|
|
||||||
|
fun build(): TypeAdapter<DHistory> {
|
||||||
|
return typeAdapter {
|
||||||
|
write {
|
||||||
|
if (it.lastRead != 0L) {
|
||||||
|
beginArray()
|
||||||
|
value(it.url)
|
||||||
|
value(it.lastRead)
|
||||||
|
endArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
read {
|
||||||
|
beginArray()
|
||||||
|
val url = nextString()
|
||||||
|
val lastRead = nextLong()
|
||||||
|
endArray()
|
||||||
|
DHistory(url, lastRead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,27 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.data.backup.serializer
|
|
||||||
|
|
||||||
import com.google.gson.ExclusionStrategy
|
|
||||||
import com.google.gson.FieldAttributes
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Category
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.MangaSync
|
|
||||||
|
|
||||||
class IdExclusion : ExclusionStrategy {
|
|
||||||
|
|
||||||
private val categoryExclusions = listOf("id")
|
|
||||||
private val mangaExclusions = listOf("id")
|
|
||||||
private val chapterExclusions = listOf("id", "manga_id")
|
|
||||||
private val syncExclusions = listOf("id", "manga_id", "update")
|
|
||||||
|
|
||||||
override fun shouldSkipField(f: FieldAttributes) = when (f.declaringClass) {
|
|
||||||
Manga::class.java -> mangaExclusions.contains(f.name)
|
|
||||||
Chapter::class.java -> chapterExclusions.contains(f.name)
|
|
||||||
MangaSync::class.java -> syncExclusions.contains(f.name)
|
|
||||||
Category::class.java -> categoryExclusions.contains(f.name)
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun shouldSkipClass(clazz: Class<*>) = false
|
|
||||||
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.data.backup.serializer
|
|
||||||
|
|
||||||
import com.google.gson.JsonElement
|
|
||||||
import com.google.gson.JsonPrimitive
|
|
||||||
import com.google.gson.JsonSerializationContext
|
|
||||||
import com.google.gson.JsonSerializer
|
|
||||||
|
|
||||||
import java.lang.reflect.Type
|
|
||||||
|
|
||||||
class IntegerSerializer : JsonSerializer<Int> {
|
|
||||||
|
|
||||||
override fun serialize(value: Int?, type: Type, context: JsonSerializationContext): JsonElement? {
|
|
||||||
if (value != null && value !== 0)
|
|
||||||
return JsonPrimitive(value)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,37 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.backup.serializer
|
||||||
|
|
||||||
|
import com.github.salomonbrys.kotson.typeAdapter
|
||||||
|
import com.google.gson.TypeAdapter
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.MangaImpl
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON Serializer used to write / read [MangaImpl] to / from json
|
||||||
|
*/
|
||||||
|
object MangaTypeAdapter {
|
||||||
|
|
||||||
|
fun build(): TypeAdapter<MangaImpl> {
|
||||||
|
return typeAdapter {
|
||||||
|
write {
|
||||||
|
beginArray()
|
||||||
|
value(it.url)
|
||||||
|
value(it.title)
|
||||||
|
value(it.source)
|
||||||
|
value(it.viewer)
|
||||||
|
value(it.chapter_flags)
|
||||||
|
endArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
read {
|
||||||
|
beginArray()
|
||||||
|
val manga = MangaImpl()
|
||||||
|
manga.url = nextString()
|
||||||
|
manga.title = nextString()
|
||||||
|
manga.source = nextLong()
|
||||||
|
manga.viewer = nextInt()
|
||||||
|
manga.chapter_flags = nextInt()
|
||||||
|
endArray()
|
||||||
|
manga
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,53 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.backup.serializer
|
||||||
|
|
||||||
|
import com.github.salomonbrys.kotson.typeAdapter
|
||||||
|
import com.google.gson.TypeAdapter
|
||||||
|
import com.google.gson.stream.JsonToken
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.TrackImpl
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON Serializer used to write / read [TrackImpl] to / from json
|
||||||
|
*/
|
||||||
|
object TrackTypeAdapter {
|
||||||
|
|
||||||
|
private const val SYNC = "s"
|
||||||
|
private const val REMOTE = "r"
|
||||||
|
private const val TITLE = "t"
|
||||||
|
private const val LAST_READ = "l"
|
||||||
|
|
||||||
|
fun build(): TypeAdapter<TrackImpl> {
|
||||||
|
return typeAdapter {
|
||||||
|
write {
|
||||||
|
beginObject()
|
||||||
|
name(TITLE)
|
||||||
|
value(it.title)
|
||||||
|
name(SYNC)
|
||||||
|
value(it.sync_id)
|
||||||
|
name(REMOTE)
|
||||||
|
value(it.remote_id)
|
||||||
|
name(LAST_READ)
|
||||||
|
value(it.last_chapter_read)
|
||||||
|
endObject()
|
||||||
|
}
|
||||||
|
|
||||||
|
read {
|
||||||
|
val track = TrackImpl()
|
||||||
|
beginObject()
|
||||||
|
while (hasNext()) {
|
||||||
|
if (peek() == JsonToken.NAME) {
|
||||||
|
val name = nextName()
|
||||||
|
|
||||||
|
when (name) {
|
||||||
|
TITLE -> track.title = nextString()
|
||||||
|
SYNC -> track.sync_id = nextInt()
|
||||||
|
REMOTE -> track.remote_id = nextInt()
|
||||||
|
LAST_READ -> track.last_chapter_read = nextInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
endObject()
|
||||||
|
track
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -2,18 +2,19 @@ package eu.kanade.tachiyomi.data.cache
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.text.format.Formatter
|
import android.text.format.Formatter
|
||||||
|
import com.github.salomonbrys.kotson.fromJson
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.reflect.TypeToken
|
|
||||||
import com.jakewharton.disklrucache.DiskLruCache
|
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.util.DiskUtils
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import eu.kanade.tachiyomi.util.saveImageTo
|
import eu.kanade.tachiyomi.util.DiskUtil
|
||||||
|
import eu.kanade.tachiyomi.util.saveTo
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okio.Okio
|
import okio.Okio
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.lang.reflect.Type
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class used to create chapter cache
|
* Class used to create chapter cache
|
||||||
@ -26,15 +27,6 @@ import java.lang.reflect.Type
|
|||||||
*/
|
*/
|
||||||
class ChapterCache(private val context: Context) {
|
class ChapterCache(private val context: Context) {
|
||||||
|
|
||||||
/** Google Json class used for parsing JSON files. */
|
|
||||||
private val gson: Gson = Gson()
|
|
||||||
|
|
||||||
/** Cache class used for cache management. */
|
|
||||||
private val diskCache: DiskLruCache
|
|
||||||
|
|
||||||
/** Page list collection used for deserializing from JSON. */
|
|
||||||
private val pageListCollection: Type = object : TypeToken<List<Page>>() {}.type
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Name of cache directory. */
|
/** Name of cache directory. */
|
||||||
const val PARAMETER_CACHE_DIRECTORY = "chapter_disk_cache"
|
const val PARAMETER_CACHE_DIRECTORY = "chapter_disk_cache"
|
||||||
@ -49,38 +41,37 @@ class ChapterCache(private val context: Context) {
|
|||||||
const val PARAMETER_CACHE_SIZE = 75L * 1024 * 1024
|
const val PARAMETER_CACHE_SIZE = 75L * 1024 * 1024
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
/** Google Json class used for parsing JSON files. */
|
||||||
// Open cache in default cache directory.
|
private val gson: Gson by injectLazy()
|
||||||
diskCache = DiskLruCache.open(
|
|
||||||
File(context.cacheDir, PARAMETER_CACHE_DIRECTORY),
|
/** Cache class used for cache management. */
|
||||||
PARAMETER_APP_VERSION,
|
private val diskCache = DiskLruCache.open(
|
||||||
PARAMETER_VALUE_COUNT,
|
File(context.externalCacheDir, PARAMETER_CACHE_DIRECTORY),
|
||||||
PARAMETER_CACHE_SIZE)
|
PARAMETER_APP_VERSION,
|
||||||
}
|
PARAMETER_VALUE_COUNT,
|
||||||
|
PARAMETER_CACHE_SIZE)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns directory of cache.
|
* Returns directory of cache.
|
||||||
* @return directory of cache.
|
|
||||||
*/
|
*/
|
||||||
val cacheDir: File
|
val cacheDir: File
|
||||||
get() = diskCache.directory
|
get() = diskCache.directory
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns real size of directory.
|
* Returns real size of directory.
|
||||||
* @return real size of directory.
|
|
||||||
*/
|
*/
|
||||||
private val realSize: Long
|
private val realSize: Long
|
||||||
get() = DiskUtils.getDirectorySize(cacheDir)
|
get() = DiskUtil.getDirectorySize(cacheDir)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns real size of directory in human readable format.
|
* Returns real size of directory in human readable format.
|
||||||
* @return real size of directory.
|
|
||||||
*/
|
*/
|
||||||
val readableSize: String
|
val readableSize: String
|
||||||
get() = Formatter.formatFileSize(context, realSize)
|
get() = Formatter.formatFileSize(context, realSize)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove file from cache.
|
* Remove file from cache.
|
||||||
|
*
|
||||||
* @param file name of file "md5.0".
|
* @param file name of file "md5.0".
|
||||||
* @return status of deletion for the file.
|
* @return status of deletion for the file.
|
||||||
*/
|
*/
|
||||||
@ -101,27 +92,29 @@ class ChapterCache(private val context: Context) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get page list from cache.
|
* Get page list from cache.
|
||||||
* @param chapterUrl the url of the chapter.
|
*
|
||||||
|
* @param chapter the chapter.
|
||||||
* @return an observable of the list of pages.
|
* @return an observable of the list of pages.
|
||||||
*/
|
*/
|
||||||
fun getPageListFromCache(chapterUrl: String): Observable<List<Page>> {
|
fun getPageListFromCache(chapter: Chapter): Observable<List<Page>> {
|
||||||
return Observable.fromCallable<List<Page>> {
|
return Observable.fromCallable {
|
||||||
// Get the key for the chapter.
|
// Get the key for the chapter.
|
||||||
val key = DiskUtils.hashKeyForDisk(chapterUrl)
|
val key = DiskUtil.hashKeyForDisk(getKey(chapter))
|
||||||
|
|
||||||
// Convert JSON string to list of objects. Throws an exception if snapshot is null
|
// Convert JSON string to list of objects. Throws an exception if snapshot is null
|
||||||
diskCache.get(key).use {
|
diskCache.get(key).use {
|
||||||
gson.fromJson(it.getString(0), pageListCollection)
|
gson.fromJson<List<Page>>(it.getString(0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add page list to disk cache.
|
* Add page list to disk cache.
|
||||||
* @param chapterUrl the url of the chapter.
|
*
|
||||||
|
* @param chapter the chapter.
|
||||||
* @param pages list of pages.
|
* @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.
|
// Convert list of pages to json string.
|
||||||
val cachedValue = gson.toJson(pages)
|
val cachedValue = gson.toJson(pages)
|
||||||
|
|
||||||
@ -130,7 +123,7 @@ class ChapterCache(private val context: Context) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Get editor from md5 key.
|
// Get editor from md5 key.
|
||||||
val key = DiskUtils.hashKeyForDisk(chapterUrl)
|
val key = DiskUtil.hashKeyForDisk(getKey(chapter))
|
||||||
editor = diskCache.edit(key) ?: return
|
editor = diskCache.edit(key) ?: return
|
||||||
|
|
||||||
// Write chapter urls to cache.
|
// Write chapter urls to cache.
|
||||||
@ -151,59 +144,61 @@ class ChapterCache(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if image is in cache.
|
* Returns true if image is in cache.
|
||||||
|
*
|
||||||
* @param imageUrl url of image.
|
* @param imageUrl url of image.
|
||||||
* @return true if in cache otherwise false.
|
* @return true if in cache otherwise false.
|
||||||
*/
|
*/
|
||||||
fun isImageInCache(imageUrl: String): Boolean {
|
fun isImageInCache(imageUrl: String): Boolean {
|
||||||
try {
|
try {
|
||||||
return diskCache.get(DiskUtils.hashKeyForDisk(imageUrl)) != null
|
return diskCache.get(DiskUtil.hashKeyForDisk(imageUrl)) != null
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get image path from url.
|
* Get image file from url.
|
||||||
|
*
|
||||||
* @param imageUrl url of image.
|
* @param imageUrl url of image.
|
||||||
* @return path of image.
|
* @return path of image.
|
||||||
*/
|
*/
|
||||||
fun getImagePath(imageUrl: String): String? {
|
fun getImageFile(imageUrl: String): File {
|
||||||
try {
|
// Get file from md5 key.
|
||||||
// Get file from md5 key.
|
val imageName = DiskUtil.hashKeyForDisk(imageUrl) + ".0"
|
||||||
val imageName = DiskUtils.hashKeyForDisk(imageUrl) + ".0"
|
return File(diskCache.directory, imageName)
|
||||||
return File(diskCache.directory, imageName).canonicalPath
|
|
||||||
} catch (e: IOException) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add image to cache.
|
* Add image to cache.
|
||||||
|
*
|
||||||
* @param imageUrl url of image.
|
* @param imageUrl url of image.
|
||||||
* @param response http response from page.
|
* @param response http response from page.
|
||||||
* @throws IOException image error.
|
* @throws IOException image error.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun putImageToCache(imageUrl: String, response: Response, reencode: Boolean) {
|
fun putImageToCache(imageUrl: String, response: Response) {
|
||||||
// Initialize editor (edits the values for an entry).
|
// Initialize editor (edits the values for an entry).
|
||||||
var editor: DiskLruCache.Editor? = null
|
var editor: DiskLruCache.Editor? = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get editor from md5 key.
|
// Get editor from md5 key.
|
||||||
val key = DiskUtils.hashKeyForDisk(imageUrl)
|
val key = DiskUtil.hashKeyForDisk(imageUrl)
|
||||||
editor = diskCache.edit(key) ?: throw IOException("Unable to edit key")
|
editor = diskCache.edit(key) ?: throw IOException("Unable to edit key")
|
||||||
|
|
||||||
// Get OutputStream and write image with Okio.
|
// Get OutputStream and write image with Okio.
|
||||||
response.body().source().saveImageTo(editor.newOutputStream(0), reencode)
|
response.body()!!.source().saveTo(editor.newOutputStream(0))
|
||||||
|
|
||||||
diskCache.flush()
|
diskCache.flush()
|
||||||
editor.commit()
|
editor.commit()
|
||||||
} finally {
|
} finally {
|
||||||
response.body().close()
|
response.body()?.close()
|
||||||
editor?.abortUnlessCommitted()
|
editor?.abortUnlessCommitted()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getKey(chapter: Chapter): String {
|
||||||
|
return "${chapter.manga_id}${chapter.url}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
package eu.kanade.tachiyomi.data.cache
|
package eu.kanade.tachiyomi.data.cache
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import eu.kanade.tachiyomi.util.DiskUtils
|
import eu.kanade.tachiyomi.util.DiskUtil
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
@ -20,7 +20,7 @@ class CoverCache(private val context: Context) {
|
|||||||
/**
|
/**
|
||||||
* Cache directory used for cache management.
|
* 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.
|
* Returns the cover from cache.
|
||||||
@ -29,7 +29,7 @@ class CoverCache(private val context: Context) {
|
|||||||
* @return cover image.
|
* @return cover image.
|
||||||
*/
|
*/
|
||||||
fun getCoverFile(thumbnailUrl: String): File {
|
fun getCoverFile(thumbnailUrl: String): File {
|
||||||
return File(cacheDir, DiskUtils.hashKeyForDisk(thumbnailUrl))
|
return File(cacheDir, DiskUtil.hashKeyForDisk(thumbnailUrl))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -10,13 +10,13 @@ import eu.kanade.tachiyomi.data.database.queries.*
|
|||||||
* This class provides operations to manage the database through its interfaces.
|
* This class provides operations to manage the database through its interfaces.
|
||||||
*/
|
*/
|
||||||
open class DatabaseHelper(context: Context)
|
open class DatabaseHelper(context: Context)
|
||||||
: MangaQueries, ChapterQueries, MangaSyncQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries {
|
: MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries {
|
||||||
|
|
||||||
override val db = DefaultStorIOSQLite.builder()
|
override val db = DefaultStorIOSQLite.builder()
|
||||||
.sqliteOpenHelper(DbOpenHelper(context))
|
.sqliteOpenHelper(DbOpenHelper(context))
|
||||||
.addTypeMapping(Manga::class.java, MangaTypeMapping())
|
.addTypeMapping(Manga::class.java, MangaTypeMapping())
|
||||||
.addTypeMapping(Chapter::class.java, ChapterTypeMapping())
|
.addTypeMapping(Chapter::class.java, ChapterTypeMapping())
|
||||||
.addTypeMapping(MangaSync::class.java, MangaSyncTypeMapping())
|
.addTypeMapping(Track::class.java, TrackTypeMapping())
|
||||||
.addTypeMapping(Category::class.java, CategoryTypeMapping())
|
.addTypeMapping(Category::class.java, CategoryTypeMapping())
|
||||||
.addTypeMapping(MangaCategory::class.java, MangaCategoryTypeMapping())
|
.addTypeMapping(MangaCategory::class.java, MangaCategoryTypeMapping())
|
||||||
.addTypeMapping(History::class.java, HistoryTypeMapping())
|
.addTypeMapping(History::class.java, HistoryTypeMapping())
|
||||||
@ -24,4 +24,6 @@ open class DatabaseHelper(context: Context)
|
|||||||
|
|
||||||
inline fun inTransaction(block: () -> Unit) = db.inTransaction(block)
|
inline fun inTransaction(block: () -> Unit) = db.inTransaction(block)
|
||||||
|
|
||||||
|
fun lowLevel() = db.lowLevel()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -17,13 +17,13 @@ class DbOpenHelper(context: Context)
|
|||||||
/**
|
/**
|
||||||
* Version of the database.
|
* Version of the database.
|
||||||
*/
|
*/
|
||||||
const val DATABASE_VERSION = 3
|
const val DATABASE_VERSION = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(db: SQLiteDatabase) = with(db) {
|
override fun onCreate(db: SQLiteDatabase) = with(db) {
|
||||||
execSQL(MangaTable.createTableQuery)
|
execSQL(MangaTable.createTableQuery)
|
||||||
execSQL(ChapterTable.createTableQuery)
|
execSQL(ChapterTable.createTableQuery)
|
||||||
execSQL(MangaSyncTable.createTableQuery)
|
execSQL(TrackTable.createTableQuery)
|
||||||
execSQL(CategoryTable.createTableQuery)
|
execSQL(CategoryTable.createTableQuery)
|
||||||
execSQL(MangaCategoryTable.createTableQuery)
|
execSQL(MangaCategoryTable.createTableQuery)
|
||||||
execSQL(HistoryTable.createTableQuery)
|
execSQL(HistoryTable.createTableQuery)
|
||||||
@ -48,6 +48,9 @@ class DbOpenHelper(context: Context)
|
|||||||
db.execSQL(HistoryTable.createTableQuery)
|
db.execSQL(HistoryTable.createTableQuery)
|
||||||
db.execSQL(HistoryTable.createChapterIdIndexQuery)
|
db.execSQL(HistoryTable.createChapterIdIndexQuery)
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 4) {
|
||||||
|
db.execSQL(ChapterTable.bookmarkUpdateQuery)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onConfigure(db: SQLiteDatabase) {
|
override fun onConfigure(db: SQLiteDatabase) {
|
||||||
|
@ -11,6 +11,7 @@ import com.pushtorefresh.storio.sqlite.queries.InsertQuery
|
|||||||
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
|
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
|
||||||
|
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_BOOKMARK
|
||||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_CHAPTER_NUMBER
|
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_CHAPTER_NUMBER
|
||||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_DATE_FETCH
|
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_DATE_FETCH
|
||||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_DATE_UPLOAD
|
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_DATE_UPLOAD
|
||||||
@ -41,12 +42,13 @@ class ChapterPutResolver : DefaultPutResolver<Chapter>() {
|
|||||||
.whereArgs(obj.id)
|
.whereArgs(obj.id)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
override fun mapToContentValues(obj: Chapter) = ContentValues(10).apply {
|
override fun mapToContentValues(obj: Chapter) = ContentValues(11).apply {
|
||||||
put(COL_ID, obj.id)
|
put(COL_ID, obj.id)
|
||||||
put(COL_MANGA_ID, obj.manga_id)
|
put(COL_MANGA_ID, obj.manga_id)
|
||||||
put(COL_URL, obj.url)
|
put(COL_URL, obj.url)
|
||||||
put(COL_NAME, obj.name)
|
put(COL_NAME, obj.name)
|
||||||
put(COL_READ, obj.read)
|
put(COL_READ, obj.read)
|
||||||
|
put(COL_BOOKMARK, obj.bookmark)
|
||||||
put(COL_DATE_FETCH, obj.date_fetch)
|
put(COL_DATE_FETCH, obj.date_fetch)
|
||||||
put(COL_DATE_UPLOAD, obj.date_upload)
|
put(COL_DATE_UPLOAD, obj.date_upload)
|
||||||
put(COL_LAST_PAGE_READ, obj.last_page_read)
|
put(COL_LAST_PAGE_READ, obj.last_page_read)
|
||||||
@ -63,6 +65,7 @@ class ChapterGetResolver : DefaultGetResolver<Chapter>() {
|
|||||||
url = cursor.getString(cursor.getColumnIndex(COL_URL))
|
url = cursor.getString(cursor.getColumnIndex(COL_URL))
|
||||||
name = cursor.getString(cursor.getColumnIndex(COL_NAME))
|
name = cursor.getString(cursor.getColumnIndex(COL_NAME))
|
||||||
read = cursor.getInt(cursor.getColumnIndex(COL_READ)) == 1
|
read = cursor.getInt(cursor.getColumnIndex(COL_READ)) == 1
|
||||||
|
bookmark = cursor.getInt(cursor.getColumnIndex(COL_BOOKMARK)) == 1
|
||||||
date_fetch = cursor.getLong(cursor.getColumnIndex(COL_DATE_FETCH))
|
date_fetch = cursor.getLong(cursor.getColumnIndex(COL_DATE_FETCH))
|
||||||
date_upload = cursor.getLong(cursor.getColumnIndex(COL_DATE_UPLOAD))
|
date_upload = cursor.getLong(cursor.getColumnIndex(COL_DATE_UPLOAD))
|
||||||
last_page_read = cursor.getInt(cursor.getColumnIndex(COL_LAST_PAGE_READ))
|
last_page_read = cursor.getInt(cursor.getColumnIndex(COL_LAST_PAGE_READ))
|
||||||
|
@ -10,6 +10,7 @@ import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
|||||||
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
|
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
|
||||||
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||||
import eu.kanade.tachiyomi.data.database.models.History
|
import eu.kanade.tachiyomi.data.database.models.History
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.HistoryImpl
|
||||||
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_CHAPTER_ID
|
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_CHAPTER_ID
|
||||||
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_ID
|
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_ID
|
||||||
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_LAST_READ
|
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_LAST_READ
|
||||||
@ -44,7 +45,7 @@ open class HistoryPutResolver : DefaultPutResolver<History>() {
|
|||||||
|
|
||||||
class HistoryGetResolver : DefaultGetResolver<History>() {
|
class HistoryGetResolver : DefaultGetResolver<History>() {
|
||||||
|
|
||||||
override fun mapFromCursor(cursor: Cursor): History = History().apply {
|
override fun mapFromCursor(cursor: Cursor): History = HistoryImpl().apply {
|
||||||
id = cursor.getLong(cursor.getColumnIndex(COL_ID))
|
id = cursor.getLong(cursor.getColumnIndex(COL_ID))
|
||||||
chapter_id = cursor.getLong(cursor.getColumnIndex(COL_CHAPTER_ID))
|
chapter_id = cursor.getLong(cursor.getColumnIndex(COL_CHAPTER_ID))
|
||||||
last_read = cursor.getLong(cursor.getColumnIndex(COL_LAST_READ))
|
last_read = cursor.getLong(cursor.getColumnIndex(COL_LAST_READ))
|
||||||
|
@ -69,7 +69,7 @@ open class MangaGetResolver : DefaultGetResolver<Manga>() {
|
|||||||
|
|
||||||
override fun mapFromCursor(cursor: Cursor): Manga = MangaImpl().apply {
|
override fun mapFromCursor(cursor: Cursor): Manga = MangaImpl().apply {
|
||||||
id = cursor.getLong(cursor.getColumnIndex(COL_ID))
|
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))
|
url = cursor.getString(cursor.getColumnIndex(COL_URL))
|
||||||
artist = cursor.getString(cursor.getColumnIndex(COL_ARTIST))
|
artist = cursor.getString(cursor.getColumnIndex(COL_ARTIST))
|
||||||
author = cursor.getString(cursor.getColumnIndex(COL_AUTHOR))
|
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.DeleteQuery
|
||||||
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
|
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
|
||||||
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||||
import eu.kanade.tachiyomi.data.database.models.MangaSync
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.database.models.MangaSyncImpl
|
import eu.kanade.tachiyomi.data.database.models.TrackImpl
|
||||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_ID
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_ID
|
||||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_LAST_CHAPTER_READ
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_LAST_CHAPTER_READ
|
||||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_MANGA_ID
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MANGA_ID
|
||||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_REMOTE_ID
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_REMOTE_ID
|
||||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_SCORE
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SCORE
|
||||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_STATUS
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_STATUS
|
||||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_SYNC_ID
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SYNC_ID
|
||||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_TITLE
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TITLE
|
||||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_TOTAL_CHAPTERS
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TOTAL_CHAPTERS
|
||||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.TABLE
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.TABLE
|
||||||
|
|
||||||
class MangaSyncTypeMapping : SQLiteTypeMapping<MangaSync>(
|
class TrackTypeMapping : SQLiteTypeMapping<Track>(
|
||||||
MangaSyncPutResolver(),
|
TrackPutResolver(),
|
||||||
MangaSyncGetResolver(),
|
TrackGetResolver(),
|
||||||
MangaSyncDeleteResolver()
|
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)
|
.table(TABLE)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
override fun mapToUpdateQuery(obj: MangaSync) = UpdateQuery.builder()
|
override fun mapToUpdateQuery(obj: Track) = UpdateQuery.builder()
|
||||||
.table(TABLE)
|
.table(TABLE)
|
||||||
.where("$COL_ID = ?")
|
.where("$COL_ID = ?")
|
||||||
.whereArgs(obj.id)
|
.whereArgs(obj.id)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
override fun mapToContentValues(obj: MangaSync) = ContentValues(9).apply {
|
override fun mapToContentValues(obj: Track) = ContentValues(9).apply {
|
||||||
put(COL_ID, obj.id)
|
put(COL_ID, obj.id)
|
||||||
put(COL_MANGA_ID, obj.manga_id)
|
put(COL_MANGA_ID, obj.manga_id)
|
||||||
put(COL_SYNC_ID, obj.sync_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))
|
id = cursor.getLong(cursor.getColumnIndex(COL_ID))
|
||||||
manga_id = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID))
|
manga_id = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID))
|
||||||
sync_id = cursor.getInt(cursor.getColumnIndex(COL_SYNC_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)
|
.table(TABLE)
|
||||||
.where("$COL_ID = ?")
|
.where("$COL_ID = ?")
|
||||||
.whereArgs(obj.id)
|
.whereArgs(obj.id)
|
@ -1,27 +1,22 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.models
|
package eu.kanade.tachiyomi.data.database.models
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
|
|
||||||
interface Chapter : Serializable {
|
interface Chapter : SChapter, Serializable {
|
||||||
|
|
||||||
var id: Long?
|
var id: Long?
|
||||||
|
|
||||||
var manga_id: Long?
|
var manga_id: Long?
|
||||||
|
|
||||||
var url: String
|
|
||||||
|
|
||||||
var name: String
|
|
||||||
|
|
||||||
var read: Boolean
|
var read: Boolean
|
||||||
|
|
||||||
|
var bookmark: Boolean
|
||||||
|
|
||||||
var last_page_read: Int
|
var last_page_read: Int
|
||||||
|
|
||||||
var date_fetch: Long
|
var date_fetch: Long
|
||||||
|
|
||||||
var date_upload: Long
|
|
||||||
|
|
||||||
var chapter_number: Float
|
|
||||||
|
|
||||||
var source_order: Int
|
var source_order: Int
|
||||||
|
|
||||||
val isRecognizedNumber: Boolean
|
val isRecognizedNumber: Boolean
|
||||||
|
@ -12,6 +12,8 @@ class ChapterImpl : Chapter {
|
|||||||
|
|
||||||
override var read: Boolean = false
|
override var read: Boolean = false
|
||||||
|
|
||||||
|
override var bookmark: Boolean = false
|
||||||
|
|
||||||
override var last_page_read: Int = 0
|
override var last_page_read: Int = 0
|
||||||
|
|
||||||
override var date_fetch: Long = 0
|
override var date_fetch: Long = 0
|
||||||
|
@ -5,27 +5,27 @@ import java.io.Serializable
|
|||||||
/**
|
/**
|
||||||
* Object containing the history statistics of a chapter
|
* Object containing the history statistics of a chapter
|
||||||
*/
|
*/
|
||||||
class History : Serializable {
|
interface History : Serializable {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Id of history object.
|
* Id of history object.
|
||||||
*/
|
*/
|
||||||
var id: Long? = null
|
var id: Long?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chapter id of history object.
|
* Chapter id of history object.
|
||||||
*/
|
*/
|
||||||
var chapter_id: Long = 0
|
var chapter_id: Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Last time chapter was read in time long format
|
* Last time chapter was read in time long format
|
||||||
*/
|
*/
|
||||||
var last_read: Long = 0
|
var last_read: Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Total time chapter was read - todo not yet implemented
|
* Total time chapter was read - todo not yet implemented
|
||||||
*/
|
*/
|
||||||
var time_read: Long = 0
|
var time_read: Long
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
@ -35,10 +35,8 @@ class History : Serializable {
|
|||||||
* @param chapter chapter object
|
* @param chapter chapter object
|
||||||
* @return history object
|
* @return history object
|
||||||
*/
|
*/
|
||||||
fun create(chapter: Chapter): History {
|
fun create(chapter: Chapter): History = HistoryImpl().apply {
|
||||||
val history = History()
|
this.chapter_id = chapter.id!!
|
||||||
history.chapter_id = chapter.id!!
|
|
||||||
return history
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,27 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.database.models
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object containing the history statistics of a chapter
|
||||||
|
*/
|
||||||
|
class HistoryImpl : History {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Id of history object.
|
||||||
|
*/
|
||||||
|
override var id: Long? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chapter id of history object.
|
||||||
|
*/
|
||||||
|
override var chapter_id: Long = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last time chapter was read in time long format
|
||||||
|
*/
|
||||||
|
override var last_read: Long = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total time chapter was read - todo not yet implemented
|
||||||
|
*/
|
||||||
|
override var time_read: Long = 0
|
||||||
|
}
|
@ -1,35 +1,17 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.models
|
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 id: Long?
|
||||||
|
|
||||||
var source: Int
|
var source: Long
|
||||||
|
|
||||||
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 favorite: Boolean
|
var favorite: Boolean
|
||||||
|
|
||||||
var last_update: Long
|
var last_update: Long
|
||||||
|
|
||||||
var initialized: Boolean
|
|
||||||
|
|
||||||
var viewer: Int
|
var viewer: Int
|
||||||
|
|
||||||
var chapter_flags: Int
|
var chapter_flags: Int
|
||||||
@ -38,27 +20,6 @@ interface Manga : Serializable {
|
|||||||
|
|
||||||
var category: Int
|
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) {
|
fun setChapterOrder(order: Int) {
|
||||||
setFlags(order, SORT_MASK)
|
setFlags(order, SORT_MASK)
|
||||||
}
|
}
|
||||||
@ -84,17 +45,16 @@ interface Manga : Serializable {
|
|||||||
get() = chapter_flags and DOWNLOADED_MASK
|
get() = chapter_flags and DOWNLOADED_MASK
|
||||||
set(filter) = setFlags(filter, DOWNLOADED_MASK)
|
set(filter) = setFlags(filter, DOWNLOADED_MASK)
|
||||||
|
|
||||||
|
var bookmarkedFilter: Int
|
||||||
|
get() = chapter_flags and BOOKMARKED_MASK
|
||||||
|
set(filter) = setFlags(filter, BOOKMARKED_MASK)
|
||||||
|
|
||||||
var sorting: Int
|
var sorting: Int
|
||||||
get() = chapter_flags and SORTING_MASK
|
get() = chapter_flags and SORTING_MASK
|
||||||
set(sort) = setFlags(sort, SORTING_MASK)
|
set(sort) = setFlags(sort, SORTING_MASK)
|
||||||
|
|
||||||
companion object {
|
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_DESC = 0x00000000
|
||||||
const val SORT_ASC = 0x00000001
|
const val SORT_ASC = 0x00000001
|
||||||
const val SORT_MASK = 0x00000001
|
const val SORT_MASK = 0x00000001
|
||||||
@ -110,6 +70,10 @@ interface Manga : Serializable {
|
|||||||
const val SHOW_NOT_DOWNLOADED = 0x00000010
|
const val SHOW_NOT_DOWNLOADED = 0x00000010
|
||||||
const val DOWNLOADED_MASK = 0x00000018
|
const val DOWNLOADED_MASK = 0x00000018
|
||||||
|
|
||||||
|
const val SHOW_BOOKMARKED = 0x00000020
|
||||||
|
const val SHOW_NOT_BOOKMARKED = 0x00000040
|
||||||
|
const val BOOKMARKED_MASK = 0x00000060
|
||||||
|
|
||||||
const val SORTING_SOURCE = 0x00000000
|
const val SORTING_SOURCE = 0x00000000
|
||||||
const val SORTING_NUMBER = 0x00000100
|
const val SORTING_NUMBER = 0x00000100
|
||||||
const val SORTING_MASK = 0x00000100
|
const val SORTING_MASK = 0x00000100
|
||||||
@ -118,12 +82,13 @@ interface Manga : Serializable {
|
|||||||
const val DISPLAY_NUMBER = 0x00100000
|
const val DISPLAY_NUMBER = 0x00100000
|
||||||
const val DISPLAY_MASK = 0x00100000
|
const val DISPLAY_MASK = 0x00100000
|
||||||
|
|
||||||
fun create(source: Int): Manga = MangaImpl().apply {
|
fun create(source: Long): Manga = MangaImpl().apply {
|
||||||
this.source = source
|
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
|
url = pathUrl
|
||||||
|
this.title = title
|
||||||
this.source = source
|
this.source = source
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,4 +7,4 @@ package eu.kanade.tachiyomi.data.database.models
|
|||||||
* @param chapter object containing chater
|
* @param chapter object containing chater
|
||||||
* @param history object containing history
|
* @param history object containing history
|
||||||
*/
|
*/
|
||||||
class MangaChapterHistory(val manga: Manga, val chapter: Chapter, val history: History)
|
data class MangaChapterHistory(val manga: Manga, val chapter: Chapter, val history: History)
|
||||||
|
@ -4,7 +4,7 @@ class MangaImpl : Manga {
|
|||||||
|
|
||||||
override var id: Long? = null
|
override var id: Long? = null
|
||||||
|
|
||||||
override var source: Int = 0
|
override var source: Long = -1
|
||||||
|
|
||||||
override lateinit var url: String
|
override lateinit var url: String
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.data.database.models
|
|||||||
|
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
|
|
||||||
interface MangaSync : Serializable {
|
interface Track : Serializable {
|
||||||
|
|
||||||
var id: Long?
|
var id: Long?
|
||||||
|
|
||||||
@ -22,9 +22,7 @@ interface MangaSync : Serializable {
|
|||||||
|
|
||||||
var status: Int
|
var status: Int
|
||||||
|
|
||||||
var update: Boolean
|
fun copyPersonalFrom(other: Track) {
|
||||||
|
|
||||||
fun copyPersonalFrom(other: MangaSync) {
|
|
||||||
last_chapter_read = other.last_chapter_read
|
last_chapter_read = other.last_chapter_read
|
||||||
score = other.score
|
score = other.score
|
||||||
status = other.status
|
status = other.status
|
||||||
@ -32,7 +30,7 @@ interface MangaSync : Serializable {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun create(serviceId: Int): MangaSync = MangaSyncImpl().apply {
|
fun create(serviceId: Int): Track = TrackImpl().apply {
|
||||||
sync_id = serviceId
|
sync_id = serviceId
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,6 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.models
|
package eu.kanade.tachiyomi.data.database.models
|
||||||
|
|
||||||
class MangaSyncImpl : MangaSync {
|
class TrackImpl : Track {
|
||||||
|
|
||||||
override var id: Long? = null
|
override var id: Long? = null
|
||||||
|
|
||||||
@ -20,17 +20,15 @@ class MangaSyncImpl : MangaSync {
|
|||||||
|
|
||||||
override var status: Int = 0
|
override var status: Int = 0
|
||||||
|
|
||||||
override var update: Boolean = false
|
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
if (other == null || javaClass != other.javaClass) return false
|
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 (manga_id != other.manga_id) return false
|
||||||
if (sync_id != mangaSync.sync_id) return false
|
if (sync_id != other.sync_id) return false
|
||||||
return remote_id == mangaSync.remote_id
|
return remote_id == other.remote_id
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
override fun hashCode(): Int {
|
@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.data.database.DbProvider
|
|||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.database.models.MangaChapter
|
import eu.kanade.tachiyomi.data.database.models.MangaChapter
|
||||||
|
import eu.kanade.tachiyomi.data.database.resolvers.ChapterBackupPutResolver
|
||||||
import eu.kanade.tachiyomi.data.database.resolvers.ChapterProgressPutResolver
|
import eu.kanade.tachiyomi.data.database.resolvers.ChapterProgressPutResolver
|
||||||
import eu.kanade.tachiyomi.data.database.resolvers.ChapterSourceOrderPutResolver
|
import eu.kanade.tachiyomi.data.database.resolvers.ChapterSourceOrderPutResolver
|
||||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterGetResolver
|
import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterGetResolver
|
||||||
@ -33,6 +34,25 @@ interface ChapterQueries : DbProvider {
|
|||||||
.withGetResolver(MangaChapterGetResolver.INSTANCE)
|
.withGetResolver(MangaChapterGetResolver.INSTANCE)
|
||||||
.prepare()
|
.prepare()
|
||||||
|
|
||||||
|
fun getChapter(id: Long) = db.get()
|
||||||
|
.`object`(Chapter::class.java)
|
||||||
|
.withQuery(Query.builder()
|
||||||
|
.table(ChapterTable.TABLE)
|
||||||
|
.where("${ChapterTable.COL_ID} = ?")
|
||||||
|
.whereArgs(id)
|
||||||
|
.build())
|
||||||
|
.prepare()
|
||||||
|
|
||||||
|
fun getChapter(url: String) = db.get()
|
||||||
|
.`object`(Chapter::class.java)
|
||||||
|
.withQuery(Query.builder()
|
||||||
|
.table(ChapterTable.TABLE)
|
||||||
|
.where("${ChapterTable.COL_URL} = ?")
|
||||||
|
.whereArgs(url)
|
||||||
|
.build())
|
||||||
|
.prepare()
|
||||||
|
|
||||||
|
|
||||||
fun insertChapter(chapter: Chapter) = db.put().`object`(chapter).prepare()
|
fun insertChapter(chapter: Chapter) = db.put().`object`(chapter).prepare()
|
||||||
|
|
||||||
fun insertChapters(chapters: List<Chapter>) = db.put().objects(chapters).prepare()
|
fun insertChapters(chapters: List<Chapter>) = db.put().objects(chapters).prepare()
|
||||||
@ -41,6 +61,11 @@ interface ChapterQueries : DbProvider {
|
|||||||
|
|
||||||
fun deleteChapters(chapters: List<Chapter>) = db.delete().objects(chapters).prepare()
|
fun deleteChapters(chapters: List<Chapter>) = db.delete().objects(chapters).prepare()
|
||||||
|
|
||||||
|
fun updateChaptersBackup(chapters: List<Chapter>) = db.put()
|
||||||
|
.objects(chapters)
|
||||||
|
.withPutResolver(ChapterBackupPutResolver())
|
||||||
|
.prepare()
|
||||||
|
|
||||||
fun updateChapterProgress(chapter: Chapter) = db.put()
|
fun updateChapterProgress(chapter: Chapter) = db.put()
|
||||||
.`object`(chapter)
|
.`object`(chapter)
|
||||||
.withPutResolver(ChapterProgressPutResolver())
|
.withPutResolver(ChapterProgressPutResolver())
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.queries
|
package eu.kanade.tachiyomi.data.database.queries
|
||||||
|
|
||||||
|
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||||
import com.pushtorefresh.storio.sqlite.queries.RawQuery
|
import com.pushtorefresh.storio.sqlite.queries.RawQuery
|
||||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||||
import eu.kanade.tachiyomi.data.database.models.History
|
import eu.kanade.tachiyomi.data.database.models.History
|
||||||
@ -40,6 +41,14 @@ interface HistoryQueries : DbProvider {
|
|||||||
.build())
|
.build())
|
||||||
.prepare()
|
.prepare()
|
||||||
|
|
||||||
|
fun getHistoryByChapterUrl(chapterUrl: String) = db.get()
|
||||||
|
.`object`(History::class.java)
|
||||||
|
.withQuery(RawQuery.builder()
|
||||||
|
.query(getHistoryByChapterUrl())
|
||||||
|
.args(chapterUrl)
|
||||||
|
.observesTables(HistoryTable.TABLE)
|
||||||
|
.build())
|
||||||
|
.prepare()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the history last read.
|
* Updates the history last read.
|
||||||
@ -60,4 +69,18 @@ interface HistoryQueries : DbProvider {
|
|||||||
.objects(historyList)
|
.objects(historyList)
|
||||||
.withPutResolver(HistoryLastReadPutResolver())
|
.withPutResolver(HistoryLastReadPutResolver())
|
||||||
.prepare()
|
.prepare()
|
||||||
|
|
||||||
|
fun deleteHistory() = db.delete()
|
||||||
|
.byQuery(DeleteQuery.builder()
|
||||||
|
.table(HistoryTable.TABLE)
|
||||||
|
.build())
|
||||||
|
.prepare()
|
||||||
|
|
||||||
|
fun deleteHistoryNoLastRead() = db.delete()
|
||||||
|
.byQuery(DeleteQuery.builder()
|
||||||
|
.table(HistoryTable.TABLE)
|
||||||
|
.where("${HistoryTable.COL_LAST_READ} = ?")
|
||||||
|
.whereArgs(0)
|
||||||
|
.build())
|
||||||
|
.prepare()
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,8 @@ import eu.kanade.tachiyomi.data.database.DbProvider
|
|||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.database.resolvers.LibraryMangaGetResolver
|
import eu.kanade.tachiyomi.data.database.resolvers.LibraryMangaGetResolver
|
||||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaFlagsPutResolver
|
import eu.kanade.tachiyomi.data.database.resolvers.MangaFlagsPutResolver
|
||||||
|
import eu.kanade.tachiyomi.data.database.resolvers.MangaLastUpdatedPutResolver
|
||||||
|
import eu.kanade.tachiyomi.data.database.tables.CategoryTable
|
||||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable
|
import eu.kanade.tachiyomi.data.database.tables.ChapterTable
|
||||||
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable
|
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable
|
||||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||||
@ -24,12 +26,12 @@ interface MangaQueries : DbProvider {
|
|||||||
.listOfObjects(Manga::class.java)
|
.listOfObjects(Manga::class.java)
|
||||||
.withQuery(RawQuery.builder()
|
.withQuery(RawQuery.builder()
|
||||||
.query(libraryQuery)
|
.query(libraryQuery)
|
||||||
.observesTables(MangaTable.TABLE, ChapterTable.TABLE, MangaCategoryTable.TABLE)
|
.observesTables(MangaTable.TABLE, ChapterTable.TABLE, MangaCategoryTable.TABLE, CategoryTable.TABLE)
|
||||||
.build())
|
.build())
|
||||||
.withGetResolver(LibraryMangaGetResolver.INSTANCE)
|
.withGetResolver(LibraryMangaGetResolver.INSTANCE)
|
||||||
.prepare()
|
.prepare()
|
||||||
|
|
||||||
open fun getFavoriteMangas() = db.get()
|
fun getFavoriteMangas() = db.get()
|
||||||
.listOfObjects(Manga::class.java)
|
.listOfObjects(Manga::class.java)
|
||||||
.withQuery(Query.builder()
|
.withQuery(Query.builder()
|
||||||
.table(MangaTable.TABLE)
|
.table(MangaTable.TABLE)
|
||||||
@ -39,7 +41,7 @@ interface MangaQueries : DbProvider {
|
|||||||
.build())
|
.build())
|
||||||
.prepare()
|
.prepare()
|
||||||
|
|
||||||
fun getManga(url: String, sourceId: Int) = db.get()
|
fun getManga(url: String, sourceId: Long) = db.get()
|
||||||
.`object`(Manga::class.java)
|
.`object`(Manga::class.java)
|
||||||
.withQuery(Query.builder()
|
.withQuery(Query.builder()
|
||||||
.table(MangaTable.TABLE)
|
.table(MangaTable.TABLE)
|
||||||
@ -66,6 +68,11 @@ interface MangaQueries : DbProvider {
|
|||||||
.withPutResolver(MangaFlagsPutResolver())
|
.withPutResolver(MangaFlagsPutResolver())
|
||||||
.prepare()
|
.prepare()
|
||||||
|
|
||||||
|
fun updateLastUpdated(manga: Manga) = db.put()
|
||||||
|
.`object`(manga)
|
||||||
|
.withPutResolver(MangaLastUpdatedPutResolver())
|
||||||
|
.prepare()
|
||||||
|
|
||||||
fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare()
|
fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare()
|
||||||
|
|
||||||
fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare()
|
fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare()
|
||||||
@ -78,4 +85,20 @@ interface MangaQueries : DbProvider {
|
|||||||
.build())
|
.build())
|
||||||
.prepare()
|
.prepare()
|
||||||
|
|
||||||
|
fun deleteMangas() = db.delete()
|
||||||
|
.byQuery(DeleteQuery.builder()
|
||||||
|
.table(MangaTable.TABLE)
|
||||||
|
.build())
|
||||||
|
.prepare()
|
||||||
|
|
||||||
|
fun getLastReadManga() = db.get()
|
||||||
|
.listOfObjects(Manga::class.java)
|
||||||
|
.withQuery(RawQuery.builder()
|
||||||
|
.query(getLastReadMangaQuery())
|
||||||
|
.observesTables(MangaTable.TABLE)
|
||||||
|
.build())
|
||||||
|
.prepare()
|
||||||
|
|
||||||
|
fun getTotalChapterManga() = db.get().listOfObjects(Manga::class.java)
|
||||||
|
.withQuery(RawQuery.builder().query(getTotalChapterMangaQuery()).observesTables(MangaTable.TABLE).build()).prepare();
|
||||||
}
|
}
|
@ -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()
|
|
||||||
|
|
||||||
}
|
|
@ -73,6 +73,35 @@ fun getHistoryByMangaId() = """
|
|||||||
WHERE ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} = ? AND ${History.TABLE}.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID}
|
WHERE ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} = ? AND ${History.TABLE}.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
fun getHistoryByChapterUrl() = """
|
||||||
|
SELECT ${History.TABLE}.*
|
||||||
|
FROM ${History.TABLE}
|
||||||
|
JOIN ${Chapter.TABLE}
|
||||||
|
ON ${History.TABLE}.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID}
|
||||||
|
WHERE ${Chapter.TABLE}.${Chapter.COL_URL} = ? AND ${History.TABLE}.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID}
|
||||||
|
"""
|
||||||
|
|
||||||
|
fun getLastReadMangaQuery() = """
|
||||||
|
SELECT ${Manga.TABLE}.*, MAX(${History.TABLE}.${History.COL_LAST_READ}) AS max
|
||||||
|
FROM ${Manga.TABLE}
|
||||||
|
JOIN ${Chapter.TABLE}
|
||||||
|
ON ${Manga.TABLE}.${Manga.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_MANGA_ID}
|
||||||
|
JOIN ${History.TABLE}
|
||||||
|
ON ${Chapter.TABLE}.${Chapter.COL_ID} = ${History.TABLE}.${History.COL_CHAPTER_ID}
|
||||||
|
WHERE ${Manga.TABLE}.${Manga.COL_FAVORITE} = 1
|
||||||
|
GROUP BY ${Manga.TABLE}.${Manga.COL_ID}
|
||||||
|
ORDER BY max DESC
|
||||||
|
"""
|
||||||
|
|
||||||
|
fun getTotalChapterMangaQuery()= """
|
||||||
|
SELECT ${Manga.TABLE}.*
|
||||||
|
FROM ${Manga.TABLE}
|
||||||
|
JOIN ${Chapter.TABLE}
|
||||||
|
ON ${Manga.TABLE}.${Manga.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_MANGA_ID}
|
||||||
|
GROUP BY ${Manga.TABLE}.${Manga.COL_ID}
|
||||||
|
ORDER by COUNT(*)
|
||||||
|
"""
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query to get the categories for a manga.
|
* Query to get the categories for a manga.
|
||||||
*/
|
*/
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.database.resolvers
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
||||||
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
||||||
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
||||||
|
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||||
|
import eu.kanade.tachiyomi.data.database.inTransactionReturn
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
|
import eu.kanade.tachiyomi.data.database.tables.ChapterTable
|
||||||
|
|
||||||
|
class ChapterBackupPutResolver : PutResolver<Chapter>() {
|
||||||
|
|
||||||
|
override fun performPut(db: StorIOSQLite, chapter: Chapter) = db.inTransactionReturn {
|
||||||
|
val updateQuery = mapToUpdateQuery(chapter)
|
||||||
|
val contentValues = mapToContentValues(chapter)
|
||||||
|
|
||||||
|
val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
|
||||||
|
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mapToUpdateQuery(chapter: Chapter) = UpdateQuery.builder()
|
||||||
|
.table(ChapterTable.TABLE)
|
||||||
|
.where("${ChapterTable.COL_URL} = ?")
|
||||||
|
.whereArgs(chapter.url)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun mapToContentValues(chapter: Chapter) = ContentValues(3).apply {
|
||||||
|
put(ChapterTable.COL_READ, chapter.read)
|
||||||
|
put(ChapterTable.COL_BOOKMARK, chapter.bookmark)
|
||||||
|
put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -25,8 +25,9 @@ class ChapterProgressPutResolver : PutResolver<Chapter>() {
|
|||||||
.whereArgs(chapter.id)
|
.whereArgs(chapter.id)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun mapToContentValues(chapter: Chapter) = ContentValues(2).apply {
|
fun mapToContentValues(chapter: Chapter) = ContentValues(3).apply {
|
||||||
put(ChapterTable.COL_READ, chapter.read)
|
put(ChapterTable.COL_READ, chapter.read)
|
||||||
|
put(ChapterTable.COL_BOOKMARK, chapter.bookmark)
|
||||||
put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read)
|
put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,33 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.database.resolvers
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
||||||
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
||||||
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
||||||
|
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||||
|
import eu.kanade.tachiyomi.data.database.inTransactionReturn
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||||
|
|
||||||
|
class MangaLastUpdatedPutResolver : PutResolver<Manga>() {
|
||||||
|
|
||||||
|
override fun performPut(db: StorIOSQLite, manga: Manga) = db.inTransactionReturn {
|
||||||
|
val updateQuery = mapToUpdateQuery(manga)
|
||||||
|
val contentValues = mapToContentValues(manga)
|
||||||
|
|
||||||
|
val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
|
||||||
|
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder()
|
||||||
|
.table(MangaTable.TABLE)
|
||||||
|
.where("${MangaTable.COL_ID} = ?")
|
||||||
|
.whereArgs(manga.id)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
|
||||||
|
put(MangaTable.COL_LAST_UPDATE, manga.last_update)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,8 @@ object ChapterTable {
|
|||||||
|
|
||||||
const val COL_READ = "read"
|
const val COL_READ = "read"
|
||||||
|
|
||||||
|
const val COL_BOOKMARK = "bookmark"
|
||||||
|
|
||||||
const val COL_DATE_FETCH = "date_fetch"
|
const val COL_DATE_FETCH = "date_fetch"
|
||||||
|
|
||||||
const val COL_DATE_UPLOAD = "date_upload"
|
const val COL_DATE_UPLOAD = "date_upload"
|
||||||
@ -31,6 +33,7 @@ object ChapterTable {
|
|||||||
$COL_URL TEXT NOT NULL,
|
$COL_URL TEXT NOT NULL,
|
||||||
$COL_NAME TEXT NOT NULL,
|
$COL_NAME TEXT NOT NULL,
|
||||||
$COL_READ BOOLEAN NOT NULL,
|
$COL_READ BOOLEAN NOT NULL,
|
||||||
|
$COL_BOOKMARK BOOLEAN NOT NULL,
|
||||||
$COL_LAST_PAGE_READ INT NOT NULL,
|
$COL_LAST_PAGE_READ INT NOT NULL,
|
||||||
$COL_CHAPTER_NUMBER FLOAT NOT NULL,
|
$COL_CHAPTER_NUMBER FLOAT NOT NULL,
|
||||||
$COL_SOURCE_ORDER INTEGER NOT NULL,
|
$COL_SOURCE_ORDER INTEGER NOT NULL,
|
||||||
@ -46,4 +49,7 @@ object ChapterTable {
|
|||||||
val sourceOrderUpdateQuery: String
|
val sourceOrderUpdateQuery: String
|
||||||
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SOURCE_ORDER INTEGER DEFAULT 0"
|
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SOURCE_ORDER INTEGER DEFAULT 0"
|
||||||
|
|
||||||
|
val bookmarkUpdateQuery: String
|
||||||
|
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_BOOKMARK BOOLEAN DEFAULT FALSE"
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.tables
|
package eu.kanade.tachiyomi.data.database.tables
|
||||||
|
|
||||||
object MangaSyncTable {
|
object TrackTable {
|
||||||
|
|
||||||
const val TABLE = "manga_sync"
|
const val TABLE = "manga_sync"
|
||||||
|
|
@ -1,450 +1,180 @@
|
|||||||
package eu.kanade.tachiyomi.data.download
|
package eu.kanade.tachiyomi.data.download
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import com.hippo.unifile.UniFile
|
||||||
import com.google.gson.Gson
|
import com.jakewharton.rxrelay.BehaviorRelay
|
||||||
import com.google.gson.reflect.TypeToken
|
|
||||||
import com.google.gson.stream.JsonReader
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.download.model.Download
|
|
||||||
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import eu.kanade.tachiyomi.data.source.Source
|
|
||||||
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.*
|
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import rx.Subscription
|
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
|
||||||
import rx.schedulers.Schedulers
|
|
||||||
import rx.subjects.BehaviorSubject
|
|
||||||
import rx.subjects.PublishSubject
|
|
||||||
import timber.log.Timber
|
|
||||||
import uy.kohesive.injekt.Injekt
|
|
||||||
import uy.kohesive.injekt.api.get
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileReader
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class DownloadManager(
|
|
||||||
private val context: Context,
|
|
||||||
private val sourceManager: SourceManager = Injekt.get(),
|
|
||||||
private val preferences: PreferencesHelper = Injekt.get()
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val gson = Gson()
|
|
||||||
|
|
||||||
private val downloadsQueueSubject = PublishSubject.create<List<Download>>()
|
|
||||||
val runningSubject = BehaviorSubject.create<Boolean>()
|
|
||||||
private var downloadsSubscription: Subscription? = null
|
|
||||||
|
|
||||||
val downloadNotifier by lazy { DownloadNotifier(context) }
|
|
||||||
|
|
||||||
private val threadsSubject = BehaviorSubject.create<Int>()
|
|
||||||
private var threadsSubscription: Subscription? = null
|
|
||||||
|
|
||||||
val queue = DownloadQueue()
|
|
||||||
|
|
||||||
val imageFilenameRegex = "[^\\sa-zA-Z0-9.-]".toRegex()
|
|
||||||
|
|
||||||
val PAGE_LIST_FILE = "index.json"
|
|
||||||
|
|
||||||
@Volatile var isRunning: Boolean = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
private fun initializeSubscriptions() {
|
|
||||||
|
|
||||||
downloadsSubscription?.unsubscribe()
|
|
||||||
|
|
||||||
threadsSubscription = preferences.downloadThreads().asObservable()
|
|
||||||
.subscribe {
|
|
||||||
threadsSubject.onNext(it)
|
|
||||||
downloadNotifier.multipleDownloadThreads = it > 1
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadsSubscription = downloadsQueueSubject.flatMap { Observable.from(it) }
|
|
||||||
.lift(DynamicConcurrentMergeOperator<Download, Download>({ downloadChapter(it) }, threadsSubject))
|
|
||||||
.onBackpressureBuffer()
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe({
|
|
||||||
// Delete successful downloads from queue
|
|
||||||
if (it.status == Download.DOWNLOADED) {
|
|
||||||
// remove downloaded chapter from queue
|
|
||||||
queue.del(it)
|
|
||||||
downloadNotifier.onProgressChange(queue)
|
|
||||||
}
|
|
||||||
if (areAllDownloadsFinished()) {
|
|
||||||
DownloadService.stop(context)
|
|
||||||
}
|
|
||||||
}, { error ->
|
|
||||||
DownloadService.stop(context)
|
|
||||||
Timber.e(error)
|
|
||||||
downloadNotifier.onError(error.message)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!isRunning) {
|
|
||||||
isRunning = true
|
|
||||||
runningSubject.onNext(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun destroySubscriptions() {
|
|
||||||
if (isRunning) {
|
|
||||||
isRunning = false
|
|
||||||
runningSubject.onNext(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (downloadsSubscription != null) {
|
|
||||||
downloadsSubscription?.unsubscribe()
|
|
||||||
downloadsSubscription = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (threadsSubscription != null) {
|
|
||||||
threadsSubscription?.unsubscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a download object for every chapter and add them to the downloads queue
|
|
||||||
fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
|
|
||||||
val source = sourceManager.get(manga.source) as? OnlineSource ?: return
|
|
||||||
|
|
||||||
// Add chapters to queue from the start
|
|
||||||
val sortedChapters = chapters.sortedByDescending { it.source_order }
|
|
||||||
|
|
||||||
// Used to avoid downloading chapters with the same name
|
|
||||||
val addedChapters = ArrayList<String>()
|
|
||||||
val pending = ArrayList<Download>()
|
|
||||||
|
|
||||||
for (chapter in sortedChapters) {
|
|
||||||
if (addedChapters.contains(chapter.name))
|
|
||||||
continue
|
|
||||||
|
|
||||||
addedChapters.add(chapter.name)
|
|
||||||
val download = Download(source, manga, chapter)
|
|
||||||
|
|
||||||
if (!prepareDownload(download)) {
|
|
||||||
queue.add(download)
|
|
||||||
pending.add(download)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize queue size
|
|
||||||
downloadNotifier.initialQueueSize = queue.size
|
|
||||||
// Show notification
|
|
||||||
downloadNotifier.onProgressChange(queue)
|
|
||||||
|
|
||||||
if (isRunning) downloadsQueueSubject.onNext(pending)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public method to check if a chapter is downloaded
|
|
||||||
fun isChapterDownloaded(source: Source, manga: Manga, chapter: Chapter): Boolean {
|
|
||||||
val directory = getAbsoluteChapterDirectory(source, manga, chapter)
|
|
||||||
if (!directory.exists())
|
|
||||||
return false
|
|
||||||
|
|
||||||
val pages = getSavedPageList(source, manga, chapter)
|
|
||||||
return isChapterDownloaded(directory, pages)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare the download. Returns true if the chapter is already downloaded
|
|
||||||
private fun prepareDownload(download: Download): Boolean {
|
|
||||||
// If the chapter is already queued, don't add it again
|
|
||||||
for (queuedDownload in queue) {
|
|
||||||
if (download.chapter.id == queuedDownload.chapter.id)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the directory to the download object for future access
|
|
||||||
download.directory = getAbsoluteChapterDirectory(download)
|
|
||||||
|
|
||||||
// If the directory doesn't exist, the chapter isn't downloaded.
|
|
||||||
if (!download.directory.exists()) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the page list doesn't exist, the chapter isn't downloaded
|
|
||||||
val savedPages = getSavedPageList(download) ?: return false
|
|
||||||
|
|
||||||
// Add the page list to the download object for future access
|
|
||||||
download.pages = savedPages
|
|
||||||
|
|
||||||
// If the number of files matches the number of pages, the chapter is downloaded.
|
|
||||||
// We have the index file, so we check one file more
|
|
||||||
return isChapterDownloaded(download.directory, download.pages)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that all the images are downloaded
|
|
||||||
private fun isChapterDownloaded(directory: File, pages: List<Page>?): Boolean {
|
|
||||||
return pages != null && !pages.isEmpty() && pages.size + 1 == directory.listFiles().size
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download the entire chapter
|
|
||||||
private fun downloadChapter(download: Download): Observable<Download> {
|
|
||||||
DiskUtils.createDirectory(download.directory)
|
|
||||||
|
|
||||||
val pageListObservable: Observable<List<Page>> = if (download.pages == null)
|
|
||||||
// Pull page list from network and add them to download object
|
|
||||||
download.source.fetchPageListFromNetwork(download.chapter)
|
|
||||||
.doOnNext { pages ->
|
|
||||||
download.pages = pages
|
|
||||||
savePageList(download)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
// Or if the page list already exists, start from the file
|
|
||||||
Observable.just(download.pages)
|
|
||||||
|
|
||||||
return Observable.defer {
|
|
||||||
pageListObservable
|
|
||||||
.doOnNext { pages ->
|
|
||||||
download.downloadedImages = 0
|
|
||||||
download.status = Download.DOWNLOADING
|
|
||||||
}
|
|
||||||
// Get all the URLs to the source images, fetch pages if necessary
|
|
||||||
.flatMap { download.source.fetchAllImageUrlsFromPageList(it) }
|
|
||||||
// Start downloading images, consider we can have downloaded images already
|
|
||||||
.concatMap { page -> getOrDownloadImage(page, download) }
|
|
||||||
// Do when page is downloaded.
|
|
||||||
.doOnNext {
|
|
||||||
downloadNotifier.onProgressChange(download, queue)
|
|
||||||
}
|
|
||||||
// Do after download completes
|
|
||||||
.doOnCompleted { onDownloadCompleted(download) }
|
|
||||||
.toList()
|
|
||||||
.map { pages -> download }
|
|
||||||
// If the page list threw, it will resume here
|
|
||||||
.onErrorResumeNext { error ->
|
|
||||||
download.status = Download.ERROR
|
|
||||||
downloadNotifier.onError(error.message, download.chapter.name)
|
|
||||||
Observable.just(download)
|
|
||||||
}
|
|
||||||
}.subscribeOn(Schedulers.io())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the image from the filesystem if it exists or download from network
|
|
||||||
private fun getOrDownloadImage(page: Page, download: Download): Observable<Page> {
|
|
||||||
// If the image URL is empty, do nothing
|
|
||||||
if (page.imageUrl == null)
|
|
||||||
return Observable.just(page)
|
|
||||||
|
|
||||||
val filename = getImageFilename(page)
|
|
||||||
val imagePath = File(download.directory, filename)
|
|
||||||
|
|
||||||
// If the image is already downloaded, do nothing. Otherwise download from network
|
|
||||||
val pageObservable = if (isImageDownloaded(imagePath))
|
|
||||||
Observable.just(page)
|
|
||||||
else
|
|
||||||
downloadImage(page, download.source, download.directory, filename)
|
|
||||||
|
|
||||||
return pageObservable
|
|
||||||
// When the image is ready, set image path, progress (just in case) and status
|
|
||||||
.doOnNext {
|
|
||||||
page.imagePath = imagePath.absolutePath
|
|
||||||
page.progress = 100
|
|
||||||
download.downloadedImages++
|
|
||||||
page.status = Page.READY
|
|
||||||
}
|
|
||||||
// Mark this page as error and allow to download the remaining
|
|
||||||
.onErrorResumeNext {
|
|
||||||
page.progress = 0
|
|
||||||
page.status = Page.ERROR
|
|
||||||
Observable.just(page)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save image on disk
|
|
||||||
private fun downloadImage(page: Page, source: OnlineSource, directory: File, filename: String): Observable<Page> {
|
|
||||||
page.status = Page.DOWNLOAD_IMAGE
|
|
||||||
return source.imageResponse(page)
|
|
||||||
.map {
|
|
||||||
val file = File(directory, filename)
|
|
||||||
try {
|
|
||||||
file.parentFile.mkdirs()
|
|
||||||
it.body().source().saveImageTo(file.outputStream(), preferences.reencodeImage())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
it.close()
|
|
||||||
file.delete()
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
page
|
|
||||||
}
|
|
||||||
// Retry 3 times, waiting 2, 4 and 8 seconds between attempts.
|
|
||||||
.retryWhen(RetryWithDelay(3, { (2 shl it - 1) * 1000 }, Schedulers.trampoline()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public method to get the image from the filesystem. It does NOT provide any way to download the image
|
|
||||||
fun getDownloadedImage(page: Page, chapterDir: File): Observable<Page> {
|
|
||||||
if (page.imageUrl == null) {
|
|
||||||
page.status = Page.ERROR
|
|
||||||
return Observable.just(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
val imagePath = File(chapterDir, getImageFilename(page))
|
|
||||||
|
|
||||||
// When the image is ready, set image path, progress (just in case) and status
|
|
||||||
if (isImageDownloaded(imagePath)) {
|
|
||||||
page.imagePath = imagePath.absolutePath
|
|
||||||
page.progress = 100
|
|
||||||
page.status = Page.READY
|
|
||||||
} else {
|
|
||||||
page.status = Page.ERROR
|
|
||||||
}
|
|
||||||
return Observable.just(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the filename for an image given the page
|
|
||||||
private fun getImageFilename(page: Page): String {
|
|
||||||
val url = page.imageUrl
|
|
||||||
val number = String.format("%03d", page.pageNumber + 1)
|
|
||||||
|
|
||||||
// Try to preserve file extension
|
|
||||||
return when {
|
|
||||||
UrlUtil.isJpg(url) -> "$number.jpg"
|
|
||||||
UrlUtil.isPng(url) -> "$number.png"
|
|
||||||
UrlUtil.isGif(url) -> "$number.gif"
|
|
||||||
else -> Uri.parse(url).lastPathSegment.replace(imageFilenameRegex, "_")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isImageDownloaded(imagePath: File): Boolean {
|
|
||||||
return imagePath.exists()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called when a download finishes. This doesn't mean the download was successful, so we check it
|
|
||||||
private fun onDownloadCompleted(download: Download) {
|
|
||||||
checkDownloadIsSuccessful(download)
|
|
||||||
savePageList(download)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkDownloadIsSuccessful(download: Download) {
|
|
||||||
var actualProgress = 0
|
|
||||||
var status = Download.DOWNLOADED
|
|
||||||
// If any page has an error, the download result will be error
|
|
||||||
for (page in download.pages!!) {
|
|
||||||
actualProgress += page.progress
|
|
||||||
if (page.status != Page.READY) {
|
|
||||||
status = Download.ERROR
|
|
||||||
downloadNotifier.onError(context.getString(R.string.download_notifier_page_ready_error), download.chapter.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Ensure that the chapter folder has all the images
|
|
||||||
if (!isChapterDownloaded(download.directory, download.pages)) {
|
|
||||||
status = Download.ERROR
|
|
||||||
downloadNotifier.onError(context.getString(R.string.download_notifier_page_error), download.chapter.name)
|
|
||||||
}
|
|
||||||
download.totalProgress = actualProgress
|
|
||||||
download.status = status
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the page list from the chapter's directory if it exists, null otherwise
|
|
||||||
fun getSavedPageList(source: Source, manga: Manga, chapter: Chapter): List<Page>? {
|
|
||||||
val chapterDir = getAbsoluteChapterDirectory(source, manga, chapter)
|
|
||||||
val pagesFile = File(chapterDir, PAGE_LIST_FILE)
|
|
||||||
|
|
||||||
return try {
|
|
||||||
JsonReader(FileReader(pagesFile)).use {
|
|
||||||
val collectionType = object : TypeToken<List<Page>>() {}.type
|
|
||||||
gson.fromJson(it, collectionType)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shortcut for the method above
|
|
||||||
private fun getSavedPageList(download: Download): List<Page>? {
|
|
||||||
return getSavedPageList(download.source, download.manga, download.chapter)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the page list to the chapter's directory
|
|
||||||
fun savePageList(source: Source, manga: Manga, chapter: Chapter, pages: List<Page>) {
|
|
||||||
val chapterDir = getAbsoluteChapterDirectory(source, manga, chapter)
|
|
||||||
val pagesFile = File(chapterDir, PAGE_LIST_FILE)
|
|
||||||
|
|
||||||
pagesFile.outputStream().use {
|
|
||||||
try {
|
|
||||||
it.write(gson.toJson(pages).toByteArray())
|
|
||||||
it.flush()
|
|
||||||
} catch (error: Exception) {
|
|
||||||
Timber.e(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shortcut for the method above
|
|
||||||
private fun savePageList(download: Download) {
|
|
||||||
savePageList(download.source, download.manga, download.chapter, download.pages!!)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAbsoluteMangaDirectory(source: Source, manga: Manga): File {
|
|
||||||
val mangaRelativePath = source.toString() +
|
|
||||||
File.separator +
|
|
||||||
manga.title.replace("[^\\sa-zA-Z0-9.-]".toRegex(), "_")
|
|
||||||
|
|
||||||
return File(preferences.downloadsDirectory().getOrDefault(), mangaRelativePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the absolute path to the chapter directory
|
|
||||||
fun getAbsoluteChapterDirectory(source: Source, manga: Manga, chapter: Chapter): File {
|
|
||||||
val chapterRelativePath = chapter.name.replace("[^\\sa-zA-Z0-9.-]".toRegex(), "_")
|
|
||||||
|
|
||||||
return File(getAbsoluteMangaDirectory(source, manga), chapterRelativePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shortcut for the method above
|
|
||||||
private fun getAbsoluteChapterDirectory(download: Download): File {
|
|
||||||
return getAbsoluteChapterDirectory(download.source, download.manga, download.chapter)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteChapter(source: Source, manga: Manga, chapter: Chapter) {
|
|
||||||
val path = getAbsoluteChapterDirectory(source, manga, chapter)
|
|
||||||
DiskUtils.deleteFiles(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun areAllDownloadsFinished(): Boolean {
|
|
||||||
for (download in queue) {
|
|
||||||
if (download.status <= Download.DOWNLOADING)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is used to manage chapter downloads in the application. It must be instantiated once
|
||||||
|
* and retrieved through dependency injection. You can use this class to queue new chapters or query
|
||||||
|
* downloaded chapters.
|
||||||
|
*
|
||||||
|
* @param context the application context.
|
||||||
|
*/
|
||||||
|
class DownloadManager(context: Context) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads provider, used to retrieve the folders where the chapters are or should be stored.
|
||||||
|
*/
|
||||||
|
private val provider = DownloadProvider(context)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloader whose only task is to download chapters.
|
||||||
|
*/
|
||||||
|
private val downloader = Downloader(context, provider)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads queue, where the pending chapters are stored.
|
||||||
|
*/
|
||||||
|
val queue: DownloadQueue
|
||||||
|
get() = downloader.queue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subject for subscribing to downloader status.
|
||||||
|
*/
|
||||||
|
val runningRelay: BehaviorRelay<Boolean>
|
||||||
|
get() = downloader.runningRelay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the downloader to begin downloads.
|
||||||
|
*
|
||||||
|
* @return true if it's started, false otherwise (empty queue).
|
||||||
|
*/
|
||||||
fun startDownloads(): Boolean {
|
fun startDownloads(): Boolean {
|
||||||
if (queue.isEmpty())
|
return downloader.start()
|
||||||
return false
|
}
|
||||||
|
|
||||||
if (downloadsSubscription == null || downloadsSubscription!!.isUnsubscribed)
|
/**
|
||||||
initializeSubscriptions()
|
* Tells the downloader to stop downloads.
|
||||||
|
*
|
||||||
|
* @param reason an optional reason for being stopped, used to notify the user.
|
||||||
|
*/
|
||||||
|
fun stopDownloads(reason: String? = null) {
|
||||||
|
downloader.stop(reason)
|
||||||
|
}
|
||||||
|
|
||||||
val pending = ArrayList<Download>()
|
/**
|
||||||
for (download in queue) {
|
* Tells the downloader to pause downloads.
|
||||||
if (download.status != Download.DOWNLOADED) {
|
*/
|
||||||
if (download.status != Download.QUEUE) download.status = Download.QUEUE
|
fun pauseDownloads() {
|
||||||
pending.add(download)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the downloader to enqueue the given list of chapters.
|
||||||
|
*
|
||||||
|
* @param manga the manga of the chapters.
|
||||||
|
* @param chapters the list of chapters to enqueue.
|
||||||
|
*/
|
||||||
|
fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
|
||||||
|
downloader.queueChapters(manga, chapters)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the page list of a downloaded chapter.
|
||||||
|
*
|
||||||
|
* @param source the source of the chapter.
|
||||||
|
* @param manga the manga of the chapter.
|
||||||
|
* @param chapter the downloaded chapter.
|
||||||
|
* @return an observable containing the list of pages from the chapter.
|
||||||
|
*/
|
||||||
|
fun buildPageList(source: Source, manga: Manga, chapter: Chapter): Observable<List<Page>> {
|
||||||
|
return buildPageList(provider.findChapterDir(source, manga, chapter))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the page list of a downloaded chapter.
|
||||||
|
*
|
||||||
|
* @param chapterDir the file where the chapter is downloaded.
|
||||||
|
* @return an observable containing the list of pages from the chapter.
|
||||||
|
*/
|
||||||
|
private fun buildPageList(chapterDir: UniFile?): Observable<List<Page>> {
|
||||||
|
return Observable.fromCallable {
|
||||||
|
val files = chapterDir?.listFiles().orEmpty()
|
||||||
|
.filter { "image" in it.type.orEmpty() }
|
||||||
|
|
||||||
|
if (files.isEmpty()) {
|
||||||
|
throw Exception("Page list is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
files.sortedBy { it.name }
|
||||||
|
.mapIndexed { i, file ->
|
||||||
|
Page(i, uri = file.uri).apply { status = Page.READY }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
downloadsQueueSubject.onNext(pending)
|
|
||||||
|
|
||||||
return !pending.isEmpty()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopDownloads(errorMessage: String? = null) {
|
/**
|
||||||
destroySubscriptions()
|
* Returns the directory name for a manga.
|
||||||
for (download in queue) {
|
*
|
||||||
if (download.status == Download.DOWNLOADING) {
|
* @param manga the manga to query.
|
||||||
download.status = Download.ERROR
|
*/
|
||||||
}
|
fun getMangaDirName(manga: Manga): String {
|
||||||
}
|
return provider.getMangaDirName(manga)
|
||||||
errorMessage?.let { downloadNotifier.onError(it) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearQueue() {
|
/**
|
||||||
queue.clear()
|
* Returns the directory name for the given chapter.
|
||||||
downloadNotifier.onClear()
|
*
|
||||||
|
* @param chapter the chapter to query.
|
||||||
|
*/
|
||||||
|
fun getChapterDirName(chapter: Chapter): String {
|
||||||
|
return provider.getChapterDirName(chapter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the download directory for a source if it exists.
|
||||||
|
*
|
||||||
|
* @param source the source to query.
|
||||||
|
*/
|
||||||
|
fun findSourceDir(source: Source): UniFile? {
|
||||||
|
return provider.findSourceDir(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the directory for the given manga, if it exists.
|
||||||
|
*
|
||||||
|
* @param source the source of the manga.
|
||||||
|
* @param manga the manga to query.
|
||||||
|
*/
|
||||||
|
fun findMangaDir(source: Source, manga: Manga): UniFile? {
|
||||||
|
return provider.findMangaDir(source, manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the directory for the given chapter, if it exists.
|
||||||
|
*
|
||||||
|
* @param source the source of the chapter.
|
||||||
|
* @param manga the manga of the chapter.
|
||||||
|
* @param chapter the chapter to query.
|
||||||
|
*/
|
||||||
|
fun findChapterDir(source: Source, manga: Manga, chapter: Chapter): UniFile? {
|
||||||
|
return provider.findChapterDir(source, manga, chapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the directory of a downloaded chapter.
|
||||||
|
*
|
||||||
|
* @param source the source of the chapter.
|
||||||
|
* @param manga the manga of the chapter.
|
||||||
|
* @param chapter the chapter to delete.
|
||||||
|
*/
|
||||||
|
fun deleteChapter(source: Source, manga: Manga, chapter: Chapter) {
|
||||||
|
provider.findChapterDir(source, manga, chapter)?.delete()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,29 +1,31 @@
|
|||||||
package eu.kanade.tachiyomi.data.download
|
package eu.kanade.tachiyomi.data.download
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.support.v4.app.NotificationCompat
|
import android.support.v4.app.NotificationCompat
|
||||||
import eu.kanade.tachiyomi.Constants
|
import eu.kanade.tachiyomi.Constants
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.download.model.Download
|
import eu.kanade.tachiyomi.data.download.model.Download
|
||||||
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
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
|
import eu.kanade.tachiyomi.util.notificationManager
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DownloadNotifier is used to show notifications when downloading one or multiple chapters.
|
* DownloadNotifier is used to show notifications when downloading one or multiple chapters.
|
||||||
*
|
*
|
||||||
* @param context context of application
|
* @param context context of application
|
||||||
*/
|
*/
|
||||||
class DownloadNotifier(private val context: Context) {
|
internal class DownloadNotifier(private val context: Context) {
|
||||||
/**
|
/**
|
||||||
* Notification builder.
|
* Notification builder.
|
||||||
*/
|
*/
|
||||||
private val notificationBuilder = NotificationCompat.Builder(context)
|
private val notification by lazy {
|
||||||
|
NotificationCompat.Builder(context)
|
||||||
/**
|
.setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher))
|
||||||
* Id of the notification.
|
}
|
||||||
*/
|
|
||||||
private val notificationId: Int
|
|
||||||
get() = Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Status of download. Used for correct notification icon.
|
* Status of download. Used for correct notification icon.
|
||||||
@ -33,12 +35,59 @@ class DownloadNotifier(private val context: Context) {
|
|||||||
/**
|
/**
|
||||||
* The size of queue on start download.
|
* The size of queue on start download.
|
||||||
*/
|
*/
|
||||||
internal var initialQueueSize = 0
|
var initialQueueSize = 0
|
||||||
|
get() = field
|
||||||
|
set(value) {
|
||||||
|
if (value != 0){
|
||||||
|
isSingleChapter = (value == 1)
|
||||||
|
}
|
||||||
|
field = value
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simultaneous download setting > 1.
|
* Simultaneous download setting > 1.
|
||||||
*/
|
*/
|
||||||
internal var multipleDownloadThreads = false
|
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.
|
||||||
|
*
|
||||||
|
* @param id the id of the notification.
|
||||||
|
*/
|
||||||
|
private fun NotificationCompat.Builder.show(id: Int = Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID) {
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
fun dismiss() {
|
||||||
|
context.notificationManager.cancel(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when download progress changes.
|
* Called when download progress changes.
|
||||||
@ -46,50 +95,41 @@ class DownloadNotifier(private val context: Context) {
|
|||||||
*
|
*
|
||||||
* @param queue the queue containing downloads.
|
* @param queue the queue containing downloads.
|
||||||
*/
|
*/
|
||||||
internal fun onProgressChange(queue: DownloadQueue) {
|
fun onProgressChange(queue: DownloadQueue) {
|
||||||
if (multipleDownloadThreads) {
|
if (multipleDownloadThreads) {
|
||||||
doOnProgressChange(null, queue)
|
doOnProgressChange(null, queue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when download progress changes
|
* Called when download progress changes.
|
||||||
* Note: Only accepted when single download active
|
* Note: Only accepted when single download active.
|
||||||
*
|
*
|
||||||
* @param download download object containing download information
|
* @param download download object containing download information.
|
||||||
* @param queue the queue containing downloads
|
* @param queue the queue containing downloads.
|
||||||
*/
|
*/
|
||||||
internal fun onProgressChange(download: Download, queue: DownloadQueue) {
|
fun onProgressChange(download: Download, queue: DownloadQueue) {
|
||||||
if (!multipleDownloadThreads) {
|
if (!multipleDownloadThreads) {
|
||||||
doOnProgressChange(download, queue)
|
doOnProgressChange(download, queue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show notification progress of chapter
|
* Show notification progress of chapter.
|
||||||
*
|
*
|
||||||
* @param download download object containing download information
|
* @param download download object containing download information.
|
||||||
* @param queue the queue containing downloads
|
* @param queue the queue containing downloads.
|
||||||
*/
|
*/
|
||||||
private fun doOnProgressChange(download: Download?, queue: DownloadQueue) {
|
private fun doOnProgressChange(download: Download?, queue: DownloadQueue) {
|
||||||
// Check if download is completed
|
|
||||||
if (multipleDownloadThreads) {
|
|
||||||
if (queue.isEmpty()) {
|
|
||||||
onComplete(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (download != null && download.pages!!.size == download.downloadedImages) {
|
|
||||||
onComplete(download)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create notification
|
// Create notification
|
||||||
with (notificationBuilder) {
|
with(notification) {
|
||||||
// Check if icon needs refresh
|
// Check if first call.
|
||||||
if (!isDownloading) {
|
if (!isDownloading) {
|
||||||
setSmallIcon(android.R.drawable.stat_sys_download)
|
setSmallIcon(android.R.drawable.stat_sys_download)
|
||||||
|
setAutoCancel(false)
|
||||||
|
clearActions()
|
||||||
|
// Open download manager when clicked
|
||||||
|
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
|
||||||
isDownloading = true
|
isDownloading = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,11 +145,10 @@ class DownloadNotifier(private val context: Context) {
|
|||||||
setProgress(initialQueueSize, initialQueueSize - queue.size, false)
|
setProgress(initialQueueSize, initialQueueSize - queue.size, false)
|
||||||
} else {
|
} else {
|
||||||
download?.let {
|
download?.let {
|
||||||
if (it.chapter.name.length >= 33)
|
val title = it.manga.title.chop(15)
|
||||||
setContentTitle(it.chapter.name.slice(IntRange(0, 30)).plus("..."))
|
val quotedTitle = Pattern.quote(title)
|
||||||
else
|
val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "")
|
||||||
setContentTitle(it.chapter.name)
|
setContentTitle("$title - $chapter".chop(30))
|
||||||
|
|
||||||
setContentText(context.getString(R.string.chapter_downloading_progress)
|
setContentText(context.getString(R.string.chapter_downloading_progress)
|
||||||
.format(it.downloadedImages, it.pages!!.size))
|
.format(it.downloadedImages, it.pages!!.size))
|
||||||
setProgress(it.pages!!.size, it.downloadedImages, false)
|
setProgress(it.pages!!.size, it.downloadedImages, false)
|
||||||
@ -118,25 +157,34 @@ class DownloadNotifier(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Displays the progress bar on notification
|
// Displays the progress bar on notification
|
||||||
context.notificationManager.notify(notificationId, notificationBuilder.build())
|
notification.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when chapter is downloaded
|
* Show notification when download is paused.
|
||||||
*
|
|
||||||
* @param download download object containing download information
|
|
||||||
*/
|
*/
|
||||||
private fun onComplete(download: Download?) {
|
fun onDownloadPaused() {
|
||||||
// Create notification.
|
with(notification) {
|
||||||
with(notificationBuilder) {
|
setContentTitle(context.getString(R.string.chapter_paused))
|
||||||
setContentTitle(download?.chapter?.name ?: context.getString(R.string.app_name))
|
setContentText(context.getString(R.string.download_notifier_download_paused))
|
||||||
setContentText(context.getString(R.string.update_check_notification_download_complete))
|
setSmallIcon(R.drawable.ic_av_pause_grey_24dp_img)
|
||||||
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
setAutoCancel(false)
|
||||||
setProgress(0, 0, 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.
|
// Show notification.
|
||||||
context.notificationManager.notify(notificationId, notificationBuilder.build())
|
notification.show()
|
||||||
|
|
||||||
// Reset initial values
|
// Reset initial values
|
||||||
isDownloading = false
|
isDownloading = false
|
||||||
@ -144,30 +192,80 @@ class DownloadNotifier(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the notification message
|
* Called when chapter is downloaded.
|
||||||
|
*
|
||||||
|
* @param download download object containing download information.
|
||||||
*/
|
*/
|
||||||
internal fun onClear() {
|
fun onDownloadCompleted(download: Download, queue: DownloadQueue) {
|
||||||
context.notificationManager.cancel(notificationId)
|
// Check if last download
|
||||||
|
if (!queue.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Create notification.
|
||||||
|
with(notification) {
|
||||||
|
val title = download.manga.title.chop(15)
|
||||||
|
val quotedTitle = Pattern.quote(title)
|
||||||
|
val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "")
|
||||||
|
setContentTitle("$title - $chapter".chop(30))
|
||||||
|
setContentText(context.getString(R.string.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show notification.
|
||||||
|
notification.show()
|
||||||
|
|
||||||
|
// Reset initial values
|
||||||
|
isDownloading = false
|
||||||
|
initialQueueSize = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called on error while downloading chapter
|
* Called when the downloader receives a warning.
|
||||||
*
|
*
|
||||||
* @param error string containing error information
|
* @param reason the text to show.
|
||||||
* @param chapter string containing chapter title
|
|
||||||
*/
|
*/
|
||||||
internal fun onError(error: String? = null, chapter: String? = null) {
|
fun onWarning(reason: String) {
|
||||||
// Create notification
|
with(notification) {
|
||||||
with(notificationBuilder) {
|
setContentTitle(context.getString(R.string.download_notifier_downloader_title))
|
||||||
setContentTitle(chapter ?: context.getString(R.string.download_notifier_title_error))
|
setContentText(reason)
|
||||||
setContentText(error ?: context.getString(R.string.download_notifier_unkown_error))
|
|
||||||
setSmallIcon(android.R.drawable.stat_sys_warning)
|
setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||||
|
setAutoCancel(true)
|
||||||
|
clearActions()
|
||||||
|
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
|
||||||
setProgress(0, 0, false)
|
setProgress(0, 0, false)
|
||||||
}
|
}
|
||||||
context.notificationManager.notify(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID, notificationBuilder.build())
|
notification.show()
|
||||||
|
|
||||||
// Reset download information
|
// Reset download information
|
||||||
onClear()
|
isDownloading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the downloader receives an error. It's shown as a separate notification to avoid
|
||||||
|
* being overwritten.
|
||||||
|
*
|
||||||
|
* @param error string containing error information.
|
||||||
|
* @param chapter string containing chapter title.
|
||||||
|
*/
|
||||||
|
fun onError(error: String? = null, chapter: String? = null) {
|
||||||
|
// Create notification
|
||||||
|
with(notification) {
|
||||||
|
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
|
isDownloading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,111 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.download
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import com.hippo.unifile.UniFile
|
||||||
|
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.source.Source
|
||||||
|
import eu.kanade.tachiyomi.util.DiskUtil
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is used to provide the directories where the downloads should be saved.
|
||||||
|
* It uses the following path scheme: /<root downloads dir>/<source name>/<manga>/<chapter>
|
||||||
|
*
|
||||||
|
* @param context the application context.
|
||||||
|
*/
|
||||||
|
class DownloadProvider(private val context: Context) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preferences helper.
|
||||||
|
*/
|
||||||
|
private val preferences: PreferencesHelper by injectLazy()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The root directory for downloads.
|
||||||
|
*/
|
||||||
|
private var downloadsDir = preferences.downloadsDirectory().getOrDefault().let {
|
||||||
|
UniFile.fromUri(context, Uri.parse(it))
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
preferences.downloadsDirectory().asObservable()
|
||||||
|
.skip(1)
|
||||||
|
.subscribe { downloadsDir = UniFile.fromUri(context, Uri.parse(it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the download directory for a manga. For internal use only.
|
||||||
|
*
|
||||||
|
* @param source the source of the manga.
|
||||||
|
* @param manga the manga to query.
|
||||||
|
*/
|
||||||
|
internal fun getMangaDir(source: Source, manga: Manga): UniFile {
|
||||||
|
return downloadsDir
|
||||||
|
.createDirectory(getSourceDirName(source))
|
||||||
|
.createDirectory(getMangaDirName(manga))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the download directory for a source if it exists.
|
||||||
|
*
|
||||||
|
* @param source the source to query.
|
||||||
|
*/
|
||||||
|
fun findSourceDir(source: Source): UniFile? {
|
||||||
|
return downloadsDir.findFile(getSourceDirName(source))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the download directory for a manga if it exists.
|
||||||
|
*
|
||||||
|
* @param source the source of the manga.
|
||||||
|
* @param manga the manga to query.
|
||||||
|
*/
|
||||||
|
fun findMangaDir(source: Source, manga: Manga): UniFile? {
|
||||||
|
val sourceDir = findSourceDir(source)
|
||||||
|
return sourceDir?.findFile(getMangaDirName(manga))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the download directory for a chapter if it exists.
|
||||||
|
*
|
||||||
|
* @param source the source of the chapter.
|
||||||
|
* @param manga the manga of the chapter.
|
||||||
|
* @param chapter the chapter to query.
|
||||||
|
*/
|
||||||
|
fun findChapterDir(source: Source, manga: Manga, chapter: Chapter): UniFile? {
|
||||||
|
val mangaDir = findMangaDir(source, manga)
|
||||||
|
return mangaDir?.findFile(getChapterDirName(chapter))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the download directory name for a source.
|
||||||
|
*
|
||||||
|
* @param source the source to query.
|
||||||
|
*/
|
||||||
|
fun getSourceDirName(source: Source): String {
|
||||||
|
return source.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the download directory name for a manga.
|
||||||
|
*
|
||||||
|
* @param manga the manga to query.
|
||||||
|
*/
|
||||||
|
fun getMangaDirName(manga: Manga): String {
|
||||||
|
return DiskUtil.buildValidFilename(manga.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the chapter directory name for a chapter.
|
||||||
|
*
|
||||||
|
* @param chapter the chapter to query.
|
||||||
|
*/
|
||||||
|
fun getChapterDirName(chapter: Chapter): String {
|
||||||
|
return DiskUtil.buildValidFilename(chapter.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -3,130 +3,177 @@ package eu.kanade.tachiyomi.data.download
|
|||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.NetworkInfo.State.CONNECTED
|
||||||
|
import android.net.NetworkInfo.State.DISCONNECTED
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import com.github.pwittchen.reactivenetwork.library.ConnectivityStatus
|
import com.github.pwittchen.reactivenetwork.library.Connectivity
|
||||||
import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork
|
import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork
|
||||||
|
import com.jakewharton.rxrelay.BehaviorRelay
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
import eu.kanade.tachiyomi.util.connectivityManager
|
||||||
|
import eu.kanade.tachiyomi.util.plusAssign
|
||||||
|
import eu.kanade.tachiyomi.util.powerManager
|
||||||
import eu.kanade.tachiyomi.util.toast
|
import eu.kanade.tachiyomi.util.toast
|
||||||
import rx.Subscription
|
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
import rx.android.schedulers.AndroidSchedulers
|
||||||
import rx.schedulers.Schedulers
|
import rx.schedulers.Schedulers
|
||||||
|
import rx.subscriptions.CompositeSubscription
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This service is used to manage the downloader. The system can decide to stop the service, in
|
||||||
|
* which case the downloader is also stopped. It's also stopped while there's no network available.
|
||||||
|
* While the downloader is running, a wake lock will be held.
|
||||||
|
*/
|
||||||
class DownloadService : Service() {
|
class DownloadService : Service() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay used to know when the service is running.
|
||||||
|
*/
|
||||||
|
val runningRelay: BehaviorRelay<Boolean> = BehaviorRelay.create(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts this service.
|
||||||
|
*
|
||||||
|
* @param context the application context.
|
||||||
|
*/
|
||||||
fun start(context: Context) {
|
fun start(context: Context) {
|
||||||
context.startService(Intent(context, DownloadService::class.java))
|
context.startService(Intent(context, DownloadService::class.java))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops this service.
|
||||||
|
*
|
||||||
|
* @param context the application context.
|
||||||
|
*/
|
||||||
fun stop(context: Context) {
|
fun stop(context: Context) {
|
||||||
context.stopService(Intent(context, DownloadService::class.java))
|
context.stopService(Intent(context, DownloadService::class.java))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val downloadManager: DownloadManager by injectLazy()
|
/**
|
||||||
val preferences: PreferencesHelper by injectLazy()
|
* Download manager.
|
||||||
|
*/
|
||||||
|
private val downloadManager: DownloadManager by injectLazy()
|
||||||
|
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
/**
|
||||||
private var networkChangeSubscription: Subscription? = null
|
* Preferences helper.
|
||||||
private var queueRunningSubscription: Subscription? = null
|
*/
|
||||||
private var isRunning: Boolean = false
|
private val preferences: PreferencesHelper by injectLazy()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wake lock to prevent the device to enter sleep mode.
|
||||||
|
*/
|
||||||
|
private val wakeLock by lazy {
|
||||||
|
powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "DownloadService:WakeLock")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscriptions to store while the service is running.
|
||||||
|
*/
|
||||||
|
private lateinit var subscriptions: CompositeSubscription
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the service is created.
|
||||||
|
*/
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
runningRelay.call(true)
|
||||||
createWakeLock()
|
subscriptions = CompositeSubscription()
|
||||||
|
listenDownloaderState()
|
||||||
listenQueueRunningChanges()
|
|
||||||
listenNetworkChanges()
|
listenNetworkChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
/**
|
||||||
return Service.START_STICKY
|
* Called when the service is destroyed.
|
||||||
}
|
*/
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
queueRunningSubscription?.unsubscribe()
|
runningRelay.call(false)
|
||||||
networkChangeSubscription?.unsubscribe()
|
subscriptions.unsubscribe()
|
||||||
downloadManager.destroySubscriptions()
|
downloadManager.stopDownloads()
|
||||||
destroyWakeLock()
|
wakeLock.releaseIfNeeded()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not used.
|
||||||
|
*/
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
return Service.START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not used.
|
||||||
|
*/
|
||||||
override fun onBind(intent: Intent): IBinder? {
|
override fun onBind(intent: Intent): IBinder? {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens to network changes.
|
||||||
|
*
|
||||||
|
* @see onNetworkStateChanged
|
||||||
|
*/
|
||||||
private fun listenNetworkChanges() {
|
private fun listenNetworkChanges() {
|
||||||
networkChangeSubscription = ReactiveNetwork().enableInternetCheck()
|
subscriptions += ReactiveNetwork.observeNetworkConnectivity(applicationContext)
|
||||||
.observeConnectivity(applicationContext)
|
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe({ state ->
|
.subscribe({ state -> onNetworkStateChanged(state)
|
||||||
when (state) {
|
|
||||||
ConnectivityStatus.WIFI_CONNECTED_HAS_INTERNET -> {
|
|
||||||
// If there are no remaining downloads, destroy the service
|
|
||||||
if (!isRunning && !downloadManager.startDownloads()) {
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ConnectivityStatus.MOBILE_CONNECTED -> {
|
|
||||||
if (!preferences.downloadOnlyOverWifi()) {
|
|
||||||
if (!isRunning && !downloadManager.startDownloads()) {
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
} else if (isRunning) {
|
|
||||||
downloadManager.stopDownloads(getString(R.string.download_notifier_text_only_wifi))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
if (isRunning) {
|
|
||||||
downloadManager.stopDownloads(getString(R.string.download_notifier_text_only_wifi))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, { error ->
|
}, { error ->
|
||||||
toast(R.string.download_queue_error)
|
toast(R.string.download_queue_error)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun listenQueueRunningChanges() {
|
/**
|
||||||
queueRunningSubscription = downloadManager.runningSubject.subscribe { running ->
|
* Called when the network state changes.
|
||||||
isRunning = running
|
*
|
||||||
|
* @param connectivity the new network state.
|
||||||
|
*/
|
||||||
|
private fun onNetworkStateChanged(connectivity: Connectivity) {
|
||||||
|
when (connectivity.state) {
|
||||||
|
CONNECTED -> {
|
||||||
|
if (preferences.downloadOnlyOverWifi() && connectivityManager.isActiveNetworkMetered) {
|
||||||
|
downloadManager.stopDownloads(getString(R.string.download_notifier_text_only_wifi))
|
||||||
|
} else {
|
||||||
|
val started = downloadManager.startDownloads()
|
||||||
|
if (!started) stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DISCONNECTED -> {
|
||||||
|
downloadManager.stopDownloads(getString(R.string.download_notifier_no_network))
|
||||||
|
}
|
||||||
|
else -> { /* Do nothing */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens to downloader status. Enables or disables the wake lock depending on the status.
|
||||||
|
*/
|
||||||
|
private fun listenDownloaderState() {
|
||||||
|
subscriptions += downloadManager.runningRelay.subscribe { running ->
|
||||||
if (running)
|
if (running)
|
||||||
acquireWakeLock()
|
wakeLock.acquireIfNeeded()
|
||||||
else
|
else
|
||||||
releaseWakeLock()
|
wakeLock.releaseIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createWakeLock() {
|
/**
|
||||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
|
* Releases the wake lock if it's held.
|
||||||
PowerManager.PARTIAL_WAKE_LOCK, "DownloadService:WakeLock")
|
*/
|
||||||
|
fun PowerManager.WakeLock.releaseIfNeeded() {
|
||||||
|
if (isHeld) release()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun destroyWakeLock() {
|
/**
|
||||||
if (wakeLock != null && wakeLock!!.isHeld) {
|
* Acquires the wake lock if it's not held.
|
||||||
wakeLock!!.release()
|
*/
|
||||||
wakeLock = null
|
fun PowerManager.WakeLock.acquireIfNeeded() {
|
||||||
}
|
if (!isHeld) acquire()
|
||||||
}
|
|
||||||
|
|
||||||
fun acquireWakeLock() {
|
|
||||||
if (wakeLock != null && !wakeLock!!.isHeld) {
|
|
||||||
wakeLock!!.acquire()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun releaseWakeLock() {
|
|
||||||
if (wakeLock != null && wakeLock!!.isHeld) {
|
|
||||||
wakeLock!!.release()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,135 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.download
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
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.source.SourceManager
|
||||||
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is used to persist active downloads across application restarts.
|
||||||
|
*
|
||||||
|
* @param context the application context.
|
||||||
|
*/
|
||||||
|
class DownloadStore(context: Context) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preference file where active downloads are stored.
|
||||||
|
*/
|
||||||
|
private val preferences = context.getSharedPreferences("active_downloads", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gson instance to serialize/deserialize downloads.
|
||||||
|
*/
|
||||||
|
private val gson: Gson by injectLazy()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source manager.
|
||||||
|
*/
|
||||||
|
private val sourceManager: SourceManager by injectLazy()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database helper.
|
||||||
|
*/
|
||||||
|
private val db: DatabaseHelper by injectLazy()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counter used to keep the queue order.
|
||||||
|
*/
|
||||||
|
private var counter = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a list of downloads to the store.
|
||||||
|
*
|
||||||
|
* @param downloads the list of downloads to add.
|
||||||
|
*/
|
||||||
|
fun addAll(downloads: List<Download>) {
|
||||||
|
val editor = preferences.edit()
|
||||||
|
downloads.forEach { editor.putString(getKey(it), serialize(it)) }
|
||||||
|
editor.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a download from the store.
|
||||||
|
*
|
||||||
|
* @param download the download to remove.
|
||||||
|
*/
|
||||||
|
fun remove(download: Download) {
|
||||||
|
preferences.edit().remove(getKey(download)).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all the downloads from the store.
|
||||||
|
*/
|
||||||
|
fun clear() {
|
||||||
|
preferences.edit().clear().apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the preference's key for the given download.
|
||||||
|
*
|
||||||
|
* @param download the download.
|
||||||
|
*/
|
||||||
|
private fun getKey(download: Download): String {
|
||||||
|
return download.chapter.id!!.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the list of downloads to restore. It should be called in a background thread.
|
||||||
|
*/
|
||||||
|
fun restore(): List<Download> {
|
||||||
|
val objs = preferences.all
|
||||||
|
.mapNotNull { it.value as? String }
|
||||||
|
.map { deserialize(it) }
|
||||||
|
.sortedBy { it.order }
|
||||||
|
|
||||||
|
val downloads = mutableListOf<Download>()
|
||||||
|
if (objs.isNotEmpty()) {
|
||||||
|
val cachedManga = mutableMapOf<Long, Manga?>()
|
||||||
|
for ((mangaId, chapterId) in objs) {
|
||||||
|
val manga = cachedManga.getOrPut(mangaId) {
|
||||||
|
db.getManga(mangaId).executeAsBlocking()
|
||||||
|
} ?: continue
|
||||||
|
val source = sourceManager.get(manga.source) as? HttpSource ?: continue
|
||||||
|
val chapter = db.getChapter(chapterId).executeAsBlocking() ?: continue
|
||||||
|
downloads.add(Download(source, manga, chapter))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the store, downloads will be added again immediately.
|
||||||
|
clear()
|
||||||
|
return downloads
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a download to a string.
|
||||||
|
*
|
||||||
|
* @param download the download to serialize.
|
||||||
|
*/
|
||||||
|
private fun serialize(download: Download): String {
|
||||||
|
val obj = DownloadObject(download.manga.id!!, download.chapter.id!!, counter++)
|
||||||
|
return gson.toJson(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a download from a string.
|
||||||
|
*
|
||||||
|
* @param string the download as string.
|
||||||
|
*/
|
||||||
|
private fun deserialize(string: String): DownloadObject {
|
||||||
|
return gson.fromJson(string, DownloadObject::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class used for download serialization
|
||||||
|
*
|
||||||
|
* @param mangaId the id of the manga.
|
||||||
|
* @param chapterId the id of the chapter.
|
||||||
|
* @param order the order of the download in the queue.
|
||||||
|
*/
|
||||||
|
data class DownloadObject(val mangaId: Long, val chapterId: Long, val order: Int)
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,463 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.download
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import com.hippo.unifile.UniFile
|
||||||
|
import com.jakewharton.rxrelay.BehaviorRelay
|
||||||
|
import com.jakewharton.rxrelay.PublishRelay
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
import eu.kanade.tachiyomi.data.download.model.Download
|
||||||
|
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
|
import eu.kanade.tachiyomi.source.online.fetchAllImageUrlsFromPageList
|
||||||
|
import eu.kanade.tachiyomi.util.*
|
||||||
|
import okhttp3.Response
|
||||||
|
import rx.Observable
|
||||||
|
import rx.android.schedulers.AndroidSchedulers
|
||||||
|
import rx.schedulers.Schedulers
|
||||||
|
import rx.subjects.BehaviorSubject
|
||||||
|
import rx.subscriptions.CompositeSubscription
|
||||||
|
import timber.log.Timber
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is the one in charge of downloading chapters.
|
||||||
|
*
|
||||||
|
* Its [queue] contains the list of chapters to download. In order to download them, the downloader
|
||||||
|
* subscriptions must be running and the list of chapters must be sent to them by [downloadsRelay].
|
||||||
|
*
|
||||||
|
* The queue manipulation must be done in one thread (currently the main thread) to avoid unexpected
|
||||||
|
* behavior, but it's safe to read it from multiple threads.
|
||||||
|
*
|
||||||
|
* @param context the application context.
|
||||||
|
* @param provider the downloads directory provider.
|
||||||
|
*/
|
||||||
|
class Downloader(private val context: Context, private val provider: DownloadProvider) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store for persisting downloads across restarts.
|
||||||
|
*/
|
||||||
|
private val store = DownloadStore(context)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue where active downloads are kept.
|
||||||
|
*/
|
||||||
|
val queue = DownloadQueue(store)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source manager.
|
||||||
|
*/
|
||||||
|
private val sourceManager: SourceManager by injectLazy()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preferences.
|
||||||
|
*/
|
||||||
|
private val preferences: PreferencesHelper by injectLazy()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifier for the downloader state and progress.
|
||||||
|
*/
|
||||||
|
private val notifier by lazy { DownloadNotifier(context) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloader subscriptions.
|
||||||
|
*/
|
||||||
|
private val subscriptions = CompositeSubscription()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subject to do a live update of the number of simultaneous downloads.
|
||||||
|
*/
|
||||||
|
private val threadsSubject = BehaviorSubject.create<Int>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay to send a list of downloads to the downloader.
|
||||||
|
*/
|
||||||
|
private val downloadsRelay = PublishRelay.create<List<Download>>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay to subscribe to the downloader status.
|
||||||
|
*/
|
||||||
|
val runningRelay: BehaviorRelay<Boolean> = BehaviorRelay.create(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the downloader is running.
|
||||||
|
*/
|
||||||
|
@Volatile private var isRunning: Boolean = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
Observable.fromCallable { store.restore() }
|
||||||
|
.map { downloads -> downloads.filter { isDownloadAllowed(it) } }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe({ downloads -> queue.addAll(downloads)
|
||||||
|
}, { error -> Timber.e(error) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the downloader. It doesn't do anything if it's already running or there isn't anything
|
||||||
|
* to download.
|
||||||
|
*
|
||||||
|
* @return true if the downloader is started, false otherwise.
|
||||||
|
*/
|
||||||
|
fun start(): Boolean {
|
||||||
|
if (isRunning || queue.isEmpty())
|
||||||
|
return false
|
||||||
|
|
||||||
|
if (!subscriptions.hasSubscriptions())
|
||||||
|
initializeSubscriptions()
|
||||||
|
|
||||||
|
val pending = queue.filter { it.status != Download.DOWNLOADED }
|
||||||
|
pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE }
|
||||||
|
|
||||||
|
downloadsRelay.call(pending)
|
||||||
|
return !pending.isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the downloader.
|
||||||
|
*/
|
||||||
|
fun stop(reason: String? = null) {
|
||||||
|
destroySubscriptions()
|
||||||
|
queue
|
||||||
|
.filter { it.status == Download.DOWNLOADING }
|
||||||
|
.forEach { it.status = Download.ERROR }
|
||||||
|
|
||||||
|
if (reason != null) {
|
||||||
|
notifier.onWarning(reason)
|
||||||
|
} else {
|
||||||
|
if (notifier.paused) {
|
||||||
|
notifier.paused = false
|
||||||
|
notifier.onDownloadPaused()
|
||||||
|
} else if (notifier.isSingleChapter && !notifier.errorThrown) {
|
||||||
|
notifier.isSingleChapter = false
|
||||||
|
} else {
|
||||||
|
notifier.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pauses the downloader
|
||||||
|
*/
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepares the subscriptions to start downloading.
|
||||||
|
*/
|
||||||
|
private fun initializeSubscriptions() {
|
||||||
|
if (isRunning) return
|
||||||
|
isRunning = true
|
||||||
|
runningRelay.call(true)
|
||||||
|
|
||||||
|
subscriptions.clear()
|
||||||
|
|
||||||
|
subscriptions += preferences.downloadThreads().asObservable()
|
||||||
|
.subscribe {
|
||||||
|
threadsSubject.onNext(it)
|
||||||
|
notifier.multipleDownloadThreads = it > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptions += downloadsRelay.flatMap { Observable.from(it) }
|
||||||
|
.lift(DynamicConcurrentMergeOperator<Download, Download>({ downloadChapter(it) }, threadsSubject))
|
||||||
|
.onBackpressureBuffer()
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe({ completeDownload(it)
|
||||||
|
}, { error ->
|
||||||
|
DownloadService.stop(context)
|
||||||
|
Timber.e(error)
|
||||||
|
notifier.onError(error.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys the downloader subscriptions.
|
||||||
|
*/
|
||||||
|
private fun destroySubscriptions() {
|
||||||
|
if (!isRunning) return
|
||||||
|
isRunning = false
|
||||||
|
runningRelay.call(false)
|
||||||
|
|
||||||
|
subscriptions.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a download object for every chapter and adds them to the downloads queue. This method
|
||||||
|
* must be called in the main thread.
|
||||||
|
*
|
||||||
|
* @param manga the manga of the chapters to download.
|
||||||
|
* @param chapters the list of chapters to download.
|
||||||
|
*/
|
||||||
|
fun queueChapters(manga: Manga, chapters: List<Chapter>) {
|
||||||
|
val source = sourceManager.get(manga.source) as? HttpSource ?: return
|
||||||
|
|
||||||
|
val chaptersToQueue = chapters
|
||||||
|
// Avoid downloading chapters with the same name.
|
||||||
|
.distinctBy { it.name }
|
||||||
|
// Add chapters to queue from the start.
|
||||||
|
.sortedByDescending { it.source_order }
|
||||||
|
// Create a downloader for each one.
|
||||||
|
.map { Download(source, manga, it) }
|
||||||
|
// Filter out those already queued or downloaded.
|
||||||
|
.filter { isDownloadAllowed(it) }
|
||||||
|
|
||||||
|
// Return if there's nothing to queue.
|
||||||
|
if (chaptersToQueue.isEmpty())
|
||||||
|
return
|
||||||
|
|
||||||
|
queue.addAll(chaptersToQueue)
|
||||||
|
|
||||||
|
// Initialize queue size.
|
||||||
|
notifier.initialQueueSize = queue.size
|
||||||
|
|
||||||
|
// Initial multi-thread
|
||||||
|
notifier.multipleDownloadThreads = preferences.downloadThreads().getOrDefault() > 1
|
||||||
|
|
||||||
|
if (isRunning) {
|
||||||
|
// Send the list of downloads to the downloader.
|
||||||
|
downloadsRelay.call(chaptersToQueue)
|
||||||
|
} else {
|
||||||
|
// Show initial notification.
|
||||||
|
notifier.onProgressChange(queue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given download can be queued and downloaded.
|
||||||
|
*
|
||||||
|
* @param download the download to be checked.
|
||||||
|
*/
|
||||||
|
private fun isDownloadAllowed(download: Download): Boolean {
|
||||||
|
// If the chapter is already queued, don't add it again
|
||||||
|
if (queue.any { it.chapter.id == download.chapter.id })
|
||||||
|
return false
|
||||||
|
|
||||||
|
val dir = provider.findChapterDir(download.source, download.manga, download.chapter)
|
||||||
|
if (dir != null && dir.exists())
|
||||||
|
return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the observable which downloads a chapter.
|
||||||
|
*
|
||||||
|
* @param download the chapter to be downloaded.
|
||||||
|
*/
|
||||||
|
private fun downloadChapter(download: Download): Observable<Download> {
|
||||||
|
val chapterDirname = provider.getChapterDirName(download.chapter)
|
||||||
|
val mangaDir = provider.getMangaDir(download.source, download.manga)
|
||||||
|
val tmpDir = mangaDir.createDirectory("${chapterDirname}_tmp")
|
||||||
|
|
||||||
|
val pageListObservable = if (download.pages == null) {
|
||||||
|
// Pull page list from network and add them to download object
|
||||||
|
download.source.fetchPageList(download.chapter)
|
||||||
|
.doOnNext { pages ->
|
||||||
|
if (pages.isEmpty()) {
|
||||||
|
throw Exception("Page list is empty")
|
||||||
|
}
|
||||||
|
download.pages = pages
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Or if the page list already exists, start from the file
|
||||||
|
Observable.just(download.pages!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageListObservable
|
||||||
|
.doOnNext { pages ->
|
||||||
|
// Delete all temporary (unfinished) files
|
||||||
|
tmpDir.listFiles()
|
||||||
|
?.filter { it.name!!.endsWith(".tmp") }
|
||||||
|
?.forEach { it.delete() }
|
||||||
|
|
||||||
|
download.downloadedImages = 0
|
||||||
|
download.status = Download.DOWNLOADING
|
||||||
|
}
|
||||||
|
// Get all the URLs to the source images, fetch pages if necessary
|
||||||
|
.flatMap { download.source.fetchAllImageUrlsFromPageList(it) }
|
||||||
|
// Start downloading images, consider we can have downloaded images already
|
||||||
|
.concatMap { page -> getOrDownloadImage(page, download, tmpDir) }
|
||||||
|
// Do when page is downloaded.
|
||||||
|
.doOnNext { notifier.onProgressChange(download, queue) }
|
||||||
|
.toList()
|
||||||
|
.map { pages -> download }
|
||||||
|
// Do after download completes
|
||||||
|
.doOnNext { ensureSuccessfulDownload(download, tmpDir, chapterDirname) }
|
||||||
|
// If the page list threw, it will resume here
|
||||||
|
.onErrorReturn { error ->
|
||||||
|
download.status = Download.ERROR
|
||||||
|
notifier.onError(error.message, download.chapter.name)
|
||||||
|
download
|
||||||
|
}
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the observable which gets the image from the filesystem if it exists or downloads it
|
||||||
|
* otherwise.
|
||||||
|
*
|
||||||
|
* @param page the page to download.
|
||||||
|
* @param download the download of the page.
|
||||||
|
* @param tmpDir the temporary directory of the download.
|
||||||
|
*/
|
||||||
|
private fun getOrDownloadImage(page: Page, download: Download, tmpDir: UniFile): Observable<Page> {
|
||||||
|
// If the image URL is empty, do nothing
|
||||||
|
if (page.imageUrl == null)
|
||||||
|
return Observable.just(page)
|
||||||
|
|
||||||
|
val filename = String.format("%03d", page.number)
|
||||||
|
val tmpFile = tmpDir.findFile("$filename.tmp")
|
||||||
|
|
||||||
|
// Delete temp file if it exists.
|
||||||
|
tmpFile?.delete()
|
||||||
|
|
||||||
|
// Try to find the image file.
|
||||||
|
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)
|
||||||
|
Observable.just(imageFile)
|
||||||
|
else
|
||||||
|
downloadImage(page, download.source, tmpDir, filename)
|
||||||
|
|
||||||
|
return pageObservable
|
||||||
|
// When the image is ready, set image path, progress (just in case) and status
|
||||||
|
.doOnNext { file ->
|
||||||
|
page.uri = file.uri
|
||||||
|
page.progress = 100
|
||||||
|
download.downloadedImages++
|
||||||
|
page.status = Page.READY
|
||||||
|
}
|
||||||
|
.map { page }
|
||||||
|
// Mark this page as error and allow to download the remaining
|
||||||
|
.onErrorReturn {
|
||||||
|
page.progress = 0
|
||||||
|
page.status = Page.ERROR
|
||||||
|
page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the observable which downloads the image from network.
|
||||||
|
*
|
||||||
|
* @param page the page to download.
|
||||||
|
* @param source the source of the page.
|
||||||
|
* @param tmpDir the temporary directory of the download.
|
||||||
|
* @param filename the filename of the image.
|
||||||
|
*/
|
||||||
|
private fun downloadImage(page: Page, source: HttpSource, tmpDir: UniFile, filename: String): Observable<UniFile> {
|
||||||
|
page.status = Page.DOWNLOAD_IMAGE
|
||||||
|
page.progress = 0
|
||||||
|
return source.fetchImage(page)
|
||||||
|
.map { response ->
|
||||||
|
val file = tmpDir.createFile("$filename.tmp")
|
||||||
|
try {
|
||||||
|
response.body()!!.source().saveTo(file.openOutputStream())
|
||||||
|
val extension = getImageExtension(response, file)
|
||||||
|
file.renameTo("$filename.$extension")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
response.close()
|
||||||
|
file.delete()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
file
|
||||||
|
}
|
||||||
|
// Retry 3 times, waiting 2, 4 and 8 seconds between attempts.
|
||||||
|
.retryWhen(RetryWithDelay(3, { (2 shl it - 1) * 1000 }, Schedulers.trampoline()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the extension of the downloaded image from the network response, or if it's null,
|
||||||
|
* analyze the file. If everything fails, assume it's a jpg.
|
||||||
|
*
|
||||||
|
* @param response the network response of the image.
|
||||||
|
* @param file the file where the image is already downloaded.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
?: DiskUtil.findImageMime { file.openInputStream() }
|
||||||
|
|
||||||
|
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the download was successful.
|
||||||
|
*
|
||||||
|
* @param download the download to check.
|
||||||
|
* @param tmpDir the directory where the download is currently stored.
|
||||||
|
* @param dirname the real (non temporary) directory name of the download.
|
||||||
|
*/
|
||||||
|
private fun ensureSuccessfulDownload(download: Download, tmpDir: UniFile, dirname: String) {
|
||||||
|
// Ensure that the chapter folder has all the images.
|
||||||
|
val downloadedImages = tmpDir.listFiles().orEmpty().filterNot { it.name!!.endsWith(".tmp") }
|
||||||
|
|
||||||
|
download.status = if (downloadedImages.size == download.pages!!.size) {
|
||||||
|
Download.DOWNLOADED
|
||||||
|
} else {
|
||||||
|
Download.ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only rename the directory if it's downloaded.
|
||||||
|
if (download.status == Download.DOWNLOADED) {
|
||||||
|
tmpDir.renameTo(dirname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completes a download. This method is called in the main thread.
|
||||||
|
*/
|
||||||
|
private fun completeDownload(download: Download) {
|
||||||
|
// Delete successful downloads from queue
|
||||||
|
if (download.status == Download.DOWNLOADED) {
|
||||||
|
// remove downloaded chapter from queue
|
||||||
|
queue.remove(download)
|
||||||
|
notifier.onProgressChange(queue)
|
||||||
|
}
|
||||||
|
if (areAllDownloadsFinished()) {
|
||||||
|
if (notifier.isSingleChapter && !notifier.errorThrown) {
|
||||||
|
notifier.onDownloadCompleted(download, queue)
|
||||||
|
}
|
||||||
|
DownloadService.stop(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if all the queued downloads are in DOWNLOADED or ERROR state.
|
||||||
|
*/
|
||||||
|
private fun areAllDownloadsFinished(): Boolean {
|
||||||
|
return queue.none { it.status <= Download.DOWNLOADING }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -2,14 +2,11 @@ package eu.kanade.tachiyomi.data.download.model
|
|||||||
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import rx.subjects.PublishSubject
|
import rx.subjects.PublishSubject
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class Download(val source: OnlineSource, val manga: Manga, val chapter: Chapter) {
|
class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) {
|
||||||
|
|
||||||
lateinit var directory: File
|
|
||||||
|
|
||||||
var pages: List<Page>? = null
|
var pages: List<Page>? = null
|
||||||
|
|
||||||
|
@ -1,38 +1,52 @@
|
|||||||
package eu.kanade.tachiyomi.data.download.model
|
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.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.source.model.Page
|
import eu.kanade.tachiyomi.data.download.DownloadStore
|
||||||
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import rx.subjects.PublishSubject
|
import rx.subjects.PublishSubject
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
class DownloadQueue(private val queue: MutableList<Download> = CopyOnWriteArrayList<Download>())
|
class DownloadQueue(
|
||||||
|
private val store: DownloadStore,
|
||||||
|
private val queue: MutableList<Download> = CopyOnWriteArrayList<Download>())
|
||||||
: List<Download> by queue {
|
: List<Download> by queue {
|
||||||
|
|
||||||
private val statusSubject = PublishSubject.create<Download>()
|
private val statusSubject = PublishSubject.create<Download>()
|
||||||
|
|
||||||
private val removeSubject = PublishSubject.create<Download>()
|
private val updatedRelay = PublishRelay.create<Unit>()
|
||||||
|
|
||||||
fun add(download: Download): Boolean {
|
fun addAll(downloads: List<Download>) {
|
||||||
download.setStatusSubject(statusSubject)
|
downloads.forEach { download ->
|
||||||
download.status = Download.QUEUE
|
download.setStatusSubject(statusSubject)
|
||||||
return queue.add(download)
|
download.status = Download.QUEUE
|
||||||
|
}
|
||||||
|
queue.addAll(downloads)
|
||||||
|
store.addAll(downloads)
|
||||||
|
updatedRelay.call(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun del(download: Download) {
|
fun remove(download: Download) {
|
||||||
val removed = queue.remove(download)
|
val removed = queue.remove(download)
|
||||||
|
store.remove(download)
|
||||||
download.setStatusSubject(null)
|
download.setStatusSubject(null)
|
||||||
if (removed) {
|
if (removed) {
|
||||||
removeSubject.onNext(download)
|
updatedRelay.call(Unit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun del(chapter: Chapter) {
|
fun remove(chapter: Chapter) {
|
||||||
find { it.chapter.id == chapter.id }?.let { del(it) }
|
find { it.chapter.id == chapter.id }?.let { remove(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
queue.forEach { del(it) }
|
queue.forEach { download ->
|
||||||
|
download.setStatusSubject(null)
|
||||||
|
}
|
||||||
|
queue.clear()
|
||||||
|
store.clear()
|
||||||
|
updatedRelay.call(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getActiveDownloads(): Observable<Download> =
|
fun getActiveDownloads(): Observable<Download> =
|
||||||
@ -40,7 +54,9 @@ class DownloadQueue(private val queue: MutableList<Download> = CopyOnWriteArrayL
|
|||||||
|
|
||||||
fun getStatusObservable(): Observable<Download> = statusSubject.onBackpressureBuffer()
|
fun getStatusObservable(): Observable<Download> = statusSubject.onBackpressureBuffer()
|
||||||
|
|
||||||
fun getRemovedObservable(): Observable<Download> = removeSubject.onBackpressureBuffer()
|
fun getUpdatedObservable(): Observable<List<Download>> = updatedRelay.onBackpressureBuffer()
|
||||||
|
.startWith(Unit)
|
||||||
|
.map { this }
|
||||||
|
|
||||||
fun getProgressObservable(): Observable<Download> {
|
fun getProgressObservable(): Observable<Download> {
|
||||||
return statusSubject.onBackpressureBuffer()
|
return statusSubject.onBackpressureBuffer()
|
||||||
|
@ -8,7 +8,7 @@ import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
|
|||||||
import com.bumptech.glide.load.model.GlideUrl
|
import com.bumptech.glide.load.model.GlideUrl
|
||||||
import com.bumptech.glide.module.GlideModule
|
import com.bumptech.glide.module.GlideModule
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
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.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import java.io.InputStream
|
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.content.Context
|
||||||
import android.util.LruCache
|
import android.util.LruCache
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.integration.okhttp3.OkHttpStreamFetcher
|
||||||
import com.bumptech.glide.load.data.DataFetcher
|
import com.bumptech.glide.load.data.DataFetcher
|
||||||
import com.bumptech.glide.load.model.*
|
import com.bumptech.glide.load.model.*
|
||||||
import com.bumptech.glide.load.model.stream.StreamModelLoader
|
import com.bumptech.glide.load.model.stream.StreamModelLoader
|
||||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.source.SourceManager
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A class for loading a cover associated with a [Manga] that can be present in our own cache.
|
* 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 RAM LRU.
|
||||||
* - Check in disk LRU.
|
* - Check in disk LRU.
|
||||||
@ -30,17 +31,17 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
|
|||||||
/**
|
/**
|
||||||
* Cover cache where persistent covers are stored.
|
* Cover cache where persistent covers are stored.
|
||||||
*/
|
*/
|
||||||
val coverCache: CoverCache by injectLazy()
|
private val coverCache: CoverCache by injectLazy()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Source manager.
|
* Source manager.
|
||||||
*/
|
*/
|
||||||
val sourceManager: SourceManager by injectLazy()
|
private val sourceManager: SourceManager by injectLazy()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base network loader.
|
* Base network loader.
|
||||||
*/
|
*/
|
||||||
private val baseLoader = Glide.buildModelLoader(GlideUrl::class.java,
|
private val baseUrlLoader = Glide.buildModelLoader(GlideUrl::class.java,
|
||||||
InputStream::class.java, context)
|
InputStream::class.java, context)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -52,7 +53,7 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
|
|||||||
/**
|
/**
|
||||||
* Map where request headers are stored for a source.
|
* 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.
|
* 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 manga the model.
|
||||||
* @param width the width of the view where the resource will be loaded.
|
* @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
|
// Check thumbnail is not null or empty
|
||||||
val url = manga.thumbnail_url
|
val url = manga.thumbnail_url
|
||||||
if (url.isNullOrEmpty()) {
|
if (url == null || url.isEmpty()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Obtain the request url and the file for this url from the LRU cache, or calculate it
|
if (url.startsWith("http")) {
|
||||||
// and add them to the cache.
|
val source = sourceManager.get(manga.source) as? HttpSource
|
||||||
val (glideUrl, file) = lruCache.get(url) ?:
|
|
||||||
Pair(GlideUrl(url, getHeaders(manga)), coverCache.getCoverFile(url!!)).apply {
|
|
||||||
lruCache.put(url, this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the network fetcher for this request url.
|
// Obtain the request url and the file for this url from the LRU cache, or calculate it
|
||||||
val networkFetcher = baseLoader.getResourceFetcher(glideUrl, width, height)
|
// 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.
|
// Get the resource fetcher for this request url.
|
||||||
return MangaDataFetcher(networkFetcher, file, manga)
|
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.
|
* @param manga the model.
|
||||||
*/
|
*/
|
||||||
fun getHeaders(manga: Manga): Headers {
|
fun getHeaders(manga: Manga, source: HttpSource?): Headers {
|
||||||
val source = sourceManager.get(manga.source) as? OnlineSource ?: return LazyHeaders.DEFAULT
|
if (source == null) return LazyHeaders.DEFAULT
|
||||||
|
|
||||||
return cachedHeaders.getOrPut(manga.source) {
|
return cachedHeaders.getOrPut(manga.source) {
|
||||||
LazyHeaders.Builder().apply {
|
LazyHeaders.Builder().apply {
|
||||||
val nullStr: String? = null
|
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 file the file where this cover should be. It may exists or not.
|
||||||
* @param manga the manga of the cover to load.
|
* @param manga the manga of the cover to load.
|
||||||
*/
|
*/
|
||||||
class MangaDataFetcher(private val networkFetcher: DataFetcher<InputStream>,
|
class MangaUrlFetcher(private val networkFetcher: DataFetcher<InputStream>,
|
||||||
private val file: File,
|
private val file: File,
|
||||||
private val manga: Manga)
|
private val manga: Manga)
|
||||||
: DataFetcher<InputStream> {
|
: MangaFileFetcher(file, manga) {
|
||||||
|
|
||||||
@Throws(Exception::class)
|
override fun loadData(priority: Priority): InputStream {
|
||||||
override fun loadData(priority: Priority): InputStream? {
|
|
||||||
if (manga.favorite) {
|
if (manga.favorite) {
|
||||||
synchronized(file) {
|
synchronized(file) {
|
||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
@ -51,7 +50,7 @@ class MangaDataFetcher(private val networkFetcher: DataFetcher<InputStream>,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return file.inputStream()
|
return super.loadData(priority)
|
||||||
} else {
|
} else {
|
||||||
if (file.exists()) {
|
if (file.exists()) {
|
||||||
file.delete()
|
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() {
|
override fun cancel() {
|
||||||
networkFetcher.cancel()
|
networkFetcher.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun cleanup() {
|
override fun cleanup() {
|
||||||
|
super.cleanup()
|
||||||
networkFetcher.cleanup()
|
networkFetcher.cleanup()
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.library
|
||||||
|
|
||||||
|
import com.evernote.android.job.Job
|
||||||
|
import com.evernote.android.job.JobManager
|
||||||
|
import com.evernote.android.job.JobRequest
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
class LibraryUpdateJob : Job() {
|
||||||
|
|
||||||
|
override fun onRunJob(params: Params): Result {
|
||||||
|
LibraryUpdateService.start(context)
|
||||||
|
return Job.Result.SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "LibraryUpdate"
|
||||||
|
|
||||||
|
fun setupTask(prefInterval: Int? = null) {
|
||||||
|
val preferences = Injekt.get<PreferencesHelper>()
|
||||||
|
val interval = prefInterval ?: preferences.libraryUpdateInterval().getOrDefault()
|
||||||
|
if (interval > 0) {
|
||||||
|
val restrictions = preferences.libraryUpdateRestriction()
|
||||||
|
val acRestriction = "ac" in restrictions
|
||||||
|
val wifiRestriction = if ("wifi" in restrictions)
|
||||||
|
JobRequest.NetworkType.UNMETERED
|
||||||
|
else
|
||||||
|
JobRequest.NetworkType.CONNECTED
|
||||||
|
|
||||||
|
JobRequest.Builder(TAG)
|
||||||
|
.setPeriodic(interval * 60 * 60 * 1000L, 10 * 60 * 1000)
|
||||||
|
.setRequiredNetworkType(wifiRestriction)
|
||||||
|
.setRequiresCharging(acRestriction)
|
||||||
|
.setRequirementsEnforced(true)
|
||||||
|
.setPersisted(true)
|
||||||
|
.setUpdateCurrent(true)
|
||||||
|
.build()
|
||||||
|
.schedule()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelTask() {
|
||||||
|
JobManager.instance().cancelAllForTag(TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,12 +1,12 @@
|
|||||||
package eu.kanade.tachiyomi.data.library
|
package eu.kanade.tachiyomi.data.library
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import android.support.v4.app.NotificationCompat
|
import android.support.v4.app.NotificationCompat
|
||||||
@ -14,21 +14,26 @@ import eu.kanade.tachiyomi.Constants
|
|||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||||
import eu.kanade.tachiyomi.data.database.models.Category
|
import eu.kanade.tachiyomi.data.database.models.Category
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||||
|
import eu.kanade.tachiyomi.data.download.DownloadService
|
||||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start
|
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.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
import eu.kanade.tachiyomi.data.source.SourceManager
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
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.ui.main.MainActivity
|
||||||
import eu.kanade.tachiyomi.util.AndroidComponentUtil
|
import eu.kanade.tachiyomi.util.*
|
||||||
import eu.kanade.tachiyomi.util.notification
|
|
||||||
import eu.kanade.tachiyomi.util.notificationManager
|
|
||||||
import eu.kanade.tachiyomi.util.syncChaptersWithSource
|
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import rx.Subscription
|
import rx.Subscription
|
||||||
import rx.schedulers.Schedulers
|
import rx.schedulers.Schedulers
|
||||||
import uy.kohesive.injekt.injectLazy
|
import timber.log.Timber
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
@ -40,22 +45,13 @@ import java.util.concurrent.atomic.AtomicInteger
|
|||||||
* progress of the update, and if case of an unexpected error, this service will be silently
|
* progress of the update, and if case of an unexpected error, this service will be silently
|
||||||
* destroyed.
|
* destroyed.
|
||||||
*/
|
*/
|
||||||
class LibraryUpdateService : Service() {
|
class LibraryUpdateService(
|
||||||
|
val db: DatabaseHelper = Injekt.get(),
|
||||||
/**
|
val sourceManager: SourceManager = Injekt.get(),
|
||||||
* Database helper.
|
val preferences: PreferencesHelper = Injekt.get(),
|
||||||
*/
|
val downloadManager: DownloadManager = Injekt.get(),
|
||||||
val db: DatabaseHelper by injectLazy()
|
val trackManager: TrackManager = Injekt.get()
|
||||||
|
) : Service() {
|
||||||
/**
|
|
||||||
* Source manager.
|
|
||||||
*/
|
|
||||||
val sourceManager: SourceManager by injectLazy()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preferences.
|
|
||||||
*/
|
|
||||||
val preferences: PreferencesHelper by injectLazy()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wake lock that will be held until the service is destroyed.
|
* Wake lock that will be held until the service is destroyed.
|
||||||
@ -68,24 +64,49 @@ class LibraryUpdateService : Service() {
|
|||||||
private var subscription: Subscription? = null
|
private var subscription: Subscription? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Id of the library update notification.
|
* Pending intent of action that cancels the library update
|
||||||
*/
|
*/
|
||||||
private val notificationId: Int
|
private val cancelIntent by lazy {
|
||||||
get() = Constants.NOTIFICATION_LIBRARY_ID
|
NotificationReceiver.cancelLibraryUpdatePendingBroadcast(this)
|
||||||
|
}
|
||||||
|
|
||||||
private var notificationBitmap: Bitmap? = null
|
/**
|
||||||
|
* Bitmap of the app for notifications.
|
||||||
|
*/
|
||||||
|
private val notificationBitmap by lazy {
|
||||||
|
BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached progress notification to avoid creating a lot.
|
||||||
|
*/
|
||||||
|
private val progressNotification by lazy { NotificationCompat.Builder(this)
|
||||||
|
.setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
|
||||||
|
.setLargeIcon(notificationBitmap)
|
||||||
|
.setOngoing(true)
|
||||||
|
.addAction(R.drawable.ic_clear_grey_24dp_img, getString(android.R.string.cancel), cancelIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines what should be updated within a service execution.
|
||||||
|
*/
|
||||||
|
enum class Target {
|
||||||
|
CHAPTERS, // Manga chapters
|
||||||
|
DETAILS, // Manga metadata
|
||||||
|
TRACKING // Tracking metadata
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Key for category to update.
|
* Key for category to update.
|
||||||
*/
|
*/
|
||||||
const val UPDATE_CATEGORY = "category"
|
const val KEY_CATEGORY = "category"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Key for updating the details instead of the chapters.
|
* Key that defines what should be updated.
|
||||||
*/
|
*/
|
||||||
const val UPDATE_DETAILS = "details"
|
const val KEY_TARGET = "target"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the status of the service.
|
* Returns the status of the service.
|
||||||
@ -94,7 +115,7 @@ class LibraryUpdateService : Service() {
|
|||||||
* @return true if the service is running, false otherwise.
|
* @return true if the service is running, false otherwise.
|
||||||
*/
|
*/
|
||||||
fun isRunning(context: Context): Boolean {
|
fun isRunning(context: Context): Boolean {
|
||||||
return AndroidComponentUtil.isServiceRunning(context, LibraryUpdateService::class.java)
|
return context.isServiceRunning(LibraryUpdateService::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -103,13 +124,13 @@ class LibraryUpdateService : Service() {
|
|||||||
*
|
*
|
||||||
* @param context the application context.
|
* @param context the application context.
|
||||||
* @param category a specific category to update, or null for global update.
|
* @param category a specific category to update, or null for global update.
|
||||||
* @param details whether to update the details instead of the list of chapters.
|
* @param target defines what should be updated.
|
||||||
*/
|
*/
|
||||||
fun start(context: Context, category: Category? = null, details: Boolean = false) {
|
fun start(context: Context, category: Category? = null, target: Target = Target.CHAPTERS) {
|
||||||
if (!isRunning(context)) {
|
if (!isRunning(context)) {
|
||||||
val intent = Intent(context, LibraryUpdateService::class.java).apply {
|
val intent = Intent(context, LibraryUpdateService::class.java).apply {
|
||||||
putExtra(UPDATE_DETAILS, details)
|
putExtra(KEY_TARGET, target)
|
||||||
category?.let { putExtra(UPDATE_CATEGORY, it.id) }
|
category?.let { putExtra(KEY_CATEGORY, it.id) }
|
||||||
}
|
}
|
||||||
context.startService(intent)
|
context.startService(intent)
|
||||||
}
|
}
|
||||||
@ -132,18 +153,20 @@ class LibraryUpdateService : Service() {
|
|||||||
*/
|
*/
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
createAndAcquireWakeLock()
|
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
|
||||||
|
PowerManager.PARTIAL_WAKE_LOCK, "LibraryUpdateService:WakeLock")
|
||||||
|
wakeLock.acquire()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method called when the service is destroyed. It destroys the running subscription, resets
|
* Method called when the service is destroyed. It destroys subscriptions and releases the wake
|
||||||
* the alarm and release the wake lock.
|
* lock.
|
||||||
*/
|
*/
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
subscription?.unsubscribe()
|
subscription?.unsubscribe()
|
||||||
notificationBitmap?.recycle()
|
if (wakeLock.isHeld) {
|
||||||
notificationBitmap = null
|
wakeLock.release()
|
||||||
destroyWakeLock()
|
}
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,6 +187,8 @@ class LibraryUpdateService : Service() {
|
|||||||
*/
|
*/
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
if (intent == null) return Service.START_NOT_STICKY
|
if (intent == null) return Service.START_NOT_STICKY
|
||||||
|
val target = intent.getSerializableExtra(KEY_TARGET) as? Target
|
||||||
|
?: return Service.START_NOT_STICKY
|
||||||
|
|
||||||
// Unsubscribe from any previous subscription if needed.
|
// Unsubscribe from any previous subscription if needed.
|
||||||
subscription?.unsubscribe()
|
subscription?.unsubscribe()
|
||||||
@ -171,22 +196,19 @@ class LibraryUpdateService : Service() {
|
|||||||
// Update favorite manga. Destroy service when completed or in case of an error.
|
// Update favorite manga. Destroy service when completed or in case of an error.
|
||||||
subscription = Observable
|
subscription = Observable
|
||||||
.defer {
|
.defer {
|
||||||
if (notificationBitmap == null) {
|
val mangaList = getMangaToUpdate(intent, target)
|
||||||
notificationBitmap = BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)
|
|
||||||
}
|
|
||||||
|
|
||||||
val mangaList = getMangaToUpdate(intent)
|
|
||||||
|
|
||||||
// Update either chapter list or manga details.
|
// Update either chapter list or manga details.
|
||||||
if (!intent.getBooleanExtra(UPDATE_DETAILS, false))
|
when (target) {
|
||||||
updateChapterList(mangaList)
|
Target.CHAPTERS -> updateChapterList(mangaList)
|
||||||
else
|
Target.DETAILS -> updateDetails(mangaList)
|
||||||
updateDetails(mangaList)
|
Target.TRACKING -> updateTrackings(mangaList)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
}, {
|
}, {
|
||||||
showNotification(getString(R.string.notification_update_error), "")
|
Timber.e(it)
|
||||||
stopSelf(startId)
|
stopSelf(startId)
|
||||||
}, {
|
}, {
|
||||||
stopSelf(startId)
|
stopSelf(startId)
|
||||||
@ -199,25 +221,26 @@ class LibraryUpdateService : Service() {
|
|||||||
* Returns the list of manga to be updated.
|
* Returns the list of manga to be updated.
|
||||||
*
|
*
|
||||||
* @param intent the update intent.
|
* @param intent the update intent.
|
||||||
|
* @param target the target to update.
|
||||||
* @return a list of manga to update
|
* @return a list of manga to update
|
||||||
*/
|
*/
|
||||||
fun getMangaToUpdate(intent: Intent): List<Manga> {
|
fun getMangaToUpdate(intent: Intent, target: Target): List<Manga> {
|
||||||
val categoryId = intent.getIntExtra(UPDATE_CATEGORY, -1)
|
val categoryId = intent.getIntExtra(KEY_CATEGORY, -1)
|
||||||
|
|
||||||
var listToUpdate = if (categoryId != -1)
|
var listToUpdate = if (categoryId != -1)
|
||||||
db.getLibraryMangas().executeAsBlocking().filter { it.category == categoryId }
|
db.getLibraryMangas().executeAsBlocking().filter { it.category == categoryId }
|
||||||
else {
|
else {
|
||||||
val categoriesToUpdate = preferences.libraryUpdateCategories().getOrDefault().map { it.toInt() }
|
val categoriesToUpdate = preferences.libraryUpdateCategories().getOrDefault().map(String::toInt)
|
||||||
if (categoriesToUpdate.isNotEmpty())
|
if (categoriesToUpdate.isNotEmpty())
|
||||||
db.getLibraryMangas().executeAsBlocking()
|
db.getLibraryMangas().executeAsBlocking()
|
||||||
.filter { it.category in categoriesToUpdate }
|
.filter { it.category in categoriesToUpdate }
|
||||||
.distinctBy { it.id }
|
.distinctBy { it.id }
|
||||||
else
|
else
|
||||||
db.getFavoriteMangas().executeAsBlocking().distinctBy { it.id }
|
db.getLibraryMangas().executeAsBlocking().distinctBy { it.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!intent.getBooleanExtra(UPDATE_DETAILS, false) && preferences.updateOnlyNonCompleted()) {
|
if (target == Target.CHAPTERS && preferences.updateOnlyNonCompleted()) {
|
||||||
listToUpdate = listToUpdate.filter { it.status != Manga.COMPLETED }
|
listToUpdate = listToUpdate.filter { it.status != SManga.COMPLETED }
|
||||||
}
|
}
|
||||||
|
|
||||||
return listToUpdate
|
return listToUpdate
|
||||||
@ -235,50 +258,84 @@ class LibraryUpdateService : Service() {
|
|||||||
fun updateChapterList(mangaToUpdate: List<Manga>): Observable<Manga> {
|
fun updateChapterList(mangaToUpdate: List<Manga>): Observable<Manga> {
|
||||||
// Initialize the variables holding the progress of the updates.
|
// Initialize the variables holding the progress of the updates.
|
||||||
val count = AtomicInteger(0)
|
val count = AtomicInteger(0)
|
||||||
|
// List containing new updates
|
||||||
val newUpdates = ArrayList<Manga>()
|
val newUpdates = ArrayList<Manga>()
|
||||||
|
// list containing failed updates
|
||||||
val failedUpdates = ArrayList<Manga>()
|
val failedUpdates = ArrayList<Manga>()
|
||||||
|
// List containing categories that get included in downloads.
|
||||||
val cancelIntent = PendingIntent.getBroadcast(this, 0,
|
val categoriesToDownload = preferences.downloadNewCategories().getOrDefault().map(String::toInt)
|
||||||
Intent(this, CancelUpdateReceiver::class.java), 0)
|
// Boolean to determine if user wants to automatically download new chapters.
|
||||||
|
val downloadNew = preferences.downloadNew().getOrDefault()
|
||||||
|
// Boolean to determine if DownloadManager has downloads
|
||||||
|
var hasDownloads = false
|
||||||
|
|
||||||
// Emit each manga and update it sequentially.
|
// Emit each manga and update it sequentially.
|
||||||
return Observable.from(mangaToUpdate)
|
return Observable.from(mangaToUpdate)
|
||||||
// Notify manga that will update.
|
// Notify manga that will update.
|
||||||
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size, cancelIntent) }
|
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size) }
|
||||||
// Update the chapters of the manga.
|
// Update the chapters of the manga.
|
||||||
.concatMap { manga ->
|
.concatMap { manga ->
|
||||||
updateManga(manga)
|
updateManga(manga)
|
||||||
// If there's any error, return empty update and continue.
|
// If there's any error, return empty update and continue.
|
||||||
.onErrorReturn {
|
.onErrorReturn {
|
||||||
failedUpdates.add(manga)
|
failedUpdates.add(manga)
|
||||||
Pair(0, 0)
|
Pair(emptyList<Chapter>(), emptyList<Chapter>())
|
||||||
}
|
}
|
||||||
// Filter out mangas without new chapters (or failed).
|
// Filter out mangas without new chapters (or failed).
|
||||||
.filter { pair -> pair.first > 0 }
|
.filter { pair -> pair.first.isNotEmpty() }
|
||||||
|
.doOnNext {
|
||||||
|
if (downloadNew && (categoriesToDownload.isEmpty() ||
|
||||||
|
manga.category in categoriesToDownload)) {
|
||||||
|
|
||||||
|
downloadChapters(manga, it.first)
|
||||||
|
hasDownloads = true
|
||||||
|
}
|
||||||
|
}
|
||||||
// Convert to the manga that contains new chapters.
|
// Convert to the manga that contains new chapters.
|
||||||
.map { manga }
|
.map { manga }
|
||||||
}
|
}
|
||||||
// Add manga with new chapters to the list.
|
// Add manga with new chapters to the list.
|
||||||
.doOnNext { newUpdates.add(it) }
|
.doOnNext { manga ->
|
||||||
|
// Set last updated time
|
||||||
|
manga.last_update = Date().time
|
||||||
|
db.updateLastUpdated(manga).executeAsBlocking()
|
||||||
|
// Add to the list
|
||||||
|
newUpdates.add(manga)
|
||||||
|
}
|
||||||
// Notify result of the overall update.
|
// Notify result of the overall update.
|
||||||
.doOnCompleted {
|
.doOnCompleted {
|
||||||
if (newUpdates.isEmpty()) {
|
if (newUpdates.isNotEmpty()) {
|
||||||
cancelNotification()
|
showResultNotification(newUpdates)
|
||||||
} else {
|
if (downloadNew && hasDownloads) {
|
||||||
showResultNotification(newUpdates, failedUpdates)
|
DownloadService.start(this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
LibraryUpdateTrigger.setupTask(this)
|
|
||||||
|
if (failedUpdates.isNotEmpty()) {
|
||||||
|
Timber.e("Failed updating: ${failedUpdates.map { it.title }}")
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelProgressNotification()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
|
||||||
|
// we need to get the chapters from the db so we have chapter ids
|
||||||
|
val mangaChapters = db.getChapters(manga).executeAsBlocking()
|
||||||
|
val dbChapters = chapters.map {
|
||||||
|
mangaChapters.find { mangaChapter -> mangaChapter.url == it.url }!!
|
||||||
|
}
|
||||||
|
downloadManager.downloadChapters(manga, dbChapters)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the chapters for the given manga and adds them to the database.
|
* Updates the chapters for the given manga and adds them to the database.
|
||||||
*
|
*
|
||||||
* @param manga the manga to update.
|
* @param manga the manga to update.
|
||||||
* @return a pair of the inserted and removed chapters.
|
* @return a pair of the inserted and removed chapters.
|
||||||
*/
|
*/
|
||||||
fun updateManga(manga: Manga): Observable<Pair<Int, Int>> {
|
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)
|
return source.fetchChapterList(manga)
|
||||||
.map { syncChaptersWithSource(db, it, manga, source) }
|
.map { syncChaptersWithSource(db, it, manga, source) }
|
||||||
}
|
}
|
||||||
@ -286,8 +343,6 @@ class LibraryUpdateService : Service() {
|
|||||||
/**
|
/**
|
||||||
* Method that updates the details of the given list of manga. It's called in a background
|
* Method that updates the details of the given list of manga. It's called in a background
|
||||||
* thread, so it's safe to do heavy operations or network calls here.
|
* thread, so it's safe to do heavy operations or network calls here.
|
||||||
* For each manga it calls [updateManga] and updates the notification showing the current
|
|
||||||
* progress.
|
|
||||||
*
|
*
|
||||||
* @param mangaToUpdate the list to update
|
* @param mangaToUpdate the list to update
|
||||||
* @return an observable delivering the progress of each update.
|
* @return an observable delivering the progress of each update.
|
||||||
@ -296,92 +351,62 @@ class LibraryUpdateService : Service() {
|
|||||||
// Initialize the variables holding the progress of the updates.
|
// Initialize the variables holding the progress of the updates.
|
||||||
val count = AtomicInteger(0)
|
val count = AtomicInteger(0)
|
||||||
|
|
||||||
val cancelIntent = PendingIntent.getBroadcast(this, 0,
|
|
||||||
Intent(this, CancelUpdateReceiver::class.java), 0)
|
|
||||||
|
|
||||||
// Emit each manga and update it sequentially.
|
// Emit each manga and update it sequentially.
|
||||||
return Observable.from(mangaToUpdate)
|
return Observable.from(mangaToUpdate)
|
||||||
// Notify manga that will update.
|
// Notify manga that will update.
|
||||||
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size, cancelIntent) }
|
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size) }
|
||||||
// Update the details of the manga.
|
// Update the details of the manga.
|
||||||
.concatMap { manga ->
|
.concatMap { manga ->
|
||||||
val source = sourceManager.get(manga.source) as? OnlineSource
|
val source = sourceManager.get(manga.source) as? HttpSource
|
||||||
?: return@concatMap Observable.empty<Manga>()
|
?: return@concatMap Observable.empty<Manga>()
|
||||||
|
|
||||||
source.fetchMangaDetails(manga)
|
source.fetchMangaDetails(manga)
|
||||||
.doOnNext { networkManga ->
|
.map { networkManga ->
|
||||||
manga.copyFrom(networkManga)
|
manga.copyFrom(networkManga)
|
||||||
db.insertManga(manga).executeAsBlocking()
|
db.insertManga(manga).executeAsBlocking()
|
||||||
|
manga
|
||||||
}
|
}
|
||||||
.onErrorReturn { manga }
|
.onErrorReturn { manga }
|
||||||
}
|
}
|
||||||
.doOnCompleted {
|
.doOnCompleted {
|
||||||
cancelNotification()
|
cancelProgressNotification()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the text that will be displayed in the notification when there are new chapters.
|
* Method that updates the metadata of the connected tracking services. It's called in a
|
||||||
*
|
* background thread, so it's safe to do heavy operations or network calls here.
|
||||||
* @param updates a list of manga that contains new chapters.
|
|
||||||
* @param failedUpdates a list of manga that failed to update.
|
|
||||||
* @return the body of the notification to display.
|
|
||||||
*/
|
*/
|
||||||
private fun getUpdatedMangasBody(updates: List<Manga>, failedUpdates: List<Manga>): String {
|
private fun updateTrackings(mangaToUpdate: List<Manga>): Observable<Manga> {
|
||||||
return with(StringBuilder()) {
|
// Initialize the variables holding the progress of the updates.
|
||||||
if (updates.isEmpty()) {
|
var count = 0
|
||||||
append(getString(R.string.notification_no_new_chapters))
|
|
||||||
append("\n")
|
val loggedServices = trackManager.services.filter { it.isLogged }
|
||||||
} else {
|
|
||||||
append(getString(R.string.notification_new_chapters))
|
// Emit each manga and update it sequentially.
|
||||||
for (manga in updates) {
|
return Observable.from(mangaToUpdate)
|
||||||
append("\n")
|
// Notify manga that will update.
|
||||||
append(manga.title)
|
.doOnNext { showProgressNotification(it, count++, mangaToUpdate.size) }
|
||||||
|
// Update the tracking details.
|
||||||
|
.concatMap { manga ->
|
||||||
|
val tracks = db.getTracks(manga).executeAsBlocking()
|
||||||
|
|
||||||
|
Observable.from(tracks)
|
||||||
|
.concatMap { track ->
|
||||||
|
val service = trackManager.getService(track.sync_id)
|
||||||
|
if (service != null && service in loggedServices) {
|
||||||
|
service.refresh(track)
|
||||||
|
.doOnNext { db.insertTrack(it).executeAsBlocking() }
|
||||||
|
.onErrorReturn { track }
|
||||||
|
} else {
|
||||||
|
Observable.empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map { manga }
|
||||||
}
|
}
|
||||||
}
|
.doOnCompleted {
|
||||||
if (!failedUpdates.isEmpty()) {
|
cancelProgressNotification()
|
||||||
append("\n\n")
|
|
||||||
append(getString(R.string.notification_manga_update_failed))
|
|
||||||
for (manga in failedUpdates) {
|
|
||||||
append("\n")
|
|
||||||
append(manga.title)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates and acquires a wake lock until the library is updated.
|
|
||||||
*/
|
|
||||||
private fun createAndAcquireWakeLock() {
|
|
||||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
|
|
||||||
PowerManager.PARTIAL_WAKE_LOCK, "LibraryUpdateService:WakeLock")
|
|
||||||
wakeLock.acquire()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Releases the wake lock if it's held.
|
|
||||||
*/
|
|
||||||
private fun destroyWakeLock() {
|
|
||||||
if (wakeLock.isHeld) {
|
|
||||||
wakeLock.release()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows the notification with the given title and body.
|
|
||||||
*
|
|
||||||
* @param title the title of the notification.
|
|
||||||
* @param body the body of the notification.
|
|
||||||
*/
|
|
||||||
private fun showNotification(title: String, body: String) {
|
|
||||||
notificationManager.notify(notificationId, notification() {
|
|
||||||
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
|
|
||||||
setLargeIcon(notificationBitmap)
|
|
||||||
setContentTitle(title)
|
|
||||||
setContentText(body)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -391,67 +416,67 @@ class LibraryUpdateService : Service() {
|
|||||||
* @param current the current progress.
|
* @param current the current progress.
|
||||||
* @param total the total progress.
|
* @param total the total progress.
|
||||||
*/
|
*/
|
||||||
private fun showProgressNotification(manga: Manga, current: Int, total: Int, cancelIntent: PendingIntent) {
|
private fun showProgressNotification(manga: Manga, current: Int, total: Int) {
|
||||||
notificationManager.notify(notificationId, notification() {
|
notificationManager.notify(Constants.NOTIFICATION_LIBRARY_PROGRESS_ID, progressNotification
|
||||||
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
|
.setContentTitle(manga.title)
|
||||||
setLargeIcon(notificationBitmap)
|
.setProgress(total, current, false)
|
||||||
setContentTitle(manga.title)
|
.build())
|
||||||
setProgress(total, current, false)
|
|
||||||
setOngoing(true)
|
|
||||||
addAction(R.drawable.ic_clear_grey_24dp_img, getString(android.R.string.cancel), cancelIntent)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows the notification containing the result of the update done by the service.
|
* Shows the notification containing the result of the update done by the service.
|
||||||
*
|
*
|
||||||
* @param updates a list of manga with new updates.
|
* @param updates a list of manga with new updates.
|
||||||
* @param failed a list of manga that failed to update.
|
|
||||||
*/
|
*/
|
||||||
private fun showResultNotification(updates: List<Manga>, failed: List<Manga>) {
|
private fun showResultNotification(updates: List<Manga>) {
|
||||||
val title = getString(R.string.notification_update_completed)
|
val newUpdates = updates.map { it.title.chop(45) }.toMutableSet()
|
||||||
val body = getUpdatedMangasBody(updates, failed)
|
|
||||||
|
|
||||||
notificationManager.notify(notificationId, notification() {
|
// Append new chapters from a previous, existing notification
|
||||||
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
val previousNotification = notificationManager.activeNotifications
|
||||||
|
.find { it.id == Constants.NOTIFICATION_LIBRARY_RESULT_ID }
|
||||||
|
|
||||||
|
if (previousNotification != null) {
|
||||||
|
val oldUpdates = previousNotification.notification.extras
|
||||||
|
.getString(Notification.EXTRA_BIG_TEXT)
|
||||||
|
|
||||||
|
if (!oldUpdates.isNullOrEmpty()) {
|
||||||
|
newUpdates += oldUpdates.split("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationManager.notify(Constants.NOTIFICATION_LIBRARY_RESULT_ID, notification {
|
||||||
|
setSmallIcon(R.drawable.ic_book_white_24dp)
|
||||||
setLargeIcon(notificationBitmap)
|
setLargeIcon(notificationBitmap)
|
||||||
setContentTitle(title)
|
setContentTitle(getString(R.string.notification_new_chapters))
|
||||||
setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
if (newUpdates.size > 1) {
|
||||||
setContentIntent(notificationIntent)
|
setContentText(getString(R.string.notification_new_chapters_text, newUpdates.size))
|
||||||
|
setStyle(NotificationCompat.BigTextStyle().bigText(newUpdates.joinToString("\n")))
|
||||||
|
} else {
|
||||||
|
setContentText(newUpdates.first())
|
||||||
|
}
|
||||||
|
priority = NotificationCompat.PRIORITY_HIGH
|
||||||
|
setContentIntent(getNotificationIntent())
|
||||||
setAutoCancel(true)
|
setAutoCancel(true)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancels the notification.
|
* Cancels the progress notification.
|
||||||
*/
|
*/
|
||||||
private fun cancelNotification() {
|
private fun cancelProgressNotification() {
|
||||||
notificationManager.cancel(notificationId)
|
notificationManager.cancel(Constants.NOTIFICATION_LIBRARY_PROGRESS_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Property that returns an intent to open the main activity.
|
* Returns an intent to open the main activity.
|
||||||
*/
|
*/
|
||||||
private val notificationIntent: PendingIntent
|
private fun getNotificationIntent(): PendingIntent {
|
||||||
get() {
|
val intent = Intent(this, MainActivity::class.java)
|
||||||
val intent = Intent(this, MainActivity::class.java)
|
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
intent.action = MainActivity.SHORTCUT_RECENTLY_UPDATED
|
||||||
return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
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,52 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.data.library
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.google.android.gms.gcm.*
|
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
|
||||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
|
||||||
import uy.kohesive.injekt.Injekt
|
|
||||||
import uy.kohesive.injekt.api.get
|
|
||||||
|
|
||||||
class LibraryUpdateTrigger : GcmTaskService() {
|
|
||||||
|
|
||||||
override fun onInitializeTasks() {
|
|
||||||
setupTask(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRunTask(params: TaskParams): Int {
|
|
||||||
LibraryUpdateService.start(this)
|
|
||||||
return GcmNetworkManager.RESULT_SUCCESS
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun setupTask(context: Context, prefInterval: Int? = null) {
|
|
||||||
val preferences = Injekt.get<PreferencesHelper>()
|
|
||||||
val interval = prefInterval ?: preferences.libraryUpdateInterval().getOrDefault()
|
|
||||||
if (interval > 0) {
|
|
||||||
val restrictions = preferences.libraryUpdateRestriction()
|
|
||||||
val acRestriction = "ac" in restrictions
|
|
||||||
val wifiRestriction = if ("wifi" in restrictions)
|
|
||||||
Task.NETWORK_STATE_UNMETERED
|
|
||||||
else
|
|
||||||
Task.NETWORK_STATE_ANY
|
|
||||||
|
|
||||||
val task = PeriodicTask.Builder()
|
|
||||||
.setService(LibraryUpdateTrigger::class.java)
|
|
||||||
.setTag("Library periodic update")
|
|
||||||
.setPeriod(interval * 60 * 60L)
|
|
||||||
.setFlex(5 * 60)
|
|
||||||
.setRequiredNetwork(wifiRestriction)
|
|
||||||
.setRequiresCharging(acRestriction)
|
|
||||||
.setUpdateCurrent(true)
|
|
||||||
.setPersisted(true)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
GcmNetworkManager.getInstance(context).schedule(task)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancelTask(context: Context) {
|
|
||||||
GcmNetworkManager.getInstance(context).cancelAllTasks(LibraryUpdateTrigger::class.java)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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,56 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.notification
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||||
|
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, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
|
||||||
|
action = MainActivity.SHORTCUT_DOWNLOADS
|
||||||
|
}
|
||||||
|
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 = file.getUriCompat(context)
|
||||||
|
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,280 @@
|
|||||||
|
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.DiskUtil
|
||||||
|
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_PROGRESS_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
|
||||||
|
val file = File(path)
|
||||||
|
file.delete()
|
||||||
|
|
||||||
|
DiskUtil.scanMedia(context, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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_UPDATE_CURRENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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_UPDATE_CURRENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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_UPDATE_CURRENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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_UPDATE_CURRENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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_UPDATE_CURRENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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_UPDATE_CURRENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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_UPDATE_CURRENT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,101 +1,116 @@
|
|||||||
package eu.kanade.tachiyomi.data.preference
|
package eu.kanade.tachiyomi.data.preference
|
||||||
|
|
||||||
import android.content.Context
|
/**
|
||||||
import eu.kanade.tachiyomi.R
|
* This class stores the keys for the preferences in the application.
|
||||||
|
*/
|
||||||
/**
|
object PreferenceKeys {
|
||||||
* This class stores the keys for the preferences in the application. Most of them are defined
|
|
||||||
* in the file "keys.xml". By using this class we can define preferences in one place and get them
|
const val theme = "pref_theme_key"
|
||||||
* referenced here.
|
|
||||||
*/
|
const val rotation = "pref_rotation_type_key"
|
||||||
class PreferenceKeys(context: Context) {
|
|
||||||
|
const val enableTransitions = "pref_enable_transitions_key"
|
||||||
val theme = context.getString(R.string.pref_theme_key)
|
|
||||||
|
const val showPageNumber = "pref_show_page_number_key"
|
||||||
val rotation = context.getString(R.string.pref_rotation_type_key)
|
|
||||||
|
const val fullscreen = "fullscreen"
|
||||||
val enableTransitions = context.getString(R.string.pref_enable_transitions_key)
|
|
||||||
|
const val keepScreenOn = "pref_keep_screen_on_key"
|
||||||
val showPageNumber = context.getString(R.string.pref_show_page_number_key)
|
|
||||||
|
const val customBrightness = "pref_custom_brightness_key"
|
||||||
val fullscreen = context.getString(R.string.pref_fullscreen_key)
|
|
||||||
|
const val customBrightnessValue = "custom_brightness_value"
|
||||||
val keepScreenOn = context.getString(R.string.pref_keep_screen_on_key)
|
|
||||||
|
const val colorFilter = "pref_color_filter_key"
|
||||||
val customBrightness = context.getString(R.string.pref_custom_brightness_key)
|
|
||||||
|
const val colorFilterValue = "color_filter_value"
|
||||||
val customBrightnessValue = context.getString(R.string.pref_custom_brightness_value_key)
|
|
||||||
|
const val defaultViewer = "pref_default_viewer_key"
|
||||||
val colorFilter = context.getString(R.string.pref_color_filter_key)
|
|
||||||
|
const val imageScaleType = "pref_image_scale_type_key"
|
||||||
val colorFilterValue = context.getString(R.string.pref_color_filter_value_key)
|
|
||||||
|
const val imageDecoder = "image_decoder"
|
||||||
val defaultViewer = context.getString(R.string.pref_default_viewer_key)
|
|
||||||
|
const val zoomStart = "pref_zoom_start_key"
|
||||||
val imageScaleType = context.getString(R.string.pref_image_scale_type_key)
|
|
||||||
|
const val readerTheme = "pref_reader_theme_key"
|
||||||
val imageDecoder = context.getString(R.string.pref_image_decoder_key)
|
|
||||||
|
const val cropBorders = "crop_borders"
|
||||||
val zoomStart = context.getString(R.string.pref_zoom_start_key)
|
|
||||||
|
const val readWithTapping = "reader_tap"
|
||||||
val readerTheme = context.getString(R.string.pref_reader_theme_key)
|
|
||||||
|
const val readWithVolumeKeys = "reader_volume_keys"
|
||||||
val readWithTapping = context.getString(R.string.pref_read_with_tapping_key)
|
|
||||||
|
const val readWithVolumeKeysInverted = "reader_volume_keys_inverted"
|
||||||
val readWithVolumeKeys = context.getString(R.string.pref_read_with_volume_keys_key)
|
|
||||||
|
const val portraitColumns = "pref_library_columns_portrait_key"
|
||||||
val reencodeImage = context.getString(R.string.pref_reencode_key)
|
|
||||||
|
const val landscapeColumns = "pref_library_columns_landscape_key"
|
||||||
val portraitColumns = context.getString(R.string.pref_library_columns_portrait_key)
|
|
||||||
|
const val updateOnlyNonCompleted = "pref_update_only_non_completed_key"
|
||||||
val landscapeColumns = context.getString(R.string.pref_library_columns_landscape_key)
|
|
||||||
|
const val autoUpdateTrack = "pref_auto_update_manga_sync_key"
|
||||||
val updateOnlyNonCompleted = context.getString(R.string.pref_update_only_non_completed_key)
|
|
||||||
|
const val askUpdateTrack = "pref_ask_update_manga_sync_key"
|
||||||
val autoUpdateMangaSync = context.getString(R.string.pref_auto_update_manga_sync_key)
|
|
||||||
|
const val lastUsedCatalogueSource = "last_catalogue_source"
|
||||||
val askUpdateMangaSync = context.getString(R.string.pref_ask_update_manga_sync_key)
|
|
||||||
|
const val lastUsedCategory = "last_used_category"
|
||||||
val lastUsedCatalogueSource = context.getString(R.string.pref_last_catalogue_source_key)
|
|
||||||
|
const val catalogueAsList = "pref_display_catalogue_as_list"
|
||||||
val lastUsedCategory = context.getString(R.string.pref_last_used_category_key)
|
|
||||||
|
const val enabledLanguages = "source_languages"
|
||||||
val catalogueAsList = context.getString(R.string.pref_display_catalogue_as_list)
|
|
||||||
|
const val backupDirectory = "backup_directory"
|
||||||
val enabledLanguages = context.getString(R.string.pref_source_languages)
|
|
||||||
|
const val downloadsDirectory = "download_directory"
|
||||||
val downloadsDirectory = context.getString(R.string.pref_download_directory_key)
|
|
||||||
|
const val downloadThreads = "pref_download_slots_key"
|
||||||
val downloadThreads = context.getString(R.string.pref_download_slots_key)
|
|
||||||
|
const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key"
|
||||||
val downloadOnlyOverWifi = context.getString(R.string.pref_download_only_over_wifi_key)
|
|
||||||
|
const val numberOfBackups = "backup_slots"
|
||||||
val removeAfterReadSlots = context.getString(R.string.pref_remove_after_read_slots_key)
|
|
||||||
|
const val backupInterval = "backup_interval"
|
||||||
val removeAfterMarkedAsRead = context.getString(R.string.pref_remove_after_marked_as_read_key)
|
|
||||||
|
const val removeAfterReadSlots = "remove_after_read_slots"
|
||||||
val libraryUpdateInterval = context.getString(R.string.pref_library_update_interval_key)
|
|
||||||
|
const val removeAfterMarkedAsRead = "pref_remove_after_marked_as_read_key"
|
||||||
val libraryUpdateRestriction = context.getString(R.string.pref_library_update_restriction_key)
|
|
||||||
|
const val libraryUpdateInterval = "pref_library_update_interval_key"
|
||||||
val libraryUpdateCategories = context.getString(R.string.pref_library_update_categories_key)
|
|
||||||
|
const val libraryUpdateRestriction = "library_update_restriction"
|
||||||
val filterDownloaded = context.getString(R.string.pref_filter_downloaded_key)
|
|
||||||
|
const val libraryUpdateCategories = "library_update_categories"
|
||||||
val filterUnread = context.getString(R.string.pref_filter_unread_key)
|
|
||||||
|
const val filterDownloaded = "pref_filter_downloaded_key"
|
||||||
val automaticUpdates = context.getString(R.string.pref_enable_automatic_updates_key)
|
|
||||||
|
const val filterUnread = "pref_filter_unread_key"
|
||||||
val startScreen = context.getString(R.string.pref_start_screen_key)
|
|
||||||
|
const val librarySortingMode = "library_sorting_mode"
|
||||||
fun sourceUsername(sourceId: Int) = "pref_source_username_$sourceId"
|
|
||||||
|
const val automaticUpdates = "automatic_updates"
|
||||||
fun sourcePassword(sourceId: Int) = "pref_source_password_$sourceId"
|
|
||||||
|
const val startScreen = "start_screen"
|
||||||
fun syncUsername(syncId: Int) = "pref_mangasync_username_$syncId"
|
|
||||||
|
const val downloadNew = "download_new"
|
||||||
fun syncPassword(syncId: Int) = "pref_mangasync_password_$syncId"
|
|
||||||
|
const val downloadNewCategories = "download_new_categories"
|
||||||
val libraryAsList = context.getString(R.string.pref_display_library_as_list)
|
|
||||||
|
const val libraryAsList = "pref_display_library_as_list"
|
||||||
}
|
|
||||||
|
const val lang = "app_language"
|
||||||
|
|
||||||
|
const val defaultCategory = "default_category"
|
||||||
|
|
||||||
|
fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId"
|
||||||
|
|
||||||
|
fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId"
|
||||||
|
|
||||||
|
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
|
||||||
|
|
||||||
|
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"
|
||||||
|
|
||||||
|
fun trackToken(syncId: Int) = "track_token_$syncId"
|
||||||
|
|
||||||
|
}
|
||||||
|
@ -1,143 +1,164 @@
|
|||||||
package eu.kanade.tachiyomi.data.preference
|
package eu.kanade.tachiyomi.data.preference
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.preference.PreferenceManager
|
import android.preference.PreferenceManager
|
||||||
import com.f2prateek.rx.preferences.Preference
|
import com.f2prateek.rx.preferences.Preference
|
||||||
import com.f2prateek.rx.preferences.RxSharedPreferences
|
import com.f2prateek.rx.preferences.RxSharedPreferences
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
|
import eu.kanade.tachiyomi.data.track.TrackService
|
||||||
import eu.kanade.tachiyomi.data.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys
|
||||||
|
|
||||||
fun <T> Preference<T>.getOrDefault(): T = get() ?: defaultValue()!!
|
fun <T> Preference<T>.getOrDefault(): T = get() ?: defaultValue()!!
|
||||||
|
|
||||||
class PreferencesHelper(context: Context) {
|
fun Preference<Boolean>.invert(): Boolean = getOrDefault().let { set(!it); !it }
|
||||||
|
|
||||||
val keys = PreferenceKeys(context)
|
class PreferencesHelper(val context: Context) {
|
||||||
|
|
||||||
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
private val rxPrefs = RxSharedPreferences.create(prefs)
|
private val rxPrefs = RxSharedPreferences.create(prefs)
|
||||||
|
|
||||||
private val defaultDownloadsDir = File(Environment.getExternalStorageDirectory().absolutePath +
|
private val defaultDownloadsDir = Uri.fromFile(
|
||||||
File.separator + context.getString(R.string.app_name), "downloads")
|
File(Environment.getExternalStorageDirectory().absolutePath + File.separator +
|
||||||
|
context.getString(R.string.app_name), "downloads"))
|
||||||
|
|
||||||
init {
|
private val defaultBackupDir = Uri.fromFile(
|
||||||
// Don't display downloaded chapters in gallery apps creating a ".nomedia" file
|
File(Environment.getExternalStorageDirectory().absolutePath + File.separator +
|
||||||
try {
|
context.getString(R.string.app_name), "backup"))
|
||||||
File(downloadsDirectory().getOrDefault(), ".nomedia").createNewFile()
|
|
||||||
} catch (e: IOException) {
|
|
||||||
/* Ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startScreen() = prefs.getInt(keys.startScreen, 1)
|
fun startScreen() = prefs.getInt(Keys.startScreen, 1)
|
||||||
|
|
||||||
fun clear() = prefs.edit().clear().apply()
|
fun clear() = prefs.edit().clear().apply()
|
||||||
|
|
||||||
fun theme() = prefs.getInt(keys.theme, 1)
|
fun theme() = prefs.getInt(Keys.theme, 1)
|
||||||
|
|
||||||
fun rotation() = rxPrefs.getInteger(keys.rotation, 1)
|
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)
|
fun showPageNumber() = rxPrefs.getBoolean(Keys.showPageNumber, true)
|
||||||
|
|
||||||
fun fullscreen() = rxPrefs.getBoolean(keys.fullscreen, true)
|
fun fullscreen() = rxPrefs.getBoolean(Keys.fullscreen, true)
|
||||||
|
|
||||||
fun keepScreenOn() = rxPrefs.getBoolean(keys.keepScreenOn, true)
|
fun keepScreenOn() = rxPrefs.getBoolean(Keys.keepScreenOn, true)
|
||||||
|
|
||||||
fun customBrightness() = rxPrefs.getBoolean(keys.customBrightness, false)
|
fun customBrightness() = rxPrefs.getBoolean(Keys.customBrightness, false)
|
||||||
|
|
||||||
fun customBrightnessValue() = rxPrefs.getInteger(keys.customBrightnessValue, 0)
|
fun customBrightnessValue() = rxPrefs.getInteger(Keys.customBrightnessValue, 0)
|
||||||
|
|
||||||
fun colorFilter() = rxPrefs.getBoolean(keys.colorFilter, false)
|
fun colorFilter() = rxPrefs.getBoolean(Keys.colorFilter, false)
|
||||||
|
|
||||||
fun colorFilterValue() = rxPrefs.getInteger(keys.colorFilterValue, 0)
|
fun colorFilterValue() = rxPrefs.getInteger(Keys.colorFilterValue, 0)
|
||||||
|
|
||||||
fun defaultViewer() = prefs.getInt(keys.defaultViewer, 1)
|
fun defaultViewer() = prefs.getInt(Keys.defaultViewer, 1)
|
||||||
|
|
||||||
fun imageScaleType() = rxPrefs.getInteger(keys.imageScaleType, 1)
|
fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1)
|
||||||
|
|
||||||
fun imageDecoder() = rxPrefs.getInteger(keys.imageDecoder, 0)
|
fun imageDecoder() = rxPrefs.getInteger(Keys.imageDecoder, 0)
|
||||||
|
|
||||||
fun zoomStart() = rxPrefs.getInteger(keys.zoomStart, 1)
|
fun zoomStart() = rxPrefs.getInteger(Keys.zoomStart, 1)
|
||||||
|
|
||||||
fun readerTheme() = rxPrefs.getInteger(keys.readerTheme, 0)
|
fun readerTheme() = rxPrefs.getInteger(Keys.readerTheme, 0)
|
||||||
|
|
||||||
fun readWithTapping() = rxPrefs.getBoolean(keys.readWithTapping, true)
|
fun cropBorders() = rxPrefs.getBoolean(Keys.cropBorders, false)
|
||||||
|
|
||||||
fun readWithVolumeKeys() = rxPrefs.getBoolean(keys.readWithVolumeKeys, false)
|
fun readWithTapping() = rxPrefs.getBoolean(Keys.readWithTapping, true)
|
||||||
|
|
||||||
fun reencodeImage() = prefs.getBoolean(keys.reencodeImage, false)
|
fun readWithVolumeKeys() = rxPrefs.getBoolean(Keys.readWithVolumeKeys, false)
|
||||||
|
|
||||||
fun portraitColumns() = rxPrefs.getInteger(keys.portraitColumns, 0)
|
fun readWithVolumeKeysInverted() = rxPrefs.getBoolean(Keys.readWithVolumeKeysInverted, false)
|
||||||
|
|
||||||
fun landscapeColumns() = rxPrefs.getInteger(keys.landscapeColumns, 0)
|
fun portraitColumns() = rxPrefs.getInteger(Keys.portraitColumns, 0)
|
||||||
|
|
||||||
fun updateOnlyNonCompleted() = prefs.getBoolean(keys.updateOnlyNonCompleted, false)
|
fun landscapeColumns() = rxPrefs.getInteger(Keys.landscapeColumns, 0)
|
||||||
|
|
||||||
fun autoUpdateMangaSync() = prefs.getBoolean(keys.autoUpdateMangaSync, true)
|
fun updateOnlyNonCompleted() = prefs.getBoolean(Keys.updateOnlyNonCompleted, false)
|
||||||
|
|
||||||
fun askUpdateMangaSync() = prefs.getBoolean(keys.askUpdateMangaSync, false)
|
fun autoUpdateTrack() = prefs.getBoolean(Keys.autoUpdateTrack, true)
|
||||||
|
|
||||||
fun lastUsedCatalogueSource() = rxPrefs.getInteger(keys.lastUsedCatalogueSource, -1)
|
fun askUpdateTrack() = prefs.getBoolean(Keys.askUpdateTrack, false)
|
||||||
|
|
||||||
fun lastUsedCategory() = rxPrefs.getInteger(keys.lastUsedCategory, 0)
|
fun lastUsedCatalogueSource() = rxPrefs.getLong(Keys.lastUsedCatalogueSource, -1)
|
||||||
|
|
||||||
|
fun lastUsedCategory() = rxPrefs.getInteger(Keys.lastUsedCategory, 0)
|
||||||
|
|
||||||
fun lastVersionCode() = rxPrefs.getInteger("last_version_code", 0)
|
fun lastVersionCode() = rxPrefs.getInteger("last_version_code", 0)
|
||||||
|
|
||||||
fun catalogueAsList() = rxPrefs.getBoolean(keys.catalogueAsList, false)
|
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), "")
|
fun sourceUsername(source: Source) = prefs.getString(Keys.sourceUsername(source.id), "")
|
||||||
|
|
||||||
fun sourcePassword(source: Source) = prefs.getString(keys.sourcePassword(source.id), "")
|
fun sourcePassword(source: Source) = prefs.getString(Keys.sourcePassword(source.id), "")
|
||||||
|
|
||||||
fun setSourceCredentials(source: Source, username: String, password: String) {
|
fun setSourceCredentials(source: Source, username: String, password: String) {
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString(keys.sourceUsername(source.id), username)
|
.putString(Keys.sourceUsername(source.id), username)
|
||||||
.putString(keys.sourcePassword(source.id), password)
|
.putString(Keys.sourcePassword(source.id), password)
|
||||||
.apply()
|
.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()
|
prefs.edit()
|
||||||
.putString(keys.syncUsername(sync.id), username)
|
.putString(Keys.trackUsername(sync.id), username)
|
||||||
.putString(keys.syncPassword(sync.id), password)
|
.putString(Keys.trackPassword(sync.id), password)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.absolutePath)
|
fun trackToken(sync: TrackService) = rxPrefs.getString(Keys.trackToken(sync.id), "")
|
||||||
|
|
||||||
fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1)
|
fun anilistScoreType() = rxPrefs.getInteger("anilist_score_type", 0)
|
||||||
|
|
||||||
fun downloadOnlyOverWifi() = prefs.getBoolean(keys.downloadOnlyOverWifi, true)
|
fun backupsDirectory() = rxPrefs.getString(Keys.backupDirectory, defaultBackupDir.toString())
|
||||||
|
|
||||||
fun removeAfterReadSlots() = prefs.getInt(keys.removeAfterReadSlots, -1)
|
fun downloadsDirectory() = rxPrefs.getString(Keys.downloadsDirectory, defaultDownloadsDir.toString())
|
||||||
|
|
||||||
fun removeAfterMarkedAsRead() = prefs.getBoolean(keys.removeAfterMarkedAsRead, false)
|
fun downloadThreads() = rxPrefs.getInteger(Keys.downloadThreads, 1)
|
||||||
|
|
||||||
fun libraryUpdateInterval() = rxPrefs.getInteger(keys.libraryUpdateInterval, 0)
|
fun downloadOnlyOverWifi() = prefs.getBoolean(Keys.downloadOnlyOverWifi, true)
|
||||||
|
|
||||||
fun libraryUpdateRestriction() = prefs.getStringSet(keys.libraryUpdateRestriction, emptySet())
|
fun numberOfBackups() = rxPrefs.getInteger(Keys.numberOfBackups, 1)
|
||||||
|
|
||||||
fun libraryUpdateCategories() = rxPrefs.getStringSet(keys.libraryUpdateCategories, emptySet())
|
fun backupInterval() = rxPrefs.getInteger(Keys.backupInterval, 0)
|
||||||
|
|
||||||
fun libraryAsList() = rxPrefs.getBoolean(keys.libraryAsList, false)
|
fun removeAfterReadSlots() = prefs.getInt(Keys.removeAfterReadSlots, -1)
|
||||||
|
|
||||||
fun filterDownloaded() = rxPrefs.getBoolean(keys.filterDownloaded, false)
|
fun removeAfterMarkedAsRead() = prefs.getBoolean(Keys.removeAfterMarkedAsRead, false)
|
||||||
|
|
||||||
fun filterUnread() = rxPrefs.getBoolean(keys.filterUnread, false)
|
fun libraryUpdateInterval() = rxPrefs.getInteger(Keys.libraryUpdateInterval, 0)
|
||||||
|
|
||||||
fun automaticUpdates() = prefs.getBoolean(keys.automaticUpdates, false)
|
fun libraryUpdateRestriction() = prefs.getStringSet(Keys.libraryUpdateRestriction, emptySet())
|
||||||
|
|
||||||
|
fun libraryUpdateCategories() = rxPrefs.getStringSet(Keys.libraryUpdateCategories, emptySet())
|
||||||
|
|
||||||
|
fun libraryAsList() = rxPrefs.getBoolean(Keys.libraryAsList, false)
|
||||||
|
|
||||||
|
fun filterDownloaded() = rxPrefs.getBoolean(Keys.filterDownloaded, false)
|
||||||
|
|
||||||
|
fun filterUnread() = rxPrefs.getBoolean(Keys.filterUnread, false)
|
||||||
|
|
||||||
|
fun librarySortingMode() = rxPrefs.getInteger(Keys.librarySortingMode, 0)
|
||||||
|
|
||||||
|
fun librarySortingAscending() = rxPrefs.getBoolean("library_sorting_ascending", true)
|
||||||
|
|
||||||
|
fun automaticUpdates() = prefs.getBoolean(Keys.automaticUpdates, false)
|
||||||
|
|
||||||
fun hiddenCatalogues() = rxPrefs.getStringSet("hidden_catalogues", emptySet())
|
fun hiddenCatalogues() = rxPrefs.getStringSet("hidden_catalogues", emptySet())
|
||||||
|
|
||||||
|
fun downloadNew() = rxPrefs.getBoolean(Keys.downloadNew, false)
|
||||||
|
|
||||||
|
fun downloadNewCategories() = rxPrefs.getStringSet(Keys.downloadNewCategories, emptySet())
|
||||||
|
|
||||||
|
fun lang() = prefs.getString(Keys.lang, "")
|
||||||
|
|
||||||
|
fun defaultCategory() = prefs.getInt(Keys.defaultCategory, -1)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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,472 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.data.source.online
|
|
||||||
|
|
||||||
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.asObservable
|
|
||||||
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))
|
|
||||||
.asObservable()
|
|
||||||
.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))
|
|
||||||
.asObservable()
|
|
||||||
.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))
|
|
||||||
.asObservable()
|
|
||||||
.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))
|
|
||||||
.asObservable()
|
|
||||||
.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))
|
|
||||||
.asObservable()
|
|
||||||
.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))
|
|
||||||
.asObservable()
|
|
||||||
.map { response ->
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
throw Exception("Webpage sent ${response.code()} code")
|
|
||||||
}
|
|
||||||
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))
|
|
||||||
.asObservable()
|
|
||||||
.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)
|
|
||||||
.asObservable()
|
|
||||||
.doOnNext {
|
|
||||||
if (!it.isSuccessful) {
|
|
||||||
it.close()
|
|
||||||
throw RuntimeException("Not a valid response")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.imagePath = chapterCache.getImagePath(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, preferences.reencodeImage()) }
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Overridable method to allow custom parsing.
|
|
||||||
open fun parseChapterNumber(chapter: Chapter) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
val 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,161 +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 urlElement = element.select("a").first()
|
|
||||||
|
|
||||||
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
|
||||||
chapter.name = urlElement.text()
|
|
||||||
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,190 +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 eu.kanade.tachiyomi.util.asJsoup
|
|
||||||
import okhttp3.Response
|
|
||||||
import org.jsoup.nodes.Document
|
|
||||||
import org.jsoup.nodes.Element
|
|
||||||
import java.util.*
|
|
||||||
import java.util.regex.Pattern
|
|
||||||
|
|
||||||
class Mangasee(override val id: Int) : ParsedOnlineSource() {
|
|
||||||
|
|
||||||
override val name = "Mangasee"
|
|
||||||
|
|
||||||
override val baseUrl = "http://www.mangasee.co"
|
|
||||||
|
|
||||||
override val lang: Language get() = EN
|
|
||||||
|
|
||||||
override val supportsLatest = false
|
|
||||||
|
|
||||||
private val datePattern = Pattern.compile("(\\d+)\\s+(.*?)s? (from now|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 dateRelationFields = HashMap<String, Int>().apply {
|
|
||||||
put("from now", 1)
|
|
||||||
put("ago", -1)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popularMangaInitialUrl() = "$baseUrl/search_result.php?Action=Yes&order=popularity&numResultPerPage=20&sort=desc"
|
|
||||||
|
|
||||||
override fun popularMangaSelector() = "div.well > table > tbody > tr"
|
|
||||||
|
|
||||||
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
|
||||||
element.select("td > h2 > a").first().let {
|
|
||||||
manga.setUrlWithoutDomain("/${it.attr("href")}")
|
|
||||||
manga.title = it.text()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popularMangaNextPageSelector() = "ul.pagination > li > a:contains(Next)"
|
|
||||||
|
|
||||||
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
|
|
||||||
"$baseUrl/advanced-search/result.php?sortBy=alphabet&direction=ASC&textOnly=no&resPerPage=20&page=1&seriesName=$query&${filters.map { it.id + "=Yes" }.joinToString("&")}"
|
|
||||||
|
|
||||||
override fun searchMangaSelector() = "div.row > div > div > div > h1"
|
|
||||||
|
|
||||||
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
|
||||||
element.select("a").first().let {
|
|
||||||
manga.setUrlWithoutDomain("/${it.attr("href")}")
|
|
||||||
manga.title = it.text()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun searchMangaNextPageSelector() = "ul.pagination > li > a:contains(Next)"
|
|
||||||
|
|
||||||
override fun mangaDetailsParse(document: Document, manga: Manga) {
|
|
||||||
val detailElement = document.select("div.well > div.row").first()
|
|
||||||
|
|
||||||
manga.author = detailElement.select("a[href^=../search_result.php?author_name=]").first()?.text()
|
|
||||||
manga.genre = detailElement.select("div > div.row > div:has(b:contains(Genre:)) > a").map { it.text() }.joinToString()
|
|
||||||
manga.description = detailElement.select("strong:contains(Description:) + div").first()?.text()
|
|
||||||
manga.status = detailElement.select("div > div.row > div:has(b:contains(Scanlation 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") -> Manga.ONGOING
|
|
||||||
status.contains("Completed") -> Manga.COMPLETED
|
|
||||||
else -> Manga.UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun chapterListSelector() = "div.row > div > div.row > div > div.row:has(a.chapter_link[alt])"
|
|
||||||
|
|
||||||
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("span").first()?.text()?.let { parseChapterDate(it) } ?: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseChapterDate(dateAsString: String): Long {
|
|
||||||
val m = datePattern.matcher(dateAsString)
|
|
||||||
|
|
||||||
if (m.matches()) {
|
|
||||||
val amount = Integer.parseInt(m.group(1))
|
|
||||||
val unit = m.group(2)
|
|
||||||
val relation = m.group(3)
|
|
||||||
|
|
||||||
return Calendar.getInstance().apply {
|
|
||||||
add(dateFields[unit]!!, dateRelationFields[relation]!! * amount)
|
|
||||||
}.time.time
|
|
||||||
} else {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pageListParse(response: Response, pages: MutableList<Page>) {
|
|
||||||
val document = response.asJsoup()
|
|
||||||
val url = response.request().url().toString().substringBeforeLast('/')
|
|
||||||
|
|
||||||
val series = document.select("input[name=series]").first().attr("value")
|
|
||||||
val chapter = document.select("input[name=chapter]").first().attr("value")
|
|
||||||
val index = document.select("input[name=index]").first().attr("value")
|
|
||||||
|
|
||||||
document.select("select[name=page] > option").forEach {
|
|
||||||
pages.add(Page(pages.size, "$url/?series=$series&chapter=$chapter&index=$index&page=${pages.size + 1}"))
|
|
||||||
}
|
|
||||||
pages.getOrNull(0)?.imageUrl = imageUrlParse(document)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not used, overrides parent.
|
|
||||||
override fun pageListParse(document: Document, pages: MutableList<Page>) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun imageUrlParse(document: Document) = document.select("div > a > img").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 {
|
|
||||||
throw UnsupportedOperationException("not implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun latestUpdatesNextPageSelector(): String {
|
|
||||||
throw UnsupportedOperationException("not implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
|
||||||
throw UnsupportedOperationException("not implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun latestUpdatesSelector(): String {
|
|
||||||
throw UnsupportedOperationException("not implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,203 +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 = false
|
|
||||||
|
|
||||||
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 popularMangaSelector() = "div.hot-manga > div.style-list > 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 popularMangaNextPageSelector() = "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")
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun latestUpdatesInitialUrl(): String {
|
|
||||||
throw UnsupportedOperationException("not implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun latestUpdatesNextPageSelector(): String {
|
|
||||||
throw UnsupportedOperationException("not implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
|
||||||
throw UnsupportedOperationException("not implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun latestUpdatesSelector(): String {
|
|
||||||
throw UnsupportedOperationException("not implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,107 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.data.source.online.russian
|
|
||||||
|
|
||||||
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.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.*
|
|
||||||
|
|
||||||
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/manga/new"
|
|
||||||
|
|
||||||
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/?do=search&subaction=search&story=$query"
|
|
||||||
|
|
||||||
override fun popularMangaSelector() = "div.content_row"
|
|
||||||
|
|
||||||
override fun latestUpdatesSelector() = "div.content_row"
|
|
||||||
|
|
||||||
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) {
|
|
||||||
popularMangaFromElement(element, manga)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popularMangaNextPageSelector() = "a:contains(Вперед)"
|
|
||||||
|
|
||||||
override fun latestUpdatesNextPageSelector() = "a:contains(Вперед)"
|
|
||||||
|
|
||||||
override fun searchMangaSelector() = popularMangaSelector()
|
|
||||||
|
|
||||||
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
|
||||||
popularMangaFromElement(element, manga)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
|
|
||||||
|
|
||||||
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(',')
|
|
||||||
|
|
||||||
for ((i, url) in pageUrls.withIndex()) {
|
|
||||||
pages.add(Page(i, "", url))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pageListParse(document: Document, pages: MutableList<Page>) { }
|
|
||||||
|
|
||||||
override fun imageUrlParse(document: Document) = ""
|
|
||||||
|
|
||||||
}
|
|
@ -1,167 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.data.source.online.russian
|
|
||||||
|
|
||||||
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.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 parseChapterNumber(chapter: Chapter) {
|
|
||||||
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,168 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.data.source.online.russian
|
|
||||||
|
|
||||||
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.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) {
|
|
||||||
element.select("h3 > a").first().let {
|
|
||||||
manga.setUrlWithoutDomain(it.attr("href"))
|
|
||||||
manga.title = it.attr("title")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 parseChapterNumber(chapter: Chapter) {
|
|
||||||
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_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())
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user