mirror of
https://github.com/mihonapp/mihon.git
synced 2025-06-26 11:07:51 +02:00
Compare commits
420 Commits
Author | SHA1 | Date | |
---|---|---|---|
706fa82a37 | |||
57c54fa275 | |||
38c5234e46 | |||
767ee164e6 | |||
11262f86f9 | |||
d1db2d60ec | |||
156e43290e | |||
6687c80b2d | |||
1fbec7bf3d | |||
6196480d1d | |||
2723aeeb5c | |||
728ab18017 | |||
0714fdc7e6 | |||
7bfdfee27b | |||
10ec3a9b0c | |||
eec728f162 | |||
1ac8ef5341 | |||
18cdddf433 | |||
02a697031f | |||
6beff242b0 | |||
46cc078e93 | |||
9aa6da0642 | |||
8bd20c39aa | |||
efd36388b0 | |||
79bb207a8d | |||
0fe350af9a | |||
4e784cd7c3 | |||
12d6919421 | |||
79ec4faddb | |||
6603c0b990 | |||
b5dbdd1774 | |||
a08cea9df8 | |||
5d9c817461 | |||
f95c9a12c9 | |||
015257fe75 | |||
dd5692bb2d | |||
bc3d5dc863 | |||
a4b6003e58 | |||
8086d1db46 | |||
26f4f53ec2 | |||
6af78418a4 | |||
f629db3c10 | |||
af0cf9e52d | |||
e885469504 | |||
ca7e5260f0 | |||
dba64f849b | |||
02e43bafd6 | |||
637dda2e22 | |||
575eaee1d2 | |||
48a1e8f74c | |||
4d65038ad3 | |||
6e8a41f898 | |||
8da11dbdb9 | |||
70fabf6a6b | |||
43fafbc747 | |||
88e64c878b | |||
0ad9e4af0b | |||
a6df745daa | |||
c64bd81339 | |||
c31f0b4bb3 | |||
136136d055 | |||
c20d86e5c0 | |||
7ca99f749b | |||
7cc4405c09 | |||
62d5deaa6f | |||
7f5879ed6f | |||
a0f7761a37 | |||
ed77c60283 | |||
8f144316a6 | |||
e73eed4a9b | |||
9de3da33aa | |||
0de214c3b5 | |||
1226023dc2 | |||
5e24054a0b | |||
af2b886599 | |||
1d1e5f1f99 | |||
49628e9cf5 | |||
47bc1f7a9f | |||
f9783407bd | |||
74ffa14304 | |||
e881488bcc | |||
97ee7b81af | |||
ff6eefe1c4 | |||
9f546d13c2 | |||
2e6fc70353 | |||
270cacb1d7 | |||
e2ecf0ce5f | |||
5d396bfb7c | |||
de6cc8394e | |||
4b7159648a | |||
eb9c5f95db | |||
55e9d2880c | |||
ec9c19ce7d | |||
31731e8f26 | |||
bfb12bc7c1 | |||
4befcf3819 | |||
cb58145361 | |||
b83efd90a8 | |||
9f0da3f1d6 | |||
50ae08ed8d | |||
5385642a5b | |||
0a27d4e185 | |||
bd8b9febd2 | |||
a30705f197 | |||
877032a757 | |||
19bf47b6d2 | |||
a9bfeb058b | |||
9213fc6999 | |||
447dfd1e3c | |||
638d3a32cf | |||
17c59657c3 | |||
81bce8ef76 | |||
78314077bb | |||
a7840bc247 | |||
6d0254c5e5 | |||
06681a3db7 | |||
55de2b7d97 | |||
065ada3d17 | |||
0ee2bf5254 | |||
0fe0088ff0 | |||
492a24ec17 | |||
17a6ea973e | |||
deaba48431 | |||
eb662f1234 | |||
5ecdecea98 | |||
b4277faf90 | |||
09902566ad | |||
dc80a5ffbd | |||
b1b97c19d4 | |||
46a0820e5c | |||
6cbdbb5be3 | |||
e753539c6d | |||
b8d1a88623 | |||
b84635ffec | |||
ed2fd00603 | |||
af20c613a4 | |||
b27669ee32 | |||
81d39ea272 | |||
840437580f | |||
936ede9aba | |||
2fa5d0cbaf | |||
e28f69cddf | |||
11f6c44442 | |||
8c9db2db61 | |||
7e7d27505a | |||
b4211ddc0c | |||
ff906e8ee7 | |||
c1ebccd0f4 | |||
9c223b48a3 | |||
16037dd9bd | |||
742e8c9f50 | |||
194cdf3b5f | |||
5fbeeade94 | |||
72f029b57f | |||
67c4781376 | |||
fe49286d97 | |||
4196a0f585 | |||
a6a9b13545 | |||
fa8d0946e9 | |||
1844b8c5a2 | |||
7c503648ff | |||
a598ebf72f | |||
d8ac35d259 | |||
5029e4a28c | |||
908e60dea4 | |||
d577bf300c | |||
579a606f93 | |||
ac15c0c57e | |||
0c0372dc51 | |||
723c0e99a5 | |||
e04596c668 | |||
a809b05808 | |||
da44dc3fb5 | |||
06c63f1207 | |||
c3425346b7 | |||
fe8e4a4f54 | |||
abab778e2e | |||
03ecb2fe13 | |||
a78f89d4eb | |||
499c2213ee | |||
abbc7b572a | |||
bf1fdda651 | |||
218ea7267d | |||
70cf085df9 | |||
71783657af | |||
137c21e6c9 | |||
bc473055b9 | |||
3d67607768 | |||
ce271649ac | |||
0078cb88c3 | |||
19cb548e18 | |||
b3cf7dbc14 | |||
bbfe0a0cd1 | |||
92b3f90380 | |||
b09345f2e1 | |||
0d41c60a38 | |||
5132f4850f | |||
53fae2939a | |||
14f248546a | |||
0d519b3d16 | |||
8e0a9d6d66 | |||
b8bc3476f4 | |||
6b326cfb79 | |||
aaef738dda | |||
35748fc1f3 | |||
a122d817e8 | |||
4ccce424de | |||
396a79899e | |||
de53681d2b | |||
aacd42b9f6 | |||
5ef5f9b45f | |||
98d420d5aa | |||
5b75818fc5 | |||
f49577bc77 | |||
a87c65872c | |||
ed636d5e2f | |||
2ced70652b | |||
dc742f4b8d | |||
689f2e7fbf | |||
ba1dca1826 | |||
a07e4c69b6 | |||
05adde552d | |||
0ddbfd1036 | |||
8b45df37d2 | |||
70e557575f | |||
bcbd541d48 | |||
6383a745ff | |||
b89d6644d8 | |||
8fbef4b4bb | |||
d9f5a97d56 | |||
e4ee03cb61 | |||
c2a65c71e1 | |||
8c456a2da4 | |||
f856386bf7 | |||
e1448eaeda | |||
3cf61f2f93 | |||
ff61282104 | |||
b2fe9d7d4d | |||
16a5e6c01a | |||
306b1d74bb | |||
a7e652f1f7 | |||
e1a3ab2726 | |||
ae9c412b6d | |||
fad0027e17 | |||
1d7f012fd1 | |||
ee4bf163ef | |||
fabdba4452 | |||
1a14fc5c48 | |||
fa5b64ce2e | |||
4397a44b80 | |||
d4bb092543 | |||
f73f0cc341 | |||
b95d0e2848 | |||
50b97fa28f | |||
61c7feca87 | |||
4dde6d1a31 | |||
9ac2f02885 | |||
19eb77f049 | |||
59276b7160 | |||
9062e40ec5 | |||
9f78c8f6b4 | |||
144d315e27 | |||
34dc85e605 | |||
4ba0f343e3 | |||
4876eaafcc | |||
16f6be3613 | |||
da0c7d7484 | |||
fbd2f86f00 | |||
a309b7a066 | |||
9ad9c275a4 | |||
7a467d43df | |||
db97250db8 | |||
c0452038f7 | |||
110df59197 | |||
75ae4081d8 | |||
2efca050b3 | |||
37e119c4f2 | |||
9786074119 | |||
d4876b426f | |||
8581e4667a | |||
b94f86765d | |||
ba0f3778ce | |||
aac6b242a0 | |||
dec9442a65 | |||
b33da641d9 | |||
96d498e7e5 | |||
eee137a084 | |||
5e834ae3be | |||
dcfda61aba | |||
5ac7f7057a | |||
ff46c61f63 | |||
57b64a412e | |||
1e81f75377 | |||
1dd49a2ab1 | |||
1cd77a97a7 | |||
f820522a69 | |||
3da613dedb | |||
5c329d2314 | |||
4c073e713d | |||
2832f4ae5e | |||
7690e8a53f | |||
3a19f8e40b | |||
a33b525f9e | |||
7d6ce46829 | |||
a90a4bf80c | |||
140bf8caee | |||
56a45f263e | |||
01d6ddfafb | |||
393b4916f6 | |||
cb3c3af865 | |||
5a83976fa5 | |||
a81f6c3ac4 | |||
6846ce5bfb | |||
0c0ebe06e5 | |||
e50c683159 | |||
872af276ea | |||
e6faee9779 | |||
bc1ddd4379 | |||
e348d6c1cf | |||
7835921045 | |||
1611a274b9 | |||
fa4a8204a4 | |||
5977e9f47f | |||
63d0161da5 | |||
d8b46c1969 | |||
f84731c2df | |||
50d71d1395 | |||
4be0b2502e | |||
6c069ad87b | |||
e69011ac5b | |||
ea130a0899 | |||
2566862e0f | |||
16081817c2 | |||
945625d3ad | |||
050b9c9fce | |||
c35184abdc | |||
34c5f0b7ba | |||
6435eeb251 | |||
eec2dcd981 | |||
57ba368ae0 | |||
ed06469885 | |||
79cd8c691e | |||
391550f49a | |||
6aa07dd17e | |||
aada373a0c | |||
3deac86bbe | |||
d7aef2e97a | |||
7953ba6e78 | |||
8aa3c2a260 | |||
c204548df5 | |||
4d47f5a387 | |||
7944bb8479 | |||
c4ae88a8ff | |||
ad953b7bf6 | |||
d799ae5d72 | |||
a3ec057384 | |||
486f129e62 | |||
e6c3864c71 | |||
7461f12066 | |||
e53b05feba | |||
bcefc176c1 | |||
d0580d0df1 | |||
28fd22dfe0 | |||
742924625d | |||
78a2eae719 | |||
38bb0b61d4 | |||
8b52fea602 | |||
c03495be94 | |||
f19889c222 | |||
af0ab5ec86 | |||
ea4fa60e01 | |||
4b60560a9f | |||
733b0da461 | |||
db074a371d | |||
bb110ce353 | |||
74c32f9e16 | |||
d8ab8f297f | |||
ec7df6b1f2 | |||
ef03ca22d1 | |||
82865dd3fd | |||
ba5d13936c | |||
23a6f76c37 | |||
0c9bc97fe8 | |||
c6ecfb2f67 | |||
8ca0814aff | |||
eceb4c3682 | |||
e7ecd5a5c2 | |||
f7c20a5517 | |||
6f409c0e3b | |||
0a31c223e3 | |||
0f42956f3f | |||
ac2485d4a7 | |||
7993ec5074 | |||
4521174138 | |||
27b95e9d73 | |||
a54425f47d | |||
4918e67fda | |||
b174adbab0 | |||
59cc87c583 | |||
0e87dc995a | |||
fad7b75b96 | |||
c99c90fc4c | |||
594219848d | |||
fa301bfbd2 | |||
50306f6ea3 | |||
9b90ad0a3b | |||
5c854984e4 | |||
6c844cfd9c | |||
9e666dcdb3 | |||
e81f98a975 | |||
11dc0d7e9e | |||
07ed2e2ebb | |||
e1aa460106 | |||
75a77566cf | |||
dd0a2d842a | |||
fa71e906c9 | |||
e6a17e25a9 | |||
d88513de56 | |||
ad97d03f1d | |||
7fc23d526b |
31
.github/CONTRIBUTING.md
vendored
Normal file
31
.github/CONTRIBUTING.md
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
# Bugs
|
||||
* Include version (Setting > About > Version)
|
||||
* If not latest, try updating, it may have already been solved
|
||||
* Dev version is equal to the number of commits as seen in the main page
|
||||
* Include steps to reproduce (if not obvious from description)
|
||||
* Include screenshot (if needed)
|
||||
* If it could be device-dependent, try reproducing on another device (if possible), include results and device names, OS, modifications (root, Xposed)
|
||||
* **Before reporting a new issue, take a look at the [FAQ](https://github.com/inorichi/tachiyomi/wiki/FAQ), the [changelog](https://github.com/inorichi/tachiyomi/releases) and the already opened [issues](https://github.com/inorichi/tachiyomi/issues).**
|
||||
* For large logs use http://pastebin.com/ (or similar)
|
||||
* For multipart issues **use list** like this:
|
||||
* [x] Done
|
||||
* [ ] Not done
|
||||
```
|
||||
* [x] Done
|
||||
* [ ] Not done
|
||||
```
|
||||
* Don't put together too many unrelated requests into one issue
|
||||
|
||||
DO: https://github.com/inorichi/tachiyomi/issues/24 https://github.com/inorichi/tachiyomi/issues/71
|
||||
|
||||
DON'T: https://github.com/inorichi/tachiyomi/issues/75
|
||||
|
||||
# Feature requests
|
||||
|
||||
* Write a detailed issue, explaning what it should do or how. Avoid writing just "like X app does"
|
||||
* Include screenshot (if needed)
|
||||
|
||||
# Translations
|
||||
|
||||
File `app/src/main/res/values/strings.xml` should be copied over to appropriate directories and then translated.
|
||||
Consult [Android.com](http://developer.android.com/training/basics/supporting-devices/languages.html#CreateDirs)
|
7
.github/ISSUE_TEMPLATE.md
vendored
Normal file
7
.github/ISSUE_TEMPLATE.md
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
**Please read https://github.com/inorichi/tachiyomi/blob/master/.github/CONTRIBUTING.md before posting**
|
||||
|
||||
Remove line above and describe your issue here. Fill out version below. Use Preview.
|
||||
|
||||
|
||||
Version: r000 or v0.0.0
|
||||
(other relevant info like OS)
|
13
.travis.yml
13
.travis.yml
@ -5,7 +5,7 @@ android:
|
||||
- tools
|
||||
|
||||
# The BuildTools version used by your project
|
||||
- build-tools-23.0.1
|
||||
- build-tools-23.0.3
|
||||
- android-23
|
||||
- extra-android-m2repository
|
||||
- extra-google-m2repository
|
||||
@ -15,5 +15,14 @@ android:
|
||||
before_script:
|
||||
- chmod +x gradlew
|
||||
#Build, and run tests
|
||||
script: "./gradlew build testDebug"
|
||||
script: "./gradlew clean buildDebug"
|
||||
sudo: false
|
||||
|
||||
before_cache:
|
||||
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/.gradle/caches/
|
||||
- $HOME/.gradle/wrapper/
|
||||
env:
|
||||
- GRADLE_OPTS="-XX:MaxPermSize=1024m -XX:+CMSClassUnloadingEnabled -XX:+HeapDumpOnOutOfMemoryError -Xmx2048m"
|
||||
|
15
README.md
15
README.md
@ -1,8 +1,16 @@
|
||||
| Build | Download | Auto Update |
|
||||
|-------|----------|-------------|
|
||||
| [](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-debug-versions) |
|
||||
|
||||
## [Report an issue](https://github.com/inorichi/tachiyomi/blob/master/.github/CONTRIBUTING.md)
|
||||
|
||||
**Before reporting a new issue, take a look at the [FAQ](https://github.com/inorichi/tachiyomi/wiki/FAQ), the [changelog](https://github.com/inorichi/tachiyomi/releases) and the already opened issues.**
|
||||
|
||||
Tachiyomi is a free and open source manga reader for Android.
|
||||
|
||||
Keep in mind it's still a beta, so expect it to crash sometimes.
|
||||
|
||||
Current features:
|
||||
# Features
|
||||
|
||||
* Online and offline reading
|
||||
* Configurable reader with multiple viewers and settings
|
||||
@ -12,11 +20,6 @@ Current features:
|
||||
* Schedule searching for updates
|
||||
* Categories to organize your library
|
||||
|
||||
## Download
|
||||
|
||||
[](https://github.com/inorichi/tachiyomi/releases/download/v0.1.0/tachiyomi-v0.1.0.apk)
|
||||
[](http://tachiyomi.kanade.eu/latest/app-debug.apk)
|
||||
|
||||
## License
|
||||
|
||||
Copyright 2015 Javier Tomás
|
||||
|
167
app/build.gradle
167
app/build.gradle
@ -1,19 +1,14 @@
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
apply plugin: 'android-sdk-manager'
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'com.neenbedankt.android-apt'
|
||||
apply plugin: 'me.tatarka.retrolambda'
|
||||
|
||||
retrolambda {
|
||||
jvmArgs '-noverify'
|
||||
}
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
|
||||
ext {
|
||||
// Git is needed in your system PATH for these commands to work.
|
||||
// If it's not installed, you can return a random value as a workaround
|
||||
getCommitCount = {
|
||||
return 'git rev-list --count origin/master'.execute().text.trim()
|
||||
return 'git rev-list --count HEAD'.execute().text.trim()
|
||||
// return "1"
|
||||
}
|
||||
|
||||
@ -29,9 +24,13 @@ ext {
|
||||
}
|
||||
}
|
||||
|
||||
def includeUpdater() {
|
||||
return hasProperty("include_updater")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion 23
|
||||
buildToolsVersion "23.0.2"
|
||||
buildToolsVersion "23.0.3"
|
||||
publishNonDefault true
|
||||
|
||||
defaultConfig {
|
||||
@ -39,21 +38,20 @@ android {
|
||||
minSdkVersion 16
|
||||
targetSdkVersion 23
|
||||
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
||||
versionCode 2
|
||||
versionName "0.1.1"
|
||||
versionCode 9
|
||||
versionName "0.2.2-1"
|
||||
|
||||
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
|
||||
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
|
||||
buildConfigField "String", "BUILD_TIME", "\"${getBuildTime()}\""
|
||||
}
|
||||
buildConfigField "boolean", "INCLUDE_UPDATER", "${includeUpdater()}"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
versionNameSuffix ".${getCommitCount()}"
|
||||
applicationIdSuffix ".debug"
|
||||
}
|
||||
release {
|
||||
@ -73,71 +71,134 @@ android {
|
||||
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
checkReleaseBuilds false
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
// http://stackoverflow.com/questions/32759529/androidhttpclient-not-found-when-running-robolectric
|
||||
useLibrary 'org.apache.http.legacy'
|
||||
|
||||
}
|
||||
|
||||
kapt {
|
||||
generateStubs = true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
final SUPPORT_LIBRARY_VERSION = '23.1.1'
|
||||
final DAGGER_VERSION = '2.0.2'
|
||||
final MOCKITO_VERSION = '1.10.19'
|
||||
final SUPPORT_LIBRARY_VERSION = '23.4.0'
|
||||
final DAGGER_VERSION = '2.4'
|
||||
final RETROFIT_VERSION = '2.0.2'
|
||||
final NUCLEUS_VERSION = '3.0.0'
|
||||
final STORIO_VERSION = '1.8.0'
|
||||
final ICEPICK_VERSION = '3.1.0'
|
||||
final MOCKITO_VERSION = '1.10.19'
|
||||
|
||||
compile fileTree(dir: 'libs', include: ['*.jar'])
|
||||
compile project(":SubsamplingScaleImageView")
|
||||
// Modified dependencies
|
||||
compile 'com.github.inorichi:subsampling-scale-image-view:421fb81'
|
||||
compile 'com.github.inorichi:ReactiveNetwork:69092ed'
|
||||
|
||||
// Android support library
|
||||
compile "com.android.support:support-v4:$SUPPORT_LIBRARY_VERSION"
|
||||
compile "com.android.support:appcompat-v7:$SUPPORT_LIBRARY_VERSION"
|
||||
compile "com.android.support:cardview-v7:$SUPPORT_LIBRARY_VERSION"
|
||||
compile "com.android.support:design:$SUPPORT_LIBRARY_VERSION"
|
||||
compile "com.android.support:recyclerview-v7:$SUPPORT_LIBRARY_VERSION"
|
||||
compile "com.android.support:support-annotations:$SUPPORT_LIBRARY_VERSION"
|
||||
compile 'com.squareup.okhttp:okhttp-urlconnection:2.7.2'
|
||||
compile 'com.squareup.okhttp:okhttp:2.7.2'
|
||||
compile 'com.squareup.okio:okio:1.6.0'
|
||||
compile 'com.google.code.gson:gson:2.5'
|
||||
compile 'com.jakewharton:disklrucache:2.0.2'
|
||||
compile 'org.jsoup:jsoup:1.8.3'
|
||||
compile 'io.reactivex:rxandroid:1.1.0'
|
||||
compile 'io.reactivex:rxjava:1.1.0'
|
||||
compile "com.android.support:preference-v7:$SUPPORT_LIBRARY_VERSION"
|
||||
compile "com.android.support:preference-v14:$SUPPORT_LIBRARY_VERSION"
|
||||
compile "com.android.support:customtabs:$SUPPORT_LIBRARY_VERSION"
|
||||
|
||||
// ReactiveX
|
||||
compile 'io.reactivex:rxandroid:1.2.0'
|
||||
compile 'io.reactivex:rxjava:1.1.5'
|
||||
compile 'com.f2prateek.rx.preferences:rx-preferences:1.0.1'
|
||||
|
||||
// Network client
|
||||
compile "com.squareup.okhttp3:okhttp:3.3.1"
|
||||
|
||||
// REST
|
||||
compile "com.squareup.retrofit2:retrofit:$RETROFIT_VERSION"
|
||||
compile "com.squareup.retrofit2:converter-gson:$RETROFIT_VERSION"
|
||||
compile "com.squareup.retrofit2:adapter-rxjava:$RETROFIT_VERSION"
|
||||
|
||||
// IO
|
||||
compile 'com.squareup.okio:okio:1.8.0'
|
||||
|
||||
// JSON
|
||||
compile 'com.google.code.gson:gson:2.6.2'
|
||||
|
||||
// YAML
|
||||
compile 'org.yaml:snakeyaml:1.17'
|
||||
|
||||
// JavaScript engine
|
||||
compile 'com.squareup.duktape:duktape-android:0.9.5'
|
||||
|
||||
// Disk cache
|
||||
compile 'com.jakewharton:disklrucache:2.0.2'
|
||||
|
||||
// Parse HTML
|
||||
compile 'org.jsoup:jsoup:1.9.2'
|
||||
|
||||
// Changelog
|
||||
compile 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
|
||||
|
||||
// Database
|
||||
compile "com.pushtorefresh.storio:sqlite:$STORIO_VERSION"
|
||||
compile "com.pushtorefresh.storio:sqlite-annotations:$STORIO_VERSION"
|
||||
compile 'info.android15.nucleus:nucleus:2.0.4'
|
||||
compile 'de.greenrobot:eventbus:2.4.0'
|
||||
compile 'com.github.bumptech.glide:glide:3.6.1'
|
||||
compile 'com.jakewharton:butterknife:7.0.1'
|
||||
compile 'com.jakewharton.timber:timber:4.1.0'
|
||||
compile 'uk.co.ribot:easyadapter:1.5.0@aar'
|
||||
compile 'ch.acra:acra:4.7.0'
|
||||
compile "frankiesardo:icepick:$ICEPICK_VERSION"
|
||||
provided "frankiesardo:icepick-processor:$ICEPICK_VERSION"
|
||||
compile 'com.github.dmytrodanylyk.android-process-button:library:1.0.4'
|
||||
compile 'eu.davidea:flexible-adapter:4.2.0@aar'
|
||||
compile 'com.nononsenseapps:filepicker:2.5.1'
|
||||
compile 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1'
|
||||
compile 'com.github.pwittchen:reactivenetwork:0.1.5'
|
||||
kapt "com.pushtorefresh.storio:sqlite-annotations-processor:$STORIO_VERSION"
|
||||
|
||||
// Model View Presenter
|
||||
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"
|
||||
|
||||
// Dependency injection
|
||||
compile "com.google.dagger:dagger:$DAGGER_VERSION"
|
||||
apt "com.google.dagger:dagger-compiler:$DAGGER_VERSION"
|
||||
apt "com.pushtorefresh.storio:sqlite-annotations-processor:$STORIO_VERSION"
|
||||
kapt "com.google.dagger:dagger-compiler:$DAGGER_VERSION"
|
||||
provided 'org.glassfish:javax.annotation:10.0-b28'
|
||||
|
||||
compile('com.mikepenz:materialdrawer:4.6.4@aar') {
|
||||
transitive = true
|
||||
}
|
||||
compile('com.github.afollestad.material-dialogs:core:0.8.5.3@aar') {
|
||||
transitive = true
|
||||
}
|
||||
// Image library
|
||||
compile 'com.github.bumptech.glide:glide:3.7.0'
|
||||
compile 'com.github.bumptech.glide:okhttp3-integration:1.4.0@aar'
|
||||
|
||||
// Logging
|
||||
compile 'com.jakewharton.timber:timber:4.1.2'
|
||||
|
||||
// Crash reports
|
||||
compile 'ch.acra:acra:4.8.5'
|
||||
|
||||
// UI
|
||||
compile 'com.github.dmytrodanylyk.android-process-button:library:1.0.4'
|
||||
compile 'eu.davidea:flexible-adapter:4.2.0'
|
||||
compile 'com.nononsenseapps:filepicker:2.5.2'
|
||||
compile 'com.github.amulyakhare:TextDrawable:558677e'
|
||||
compile 'com.afollestad.material-dialogs:core:0.8.5.9'
|
||||
|
||||
// Tests
|
||||
testCompile 'junit:junit:4.12'
|
||||
testCompile 'org.assertj:assertj-core:2.3.0'
|
||||
testCompile 'org.assertj:assertj-core:1.7.1'
|
||||
testCompile "org.mockito:mockito-core:$MOCKITO_VERSION"
|
||||
testCompile('org.robolectric:robolectric:3.0') {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
exclude group: 'org.apache.httpcomponents', module: 'httpclient'
|
||||
}
|
||||
|
||||
androidTestApt "com.google.dagger:dagger-compiler:$DAGGER_VERSION"
|
||||
kaptTest "com.google.dagger:dagger-compiler:$DAGGER_VERSION"
|
||||
compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
}
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.0.2'
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
68
app/proguard-rules.pro
vendored
68
app/proguard-rules.pro
vendored
@ -2,15 +2,12 @@
|
||||
|
||||
-keep class eu.kanade.tachiyomi.injection.** { *; }
|
||||
|
||||
# Retrolambda
|
||||
-dontwarn java.lang.invoke.*
|
||||
|
||||
# OkHttp
|
||||
-keepattributes Signature
|
||||
-keepattributes *Annotation*
|
||||
-keep class com.squareup.okhttp.** { *; }
|
||||
-keep interface com.squareup.okhttp.** { *; }
|
||||
-dontwarn com.squareup.okhttp.**
|
||||
-keep class okhttp3.** { *; }
|
||||
-keep interface okhttp3.** { *; }
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
|
||||
# Okio
|
||||
@ -19,35 +16,6 @@
|
||||
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
|
||||
-dontwarn okio.**
|
||||
|
||||
# ButterKnife 7
|
||||
-keep class butterknife.** { *; }
|
||||
-dontwarn butterknife.internal.**
|
||||
-keep class **$$ViewBinder { *; }
|
||||
|
||||
-keepclasseswithmembernames class * {
|
||||
@butterknife.* <fields>;
|
||||
}
|
||||
|
||||
-keepclasseswithmembernames class * {
|
||||
@butterknife.* <methods>;
|
||||
}
|
||||
|
||||
#Easy-Adapter v1.5.0
|
||||
-keepattributes *Annotation*
|
||||
-keepclassmembers class * extends uk.co.ribot.easyadapter.ItemViewHolder {
|
||||
public <init>(...);
|
||||
}
|
||||
|
||||
## GreenRobot EventBus specific rules ##
|
||||
# https://github.com/greenrobot/EventBus/blob/master/HOWTO.md#proguard-configuration
|
||||
-keepclassmembers class ** {
|
||||
public void onEvent*(***);
|
||||
}
|
||||
|
||||
# Don't warn for missing support classes
|
||||
-dontwarn de.greenrobot.event.util.*$Support
|
||||
-dontwarn de.greenrobot.event.util.*$SupportManagerFragment
|
||||
|
||||
# Glide specific rules #
|
||||
# https://github.com/bumptech/glide
|
||||
-keep public class * implements com.bumptech.glide.module.GlideModule
|
||||
@ -57,7 +25,6 @@
|
||||
}
|
||||
|
||||
# RxJava 1.1.0
|
||||
|
||||
-dontwarn sun.misc.**
|
||||
|
||||
-keepclassmembers class rx.internal.util.unsafe.*ArrayQueue*Field* {
|
||||
@ -73,6 +40,18 @@
|
||||
rx.internal.util.atomic.LinkedQueueNode consumerNode;
|
||||
}
|
||||
|
||||
# Retrofit 2.X
|
||||
## https://square.github.io/retrofit/ ##
|
||||
|
||||
-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.internal.widget.** { *; }
|
||||
@ -82,13 +61,6 @@
|
||||
public <init>(android.content.Context);
|
||||
}
|
||||
|
||||
# Icepick
|
||||
-dontwarn icepick.**
|
||||
-keep class **$$Icepick { *; }
|
||||
-keepclasseswithmembernames class * {
|
||||
@icepick.* <fields>;
|
||||
}
|
||||
|
||||
## GSON 2.2.4 specific rules ##
|
||||
|
||||
# Gson uses generic type information stored in a class file when working with fields. Proguard
|
||||
@ -146,4 +118,12 @@
|
||||
|
||||
# Keep the support library
|
||||
-keep class org.acra.** { *; }
|
||||
-keep interface org.acra.** { *; }
|
||||
-keep interface org.acra.** { *; }
|
||||
|
||||
# SnakeYaml
|
||||
-keep class org.yaml.snakeyaml.** { public protected private *; }
|
||||
-keep class org.yaml.snakeyaml.** { public protected private *; }
|
||||
-dontwarn org.yaml.snakeyaml.**
|
||||
|
||||
# Duktape
|
||||
-keep class com.squareup.duktape.** { *; }
|
@ -1,9 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="eu.kanade.tachiyomi" >
|
||||
package="eu.kanade.tachiyomi">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
@ -11,12 +12,14 @@
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:hardwareAccelerated="true"
|
||||
android:theme="@style/AppTheme" >
|
||||
android:largeHeap="true"
|
||||
android:theme="@style/Theme.Tachiyomi" >
|
||||
<activity
|
||||
android:name=".ui.main.MainActivity">
|
||||
android:name=".ui.main.MainActivity"
|
||||
android:theme="@style/Theme.BrandedLaunch">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@ -29,10 +32,7 @@
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.reader.ReaderActivity"
|
||||
android:parentActivityName=".ui.manga.MangaActivity">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value=".ui.manga.MangaActivity" />
|
||||
android:theme="@style/Theme.Reader">
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.setting.SettingsActivity"
|
||||
@ -40,28 +40,27 @@
|
||||
android:parentActivityName=".ui.main.MainActivity" >
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.library.category.CategoryActivity"
|
||||
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:theme="@style/FilePickerTheme">
|
||||
</activity>
|
||||
|
||||
<service android:name=".data.sync.LibraryUpdateService"
|
||||
<service android:name=".data.library.LibraryUpdateService"
|
||||
android:exported="false"/>
|
||||
|
||||
<service android:name=".data.download.DownloadService"
|
||||
android:exported="false"/>
|
||||
|
||||
<service android:name=".data.sync.UpdateMangaSyncService"
|
||||
<service android:name=".data.mangasync.UpdateMangaSyncService"
|
||||
android:exported="false"/>
|
||||
|
||||
<receiver
|
||||
android:name=".data.sync.LibraryUpdateService$SyncOnConnectionAvailable"
|
||||
android:name=".data.library.LibraryUpdateService$SyncOnConnectionAvailable"
|
||||
android:enabled="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.conn.CONNECTIVITY_CHANGE" />
|
||||
@ -69,15 +68,40 @@
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".data.sync.LibraryUpdateAlarm">
|
||||
android:name=".data.library.LibraryUpdateService$SyncOnPowerConnected"
|
||||
android:enabled="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.ACTION_POWER_CONNECTED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".data.library.LibraryUpdateService$CancelUpdateReceiver">
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".data.updater.UpdateDownloader$InstallOnReceived">
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".data.library.LibraryUpdateAlarm">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||
<action android:name="eu.kanade.UPDATE_LIBRARY" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".data.updater.UpdateDownloaderAlarm">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||
<action android:name="eu.kanade.CHECK_UPDATE"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
|
||||
<meta-data
|
||||
android:name="eu.kanade.tachiyomi.data.cache.CoverGlideModule"
|
||||
android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule"
|
||||
android:value="GlideModule" />
|
||||
|
||||
</application>
|
||||
|
@ -1,57 +0,0 @@
|
||||
package eu.kanade.tachiyomi;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
|
||||
import org.acra.ACRA;
|
||||
import org.acra.annotation.ReportsCrashes;
|
||||
|
||||
import eu.kanade.tachiyomi.injection.ComponentReflectionInjector;
|
||||
import eu.kanade.tachiyomi.injection.component.AppComponent;
|
||||
import eu.kanade.tachiyomi.injection.component.DaggerAppComponent;
|
||||
import eu.kanade.tachiyomi.injection.module.AppModule;
|
||||
import timber.log.Timber;
|
||||
|
||||
@ReportsCrashes(
|
||||
formUri = "http://tachiyomi.kanade.eu/crash_report",
|
||||
reportType = org.acra.sender.HttpSender.Type.JSON,
|
||||
httpMethod = org.acra.sender.HttpSender.Method.PUT,
|
||||
excludeMatchingSharedPreferencesKeys={".*username.*",".*password.*"}
|
||||
)
|
||||
public class App extends Application {
|
||||
|
||||
AppComponent applicationComponent;
|
||||
ComponentReflectionInjector<AppComponent> componentInjector;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
if (BuildConfig.DEBUG) Timber.plant(new Timber.DebugTree());
|
||||
|
||||
applicationComponent = DaggerAppComponent.builder()
|
||||
.appModule(new AppModule(this))
|
||||
.build();
|
||||
|
||||
componentInjector =
|
||||
new ComponentReflectionInjector<>(AppComponent.class, applicationComponent);
|
||||
|
||||
ACRA.init(this);
|
||||
}
|
||||
|
||||
public static App get(Context context) {
|
||||
return (App) context.getApplicationContext();
|
||||
}
|
||||
|
||||
public AppComponent getComponent() {
|
||||
return applicationComponent;
|
||||
}
|
||||
|
||||
public ComponentReflectionInjector<AppComponent> getComponentReflection() {
|
||||
return componentInjector;
|
||||
}
|
||||
|
||||
// Needed to replace the component with a test specific one
|
||||
public void setComponent(AppComponent applicationComponent) {
|
||||
this.applicationComponent = applicationComponent;
|
||||
}
|
||||
}
|
62
app/src/main/java/eu/kanade/tachiyomi/App.kt
Normal file
62
app/src/main/java/eu/kanade/tachiyomi/App.kt
Normal file
@ -0,0 +1,62 @@
|
||||
package eu.kanade.tachiyomi
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.injection.ComponentReflectionInjector
|
||||
import eu.kanade.tachiyomi.injection.component.AppComponent
|
||||
import eu.kanade.tachiyomi.injection.component.DaggerAppComponent
|
||||
import eu.kanade.tachiyomi.injection.module.AppModule
|
||||
import org.acra.ACRA
|
||||
import org.acra.annotation.ReportsCrashes
|
||||
import timber.log.Timber
|
||||
|
||||
@ReportsCrashes(
|
||||
formUri = "http://tachiyomi.kanade.eu/crash_report",
|
||||
reportType = org.acra.sender.HttpSender.Type.JSON,
|
||||
httpMethod = org.acra.sender.HttpSender.Method.PUT,
|
||||
buildConfigClass = BuildConfig::class,
|
||||
excludeMatchingSharedPreferencesKeys = arrayOf(".*username.*", ".*password.*")
|
||||
)
|
||||
open class App : Application() {
|
||||
|
||||
lateinit var component: AppComponent
|
||||
private set
|
||||
|
||||
lateinit var componentReflection: ComponentReflectionInjector<AppComponent>
|
||||
private set
|
||||
|
||||
var appTheme = 0
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
|
||||
|
||||
component = prepareAppComponent().build()
|
||||
|
||||
componentReflection = ComponentReflectionInjector(AppComponent::class.java, component)
|
||||
|
||||
setupTheme()
|
||||
setupAcra()
|
||||
}
|
||||
|
||||
private fun setupTheme() {
|
||||
appTheme = PreferencesHelper.getTheme(this)
|
||||
}
|
||||
|
||||
protected open fun prepareAppComponent(): DaggerAppComponent.Builder {
|
||||
return DaggerAppComponent.builder()
|
||||
.appModule(AppModule(this))
|
||||
}
|
||||
|
||||
protected open fun setupAcra() {
|
||||
ACRA.init(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun get(context: Context): App {
|
||||
return context.applicationContext as App
|
||||
}
|
||||
}
|
||||
}
|
8
app/src/main/java/eu/kanade/tachiyomi/Constants.kt
Normal file
8
app/src/main/java/eu/kanade/tachiyomi/Constants.kt
Normal file
@ -0,0 +1,8 @@
|
||||
package eu.kanade.tachiyomi
|
||||
|
||||
object Constants {
|
||||
const val NOTIFICATION_LIBRARY_ID = 1
|
||||
const val NOTIFICATION_UPDATER_ID = 2
|
||||
const val NOTIFICATION_DOWNLOAD_CHAPTER_ID = 3
|
||||
const val NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID = 4
|
||||
}
|
@ -0,0 +1,381 @@
|
||||
package eu.kanade.tachiyomi.data.backup
|
||||
|
||||
import com.google.gson.*
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.google.gson.stream.JsonReader
|
||||
import eu.kanade.tachiyomi.data.backup.serializer.IdExclusion
|
||||
import eu.kanade.tachiyomi.data.backup.serializer.IntegerSerializer
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.*
|
||||
import java.io.*
|
||||
import java.lang.reflect.Type
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param file the file where the backup will be saved.
|
||||
* @throws IOException if there's any IO error.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun backupToFile(file: File) {
|
||||
val root = backupToJson()
|
||||
|
||||
FileWriter(file).use {
|
||||
gson.toJson(root, it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a JSON object containing the backup of the app's data.
|
||||
*
|
||||
* @return the backup as a JSON object.
|
||||
*/
|
||||
fun backupToJson(): JsonObject {
|
||||
val root = JsonObject()
|
||||
|
||||
// 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...).
|
||||
*
|
||||
* @param manga the manga to backup.
|
||||
* @return a JSON object containing all the data of the manga.
|
||||
*/
|
||||
private fun backupManga(manga: Manga): JsonObject {
|
||||
// Entry for this manga
|
||||
val entry = JsonObject()
|
||||
|
||||
// Backup manga fields
|
||||
entry.add(MANGA, gson.toJsonTree(manga))
|
||||
|
||||
// Backup all the chapters
|
||||
val chapters = db.getChapters(manga).executeAsBlocking()
|
||||
if (!chapters.isEmpty()) {
|
||||
entry.add(CHAPTERS, gson.toJsonTree(chapters))
|
||||
}
|
||||
|
||||
// Backup manga sync
|
||||
val mangaSync = db.getMangasSync(manga).executeAsBlocking()
|
||||
if (!mangaSync.isEmpty()) {
|
||||
entry.add(MANGA_SYNC, gson.toJsonTree(mangaSync))
|
||||
}
|
||||
|
||||
// Backup categories for this manga
|
||||
val categoriesForManga = db.getCategoriesForManga(manga).executeAsBlocking()
|
||||
if (!categoriesForManga.isEmpty()) {
|
||||
val categoriesNames = ArrayList<String>()
|
||||
for (category in categoriesForManga) {
|
||||
categoriesNames.add(category.name)
|
||||
}
|
||||
entry.add(CATEGORIES, gson.toJsonTree(categoriesNames))
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
* Backups a category.
|
||||
*
|
||||
* @param category the category to backup.
|
||||
* @return a JSON object containing the data of the category.
|
||||
*/
|
||||
private fun backupCategory(category: Category): JsonElement {
|
||||
return gson.toJsonTree(category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a backup from a file.
|
||||
*
|
||||
* @param file the file containing the backup.
|
||||
* @throws IOException if there's any IO error.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun restoreFromFile(file: File) {
|
||||
JsonReader(FileReader(file)).use {
|
||||
val root = JsonParser().parse(it).asJsonObject
|
||||
restoreFromJson(root)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a backup from an input stream.
|
||||
*
|
||||
* @param stream the stream containing the backup.
|
||||
* @throws IOException if there's any IO error.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun restoreFromStream(stream: InputStream) {
|
||||
JsonReader(InputStreamReader(stream)).use {
|
||||
val root = JsonParser().parse(it).asJsonObject
|
||||
restoreFromJson(root)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a backup from a JSON object. Everything executes in a single transaction so that
|
||||
* nothing is modified if there's an error.
|
||||
*
|
||||
* @param root the root of the JSON.
|
||||
*/
|
||||
fun restoreFromJson(root: JsonObject) {
|
||||
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
|
||||
val dbCategories = db.getCategories().executeAsBlocking()
|
||||
val backupCategories = getArrayOrEmpty<Category>(jsonCategories,
|
||||
object : TypeToken<List<Category>>() {}.type)
|
||||
|
||||
// Iterate over them
|
||||
for (category in backupCategories) {
|
||||
// Used to know if the category is already in the db
|
||||
var found = false
|
||||
for (dbCategory in dbCategories) {
|
||||
// If the category is already in the db, assign the id to the file's category
|
||||
// and do nothing
|
||||
if (category.nameLower == dbCategory.nameLower) {
|
||||
category.id = dbCategory.id
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// If the category isn't in the db, remove the id and insert a new category
|
||||
// Store the inserted id in the category
|
||||
if (!found) {
|
||||
// Let the db assign the id
|
||||
category.id = null
|
||||
val result = db.insertCategory(category).executeAsBlocking()
|
||||
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) {
|
||||
val chapterToken = object : TypeToken<List<Chapter>>() {}.type
|
||||
val mangaSyncToken = object : TypeToken<List<MangaSync>>() {}.type
|
||||
val categoriesNamesToken = object : TypeToken<List<String>>() {}.type
|
||||
|
||||
for (backupManga in jsonMangas) {
|
||||
// Map every entry to objects
|
||||
val element = backupManga.asJsonObject
|
||||
val manga = gson.fromJson(element.get(MANGA), Manga::class.java)
|
||||
val chapters = getArrayOrEmpty<Chapter>(element.get(CHAPTERS), chapterToken)
|
||||
val sync = getArrayOrEmpty<MangaSync>(element.get(MANGA_SYNC), mangaSyncToken)
|
||||
val categories = getArrayOrEmpty<String>(element.get(CATEGORIES), categoriesNamesToken)
|
||||
|
||||
// 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.
|
||||
*
|
||||
* @param manga the manga whose categories have to be restored.
|
||||
* @param categories the categories to restore.
|
||||
*/
|
||||
private fun restoreCategoriesForManga(manga: Manga, categories: List<String>) {
|
||||
val dbCategories = db.getCategories().executeAsBlocking()
|
||||
val mangaCategoriesToUpdate = ArrayList<MangaCategory>()
|
||||
for (backupCategoryStr in categories) {
|
||||
for (dbCategory in dbCategories) {
|
||||
if (backupCategoryStr.toLowerCase() == dbCategory.nameLower) {
|
||||
mangaCategoriesToUpdate.add(MangaCategory.create(manga, dbCategory))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update database
|
||||
if (!mangaCategoriesToUpdate.isEmpty()) {
|
||||
val mangaAsList = ArrayList<Manga>()
|
||||
mangaAsList.add(manga)
|
||||
db.deleteOldMangasCategories(mangaAsList).executeAsBlocking()
|
||||
db.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the sync of a manga.
|
||||
*
|
||||
* @param manga the manga whose sync have to be restored.
|
||||
* @param sync the sync to restore.
|
||||
*/
|
||||
private fun restoreSyncForManga(manga: Manga, sync: List<MangaSync>) {
|
||||
// Fix foreign keys with the current manga id
|
||||
for (mangaSync in sync) {
|
||||
mangaSync.manga_id = manga.id
|
||||
}
|
||||
|
||||
val dbSyncs = db.getMangasSync(manga).executeAsBlocking()
|
||||
val syncToUpdate = ArrayList<MangaSync>()
|
||||
for (backupSync in sync) {
|
||||
// Try to find existing chapter in db
|
||||
val pos = dbSyncs.indexOf(backupSync)
|
||||
if (pos != -1) {
|
||||
// The sync is already in the db, only update its fields
|
||||
val dbSync = dbSyncs[pos]
|
||||
// Mark the max chapter as read and nothing else
|
||||
dbSync.last_chapter_read = Math.max(backupSync.last_chapter_read, dbSync.last_chapter_read)
|
||||
syncToUpdate.add(dbSync)
|
||||
} else {
|
||||
// Insert new sync. Let the db assign the id
|
||||
backupSync.id = null
|
||||
syncToUpdate.add(backupSync)
|
||||
}
|
||||
}
|
||||
|
||||
// Update database
|
||||
if (!syncToUpdate.isEmpty()) {
|
||||
db.insertMangasSync(syncToUpdate).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of items from a json element, or an empty list if the element is null.
|
||||
*
|
||||
* @param element the json to be mapped to a list of items.
|
||||
* @param type the gson mapping to restore the list.
|
||||
* @return a list of items.
|
||||
*/
|
||||
private fun <T> getArrayOrEmpty(element: JsonElement?, type: Type): List<T> {
|
||||
return gson.fromJson<List<T>>(element, type) ?: ArrayList<T>()
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
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
|
||||
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
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
|
||||
}
|
||||
}
|
@ -1,197 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.cache;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.format.Formatter;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.jakewharton.disklrucache.DiskLruCache;
|
||||
import com.squareup.okhttp.Response;
|
||||
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.List;
|
||||
|
||||
import eu.kanade.tachiyomi.data.source.model.Page;
|
||||
import eu.kanade.tachiyomi.util.DiskUtils;
|
||||
import okio.BufferedSink;
|
||||
import okio.Okio;
|
||||
import rx.Observable;
|
||||
|
||||
public class ChapterCache {
|
||||
|
||||
private static final String PARAMETER_CACHE_DIRECTORY = "chapter_disk_cache";
|
||||
private static final int PARAMETER_APP_VERSION = 1;
|
||||
private static final int PARAMETER_VALUE_COUNT = 1;
|
||||
private static final int PARAMETER_CACHE_SIZE = 75 * 1024 * 1024;
|
||||
|
||||
private Context context;
|
||||
private Gson gson;
|
||||
|
||||
private DiskLruCache diskCache;
|
||||
|
||||
public ChapterCache(Context context) {
|
||||
this.context = context;
|
||||
gson = new Gson();
|
||||
|
||||
try {
|
||||
diskCache = DiskLruCache.open(
|
||||
new File(context.getCacheDir(), PARAMETER_CACHE_DIRECTORY),
|
||||
PARAMETER_APP_VERSION,
|
||||
PARAMETER_VALUE_COUNT,
|
||||
PARAMETER_CACHE_SIZE
|
||||
);
|
||||
} catch (IOException e) {
|
||||
// Do Nothing.
|
||||
}
|
||||
}
|
||||
|
||||
public boolean remove(String file) {
|
||||
if (file.equals("journal") || file.startsWith("journal."))
|
||||
return false;
|
||||
|
||||
try {
|
||||
String key = file.substring(0, file.lastIndexOf("."));
|
||||
return diskCache.remove(key);
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public File getCacheDir() {
|
||||
return diskCache.getDirectory();
|
||||
}
|
||||
|
||||
public long getRealSize() {
|
||||
return DiskUtils.getDirectorySize(getCacheDir());
|
||||
}
|
||||
|
||||
public String getReadableSize() {
|
||||
return Formatter.formatFileSize(context, getRealSize());
|
||||
}
|
||||
|
||||
public void setSize(int value) {
|
||||
diskCache.setMaxSize(value * 1024 * 1024);
|
||||
}
|
||||
|
||||
public Observable<List<Page>> getPageUrlsFromDiskCache(final String chapterUrl) {
|
||||
return Observable.create(subscriber -> {
|
||||
try {
|
||||
List<Page> pages = getPageUrlsFromDiskCacheImpl(chapterUrl);
|
||||
subscriber.onNext(pages);
|
||||
subscriber.onCompleted();
|
||||
} catch (Throwable e) {
|
||||
subscriber.onError(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private List<Page> getPageUrlsFromDiskCacheImpl(String chapterUrl) throws IOException {
|
||||
DiskLruCache.Snapshot snapshot = null;
|
||||
List<Page> pages = null;
|
||||
|
||||
try {
|
||||
String key = DiskUtils.hashKeyForDisk(chapterUrl);
|
||||
snapshot = diskCache.get(key);
|
||||
|
||||
Type collectionType = new TypeToken<List<Page>>() {}.getType();
|
||||
pages = gson.fromJson(snapshot.getString(0), collectionType);
|
||||
} catch (IOException e) {
|
||||
// Do Nothing.
|
||||
} finally {
|
||||
if (snapshot != null) {
|
||||
snapshot.close();
|
||||
}
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
public void putPageUrlsToDiskCache(final String chapterUrl, final List<Page> pages) {
|
||||
String cachedValue = gson.toJson(pages);
|
||||
|
||||
DiskLruCache.Editor editor = null;
|
||||
OutputStream outputStream = null;
|
||||
try {
|
||||
String key = DiskUtils.hashKeyForDisk(chapterUrl);
|
||||
editor = diskCache.edit(key);
|
||||
if (editor == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
outputStream = new BufferedOutputStream(editor.newOutputStream(0));
|
||||
outputStream.write(cachedValue.getBytes());
|
||||
outputStream.flush();
|
||||
|
||||
diskCache.flush();
|
||||
editor.commit();
|
||||
} catch (Exception e) {
|
||||
// Do Nothing.
|
||||
} finally {
|
||||
if (editor != null) {
|
||||
editor.abortUnlessCommitted();
|
||||
}
|
||||
if (outputStream != null) {
|
||||
try {
|
||||
outputStream.close();
|
||||
} catch (IOException ignore) {
|
||||
// Do Nothing.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isImageInCache(final String imageUrl) {
|
||||
try {
|
||||
return diskCache.get(DiskUtils.hashKeyForDisk(imageUrl)) != null;
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public String getImagePath(final String imageUrl) {
|
||||
try {
|
||||
String imageName = DiskUtils.hashKeyForDisk(imageUrl) + ".0";
|
||||
File file = new File(diskCache.getDirectory(), imageName);
|
||||
return file.getCanonicalPath();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void putImageToDiskCache(final String imageUrl, final Response response) throws IOException {
|
||||
DiskLruCache.Editor editor = null;
|
||||
BufferedSink sink = null;
|
||||
|
||||
try {
|
||||
String key = DiskUtils.hashKeyForDisk(imageUrl);
|
||||
editor = diskCache.edit(key);
|
||||
if (editor == null) {
|
||||
throw new IOException("Unable to edit key");
|
||||
}
|
||||
|
||||
OutputStream outputStream = new BufferedOutputStream(editor.newOutputStream(0));
|
||||
sink = Okio.buffer(Okio.sink(outputStream));
|
||||
sink.writeAll(response.body().source());
|
||||
|
||||
diskCache.flush();
|
||||
editor.commit();
|
||||
} catch (Exception e) {
|
||||
throw new IOException("Unable to save image");
|
||||
} finally {
|
||||
if (editor != null) {
|
||||
editor.abortUnlessCommitted();
|
||||
}
|
||||
if (sink != null) {
|
||||
sink.close();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
209
app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt
vendored
Normal file
209
app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt
vendored
Normal file
@ -0,0 +1,209 @@
|
||||
package eu.kanade.tachiyomi.data.cache
|
||||
|
||||
import android.content.Context
|
||||
import android.text.format.Formatter
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.jakewharton.disklrucache.DiskLruCache
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import eu.kanade.tachiyomi.util.DiskUtils
|
||||
import eu.kanade.tachiyomi.util.saveImageTo
|
||||
import okhttp3.Response
|
||||
import okio.Okio
|
||||
import rx.Observable
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.lang.reflect.Type
|
||||
|
||||
/**
|
||||
* Class used to create chapter cache
|
||||
* For each image in a chapter a file is created
|
||||
* For each chapter a Json list is created and converted to a file.
|
||||
* The files are in format *md5key*.0
|
||||
*
|
||||
* @param context the application context.
|
||||
* @constructor creates an instance of the chapter cache.
|
||||
*/
|
||||
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 {
|
||||
/** Name of cache directory. */
|
||||
const val PARAMETER_CACHE_DIRECTORY = "chapter_disk_cache"
|
||||
|
||||
/** Application cache version. */
|
||||
const val PARAMETER_APP_VERSION = 1
|
||||
|
||||
/** The number of values per cache entry. Must be positive. */
|
||||
const val PARAMETER_VALUE_COUNT = 1
|
||||
|
||||
/** The maximum number of bytes this cache should use to store. */
|
||||
const val PARAMETER_CACHE_SIZE = 75L * 1024 * 1024
|
||||
}
|
||||
|
||||
init {
|
||||
// Open cache in default cache directory.
|
||||
diskCache = DiskLruCache.open(
|
||||
File(context.cacheDir, PARAMETER_CACHE_DIRECTORY),
|
||||
PARAMETER_APP_VERSION,
|
||||
PARAMETER_VALUE_COUNT,
|
||||
PARAMETER_CACHE_SIZE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns directory of cache.
|
||||
* @return directory of cache.
|
||||
*/
|
||||
val cacheDir: File
|
||||
get() = diskCache.directory
|
||||
|
||||
/**
|
||||
* Returns real size of directory.
|
||||
* @return real size of directory.
|
||||
*/
|
||||
private val realSize: Long
|
||||
get() = DiskUtils.getDirectorySize(cacheDir)
|
||||
|
||||
/**
|
||||
* Returns real size of directory in human readable format.
|
||||
* @return real size of directory.
|
||||
*/
|
||||
val readableSize: String
|
||||
get() = Formatter.formatFileSize(context, realSize)
|
||||
|
||||
/**
|
||||
* Remove file from cache.
|
||||
* @param file name of file "md5.0".
|
||||
* @return status of deletion for the file.
|
||||
*/
|
||||
fun removeFileFromCache(file: String): Boolean {
|
||||
// Make sure we don't delete the journal file (keeps track of cache).
|
||||
if (file == "journal" || file.startsWith("journal."))
|
||||
return false
|
||||
|
||||
try {
|
||||
// Remove the extension from the file to get the key of the cache
|
||||
val key = file.substring(0, file.lastIndexOf("."))
|
||||
// Remove file from cache.
|
||||
return diskCache.remove(key)
|
||||
} catch (e: IOException) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get page list from cache.
|
||||
* @param chapterUrl the url of the chapter.
|
||||
* @return an observable of the list of pages.
|
||||
*/
|
||||
fun getPageListFromCache(chapterUrl: String): Observable<List<Page>> {
|
||||
return Observable.fromCallable<List<Page>> {
|
||||
// Get the key for the chapter.
|
||||
val key = DiskUtils.hashKeyForDisk(chapterUrl)
|
||||
|
||||
// Convert JSON string to list of objects. Throws an exception if snapshot is null
|
||||
diskCache.get(key).use {
|
||||
gson.fromJson(it.getString(0), pageListCollection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add page list to disk cache.
|
||||
* @param chapterUrl the url of the chapter.
|
||||
* @param pages list of pages.
|
||||
*/
|
||||
fun putPageListToCache(chapterUrl: String, pages: List<Page>) {
|
||||
// Convert list of pages to json string.
|
||||
val cachedValue = gson.toJson(pages)
|
||||
|
||||
// Initialize the editor (edits the values for an entry).
|
||||
var editor: DiskLruCache.Editor? = null
|
||||
|
||||
try {
|
||||
// Get editor from md5 key.
|
||||
val key = DiskUtils.hashKeyForDisk(chapterUrl)
|
||||
editor = diskCache.edit(key) ?: return
|
||||
|
||||
// Write chapter urls to cache.
|
||||
Okio.buffer(Okio.sink(editor.newOutputStream(0))).use {
|
||||
it.write(cachedValue.toByteArray())
|
||||
it.flush()
|
||||
}
|
||||
|
||||
diskCache.flush()
|
||||
editor.commit()
|
||||
editor.abortUnlessCommitted()
|
||||
|
||||
} catch (e: Exception) {
|
||||
// Ignore.
|
||||
} finally {
|
||||
editor?.abortUnlessCommitted()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if image is in cache.
|
||||
* @param imageUrl url of image.
|
||||
* @return true if in cache otherwise false.
|
||||
*/
|
||||
fun isImageInCache(imageUrl: String): Boolean {
|
||||
try {
|
||||
return diskCache.get(DiskUtils.hashKeyForDisk(imageUrl)) != null
|
||||
} catch (e: IOException) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image path from url.
|
||||
* @param imageUrl url of image.
|
||||
* @return path of image.
|
||||
*/
|
||||
fun getImagePath(imageUrl: String): String? {
|
||||
try {
|
||||
// Get file from md5 key.
|
||||
val imageName = DiskUtils.hashKeyForDisk(imageUrl) + ".0"
|
||||
return File(diskCache.directory, imageName).canonicalPath
|
||||
} catch (e: IOException) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add image to cache.
|
||||
* @param imageUrl url of image.
|
||||
* @param response http response from page.
|
||||
* @throws IOException image error.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun putImageToCache(imageUrl: String, response: Response, reencode: Boolean) {
|
||||
// Initialize editor (edits the values for an entry).
|
||||
var editor: DiskLruCache.Editor? = null
|
||||
|
||||
try {
|
||||
// Get editor from md5 key.
|
||||
val key = DiskUtils.hashKeyForDisk(imageUrl)
|
||||
editor = diskCache.edit(key) ?: throw IOException("Unable to edit key")
|
||||
|
||||
// Get OutputStream and write image with Okio.
|
||||
response.body().source().saveImageTo(editor.newOutputStream(0), reencode)
|
||||
|
||||
diskCache.flush()
|
||||
editor.commit()
|
||||
} finally {
|
||||
response.body().close()
|
||||
editor?.abortUnlessCommitted()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,148 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.cache;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.load.model.GlideUrl;
|
||||
import com.bumptech.glide.load.model.LazyHeaders;
|
||||
import com.bumptech.glide.request.animation.GlideAnimation;
|
||||
import com.bumptech.glide.request.target.SimpleTarget;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
import eu.kanade.tachiyomi.util.DiskUtils;
|
||||
|
||||
public class CoverCache {
|
||||
|
||||
private static final String PARAMETER_CACHE_DIRECTORY = "cover_disk_cache";
|
||||
|
||||
private Context context;
|
||||
private File cacheDir;
|
||||
|
||||
public CoverCache(Context context) {
|
||||
this.context = context;
|
||||
cacheDir = new File(context.getCacheDir(), PARAMETER_CACHE_DIRECTORY);
|
||||
createCacheDir();
|
||||
}
|
||||
|
||||
private boolean createCacheDir() {
|
||||
return !cacheDir.exists() && cacheDir.mkdirs();
|
||||
}
|
||||
|
||||
public void save(String thumbnailUrl, LazyHeaders headers) {
|
||||
save(thumbnailUrl, headers, null);
|
||||
}
|
||||
|
||||
// Download the cover with Glide (it can avoid repeating requests) and save the file on this cache
|
||||
// Optionally, load the image in the given image view when the resource is ready, if not null
|
||||
public void save(String thumbnailUrl, LazyHeaders headers, ImageView imageView) {
|
||||
if (TextUtils.isEmpty(thumbnailUrl))
|
||||
return;
|
||||
|
||||
GlideUrl url = new GlideUrl(thumbnailUrl, headers);
|
||||
Glide.with(context)
|
||||
.load(url)
|
||||
.downloadOnly(new SimpleTarget<File>() {
|
||||
@Override
|
||||
public void onResourceReady(File resource, GlideAnimation<? super File> anim) {
|
||||
try {
|
||||
add(thumbnailUrl, resource);
|
||||
if (imageView != null) {
|
||||
loadFromCache(imageView, resource);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Copy the cover from Glide's cache to this cache
|
||||
public void add(String thumbnailUrl, File source) throws IOException {
|
||||
createCacheDir();
|
||||
File dest = new File(cacheDir, DiskUtils.hashKeyForDisk(thumbnailUrl));
|
||||
if (dest.exists())
|
||||
dest.delete();
|
||||
|
||||
InputStream in = new FileInputStream(source);
|
||||
try {
|
||||
OutputStream out = new FileOutputStream(dest);
|
||||
try {
|
||||
// Transfer bytes from in to out
|
||||
byte[] buf = new byte[1024];
|
||||
int len;
|
||||
while ((len = in.read(buf)) > 0) {
|
||||
out.write(buf, 0, len);
|
||||
}
|
||||
} finally {
|
||||
out.close();
|
||||
}
|
||||
} finally {
|
||||
in.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Get the cover from cache
|
||||
public File get(String thumbnailUrl) {
|
||||
return new File(cacheDir, DiskUtils.hashKeyForDisk(thumbnailUrl));
|
||||
}
|
||||
|
||||
// Delete the cover from cache
|
||||
public boolean delete(String thumbnailUrl) {
|
||||
if (TextUtils.isEmpty(thumbnailUrl))
|
||||
return false;
|
||||
|
||||
File file = new File(cacheDir, DiskUtils.hashKeyForDisk(thumbnailUrl));
|
||||
return file.exists() && file.delete();
|
||||
}
|
||||
|
||||
// Save and load the image from cache
|
||||
public void saveAndLoadFromCache(ImageView imageView, String thumbnailUrl, LazyHeaders headers) {
|
||||
File localCover = get(thumbnailUrl);
|
||||
if (localCover.exists()) {
|
||||
loadFromCache(imageView, localCover);
|
||||
} else {
|
||||
save(thumbnailUrl, headers, imageView);
|
||||
}
|
||||
}
|
||||
|
||||
// If the image is already in our cache, use it. If not, load it with glide
|
||||
public void loadFromCacheOrNetwork(ImageView imageView, String thumbnailUrl, LazyHeaders headers) {
|
||||
File localCover = get(thumbnailUrl);
|
||||
if (localCover.exists()) {
|
||||
loadFromCache(imageView, localCover);
|
||||
} else {
|
||||
loadFromNetwork(imageView, thumbnailUrl, headers);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to load the cover from the cache directory into the specified image view
|
||||
// The file must exist
|
||||
private void loadFromCache(ImageView imageView, File file) {
|
||||
Glide.with(context)
|
||||
.load(file)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.centerCrop()
|
||||
.into(imageView);
|
||||
}
|
||||
|
||||
// Helper method to load the cover from network into the specified image view.
|
||||
// It does NOT save the image in cache
|
||||
public void loadFromNetwork(ImageView imageView, String thumbnailUrl, LazyHeaders headers) {
|
||||
GlideUrl url = new GlideUrl(thumbnailUrl, headers);
|
||||
Glide.with(context)
|
||||
.load(url)
|
||||
.diskCacheStrategy(DiskCacheStrategy.SOURCE)
|
||||
.centerCrop()
|
||||
.into(imageView);
|
||||
}
|
||||
|
||||
}
|
66
app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt
vendored
Normal file
66
app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt
vendored
Normal file
@ -0,0 +1,66 @@
|
||||
package eu.kanade.tachiyomi.data.cache
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.util.DiskUtils
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Class used to create cover cache.
|
||||
* It is used to store the covers of the library.
|
||||
* Makes use of Glide (which can avoid repeating requests) to download covers.
|
||||
* Names of files are created with the md5 of the thumbnail URL.
|
||||
*
|
||||
* @param context the application context.
|
||||
* @constructor creates an instance of the cover cache.
|
||||
*/
|
||||
class CoverCache(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Cache directory used for cache management.
|
||||
*/
|
||||
private val cacheDir: File = File(context.externalCacheDir, "cover_disk_cache")
|
||||
|
||||
/**
|
||||
* Returns the cover from cache.
|
||||
*
|
||||
* @param thumbnailUrl the thumbnail url.
|
||||
* @return cover image.
|
||||
*/
|
||||
fun getCoverFile(thumbnailUrl: String): File {
|
||||
return File(cacheDir, DiskUtils.hashKeyForDisk(thumbnailUrl))
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the given stream to this cache.
|
||||
*
|
||||
* @param thumbnailUrl url of the thumbnail.
|
||||
* @param inputStream the stream to copy.
|
||||
* @throws IOException if there's any error.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun copyToCache(thumbnailUrl: String, inputStream: InputStream) {
|
||||
// Get destination file.
|
||||
val destFile = getCoverFile(thumbnailUrl)
|
||||
|
||||
destFile.outputStream().use { inputStream.copyTo(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the cover file from the cache.
|
||||
*
|
||||
* @param thumbnailUrl the thumbnail url.
|
||||
* @return status of deletion.
|
||||
*/
|
||||
fun deleteFromCache(thumbnailUrl: String?): Boolean {
|
||||
// Check if url is empty.
|
||||
if (thumbnailUrl.isNullOrEmpty())
|
||||
return false
|
||||
|
||||
// Remove file.
|
||||
val file = getCoverFile(thumbnailUrl!!)
|
||||
return file.exists() && file.delete()
|
||||
}
|
||||
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.cache;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.GlideBuilder;
|
||||
import com.bumptech.glide.load.DecodeFormat;
|
||||
import com.bumptech.glide.module.GlideModule;
|
||||
|
||||
public class CoverGlideModule implements GlideModule {
|
||||
|
||||
@Override
|
||||
public void applyOptions(Context context, GlideBuilder builder) {
|
||||
builder.setDecodeFormat(DecodeFormat.PREFER_ARGB_8888);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerComponents(Context context, Glide glide) {
|
||||
|
||||
}
|
||||
}
|
@ -1,408 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.database;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Pair;
|
||||
|
||||
import com.pushtorefresh.storio.Queries;
|
||||
import com.pushtorefresh.storio.sqlite.StorIOSQLite;
|
||||
import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite;
|
||||
import com.pushtorefresh.storio.sqlite.operations.delete.PreparedDeleteByQuery;
|
||||
import com.pushtorefresh.storio.sqlite.operations.delete.PreparedDeleteCollectionOfObjects;
|
||||
import com.pushtorefresh.storio.sqlite.operations.delete.PreparedDeleteObject;
|
||||
import com.pushtorefresh.storio.sqlite.operations.get.PreparedGetListOfObjects;
|
||||
import com.pushtorefresh.storio.sqlite.operations.get.PreparedGetObject;
|
||||
import com.pushtorefresh.storio.sqlite.operations.put.PreparedPutCollectionOfObjects;
|
||||
import com.pushtorefresh.storio.sqlite.operations.put.PreparedPutObject;
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery;
|
||||
import com.pushtorefresh.storio.sqlite.queries.Query;
|
||||
import com.pushtorefresh.storio.sqlite.queries.RawQuery;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Category;
|
||||
import eu.kanade.tachiyomi.data.database.models.CategorySQLiteTypeMapping;
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter;
|
||||
import eu.kanade.tachiyomi.data.database.models.ChapterSQLiteTypeMapping;
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga;
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaCategory;
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaCategorySQLiteTypeMapping;
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaChapter;
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSQLiteTypeMapping;
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSync;
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSyncSQLiteTypeMapping;
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.LibraryMangaGetResolver;
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterGetResolver;
|
||||
import eu.kanade.tachiyomi.data.database.tables.CategoryTable;
|
||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable;
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable;
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable;
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable;
|
||||
import eu.kanade.tachiyomi.data.mangasync.base.MangaSyncService;
|
||||
import eu.kanade.tachiyomi.util.ChapterRecognition;
|
||||
import rx.Observable;
|
||||
|
||||
public class DatabaseHelper {
|
||||
|
||||
private StorIOSQLite db;
|
||||
|
||||
public DatabaseHelper(Context context) {
|
||||
|
||||
db = DefaultStorIOSQLite.builder()
|
||||
.sqliteOpenHelper(new DbOpenHelper(context))
|
||||
.addTypeMapping(Manga.class, new MangaSQLiteTypeMapping())
|
||||
.addTypeMapping(Chapter.class, new ChapterSQLiteTypeMapping())
|
||||
.addTypeMapping(MangaSync.class, new MangaSyncSQLiteTypeMapping())
|
||||
.addTypeMapping(Category.class, new CategorySQLiteTypeMapping())
|
||||
.addTypeMapping(MangaCategory.class, new MangaCategorySQLiteTypeMapping())
|
||||
.build();
|
||||
}
|
||||
|
||||
// Mangas related queries
|
||||
|
||||
public PreparedGetListOfObjects<Manga> getMangas() {
|
||||
return db.get()
|
||||
.listOfObjects(Manga.class)
|
||||
.withQuery(Query.builder()
|
||||
.table(MangaTable.TABLE)
|
||||
.build())
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedGetListOfObjects<Manga> getLibraryMangas() {
|
||||
return db.get()
|
||||
.listOfObjects(Manga.class)
|
||||
.withQuery(RawQuery.builder()
|
||||
.query(LibraryMangaGetResolver.QUERY)
|
||||
.observesTables(MangaTable.TABLE, ChapterTable.TABLE, MangaCategoryTable.TABLE)
|
||||
.build())
|
||||
.withGetResolver(LibraryMangaGetResolver.INSTANCE)
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedGetListOfObjects<Manga> getFavoriteMangas() {
|
||||
return db.get()
|
||||
.listOfObjects(Manga.class)
|
||||
.withQuery(Query.builder()
|
||||
.table(MangaTable.TABLE)
|
||||
.where(MangaTable.COLUMN_FAVORITE + "=?")
|
||||
.whereArgs(1)
|
||||
.orderBy(MangaTable.COLUMN_TITLE)
|
||||
.build())
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedGetObject<Manga> getManga(String url, int sourceId) {
|
||||
return db.get()
|
||||
.object(Manga.class)
|
||||
.withQuery(Query.builder()
|
||||
.table(MangaTable.TABLE)
|
||||
.where(MangaTable.COLUMN_URL + "=? AND " + MangaTable.COLUMN_SOURCE + "=?")
|
||||
.whereArgs(url, sourceId)
|
||||
.build())
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedGetObject<Manga> getManga(long id) {
|
||||
return db.get()
|
||||
.object(Manga.class)
|
||||
.withQuery(Query.builder()
|
||||
.table(MangaTable.TABLE)
|
||||
.where(MangaTable.COLUMN_ID + "=?")
|
||||
.whereArgs(id)
|
||||
.build())
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedPutObject<Manga> insertManga(Manga manga) {
|
||||
return db.put()
|
||||
.object(manga)
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedPutCollectionOfObjects<Manga> insertMangas(List<Manga> mangas) {
|
||||
return db.put()
|
||||
.objects(mangas)
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedDeleteObject<Manga> deleteManga(Manga manga) {
|
||||
return db.delete()
|
||||
.object(manga)
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedDeleteCollectionOfObjects<Manga> deleteMangas(List<Manga> mangas) {
|
||||
return db.delete()
|
||||
.objects(mangas)
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedDeleteByQuery deleteMangasNotInLibrary() {
|
||||
return db.delete()
|
||||
.byQuery(DeleteQuery.builder()
|
||||
.table(MangaTable.TABLE)
|
||||
.where(MangaTable.COLUMN_FAVORITE + "=?")
|
||||
.whereArgs(0)
|
||||
.build())
|
||||
.prepare();
|
||||
}
|
||||
|
||||
|
||||
// Chapters related queries
|
||||
|
||||
public PreparedGetListOfObjects<Chapter> getChapters(Manga manga) {
|
||||
return db.get()
|
||||
.listOfObjects(Chapter.class)
|
||||
.withQuery(Query.builder()
|
||||
.table(ChapterTable.TABLE)
|
||||
.where(ChapterTable.COLUMN_MANGA_ID + "=?")
|
||||
.whereArgs(manga.id)
|
||||
.build())
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedGetListOfObjects<MangaChapter> getRecentChapters() {
|
||||
return db.get()
|
||||
.listOfObjects(MangaChapter.class)
|
||||
.withQuery(RawQuery.builder()
|
||||
.query(MangaChapterGetResolver.RECENT_CHAPTERS_QUERY)
|
||||
.observesTables(ChapterTable.TABLE)
|
||||
.build())
|
||||
.withGetResolver(MangaChapterGetResolver.INSTANCE)
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedGetObject<Chapter> getNextChapter(Chapter chapter) {
|
||||
// Add a delta to the chapter number, because binary decimal representation
|
||||
// can retrieve the same chapter again
|
||||
double chapterNumber = chapter.chapter_number + 0.00001;
|
||||
|
||||
return db.get()
|
||||
.object(Chapter.class)
|
||||
.withQuery(Query.builder()
|
||||
.table(ChapterTable.TABLE)
|
||||
.where(ChapterTable.COLUMN_MANGA_ID + "=? AND " +
|
||||
ChapterTable.COLUMN_CHAPTER_NUMBER + ">? AND " +
|
||||
ChapterTable.COLUMN_CHAPTER_NUMBER + "<=?")
|
||||
.whereArgs(chapter.manga_id, chapterNumber, chapterNumber + 1)
|
||||
.orderBy(ChapterTable.COLUMN_CHAPTER_NUMBER)
|
||||
.limit(1)
|
||||
.build())
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedGetObject<Chapter> getPreviousChapter(Chapter chapter) {
|
||||
// Add a delta to the chapter number, because binary decimal representation
|
||||
// can retrieve the same chapter again
|
||||
double chapterNumber = chapter.chapter_number - 0.00001;
|
||||
|
||||
return db.get()
|
||||
.object(Chapter.class)
|
||||
.withQuery(Query.builder()
|
||||
.table(ChapterTable.TABLE)
|
||||
.where(ChapterTable.COLUMN_MANGA_ID + "=? AND " +
|
||||
ChapterTable.COLUMN_CHAPTER_NUMBER + "<? AND " +
|
||||
ChapterTable.COLUMN_CHAPTER_NUMBER + ">=?")
|
||||
.whereArgs(chapter.manga_id, chapterNumber, chapterNumber - 1)
|
||||
.orderBy(ChapterTable.COLUMN_CHAPTER_NUMBER + " DESC")
|
||||
.limit(1)
|
||||
.build())
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedGetObject<Chapter> getNextUnreadChapter(Manga manga) {
|
||||
return db.get()
|
||||
.object(Chapter.class)
|
||||
.withQuery(Query.builder()
|
||||
.table(ChapterTable.TABLE)
|
||||
.where(ChapterTable.COLUMN_MANGA_ID + "=? AND " +
|
||||
ChapterTable.COLUMN_READ + "=? AND " +
|
||||
ChapterTable.COLUMN_CHAPTER_NUMBER + ">=?")
|
||||
.whereArgs(manga.id, 0, 0)
|
||||
.orderBy(ChapterTable.COLUMN_CHAPTER_NUMBER)
|
||||
.limit(1)
|
||||
.build())
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedPutObject<Chapter> insertChapter(Chapter chapter) {
|
||||
return db.put()
|
||||
.object(chapter)
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedPutCollectionOfObjects<Chapter> insertChapters(List<Chapter> chapters) {
|
||||
return db.put()
|
||||
.objects(chapters)
|
||||
.prepare();
|
||||
}
|
||||
|
||||
// Add new chapters or delete if the source deletes them
|
||||
public Observable<Pair<Integer, Integer>> insertOrRemoveChapters(Manga manga, List<Chapter> sourceChapters) {
|
||||
List<Chapter> dbChapters = getChapters(manga).executeAsBlocking();
|
||||
|
||||
Observable<List<Chapter>> newChapters = Observable.from(sourceChapters)
|
||||
.filter(c -> !dbChapters.contains(c))
|
||||
.doOnNext(c -> {
|
||||
c.manga_id = manga.id;
|
||||
ChapterRecognition.parseChapterNumber(c, manga);
|
||||
})
|
||||
.toList();
|
||||
|
||||
Observable<List<Chapter>> deletedChapters = Observable.from(dbChapters)
|
||||
.filter(c -> !sourceChapters.contains(c))
|
||||
.toList();
|
||||
|
||||
return Observable.zip(newChapters, deletedChapters, (toAdd, toDelete) -> {
|
||||
int added = 0;
|
||||
int deleted = 0;
|
||||
db.internal().beginTransaction();
|
||||
try {
|
||||
if (!toAdd.isEmpty()) {
|
||||
// Set the date fetch for new items in reverse order to allow another sorting method.
|
||||
// Sources MUST return the chapters from most to less recent, which is common.
|
||||
long now = new Date().getTime();
|
||||
|
||||
for (int i = toAdd.size() - 1; i >= 0; i--) {
|
||||
toAdd.get(i).date_fetch = now++;
|
||||
}
|
||||
added = insertChapters(toAdd).executeAsBlocking().numberOfInserts();
|
||||
}
|
||||
|
||||
if (!toDelete.isEmpty()) {
|
||||
deleted = deleteChapters(toDelete).executeAsBlocking().results().size();
|
||||
}
|
||||
|
||||
db.internal().setTransactionSuccessful();
|
||||
} finally {
|
||||
db.internal().endTransaction();
|
||||
}
|
||||
return Pair.create(added, deleted);
|
||||
});
|
||||
}
|
||||
|
||||
public PreparedDeleteObject<Chapter> deleteChapter(Chapter chapter) {
|
||||
return db.delete()
|
||||
.object(chapter)
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedDeleteCollectionOfObjects<Chapter> deleteChapters(List<Chapter> chapters) {
|
||||
return db.delete()
|
||||
.objects(chapters)
|
||||
.prepare();
|
||||
}
|
||||
|
||||
// Manga sync related queries
|
||||
|
||||
public PreparedGetObject<MangaSync> getMangaSync(Manga manga, MangaSyncService sync) {
|
||||
return db.get()
|
||||
.object(MangaSync.class)
|
||||
.withQuery(Query.builder()
|
||||
.table(MangaSyncTable.TABLE)
|
||||
.where(MangaSyncTable.COLUMN_MANGA_ID + "=? AND " +
|
||||
MangaSyncTable.COLUMN_SYNC_ID + "=?")
|
||||
.whereArgs(manga.id, sync.getId())
|
||||
.build())
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedGetListOfObjects<MangaSync> getMangasSync(Manga manga) {
|
||||
return db.get()
|
||||
.listOfObjects(MangaSync.class)
|
||||
.withQuery(Query.builder()
|
||||
.table(MangaSyncTable.TABLE)
|
||||
.where(MangaSyncTable.COLUMN_MANGA_ID + "=?")
|
||||
.whereArgs(manga.id)
|
||||
.build())
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedPutObject<MangaSync> insertMangaSync(MangaSync manga) {
|
||||
return db.put()
|
||||
.object(manga)
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedDeleteObject<MangaSync> deleteMangaSync(MangaSync manga) {
|
||||
return db.delete()
|
||||
.object(manga)
|
||||
.prepare();
|
||||
}
|
||||
|
||||
// Categories related queries
|
||||
|
||||
public PreparedGetListOfObjects<Category> getCategories() {
|
||||
return db.get()
|
||||
.listOfObjects(Category.class)
|
||||
.withQuery(Query.builder()
|
||||
.table(CategoryTable.TABLE)
|
||||
.orderBy(CategoryTable.COLUMN_ORDER)
|
||||
.build())
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedPutObject<Category> insertCategory(Category category) {
|
||||
return db.put()
|
||||
.object(category)
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedPutCollectionOfObjects<Category> insertCategories(List<Category> categories) {
|
||||
return db.put()
|
||||
.objects(categories)
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedDeleteObject<Category> deleteCategory(Category category) {
|
||||
return db.delete()
|
||||
.object(category)
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedDeleteCollectionOfObjects<Category> deleteCategories(List<Category> categories) {
|
||||
return db.delete()
|
||||
.objects(categories)
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedPutObject<MangaCategory> insertMangaCategory(MangaCategory mangaCategory) {
|
||||
return db.put()
|
||||
.object(mangaCategory)
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedPutCollectionOfObjects<MangaCategory> insertMangasCategories(List<MangaCategory> mangasCategories) {
|
||||
return db.put()
|
||||
.objects(mangasCategories)
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public PreparedDeleteByQuery deleteOldMangasCategories(List<Manga> mangas) {
|
||||
List<Long> mangaIds = Observable.from(mangas)
|
||||
.map(manga -> manga.id)
|
||||
.toList().toBlocking().single();
|
||||
|
||||
return db.delete()
|
||||
.byQuery(DeleteQuery.builder()
|
||||
.table(MangaCategoryTable.TABLE)
|
||||
.where(MangaCategoryTable.COLUMN_MANGA_ID + " IN ("
|
||||
+ Queries.placeholders(mangas.size()) + ")")
|
||||
.whereArgs(mangaIds.toArray())
|
||||
.build())
|
||||
.prepare();
|
||||
}
|
||||
|
||||
public void setMangaCategories(List<MangaCategory> mangasCategories, List<Manga> mangas) {
|
||||
db.internal().beginTransaction();
|
||||
try {
|
||||
deleteOldMangasCategories(mangas).executeAsBlocking();
|
||||
insertMangasCategories(mangasCategories).executeAsBlocking();
|
||||
db.internal().setTransactionSuccessful();
|
||||
} finally {
|
||||
db.internal().endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
package eu.kanade.tachiyomi.data.database
|
||||
|
||||
import android.content.Context
|
||||
import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite
|
||||
import eu.kanade.tachiyomi.data.database.models.*
|
||||
import eu.kanade.tachiyomi.data.database.queries.*
|
||||
|
||||
/**
|
||||
* This class provides operations to manage the database through its interfaces.
|
||||
*/
|
||||
open class DatabaseHelper(context: Context)
|
||||
: MangaQueries, ChapterQueries, MangaSyncQueries, CategoryQueries, MangaCategoryQueries {
|
||||
|
||||
override val db = DefaultStorIOSQLite.builder()
|
||||
.sqliteOpenHelper(DbOpenHelper(context))
|
||||
.addTypeMapping(Manga::class.java, MangaSQLiteTypeMapping())
|
||||
.addTypeMapping(Chapter::class.java, ChapterSQLiteTypeMapping())
|
||||
.addTypeMapping(MangaSync::class.java, MangaSyncSQLiteTypeMapping())
|
||||
.addTypeMapping(Category::class.java, CategorySQLiteTypeMapping())
|
||||
.addTypeMapping(MangaCategory::class.java, MangaCategorySQLiteTypeMapping())
|
||||
.build()
|
||||
|
||||
inline fun inTransaction(block: () -> Unit) = db.inTransaction(block)
|
||||
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
package eu.kanade.tachiyomi.data.database
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
||||
|
||||
inline fun StorIOSQLite.inTransaction(block: () -> Unit) {
|
||||
internal().beginTransaction()
|
||||
try {
|
||||
block()
|
||||
internal().setTransactionSuccessful()
|
||||
} finally {
|
||||
internal().endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <T> StorIOSQLite.inTransactionReturn(block: () -> T): T {
|
||||
internal().beginTransaction()
|
||||
try {
|
||||
val result = block()
|
||||
internal().setTransactionSuccessful()
|
||||
return result
|
||||
} finally {
|
||||
internal().endTransaction()
|
||||
}
|
||||
}
|
||||
|
@ -1,47 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.database;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.database.sqlite.SQLiteOpenHelper;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.tables.CategoryTable;
|
||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable;
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable;
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable;
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable;
|
||||
|
||||
public class DbOpenHelper extends SQLiteOpenHelper {
|
||||
|
||||
public static final String DATABASE_NAME = "tachiyomi.db";
|
||||
public static final int DATABASE_VERSION = 1;
|
||||
|
||||
public DbOpenHelper(@NonNull Context context) {
|
||||
super(context, DATABASE_NAME, null, DATABASE_VERSION);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@NonNull SQLiteDatabase db) {
|
||||
db.execSQL(MangaTable.getCreateTableQuery());
|
||||
db.execSQL(ChapterTable.getCreateTableQuery());
|
||||
db.execSQL(MangaSyncTable.getCreateTableQuery());
|
||||
db.execSQL(CategoryTable.getCreateTableQuery());
|
||||
db.execSQL(MangaCategoryTable.getCreateTableQuery());
|
||||
|
||||
// DB indexes
|
||||
db.execSQL(MangaTable.getCreateUrlIndexQuery());
|
||||
db.execSQL(MangaTable.getCreateFavoriteIndexQuery());
|
||||
db.execSQL(ChapterTable.getCreateMangaIdIndexQuery());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUpgrade(@NonNull SQLiteDatabase db, int oldVersion, int newVersion) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigure(@NonNull SQLiteDatabase db) {
|
||||
db.setForeignKeyConstraintsEnabled(true);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
package eu.kanade.tachiyomi.data.database
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import eu.kanade.tachiyomi.data.database.tables.*
|
||||
|
||||
class DbOpenHelper(context: Context)
|
||||
: SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Name of the database file.
|
||||
*/
|
||||
const val DATABASE_NAME = "tachiyomi.db"
|
||||
|
||||
/**
|
||||
* Version of the database.
|
||||
*/
|
||||
const val DATABASE_VERSION = 2
|
||||
}
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) = with(db) {
|
||||
execSQL(MangaTable.createTableQuery)
|
||||
execSQL(ChapterTable.createTableQuery)
|
||||
execSQL(MangaSyncTable.createTableQuery)
|
||||
execSQL(CategoryTable.createTableQuery)
|
||||
execSQL(MangaCategoryTable.createTableQuery)
|
||||
|
||||
// DB indexes
|
||||
execSQL(MangaTable.createUrlIndexQuery)
|
||||
execSQL(MangaTable.createFavoriteIndexQuery)
|
||||
execSQL(ChapterTable.createMangaIdIndexQuery)
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
if (oldVersion < 2) {
|
||||
db.execSQL(ChapterTable.sourceOrderUpdateQuery)
|
||||
|
||||
// Fix kissmanga covers after supporting cloudflare
|
||||
db.execSQL("""UPDATE mangas SET thumbnail_url =
|
||||
REPLACE(thumbnail_url, '93.174.95.110', 'kissmanga.com') WHERE source = 4""")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConfigure(db: SQLiteDatabase) {
|
||||
db.setForeignKeyConstraintsEnabled(true)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
package eu.kanade.tachiyomi.data.database
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite
|
||||
|
||||
interface DbProvider {
|
||||
|
||||
val db: DefaultStorIOSQLite
|
||||
|
||||
}
|
@ -10,16 +10,16 @@ import eu.kanade.tachiyomi.data.database.tables.CategoryTable;
|
||||
@StorIOSQLiteType(table = CategoryTable.TABLE)
|
||||
public class Category implements Serializable {
|
||||
|
||||
@StorIOSQLiteColumn(name = CategoryTable.COLUMN_ID, key = true)
|
||||
@StorIOSQLiteColumn(name = CategoryTable.COL_ID, key = true)
|
||||
public Integer id;
|
||||
|
||||
@StorIOSQLiteColumn(name = CategoryTable.COLUMN_NAME)
|
||||
@StorIOSQLiteColumn(name = CategoryTable.COL_NAME)
|
||||
public String name;
|
||||
|
||||
@StorIOSQLiteColumn(name = CategoryTable.COLUMN_ORDER)
|
||||
@StorIOSQLiteColumn(name = CategoryTable.COL_ORDER)
|
||||
public int order;
|
||||
|
||||
@StorIOSQLiteColumn(name = CategoryTable.COLUMN_FLAGS)
|
||||
@StorIOSQLiteColumn(name = CategoryTable.COL_FLAGS)
|
||||
public int flags;
|
||||
|
||||
public Category() {}
|
||||
@ -35,4 +35,23 @@ public class Category implements Serializable {
|
||||
c.id = 0;
|
||||
return c;
|
||||
}
|
||||
|
||||
public String getNameLower() {
|
||||
return name.toLowerCase();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
Category category = (Category) o;
|
||||
|
||||
return name.equals(category.name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return name.hashCode();
|
||||
}
|
||||
}
|
||||
|
@ -4,42 +4,50 @@ import com.pushtorefresh.storio.sqlite.annotations.StorIOSQLiteColumn;
|
||||
import com.pushtorefresh.storio.sqlite.annotations.StorIOSQLiteType;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable;
|
||||
import eu.kanade.tachiyomi.data.download.model.Download;
|
||||
import eu.kanade.tachiyomi.data.source.model.Page;
|
||||
import eu.kanade.tachiyomi.util.UrlUtil;
|
||||
|
||||
@StorIOSQLiteType(table = ChapterTable.TABLE)
|
||||
public class Chapter implements Serializable {
|
||||
|
||||
@StorIOSQLiteColumn(name = ChapterTable.COLUMN_ID, key = true)
|
||||
@StorIOSQLiteColumn(name = ChapterTable.COL_ID, key = true)
|
||||
public Long id;
|
||||
|
||||
@StorIOSQLiteColumn(name = ChapterTable.COLUMN_MANGA_ID)
|
||||
@StorIOSQLiteColumn(name = ChapterTable.COL_MANGA_ID)
|
||||
public Long manga_id;
|
||||
|
||||
@StorIOSQLiteColumn(name = ChapterTable.COLUMN_URL)
|
||||
@StorIOSQLiteColumn(name = ChapterTable.COL_URL)
|
||||
public String url;
|
||||
|
||||
@StorIOSQLiteColumn(name = ChapterTable.COLUMN_NAME)
|
||||
@StorIOSQLiteColumn(name = ChapterTable.COL_NAME)
|
||||
public String name;
|
||||
|
||||
@StorIOSQLiteColumn(name = ChapterTable.COLUMN_READ)
|
||||
@StorIOSQLiteColumn(name = ChapterTable.COL_READ)
|
||||
public boolean read;
|
||||
|
||||
@StorIOSQLiteColumn(name = ChapterTable.COLUMN_LAST_PAGE_READ)
|
||||
@StorIOSQLiteColumn(name = ChapterTable.COL_LAST_PAGE_READ)
|
||||
public int last_page_read;
|
||||
|
||||
@StorIOSQLiteColumn(name = ChapterTable.COLUMN_DATE_FETCH)
|
||||
@StorIOSQLiteColumn(name = ChapterTable.COL_DATE_FETCH)
|
||||
public long date_fetch;
|
||||
|
||||
@StorIOSQLiteColumn(name = ChapterTable.COLUMN_DATE_UPLOAD)
|
||||
@StorIOSQLiteColumn(name = ChapterTable.COL_DATE_UPLOAD)
|
||||
public long date_upload;
|
||||
|
||||
@StorIOSQLiteColumn(name = ChapterTable.COLUMN_CHAPTER_NUMBER)
|
||||
@StorIOSQLiteColumn(name = ChapterTable.COL_CHAPTER_NUMBER)
|
||||
public float chapter_number;
|
||||
|
||||
@StorIOSQLiteColumn(name = ChapterTable.COL_SOURCE_ORDER)
|
||||
public int source_order;
|
||||
|
||||
public int status;
|
||||
|
||||
private transient List<Page> pages;
|
||||
|
||||
public Chapter() {}
|
||||
|
||||
public void setUrl(String url) {
|
||||
@ -68,4 +76,19 @@ public class Chapter implements Serializable {
|
||||
return chapter;
|
||||
}
|
||||
|
||||
public List<Page> getPages() {
|
||||
return pages;
|
||||
}
|
||||
|
||||
public void setPages(List<Page> pages) {
|
||||
this.pages = pages;
|
||||
}
|
||||
|
||||
public boolean isDownloaded() {
|
||||
return status == Download.DOWNLOADED;
|
||||
}
|
||||
|
||||
public boolean isRecognizedNumber() {
|
||||
return chapter_number >= 0f;
|
||||
}
|
||||
}
|
||||
|
@ -14,60 +14,83 @@ import eu.kanade.tachiyomi.util.UrlUtil;
|
||||
@StorIOSQLiteType(table = MangaTable.TABLE)
|
||||
public class Manga implements Serializable {
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaTable.COLUMN_ID, key = true)
|
||||
@StorIOSQLiteColumn(name = MangaTable.COL_ID, key = true)
|
||||
public Long id;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaTable.COLUMN_SOURCE)
|
||||
@StorIOSQLiteColumn(name = MangaTable.COL_SOURCE)
|
||||
public int source;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaTable.COLUMN_URL)
|
||||
@StorIOSQLiteColumn(name = MangaTable.COL_URL)
|
||||
public String url;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaTable.COLUMN_ARTIST)
|
||||
@StorIOSQLiteColumn(name = MangaTable.COL_ARTIST)
|
||||
public String artist;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaTable.COLUMN_AUTHOR)
|
||||
@StorIOSQLiteColumn(name = MangaTable.COL_AUTHOR)
|
||||
public String author;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaTable.COLUMN_DESCRIPTION)
|
||||
@StorIOSQLiteColumn(name = MangaTable.COL_DESCRIPTION)
|
||||
public String description;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaTable.COLUMN_GENRE)
|
||||
@StorIOSQLiteColumn(name = MangaTable.COL_GENRE)
|
||||
public String genre;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaTable.COLUMN_TITLE)
|
||||
@StorIOSQLiteColumn(name = MangaTable.COL_TITLE)
|
||||
public String title;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaTable.COLUMN_STATUS)
|
||||
@StorIOSQLiteColumn(name = MangaTable.COL_STATUS)
|
||||
public int status;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaTable.COLUMN_THUMBNAIL_URL)
|
||||
@StorIOSQLiteColumn(name = MangaTable.COL_THUMBNAIL_URL)
|
||||
public String thumbnail_url;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaTable.COLUMN_FAVORITE)
|
||||
@StorIOSQLiteColumn(name = MangaTable.COL_FAVORITE)
|
||||
public boolean favorite;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaTable.COLUMN_LAST_UPDATE)
|
||||
@StorIOSQLiteColumn(name = MangaTable.COL_LAST_UPDATE)
|
||||
public long last_update;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaTable.COLUMN_INITIALIZED)
|
||||
@StorIOSQLiteColumn(name = MangaTable.COL_INITIALIZED)
|
||||
public boolean initialized;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaTable.COLUMN_VIEWER)
|
||||
@StorIOSQLiteColumn(name = MangaTable.COL_VIEWER)
|
||||
public int viewer;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaTable.COLUMN_CHAPTER_FLAGS)
|
||||
@StorIOSQLiteColumn(name = MangaTable.COL_CHAPTER_FLAGS)
|
||||
public int chapter_flags;
|
||||
|
||||
public int unread;
|
||||
public transient int unread;
|
||||
|
||||
public int category;
|
||||
public transient int category;
|
||||
|
||||
public static final int UNKNOWN = 0;
|
||||
public static final int ONGOING = 1;
|
||||
public static final int COMPLETED = 2;
|
||||
public static final int LICENSED = 3;
|
||||
|
||||
public static final int SORT_DESC = 0x00000000;
|
||||
public static final int SORT_ASC = 0x00000001;
|
||||
public static final int SORT_MASK = 0x00000001;
|
||||
|
||||
// Generic filter that does not filter anything
|
||||
public static final int SHOW_ALL = 0x00000000;
|
||||
|
||||
public static final int SHOW_UNREAD = 0x00000002;
|
||||
public static final int SHOW_READ = 0x00000004;
|
||||
public static final int READ_MASK = 0x00000006;
|
||||
|
||||
public static final int SHOW_DOWNLOADED = 0x00000008;
|
||||
public static final int SHOW_NOT_DOWNLOADED = 0x00000010;
|
||||
public static final int DOWNLOADED_MASK = 0x00000018;
|
||||
|
||||
public static final int SORTING_SOURCE = 0x00000000;
|
||||
public static final int SORTING_NUMBER = 0x00000100;
|
||||
public static final int SORTING_MASK = 0x00000100;
|
||||
|
||||
public static final int DISPLAY_NAME = 0x00000000;
|
||||
public static final int DISPLAY_NUMBER = 0x00100000;
|
||||
public static final int DISPLAY_MASK = 0x00100000;
|
||||
|
||||
public Manga() {}
|
||||
|
||||
public static Manga create(String pathUrl) {
|
||||
@ -76,6 +99,13 @@ public class Manga implements Serializable {
|
||||
return m;
|
||||
}
|
||||
|
||||
public static Manga create(String pathUrl, int source) {
|
||||
Manga m = new Manga();
|
||||
m.url = pathUrl;
|
||||
m.source = source;
|
||||
return m;
|
||||
}
|
||||
|
||||
public void setUrl(String url) {
|
||||
this.url = UrlUtil.getPath(url);
|
||||
}
|
||||
@ -120,6 +150,51 @@ public class Manga implements Serializable {
|
||||
}
|
||||
}
|
||||
|
||||
public void setChapterOrder(int order) {
|
||||
setFlags(order, SORT_MASK);
|
||||
}
|
||||
|
||||
public void setDisplayMode(int mode) {
|
||||
setFlags(mode, DISPLAY_MASK);
|
||||
}
|
||||
|
||||
public void setReadFilter(int filter) {
|
||||
setFlags(filter, READ_MASK);
|
||||
}
|
||||
|
||||
public void setDownloadedFilter(int filter) {
|
||||
setFlags(filter, DOWNLOADED_MASK);
|
||||
}
|
||||
|
||||
public void setSorting(int sort) {
|
||||
setFlags(sort, SORTING_MASK);
|
||||
}
|
||||
|
||||
private void setFlags(int flag, int mask) {
|
||||
chapter_flags = (chapter_flags & ~mask) | (flag & mask);
|
||||
}
|
||||
|
||||
public boolean sortDescending() {
|
||||
return (chapter_flags & SORT_MASK) == SORT_DESC;
|
||||
}
|
||||
|
||||
// Used to display the chapter's title one way or another
|
||||
public int getDisplayMode() {
|
||||
return chapter_flags & DISPLAY_MASK;
|
||||
}
|
||||
|
||||
public int getReadFilter() {
|
||||
return chapter_flags & READ_MASK;
|
||||
}
|
||||
|
||||
public int getDownloadedFilter() {
|
||||
return chapter_flags & DOWNLOADED_MASK;
|
||||
}
|
||||
|
||||
public int getSorting() {
|
||||
return chapter_flags & SORTING_MASK;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
|
@ -8,13 +8,13 @@ import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable;
|
||||
@StorIOSQLiteType(table = MangaCategoryTable.TABLE)
|
||||
public class MangaCategory {
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaCategoryTable.COLUMN_ID, key = true)
|
||||
@StorIOSQLiteColumn(name = MangaCategoryTable.COL_ID, key = true)
|
||||
public Long id;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaCategoryTable.COLUMN_MANGA_ID)
|
||||
@StorIOSQLiteColumn(name = MangaCategoryTable.COL_MANGA_ID)
|
||||
public long manga_id;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaCategoryTable.COLUMN_CATEGORY_ID)
|
||||
@StorIOSQLiteColumn(name = MangaCategoryTable.COL_CATEGORY_ID)
|
||||
public int category_id;
|
||||
|
||||
public MangaCategory() {}
|
||||
|
@ -6,40 +6,44 @@ import com.pushtorefresh.storio.sqlite.annotations.StorIOSQLiteType;
|
||||
import java.io.Serializable;
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable;
|
||||
import eu.kanade.tachiyomi.data.mangasync.base.MangaSyncService;
|
||||
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService;
|
||||
|
||||
@StorIOSQLiteType(table = MangaSyncTable.TABLE)
|
||||
public class MangaSync implements Serializable {
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaSyncTable.COLUMN_ID, key = true)
|
||||
@StorIOSQLiteColumn(name = MangaSyncTable.COL_ID, key = true)
|
||||
public Long id;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaSyncTable.COLUMN_MANGA_ID)
|
||||
@StorIOSQLiteColumn(name = MangaSyncTable.COL_MANGA_ID)
|
||||
public long manga_id;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaSyncTable.COLUMN_SYNC_ID)
|
||||
@StorIOSQLiteColumn(name = MangaSyncTable.COL_SYNC_ID)
|
||||
public int sync_id;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaSyncTable.COLUMN_REMOTE_ID)
|
||||
@StorIOSQLiteColumn(name = MangaSyncTable.COL_REMOTE_ID)
|
||||
public int remote_id;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaSyncTable.COLUMN_TITLE)
|
||||
@StorIOSQLiteColumn(name = MangaSyncTable.COL_TITLE)
|
||||
public String title;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaSyncTable.COLUMN_LAST_CHAPTER_READ)
|
||||
@StorIOSQLiteColumn(name = MangaSyncTable.COL_LAST_CHAPTER_READ)
|
||||
public int last_chapter_read;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaSyncTable.COLUMN_TOTAL_CHAPTERS)
|
||||
@StorIOSQLiteColumn(name = MangaSyncTable.COL_TOTAL_CHAPTERS)
|
||||
public int total_chapters;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaSyncTable.COLUMN_SCORE)
|
||||
@StorIOSQLiteColumn(name = MangaSyncTable.COL_SCORE)
|
||||
public float score;
|
||||
|
||||
@StorIOSQLiteColumn(name = MangaSyncTable.COLUMN_STATUS)
|
||||
@StorIOSQLiteColumn(name = MangaSyncTable.COL_STATUS)
|
||||
public int status;
|
||||
|
||||
public boolean update;
|
||||
|
||||
public static MangaSync create() {
|
||||
return new MangaSync();
|
||||
}
|
||||
|
||||
public static MangaSync create(MangaSyncService service) {
|
||||
MangaSync mangasync = new MangaSync();
|
||||
mangasync.sync_id = service.getId();
|
||||
@ -52,4 +56,23 @@ public class MangaSync implements Serializable {
|
||||
status = other.status;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
MangaSync mangaSync = (MangaSync) o;
|
||||
|
||||
if (manga_id != mangaSync.manga_id) return false;
|
||||
if (sync_id != mangaSync.sync_id) return false;
|
||||
return remote_id == mangaSync.remote_id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = (int) (manga_id ^ (manga_id >>> 32));
|
||||
result = 31 * result + sync_id;
|
||||
result = 31 * result + remote_id;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,36 @@
|
||||
package eu.kanade.tachiyomi.data.database.queries
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.queries.Query
|
||||
import com.pushtorefresh.storio.sqlite.queries.RawQuery
|
||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.tables.CategoryTable
|
||||
|
||||
interface CategoryQueries : DbProvider {
|
||||
|
||||
fun getCategories() = db.get()
|
||||
.listOfObjects(Category::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(CategoryTable.TABLE)
|
||||
.orderBy(CategoryTable.COL_ORDER)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun getCategoriesForManga(manga: Manga) = db.get()
|
||||
.listOfObjects(Category::class.java)
|
||||
.withQuery(RawQuery.builder()
|
||||
.query(getCategoriesForMangaQuery())
|
||||
.args(manga.id)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun insertCategory(category: Category) = db.put().`object`(category).prepare()
|
||||
|
||||
fun insertCategories(categories: List<Category>) = db.put().objects(categories).prepare()
|
||||
|
||||
fun deleteCategory(category: Category) = db.delete().`object`(category).prepare()
|
||||
|
||||
fun deleteCategories(categories: List<Category>) = db.delete().objects(categories).prepare()
|
||||
|
||||
}
|
@ -0,0 +1,134 @@
|
||||
package eu.kanade.tachiyomi.data.database.queries
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.operations.get.PreparedGetObject
|
||||
import com.pushtorefresh.storio.sqlite.queries.Query
|
||||
import com.pushtorefresh.storio.sqlite.queries.RawQuery
|
||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaChapter
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.ChapterProgressPutResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.ChapterSourceOrderPutResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterGetResolver
|
||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable
|
||||
import java.util.*
|
||||
|
||||
interface ChapterQueries : DbProvider {
|
||||
|
||||
fun getChapters(manga: Manga) = db.get()
|
||||
.listOfObjects(Chapter::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(ChapterTable.TABLE)
|
||||
.where("${ChapterTable.COL_MANGA_ID} = ?")
|
||||
.whereArgs(manga.id)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun getRecentChapters(date: Date) = db.get()
|
||||
.listOfObjects(MangaChapter::class.java)
|
||||
.withQuery(RawQuery.builder()
|
||||
.query(getRecentsQuery())
|
||||
.args(date.time)
|
||||
.observesTables(ChapterTable.TABLE)
|
||||
.build())
|
||||
.withGetResolver(MangaChapterGetResolver.INSTANCE)
|
||||
.prepare()
|
||||
|
||||
fun getNextChapter(chapter: Chapter): PreparedGetObject<Chapter> {
|
||||
// Add a delta to the chapter number, because binary decimal representation
|
||||
// can retrieve the same chapter again
|
||||
val chapterNumber = chapter.chapter_number + 0.00001
|
||||
|
||||
return db.get()
|
||||
.`object`(Chapter::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(ChapterTable.TABLE)
|
||||
.where("${ChapterTable.COL_MANGA_ID} = ? AND " +
|
||||
"${ChapterTable.COL_CHAPTER_NUMBER} > ? AND " +
|
||||
"${ChapterTable.COL_CHAPTER_NUMBER} <= ?")
|
||||
.whereArgs(chapter.manga_id, chapterNumber, chapterNumber + 1)
|
||||
.orderBy(ChapterTable.COL_CHAPTER_NUMBER)
|
||||
.limit(1)
|
||||
.build())
|
||||
.prepare()
|
||||
}
|
||||
|
||||
fun getNextChapterBySource(chapter: Chapter) = db.get()
|
||||
.`object`(Chapter::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(ChapterTable.TABLE)
|
||||
.where("""${ChapterTable.COL_MANGA_ID} = ? AND
|
||||
${ChapterTable.COL_SOURCE_ORDER} < ?""")
|
||||
.whereArgs(chapter.manga_id, chapter.source_order)
|
||||
.orderBy("${ChapterTable.COL_SOURCE_ORDER} DESC")
|
||||
.limit(1)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun getPreviousChapter(chapter: Chapter): PreparedGetObject<Chapter> {
|
||||
// Add a delta to the chapter number, because binary decimal representation
|
||||
// can retrieve the same chapter again
|
||||
val chapterNumber = chapter.chapter_number - 0.00001
|
||||
|
||||
return db.get()
|
||||
.`object`(Chapter::class.java)
|
||||
.withQuery(Query.builder().table(ChapterTable.TABLE)
|
||||
.where("${ChapterTable.COL_MANGA_ID} = ? AND " +
|
||||
"${ChapterTable.COL_CHAPTER_NUMBER} < ? AND " +
|
||||
"${ChapterTable.COL_CHAPTER_NUMBER} >= ?")
|
||||
.whereArgs(chapter.manga_id, chapterNumber, chapterNumber - 1)
|
||||
.orderBy("${ChapterTable.COL_CHAPTER_NUMBER} DESC")
|
||||
.limit(1)
|
||||
.build())
|
||||
.prepare()
|
||||
}
|
||||
|
||||
fun getPreviousChapterBySource(chapter: Chapter) = db.get()
|
||||
.`object`(Chapter::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(ChapterTable.TABLE)
|
||||
.where("""${ChapterTable.COL_MANGA_ID} = ? AND
|
||||
${ChapterTable.COL_SOURCE_ORDER} > ?""")
|
||||
.whereArgs(chapter.manga_id, chapter.source_order)
|
||||
.orderBy(ChapterTable.COL_SOURCE_ORDER)
|
||||
.limit(1)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun getNextUnreadChapter(manga: Manga) = db.get()
|
||||
.`object`(Chapter::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(ChapterTable.TABLE)
|
||||
.where("${ChapterTable.COL_MANGA_ID} = ? AND " +
|
||||
"${ChapterTable.COL_READ} = ? AND " +
|
||||
"${ChapterTable.COL_CHAPTER_NUMBER} >= ?")
|
||||
.whereArgs(manga.id, 0, 0)
|
||||
.orderBy(ChapterTable.COL_CHAPTER_NUMBER)
|
||||
.limit(1)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun insertChapter(chapter: Chapter) = db.put().`object`(chapter).prepare()
|
||||
|
||||
fun insertChapters(chapters: List<Chapter>) = db.put().objects(chapters).prepare()
|
||||
|
||||
fun deleteChapter(chapter: Chapter) = db.delete().`object`(chapter).prepare()
|
||||
|
||||
fun deleteChapters(chapters: List<Chapter>) = db.delete().objects(chapters).prepare()
|
||||
|
||||
fun updateChapterProgress(chapter: Chapter) = db.put()
|
||||
.`object`(chapter)
|
||||
.withPutResolver(ChapterProgressPutResolver())
|
||||
.prepare()
|
||||
|
||||
fun updateChaptersProgress(chapters: List<Chapter>) = db.put()
|
||||
.objects(chapters)
|
||||
.withPutResolver(ChapterProgressPutResolver())
|
||||
.prepare()
|
||||
|
||||
fun fixChaptersSourceOrder(chapters: List<Chapter>) = db.put()
|
||||
.objects(chapters)
|
||||
.withPutResolver(ChapterSourceOrderPutResolver())
|
||||
.prepare()
|
||||
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
package eu.kanade.tachiyomi.data.database.queries
|
||||
|
||||
import com.pushtorefresh.storio.Queries
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.inTransaction
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaCategory
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable
|
||||
|
||||
interface MangaCategoryQueries : DbProvider {
|
||||
|
||||
fun insertMangaCategory(mangaCategory: MangaCategory) = db.put().`object`(mangaCategory).prepare()
|
||||
|
||||
fun insertMangasCategories(mangasCategories: List<MangaCategory>) = db.put().objects(mangasCategories).prepare()
|
||||
|
||||
fun deleteOldMangasCategories(mangas: List<Manga>) = db.delete()
|
||||
.byQuery(DeleteQuery.builder()
|
||||
.table(MangaCategoryTable.TABLE)
|
||||
.where("${MangaCategoryTable.COL_MANGA_ID} IN (${Queries.placeholders(mangas.size)})")
|
||||
.whereArgs(*mangas.map { it.id }.toTypedArray())
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun setMangaCategories(mangasCategories: List<MangaCategory>, mangas: List<Manga>) {
|
||||
db.inTransaction {
|
||||
deleteOldMangasCategories(mangas).executeAsBlocking()
|
||||
insertMangasCategories(mangasCategories).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,81 @@
|
||||
package eu.kanade.tachiyomi.data.database.queries
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.Query
|
||||
import com.pushtorefresh.storio.sqlite.queries.RawQuery
|
||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.LibraryMangaGetResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaFlagsPutResolver
|
||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||
|
||||
interface MangaQueries : DbProvider {
|
||||
|
||||
fun getMangas() = db.get()
|
||||
.listOfObjects(Manga::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(MangaTable.TABLE)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun getLibraryMangas() = db.get()
|
||||
.listOfObjects(Manga::class.java)
|
||||
.withQuery(RawQuery.builder()
|
||||
.query(libraryQuery)
|
||||
.observesTables(MangaTable.TABLE, ChapterTable.TABLE, MangaCategoryTable.TABLE)
|
||||
.build())
|
||||
.withGetResolver(LibraryMangaGetResolver.INSTANCE)
|
||||
.prepare()
|
||||
|
||||
open fun getFavoriteMangas() = db.get()
|
||||
.listOfObjects(Manga::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(MangaTable.TABLE)
|
||||
.where("${MangaTable.COL_FAVORITE} = ?")
|
||||
.whereArgs(1)
|
||||
.orderBy(MangaTable.COL_TITLE)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun getManga(url: String, sourceId: Int) = db.get()
|
||||
.`object`(Manga::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(MangaTable.TABLE)
|
||||
.where("${MangaTable.COL_URL} = ? AND ${MangaTable.COL_SOURCE} = ?")
|
||||
.whereArgs(url, sourceId)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun getManga(id: Long) = db.get()
|
||||
.`object`(Manga::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(MangaTable.TABLE)
|
||||
.where("${MangaTable.COL_ID} = ?")
|
||||
.whereArgs(id)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun insertManga(manga: Manga) = db.put().`object`(manga).prepare()
|
||||
|
||||
fun insertMangas(mangas: List<Manga>) = db.put().objects(mangas).prepare()
|
||||
|
||||
fun updateFlags(manga: Manga) = db.put()
|
||||
.`object`(manga)
|
||||
.withPutResolver(MangaFlagsPutResolver())
|
||||
.prepare()
|
||||
|
||||
fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare()
|
||||
|
||||
fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare()
|
||||
|
||||
fun deleteMangasNotInLibrary() = db.delete()
|
||||
.byQuery(DeleteQuery.builder()
|
||||
.table(MangaTable.TABLE)
|
||||
.where("${MangaTable.COL_FAVORITE} = ?")
|
||||
.whereArgs(0)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
package eu.kanade.tachiyomi.data.database.queries
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.Query
|
||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSync
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable
|
||||
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
|
||||
|
||||
interface MangaSyncQueries : DbProvider {
|
||||
|
||||
fun getMangaSync(manga: Manga, sync: MangaSyncService) = db.get()
|
||||
.`object`(MangaSync::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(MangaSyncTable.TABLE)
|
||||
.where("${MangaSyncTable.COL_MANGA_ID} = ? AND " +
|
||||
"${MangaSyncTable.COL_SYNC_ID} = ?")
|
||||
.whereArgs(manga.id, sync.id)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun getMangasSync(manga: Manga) = db.get()
|
||||
.listOfObjects(MangaSync::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(MangaSyncTable.TABLE)
|
||||
.where("${MangaSyncTable.COL_MANGA_ID} = ?")
|
||||
.whereArgs(manga.id)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun insertMangaSync(manga: MangaSync) = db.put().`object`(manga).prepare()
|
||||
|
||||
fun insertMangasSync(mangas: List<MangaSync>) = db.put().objects(mangas).prepare()
|
||||
|
||||
fun deleteMangaSync(manga: MangaSync) = db.delete().`object`(manga).prepare()
|
||||
|
||||
fun deleteMangaSyncForManga(manga: Manga) = db.delete()
|
||||
.byQuery(DeleteQuery.builder()
|
||||
.table(MangaSyncTable.TABLE)
|
||||
.where("${MangaSyncTable.COL_MANGA_ID} = ?")
|
||||
.whereArgs(manga.id)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
package eu.kanade.tachiyomi.data.database.queries
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.tables.CategoryTable as Category
|
||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable as Chapter
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable as MangaCategory
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable as Manga
|
||||
|
||||
/**
|
||||
* Query to get the manga from the library, with their categories and unread count.
|
||||
*/
|
||||
val libraryQuery = """
|
||||
SELECT M.*, COALESCE(MC.${MangaCategory.COL_CATEGORY_ID}, 0) AS ${Manga.COL_CATEGORY}
|
||||
FROM (
|
||||
SELECT ${Manga.TABLE}.*, COALESCE(C.unread, 0) AS ${Manga.COL_UNREAD}
|
||||
FROM ${Manga.TABLE}
|
||||
LEFT JOIN (
|
||||
SELECT ${Chapter.COL_MANGA_ID}, COUNT(*) AS unread
|
||||
FROM ${Chapter.TABLE}
|
||||
WHERE ${Chapter.COL_READ} = 0
|
||||
GROUP BY ${Chapter.COL_MANGA_ID}
|
||||
) AS C
|
||||
ON ${Manga.COL_ID} = C.${Chapter.COL_MANGA_ID}
|
||||
WHERE ${Manga.COL_FAVORITE} = 1
|
||||
GROUP BY ${Manga.COL_ID}
|
||||
ORDER BY ${Manga.COL_TITLE}
|
||||
) AS M
|
||||
LEFT JOIN (
|
||||
SELECT * FROM ${MangaCategory.TABLE}) AS MC
|
||||
ON MC.${MangaCategory.COL_MANGA_ID} = M.${Manga.COL_ID}
|
||||
"""
|
||||
|
||||
/**
|
||||
* Query to get the recent chapters of manga from the library up to a date.
|
||||
*/
|
||||
fun getRecentsQuery() = """
|
||||
SELECT ${Manga.TABLE}.${Manga.COL_URL} as mangaUrl, * FROM ${Manga.TABLE} JOIN ${Chapter.TABLE}
|
||||
ON ${Manga.TABLE}.${Manga.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_MANGA_ID}
|
||||
WHERE ${Manga.COL_FAVORITE} = 1 AND ${Chapter.COL_DATE_UPLOAD} > ?
|
||||
ORDER BY ${Chapter.COL_DATE_UPLOAD} DESC
|
||||
"""
|
||||
|
||||
|
||||
/**
|
||||
* Query to get the categories for a manga.
|
||||
*/
|
||||
fun getCategoriesForMangaQuery() = """
|
||||
SELECT ${Category.TABLE}.* FROM ${Category.TABLE}
|
||||
JOIN ${MangaCategory.TABLE} ON ${Category.TABLE}.${Category.COL_ID} =
|
||||
${MangaCategory.TABLE}.${MangaCategory.COL_CATEGORY_ID}
|
||||
WHERE ${MangaCategory.COL_MANGA_ID} = ?
|
||||
"""
|
@ -0,0 +1,34 @@
|
||||
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 ChapterProgressPutResolver : PutResolver<Chapter>() {
|
||||
|
||||
override fun performPut(db: StorIOSQLite, chapter: Chapter) = db.inTransactionReturn {
|
||||
val updateQuery = mapToUpdateQuery(chapter)
|
||||
val contentValues = mapToContentValues(chapter)
|
||||
|
||||
val numberOfRowsUpdated = db.internal().update(updateQuery, contentValues)
|
||||
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
|
||||
}
|
||||
|
||||
fun mapToUpdateQuery(chapter: Chapter) = UpdateQuery.builder()
|
||||
.table(ChapterTable.TABLE)
|
||||
.where("${ChapterTable.COL_ID} = ?")
|
||||
.whereArgs(chapter.id)
|
||||
.build()
|
||||
|
||||
fun mapToContentValues(chapter: Chapter) = ContentValues(2).apply {
|
||||
put(ChapterTable.COL_READ, chapter.read)
|
||||
put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -0,0 +1,32 @@
|
||||
package eu.kanade.tachiyomi.data.database.resolvers
|
||||
|
||||
import android.content.ContentValues
|
||||
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
||||
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||
import eu.kanade.tachiyomi.data.database.inTransactionReturn
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable
|
||||
|
||||
class ChapterSourceOrderPutResolver : PutResolver<Chapter>() {
|
||||
|
||||
override fun performPut(db: StorIOSQLite, chapter: Chapter) = db.inTransactionReturn {
|
||||
val updateQuery = mapToUpdateQuery(chapter)
|
||||
val contentValues = mapToContentValues(chapter)
|
||||
|
||||
val numberOfRowsUpdated = db.internal().update(updateQuery, contentValues)
|
||||
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
|
||||
}
|
||||
|
||||
fun mapToUpdateQuery(chapter: Chapter) = UpdateQuery.builder()
|
||||
.table(ChapterTable.TABLE)
|
||||
.where("${ChapterTable.COL_URL} = ? AND ${ChapterTable.COL_MANGA_ID} = ?")
|
||||
.whereArgs(chapter.url, chapter.manga_id)
|
||||
.build()
|
||||
|
||||
fun mapToContentValues(chapter: Chapter) = ContentValues(1).apply {
|
||||
put(ChapterTable.COL_SOURCE_ORDER, chapter.source_order)
|
||||
}
|
||||
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.database.resolvers;
|
||||
|
||||
import android.database.Cursor;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga;
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaStorIOSQLiteGetResolver;
|
||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable;
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable;
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable;
|
||||
|
||||
public class LibraryMangaGetResolver extends MangaStorIOSQLiteGetResolver {
|
||||
|
||||
public static final LibraryMangaGetResolver INSTANCE = new LibraryMangaGetResolver();
|
||||
|
||||
public static final String QUERY = String.format(
|
||||
"SELECT M.*, COALESCE(MC.%10$s, 0) AS %12$s " +
|
||||
"FROM (" +
|
||||
"SELECT %1$s.*, COALESCE(C.unread, 0) AS %6$s " +
|
||||
"FROM %1$s " +
|
||||
"LEFT JOIN (" +
|
||||
"SELECT %5$s, COUNT(*) AS unread " +
|
||||
"FROM %2$s " +
|
||||
"WHERE %7$s = 0 " +
|
||||
"GROUP BY %5$s" +
|
||||
") AS C " +
|
||||
"ON %4$s = C.%5$s " +
|
||||
"WHERE %8$s = 1 " +
|
||||
"GROUP BY %4$s " +
|
||||
"ORDER BY %9$s" +
|
||||
") AS M " +
|
||||
"LEFT JOIN (SELECT * FROM %3$s) AS MC ON MC.%11$s = M.%4$s",
|
||||
MangaTable.TABLE,
|
||||
ChapterTable.TABLE,
|
||||
MangaCategoryTable.TABLE,
|
||||
MangaTable.COLUMN_ID,
|
||||
ChapterTable.COLUMN_MANGA_ID,
|
||||
MangaTable.COLUMN_UNREAD,
|
||||
ChapterTable.COLUMN_READ,
|
||||
MangaTable.COLUMN_FAVORITE,
|
||||
MangaTable.COLUMN_TITLE,
|
||||
MangaCategoryTable.COLUMN_CATEGORY_ID,
|
||||
MangaCategoryTable.COLUMN_MANGA_ID,
|
||||
MangaTable.COLUMN_CATEGORY
|
||||
);
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public Manga mapFromCursor(@NonNull Cursor cursor) {
|
||||
Manga manga = super.mapFromCursor(cursor);
|
||||
|
||||
int unreadColumn = cursor.getColumnIndex(MangaTable.COLUMN_UNREAD);
|
||||
manga.unread = cursor.getInt(unreadColumn);
|
||||
|
||||
int categoryColumn = cursor.getColumnIndex(MangaTable.COLUMN_CATEGORY);
|
||||
manga.category = cursor.getInt(categoryColumn);
|
||||
|
||||
return manga;
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
package eu.kanade.tachiyomi.data.database.resolvers
|
||||
|
||||
import android.database.Cursor
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaStorIOSQLiteGetResolver
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||
|
||||
class LibraryMangaGetResolver : MangaStorIOSQLiteGetResolver() {
|
||||
|
||||
companion object {
|
||||
val INSTANCE = LibraryMangaGetResolver()
|
||||
}
|
||||
|
||||
override fun mapFromCursor(cursor: Cursor): Manga {
|
||||
val manga = super.mapFromCursor(cursor)
|
||||
|
||||
val unreadColumn = cursor.getColumnIndex(MangaTable.COL_UNREAD)
|
||||
manga.unread = cursor.getInt(unreadColumn)
|
||||
|
||||
val categoryColumn = cursor.getColumnIndex(MangaTable.COL_CATEGORY)
|
||||
manga.category = cursor.getInt(categoryColumn)
|
||||
|
||||
return manga
|
||||
}
|
||||
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.database.resolvers;
|
||||
|
||||
import android.database.Cursor;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver;
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter;
|
||||
import eu.kanade.tachiyomi.data.database.models.ChapterStorIOSQLiteGetResolver;
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga;
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaChapter;
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaStorIOSQLiteGetResolver;
|
||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable;
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable;
|
||||
|
||||
public class MangaChapterGetResolver extends DefaultGetResolver<MangaChapter> {
|
||||
|
||||
public static final MangaChapterGetResolver INSTANCE = new MangaChapterGetResolver();
|
||||
|
||||
public static final String QUERY = String.format(
|
||||
"SELECT * FROM %1$s JOIN %2$s on %1$s.%3$s = %2$s.%4$s",
|
||||
MangaTable.TABLE,
|
||||
ChapterTable.TABLE,
|
||||
MangaTable.COLUMN_ID,
|
||||
ChapterTable.COLUMN_MANGA_ID);
|
||||
|
||||
public static final String RECENT_CHAPTERS_QUERY = String.format(
|
||||
QUERY + " WHERE %1$s = 1 ORDER BY %2$s DESC LIMIT 100",
|
||||
MangaTable.COLUMN_FAVORITE,
|
||||
ChapterTable.COLUMN_DATE_UPLOAD);
|
||||
|
||||
@NonNull
|
||||
private final MangaStorIOSQLiteGetResolver mangaGetResolver;
|
||||
|
||||
@NonNull
|
||||
private final ChapterStorIOSQLiteGetResolver chapterGetResolver;
|
||||
|
||||
public MangaChapterGetResolver() {
|
||||
this.mangaGetResolver = new MangaStorIOSQLiteGetResolver();
|
||||
this.chapterGetResolver = new ChapterStorIOSQLiteGetResolver();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public MangaChapter mapFromCursor(@NonNull Cursor cursor) {
|
||||
final Manga manga = mangaGetResolver.mapFromCursor(cursor);
|
||||
final Chapter chapter = chapterGetResolver.mapFromCursor(cursor);
|
||||
|
||||
return new MangaChapter(manga, chapter);
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
package eu.kanade.tachiyomi.data.database.resolvers
|
||||
|
||||
import android.database.Cursor
|
||||
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
||||
import eu.kanade.tachiyomi.data.database.models.ChapterStorIOSQLiteGetResolver
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaChapter
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaStorIOSQLiteGetResolver
|
||||
|
||||
class MangaChapterGetResolver : DefaultGetResolver<MangaChapter>() {
|
||||
|
||||
companion object {
|
||||
val INSTANCE = MangaChapterGetResolver()
|
||||
}
|
||||
|
||||
private val mangaGetResolver = MangaStorIOSQLiteGetResolver()
|
||||
|
||||
private val chapterGetResolver = ChapterStorIOSQLiteGetResolver()
|
||||
|
||||
override fun mapFromCursor(cursor: Cursor): MangaChapter {
|
||||
val manga = mangaGetResolver.mapFromCursor(cursor)
|
||||
val chapter = chapterGetResolver.mapFromCursor(cursor)
|
||||
manga.id = chapter.manga_id
|
||||
manga.url = cursor.getString(cursor.getColumnIndex("mangaUrl"));
|
||||
|
||||
return MangaChapter(manga, chapter)
|
||||
}
|
||||
|
||||
}
|
@ -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 MangaFlagsPutResolver : PutResolver<Manga>() {
|
||||
|
||||
override fun performPut(db: StorIOSQLite, manga: Manga) = db.inTransactionReturn {
|
||||
val updateQuery = mapToUpdateQuery(manga)
|
||||
val contentValues = mapToContentValues(manga)
|
||||
|
||||
val numberOfRowsUpdated = db.internal().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_CHAPTER_FLAGS, manga.chapter_flags)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,39 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.database.tables;
|
||||
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
public class CategoryTable {
|
||||
|
||||
@NonNull
|
||||
public static final String TABLE = "categories";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_ID = "_id";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_NAME = "name";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_ORDER = "sort";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_FLAGS = "flags";
|
||||
|
||||
// This is just class with Meta Data, we don't need instances
|
||||
private CategoryTable() {
|
||||
throw new IllegalStateException("No instances please");
|
||||
}
|
||||
|
||||
// Better than static final field -> allows VM to unload useless String
|
||||
// Because you need this string only once per application life on the device
|
||||
@NonNull
|
||||
public static String getCreateTableQuery() {
|
||||
return "CREATE TABLE " + TABLE + "("
|
||||
+ COLUMN_ID + " INTEGER NOT NULL PRIMARY KEY, "
|
||||
+ COLUMN_NAME + " TEXT NOT NULL, "
|
||||
+ COLUMN_ORDER + " INTEGER NOT NULL, "
|
||||
+ COLUMN_FLAGS + " INTEGER NOT NULL"
|
||||
+ ");";
|
||||
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
package eu.kanade.tachiyomi.data.database.tables
|
||||
|
||||
object CategoryTable {
|
||||
|
||||
const val TABLE = "categories"
|
||||
|
||||
const val COL_ID = "_id"
|
||||
|
||||
const val COL_NAME = "name"
|
||||
|
||||
const val COL_ORDER = "sort"
|
||||
|
||||
const val COL_FLAGS = "flags"
|
||||
|
||||
val createTableQuery: String
|
||||
get() = """CREATE TABLE $TABLE(
|
||||
$COL_ID INTEGER NOT NULL PRIMARY KEY,
|
||||
$COL_NAME TEXT NOT NULL,
|
||||
$COL_ORDER INTEGER NOT NULL,
|
||||
$COL_FLAGS INTEGER NOT NULL
|
||||
)"""
|
||||
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.database.tables;
|
||||
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
public class ChapterTable {
|
||||
|
||||
@NonNull
|
||||
public static final String TABLE = "chapters";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_ID = "_id";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_MANGA_ID = "manga_id";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_URL = "url";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_NAME = "name";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_READ = "read";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_DATE_FETCH = "date_fetch";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_DATE_UPLOAD = "date_upload";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_LAST_PAGE_READ = "last_page_read";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_CHAPTER_NUMBER = "chapter_number";
|
||||
|
||||
@NonNull
|
||||
public static String getCreateTableQuery() {
|
||||
return "CREATE TABLE " + TABLE + "("
|
||||
+ COLUMN_ID + " INTEGER NOT NULL PRIMARY KEY, "
|
||||
+ COLUMN_MANGA_ID + " INTEGER NOT NULL, "
|
||||
+ COLUMN_URL + " TEXT NOT NULL, "
|
||||
+ COLUMN_NAME + " TEXT NOT NULL, "
|
||||
+ COLUMN_READ + " BOOLEAN NOT NULL, "
|
||||
+ COLUMN_LAST_PAGE_READ + " INT NOT NULL, "
|
||||
+ COLUMN_CHAPTER_NUMBER + " FLOAT NOT NULL, "
|
||||
+ COLUMN_DATE_FETCH + " LONG NOT NULL, "
|
||||
+ COLUMN_DATE_UPLOAD + " LONG NOT NULL, "
|
||||
+ "FOREIGN KEY(" + COLUMN_MANGA_ID + ") REFERENCES " + MangaTable.TABLE + "(" + MangaTable.COLUMN_ID + ") "
|
||||
+ "ON DELETE CASCADE"
|
||||
+ ");";
|
||||
}
|
||||
|
||||
public static String getCreateMangaIdIndexQuery() {
|
||||
return "CREATE INDEX " + TABLE + "_" + COLUMN_MANGA_ID + "_index ON " + TABLE + "(" + COLUMN_MANGA_ID + ");";
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
package eu.kanade.tachiyomi.data.database.tables
|
||||
|
||||
object ChapterTable {
|
||||
|
||||
const val TABLE = "chapters"
|
||||
|
||||
const val COL_ID = "_id"
|
||||
|
||||
const val COL_MANGA_ID = "manga_id"
|
||||
|
||||
const val COL_URL = "url"
|
||||
|
||||
const val COL_NAME = "name"
|
||||
|
||||
const val COL_READ = "read"
|
||||
|
||||
const val COL_DATE_FETCH = "date_fetch"
|
||||
|
||||
const val COL_DATE_UPLOAD = "date_upload"
|
||||
|
||||
const val COL_LAST_PAGE_READ = "last_page_read"
|
||||
|
||||
const val COL_CHAPTER_NUMBER = "chapter_number"
|
||||
|
||||
const val COL_SOURCE_ORDER = "source_order"
|
||||
|
||||
val createTableQuery: String
|
||||
get() = """CREATE TABLE $TABLE(
|
||||
$COL_ID INTEGER NOT NULL PRIMARY KEY,
|
||||
$COL_MANGA_ID INTEGER NOT NULL,
|
||||
$COL_URL TEXT NOT NULL,
|
||||
$COL_NAME TEXT NOT NULL,
|
||||
$COL_READ BOOLEAN NOT NULL,
|
||||
$COL_LAST_PAGE_READ INT NOT NULL,
|
||||
$COL_CHAPTER_NUMBER FLOAT NOT NULL,
|
||||
$COL_SOURCE_ORDER INTEGER NOT NULL,
|
||||
$COL_DATE_FETCH LONG NOT NULL,
|
||||
$COL_DATE_UPLOAD LONG NOT NULL,
|
||||
FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID})
|
||||
ON DELETE CASCADE
|
||||
)"""
|
||||
|
||||
val createMangaIdIndexQuery: String
|
||||
get() = "CREATE INDEX ${TABLE}_${COL_MANGA_ID}_index ON $TABLE($COL_MANGA_ID)"
|
||||
|
||||
val sourceOrderUpdateQuery: String
|
||||
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SOURCE_ORDER INTEGER DEFAULT 0"
|
||||
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.database.tables;
|
||||
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
public class MangaCategoryTable {
|
||||
|
||||
@NonNull
|
||||
public static final String TABLE = "mangas_categories";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_ID = "_id";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_MANGA_ID = "manga_id";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_CATEGORY_ID = "category_id";
|
||||
|
||||
// This is just class with Meta Data, we don't need instances
|
||||
private MangaCategoryTable() {
|
||||
throw new IllegalStateException("No instances please");
|
||||
}
|
||||
|
||||
// Better than static final field -> allows VM to unload useless String
|
||||
// Because you need this string only once per application life on the device
|
||||
@NonNull
|
||||
public static String getCreateTableQuery() {
|
||||
return "CREATE TABLE " + TABLE + "("
|
||||
+ COLUMN_ID + " INTEGER NOT NULL PRIMARY KEY, "
|
||||
+ COLUMN_MANGA_ID + " INTEGER NOT NULL, "
|
||||
+ COLUMN_CATEGORY_ID + " INTEGER NOT NULL, "
|
||||
+ "FOREIGN KEY(" + COLUMN_CATEGORY_ID + ") REFERENCES " + CategoryTable.TABLE + "(" + CategoryTable.COLUMN_ID + ") "
|
||||
+ "ON DELETE CASCADE, "
|
||||
+ "FOREIGN KEY(" + COLUMN_MANGA_ID + ") REFERENCES " + MangaTable.TABLE + "(" + MangaTable.COLUMN_ID + ") "
|
||||
+ "ON DELETE CASCADE"
|
||||
+ ");";
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
package eu.kanade.tachiyomi.data.database.tables
|
||||
|
||||
object MangaCategoryTable {
|
||||
|
||||
const val TABLE = "mangas_categories"
|
||||
|
||||
const val COL_ID = "_id"
|
||||
|
||||
const val COL_MANGA_ID = "manga_id"
|
||||
|
||||
const val COL_CATEGORY_ID = "category_id"
|
||||
|
||||
val createTableQuery: String
|
||||
get() = """CREATE TABLE $TABLE(
|
||||
$COL_ID INTEGER NOT NULL PRIMARY KEY,
|
||||
$COL_MANGA_ID INTEGER NOT NULL,
|
||||
$COL_CATEGORY_ID INTEGER NOT NULL,
|
||||
FOREIGN KEY($COL_CATEGORY_ID) REFERENCES ${CategoryTable.TABLE} (${CategoryTable.COL_ID})
|
||||
ON DELETE CASCADE,
|
||||
FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID})
|
||||
ON DELETE CASCADE
|
||||
)"""
|
||||
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.database.tables;
|
||||
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
public class MangaSyncTable {
|
||||
|
||||
public static final String TABLE = "manga_sync";
|
||||
|
||||
public static final String COLUMN_ID = "_id";
|
||||
|
||||
public static final String COLUMN_MANGA_ID = "manga_id";
|
||||
|
||||
public static final String COLUMN_SYNC_ID = "sync_id";
|
||||
|
||||
public static final String COLUMN_REMOTE_ID = "remote_id";
|
||||
|
||||
public static final String COLUMN_TITLE = "title";
|
||||
|
||||
public static final String COLUMN_LAST_CHAPTER_READ = "last_chapter_read";
|
||||
|
||||
public static final String COLUMN_STATUS = "status";
|
||||
|
||||
public static final String COLUMN_SCORE = "score";
|
||||
|
||||
public static final String COLUMN_TOTAL_CHAPTERS = "total_chapters";
|
||||
|
||||
@NonNull
|
||||
public static String getCreateTableQuery() {
|
||||
return "CREATE TABLE " + TABLE + "("
|
||||
+ COLUMN_ID + " INTEGER NOT NULL PRIMARY KEY, "
|
||||
+ COLUMN_MANGA_ID + " INTEGER NOT NULL, "
|
||||
+ COLUMN_SYNC_ID + " INTEGER NOT NULL, "
|
||||
+ COLUMN_REMOTE_ID + " INTEGER NOT NULL, "
|
||||
+ COLUMN_TITLE + " TEXT NOT NULL, "
|
||||
+ COLUMN_LAST_CHAPTER_READ + " INTEGER NOT NULL, "
|
||||
+ COLUMN_TOTAL_CHAPTERS + " INTEGER NOT NULL, "
|
||||
+ COLUMN_STATUS + " INTEGER NOT NULL, "
|
||||
+ COLUMN_SCORE + " FLOAT NOT NULL, "
|
||||
+ "UNIQUE (" + COLUMN_MANGA_ID + ", " + COLUMN_SYNC_ID + ") ON CONFLICT REPLACE, "
|
||||
+ "FOREIGN KEY(" + COLUMN_MANGA_ID + ") REFERENCES " + MangaTable.TABLE + "(" + MangaTable.COLUMN_ID + ") "
|
||||
+ "ON DELETE CASCADE"
|
||||
+ ");";
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
package eu.kanade.tachiyomi.data.database.tables
|
||||
|
||||
object MangaSyncTable {
|
||||
|
||||
const val TABLE = "manga_sync"
|
||||
|
||||
const val COL_ID = "_id"
|
||||
|
||||
const val COL_MANGA_ID = "manga_id"
|
||||
|
||||
const val COL_SYNC_ID = "sync_id"
|
||||
|
||||
const val COL_REMOTE_ID = "remote_id"
|
||||
|
||||
const val COL_TITLE = "title"
|
||||
|
||||
const val COL_LAST_CHAPTER_READ = "last_chapter_read"
|
||||
|
||||
const val COL_STATUS = "status"
|
||||
|
||||
const val COL_SCORE = "score"
|
||||
|
||||
const val COL_TOTAL_CHAPTERS = "total_chapters"
|
||||
|
||||
val createTableQuery: String
|
||||
get() = """CREATE TABLE $TABLE(
|
||||
$COL_ID INTEGER NOT NULL PRIMARY KEY,
|
||||
$COL_MANGA_ID INTEGER NOT NULL,
|
||||
$COL_SYNC_ID INTEGER NOT NULL,
|
||||
$COL_REMOTE_ID INTEGER NOT NULL,
|
||||
$COL_TITLE TEXT NOT NULL,
|
||||
$COL_LAST_CHAPTER_READ INTEGER NOT NULL,
|
||||
$COL_TOTAL_CHAPTERS INTEGER NOT NULL,
|
||||
$COL_STATUS INTEGER NOT NULL,
|
||||
$COL_SCORE FLOAT NOT NULL,
|
||||
UNIQUE ($COL_MANGA_ID, $COL_SYNC_ID) ON CONFLICT REPLACE,
|
||||
FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID})
|
||||
ON DELETE CASCADE
|
||||
)"""
|
||||
|
||||
}
|
@ -1,98 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.database.tables;
|
||||
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
public class MangaTable {
|
||||
|
||||
@NonNull
|
||||
public static final String TABLE = "mangas";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_ID = "_id";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_SOURCE = "source";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_URL = "url";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_ARTIST = "artist";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_AUTHOR = "author" ;
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_DESCRIPTION = "description";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_GENRE = "genre";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_TITLE = "title";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_STATUS = "status";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_THUMBNAIL_URL = "thumbnail_url";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_FAVORITE = "favorite";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_LAST_UPDATE = "last_update";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_INITIALIZED = "initialized";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_VIEWER = "viewer";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_CHAPTER_FLAGS = "chapter_flags";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_UNREAD = "unread";
|
||||
|
||||
@NonNull
|
||||
public static final String COLUMN_CATEGORY = "category";
|
||||
|
||||
// This is just class with Meta Data, we don't need instances
|
||||
private MangaTable() {
|
||||
throw new IllegalStateException("No instances please");
|
||||
}
|
||||
|
||||
// Better than static final field -> allows VM to unload useless String
|
||||
// Because you need this string only once per application life on the device
|
||||
@NonNull
|
||||
public static String getCreateTableQuery() {
|
||||
return "CREATE TABLE " + TABLE + "("
|
||||
+ COLUMN_ID + " INTEGER NOT NULL PRIMARY KEY, "
|
||||
+ COLUMN_SOURCE + " INTEGER NOT NULL, "
|
||||
+ COLUMN_URL + " TEXT NOT NULL, "
|
||||
+ COLUMN_ARTIST + " TEXT, "
|
||||
+ COLUMN_AUTHOR + " TEXT, "
|
||||
+ COLUMN_DESCRIPTION + " TEXT, "
|
||||
+ COLUMN_GENRE + " TEXT, "
|
||||
+ COLUMN_TITLE + " TEXT NOT NULL, "
|
||||
+ COLUMN_STATUS + " INTEGER NOT NULL, "
|
||||
+ COLUMN_THUMBNAIL_URL + " TEXT, "
|
||||
+ COLUMN_FAVORITE + " INTEGER NOT NULL, "
|
||||
+ COLUMN_LAST_UPDATE + " LONG, "
|
||||
+ COLUMN_INITIALIZED + " BOOLEAN NOT NULL, "
|
||||
+ COLUMN_VIEWER + " INTEGER NOT NULL, "
|
||||
+ COLUMN_CHAPTER_FLAGS + " INTEGER NOT NULL"
|
||||
+ ");";
|
||||
|
||||
}
|
||||
|
||||
public static String getCreateUrlIndexQuery() {
|
||||
return "CREATE INDEX " + TABLE + "_" + COLUMN_URL + "_index ON " + TABLE + "(" + COLUMN_URL + ");";
|
||||
}
|
||||
|
||||
public static String getCreateFavoriteIndexQuery() {
|
||||
return "CREATE INDEX " + TABLE + "_" + COLUMN_FAVORITE + "_index ON " + TABLE + "(" + COLUMN_FAVORITE + ");";
|
||||
|
||||
}
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
package eu.kanade.tachiyomi.data.database.tables
|
||||
|
||||
object MangaTable {
|
||||
|
||||
const val TABLE = "mangas"
|
||||
|
||||
const val COL_ID = "_id"
|
||||
|
||||
const val COL_SOURCE = "source"
|
||||
|
||||
const val COL_URL = "url"
|
||||
|
||||
const val COL_ARTIST = "artist"
|
||||
|
||||
const val COL_AUTHOR = "author"
|
||||
|
||||
const val COL_DESCRIPTION = "description"
|
||||
|
||||
const val COL_GENRE = "genre"
|
||||
|
||||
const val COL_TITLE = "title"
|
||||
|
||||
const val COL_STATUS = "status"
|
||||
|
||||
const val COL_THUMBNAIL_URL = "thumbnail_url"
|
||||
|
||||
const val COL_FAVORITE = "favorite"
|
||||
|
||||
const val COL_LAST_UPDATE = "last_update"
|
||||
|
||||
const val COL_INITIALIZED = "initialized"
|
||||
|
||||
const val COL_VIEWER = "viewer"
|
||||
|
||||
const val COL_CHAPTER_FLAGS = "chapter_flags"
|
||||
|
||||
const val COL_UNREAD = "unread"
|
||||
|
||||
const val COL_CATEGORY = "category"
|
||||
|
||||
val createTableQuery: String
|
||||
get() = """CREATE TABLE $TABLE(
|
||||
$COL_ID INTEGER NOT NULL PRIMARY KEY,
|
||||
$COL_SOURCE INTEGER NOT NULL,
|
||||
$COL_URL TEXT NOT NULL,
|
||||
$COL_ARTIST TEXT,
|
||||
$COL_AUTHOR TEXT,
|
||||
$COL_DESCRIPTION TEXT,
|
||||
$COL_GENRE TEXT,
|
||||
$COL_TITLE TEXT NOT NULL,
|
||||
$COL_STATUS INTEGER NOT NULL,
|
||||
$COL_THUMBNAIL_URL TEXT,
|
||||
$COL_FAVORITE INTEGER NOT NULL,
|
||||
$COL_LAST_UPDATE LONG,
|
||||
$COL_INITIALIZED BOOLEAN NOT NULL,
|
||||
$COL_VIEWER INTEGER NOT NULL,
|
||||
$COL_CHAPTER_FLAGS INTEGER NOT NULL
|
||||
)"""
|
||||
|
||||
val createUrlIndexQuery: String
|
||||
get() = "CREATE INDEX ${TABLE}_${COL_URL}_index ON $TABLE($COL_URL)"
|
||||
|
||||
val createFavoriteIndexQuery: String
|
||||
get() = "CREATE INDEX ${TABLE}_${COL_FAVORITE}_index ON $TABLE($COL_FAVORITE)"
|
||||
}
|
@ -1,420 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.download;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.FileReader;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
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.source.SourceManager;
|
||||
import eu.kanade.tachiyomi.data.source.base.Source;
|
||||
import eu.kanade.tachiyomi.data.source.model.Page;
|
||||
import eu.kanade.tachiyomi.event.DownloadChaptersEvent;
|
||||
import eu.kanade.tachiyomi.util.DiskUtils;
|
||||
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;
|
||||
|
||||
public class DownloadManager {
|
||||
|
||||
private Context context;
|
||||
private SourceManager sourceManager;
|
||||
private PreferencesHelper preferences;
|
||||
private Gson gson;
|
||||
|
||||
private PublishSubject<List<Download>> downloadsQueueSubject;
|
||||
private BehaviorSubject<Boolean> runningSubject;
|
||||
private Subscription downloadsSubscription;
|
||||
|
||||
private DownloadQueue queue;
|
||||
private volatile boolean isRunning;
|
||||
|
||||
public static final String PAGE_LIST_FILE = "index.json";
|
||||
|
||||
public DownloadManager(Context context, SourceManager sourceManager, PreferencesHelper preferences) {
|
||||
this.context = context;
|
||||
this.sourceManager = sourceManager;
|
||||
this.preferences = preferences;
|
||||
|
||||
gson = new Gson();
|
||||
queue = new DownloadQueue();
|
||||
|
||||
downloadsQueueSubject = PublishSubject.create();
|
||||
runningSubject = BehaviorSubject.create();
|
||||
}
|
||||
|
||||
private void initializeSubscriptions() {
|
||||
if (downloadsSubscription != null && !downloadsSubscription.isUnsubscribed())
|
||||
downloadsSubscription.unsubscribe();
|
||||
|
||||
downloadsSubscription = downloadsQueueSubject
|
||||
.concatMap(downloads -> Observable.from(downloads)
|
||||
.flatMap(this::downloadChapter, preferences.downloadThreads()))
|
||||
.onBackpressureBuffer()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.map(download -> areAllDownloadsFinished())
|
||||
.subscribe(finished -> {
|
||||
if (finished) {
|
||||
DownloadService.stop(context);
|
||||
}
|
||||
}, e -> Timber.e(e.getCause(), e.getMessage()));
|
||||
|
||||
if (!isRunning) {
|
||||
isRunning = true;
|
||||
runningSubject.onNext(true);
|
||||
}
|
||||
}
|
||||
|
||||
public void destroySubscriptions() {
|
||||
if (isRunning) {
|
||||
isRunning = false;
|
||||
runningSubject.onNext(false);
|
||||
}
|
||||
|
||||
if (downloadsSubscription != null && !downloadsSubscription.isUnsubscribed()) {
|
||||
downloadsSubscription.unsubscribe();
|
||||
downloadsSubscription = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a download object for every chapter in the event and add them to the downloads queue
|
||||
public void onDownloadChaptersEvent(DownloadChaptersEvent event) {
|
||||
final Manga manga = event.getManga();
|
||||
final Source source = sourceManager.get(manga.source);
|
||||
|
||||
// Used to avoid downloading chapters with the same name
|
||||
final List<String> addedChapters = new ArrayList<>();
|
||||
final List<Download> pending = new ArrayList<>();
|
||||
|
||||
for (Chapter chapter : event.getChapters()) {
|
||||
if (addedChapters.contains(chapter.name))
|
||||
continue;
|
||||
|
||||
addedChapters.add(chapter.name);
|
||||
Download download = new Download(source, manga, chapter);
|
||||
|
||||
if (!prepareDownload(download)) {
|
||||
queue.add(download);
|
||||
pending.add(download);
|
||||
}
|
||||
}
|
||||
if (isRunning) downloadsQueueSubject.onNext(pending);
|
||||
}
|
||||
|
||||
// Public method to check if a chapter is downloaded
|
||||
public boolean isChapterDownloaded(Source source, Manga manga, Chapter chapter) {
|
||||
File directory = getAbsoluteChapterDirectory(source, manga, chapter);
|
||||
if (!directory.exists())
|
||||
return false;
|
||||
|
||||
List<Page> pages = getSavedPageList(source, manga, chapter);
|
||||
return isChapterDownloaded(directory, pages);
|
||||
}
|
||||
|
||||
// Prepare the download. Returns true if the chapter is already downloaded
|
||||
private boolean prepareDownload(Download download) {
|
||||
// If the chapter is already queued, don't add it again
|
||||
for (Download queuedDownload : queue) {
|
||||
if (download.chapter.id.equals(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
|
||||
List<Page> savedPages = getSavedPageList(download);
|
||||
if (savedPages == null)
|
||||
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 boolean isChapterDownloaded(File directory, List<Page> pages) {
|
||||
return pages != null && !pages.isEmpty() && pages.size() + 1 == directory.listFiles().length;
|
||||
}
|
||||
|
||||
// Download the entire chapter
|
||||
private Observable<Download> downloadChapter(Download download) {
|
||||
try {
|
||||
DiskUtils.createDirectory(download.directory);
|
||||
} catch (IOException e) {
|
||||
return Observable.error(e);
|
||||
}
|
||||
|
||||
Observable<List<Page>> pageListObservable = download.pages == null ?
|
||||
// Pull page list from network and add them to download object
|
||||
download.source
|
||||
.pullPageListFromNetwork(download.chapter.url)
|
||||
.doOnNext(pages -> download.pages = pages)
|
||||
.doOnNext(pages -> savePageList(download)) :
|
||||
// Or if the page list already exists, start from the file
|
||||
Observable.just(download.pages);
|
||||
|
||||
return pageListObservable
|
||||
.subscribeOn(Schedulers.io())
|
||||
.doOnNext(pages -> {
|
||||
download.downloadedImages = 0;
|
||||
download.setStatus(Download.DOWNLOADING);
|
||||
})
|
||||
// Get all the URLs to the source images, fetch pages if necessary
|
||||
.flatMap(download.source::getAllImageUrlsFromPageList)
|
||||
// Start downloading images, consider we can have downloaded images already
|
||||
.concatMap(page -> getOrDownloadImage(page, download))
|
||||
// Do after download completes
|
||||
.doOnCompleted(() -> onDownloadCompleted(download))
|
||||
.toList()
|
||||
.map(pages -> download)
|
||||
// If the page list threw, it will resume here
|
||||
.onErrorResumeNext(error -> {
|
||||
download.setStatus(Download.ERROR);
|
||||
return Observable.just(download);
|
||||
});
|
||||
}
|
||||
|
||||
// Get the image from the filesystem if it exists or download from network
|
||||
private Observable<Page> getOrDownloadImage(final Page page, Download download) {
|
||||
// If the image URL is empty, do nothing
|
||||
if (page.getImageUrl() == null)
|
||||
return Observable.just(page);
|
||||
|
||||
String filename = getImageFilename(page);
|
||||
File imagePath = new File(download.directory, filename);
|
||||
|
||||
// If the image is already downloaded, do nothing. Otherwise download from network
|
||||
Observable<Page> pageObservable = isImageDownloaded(imagePath) ?
|
||||
Observable.just(page) :
|
||||
downloadImage(page, download.source, download.directory, filename);
|
||||
|
||||
return pageObservable
|
||||
// When the image is ready, set image path, progress (just in case) and status
|
||||
.doOnNext(p -> {
|
||||
page.setImagePath(imagePath.getAbsolutePath());
|
||||
page.setProgress(100);
|
||||
download.downloadedImages++;
|
||||
page.setStatus(Page.READY);
|
||||
})
|
||||
// Mark this page as error and allow to download the remaining
|
||||
.onErrorResumeNext(e -> {
|
||||
page.setProgress(0);
|
||||
page.setStatus(Page.ERROR);
|
||||
return Observable.just(page);
|
||||
});
|
||||
}
|
||||
|
||||
// Save image on disk
|
||||
private Observable<Page> downloadImage(Page page, Source source, File directory, String filename) {
|
||||
page.setStatus(Page.DOWNLOAD_IMAGE);
|
||||
return source.getImageProgressResponse(page)
|
||||
.flatMap(resp -> {
|
||||
try {
|
||||
DiskUtils.saveBufferedSourceToDirectory(resp.body().source(), directory, filename);
|
||||
} catch (Exception e) {
|
||||
Timber.e(e.getCause(), e.getMessage());
|
||||
return Observable.error(e);
|
||||
}
|
||||
return Observable.just(page);
|
||||
})
|
||||
.retry(2);
|
||||
}
|
||||
|
||||
// Public method to get the image from the filesystem. It does NOT provide any way to download the image
|
||||
public Observable<Page> getDownloadedImage(final Page page, File chapterDir) {
|
||||
if (page.getImageUrl() == null) {
|
||||
page.setStatus(Page.ERROR);
|
||||
return Observable.just(page);
|
||||
}
|
||||
|
||||
File imagePath = new File(chapterDir, getImageFilename(page));
|
||||
|
||||
// When the image is ready, set image path, progress (just in case) and status
|
||||
if (isImageDownloaded(imagePath)) {
|
||||
page.setImagePath(imagePath.getAbsolutePath());
|
||||
page.setProgress(100);
|
||||
page.setStatus(Page.READY);
|
||||
} else {
|
||||
page.setStatus(Page.ERROR);
|
||||
}
|
||||
return Observable.just(page);
|
||||
}
|
||||
|
||||
// Get the filename for an image given the page
|
||||
private String getImageFilename(Page page) {
|
||||
String url = page.getImageUrl();
|
||||
return Uri.parse(url).getLastPathSegment().replaceAll("[^\\sa-zA-Z0-9.-]", "_");
|
||||
}
|
||||
|
||||
private boolean isImageDownloaded(File imagePath) {
|
||||
return imagePath.exists();
|
||||
}
|
||||
|
||||
// Called when a download finishes. This doesn't mean the download was successful, so we check it
|
||||
private void onDownloadCompleted(final Download download) {
|
||||
checkDownloadIsSuccessful(download);
|
||||
savePageList(download);
|
||||
}
|
||||
|
||||
private void checkDownloadIsSuccessful(final Download download) {
|
||||
int actualProgress = 0;
|
||||
int status = Download.DOWNLOADED;
|
||||
// If any page has an error, the download result will be error
|
||||
for (Page page : download.pages) {
|
||||
actualProgress += page.getProgress();
|
||||
if (page.getStatus() != Page.READY) status = Download.ERROR;
|
||||
}
|
||||
// Ensure that the chapter folder has all the images
|
||||
if (!isChapterDownloaded(download.directory, download.pages)) {
|
||||
status = Download.ERROR;
|
||||
}
|
||||
download.totalProgress = actualProgress;
|
||||
download.setStatus(status);
|
||||
// Delete successful downloads from queue after notifying
|
||||
if (status == Download.DOWNLOADED) {
|
||||
queue.remove(download);
|
||||
}
|
||||
}
|
||||
|
||||
// Return the page list from the chapter's directory if it exists, null otherwise
|
||||
public List<Page> getSavedPageList(Source source, Manga manga, Chapter chapter) {
|
||||
List<Page> pages = null;
|
||||
File chapterDir = getAbsoluteChapterDirectory(source, manga, chapter);
|
||||
File pagesFile = new File(chapterDir, PAGE_LIST_FILE);
|
||||
|
||||
JsonReader reader = null;
|
||||
try {
|
||||
if (pagesFile.exists()) {
|
||||
reader = new JsonReader(new FileReader(pagesFile.getAbsolutePath()));
|
||||
Type collectionType = new TypeToken<List<Page>>() {}.getType();
|
||||
pages = gson.fromJson(reader, collectionType);
|
||||
}
|
||||
} catch (FileNotFoundException e) {
|
||||
Timber.e(e.getCause(), e.getMessage());
|
||||
} finally {
|
||||
if (reader != null) try { reader.close(); } catch (IOException e) { /* Do nothing */ }
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
// Shortcut for the method above
|
||||
private List<Page> getSavedPageList(Download download) {
|
||||
return getSavedPageList(download.source, download.manga, download.chapter);
|
||||
}
|
||||
|
||||
// Save the page list to the chapter's directory
|
||||
public void savePageList(Source source, Manga manga, Chapter chapter, List<Page> pages) {
|
||||
File chapterDir = getAbsoluteChapterDirectory(source, manga, chapter);
|
||||
File pagesFile = new File(chapterDir, PAGE_LIST_FILE);
|
||||
|
||||
FileOutputStream out = null;
|
||||
try {
|
||||
out = new FileOutputStream(pagesFile);
|
||||
out.write(gson.toJson(pages).getBytes());
|
||||
out.flush();
|
||||
} catch (IOException e) {
|
||||
Timber.e(e.getCause(), e.getMessage());
|
||||
} finally {
|
||||
if (out != null) try { out.close(); } catch (IOException e) { /* Do nothing */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Shortcut for the method above
|
||||
private void savePageList(Download download) {
|
||||
savePageList(download.source, download.manga, download.chapter, download.pages);
|
||||
}
|
||||
|
||||
// Get the absolute path to the chapter directory
|
||||
public File getAbsoluteChapterDirectory(Source source, Manga manga, Chapter chapter) {
|
||||
String chapterRelativePath = source.getName() +
|
||||
File.separator +
|
||||
manga.title.replaceAll("[^\\sa-zA-Z0-9.-]", "_") +
|
||||
File.separator +
|
||||
chapter.name.replaceAll("[^\\sa-zA-Z0-9.-]", "_");
|
||||
|
||||
return new File(preferences.getDownloadsDirectory(), chapterRelativePath);
|
||||
}
|
||||
|
||||
// Shortcut for the method above
|
||||
private File getAbsoluteChapterDirectory(Download download) {
|
||||
return getAbsoluteChapterDirectory(download.source, download.manga, download.chapter);
|
||||
}
|
||||
|
||||
public void deleteChapter(Source source, Manga manga, Chapter chapter) {
|
||||
File path = getAbsoluteChapterDirectory(source, manga, chapter);
|
||||
DiskUtils.deleteFiles(path);
|
||||
}
|
||||
|
||||
public DownloadQueue getQueue() {
|
||||
return queue;
|
||||
}
|
||||
|
||||
public boolean areAllDownloadsFinished() {
|
||||
for (Download download : queue) {
|
||||
if (download.getStatus() <= Download.DOWNLOADING)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean startDownloads() {
|
||||
if (queue.isEmpty())
|
||||
return false;
|
||||
|
||||
if (downloadsSubscription == null)
|
||||
initializeSubscriptions();
|
||||
|
||||
final List<Download> pending = new ArrayList<>();
|
||||
for (Download download : queue) {
|
||||
if (download.getStatus() != Download.DOWNLOADED) {
|
||||
if (download.getStatus() != Download.QUEUE) download.setStatus(Download.QUEUE);
|
||||
pending.add(download);
|
||||
}
|
||||
}
|
||||
downloadsQueueSubject.onNext(pending);
|
||||
|
||||
return !pending.isEmpty();
|
||||
}
|
||||
|
||||
public void stopDownloads() {
|
||||
destroySubscriptions();
|
||||
for (Download download : queue) {
|
||||
if (download.getStatus() == Download.DOWNLOADING) {
|
||||
download.setStatus(Download.ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public BehaviorSubject<Boolean> getRunningSubject() {
|
||||
return runningSubject;
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,449 @@
|
||||
package eu.kanade.tachiyomi.data.download
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.google.gson.Gson
|
||||
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.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.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.DiskUtils
|
||||
import eu.kanade.tachiyomi.util.DynamicConcurrentMergeOperator
|
||||
import eu.kanade.tachiyomi.util.UrlUtil
|
||||
import eu.kanade.tachiyomi.util.saveImageTo
|
||||
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 java.io.File
|
||||
import java.io.FileReader
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class DownloadManager(private val context: Context, private val sourceManager: SourceManager, private val preferences: PreferencesHelper) {
|
||||
|
||||
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)
|
||||
}
|
||||
}, { e ->
|
||||
DownloadService.stop(context)
|
||||
Timber.e(e, e.message)
|
||||
downloadNotifier.onError(e.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 = 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)
|
||||
.flatMap {
|
||||
try {
|
||||
val file = File(directory, filename)
|
||||
file.parentFile.mkdirs()
|
||||
it.body().source().saveImageTo(file.outputStream(), preferences.reencodeImage())
|
||||
} catch (e: Exception) {
|
||||
it.body().close()
|
||||
throw e
|
||||
}
|
||||
Observable.just(page)
|
||||
}
|
||||
.retryWhen {
|
||||
it.zipWith(Observable.range(1, 3)) { errors, retries -> retries }
|
||||
.flatMap { retries -> Observable.timer((retries * 2).toLong(), TimeUnit.SECONDS) }
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (e: Exception) {
|
||||
Timber.e(e, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
fun startDownloads(): Boolean {
|
||||
if (queue.isEmpty())
|
||||
return false
|
||||
|
||||
if (downloadsSubscription == null || downloadsSubscription!!.isUnsubscribed)
|
||||
initializeSubscriptions()
|
||||
|
||||
val pending = ArrayList<Download>()
|
||||
for (download in queue) {
|
||||
if (download.status != Download.DOWNLOADED) {
|
||||
if (download.status != Download.QUEUE) download.status = Download.QUEUE
|
||||
pending.add(download)
|
||||
}
|
||||
}
|
||||
downloadsQueueSubject.onNext(pending)
|
||||
|
||||
return !pending.isEmpty()
|
||||
}
|
||||
|
||||
fun stopDownloads(errorMessage: String? = null) {
|
||||
destroySubscriptions()
|
||||
for (download in queue) {
|
||||
if (download.status == Download.DOWNLOADING) {
|
||||
download.status = Download.ERROR
|
||||
}
|
||||
}
|
||||
errorMessage?.let { downloadNotifier.onError(it) }
|
||||
}
|
||||
|
||||
fun clearQueue() {
|
||||
queue.clear()
|
||||
downloadNotifier.onClear()
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,166 @@
|
||||
package eu.kanade.tachiyomi.data.download
|
||||
|
||||
import android.content.Context
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import eu.kanade.tachiyomi.Constants
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.download.model.Download
|
||||
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
||||
import eu.kanade.tachiyomi.util.notificationManager
|
||||
|
||||
/**
|
||||
* DownloadNotifier is used to show notifications when downloading one or multiple chapters.
|
||||
*
|
||||
* @param context context of application
|
||||
*/
|
||||
class DownloadNotifier(private val context: Context) {
|
||||
/**
|
||||
* Notification builder.
|
||||
*/
|
||||
private val notificationBuilder = NotificationCompat.Builder(context)
|
||||
|
||||
/**
|
||||
* Id of the notification.
|
||||
*/
|
||||
private val notificationId: Int
|
||||
get() = Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID
|
||||
|
||||
/**
|
||||
* Status of download. Used for correct notification icon.
|
||||
*/
|
||||
private var isDownloading = false
|
||||
|
||||
/**
|
||||
* The size of queue on start download.
|
||||
*/
|
||||
internal var initialQueueSize = 0
|
||||
|
||||
/**
|
||||
* Simultaneous download setting > 1.
|
||||
*/
|
||||
internal var multipleDownloadThreads = false
|
||||
|
||||
/**
|
||||
* Called when download progress changes.
|
||||
* Note: Only accepted when multi download active.
|
||||
*
|
||||
* @param queue the queue containing downloads.
|
||||
*/
|
||||
internal fun onProgressChange(queue: DownloadQueue) {
|
||||
if (multipleDownloadThreads) {
|
||||
doOnProgressChange(null, queue)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when download progress changes
|
||||
* Note: Only accepted when single download active
|
||||
*
|
||||
* @param download download object containing download information
|
||||
* @param queue the queue containing downloads
|
||||
*/
|
||||
internal fun onProgressChange(download: Download, queue: DownloadQueue) {
|
||||
if (!multipleDownloadThreads) {
|
||||
doOnProgressChange(download, queue)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show notification progress of chapter
|
||||
*
|
||||
* @param download download object containing download information
|
||||
* @param queue the queue containing downloads
|
||||
*/
|
||||
private fun doOnProgressChange(download: Download?, queue: DownloadQueue) {
|
||||
// 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
|
||||
with (notificationBuilder) {
|
||||
// Check if icon needs refresh
|
||||
if (!isDownloading) {
|
||||
setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
isDownloading = true
|
||||
}
|
||||
|
||||
if (multipleDownloadThreads) {
|
||||
setContentTitle(context.getString(R.string.app_name))
|
||||
|
||||
setContentText(context.getString(R.string.chapter_downloading_progress)
|
||||
.format(initialQueueSize - queue.size, initialQueueSize))
|
||||
setProgress(initialQueueSize, initialQueueSize - queue.size, false)
|
||||
} else {
|
||||
download?.let {
|
||||
if (it.chapter.name.length >= 33)
|
||||
setContentTitle(it.chapter.name.slice(IntRange(0, 30)).plus("..."))
|
||||
else
|
||||
setContentTitle(it.chapter.name)
|
||||
|
||||
setContentText(context.getString(R.string.chapter_downloading_progress)
|
||||
.format(it.downloadedImages, it.pages.size))
|
||||
setProgress(it.pages.size, it.downloadedImages, false)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
// Displays the progress bar on notification
|
||||
context.notificationManager.notify(notificationId, notificationBuilder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when chapter is downloaded
|
||||
*
|
||||
* @param download download object containing download information
|
||||
*/
|
||||
private fun onComplete(download: Download?) {
|
||||
// Create notification.
|
||||
with(notificationBuilder) {
|
||||
setContentTitle(download?.chapter?.name ?: context.getString(R.string.app_name))
|
||||
setContentText(context.getString(R.string.update_check_notification_download_complete))
|
||||
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
setProgress(0, 0, false)
|
||||
}
|
||||
|
||||
// Show notification.
|
||||
context.notificationManager.notify(notificationId, notificationBuilder.build())
|
||||
|
||||
// Reset initial values
|
||||
isDownloading = false
|
||||
initialQueueSize = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the notification message
|
||||
*/
|
||||
internal fun onClear() {
|
||||
context.notificationManager.cancel(notificationId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on error while downloading chapter
|
||||
*
|
||||
* @param error string containing error information
|
||||
* @param chapter string containing chapter title
|
||||
*/
|
||||
internal fun onError(error: String? = null, chapter: String? = null) {
|
||||
// Create notification
|
||||
with(notificationBuilder) {
|
||||
setContentTitle(chapter ?: context.getString(R.string.download_notifier_title_error))
|
||||
setContentText(error ?: context.getString(R.string.download_notifier_unkown_error))
|
||||
setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||
setProgress(0, 0, false)
|
||||
}
|
||||
context.notificationManager.notify(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID, notificationBuilder.build())
|
||||
isDownloading = false
|
||||
}
|
||||
}
|
@ -1,149 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.download;
|
||||
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.IBinder;
|
||||
import android.os.PowerManager;
|
||||
|
||||
import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import de.greenrobot.event.EventBus;
|
||||
import eu.kanade.tachiyomi.App;
|
||||
import eu.kanade.tachiyomi.R;
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper;
|
||||
import eu.kanade.tachiyomi.event.DownloadChaptersEvent;
|
||||
import eu.kanade.tachiyomi.util.EventBusHook;
|
||||
import eu.kanade.tachiyomi.util.ToastUtil;
|
||||
import rx.Subscription;
|
||||
import rx.android.schedulers.AndroidSchedulers;
|
||||
import rx.schedulers.Schedulers;
|
||||
|
||||
public class DownloadService extends Service {
|
||||
|
||||
@Inject DownloadManager downloadManager;
|
||||
@Inject PreferencesHelper preferences;
|
||||
|
||||
private PowerManager.WakeLock wakeLock;
|
||||
private Subscription networkChangeSubscription;
|
||||
private Subscription queueRunningSubscription;
|
||||
private boolean isRunning;
|
||||
|
||||
public static void start(Context context) {
|
||||
context.startService(new Intent(context, DownloadService.class));
|
||||
}
|
||||
|
||||
public static void stop(Context context) {
|
||||
context.stopService(new Intent(context, DownloadService.class));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
App.get(this).getComponent().inject(this);
|
||||
|
||||
createWakeLock();
|
||||
|
||||
listenQueueRunningChanges();
|
||||
EventBus.getDefault().registerSticky(this);
|
||||
listenNetworkChanges();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
EventBus.getDefault().unregister(this);
|
||||
queueRunningSubscription.unsubscribe();
|
||||
networkChangeSubscription.unsubscribe();
|
||||
downloadManager.destroySubscriptions();
|
||||
destroyWakeLock();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@EventBusHook
|
||||
public void onEvent(DownloadChaptersEvent event) {
|
||||
EventBus.getDefault().removeStickyEvent(event);
|
||||
downloadManager.onDownloadChaptersEvent(event);
|
||||
}
|
||||
|
||||
private void listenNetworkChanges() {
|
||||
networkChangeSubscription = new ReactiveNetwork().enableInternetCheck()
|
||||
.observeConnectivity(getApplicationContext())
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(state -> {
|
||||
switch (state) {
|
||||
case WIFI_CONNECTED_HAS_INTERNET:
|
||||
// If there are no remaining downloads, destroy the service
|
||||
if (!isRunning && !downloadManager.startDownloads()) {
|
||||
stopSelf();
|
||||
}
|
||||
break;
|
||||
case MOBILE_CONNECTED:
|
||||
if (!preferences.downloadOnlyOverWifi()) {
|
||||
if (!isRunning && !downloadManager.startDownloads()) {
|
||||
stopSelf();
|
||||
}
|
||||
} else if (isRunning) {
|
||||
downloadManager.stopDownloads();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (isRunning) {
|
||||
downloadManager.stopDownloads();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, error -> {
|
||||
ToastUtil.showShort(this, R.string.download_queue_error);
|
||||
stopSelf();
|
||||
});
|
||||
}
|
||||
|
||||
private void listenQueueRunningChanges() {
|
||||
queueRunningSubscription = downloadManager.getRunningSubject()
|
||||
.subscribe(running -> {
|
||||
isRunning = running;
|
||||
if (running)
|
||||
acquireWakeLock();
|
||||
else
|
||||
releaseWakeLock();
|
||||
});
|
||||
}
|
||||
|
||||
private void createWakeLock() {
|
||||
wakeLock = ((PowerManager)getSystemService(POWER_SERVICE)).newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK, "DownloadService:WakeLock");
|
||||
}
|
||||
|
||||
private void destroyWakeLock() {
|
||||
if (wakeLock != null && wakeLock.isHeld()) {
|
||||
wakeLock.release();
|
||||
wakeLock = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void acquireWakeLock() {
|
||||
if (wakeLock != null && !wakeLock.isHeld()) {
|
||||
wakeLock.acquire();
|
||||
}
|
||||
}
|
||||
|
||||
public void releaseWakeLock() {
|
||||
if (wakeLock != null && wakeLock.isHeld()) {
|
||||
wakeLock.release();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,134 @@
|
||||
package eu.kanade.tachiyomi.data.download
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import com.github.pwittchen.reactivenetwork.library.ConnectivityStatus
|
||||
import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork
|
||||
import eu.kanade.tachiyomi.App
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.util.toast
|
||||
import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import javax.inject.Inject
|
||||
|
||||
class DownloadService : Service() {
|
||||
|
||||
companion object {
|
||||
|
||||
fun start(context: Context) {
|
||||
context.startService(Intent(context, DownloadService::class.java))
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, DownloadService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
@Inject lateinit var downloadManager: DownloadManager
|
||||
@Inject lateinit var preferences: PreferencesHelper
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var networkChangeSubscription: Subscription? = null
|
||||
private var queueRunningSubscription: Subscription? = null
|
||||
private var isRunning: Boolean = false
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
App.get(this).component.inject(this)
|
||||
|
||||
createWakeLock()
|
||||
|
||||
listenQueueRunningChanges()
|
||||
listenNetworkChanges()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
return Service.START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
queueRunningSubscription?.unsubscribe()
|
||||
networkChangeSubscription?.unsubscribe()
|
||||
downloadManager.destroySubscriptions()
|
||||
destroyWakeLock()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun listenNetworkChanges() {
|
||||
networkChangeSubscription = ReactiveNetwork().enableInternetCheck()
|
||||
.observeConnectivity(applicationContext)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({ 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 ->
|
||||
toast(R.string.download_queue_error)
|
||||
stopSelf()
|
||||
})
|
||||
}
|
||||
|
||||
private fun listenQueueRunningChanges() {
|
||||
queueRunningSubscription = downloadManager.runningSubject.subscribe { running ->
|
||||
isRunning = running
|
||||
if (running)
|
||||
acquireWakeLock()
|
||||
else
|
||||
releaseWakeLock()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createWakeLock() {
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK, "DownloadService:WakeLock")
|
||||
}
|
||||
|
||||
private fun destroyWakeLock() {
|
||||
if (wakeLock != null && wakeLock!!.isHeld) {
|
||||
wakeLock!!.release()
|
||||
wakeLock = null
|
||||
}
|
||||
}
|
||||
|
||||
fun acquireWakeLock() {
|
||||
if (wakeLock != null && !wakeLock!!.isHeld) {
|
||||
wakeLock!!.acquire()
|
||||
}
|
||||
}
|
||||
|
||||
fun releaseWakeLock() {
|
||||
if (wakeLock != null && wakeLock!!.isHeld) {
|
||||
wakeLock!!.release()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -5,12 +5,12 @@ import java.util.List;
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter;
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga;
|
||||
import eu.kanade.tachiyomi.data.source.base.Source;
|
||||
import eu.kanade.tachiyomi.data.source.model.Page;
|
||||
import eu.kanade.tachiyomi.data.source.online.OnlineSource;
|
||||
import rx.subjects.PublishSubject;
|
||||
|
||||
public class Download {
|
||||
public Source source;
|
||||
public OnlineSource source;
|
||||
public Manga manga;
|
||||
public Chapter chapter;
|
||||
public List<Page> pages;
|
||||
@ -29,7 +29,7 @@ public class Download {
|
||||
public static final int ERROR = 4;
|
||||
|
||||
|
||||
public Download(Source source, Manga manga, Chapter chapter) {
|
||||
public Download(OnlineSource source, Manga manga, Chapter chapter) {
|
||||
this.source = source;
|
||||
this.manga = manga;
|
||||
this.chapter = chapter;
|
||||
|
@ -1,78 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.download.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter;
|
||||
import eu.kanade.tachiyomi.data.source.model.Page;
|
||||
import rx.Observable;
|
||||
import rx.subjects.PublishSubject;
|
||||
|
||||
public class DownloadQueue extends ArrayList<Download> {
|
||||
|
||||
private PublishSubject<Download> statusSubject;
|
||||
|
||||
public DownloadQueue() {
|
||||
super();
|
||||
statusSubject = PublishSubject.create();
|
||||
}
|
||||
|
||||
public boolean add(Download download) {
|
||||
download.setStatusSubject(statusSubject);
|
||||
download.setStatus(Download.QUEUE);
|
||||
return super.add(download);
|
||||
}
|
||||
|
||||
public void remove(Download download) {
|
||||
super.remove(download);
|
||||
download.setStatusSubject(null);
|
||||
}
|
||||
|
||||
public void remove(Chapter chapter) {
|
||||
for (Download download : this) {
|
||||
if (download.chapter.id.equals(chapter.id)) {
|
||||
remove(download);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Observable<Download> getActiveDownloads() {
|
||||
return Observable.from(this)
|
||||
.filter(download -> download.getStatus() == Download.DOWNLOADING);
|
||||
}
|
||||
|
||||
public Observable<Download> getStatusObservable() {
|
||||
return statusSubject;
|
||||
}
|
||||
|
||||
public Observable<Download> getProgressObservable() {
|
||||
return statusSubject
|
||||
.startWith(getActiveDownloads())
|
||||
.flatMap(download -> {
|
||||
if (download.getStatus() == Download.DOWNLOADING) {
|
||||
PublishSubject<Integer> pageStatusSubject = PublishSubject.create();
|
||||
setPagesSubject(download.pages, pageStatusSubject);
|
||||
return pageStatusSubject
|
||||
.filter(status -> status == Page.READY)
|
||||
.map(status -> download);
|
||||
|
||||
} else if (download.getStatus() == Download.DOWNLOADED ||
|
||||
download.getStatus() == Download.ERROR) {
|
||||
|
||||
setPagesSubject(download.pages, null);
|
||||
}
|
||||
return Observable.just(download);
|
||||
})
|
||||
.filter(download -> download.getStatus() == Download.DOWNLOADING);
|
||||
}
|
||||
|
||||
private void setPagesSubject(List<Page> pages, PublishSubject<Integer> subject) {
|
||||
if (pages != null) {
|
||||
for (Page page : pages) {
|
||||
page.setStatusSubject(subject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
package eu.kanade.tachiyomi.data.download.model
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import rx.Observable
|
||||
import rx.subjects.PublishSubject
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
class DownloadQueue : CopyOnWriteArrayList<Download>() {
|
||||
|
||||
private val statusSubject = PublishSubject.create<Download>()
|
||||
|
||||
override fun add(download: Download): Boolean {
|
||||
download.setStatusSubject(statusSubject)
|
||||
download.status = Download.QUEUE
|
||||
return super.add(download)
|
||||
}
|
||||
|
||||
fun del(download: Download) {
|
||||
super.remove(download)
|
||||
download.setStatusSubject(null)
|
||||
}
|
||||
|
||||
fun del(chapter: Chapter) {
|
||||
find { it.chapter.id == chapter.id }?.let { del(it) }
|
||||
}
|
||||
|
||||
fun getActiveDownloads() =
|
||||
Observable.from(this).filter { download -> download.status == Download.DOWNLOADING }
|
||||
|
||||
fun getStatusObservable() = statusSubject.onBackpressureBuffer()
|
||||
|
||||
fun getProgressObservable(): Observable<Download> {
|
||||
return statusSubject.onBackpressureBuffer()
|
||||
.startWith(getActiveDownloads())
|
||||
.flatMap { download ->
|
||||
if (download.status == Download.DOWNLOADING) {
|
||||
val pageStatusSubject = PublishSubject.create<Int>()
|
||||
setPagesSubject(download.pages, pageStatusSubject)
|
||||
return@flatMap pageStatusSubject
|
||||
.filter { it == Page.READY }
|
||||
.map { download }
|
||||
|
||||
} else if (download.status == Download.DOWNLOADED || download.status == Download.ERROR) {
|
||||
setPagesSubject(download.pages, null)
|
||||
}
|
||||
Observable.just(download)
|
||||
}
|
||||
.filter { it.status == Download.DOWNLOADING }
|
||||
}
|
||||
|
||||
private fun setPagesSubject(pages: List<Page>?, subject: PublishSubject<Int>?) {
|
||||
if (pages != null) {
|
||||
for (page in pages) {
|
||||
page.setStatusSubject(subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
package eu.kanade.tachiyomi.data.glide
|
||||
|
||||
import android.content.Context
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.GlideBuilder
|
||||
import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
|
||||
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
|
||||
import com.bumptech.glide.load.model.GlideUrl
|
||||
import com.bumptech.glide.module.GlideModule
|
||||
import eu.kanade.tachiyomi.App
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.network.NetworkHelper
|
||||
import java.io.InputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Class used to update Glide module settings
|
||||
*/
|
||||
class AppGlideModule : GlideModule {
|
||||
|
||||
@Inject lateinit var networkHelper: NetworkHelper
|
||||
|
||||
override fun applyOptions(context: Context, builder: GlideBuilder) {
|
||||
// Set the cache size of Glide to 15 MiB
|
||||
builder.setDiskCache(InternalCacheDiskCacheFactory(context, 15 * 1024 * 1024))
|
||||
}
|
||||
|
||||
override fun registerComponents(context: Context, glide: Glide) {
|
||||
App.get(context).component.inject(this)
|
||||
glide.register(GlideUrl::class.java, InputStream::class.java,
|
||||
OkHttpUrlLoader.Factory(networkHelper.client))
|
||||
glide.register(Manga::class.java, InputStream::class.java, MangaModelLoader.Factory())
|
||||
}
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
package eu.kanade.tachiyomi.data.glide
|
||||
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* A [DataFetcher] for loading a cover of a manga depending on its favorite status.
|
||||
* If the manga is favorite, it tries to load the cover from our cache, and if it's not found, it
|
||||
* fallbacks to network and copies it to the cache.
|
||||
* If the manga is not favorite, it tries to delete the cover from our cache and always fallback
|
||||
* to network for fetching.
|
||||
*
|
||||
* @param networkFetcher the network fetcher for this cover.
|
||||
* @param file the file where this cover should be. It may exists or not.
|
||||
* @param manga the manga of the cover to load.
|
||||
*/
|
||||
class MangaDataFetcher(private val networkFetcher: DataFetcher<InputStream>,
|
||||
private val file: File,
|
||||
private val manga: Manga)
|
||||
: DataFetcher<InputStream> {
|
||||
|
||||
@Throws(Exception::class)
|
||||
override fun loadData(priority: Priority): InputStream? {
|
||||
if (manga.favorite) {
|
||||
if (!file.exists()) {
|
||||
file.parentFile.mkdirs()
|
||||
networkFetcher.loadData(priority)?.let {
|
||||
it.use { input ->
|
||||
file.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return FileInputStream(file)
|
||||
} else {
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
}
|
||||
return networkFetcher.loadData(priority)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the id for this manga's cover.
|
||||
*
|
||||
* Appending the file's modified date to the url, we can force Glide to skip its memory and disk
|
||||
* lookup step and fetch from our custom cache. This allows us to invalidate Glide's cache when
|
||||
* the file has changed. If the file doesn't exist it will append a 0.
|
||||
*/
|
||||
override fun getId(): String {
|
||||
return manga.thumbnail_url + file.lastModified()
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
networkFetcher.cancel()
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
networkFetcher.cleanup()
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,119 @@
|
||||
package eu.kanade.tachiyomi.data.glide
|
||||
|
||||
import android.content.Context
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import com.bumptech.glide.load.model.*
|
||||
import com.bumptech.glide.load.model.stream.StreamModelLoader
|
||||
import eu.kanade.tachiyomi.App
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.source.SourceManager
|
||||
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* 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:
|
||||
*
|
||||
* - Check in RAM LRU.
|
||||
* - Check in disk LRU.
|
||||
* - Check in this module.
|
||||
* - Fetch from the network connection.
|
||||
*
|
||||
* @param context the application context.
|
||||
*/
|
||||
class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
|
||||
|
||||
/**
|
||||
* Cover cache where persistent covers are stored.
|
||||
*/
|
||||
@Inject lateinit var coverCache: CoverCache
|
||||
|
||||
/**
|
||||
* Source manager.
|
||||
*/
|
||||
@Inject lateinit var sourceManager: SourceManager
|
||||
|
||||
/**
|
||||
* Base network loader.
|
||||
*/
|
||||
private val baseLoader = Glide.buildModelLoader(GlideUrl::class.java,
|
||||
InputStream::class.java, context)
|
||||
|
||||
/**
|
||||
* LRU cache whose key is the thumbnail url of the manga, and the value contains the request url
|
||||
* and the file where it should be stored in case the manga is a favorite.
|
||||
*/
|
||||
private val modelCache = ModelCache<String, Pair<GlideUrl, File>>(100)
|
||||
|
||||
/**
|
||||
* Map where request headers are stored for a source.
|
||||
*/
|
||||
private val cachedHeaders = hashMapOf<Int, LazyHeaders>()
|
||||
|
||||
init {
|
||||
App.get(context).component.inject(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory class for creating [MangaModelLoader] instances.
|
||||
*/
|
||||
class Factory : ModelLoaderFactory<Manga, InputStream> {
|
||||
|
||||
override fun build(context: Context, factories: GenericLoaderFactory)
|
||||
= MangaModelLoader(context)
|
||||
|
||||
override fun teardown() {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a [MangaDataFetcher] for the given manga or null if the url is empty.
|
||||
*
|
||||
* @param manga the model.
|
||||
* @param width the width of the view where the resource will be loaded.
|
||||
* @param height the height of the view where the resource will be loaded.
|
||||
*/
|
||||
override fun getResourceFetcher(manga: Manga,
|
||||
width: Int,
|
||||
height: Int): DataFetcher<InputStream>? {
|
||||
// Check thumbnail is not null or empty
|
||||
val url = manga.thumbnail_url
|
||||
if (url.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Obtain the request url and the file for this url from the LRU cache, or calculate it
|
||||
// and add them to the cache.
|
||||
val (glideUrl, file) = modelCache.get(url, width, height) ?:
|
||||
Pair(GlideUrl(url, getHeaders(manga)), coverCache.getCoverFile(url)).apply {
|
||||
modelCache.put(url, width, height, this)
|
||||
}
|
||||
|
||||
// Get the network fetcher for this request url.
|
||||
val networkFetcher = baseLoader.getResourceFetcher(glideUrl, width, height)
|
||||
|
||||
// Return an instance of our fetcher providing the needed elements.
|
||||
return MangaDataFetcher(networkFetcher, file, manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request headers for a source copying its OkHttp headers and caching them.
|
||||
*
|
||||
* @param manga the model.
|
||||
*/
|
||||
fun getHeaders(manga: Manga): Headers {
|
||||
val source = sourceManager.get(manga.source) as? OnlineSource ?: return LazyHeaders.DEFAULT
|
||||
return cachedHeaders.getOrPut(manga.source) {
|
||||
LazyHeaders.Builder().apply {
|
||||
setHeader("User-Agent", null as? String)
|
||||
for ((key, value) in source.headers.toMultimap()) {
|
||||
addHeader(key, value[0])
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
package eu.kanade.tachiyomi.data.library
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.util.alarmManager
|
||||
|
||||
/**
|
||||
* This class is used to update the library by firing an alarm after a specified time.
|
||||
* It has a receiver reacting to system's boot and the intent fired by this alarm.
|
||||
* See [onReceive] for more information.
|
||||
*/
|
||||
class LibraryUpdateAlarm : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
const val LIBRARY_UPDATE_ACTION = "eu.kanade.UPDATE_LIBRARY"
|
||||
|
||||
/**
|
||||
* Sets the alarm to run the intent that updates the library.
|
||||
* @param context the application context.
|
||||
* @param intervalInHours the time in hours when it will be executed. Defaults to the
|
||||
* value stored in preferences.
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun startAlarm(context: Context,
|
||||
intervalInHours: Int = PreferencesHelper.getLibraryUpdateInterval(context)) {
|
||||
// Stop previous running alarms if needed, and do not restart it if the interval is 0.
|
||||
stopAlarm(context)
|
||||
if (intervalInHours == 0)
|
||||
return
|
||||
|
||||
// Get the time the alarm should fire the event to update.
|
||||
val intervalInMillis = intervalInHours * 60 * 60 * 1000
|
||||
val nextRun = SystemClock.elapsedRealtime() + intervalInMillis
|
||||
|
||||
// Start the alarm.
|
||||
val pendingIntent = getPendingIntent(context)
|
||||
context.alarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP,
|
||||
nextRun, intervalInMillis.toLong(), pendingIntent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the alarm if it's running.
|
||||
* @param context the application context.
|
||||
*/
|
||||
fun stopAlarm(context: Context) {
|
||||
val pendingIntent = getPendingIntent(context)
|
||||
context.alarmManager.cancel(pendingIntent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the intent the alarm should run when it's fired.
|
||||
* @param context the application context.
|
||||
* @return the intent that will run when the alarm is fired.
|
||||
*/
|
||||
private fun getPendingIntent(context: Context): PendingIntent {
|
||||
val intent = Intent(context, LibraryUpdateAlarm::class.java)
|
||||
intent.action = LIBRARY_UPDATE_ACTION
|
||||
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the intents received by this [BroadcastReceiver].
|
||||
* @param context the application context.
|
||||
* @param intent the intent to process.
|
||||
*/
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
// Start the alarm when the system is booted.
|
||||
Intent.ACTION_BOOT_COMPLETED -> startAlarm(context)
|
||||
// Update the library when the alarm fires an event.
|
||||
LIBRARY_UPDATE_ACTION -> LibraryUpdateService.start(context)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,463 @@
|
||||
package eu.kanade.tachiyomi.data.library
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import com.github.pwittchen.reactivenetwork.library.ConnectivityStatus
|
||||
import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork
|
||||
import eu.kanade.tachiyomi.App
|
||||
import eu.kanade.tachiyomi.Constants
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.source.SourceManager
|
||||
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||
import eu.kanade.tachiyomi.util.*
|
||||
import rx.Observable
|
||||
import rx.Subscription
|
||||
import rx.schedulers.Schedulers
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* This class will take care of updating the chapters of the manga from the library. It can be
|
||||
* started calling the [start] method. If it's already running, it won't do anything.
|
||||
* While the library is updating, a [PowerManager.WakeLock] will be held until the update is
|
||||
* completed, preventing the device from going to sleep mode. A notification will display the
|
||||
* progress of the update, and if case of an unexpected error, this service will be silently
|
||||
* destroyed.
|
||||
*/
|
||||
class LibraryUpdateService : Service() {
|
||||
|
||||
/**
|
||||
* Database helper.
|
||||
*/
|
||||
@Inject lateinit var db: DatabaseHelper
|
||||
|
||||
/**
|
||||
* Source manager.
|
||||
*/
|
||||
@Inject lateinit var sourceManager: SourceManager
|
||||
|
||||
/**
|
||||
* Preferences.
|
||||
*/
|
||||
@Inject lateinit var preferences: PreferencesHelper
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Id of the library update notification.
|
||||
*/
|
||||
private val notificationId: Int
|
||||
get() = Constants.NOTIFICATION_LIBRARY_ID
|
||||
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Key for manual library update.
|
||||
*/
|
||||
const val UPDATE_IS_MANUAL = "is_manual"
|
||||
|
||||
/**
|
||||
* Key for category to update.
|
||||
*/
|
||||
const val UPDATE_CATEGORY = "category"
|
||||
|
||||
/**
|
||||
* 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 AndroidComponentUtil.isServiceRunning(context, LibraryUpdateService::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the service. It will be started only if there isn't another instance already
|
||||
* running.
|
||||
*
|
||||
* @param context the application context.
|
||||
* @param isManual whether the update has been manually triggered.
|
||||
* @param category a specific category to update, or null for all in the library.
|
||||
*/
|
||||
fun start(context: Context, isManual: Boolean = false, category: Category? = null) {
|
||||
if (!isRunning(context)) {
|
||||
val intent = Intent(context, LibraryUpdateService::class.java).apply {
|
||||
putExtra(UPDATE_IS_MANUAL, isManual)
|
||||
category?.let { putExtra(UPDATE_CATEGORY, it.id) }
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the service.
|
||||
*
|
||||
* @param context the application context.
|
||||
*/
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, LibraryUpdateService::class.java))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Method called when the service is created. It injects dagger dependencies and acquire
|
||||
* the wake lock.
|
||||
*/
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
App.get(this).component.inject(this)
|
||||
createAndAcquireWakeLock()
|
||||
}
|
||||
|
||||
/**
|
||||
* Method called when the service is destroyed. It destroys the running subscription, resets
|
||||
* the alarm and release the wake lock.
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
subscription?.unsubscribe()
|
||||
LibraryUpdateAlarm.startAlarm(this)
|
||||
destroyWakeLock()
|
||||
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 {
|
||||
|
||||
// Get connectivity status
|
||||
val connection = ReactiveNetwork().getConnectivityStatus(this, true)
|
||||
|
||||
// Get library update restrictions
|
||||
val restrictions = preferences.libraryUpdateRestriction()
|
||||
|
||||
// Check if users updates library manual
|
||||
val isManualUpdate = intent?.getBooleanExtra(UPDATE_IS_MANUAL, false) ?: false
|
||||
|
||||
// Whether to cancel the update.
|
||||
var cancelUpdate = false
|
||||
|
||||
// Check if device has internet connection
|
||||
// Check if device has wifi connection if only wifi is enabled
|
||||
if (connection == ConnectivityStatus.OFFLINE || (!isManualUpdate && "wifi" in restrictions
|
||||
&& connection != ConnectivityStatus.WIFI_CONNECTED_HAS_INTERNET)) {
|
||||
|
||||
if (isManualUpdate) {
|
||||
toast(R.string.notification_no_connection_title)
|
||||
}
|
||||
|
||||
// Enable library update when connection available
|
||||
AndroidComponentUtil.toggleComponent(this, SyncOnConnectionAvailable::class.java, true)
|
||||
cancelUpdate = true
|
||||
}
|
||||
if (!isManualUpdate && "ac" in restrictions && !DeviceUtil.isPowerConnected(this)) {
|
||||
AndroidComponentUtil.toggleComponent(this, SyncOnPowerConnected::class.java, true)
|
||||
cancelUpdate = true
|
||||
}
|
||||
|
||||
if (cancelUpdate) {
|
||||
stopSelf(startId)
|
||||
return Service.START_NOT_STICKY
|
||||
}
|
||||
|
||||
// Stop enabled components.
|
||||
AndroidComponentUtil.toggleComponent(this, SyncOnConnectionAvailable::class.java, false)
|
||||
AndroidComponentUtil.toggleComponent(this, SyncOnPowerConnected::class.java, false)
|
||||
|
||||
// Unsubscribe from any previous subscription if needed.
|
||||
subscription?.unsubscribe()
|
||||
|
||||
// Update favorite manga. Destroy service when completed or in case of an error.
|
||||
subscription = Observable.defer { updateMangaList(getMangaToUpdate(intent)) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe({},
|
||||
{
|
||||
showNotification(getString(R.string.notification_update_error), "")
|
||||
stopSelf(startId)
|
||||
}, {
|
||||
stopSelf(startId)
|
||||
})
|
||||
|
||||
return Service.START_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of manga to be updated.
|
||||
*
|
||||
* @param intent the update intent.
|
||||
* @return a list of manga to update
|
||||
*/
|
||||
fun getMangaToUpdate(intent: Intent?): List<Manga> {
|
||||
val categoryId = intent?.getIntExtra(UPDATE_CATEGORY, -1) ?: -1
|
||||
|
||||
var toUpdate = if (categoryId != -1)
|
||||
db.getLibraryMangas().executeAsBlocking().filter { it.category == categoryId }
|
||||
else
|
||||
db.getFavoriteMangas().executeAsBlocking()
|
||||
|
||||
if (preferences.updateOnlyNonCompleted()) {
|
||||
toUpdate = toUpdate.filter { it.status != Manga.COMPLETED }
|
||||
}
|
||||
|
||||
return toUpdate
|
||||
}
|
||||
|
||||
/**
|
||||
* Method that updates the given list of manga. It's called in a background 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
|
||||
* @return an observable delivering the progress of each update.
|
||||
*/
|
||||
fun updateMangaList(mangaToUpdate: List<Manga>): Observable<Manga> {
|
||||
// Initialize the variables holding the progress of the updates.
|
||||
val count = AtomicInteger(0)
|
||||
val newUpdates = ArrayList<Manga>()
|
||||
val failedUpdates = ArrayList<Manga>()
|
||||
|
||||
val cancelIntent = PendingIntent.getBroadcast(this, 0,
|
||||
Intent(this, CancelUpdateReceiver::class.java), 0)
|
||||
|
||||
// Emit each manga and update it sequentially.
|
||||
return Observable.from(mangaToUpdate)
|
||||
// Notify manga that will update.
|
||||
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size, cancelIntent) }
|
||||
// Update the chapters of the manga.
|
||||
.concatMap { manga ->
|
||||
updateManga(manga)
|
||||
// If there's any error, return empty update and continue.
|
||||
.onErrorReturn {
|
||||
failedUpdates.add(manga)
|
||||
Pair(0, 0)
|
||||
}
|
||||
// Filter out mangas without new chapters (or failed).
|
||||
.filter { pair -> pair.first > 0 }
|
||||
// Convert to the manga that contains new chapters.
|
||||
.map { manga }
|
||||
}
|
||||
// Add manga with new chapters to the list.
|
||||
.doOnNext { newUpdates.add(it) }
|
||||
// Notify result of the overall update.
|
||||
.doOnCompleted {
|
||||
if (newUpdates.isEmpty()) {
|
||||
cancelNotification()
|
||||
} else {
|
||||
showResultNotification(newUpdates, failedUpdates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the chapters for the given manga and adds them to the database.
|
||||
*
|
||||
* @param manga the manga to update.
|
||||
* @return a pair of the inserted and removed chapters.
|
||||
*/
|
||||
fun updateManga(manga: Manga): Observable<Pair<Int, Int>> {
|
||||
val source = sourceManager.get(manga.source) as? OnlineSource ?: return Observable.empty()
|
||||
return source.fetchChapterList(manga)
|
||||
.map { syncChaptersWithSource(db, it, manga, source) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the text that will be displayed in the notification when there are new chapters.
|
||||
*
|
||||
* @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 {
|
||||
return with(StringBuilder()) {
|
||||
if (updates.isEmpty()) {
|
||||
append(getString(R.string.notification_no_new_chapters))
|
||||
append("\n")
|
||||
} else {
|
||||
append(getString(R.string.notification_new_chapters))
|
||||
for (manga in updates) {
|
||||
append("\n")
|
||||
append(manga.title)
|
||||
}
|
||||
}
|
||||
if (!failedUpdates.isEmpty()) {
|
||||
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)
|
||||
setContentTitle(title)
|
||||
setContentText(body)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the notification containing the currently updating manga and the progress.
|
||||
*
|
||||
* @param manga the manga that's being updated.
|
||||
* @param current the current progress.
|
||||
* @param total the total progress.
|
||||
*/
|
||||
private fun showProgressNotification(manga: Manga, current: Int, total: Int, cancelIntent: PendingIntent) {
|
||||
notificationManager.notify(notificationId, notification() {
|
||||
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
|
||||
setContentTitle(manga.title)
|
||||
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.
|
||||
*
|
||||
* @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>) {
|
||||
val title = getString(R.string.notification_update_completed)
|
||||
val body = getUpdatedMangasBody(updates, failed)
|
||||
|
||||
notificationManager.notify(notificationId, notification() {
|
||||
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
|
||||
setContentTitle(title)
|
||||
setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
setContentIntent(notificationIntent)
|
||||
setAutoCancel(true)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the notification.
|
||||
*/
|
||||
private fun cancelNotification() {
|
||||
notificationManager.cancel(notificationId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Property that returns an intent to open the main activity.
|
||||
*/
|
||||
private val notificationIntent: PendingIntent
|
||||
get() {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Class that triggers the library to update when a connection is available. It receives
|
||||
* network changes.
|
||||
*/
|
||||
class SyncOnConnectionAvailable : BroadcastReceiver() {
|
||||
/**
|
||||
* Method called when a network change occurs.
|
||||
*
|
||||
* @param context the application context.
|
||||
* @param intent the intent received.
|
||||
*/
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (DeviceUtil.isNetworkConnected(context)) {
|
||||
AndroidComponentUtil.toggleComponent(context, this.javaClass, false)
|
||||
start(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class that triggers the library to update when connected to power.
|
||||
*/
|
||||
class SyncOnPowerConnected: BroadcastReceiver() {
|
||||
/**
|
||||
* Method called when AC is connected.
|
||||
*
|
||||
* @param context the application context.
|
||||
* @param intent the intent received.
|
||||
*/
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
AndroidComponentUtil.toggleComponent(context, this.javaClass, false)
|
||||
start(context)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,40 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.mangasync;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import eu.kanade.tachiyomi.data.mangasync.base.MangaSyncService;
|
||||
import eu.kanade.tachiyomi.data.mangasync.services.MyAnimeList;
|
||||
|
||||
public class MangaSyncManager {
|
||||
|
||||
private List<MangaSyncService> services;
|
||||
private MyAnimeList myAnimeList;
|
||||
|
||||
public static final int MYANIMELIST = 1;
|
||||
|
||||
public MangaSyncManager(Context context) {
|
||||
services = new ArrayList<>();
|
||||
myAnimeList = new MyAnimeList(context);
|
||||
services.add(myAnimeList);
|
||||
}
|
||||
|
||||
public MyAnimeList getMyAnimeList() {
|
||||
return myAnimeList;
|
||||
}
|
||||
|
||||
public List<MangaSyncService> getSyncServices() {
|
||||
return services;
|
||||
}
|
||||
|
||||
public MangaSyncService getSyncService(int id) {
|
||||
switch (id) {
|
||||
case MYANIMELIST:
|
||||
return myAnimeList;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
package eu.kanade.tachiyomi.data.mangasync
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.data.mangasync.myanimelist.MyAnimeList
|
||||
|
||||
class MangaSyncManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
const val MYANIMELIST = 1
|
||||
}
|
||||
|
||||
val myAnimeList = MyAnimeList(context, MYANIMELIST)
|
||||
|
||||
val services = listOf(myAnimeList)
|
||||
|
||||
fun getService(id: Int) = services.find { it.id == id }
|
||||
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
package eu.kanade.tachiyomi.data.mangasync
|
||||
|
||||
import android.content.Context
|
||||
import android.support.annotation.CallSuper
|
||||
import eu.kanade.tachiyomi.App
|
||||
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 javax.inject.Inject
|
||||
|
||||
abstract class MangaSyncService(private val context: Context, val id: Int) {
|
||||
|
||||
@Inject lateinit var preferences: PreferencesHelper
|
||||
@Inject lateinit var networkService: NetworkHelper
|
||||
|
||||
init {
|
||||
App.get(context).component.inject(this)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
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.App
|
||||
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 javax.inject.Inject
|
||||
|
||||
class UpdateMangaSyncService : Service() {
|
||||
|
||||
@Inject lateinit var syncManager: MangaSyncManager
|
||||
@Inject lateinit var db: DatabaseHelper
|
||||
|
||||
private lateinit var subscriptions: CompositeSubscription
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
App.get(this).component.inject(this)
|
||||
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,28 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.mangasync.base;
|
||||
|
||||
import com.squareup.okhttp.Response;
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSync;
|
||||
import rx.Observable;
|
||||
|
||||
public abstract class MangaSyncService {
|
||||
|
||||
// Name of the manga sync service to display
|
||||
public abstract String getName();
|
||||
|
||||
// Id of the sync service (must be declared and obtained from MangaSyncManager to avoid conflicts)
|
||||
public abstract int getId();
|
||||
|
||||
public abstract Observable<Boolean> login(String username, String password);
|
||||
|
||||
public abstract boolean isLogged();
|
||||
|
||||
public abstract Observable<Response> update(MangaSync manga);
|
||||
|
||||
public abstract Observable<Response> add(MangaSync manga);
|
||||
|
||||
public abstract Observable<Response> bind(MangaSync manga);
|
||||
|
||||
public abstract String getStatus(int status);
|
||||
|
||||
}
|
@ -0,0 +1,222 @@
|
||||
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 = "http://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(this).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(this).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()
|
||||
}
|
||||
|
||||
}
|
@ -1,264 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.mangasync.services;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.util.Xml;
|
||||
|
||||
import com.squareup.okhttp.Credentials;
|
||||
import com.squareup.okhttp.FormEncodingBuilder;
|
||||
import com.squareup.okhttp.Headers;
|
||||
import com.squareup.okhttp.RequestBody;
|
||||
import com.squareup.okhttp.Response;
|
||||
|
||||
import org.jsoup.Jsoup;
|
||||
import org.xmlpull.v1.XmlSerializer;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.util.List;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import eu.kanade.tachiyomi.App;
|
||||
import eu.kanade.tachiyomi.R;
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSync;
|
||||
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager;
|
||||
import eu.kanade.tachiyomi.data.mangasync.base.MangaSyncService;
|
||||
import eu.kanade.tachiyomi.data.network.NetworkHelper;
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper;
|
||||
import rx.Observable;
|
||||
|
||||
public class MyAnimeList extends MangaSyncService {
|
||||
|
||||
@Inject PreferencesHelper preferences;
|
||||
@Inject NetworkHelper networkService;
|
||||
|
||||
private Headers headers;
|
||||
private String username;
|
||||
|
||||
public static final String BASE_URL = "http://myanimelist.net";
|
||||
|
||||
private static final String ENTRY_TAG = "entry";
|
||||
private static final String CHAPTER_TAG = "chapter";
|
||||
private static final String SCORE_TAG = "score";
|
||||
private static final String STATUS_TAG = "status";
|
||||
|
||||
public static final int READING = 1;
|
||||
public static final int COMPLETED = 2;
|
||||
public static final int ON_HOLD = 3;
|
||||
public static final int DROPPED = 4;
|
||||
public static final int PLAN_TO_READ = 6;
|
||||
|
||||
public static final int DEFAULT_STATUS = READING;
|
||||
public static final int DEFAULT_SCORE = 0;
|
||||
|
||||
private Context context;
|
||||
|
||||
public MyAnimeList(Context context) {
|
||||
this.context = context;
|
||||
App.get(context).getComponent().inject(this);
|
||||
|
||||
String username = preferences.getMangaSyncUsername(this);
|
||||
String password = preferences.getMangaSyncPassword(this);
|
||||
|
||||
if (!username.isEmpty() && !password.isEmpty()) {
|
||||
createHeaders(username, password);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "MyAnimeList";
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getId() {
|
||||
return MangaSyncManager.MYANIMELIST;
|
||||
}
|
||||
|
||||
public String getLoginUrl() {
|
||||
return Uri.parse(BASE_URL).buildUpon()
|
||||
.appendEncodedPath("api/account/verify_credentials.xml")
|
||||
.toString();
|
||||
}
|
||||
|
||||
public Observable<Boolean> login(String username, String password) {
|
||||
createHeaders(username, password);
|
||||
return networkService.getResponse(getLoginUrl(), headers, null)
|
||||
.map(response -> response.code() == 200);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isLogged() {
|
||||
return !preferences.getMangaSyncUsername(this).isEmpty()
|
||||
&& !preferences.getMangaSyncPassword(this).isEmpty();
|
||||
}
|
||||
|
||||
public String getSearchUrl(String query) {
|
||||
return Uri.parse(BASE_URL).buildUpon()
|
||||
.appendEncodedPath("api/manga/search.xml")
|
||||
.appendQueryParameter("q", query)
|
||||
.toString();
|
||||
}
|
||||
|
||||
public Observable<List<MangaSync>> search(String query) {
|
||||
return networkService.getStringResponse(getSearchUrl(query), headers, null)
|
||||
.map(Jsoup::parse)
|
||||
.flatMap(doc -> Observable.from(doc.select("entry")))
|
||||
.filter(entry -> !entry.select("type").text().equals("Novel"))
|
||||
.map(entry -> {
|
||||
MangaSync manga = MangaSync.create(this);
|
||||
manga.title = entry.select("title").first().text();
|
||||
manga.remote_id = Integer.parseInt(entry.select("id").first().text());
|
||||
manga.total_chapters = Integer.parseInt(entry.select("chapters").first().text());
|
||||
return manga;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
public String getListUrl(String username) {
|
||||
return Uri.parse(BASE_URL).buildUpon()
|
||||
.appendPath("malappinfo.php")
|
||||
.appendQueryParameter("u", username)
|
||||
.appendQueryParameter("status", "all")
|
||||
.appendQueryParameter("type", "manga")
|
||||
.toString();
|
||||
}
|
||||
|
||||
public Observable<List<MangaSync>> getList() {
|
||||
// TODO cache this list for a few minutes
|
||||
return networkService.getStringResponse(getListUrl(username), headers, null)
|
||||
.map(Jsoup::parse)
|
||||
.flatMap(doc -> Observable.from(doc.select("manga")))
|
||||
.map(entry -> {
|
||||
MangaSync manga = MangaSync.create(this);
|
||||
manga.title = entry.select("series_title").first().text();
|
||||
manga.remote_id = Integer.parseInt(
|
||||
entry.select("series_mangadb_id").first().text());
|
||||
manga.last_chapter_read = Integer.parseInt(
|
||||
entry.select("my_read_chapters").first().text());
|
||||
manga.status = Integer.parseInt(
|
||||
entry.select("my_status").first().text());
|
||||
// MAL doesn't support score with decimals
|
||||
manga.score = Integer.parseInt(
|
||||
entry.select("my_score").first().text());
|
||||
manga.total_chapters = Integer.parseInt(
|
||||
entry.select("series_chapters").first().text());
|
||||
return manga;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
public String getUpdateUrl(MangaSync manga) {
|
||||
return Uri.parse(BASE_URL).buildUpon()
|
||||
.appendEncodedPath("api/mangalist/update")
|
||||
.appendPath(manga.remote_id + ".xml")
|
||||
.toString();
|
||||
}
|
||||
|
||||
public Observable<Response> update(MangaSync manga) {
|
||||
try {
|
||||
if (manga.total_chapters != 0 && manga.last_chapter_read == manga.total_chapters) {
|
||||
manga.status = COMPLETED;
|
||||
}
|
||||
RequestBody payload = getMangaPostPayload(manga);
|
||||
return networkService.postData(getUpdateUrl(manga), payload, headers);
|
||||
} catch (IOException e) {
|
||||
return Observable.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
public String getAddUrl(MangaSync manga) {
|
||||
return Uri.parse(BASE_URL).buildUpon()
|
||||
.appendEncodedPath("api/mangalist/add")
|
||||
.appendPath(manga.remote_id + ".xml")
|
||||
.toString();
|
||||
}
|
||||
|
||||
public Observable<Response> add(MangaSync manga) {
|
||||
try {
|
||||
RequestBody payload = getMangaPostPayload(manga);
|
||||
return networkService.postData(getAddUrl(manga), payload, headers);
|
||||
} catch (IOException e) {
|
||||
return Observable.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
private RequestBody getMangaPostPayload(MangaSync manga) throws IOException {
|
||||
XmlSerializer xml = Xml.newSerializer();
|
||||
StringWriter writer = new StringWriter();
|
||||
xml.setOutput(writer);
|
||||
xml.startDocument("UTF-8", false);
|
||||
xml.startTag("", ENTRY_TAG);
|
||||
|
||||
// Last chapter read
|
||||
if (manga.last_chapter_read != 0) {
|
||||
xml.startTag("", CHAPTER_TAG);
|
||||
xml.text(manga.last_chapter_read + "");
|
||||
xml.endTag("", CHAPTER_TAG);
|
||||
}
|
||||
// Manga status in the list
|
||||
xml.startTag("", STATUS_TAG);
|
||||
xml.text(manga.status + "");
|
||||
xml.endTag("", STATUS_TAG);
|
||||
// Manga score
|
||||
xml.startTag("", SCORE_TAG);
|
||||
xml.text(manga.score + "");
|
||||
xml.endTag("", SCORE_TAG);
|
||||
|
||||
xml.endTag("", ENTRY_TAG);
|
||||
xml.endDocument();
|
||||
|
||||
FormEncodingBuilder form = new FormEncodingBuilder();
|
||||
form.add("data", writer.toString());
|
||||
return form.build();
|
||||
}
|
||||
|
||||
public Observable<Response> bind(MangaSync manga) {
|
||||
return getList()
|
||||
.flatMap(list -> {
|
||||
manga.sync_id = getId();
|
||||
for (MangaSync remoteManga : list) {
|
||||
if (remoteManga.remote_id == manga.remote_id) {
|
||||
// Manga is already in the list
|
||||
manga.copyPersonalFrom(remoteManga);
|
||||
return update(manga);
|
||||
}
|
||||
}
|
||||
// Set default fields if it's not found in the list
|
||||
manga.score = DEFAULT_SCORE;
|
||||
manga.status = DEFAULT_STATUS;
|
||||
return add(manga);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getStatus(int status) {
|
||||
switch (status) {
|
||||
case READING:
|
||||
return context.getString(R.string.reading);
|
||||
case COMPLETED:
|
||||
return context.getString(R.string.completed);
|
||||
case ON_HOLD:
|
||||
return context.getString(R.string.on_hold);
|
||||
case DROPPED:
|
||||
return context.getString(R.string.dropped);
|
||||
case PLAN_TO_READ:
|
||||
return context.getString(R.string.plan_to_read);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
public void createHeaders(String username, String password) {
|
||||
this.username = username;
|
||||
Headers.Builder builder = new Headers.Builder();
|
||||
builder.add("Authorization", Credentials.basic(username, password));
|
||||
builder.add("User-Agent", "api-indiv-9F93C52A963974CF674325391990191C");
|
||||
setHeaders(builder.build());
|
||||
}
|
||||
|
||||
public void setHeaders(Headers headers) {
|
||||
this.headers = headers;
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,81 @@
|
||||
package eu.kanade.tachiyomi.data.network
|
||||
|
||||
import com.squareup.duktape.Duktape
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
class CloudflareInterceptor(private val cookies: PersistentCookieStore) : Interceptor {
|
||||
|
||||
//language=RegExp
|
||||
private val operationPattern = Regex("""setTimeout\(function\(\)\{\s+(var (?:\w,)+f.+?\r?\n[\s\S]+?a\.value =.+?)\r?\n""")
|
||||
|
||||
//language=RegExp
|
||||
private val passPattern = Regex("""name="pass" value="(.+?)"""")
|
||||
|
||||
//language=RegExp
|
||||
private val challengePattern = Regex("""name="jschl_vc" value="(\w+)"""")
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val response = chain.proceed(chain.request())
|
||||
|
||||
// Check if we already solved a challenge
|
||||
if (response.code() != 503 &&
|
||||
cookies.get(response.request().url()).any { it.name() == "cf_clearance" }) {
|
||||
return response
|
||||
}
|
||||
|
||||
// Check if Cloudflare anti-bot is on
|
||||
if ("URL=/cdn-cgi/" in response.header("Refresh", "")
|
||||
&& response.header("Server", "") == "cloudflare-nginx") {
|
||||
return chain.proceed(resolveChallenge(response))
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private fun resolveChallenge(response: Response): Request {
|
||||
val duktape = Duktape.create()
|
||||
try {
|
||||
val originalRequest = response.request()
|
||||
val domain = originalRequest.url().host()
|
||||
val content = response.body().string()
|
||||
|
||||
// CloudFlare requires waiting 4 seconds before resolving the challenge
|
||||
Thread.sleep(4000)
|
||||
|
||||
val operation = operationPattern.find(content)?.groups?.get(1)?.value
|
||||
val challenge = challengePattern.find(content)?.groups?.get(1)?.value
|
||||
val pass = passPattern.find(content)?.groups?.get(1)?.value
|
||||
|
||||
if (operation == null || challenge == null || pass == null) {
|
||||
throw RuntimeException("Failed resolving Cloudflare challenge")
|
||||
}
|
||||
|
||||
val js = operation
|
||||
//language=RegExp
|
||||
.replace(Regex("""a\.value =(.+?) \+.*"""), "$1")
|
||||
//language=RegExp
|
||||
.replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "")
|
||||
.replace("\n", "")
|
||||
|
||||
// Duktape can only return strings, so the result has to be converted to string first
|
||||
val result = duktape.evaluate("$js.toString()").toInt()
|
||||
|
||||
val answer = "${result + domain.length}"
|
||||
|
||||
val url = HttpUrl.parse("http://$domain/cdn-cgi/l/chk_jschl").newBuilder()
|
||||
.addQueryParameter("jschl_vc", challenge)
|
||||
.addQueryParameter("pass", pass)
|
||||
.addQueryParameter("jschl_answer", answer)
|
||||
.toString()
|
||||
|
||||
val referer = originalRequest.url().toString()
|
||||
return GET(url, originalRequest.headers().newBuilder().add("Referer", referer).build())
|
||||
} finally {
|
||||
duktape.close()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,108 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.network;
|
||||
|
||||
|
||||
import com.squareup.okhttp.CacheControl;
|
||||
import com.squareup.okhttp.FormEncodingBuilder;
|
||||
import com.squareup.okhttp.Headers;
|
||||
import com.squareup.okhttp.OkHttpClient;
|
||||
import com.squareup.okhttp.Request;
|
||||
import com.squareup.okhttp.RequestBody;
|
||||
import com.squareup.okhttp.Response;
|
||||
|
||||
import java.net.CookieManager;
|
||||
import java.net.CookiePolicy;
|
||||
import java.net.CookieStore;
|
||||
|
||||
import rx.Observable;
|
||||
|
||||
public final class NetworkHelper {
|
||||
|
||||
private OkHttpClient client;
|
||||
private CookieManager cookieManager;
|
||||
|
||||
public final CacheControl NULL_CACHE_CONTROL = new CacheControl.Builder().noCache().build();
|
||||
public final Headers NULL_HEADERS = new Headers.Builder().build();
|
||||
public final RequestBody NULL_REQUEST_BODY = new FormEncodingBuilder().build();
|
||||
|
||||
public NetworkHelper() {
|
||||
client = new OkHttpClient();
|
||||
cookieManager = new CookieManager();
|
||||
cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL);
|
||||
client.setCookieHandler(cookieManager);
|
||||
}
|
||||
|
||||
public Observable<Response> getResponse(final String url, final Headers headers, final CacheControl cacheControl) {
|
||||
return Observable.defer(() -> {
|
||||
try {
|
||||
Request request = new Request.Builder()
|
||||
.url(url)
|
||||
.cacheControl(cacheControl != null ? cacheControl : NULL_CACHE_CONTROL)
|
||||
.headers(headers != null ? headers : NULL_HEADERS)
|
||||
.build();
|
||||
|
||||
return Observable.just(client.newCall(request).execute());
|
||||
} catch (Throwable e) {
|
||||
return Observable.error(e);
|
||||
}
|
||||
}).retry(1);
|
||||
}
|
||||
|
||||
public Observable<String> mapResponseToString(final Response response) {
|
||||
return Observable.defer(() -> {
|
||||
try {
|
||||
return Observable.just(response.body().string());
|
||||
} catch (Throwable e) {
|
||||
return Observable.error(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Observable<String> getStringResponse(final String url, final Headers headers, final CacheControl cacheControl) {
|
||||
return getResponse(url, headers, cacheControl)
|
||||
.flatMap(this::mapResponseToString);
|
||||
}
|
||||
|
||||
public Observable<Response> postData(final String url, final RequestBody formBody, final Headers headers) {
|
||||
return Observable.defer(() -> {
|
||||
try {
|
||||
Request request = new Request.Builder()
|
||||
.url(url)
|
||||
.post(formBody != null ? formBody : NULL_REQUEST_BODY)
|
||||
.headers(headers != null ? headers : NULL_HEADERS)
|
||||
.build();
|
||||
return Observable.just(client.newCall(request).execute());
|
||||
} catch (Throwable e) {
|
||||
return Observable.error(e);
|
||||
}
|
||||
}).retry(1);
|
||||
}
|
||||
|
||||
public Observable<Response> getProgressResponse(final String url, final Headers headers, final ProgressListener listener) {
|
||||
return Observable.defer(() -> {
|
||||
try {
|
||||
Request request = new Request.Builder()
|
||||
.url(url)
|
||||
.cacheControl(NULL_CACHE_CONTROL)
|
||||
.headers(headers != null ? headers : NULL_HEADERS)
|
||||
.build();
|
||||
|
||||
OkHttpClient progressClient = client.clone();
|
||||
|
||||
progressClient.networkInterceptors().add(chain -> {
|
||||
Response originalResponse = chain.proceed(chain.request());
|
||||
return originalResponse.newBuilder()
|
||||
.body(new ProgressResponseBody(originalResponse.body(), listener))
|
||||
.build();
|
||||
});
|
||||
return Observable.just(progressClient.newCall(request).execute());
|
||||
} catch (Throwable e) {
|
||||
return Observable.error(e);
|
||||
}
|
||||
}).retry(2);
|
||||
}
|
||||
|
||||
public CookieStore getCookies() {
|
||||
return cookieManager.getCookieStore();
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
package eu.kanade.tachiyomi.data.network
|
||||
|
||||
import android.content.Context
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
|
||||
class NetworkHelper(context: Context) {
|
||||
|
||||
private val cacheDir = File(context.cacheDir, "network_cache")
|
||||
|
||||
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
|
||||
|
||||
private val cookieManager = PersistentCookieJar(context)
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
.cookieJar(cookieManager)
|
||||
.cache(Cache(cacheDir, cacheSize))
|
||||
.build()
|
||||
|
||||
val forceCacheClient = client.newBuilder()
|
||||
.addNetworkInterceptor { chain ->
|
||||
val originalResponse = chain.proceed(chain.request())
|
||||
originalResponse.newBuilder()
|
||||
.removeHeader("Pragma")
|
||||
.header("Cache-Control", "max-age=600")
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
|
||||
val cloudflareClient = client.newBuilder()
|
||||
.addInterceptor(CloudflareInterceptor(cookies))
|
||||
.build()
|
||||
|
||||
val cookies: PersistentCookieStore
|
||||
get() = cookieManager.store
|
||||
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
package eu.kanade.tachiyomi.data.network
|
||||
|
||||
import okhttp3.Call
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import rx.subscriptions.Subscriptions
|
||||
import java.io.IOException
|
||||
|
||||
fun Call.asObservable(): Observable<Response> {
|
||||
return Observable.create { subscriber ->
|
||||
subscriber.add(Subscriptions.create { cancel() })
|
||||
|
||||
try {
|
||||
val response = execute()
|
||||
if (!subscriber.isUnsubscribed) {
|
||||
subscriber.onNext(response)
|
||||
subscriber.onCompleted()
|
||||
}
|
||||
} catch (error: IOException) {
|
||||
if (!subscriber.isUnsubscribed) {
|
||||
subscriber.onError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call {
|
||||
val progressClient = newBuilder()
|
||||
.cache(null)
|
||||
.addNetworkInterceptor { chain ->
|
||||
val originalResponse = chain.proceed(chain.request())
|
||||
originalResponse.newBuilder()
|
||||
.body(ProgressResponseBody(originalResponse.body(), listener))
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
|
||||
return progressClient.newCall(request)
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
package eu.kanade.tachiyomi.data.network
|
||||
|
||||
import android.content.Context
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
class PersistentCookieJar(context: Context) : CookieJar {
|
||||
|
||||
val store = PersistentCookieStore(context)
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
store.addAll(url, cookies)
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
return store.get(url)
|
||||
}
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
package eu.kanade.tachiyomi.data.network
|
||||
|
||||
import android.content.Context
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl
|
||||
import java.net.URI
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class PersistentCookieStore(context: Context) {
|
||||
|
||||
private val cookieMap = ConcurrentHashMap<String, List<Cookie>>()
|
||||
private val prefs = context.getSharedPreferences("cookie_store", Context.MODE_PRIVATE)
|
||||
|
||||
init {
|
||||
for ((key, value) in prefs.all) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val cookies = value as? Set<String>
|
||||
if (cookies != null) {
|
||||
try {
|
||||
val url = HttpUrl.parse("http://$key")
|
||||
val nonExpiredCookies = cookies.map { Cookie.parse(url, it) }
|
||||
.filter { !it.hasExpired() }
|
||||
cookieMap.put(key, nonExpiredCookies)
|
||||
} catch (e: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addAll(url: HttpUrl, cookies: List<Cookie>) {
|
||||
synchronized(this) {
|
||||
val key = url.uri().host
|
||||
|
||||
// Append or replace the cookies for this domain.
|
||||
val cookiesForDomain = cookieMap[key].orEmpty().toMutableList()
|
||||
for (cookie in cookies) {
|
||||
// Find a cookie with the same name. Replace it if found, otherwise add a new one.
|
||||
val pos = cookiesForDomain.indexOfFirst { it.name() == cookie.name() }
|
||||
if (pos == -1) {
|
||||
cookiesForDomain.add(cookie)
|
||||
} else {
|
||||
cookiesForDomain[pos] = cookie
|
||||
}
|
||||
}
|
||||
cookieMap.put(key, cookiesForDomain)
|
||||
|
||||
// Get cookies to be stored in disk
|
||||
val newValues = cookiesForDomain.asSequence()
|
||||
.filter { it.persistent() && !it.hasExpired() }
|
||||
.map { it.toString() }
|
||||
.toSet()
|
||||
|
||||
prefs.edit().putStringSet(key, newValues).apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAll() {
|
||||
synchronized(this) {
|
||||
prefs.edit().clear().apply()
|
||||
cookieMap.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun get(url: HttpUrl) = get(url.uri().host)
|
||||
|
||||
fun get(uri: URI) = get(uri.host)
|
||||
|
||||
private fun get(url: String): List<Cookie> {
|
||||
return cookieMap[url].orEmpty().filter { !it.hasExpired() }
|
||||
}
|
||||
|
||||
private fun Cookie.hasExpired() = System.currentTimeMillis() >= expiresAt()
|
||||
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.network;
|
||||
|
||||
public interface ProgressListener {
|
||||
void update(long bytesRead, long contentLength, boolean done);
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
package eu.kanade.tachiyomi.data.network
|
||||
|
||||
interface ProgressListener {
|
||||
fun update(bytesRead: Long, contentLength: Long, done: Boolean)
|
||||
}
|
@ -1,52 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.network;
|
||||
|
||||
import com.squareup.okhttp.MediaType;
|
||||
import com.squareup.okhttp.ResponseBody;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import okio.Buffer;
|
||||
import okio.BufferedSource;
|
||||
import okio.ForwardingSource;
|
||||
import okio.Okio;
|
||||
import okio.Source;
|
||||
|
||||
public class ProgressResponseBody extends ResponseBody {
|
||||
|
||||
private final ResponseBody responseBody;
|
||||
private final ProgressListener progressListener;
|
||||
private BufferedSource bufferedSource;
|
||||
|
||||
public ProgressResponseBody(ResponseBody responseBody, ProgressListener progressListener) {
|
||||
this.responseBody = responseBody;
|
||||
this.progressListener = progressListener;
|
||||
}
|
||||
|
||||
@Override public MediaType contentType() {
|
||||
return responseBody.contentType();
|
||||
}
|
||||
|
||||
@Override public long contentLength() throws IOException {
|
||||
return responseBody.contentLength();
|
||||
}
|
||||
|
||||
@Override public BufferedSource source() throws IOException {
|
||||
if (bufferedSource == null) {
|
||||
bufferedSource = Okio.buffer(source(responseBody.source()));
|
||||
}
|
||||
return bufferedSource;
|
||||
}
|
||||
|
||||
private Source source(Source source) {
|
||||
return new ForwardingSource(source) {
|
||||
long totalBytesRead = 0L;
|
||||
@Override public long read(Buffer sink, long byteCount) throws IOException {
|
||||
long bytesRead = super.read(sink, byteCount);
|
||||
// read() returns the number of bytes read, or -1 if this source is exhausted.
|
||||
totalBytesRead += bytesRead != -1 ? bytesRead : 0;
|
||||
progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1);
|
||||
return bytesRead;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
package eu.kanade.tachiyomi.data.network
|
||||
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.ResponseBody
|
||||
import okio.*
|
||||
import java.io.IOException
|
||||
|
||||
class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() {
|
||||
|
||||
private val bufferedSource: BufferedSource by lazy {
|
||||
Okio.buffer(source(responseBody.source()))
|
||||
}
|
||||
|
||||
override fun contentType(): MediaType {
|
||||
return responseBody.contentType()
|
||||
}
|
||||
|
||||
override fun contentLength(): Long {
|
||||
return responseBody.contentLength()
|
||||
}
|
||||
|
||||
override fun source(): BufferedSource {
|
||||
return bufferedSource
|
||||
}
|
||||
|
||||
private fun source(source: Source): Source {
|
||||
return object : ForwardingSource(source) {
|
||||
internal var totalBytesRead = 0L
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(sink: Buffer, byteCount: Long): Long {
|
||||
val bytesRead = super.read(sink, byteCount)
|
||||
// read() returns the number of bytes read, or -1 if this source is exhausted.
|
||||
totalBytesRead += if (bytesRead != -1L) bytesRead else 0
|
||||
progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
|
||||
return bytesRead
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
package eu.kanade.tachiyomi.data.network
|
||||
|
||||
import okhttp3.*
|
||||
import java.util.concurrent.TimeUnit.MINUTES
|
||||
|
||||
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build()
|
||||
private val DEFAULT_HEADERS = Headers.Builder().build()
|
||||
private val DEFAULT_BODY: RequestBody = FormBody.Builder().build()
|
||||
|
||||
fun GET(url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL): Request {
|
||||
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun POST(url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
body: RequestBody = DEFAULT_BODY,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL): Request {
|
||||
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.post(body)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
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. Most of them are defined
|
||||
* in the file "keys.xml". By using this class we can define preferences in one place and get them
|
||||
* referenced here.
|
||||
*/
|
||||
class PreferenceKeys(context: Context) {
|
||||
|
||||
val rotation = context.getString(R.string.pref_rotation_type_key)
|
||||
|
||||
val enableTransitions = context.getString(R.string.pref_enable_transitions_key)
|
||||
|
||||
val showPageNumber = context.getString(R.string.pref_show_page_number_key)
|
||||
|
||||
val hideStatusBar = context.getString(R.string.pref_hide_status_bar_key)
|
||||
|
||||
val keepScreenOn = context.getString(R.string.pref_keep_screen_on_key)
|
||||
|
||||
val customBrightness = context.getString(R.string.pref_custom_brightness_key)
|
||||
|
||||
val customBrightnessValue = context.getString(R.string.pref_custom_brightness_value_key)
|
||||
|
||||
val defaultViewer = context.getString(R.string.pref_default_viewer_key)
|
||||
|
||||
val imageScaleType = context.getString(R.string.pref_image_scale_type_key)
|
||||
|
||||
val imageDecoder = context.getString(R.string.pref_image_decoder_key)
|
||||
|
||||
val zoomStart = context.getString(R.string.pref_zoom_start_key)
|
||||
|
||||
val readerTheme = context.getString(R.string.pref_reader_theme_key)
|
||||
|
||||
val readWithTapping = context.getString(R.string.pref_read_with_tapping_key)
|
||||
|
||||
val readWithVolumeKeys = context.getString(R.string.pref_read_with_volume_keys_key)
|
||||
|
||||
val reencodeImage = context.getString(R.string.pref_reencode_key)
|
||||
|
||||
val portraitColumns = context.getString(R.string.pref_library_columns_portrait_key)
|
||||
|
||||
val landscapeColumns = context.getString(R.string.pref_library_columns_landscape_key)
|
||||
|
||||
val updateOnlyNonCompleted = context.getString(R.string.pref_update_only_non_completed_key)
|
||||
|
||||
val autoUpdateMangaSync = context.getString(R.string.pref_auto_update_manga_sync_key)
|
||||
|
||||
val askUpdateMangaSync = context.getString(R.string.pref_ask_update_manga_sync_key)
|
||||
|
||||
val lastUsedCatalogueSource = context.getString(R.string.pref_last_catalogue_source_key)
|
||||
|
||||
val lastUsedCategory = context.getString(R.string.pref_last_used_category_key)
|
||||
|
||||
val seamlessMode = context.getString(R.string.pref_seamless_mode_key)
|
||||
|
||||
val catalogueAsList = context.getString(R.string.pref_display_catalogue_as_list)
|
||||
|
||||
val enabledLanguages = context.getString(R.string.pref_source_languages)
|
||||
|
||||
val downloadsDirectory = context.getString(R.string.pref_download_directory_key)
|
||||
|
||||
val downloadThreads = context.getString(R.string.pref_download_slots_key)
|
||||
|
||||
val downloadOnlyOverWifi = context.getString(R.string.pref_download_only_over_wifi_key)
|
||||
|
||||
val removeAfterRead = context.getString(R.string.pref_remove_after_read_key)
|
||||
|
||||
val removeAfterReadPrevious = context.getString(R.string.pref_remove_after_read_previous_key)
|
||||
|
||||
val removeAfterMarkedAsRead = context.getString(R.string.pref_remove_after_marked_as_read_key)
|
||||
|
||||
val libraryUpdateInterval = context.getString(R.string.pref_library_update_interval_key)
|
||||
|
||||
val libraryUpdateRestriction = context.getString(R.string.pref_library_update_restriction_key)
|
||||
|
||||
val filterDownloaded = context.getString(R.string.pref_filter_downloaded_key)
|
||||
|
||||
val filterUnread = context.getString(R.string.pref_filter_unread_key)
|
||||
|
||||
fun sourceUsername(sourceId: Int) = "pref_source_username_$sourceId"
|
||||
|
||||
fun sourcePassword(sourceId: Int) = "pref_source_password_$sourceId"
|
||||
|
||||
fun syncUsername(syncId: Int) = "pref_mangasync_username_$syncId"
|
||||
|
||||
fun syncPassword(syncId: Int) = "pref_mangasync_password_$syncId"
|
||||
|
||||
}
|
@ -1,171 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.preference;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Environment;
|
||||
import android.preference.PreferenceManager;
|
||||
|
||||
import com.f2prateek.rx.preferences.Preference;
|
||||
import com.f2prateek.rx.preferences.RxSharedPreferences;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
||||
import eu.kanade.tachiyomi.R;
|
||||
import eu.kanade.tachiyomi.data.mangasync.base.MangaSyncService;
|
||||
import eu.kanade.tachiyomi.data.source.base.Source;
|
||||
|
||||
public class PreferencesHelper {
|
||||
|
||||
private Context context;
|
||||
private SharedPreferences prefs;
|
||||
private RxSharedPreferences rxPrefs;
|
||||
|
||||
private static final String SOURCE_ACCOUNT_USERNAME = "pref_source_username_";
|
||||
private static final String SOURCE_ACCOUNT_PASSWORD = "pref_source_password_";
|
||||
private static final String MANGASYNC_ACCOUNT_USERNAME = "pref_mangasync_username_";
|
||||
private static final String MANGASYNC_ACCOUNT_PASSWORD = "pref_mangasync_password_";
|
||||
|
||||
private File defaultDownloadsDir;
|
||||
|
||||
public PreferencesHelper(Context context) {
|
||||
this.context = context;
|
||||
PreferenceManager.setDefaultValues(context, R.xml.pref_reader, false);
|
||||
|
||||
prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
rxPrefs = RxSharedPreferences.create(prefs);
|
||||
|
||||
defaultDownloadsDir = new File(Environment.getExternalStorageDirectory() +
|
||||
File.separator + context.getString(R.string.app_name), "downloads");
|
||||
|
||||
// Create default directory
|
||||
if (getDownloadsDirectory().equals(defaultDownloadsDir.getAbsolutePath()) &&
|
||||
!defaultDownloadsDir.exists()) {
|
||||
defaultDownloadsDir.mkdirs();
|
||||
try {
|
||||
new File(defaultDownloadsDir, ".nomedia").createNewFile();
|
||||
} catch (IOException e) { /* Ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
private String getKey(int keyResource) {
|
||||
return context.getString(keyResource);
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
prefs.edit().clear().apply();
|
||||
}
|
||||
|
||||
public Preference<Boolean> lockOrientation() {
|
||||
return rxPrefs.getBoolean(getKey(R.string.pref_lock_orientation_key), true);
|
||||
}
|
||||
|
||||
public Preference<Boolean> enableTransitions() {
|
||||
return rxPrefs.getBoolean(getKey(R.string.pref_enable_transitions_key), true);
|
||||
}
|
||||
|
||||
public Preference<Boolean> showPageNumber() {
|
||||
return rxPrefs.getBoolean(getKey(R.string.pref_show_page_number_key), true);
|
||||
}
|
||||
|
||||
public Preference<Boolean> hideStatusBar() {
|
||||
return rxPrefs.getBoolean(getKey(R.string.pref_hide_status_bar_key), true);
|
||||
}
|
||||
|
||||
public Preference<Boolean> keepScreenOn() {
|
||||
return rxPrefs.getBoolean(getKey(R.string.pref_keep_screen_on_key), true);
|
||||
}
|
||||
|
||||
public Preference<Boolean> customBrightness() {
|
||||
return rxPrefs.getBoolean(getKey(R.string.pref_custom_brightness_key), false);
|
||||
}
|
||||
|
||||
public Preference<Float> customBrightnessValue() {
|
||||
return rxPrefs.getFloat(getKey(R.string.pref_custom_brightness_value_key), 0F);
|
||||
}
|
||||
|
||||
public int getDefaultViewer() {
|
||||
return prefs.getInt(getKey(R.string.pref_default_viewer_key), 1);
|
||||
}
|
||||
|
||||
public Preference<Integer> portraitColumns() {
|
||||
return rxPrefs.getInteger(getKey(R.string.pref_library_columns_portrait_key), 0);
|
||||
}
|
||||
|
||||
public Preference<Integer> landscapeColumns() {
|
||||
return rxPrefs.getInteger(getKey(R.string.pref_library_columns_landscape_key), 0);
|
||||
}
|
||||
|
||||
public boolean updateOnlyNonCompleted() {
|
||||
return prefs.getBoolean(getKey(R.string.pref_update_only_non_completed_key), false);
|
||||
}
|
||||
|
||||
public boolean autoUpdateMangaSync() {
|
||||
return prefs.getBoolean(getKey(R.string.pref_auto_update_manga_sync_key), true);
|
||||
}
|
||||
|
||||
public boolean askUpdateMangaSync() {
|
||||
return prefs.getBoolean(getKey(R.string.pref_ask_update_manga_sync_key), false);
|
||||
}
|
||||
|
||||
public Preference<Integer> imageDecoder() {
|
||||
return rxPrefs.getInteger(getKey(R.string.pref_image_decoder_key), 0);
|
||||
}
|
||||
|
||||
public Preference<Integer> readerTheme() {
|
||||
return rxPrefs.getInteger(getKey(R.string.pref_reader_theme_key), 0);
|
||||
}
|
||||
|
||||
public String getSourceUsername(Source source) {
|
||||
return prefs.getString(SOURCE_ACCOUNT_USERNAME + source.getId(), "");
|
||||
}
|
||||
|
||||
public String getSourcePassword(Source source) {
|
||||
return prefs.getString(SOURCE_ACCOUNT_PASSWORD + source.getId(), "");
|
||||
}
|
||||
|
||||
public void setSourceCredentials(Source source, String username, String password) {
|
||||
prefs.edit()
|
||||
.putString(SOURCE_ACCOUNT_USERNAME + source.getId(), username)
|
||||
.putString(SOURCE_ACCOUNT_PASSWORD + source.getId(), password)
|
||||
.apply();
|
||||
}
|
||||
|
||||
public String getMangaSyncUsername(MangaSyncService sync) {
|
||||
return prefs.getString(MANGASYNC_ACCOUNT_USERNAME + sync.getId(), "");
|
||||
}
|
||||
|
||||
public String getMangaSyncPassword(MangaSyncService sync) {
|
||||
return prefs.getString(MANGASYNC_ACCOUNT_PASSWORD + sync.getId(), "");
|
||||
}
|
||||
|
||||
public void setMangaSyncCredentials(MangaSyncService sync, String username, String password) {
|
||||
prefs.edit()
|
||||
.putString(MANGASYNC_ACCOUNT_USERNAME + sync.getId(), username)
|
||||
.putString(MANGASYNC_ACCOUNT_PASSWORD + sync.getId(), password)
|
||||
.apply();
|
||||
}
|
||||
|
||||
public String getDownloadsDirectory() {
|
||||
return prefs.getString(getKey(R.string.pref_download_directory_key),
|
||||
defaultDownloadsDir.getAbsolutePath());
|
||||
}
|
||||
|
||||
public void setDownloadsDirectory(String path) {
|
||||
prefs.edit().putString(getKey(R.string.pref_download_directory_key), path).apply();
|
||||
}
|
||||
|
||||
public int downloadThreads() {
|
||||
return prefs.getInt(getKey(R.string.pref_download_slots_key), 1);
|
||||
}
|
||||
|
||||
public boolean downloadOnlyOverWifi() {
|
||||
return prefs.getBoolean(getKey(R.string.pref_download_only_over_wifi_key), true);
|
||||
}
|
||||
|
||||
public static int getLibraryUpdateInterval(Context context) {
|
||||
return PreferenceManager.getDefaultSharedPreferences(context).getInt(
|
||||
context.getString(R.string.pref_library_update_interval_key), 0);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,152 @@
|
||||
package eu.kanade.tachiyomi.data.preference
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.preference.PreferenceManager
|
||||
import com.f2prateek.rx.preferences.Preference
|
||||
import com.f2prateek.rx.preferences.RxSharedPreferences
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
|
||||
import eu.kanade.tachiyomi.data.source.Source
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
fun <T> Preference<T>.getOrDefault(): T = get() ?: defaultValue()!!
|
||||
|
||||
class PreferencesHelper(private val context: Context) {
|
||||
|
||||
val keys = PreferenceKeys(context)
|
||||
|
||||
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
private val rxPrefs = RxSharedPreferences.create(prefs)
|
||||
|
||||
private val defaultDownloadsDir = File(Environment.getExternalStorageDirectory().absolutePath +
|
||||
File.separator + context.getString(R.string.app_name), "downloads")
|
||||
|
||||
init {
|
||||
// Don't display downloaded chapters in gallery apps creating a ".nomedia" file
|
||||
try {
|
||||
File(downloadsDirectory().getOrDefault(), ".nomedia").createNewFile()
|
||||
} catch (e: IOException) {
|
||||
/* Ignore */
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun getLibraryUpdateInterval(context: Context): Int {
|
||||
return PreferenceManager.getDefaultSharedPreferences(context).getInt(
|
||||
context.getString(R.string.pref_library_update_interval_key), 0)
|
||||
}
|
||||
|
||||
fun getAutomaticUpdateStatus(context: Context): Boolean {
|
||||
return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(
|
||||
context.getString(R.string.pref_enable_automatic_updates), false)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getTheme(context: Context): Int {
|
||||
return PreferenceManager.getDefaultSharedPreferences(context).getInt(
|
||||
context.getString(R.string.pref_theme_key), 1)
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
|
||||
fun rotation() = rxPrefs.getInteger(keys.rotation, 1)
|
||||
|
||||
fun enableTransitions() = rxPrefs.getBoolean(keys.enableTransitions, true)
|
||||
|
||||
fun showPageNumber() = rxPrefs.getBoolean(keys.showPageNumber, true)
|
||||
|
||||
fun hideStatusBar() = rxPrefs.getBoolean(keys.hideStatusBar, true)
|
||||
|
||||
fun keepScreenOn() = rxPrefs.getBoolean(keys.keepScreenOn, true)
|
||||
|
||||
fun customBrightness() = rxPrefs.getBoolean(keys.customBrightness, false)
|
||||
|
||||
fun customBrightnessValue() = rxPrefs.getFloat(keys.customBrightnessValue, 0f)
|
||||
|
||||
fun defaultViewer() = prefs.getInt(keys.defaultViewer, 1)
|
||||
|
||||
fun imageScaleType() = rxPrefs.getInteger(keys.imageScaleType, 1)
|
||||
|
||||
fun imageDecoder() = rxPrefs.getInteger(keys.imageDecoder, 0)
|
||||
|
||||
fun zoomStart() = rxPrefs.getInteger(keys.zoomStart, 1)
|
||||
|
||||
fun readerTheme() = rxPrefs.getInteger(keys.readerTheme, 0)
|
||||
|
||||
fun readWithTapping() = rxPrefs.getBoolean(keys.readWithTapping, true)
|
||||
|
||||
fun readWithVolumeKeys() = rxPrefs.getBoolean(keys.readWithVolumeKeys, false)
|
||||
|
||||
fun reencodeImage() = prefs.getBoolean(keys.reencodeImage, false)
|
||||
|
||||
fun portraitColumns() = rxPrefs.getInteger(keys.portraitColumns, 0)
|
||||
|
||||
fun landscapeColumns() = rxPrefs.getInteger(keys.landscapeColumns, 0)
|
||||
|
||||
fun updateOnlyNonCompleted() = prefs.getBoolean(keys.updateOnlyNonCompleted, false)
|
||||
|
||||
fun autoUpdateMangaSync() = prefs.getBoolean(keys.autoUpdateMangaSync, true)
|
||||
|
||||
fun askUpdateMangaSync() = prefs.getBoolean(keys.askUpdateMangaSync, false)
|
||||
|
||||
fun lastUsedCatalogueSource() = rxPrefs.getInteger(keys.lastUsedCatalogueSource, -1)
|
||||
|
||||
fun lastUsedCategory() = rxPrefs.getInteger(keys.lastUsedCategory, 0)
|
||||
|
||||
fun lastVersionCode() = rxPrefs.getInteger("last_version_code", 0)
|
||||
|
||||
fun seamlessMode() = prefs.getBoolean(keys.seamlessMode, true)
|
||||
|
||||
fun catalogueAsList() = rxPrefs.getBoolean(keys.catalogueAsList, false)
|
||||
|
||||
fun enabledLanguages() = rxPrefs.getStringSet(keys.enabledLanguages, setOf("EN"))
|
||||
|
||||
fun sourceUsername(source: Source) = prefs.getString(keys.sourceUsername(source.id), "")
|
||||
|
||||
fun sourcePassword(source: Source) = prefs.getString(keys.sourcePassword(source.id), "")
|
||||
|
||||
fun setSourceCredentials(source: Source, username: String, password: String) {
|
||||
prefs.edit()
|
||||
.putString(keys.sourceUsername(source.id), username)
|
||||
.putString(keys.sourcePassword(source.id), password)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun mangaSyncUsername(sync: MangaSyncService) = prefs.getString(keys.syncUsername(sync.id), "")
|
||||
|
||||
fun mangaSyncPassword(sync: MangaSyncService) = prefs.getString(keys.syncPassword(sync.id), "")
|
||||
|
||||
fun setMangaSyncCredentials(sync: MangaSyncService, username: String, password: String) {
|
||||
prefs.edit()
|
||||
.putString(keys.syncUsername(sync.id), username)
|
||||
.putString(keys.syncPassword(sync.id), password)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.absolutePath)
|
||||
|
||||
fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1)
|
||||
|
||||
fun downloadOnlyOverWifi() = prefs.getBoolean(keys.downloadOnlyOverWifi, true)
|
||||
|
||||
fun removeAfterRead() = prefs.getBoolean(keys.removeAfterRead, false)
|
||||
|
||||
fun removeAfterReadPrevious() = prefs.getBoolean(keys.removeAfterReadPrevious, false)
|
||||
|
||||
fun removeAfterMarkedAsRead() = prefs.getBoolean(keys.removeAfterMarkedAsRead, false)
|
||||
|
||||
fun libraryUpdateInterval() = rxPrefs.getInteger(keys.libraryUpdateInterval, 0)
|
||||
|
||||
fun libraryUpdateRestriction() = prefs.getStringSet(keys.libraryUpdateRestriction, emptySet())
|
||||
|
||||
fun filterDownloaded() = rxPrefs.getBoolean(keys.filterDownloaded, false)
|
||||
|
||||
fun filterUnread() = rxPrefs.getBoolean(keys.filterUnread, false)
|
||||
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package eu.kanade.tachiyomi.data.source
|
||||
|
||||
class Language(val code: String, val lang: String)
|
||||
|
||||
val EN = Language("EN", "English")
|
||||
val RU = Language("RU", "Russian")
|
||||
|
||||
fun getLanguages() = listOf(EN, RU)
|
51
app/src/main/java/eu/kanade/tachiyomi/data/source/Source.kt
Normal file
51
app/src/main/java/eu/kanade/tachiyomi/data/source/Source.kt
Normal file
@ -0,0 +1,51 @@
|
||||
package eu.kanade.tachiyomi.data.source
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import rx.Observable
|
||||
|
||||
/**
|
||||
* A basic interface for creating a source. It could be an online source, a local source, etc...
|
||||
*/
|
||||
interface Source {
|
||||
|
||||
/**
|
||||
* Id for the source. Must be unique.
|
||||
*/
|
||||
val id: Int
|
||||
|
||||
/**
|
||||
* Name of the source.
|
||||
*/
|
||||
val name: String
|
||||
|
||||
/**
|
||||
* Returns an observable with the updated details for a manga.
|
||||
*
|
||||
* @param manga the manga to update.
|
||||
*/
|
||||
fun fetchMangaDetails(manga: Manga): Observable<Manga>
|
||||
|
||||
/**
|
||||
* Returns an observable with all the available chapters for a manga.
|
||||
*
|
||||
* @param manga the manga to update.
|
||||
*/
|
||||
fun fetchChapterList(manga: Manga): Observable<List<Chapter>>
|
||||
|
||||
/**
|
||||
* Returns an observable with the list of pages a chapter has.
|
||||
*
|
||||
* @param chapter the chapter.
|
||||
*/
|
||||
fun fetchPageList(chapter: Chapter): Observable<List<Page>>
|
||||
|
||||
/**
|
||||
* Returns an observable with the path of the image.
|
||||
*
|
||||
* @param page the page.
|
||||
*/
|
||||
fun fetchImage(page: Page): Observable<Page>
|
||||
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.source;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
||||
import eu.kanade.tachiyomi.data.source.base.Source;
|
||||
import eu.kanade.tachiyomi.data.source.online.english.Batoto;
|
||||
import eu.kanade.tachiyomi.data.source.online.english.Kissmanga;
|
||||
import eu.kanade.tachiyomi.data.source.online.english.Mangafox;
|
||||
import eu.kanade.tachiyomi.data.source.online.english.Mangahere;
|
||||
|
||||
public class SourceManager {
|
||||
|
||||
public static final int BATOTO = 1;
|
||||
public static final int MANGAHERE = 2;
|
||||
public static final int MANGAFOX = 3;
|
||||
public static final int KISSMANGA = 4;
|
||||
|
||||
private HashMap<Integer, Source> sourcesMap;
|
||||
private Context context;
|
||||
|
||||
public SourceManager(Context context) {
|
||||
sourcesMap = new HashMap<>();
|
||||
this.context = context;
|
||||
|
||||
initializeSources();
|
||||
}
|
||||
|
||||
public Source get(int sourceKey) {
|
||||
if (!sourcesMap.containsKey(sourceKey)) {
|
||||
sourcesMap.put(sourceKey, createSource(sourceKey));
|
||||
}
|
||||
return sourcesMap.get(sourceKey);
|
||||
}
|
||||
|
||||
private Source createSource(int sourceKey) {
|
||||
switch (sourceKey) {
|
||||
case BATOTO:
|
||||
return new Batoto(context);
|
||||
case MANGAHERE:
|
||||
return new Mangahere(context);
|
||||
case MANGAFOX:
|
||||
return new Mangafox(context);
|
||||
case KISSMANGA:
|
||||
return new Kissmanga(context);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void initializeSources() {
|
||||
sourcesMap.put(BATOTO, createSource(BATOTO));
|
||||
sourcesMap.put(MANGAHERE, createSource(MANGAHERE));
|
||||
sourcesMap.put(MANGAFOX, createSource(MANGAFOX));
|
||||
sourcesMap.put(KISSMANGA, createSource(KISSMANGA));
|
||||
}
|
||||
|
||||
public List<Source> getSources() {
|
||||
List<Source> sources = new ArrayList<>(sourcesMap.values());
|
||||
Collections.sort(sources, (s1, s2) -> s1.getName().compareTo(s2.getName()));
|
||||
return sources;
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
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.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) {
|
||||
|
||||
val BATOTO = 1
|
||||
val MANGAHERE = 2
|
||||
val MANGAFOX = 3
|
||||
val KISSMANGA = 4
|
||||
val READMANGA = 5
|
||||
val MINTMANGA = 6
|
||||
val MANGACHAN = 7
|
||||
val READMANGATODAY = 8
|
||||
|
||||
val LAST_SOURCE = 8
|
||||
|
||||
val sourcesMap = createSources()
|
||||
|
||||
open fun get(sourceKey: Int): Source? {
|
||||
return sourcesMap[sourceKey]
|
||||
}
|
||||
|
||||
fun getOnlineSources() = sourcesMap.values.filterIsInstance(OnlineSource::class.java)
|
||||
|
||||
private fun createSource(id: Int): Source? = when (id) {
|
||||
BATOTO -> Batoto(context, id)
|
||||
KISSMANGA -> Kissmanga(context, id)
|
||||
MANGAHERE -> Mangahere(context, id)
|
||||
MANGAFOX -> Mangafox(context, id)
|
||||
READMANGA -> Readmanga(context, id)
|
||||
MINTMANGA -> Mintmanga(context, id)
|
||||
MANGACHAN -> Mangachan(context, id)
|
||||
READMANGATODAY -> Readmangatoday(context, id)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun createSources(): Map<Int, Source> = hashMapOf<Int, Source>().apply {
|
||||
for (i in 1..LAST_SOURCE) {
|
||||
createSource(i)?.let { put(i, 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(context, map).let { put(it.id, it) }
|
||||
} catch (e: Exception) {
|
||||
Timber.e("Error loading source from file. Bad format?")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,102 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.source.base;
|
||||
|
||||
import com.squareup.okhttp.Headers;
|
||||
import com.squareup.okhttp.Response;
|
||||
|
||||
import org.jsoup.nodes.Document;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
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 rx.Observable;
|
||||
|
||||
public abstract class BaseSource {
|
||||
|
||||
// Name of the source to display
|
||||
public abstract String getName();
|
||||
|
||||
// Id of the source (must be declared and obtained from SourceManager to avoid conflicts)
|
||||
public abstract int getId();
|
||||
|
||||
// Base url of the source, like: http://example.com
|
||||
public abstract String getBaseUrl();
|
||||
|
||||
// True if the source requires a login
|
||||
public abstract boolean isLoginRequired();
|
||||
|
||||
// Return the initial popular mangas URL
|
||||
protected abstract String getInitialPopularMangasUrl();
|
||||
|
||||
// Return the initial search url given a query
|
||||
protected abstract String getInitialSearchUrl(String query);
|
||||
|
||||
// Get the popular list of mangas from the source's parsed document
|
||||
protected abstract List<Manga> parsePopularMangasFromHtml(Document parsedHtml);
|
||||
|
||||
// Get the next popular page URL or null if it's the last
|
||||
protected abstract String parseNextPopularMangasUrl(Document parsedHtml, MangasPage page);
|
||||
|
||||
// Get the searched list of mangas from the source's parsed document
|
||||
protected abstract List<Manga> parseSearchFromHtml(Document parsedHtml);
|
||||
|
||||
// Get the next search page URL or null if it's the last
|
||||
protected abstract String parseNextSearchUrl(Document parsedHtml, MangasPage page, String query);
|
||||
|
||||
// Given the URL of a manga and the result of the request, return the details of the manga
|
||||
protected abstract Manga parseHtmlToManga(String mangaUrl, String unparsedHtml);
|
||||
|
||||
// Given the result of the request to mangas' chapters, return a list of chapters
|
||||
protected abstract List<Chapter> parseHtmlToChapters(String unparsedHtml);
|
||||
|
||||
// Given the result of the request to a chapter, return the list of URLs of the chapter
|
||||
protected abstract List<String> parseHtmlToPageUrls(String unparsedHtml);
|
||||
|
||||
// Given the result of the request to a chapter's page, return the URL of the image of the page
|
||||
protected abstract String parseHtmlToImageUrl(String unparsedHtml);
|
||||
|
||||
|
||||
// Login related methods, shouldn't be overriden if the source doesn't require it
|
||||
public Observable<Boolean> login(String username, String password) {
|
||||
throw new UnsupportedOperationException("Not implemented");
|
||||
}
|
||||
|
||||
public boolean isLogged() {
|
||||
throw new UnsupportedOperationException("Not implemented");
|
||||
}
|
||||
|
||||
protected boolean isAuthenticationSuccessful(Response response) {
|
||||
throw new UnsupportedOperationException("Not implemented");
|
||||
}
|
||||
|
||||
|
||||
// Default fields, they can be overriden by sources' implementation
|
||||
|
||||
// Get the URL to the details of a manga, useful if the source provides some kind of API or fast calls
|
||||
protected String overrideMangaUrl(String defaultMangaUrl) {
|
||||
return defaultMangaUrl;
|
||||
}
|
||||
|
||||
// Get the URL of the first page that contains a source image and the page list
|
||||
protected String overrideChapterUrl(String defaultPageUrl) {
|
||||
return defaultPageUrl;
|
||||
}
|
||||
|
||||
// Get the URL of the pages that contains source images
|
||||
protected String overridePageUrl(String defaultPageUrl) {
|
||||
return defaultPageUrl;
|
||||
}
|
||||
|
||||
// Default headers, it can be overriden by children or just add new keys
|
||||
protected Headers.Builder headersBuilder() {
|
||||
Headers.Builder builder = new Headers.Builder();
|
||||
builder.add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)");
|
||||
return builder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return getName();
|
||||
}
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.source.base;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
public abstract class LoginSource extends Source {
|
||||
|
||||
public LoginSource() {}
|
||||
|
||||
public LoginSource(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isLoginRequired() {
|
||||
return true;
|
||||
}
|
||||
}
|
@ -1,219 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.source.base;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.bumptech.glide.load.model.LazyHeaders;
|
||||
import com.squareup.okhttp.Headers;
|
||||
import com.squareup.okhttp.Response;
|
||||
|
||||
import org.jsoup.Jsoup;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import eu.kanade.tachiyomi.App;
|
||||
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.NetworkHelper;
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper;
|
||||
import eu.kanade.tachiyomi.data.source.model.MangasPage;
|
||||
import eu.kanade.tachiyomi.data.source.model.Page;
|
||||
import rx.Observable;
|
||||
import rx.schedulers.Schedulers;
|
||||
|
||||
public abstract class Source extends BaseSource {
|
||||
|
||||
@Inject protected NetworkHelper networkService;
|
||||
@Inject protected ChapterCache chapterCache;
|
||||
@Inject protected PreferencesHelper prefs;
|
||||
protected Headers requestHeaders;
|
||||
protected LazyHeaders glideHeaders;
|
||||
|
||||
public Source() {}
|
||||
|
||||
public Source(Context context) {
|
||||
App.get(context).getComponent().inject(this);
|
||||
requestHeaders = headersBuilder().build();
|
||||
glideHeaders = glideHeadersBuilder().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isLoginRequired() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the most popular mangas from the source
|
||||
public Observable<MangasPage> pullPopularMangasFromNetwork(MangasPage page) {
|
||||
if (page.page == 1)
|
||||
page.url = getInitialPopularMangasUrl();
|
||||
|
||||
return networkService
|
||||
.getStringResponse(page.url, requestHeaders, null)
|
||||
.map(Jsoup::parse)
|
||||
.doOnNext(doc -> page.mangas = parsePopularMangasFromHtml(doc))
|
||||
.doOnNext(doc -> page.nextPageUrl = parseNextPopularMangasUrl(doc, page))
|
||||
.map(response -> page);
|
||||
}
|
||||
|
||||
// Get mangas from the source with a query
|
||||
public Observable<MangasPage> searchMangasFromNetwork(MangasPage page, String query) {
|
||||
if (page.page == 1)
|
||||
page.url = getInitialSearchUrl(query);
|
||||
|
||||
return networkService
|
||||
.getStringResponse(page.url, requestHeaders, null)
|
||||
.map(Jsoup::parse)
|
||||
.doOnNext(doc -> page.mangas = parseSearchFromHtml(doc))
|
||||
.doOnNext(doc -> page.nextPageUrl = parseNextSearchUrl(doc, page, query))
|
||||
.map(response -> page);
|
||||
}
|
||||
|
||||
// Get manga details from the source
|
||||
public Observable<Manga> pullMangaFromNetwork(final String mangaUrl) {
|
||||
return networkService
|
||||
.getStringResponse(getBaseUrl() + overrideMangaUrl(mangaUrl), requestHeaders, null)
|
||||
.flatMap(unparsedHtml -> Observable.just(parseHtmlToManga(mangaUrl, unparsedHtml)));
|
||||
}
|
||||
|
||||
// Get chapter list of a manga from the source
|
||||
public Observable<List<Chapter>> pullChaptersFromNetwork(final String mangaUrl) {
|
||||
return networkService
|
||||
.getStringResponse(getBaseUrl() + mangaUrl, requestHeaders, null)
|
||||
.flatMap(unparsedHtml -> {
|
||||
List<Chapter> chapters = parseHtmlToChapters(unparsedHtml);
|
||||
return !chapters.isEmpty() ?
|
||||
Observable.just(chapters) :
|
||||
Observable.error(new Exception("No chapters found"));
|
||||
});
|
||||
}
|
||||
|
||||
public Observable<List<Page>> getCachedPageListOrPullFromNetwork(final String chapterUrl) {
|
||||
return chapterCache.getPageUrlsFromDiskCache(getChapterCacheKey(chapterUrl))
|
||||
.onErrorResumeNext(throwable -> {
|
||||
return pullPageListFromNetwork(chapterUrl);
|
||||
})
|
||||
.onBackpressureBuffer();
|
||||
}
|
||||
|
||||
public Observable<List<Page>> pullPageListFromNetwork(final String chapterUrl) {
|
||||
return networkService
|
||||
.getStringResponse(getBaseUrl() + overrideChapterUrl(chapterUrl), requestHeaders, null)
|
||||
.flatMap(unparsedHtml -> {
|
||||
List<Page> pages = convertToPages(parseHtmlToPageUrls(unparsedHtml));
|
||||
return !pages.isEmpty() ?
|
||||
Observable.just(parseFirstPage(pages, unparsedHtml)) :
|
||||
Observable.error(new Exception("Page list is empty"));
|
||||
});
|
||||
}
|
||||
|
||||
public Observable<Page> getAllImageUrlsFromPageList(final List<Page> pages) {
|
||||
return Observable.from(pages)
|
||||
.filter(page -> page.getImageUrl() != null)
|
||||
.mergeWith(getRemainingImageUrlsFromPageList(pages));
|
||||
}
|
||||
|
||||
// Get the URLs of the images of a chapter
|
||||
public Observable<Page> getRemainingImageUrlsFromPageList(final List<Page> pages) {
|
||||
return Observable.from(pages)
|
||||
.filter(page -> page.getImageUrl() == null)
|
||||
.concatMap(this::getImageUrlFromPage);
|
||||
}
|
||||
|
||||
public Observable<Page> getImageUrlFromPage(final Page page) {
|
||||
page.setStatus(Page.LOAD_PAGE);
|
||||
return networkService
|
||||
.getStringResponse(overridePageUrl(page.getUrl()), requestHeaders, null)
|
||||
.flatMap(unparsedHtml -> Observable.just(parseHtmlToImageUrl(unparsedHtml)))
|
||||
.onErrorResumeNext(e -> {
|
||||
page.setStatus(Page.ERROR);
|
||||
return Observable.just(null);
|
||||
})
|
||||
.flatMap(imageUrl -> {
|
||||
page.setImageUrl(imageUrl);
|
||||
return Observable.just(page);
|
||||
})
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Observable<Page> getCachedImage(final Page page) {
|
||||
Observable<Page> pageObservable = Observable.just(page);
|
||||
if (page.getImageUrl() == null)
|
||||
return pageObservable;
|
||||
|
||||
return pageObservable
|
||||
.flatMap(p -> {
|
||||
if (!chapterCache.isImageInCache(page.getImageUrl())) {
|
||||
return cacheImage(page);
|
||||
}
|
||||
return Observable.just(page);
|
||||
})
|
||||
.flatMap(p -> {
|
||||
page.setImagePath(chapterCache.getImagePath(page.getImageUrl()));
|
||||
page.setStatus(Page.READY);
|
||||
return Observable.just(page);
|
||||
})
|
||||
.onErrorResumeNext(e -> {
|
||||
page.setStatus(Page.ERROR);
|
||||
return Observable.just(page);
|
||||
});
|
||||
}
|
||||
|
||||
private Observable<Page> cacheImage(final Page page) {
|
||||
page.setStatus(Page.DOWNLOAD_IMAGE);
|
||||
return getImageProgressResponse(page)
|
||||
.flatMap(resp -> {
|
||||
try {
|
||||
chapterCache.putImageToDiskCache(page.getImageUrl(), resp);
|
||||
} catch (IOException e) {
|
||||
return Observable.error(e);
|
||||
}
|
||||
return Observable.just(page);
|
||||
});
|
||||
}
|
||||
|
||||
public Observable<Response> getImageProgressResponse(final Page page) {
|
||||
return networkService.getProgressResponse(page.getImageUrl(), requestHeaders, page);
|
||||
}
|
||||
|
||||
public void savePageList(String chapterUrl, List<Page> pages) {
|
||||
if (pages != null)
|
||||
chapterCache.putPageUrlsToDiskCache(getChapterCacheKey(chapterUrl), pages);
|
||||
}
|
||||
|
||||
protected List<Page> convertToPages(List<String> pageUrls) {
|
||||
List<Page> pages = new ArrayList<>();
|
||||
for (int i = 0; i < pageUrls.size(); i++) {
|
||||
pages.add(new Page(i, pageUrls.get(i)));
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
protected List<Page> parseFirstPage(List<Page> pages, String unparsedHtml) {
|
||||
String firstImage = parseHtmlToImageUrl(unparsedHtml);
|
||||
pages.get(0).setImageUrl(firstImage);
|
||||
return pages;
|
||||
}
|
||||
|
||||
protected String getChapterCacheKey(String chapterUrl) {
|
||||
return getId() + chapterUrl;
|
||||
}
|
||||
|
||||
protected LazyHeaders.Builder glideHeadersBuilder() {
|
||||
LazyHeaders.Builder builder = new LazyHeaders.Builder();
|
||||
for (Map.Entry<String, List<String>> entry : requestHeaders.toMultimap().entrySet()) {
|
||||
builder.addHeader(entry.getKey(), entry.getValue().get(0));
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public LazyHeaders getGlideHeaders() {
|
||||
return glideHeaders;
|
||||
}
|
||||
|
||||
}
|
@ -1,5 +1,8 @@
|
||||
package eu.kanade.tachiyomi.data.source.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter;
|
||||
import eu.kanade.tachiyomi.data.network.ProgressListener;
|
||||
import rx.subjects.PublishSubject;
|
||||
|
||||
@ -8,6 +11,7 @@ public class Page implements ProgressListener {
|
||||
private int pageNumber;
|
||||
private String url;
|
||||
private String imageUrl;
|
||||
private transient Chapter chapter;
|
||||
private transient String imagePath;
|
||||
private transient volatile int status;
|
||||
private transient volatile int progress;
|
||||
@ -20,6 +24,14 @@ public class Page implements ProgressListener {
|
||||
public static final int READY = 3;
|
||||
public static final int ERROR = 4;
|
||||
|
||||
public Page(int pageNumber, String url) {
|
||||
this(pageNumber, url, null, null);
|
||||
}
|
||||
|
||||
public Page(int pageNumber, String url, String imageUrl) {
|
||||
this(pageNumber, url, imageUrl, null);
|
||||
}
|
||||
|
||||
public Page(int pageNumber, String url, String imageUrl, String imagePath) {
|
||||
this.pageNumber = pageNumber;
|
||||
this.url = url;
|
||||
@ -27,10 +39,6 @@ public class Page implements ProgressListener {
|
||||
this.imagePath = imagePath;
|
||||
}
|
||||
|
||||
public Page(int pageNumber, String url) {
|
||||
this(pageNumber, url, null, null);
|
||||
}
|
||||
|
||||
public int getPageNumber() {
|
||||
return pageNumber;
|
||||
}
|
||||
@ -82,4 +90,16 @@ public class Page implements ProgressListener {
|
||||
this.statusSubject = subject;
|
||||
}
|
||||
|
||||
public Chapter getChapter() {
|
||||
return chapter;
|
||||
}
|
||||
|
||||
public void setChapter(Chapter chapter) {
|
||||
this.chapter = chapter;
|
||||
}
|
||||
|
||||
public boolean isLastPage() {
|
||||
List<Page> chapterPages = chapter.getPages();
|
||||
return chapterPages.size() -1 == pageNumber;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,15 @@
|
||||
package eu.kanade.tachiyomi.data.source.online
|
||||
|
||||
import eu.kanade.tachiyomi.data.source.Source
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
|
||||
interface LoginSource : Source {
|
||||
|
||||
fun isLogged(): Boolean
|
||||
|
||||
fun login(username: String, password: String): Observable<Boolean>
|
||||
|
||||
fun isAuthenticationSuccessful(response: Response): Boolean
|
||||
|
||||
}
|
@ -0,0 +1,449 @@
|
||||
package eu.kanade.tachiyomi.data.source.online
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.App
|
||||
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 okhttp3.*
|
||||
import rx.Observable
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* A simple implementation for sources from a website.
|
||||
*
|
||||
* @param context the application context.
|
||||
*/
|
||||
abstract class OnlineSource(context: Context) : Source {
|
||||
|
||||
/**
|
||||
* Network service.
|
||||
*/
|
||||
@Inject lateinit var network: NetworkHelper
|
||||
|
||||
/**
|
||||
* Chapter cache.
|
||||
*/
|
||||
@Inject lateinit var chapterCache: ChapterCache
|
||||
|
||||
/**
|
||||
* Preferences helper.
|
||||
*/
|
||||
@Inject lateinit var preferences: PreferencesHelper
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Headers used for requests.
|
||||
*/
|
||||
val headers by lazy { headersBuilder().build() }
|
||||
|
||||
/**
|
||||
* Default network client for doing requests.
|
||||
*/
|
||||
open val client: OkHttpClient
|
||||
get() = network.client
|
||||
|
||||
init {
|
||||
// Inject dependencies.
|
||||
App.get(context).component.inject(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ->
|
||||
page.apply {
|
||||
mangas = mutableListOf<Manga>()
|
||||
popularMangaParse(response, this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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): Observable<MangasPage> = client
|
||||
.newCall(searchMangaRequest(page, query))
|
||||
.asObservable()
|
||||
.map { response ->
|
||||
page.apply {
|
||||
mangas = mutableListOf<Manga>()
|
||||
searchMangaParse(response, this, query)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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): Request {
|
||||
if (page.page == 1) {
|
||||
page.url = searchMangaInitialUrl(query)
|
||||
}
|
||||
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): 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)
|
||||
|
||||
/**
|
||||
* 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 protected 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 pageObservable = Observable.just(page)
|
||||
if (page.imageUrl.isNullOrEmpty())
|
||||
return pageObservable
|
||||
|
||||
return pageObservable
|
||||
.flatMap {
|
||||
if (!chapterCache.isImageInCache(page.imageUrl)) {
|
||||
cacheImage(page)
|
||||
} else {
|
||||
Observable.just(page)
|
||||
}
|
||||
}
|
||||
.doOnNext {
|
||||
page.imagePath = chapterCache.getImagePath(page.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
|
||||
|
||||
/**
|
||||
* Returns an absolute url from a href.
|
||||
*
|
||||
* Ex:
|
||||
* href="http://example.com/foo" url="http://example.com" -> http://example.com/foo
|
||||
* href="/mypath" url="http://example.com/foo" -> http://example.com/mypath
|
||||
* href="bar" url="http://example.com/foo" -> http://example.com/bar
|
||||
* href="bar" url="http://example.com/foo/" -> http://example.com/foo/bar
|
||||
*
|
||||
* @param href the href attribute from the html.
|
||||
* @param url the requested url.
|
||||
*/
|
||||
fun getAbsoluteUrl(href: String, url: HttpUrl) = when {
|
||||
href.startsWith("http://") || href.startsWith("https://") -> href
|
||||
href.startsWith("/") -> url.newBuilder().encodedPath("/").fragment(null).query(null)
|
||||
.toString() + href.substring(1)
|
||||
else -> url.toString().substringBeforeLast('/') + "/$href"
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Overridable method to allow custom parsing.
|
||||
open fun parseChapterNumber(chapter: Chapter) {
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,189 @@
|
||||
package eu.kanade.tachiyomi.data.source.online
|
||||
|
||||
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.model.MangasPage
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import okhttp3.Response
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
|
||||
/**
|
||||
* A simple implementation for sources from a website using Jsoup, an HTML parser.
|
||||
*
|
||||
* @param context the application context.
|
||||
*/
|
||||
abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
|
||||
|
||||
/**
|
||||
* 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 = Jsoup.parse(response.body().string())
|
||||
for (element in document.select(popularMangaSelector())) {
|
||||
Manga().apply {
|
||||
source = this@ParsedOnlineSource.id
|
||||
popularMangaFromElement(element, this)
|
||||
page.mangas.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
popularMangaNextPageSelector()?.let { selector ->
|
||||
page.nextPageUrl = document.select(selector).first()?.attr("href")?.let {
|
||||
getAbsoluteUrl(it, response.request().url())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
val document = Jsoup.parse(response.body().string())
|
||||
for (element in document.select(searchMangaSelector())) {
|
||||
Manga().apply {
|
||||
source = this@ParsedOnlineSource.id
|
||||
searchMangaFromElement(element, this)
|
||||
page.mangas.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
searchMangaNextPageSelector()?.let { selector ->
|
||||
page.nextPageUrl = document.select(selector).first()?.attr("href")?.let {
|
||||
getAbsoluteUrl(it, response.request().url())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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(Jsoup.parse(response.body().string()), 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 = Jsoup.parse(response.body().string())
|
||||
|
||||
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(Jsoup.parse(response.body().string()), 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(Jsoup.parse(response.body().string()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute url to the source image from the document.
|
||||
*
|
||||
* @param document the parsed document.
|
||||
*/
|
||||
abstract protected fun imageUrlParse(document: Document): String
|
||||
|
||||
}
|
@ -0,0 +1,165 @@
|
||||
package eu.kanade.tachiyomi.data.source.online
|
||||
|
||||
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.getLanguages
|
||||
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.data.source.model.Page
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(context) {
|
||||
|
||||
val map = YamlSourceNode(mappings)
|
||||
|
||||
override val name: String
|
||||
get() = map.name
|
||||
|
||||
override val baseUrl = map.host.let {
|
||||
if (it.endsWith("/")) it.dropLast(1) else it
|
||||
}
|
||||
|
||||
override val lang = map.lang.toUpperCase().let { code ->
|
||||
getLanguages().find { code == it.code }!!
|
||||
}
|
||||
|
||||
override val client = when(map.client) {
|
||||
"cloudflare" -> network.cloudflareClient
|
||||
else -> network.client
|
||||
}
|
||||
|
||||
override val id = map.id.let {
|
||||
if (it is Int) it else (lang.code.hashCode() + 31 * it.hashCode()) and 0x7fffffff
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: MangasPage): Request {
|
||||
if (page.page == 1) {
|
||||
page.url = popularMangaInitialUrl()
|
||||
}
|
||||
return when (map.popular.method?.toLowerCase()) {
|
||||
"post" -> POST(page.url, headers, map.popular.createForm())
|
||||
else -> GET(page.url, headers)
|
||||
}
|
||||
}
|
||||
|
||||
override fun popularMangaInitialUrl() = map.popular.url
|
||||
|
||||
override fun popularMangaParse(response: Response, page: MangasPage) {
|
||||
val document = Jsoup.parse(response.body().string())
|
||||
for (element in document.select(map.popular.manga_css)) {
|
||||
Manga().apply {
|
||||
source = this@YamlOnlineSource.id
|
||||
title = element.text()
|
||||
setUrl(element.attr("href"))
|
||||
page.mangas.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
map.popular.next_url_css?.let { selector ->
|
||||
page.nextPageUrl = document.select(selector).first()?.attr("href")?.let {
|
||||
getAbsoluteUrl(it, response.request().url())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: MangasPage, query: String): Request {
|
||||
if (page.page == 1) {
|
||||
page.url = searchMangaInitialUrl(query)
|
||||
}
|
||||
return when (map.search.method?.toLowerCase()) {
|
||||
"post" -> POST(page.url, headers, map.search.createForm())
|
||||
else -> GET(page.url, headers)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaInitialUrl(query: String) = map.search.url.replace("\$query", query)
|
||||
|
||||
override fun searchMangaParse(response: Response, page: MangasPage, query: String) {
|
||||
val document = Jsoup.parse(response.body().string())
|
||||
for (element in document.select(map.search.manga_css)) {
|
||||
Manga().apply {
|
||||
source = this@YamlOnlineSource.id
|
||||
title = element.text()
|
||||
setUrl(element.attr("href"))
|
||||
page.mangas.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
map.search.next_url_css?.let { selector ->
|
||||
page.nextPageUrl = document.select(selector).first()?.attr("href")?.let {
|
||||
getAbsoluteUrl(it, response.request().url())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response, manga: Manga) {
|
||||
val document = Jsoup.parse(response.body().string())
|
||||
with(map.manga) {
|
||||
val pool = parts.get(document)
|
||||
|
||||
manga.author = author?.process(document, pool)
|
||||
manga.artist = artist?.process(document, pool)
|
||||
manga.description = summary?.process(document, pool)
|
||||
manga.thumbnail_url = cover?.process(document, pool)
|
||||
manga.genre = genres?.process(document, pool)
|
||||
manga.status = status?.getStatus(document, pool) ?: Manga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response, chapters: MutableList<Chapter>) {
|
||||
val document = Jsoup.parse(response.body().string())
|
||||
with(map.chapters) {
|
||||
val pool = emptyMap<String, Element>()
|
||||
val dateFormat = SimpleDateFormat(date?.format, Locale.ENGLISH)
|
||||
|
||||
for (element in document.select(chapter_css)) {
|
||||
val chapter = Chapter.create()
|
||||
element.select(title).first().let {
|
||||
chapter.name = it.text()
|
||||
chapter.setUrl(it.attr("href"))
|
||||
}
|
||||
val dateElement = element.select(date?.select).first()
|
||||
chapter.date_upload = date?.getDate(dateElement, pool, dateFormat)?.time ?: 0
|
||||
chapters.add(chapter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response, pages: MutableList<Page>) {
|
||||
val document = Jsoup.parse(response.body().string())
|
||||
with(map.pages) {
|
||||
val url = response.request().url().toString()
|
||||
pages_css?.let {
|
||||
for (element in document.select(it)) {
|
||||
val value = element.attr(pages_attr)
|
||||
val pageUrl = replace?.let { url.replace(it.toRegex(), replacement!!.replace("\$value", value)) } ?: value
|
||||
pages.add(Page(pages.size, pageUrl))
|
||||
}
|
||||
}
|
||||
|
||||
for ((i, element) in document.select(image_css).withIndex()) {
|
||||
pages.getOrNull(i)?.imageUrl = element.attr(image_attr).let {
|
||||
getAbsoluteUrl(it, response.request().url())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
val document = Jsoup.parse(response.body().string())
|
||||
return with(map.pages) {
|
||||
document.select(image_css).first().attr(image_attr).let {
|
||||
getAbsoluteUrl(it, response.request().url())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,214 @@
|
||||
@file:Suppress("UNCHECKED_CAST")
|
||||
|
||||
package eu.kanade.tachiyomi.data.source.online
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.RequestBody
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
private fun toMap(map: Any?) = map as? Map<String, Any?>
|
||||
|
||||
class YamlSourceNode(uncheckedMap: Map<*, *>) {
|
||||
|
||||
val map = toMap(uncheckedMap)!!
|
||||
|
||||
val id: Any by map
|
||||
|
||||
val name: String by map
|
||||
|
||||
val host: String by map
|
||||
|
||||
val lang: String by map
|
||||
|
||||
val client: String?
|
||||
get() = map["client"] as? String
|
||||
|
||||
val popular = PopularNode(toMap(map["popular"])!!)
|
||||
|
||||
val search = SearchNode(toMap(map["search"])!!)
|
||||
|
||||
val manga = MangaNode(toMap(map["manga"])!!)
|
||||
|
||||
val chapters = ChaptersNode(toMap(map["chapters"])!!)
|
||||
|
||||
val pages = PagesNode(toMap(map["pages"])!!)
|
||||
}
|
||||
|
||||
interface RequestableNode {
|
||||
|
||||
val map: Map<String, Any?>
|
||||
|
||||
val url: String
|
||||
get() = map["url"] as String
|
||||
|
||||
val method: String?
|
||||
get() = map["method"] as? String
|
||||
|
||||
val payload: Map<String, String>?
|
||||
get() = map["payload"] as? Map<String, String>
|
||||
|
||||
fun createForm(): RequestBody {
|
||||
return FormBody.Builder().apply {
|
||||
payload?.let {
|
||||
for ((key, value) in it) {
|
||||
add(key, value)
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class PopularNode(override val map: Map<String, Any?>): RequestableNode {
|
||||
|
||||
val manga_css: String by map
|
||||
|
||||
val next_url_css: String?
|
||||
get() = map["next_url_css"] as? String
|
||||
|
||||
}
|
||||
|
||||
class SearchNode(override val map: Map<String, Any?>): RequestableNode {
|
||||
|
||||
val manga_css: String by map
|
||||
|
||||
val next_url_css: String?
|
||||
get() = map["next_url_css"] as? String
|
||||
}
|
||||
|
||||
class MangaNode(private val map: Map<String, Any?>) {
|
||||
|
||||
val parts = CacheNode(toMap(map["parts"]) ?: emptyMap())
|
||||
|
||||
val artist = toMap(map["artist"])?.let { SelectableNode(it) }
|
||||
|
||||
val author = toMap(map["author"])?.let { SelectableNode(it) }
|
||||
|
||||
val summary = toMap(map["summary"])?.let { SelectableNode(it) }
|
||||
|
||||
val status = toMap(map["status"])?.let { StatusNode(it) }
|
||||
|
||||
val genres = toMap(map["genres"])?.let { SelectableNode(it) }
|
||||
|
||||
val cover = toMap(map["cover"])?.let { CoverNode(it) }
|
||||
|
||||
}
|
||||
|
||||
class ChaptersNode(private val map: Map<String, Any?>) {
|
||||
|
||||
val chapter_css: String by map
|
||||
|
||||
val title: String by map
|
||||
|
||||
val date = toMap(toMap(map["date"]))?.let { DateNode(it) }
|
||||
}
|
||||
|
||||
class CacheNode(private val map: Map<String, Any?>) {
|
||||
|
||||
fun get(document: Document) = map.mapValues { document.select(it.value as String).first() }
|
||||
}
|
||||
|
||||
open class SelectableNode(private val map: Map<String, Any?>) {
|
||||
|
||||
val select: String by map
|
||||
|
||||
val from: String?
|
||||
get() = map["from"] as? String
|
||||
|
||||
open val attr: String?
|
||||
get() = map["attr"] as? String
|
||||
|
||||
val capture: String?
|
||||
get() = map["capture"] as? String
|
||||
|
||||
fun process(document: Element, cache: Map<String, Element>): String {
|
||||
val parent = from?.let { cache[it] } ?: document
|
||||
val node = parent.select(select).first()
|
||||
var text = attr?.let { node.attr(it) } ?: node.text()
|
||||
capture?.let {
|
||||
text = Regex(it).find(text)?.groupValues?.get(1) ?: text
|
||||
}
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
class StatusNode(private val map: Map<String, Any?>) : SelectableNode(map) {
|
||||
|
||||
val complete: String?
|
||||
get() = map["complete"] as? String
|
||||
|
||||
val ongoing: String?
|
||||
get() = map["ongoing"] as? String
|
||||
|
||||
val licensed: String?
|
||||
get() = map["licensed"] as? String
|
||||
|
||||
fun getStatus(document: Element, cache: Map<String, Element>): Int {
|
||||
val text = process(document, cache)
|
||||
complete?.let {
|
||||
if (text.contains(it)) return Manga.COMPLETED
|
||||
}
|
||||
ongoing?.let {
|
||||
if (text.contains(it)) return Manga.ONGOING
|
||||
}
|
||||
licensed?.let {
|
||||
if (text.contains(it)) return Manga.LICENSED
|
||||
}
|
||||
return Manga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
class CoverNode(private val map: Map<String, Any?>) : SelectableNode(map) {
|
||||
|
||||
override val attr: String?
|
||||
get() = map["attr"] as? String ?: "src"
|
||||
}
|
||||
|
||||
class DateNode(private val map: Map<String, Any?>) : SelectableNode(map) {
|
||||
|
||||
val format: String by map
|
||||
|
||||
fun getDate(document: Element, cache: Map<String, Element>, formatter: SimpleDateFormat): Date {
|
||||
val text = process(document, cache)
|
||||
try {
|
||||
return formatter.parse(text)
|
||||
} catch (exception: ParseException) {}
|
||||
|
||||
for (i in 0..7) {
|
||||
(map["day$i"] as? List<String>)?.let {
|
||||
it.find { it.toRegex().containsMatchIn(text) }?.let {
|
||||
return Calendar.getInstance().apply { add(Calendar.DATE, -i) }.time
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Date(0)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class PagesNode(private val map: Map<String, Any?>) {
|
||||
|
||||
val pages_css: String?
|
||||
get() = map["pages_css"] as? String
|
||||
|
||||
val pages_attr: String?
|
||||
get() = map["pages_attr"] as? String ?: "value"
|
||||
|
||||
val replace: String?
|
||||
get() = map["url_replace"] as? String
|
||||
|
||||
val replacement: String?
|
||||
get() = map["url_replacement"] as? String
|
||||
|
||||
val image_css: String by map
|
||||
|
||||
val image_attr: String
|
||||
get() = map["image_attr"] as? String ?: "src"
|
||||
|
||||
}
|
@ -1,367 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.source.online.english;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.squareup.okhttp.FormEncodingBuilder;
|
||||
import com.squareup.okhttp.Headers;
|
||||
import com.squareup.okhttp.Response;
|
||||
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.jsoup.select.Elements;
|
||||
|
||||
import java.net.HttpCookie;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter;
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga;
|
||||
import eu.kanade.tachiyomi.data.source.SourceManager;
|
||||
import eu.kanade.tachiyomi.data.source.base.LoginSource;
|
||||
import eu.kanade.tachiyomi.data.source.model.MangasPage;
|
||||
import eu.kanade.tachiyomi.data.source.model.Page;
|
||||
import eu.kanade.tachiyomi.util.Parser;
|
||||
import rx.Observable;
|
||||
|
||||
public class Batoto extends LoginSource {
|
||||
|
||||
public static final String NAME = "Batoto (EN)";
|
||||
public static final String BASE_URL = "http://bato.to";
|
||||
public static final String POPULAR_MANGAS_URL = BASE_URL + "/search_ajax?order_cond=views&order=desc&p=%d";
|
||||
public static final String SEARCH_URL = BASE_URL + "/search_ajax?name=%s&p=%s";
|
||||
public static final String CHAPTER_URL = "/areader?id=%s&p=1";
|
||||
public static final String PAGE_URL = BASE_URL + "/areader?id=%s&p=%s";
|
||||
public static final String MANGA_URL = "/comic_pop?id=%s";
|
||||
public static final String LOGIN_URL = BASE_URL + "/forums/index.php?app=core&module=global§ion=login";
|
||||
|
||||
private Pattern datePattern;
|
||||
private Map<String, Integer> dateFields;
|
||||
|
||||
public Batoto(Context context) {
|
||||
super(context);
|
||||
|
||||
datePattern = Pattern.compile("(\\d+|A|An)\\s+(.*?)s? ago.*");
|
||||
dateFields = new HashMap<String, Integer>() {{
|
||||
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);
|
||||
}};
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return NAME;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getId() {
|
||||
return SourceManager.BATOTO;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getBaseUrl() {
|
||||
return BASE_URL;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Headers.Builder headersBuilder() {
|
||||
Headers.Builder builder = super.headersBuilder();
|
||||
builder.add("Cookie", "lang_option=English");
|
||||
builder.add("Referer", "http://bato.to/reader");
|
||||
return builder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getInitialPopularMangasUrl() {
|
||||
return String.format(POPULAR_MANGAS_URL, 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getInitialSearchUrl(String query) {
|
||||
return String.format(SEARCH_URL, Uri.encode(query), 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String overrideMangaUrl(String defaultMangaUrl) {
|
||||
String mangaId = defaultMangaUrl.substring(defaultMangaUrl.lastIndexOf("r") + 1);
|
||||
return String.format(MANGA_URL, mangaId);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String overrideChapterUrl(String defaultPageUrl) {
|
||||
String id = defaultPageUrl.substring(defaultPageUrl.indexOf("#") + 1);
|
||||
return String.format(CHAPTER_URL, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String overridePageUrl(String defaultPageUrl) {
|
||||
int start = defaultPageUrl.indexOf("#") + 1;
|
||||
int end = defaultPageUrl.indexOf("_", start);
|
||||
String id = defaultPageUrl.substring(start, end);
|
||||
return String.format(PAGE_URL, id, defaultPageUrl.substring(end+1));
|
||||
}
|
||||
|
||||
private List<Manga> parseMangasFromHtml(Document parsedHtml) {
|
||||
List<Manga> mangaList = new ArrayList<>();
|
||||
|
||||
if (!parsedHtml.text().contains("No (more) comics found!")) {
|
||||
for (Element currentHtmlBlock : parsedHtml.select("tr:not([id]):not([class])")) {
|
||||
Manga manga = constructMangaFromHtmlBlock(currentHtmlBlock);
|
||||
mangaList.add(manga);
|
||||
}
|
||||
}
|
||||
return mangaList;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<Manga> parsePopularMangasFromHtml(Document parsedHtml) {
|
||||
return parseMangasFromHtml(parsedHtml);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String parseNextPopularMangasUrl(Document parsedHtml, MangasPage page) {
|
||||
Element next = Parser.element(parsedHtml, "#show_more_row");
|
||||
return next != null ? String.format(POPULAR_MANGAS_URL, page.page + 1) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<Manga> parseSearchFromHtml(Document parsedHtml) {
|
||||
return parseMangasFromHtml(parsedHtml);
|
||||
}
|
||||
|
||||
private Manga constructMangaFromHtmlBlock(Element htmlBlock) {
|
||||
Manga manga = new Manga();
|
||||
manga.source = getId();
|
||||
|
||||
Element urlElement = Parser.element(htmlBlock, "a[href^=http://bato.to]");
|
||||
if (urlElement != null) {
|
||||
manga.setUrl(urlElement.attr("href"));
|
||||
manga.title = urlElement.text().trim();
|
||||
}
|
||||
return manga;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String parseNextSearchUrl(Document parsedHtml, MangasPage page, String query) {
|
||||
Element next = Parser.element(parsedHtml, "#show_more_row");
|
||||
return next != null ? String.format(SEARCH_URL, query, page.page + 1) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Manga parseHtmlToManga(String mangaUrl, String unparsedHtml) {
|
||||
Document parsedDocument = Jsoup.parse(unparsedHtml);
|
||||
|
||||
Element tbody = parsedDocument.select("tbody").first();
|
||||
Element artistElement = tbody.select("tr:contains(Author/Artist:)").first();
|
||||
Elements genreElements = tbody.select("tr:contains(Genres:) img");
|
||||
|
||||
Manga manga = Manga.create(mangaUrl);
|
||||
manga.author = Parser.text(artistElement, "td:eq(1)");
|
||||
manga.artist = Parser.text(artistElement, "td:eq(2)", manga.author);
|
||||
manga.description = Parser.text(tbody, "tr:contains(Description:) > td:eq(1)");
|
||||
manga.thumbnail_url = Parser.src(parsedDocument, "img[src^=http://img.bato.to/forums/uploads/]");
|
||||
manga.status = parseStatus(Parser.text(parsedDocument, "tr:contains(Status:) > td:eq(1)"));
|
||||
|
||||
if (!genreElements.isEmpty()) {
|
||||
List<String> genres = new ArrayList<>();
|
||||
for (Element element : genreElements) {
|
||||
genres.add(element.attr("alt"));
|
||||
}
|
||||
manga.genre = TextUtils.join(", ", genres);
|
||||
}
|
||||
|
||||
manga.initialized = true;
|
||||
return manga;
|
||||
}
|
||||
|
||||
private int parseStatus(String status) {
|
||||
switch (status) {
|
||||
case "Ongoing":
|
||||
return Manga.ONGOING;
|
||||
case "Complete":
|
||||
return Manga.COMPLETED;
|
||||
default:
|
||||
return Manga.UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<Chapter> parseHtmlToChapters(String unparsedHtml) {
|
||||
Document parsedDocument = Jsoup.parse(unparsedHtml);
|
||||
|
||||
List<Chapter> chapterList = new ArrayList<>();
|
||||
|
||||
Elements chapterElements = parsedDocument.select("tr.row.lang_English.chapter_row");
|
||||
for (Element chapterElement : chapterElements) {
|
||||
Chapter chapter = constructChapterFromHtmlBlock(chapterElement);
|
||||
chapterList.add(chapter);
|
||||
}
|
||||
return chapterList;
|
||||
|
||||
}
|
||||
|
||||
private Chapter constructChapterFromHtmlBlock(Element chapterElement) {
|
||||
Chapter chapter = Chapter.create();
|
||||
|
||||
Element urlElement = chapterElement.select("a[href^=http://bato.to/reader").first();
|
||||
Element dateElement = chapterElement.select("td").get(4);
|
||||
|
||||
if (urlElement != null) {
|
||||
String fieldUrl = urlElement.attr("href");
|
||||
chapter.setUrl(fieldUrl);
|
||||
chapter.name = urlElement.text().trim();
|
||||
}
|
||||
if (dateElement != null) {
|
||||
chapter.date_upload = parseDateFromElement(dateElement);
|
||||
}
|
||||
return chapter;
|
||||
}
|
||||
|
||||
private long parseDateFromElement(Element dateElement) {
|
||||
String dateAsString = dateElement.text();
|
||||
|
||||
Date date;
|
||||
try {
|
||||
date = new SimpleDateFormat("dd MMMMM yyyy - hh:mm a", Locale.ENGLISH).parse(dateAsString);
|
||||
} catch (ParseException e) {
|
||||
Matcher m = datePattern.matcher(dateAsString);
|
||||
|
||||
if (m.matches()) {
|
||||
String number = m.group(1);
|
||||
int amount = number.contains("A") ? 1 : Integer.parseInt(m.group(1));
|
||||
String unit = m.group(2);
|
||||
|
||||
Calendar cal = Calendar.getInstance();
|
||||
// Not an error
|
||||
cal.add(dateFields.get(unit), -amount);
|
||||
date = cal.getTime();
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<String> parseHtmlToPageUrls(String unparsedHtml) {
|
||||
Document parsedDocument = Jsoup.parse(unparsedHtml);
|
||||
|
||||
List<String> pageUrlList = new ArrayList<>();
|
||||
|
||||
Element selectElement = Parser.element(parsedDocument, "#page_select");
|
||||
if (selectElement != null) {
|
||||
for (Element pageUrlElement : selectElement.select("option")) {
|
||||
pageUrlList.add(pageUrlElement.attr("value"));
|
||||
}
|
||||
} else {
|
||||
// For webtoons in one page
|
||||
for (int i = 0; i < parsedDocument.select("div > img").size(); i++) {
|
||||
pageUrlList.add("");
|
||||
}
|
||||
}
|
||||
|
||||
return pageUrlList;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<Page> parseFirstPage(List<Page> pages, String unparsedHtml) {
|
||||
if (!unparsedHtml.contains("Want to see this chapter per page instead?")) {
|
||||
String firstImage = parseHtmlToImageUrl(unparsedHtml);
|
||||
pages.get(0).setImageUrl(firstImage);
|
||||
} else {
|
||||
// For webtoons in one page
|
||||
Document parsedDocument = Jsoup.parse(unparsedHtml);
|
||||
Elements imageUrls = parsedDocument.select("div > img");
|
||||
for (int i = 0; i < pages.size(); i++) {
|
||||
pages.get(i).setImageUrl(imageUrls.get(i).attr("src"));
|
||||
}
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String parseHtmlToImageUrl(String unparsedHtml) {
|
||||
int beginIndex = unparsedHtml.indexOf("<img id=\"comic_page\"");
|
||||
int endIndex = unparsedHtml.indexOf("</a>", beginIndex);
|
||||
String trimmedHtml = unparsedHtml.substring(beginIndex, endIndex);
|
||||
|
||||
Document parsedDocument = Jsoup.parse(trimmedHtml);
|
||||
Element imageElement = parsedDocument.getElementById("comic_page");
|
||||
return imageElement.attr("src");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Observable<Boolean> login(String username, String password) {
|
||||
return networkService.getStringResponse(LOGIN_URL, requestHeaders, null)
|
||||
.flatMap(response -> doLogin(response, username, password))
|
||||
.map(this::isAuthenticationSuccessful);
|
||||
}
|
||||
|
||||
private Observable<Response> doLogin(String response, String username, String password) {
|
||||
Document doc = Jsoup.parse(response);
|
||||
Element form = doc.select("#login").first();
|
||||
String postUrl = form.attr("action");
|
||||
|
||||
FormEncodingBuilder formBody = new FormEncodingBuilder();
|
||||
Element authKey = form.select("input[name=auth_key]").first();
|
||||
|
||||
formBody.add(authKey.attr("name"), authKey.attr("value"));
|
||||
formBody.add("ips_username", username);
|
||||
formBody.add("ips_password", password);
|
||||
formBody.add("invisible", "1");
|
||||
formBody.add("rememberMe", "1");
|
||||
|
||||
return networkService.postData(postUrl, formBody.build(), requestHeaders);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isAuthenticationSuccessful(Response response) {
|
||||
return response.priorResponse() != null && response.priorResponse().code() == 302;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isLogged() {
|
||||
try {
|
||||
for ( HttpCookie cookie : networkService.getCookies().get(new URI(BASE_URL)) ) {
|
||||
if (cookie.getName().equals("pass_hash"))
|
||||
return true;
|
||||
}
|
||||
|
||||
} catch (URISyntaxException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Observable<List<Chapter>> pullChaptersFromNetwork(String mangaUrl) {
|
||||
Observable<List<Chapter>> observable;
|
||||
if (!isLogged()) {
|
||||
observable = login(prefs.getSourceUsername(this), prefs.getSourcePassword(this))
|
||||
.flatMap(result -> super.pullChaptersFromNetwork(mangaUrl));
|
||||
}
|
||||
else {
|
||||
observable = super.pullChaptersFromNetwork(mangaUrl);
|
||||
}
|
||||
return observable;
|
||||
}
|
||||
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user