Compare commits
170 Commits
Author | SHA1 | Date | |
---|---|---|---|
9baf3b5a09 | |||
ca3f0873f3 | |||
adb0201449 | |||
cf293642fb | |||
10e1106760 | |||
3f2d375a53 | |||
f8e121ee06 | |||
0ee005579b | |||
6ecd7fced8 | |||
aeaf4d78f8 | |||
7baf0ddcc2 | |||
d79e141fe5 | |||
030071e659 | |||
9cbf226cfd | |||
36aabf23e1 | |||
8b67255186 | |||
3186661420 | |||
46896d9e86 | |||
2c4fd340c8 | |||
ae6d052978 | |||
974891a085 | |||
d44cd16682 | |||
23e99a3ed8 | |||
024a457250 | |||
788cb843fc | |||
790e0908a3 | |||
7a45cd5b56 | |||
f61a8ce51d | |||
fcce29a467 | |||
5f568733f3 | |||
96340de17d | |||
3611f67fb4 | |||
353ccbd444 | |||
3c1179d27b | |||
e502caee9f | |||
da8b870670 | |||
6b26859983 | |||
7afd224aff | |||
62e7bead73 | |||
116f7d1c4a | |||
18f89cc341 | |||
7c99ae1b3b | |||
16dc4d298d | |||
762c378bd6 | |||
515289134e | |||
3d1afe7cf2 | |||
fd825b1049 | |||
136e90638a | |||
9bf071132d | |||
014bb2f426 | |||
56927927c8 | |||
b19a4d2977 | |||
f4b838d8e2 | |||
c6cfd24f19 | |||
10f36f40d6 | |||
9d5cf9163a | |||
9abce0cca3 | |||
c6245f4fa3 | |||
75fc160204 | |||
263198dd89 | |||
345f96055d | |||
51144aa45e | |||
86a599d13f | |||
0cf81e6f7a | |||
8874fe973c | |||
f8a03226ee | |||
32db1e3045 | |||
303e6c0102 | |||
18883f1ba3 | |||
5c31271e91 | |||
00981cf4e8 | |||
968f4a69e8 | |||
e7e1a9bf50 | |||
fe1becb001 | |||
7789171c71 | |||
3fd2222c99 | |||
6de36a88c0 | |||
b37685542d | |||
a2b1b9e746 | |||
8017324033 | |||
7464497c88 | |||
499def3daa | |||
6931b75cc5 | |||
f853610578 | |||
69f51b88bf | |||
e0d680201a | |||
1566b8f8b8 | |||
4bbf78e840 | |||
7ab16a69df | |||
95e60ed775 | |||
d38cd2547a | |||
2159b72e69 | |||
81c23bbf9d | |||
0d5b8edf31 | |||
fcdb80830b | |||
50b48ab25c | |||
31b45666b0 | |||
233e76724a | |||
af637a82c3 | |||
ea32ea11f2 | |||
1b7a0de745 | |||
50e0cb65d9 | |||
ba4807f62c | |||
5efc02a238 | |||
8e50ac67bc | |||
a3c03e8ceb | |||
5a3e30b30a | |||
e3ab90042d | |||
f35c15f7d2 | |||
32387cd034 | |||
cf5c816483 | |||
bf9b9ca54c | |||
0ca2ca33c2 | |||
51f25e96e9 | |||
1875047638 | |||
fa4d61eaf0 | |||
49eb638e15 | |||
fc1f290b85 | |||
9194dc0161 | |||
0d480dbf7c | |||
183e83684a | |||
7b4ac7998a | |||
d75c6b0c36 | |||
40b222f8bc | |||
aa7dfb7bee | |||
6c1453eb54 | |||
c1845aec83 | |||
eb8479ac9a | |||
636c027298 | |||
02e187f066 | |||
854112095b | |||
a71c805959 | |||
c3ced0d089 | |||
80996ea63e | |||
aff51f8af1 | |||
ccbb81e9f5 | |||
f88dd28c51 | |||
a65a71df5d | |||
22f2ecc433 | |||
7f90ad7847 | |||
1292c0ecea | |||
55b7d5025b | |||
6a310bbaa9 | |||
bc8753da85 | |||
7f63e318f1 | |||
6c749319cf | |||
7a4463e104 | |||
e1be4ba925 | |||
34d21c1de3 | |||
fae36aebf4 | |||
75e828923a | |||
b499b87f8c | |||
233dbec4b3 | |||
d56ff9592e | |||
08f6317beb | |||
a75457ad88 | |||
b0482003bd | |||
634356e72f | |||
6d3cc16ab1 | |||
6027671c09 | |||
29d0cb4a15 | |||
fe7001975a | |||
ac88f1c146 | |||
b5b86218c5 | |||
bdcc6e52e6 | |||
0eae817aa6 | |||
8994b42760 | |||
6a63ce992a | |||
9ae6285eef | |||
8f9737f567 |
2
.github/CONTRIBUTING.md
vendored
@ -1,5 +1,5 @@
|
|||||||
1. **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).**
|
1. **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).**
|
||||||
2. If you are unsure, ask here: [](https://discord.gg/WrBkRk4)
|
2. If you are unsure, ask here: [](https://discord.gg/tachiyomi)
|
||||||
3. What is your type of issue?
|
3. What is your type of issue?
|
||||||
* [Catalogue request](#catalogue-requests)
|
* [Catalogue request](#catalogue-requests)
|
||||||
* [Bugs](#bugs)
|
* [Bugs](#bugs)
|
||||||
|
16
.github/ISSUE_TEMPLATE.md
vendored
@ -1 +1,15 @@
|
|||||||
**Please read https://github.com/inorichi/tachiyomi/blob/master/.github/CONTRIBUTING.md before posting**
|
**DO NOT OPEN ISSUES/REQUESTS RELATING TO EXTENSIONS/CATALOGUES IN THIS REPOSITORY. Open them at the following repository https://github.com/inorichi/tachiyomi-extensions/**
|
||||||
|
|
||||||
|
**For all other requests Please fill out the form below and remove the first 3 lines of this template**
|
||||||
|
|
||||||
|
**App version:**
|
||||||
|
|
||||||
|
**Issue/Request:**
|
||||||
|
|
||||||
|
**Steps to reproduce (if applicable)**
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
**Other details:**
|
||||||
|
BIN
.github/readme-images/app-icon.png
vendored
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
.github/readme-images/screens.png
vendored
Normal file
After Width: | Height: | Size: 730 KiB |
@ -1,8 +1,8 @@
|
|||||||
language: android
|
language: android
|
||||||
android:
|
android:
|
||||||
components:
|
components:
|
||||||
- build-tools-27.0.1
|
- build-tools-28.0.3
|
||||||
- android-26
|
- android-27
|
||||||
- extra-android-m2repository
|
- extra-android-m2repository
|
||||||
- extra-google-m2repository
|
- extra-google-m2repository
|
||||||
- extra-android-support
|
- extra-android-support
|
||||||
@ -10,6 +10,7 @@ android:
|
|||||||
licenses:
|
licenses:
|
||||||
- android-sdk-license-.+
|
- android-sdk-license-.+
|
||||||
before_install:
|
before_install:
|
||||||
|
- yes | sdkmanager "platforms;android-27" # workaround for accepting the license
|
||||||
- if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then
|
- if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then
|
||||||
openssl aes-256-cbc -K $encrypted_e56be693d4fd_key -iv $encrypted_e56be693d4fd_iv -in "$PWD/.travis/secrets.tar.enc" -out secrets.tar -d;
|
openssl aes-256-cbc -K $encrypted_e56be693d4fd_key -iv $encrypted_e56be693d4fd_iv -in "$PWD/.travis/secrets.tar.enc" -out secrets.tar -d;
|
||||||
tar xf secrets.tar;
|
tar xf secrets.tar;
|
||||||
|
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
git fetch --unshallow #required for commit count
|
git fetch --unshallow #required for commit count
|
||||||
|
|
||||||
|
cp .travis/google-services.json app/
|
||||||
|
|
||||||
if [ -z "$TRAVIS_TAG" ]; then
|
if [ -z "$TRAVIS_TAG" ]; then
|
||||||
./gradlew clean assembleStandardDebug
|
./gradlew clean assembleStandardDebug
|
||||||
|
|
||||||
|
73
.travis/google-services.json
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "777921915939",
|
||||||
|
"firebase_url": "https://tachiyomi-47364.firebaseio.com",
|
||||||
|
"project_id": "tachiyomi-47364",
|
||||||
|
"storage_bucket": "tachiyomi-47364.appspot.com"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:777921915939:android:36544cd2d96c50c7",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "eu.kanade.tachiyomi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "777921915939-9q25jvgbdtpk91daqlk7sa1cbdcg77o6.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyAHr8RxyeiSPC_MxJTnivz-hmdo5oX0QQQ"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"analytics_service": {
|
||||||
|
"status": 1
|
||||||
|
},
|
||||||
|
"appinvite_service": {
|
||||||
|
"status": 1,
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
},
|
||||||
|
"ads_service": {
|
||||||
|
"status": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:777921915939:android:564fdc1d62efd1de",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "eu.kanade.tachiyomi.debug"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "777921915939-9q25jvgbdtpk91daqlk7sa1cbdcg77o6.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyAHr8RxyeiSPC_MxJTnivz-hmdo5oX0QQQ"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"analytics_service": {
|
||||||
|
"status": 1
|
||||||
|
},
|
||||||
|
"appinvite_service": {
|
||||||
|
"status": 1,
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
},
|
||||||
|
"ads_service": {
|
||||||
|
"status": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
73
README.md
@ -1,25 +1,70 @@
|
|||||||
| Build | Download | F-Droid | Contribute | Contact |
|
| Build | Stable | Dev | Contribute | Contact |
|
||||||
|-------|----------|---------|------------|---------|
|
|-------|----------|---------|------------|---------|
|
||||||
| [](https://travis-ci.org/inorichi/tachiyomi) | [](https://github.com/inorichi/tachiyomi/releases) [](http://tachiyomi.kanade.eu/latest) | [](https://f-droid.org/repository/browse/?fdid=eu.kanade.tachiyomi) [](//github.com/inorichi/tachiyomi/wiki/FDroid-for-dev-versions) | [](https://github.com/inorichi/tachiyomi/wiki/Translation) | [](https://discord.gg/WrBkRk4) |
|
| [](https://travis-ci.org/inorichi/tachiyomi) | [)](https://github.com/inorichi/tachiyomi/releases) | [](http://tachiyomi.kanade.eu/latest) [](//github.com/inorichi/tachiyomi/wiki/F-Droid-for-dev-versions) | [](https://hosted.weblate.org/engage/tachiyomi/?utm_source=widget) | [](https://discord.gg/tachiyomi) |
|
||||||
|
|
||||||
### **Contact us on [Discord](https://discord.gg/WrBkRk4)**
|
|
||||||
If you want to open an issue, please read [contributing guidelines](https://github.com/inorichi/tachiyomi/blob/master/.github/CONTRIBUTING.md). Your issue may be closed otherwise.
|
|
||||||
|
|
||||||
***
|
|
||||||
|
|
||||||
|
# Tachiyomi
|
||||||
Tachiyomi is a free and open source manga reader for Android.
|
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.
|

|
||||||
|
|
||||||
# Features
|
## Features
|
||||||
|
|
||||||
* Online and offline reading
|
Features include:
|
||||||
* Configurable reader with multiple viewers and settings
|
* Online reading from sources such as KissManga, MangaFox, [and more](https://github.com/inorichi/tachiyomi-extensions)
|
||||||
* MyAnimeList support
|
* Local reading of downloaded manga
|
||||||
* Resume from the next unread chapter
|
* Configurable reader with multiple viewers, reading directions and other settings
|
||||||
* Chapter filtering
|
* [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), and [Kitsu](https://kitsu.io/explore/anime) support
|
||||||
* Schedule searching for updates
|
|
||||||
* Categories to organize your library
|
* Categories to organize your library
|
||||||
|
* Light and dark themes
|
||||||
|
* Schedule updating your library for new chapters
|
||||||
|
* Create backups locally to read offline or to your desired cloud service
|
||||||
|
|
||||||
|
## Download
|
||||||
|
Get the app from our [releases page](https://github.com/inorichi/tachiyomi/releases).
|
||||||
|
|
||||||
|
If you want to try new features before they get to the stable release, you can download the dev version [here](http://tachiyomi.kanade.eu/latest) (auto-updates not included), or add our [F-Droid repo](https://github.com/inorichi/tachiyomi/wiki/F-Droid-for-dev-versions).
|
||||||
|
|
||||||
|
## Issues, Feature Requests and Contributing
|
||||||
|
|
||||||
|
Please make sure to read the full guidelines. Your issue may be closed without warning if you do not.
|
||||||
|
|
||||||
|
<details><summary>Issues</summary>
|
||||||
|
|
||||||
|
1. **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).**
|
||||||
|
2. If you are unsure, ask here: [](https://discord.gg/tachiyomi)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>Bugs</summary>
|
||||||
|
|
||||||
|
* 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)
|
||||||
|
* For large logs use http://pastebin.com/ (or similar)
|
||||||
|
* Don't group 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
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>Feature Requests</summary>
|
||||||
|
|
||||||
|
* Write a detailed issue, explaning what it should do or how. Avoid writing just "like X app does"
|
||||||
|
* Include screenshot (if needed)
|
||||||
|
|
||||||
|
Catalogue requests should be created at https://github.com/inorichi/tachiyomi-extensions, they do not belong in this repository.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
[See our wiki.](https://github.com/inorichi/tachiyomi/wiki/FAQ)
|
||||||
|
You can also reach out to us on [Discord](https://discord.gg/WrBkRk4).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
3
app/.gitignore
vendored
@ -1,4 +1,5 @@
|
|||||||
/build
|
/build
|
||||||
*iml
|
*iml
|
||||||
*.iml
|
*.iml
|
||||||
custom.gradle
|
custom.gradle
|
||||||
|
google-services.json
|
||||||
|
@ -29,17 +29,17 @@ ext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdkVersion 26
|
compileSdkVersion 27
|
||||||
buildToolsVersion "27.0.1"
|
buildToolsVersion '28.0.3'
|
||||||
publishNonDefault true
|
publishNonDefault true
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "eu.kanade.tachiyomi"
|
applicationId "eu.kanade.tachiyomi"
|
||||||
minSdkVersion 16
|
minSdkVersion 16
|
||||||
targetSdkVersion 26
|
targetSdkVersion 27
|
||||||
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
||||||
versionCode 29
|
versionCode 39
|
||||||
versionName "0.6.6"
|
versionName "0.8.1"
|
||||||
|
|
||||||
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
|
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
|
||||||
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
|
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
|
||||||
@ -102,7 +102,7 @@ android {
|
|||||||
dependencies {
|
dependencies {
|
||||||
|
|
||||||
// Modified dependencies
|
// Modified dependencies
|
||||||
implementation 'com.github.inorichi:subsampling-scale-image-view:c19b883'
|
implementation 'com.github.inorichi:subsampling-scale-image-view:ac0dae7'
|
||||||
implementation 'com.github.inorichi:junrar-android:634c1f5'
|
implementation 'com.github.inorichi:junrar-android:634c1f5'
|
||||||
|
|
||||||
// Android support library
|
// Android support library
|
||||||
@ -116,20 +116,22 @@ dependencies {
|
|||||||
implementation "com.android.support:support-annotations:$support_library_version"
|
implementation "com.android.support:support-annotations:$support_library_version"
|
||||||
implementation "com.android.support:customtabs:$support_library_version"
|
implementation "com.android.support:customtabs:$support_library_version"
|
||||||
|
|
||||||
implementation 'com.android.support.constraint:constraint-layout:1.0.2'
|
implementation 'com.android.support.constraint:constraint-layout:1.1.2'
|
||||||
|
|
||||||
implementation 'com.android.support:multidex:1.0.2'
|
implementation 'com.android.support:multidex:1.0.3'
|
||||||
|
|
||||||
|
standardImplementation 'com.google.firebase:firebase-core:11.8.0'
|
||||||
|
|
||||||
// ReactiveX
|
// ReactiveX
|
||||||
implementation 'io.reactivex:rxandroid:1.2.1'
|
implementation 'io.reactivex:rxandroid:1.2.1'
|
||||||
implementation 'io.reactivex:rxjava:1.3.4'
|
implementation 'io.reactivex:rxjava:1.3.6'
|
||||||
implementation 'com.jakewharton.rxrelay:rxrelay:1.2.0'
|
implementation 'com.jakewharton.rxrelay:rxrelay:1.2.0'
|
||||||
implementation 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
|
implementation 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
|
||||||
implementation 'com.github.pwittchen:reactivenetwork:0.7.0'
|
implementation 'com.github.pwittchen:reactivenetwork:0.7.0'
|
||||||
|
|
||||||
// Network client
|
// Network client
|
||||||
implementation "com.squareup.okhttp3:okhttp:3.9.1"
|
implementation "com.squareup.okhttp3:okhttp:3.10.0"
|
||||||
implementation 'com.squareup.okio:okio:1.13.0'
|
implementation 'com.squareup.okio:okio:1.14.0'
|
||||||
|
|
||||||
// REST
|
// REST
|
||||||
final retrofit_version = '2.3.0'
|
final retrofit_version = '2.3.0'
|
||||||
@ -141,9 +143,6 @@ dependencies {
|
|||||||
implementation 'com.google.code.gson:gson:2.8.2'
|
implementation 'com.google.code.gson:gson:2.8.2'
|
||||||
implementation 'com.github.salomonbrys.kotson:kotson:2.5.0'
|
implementation 'com.github.salomonbrys.kotson:kotson:2.5.0'
|
||||||
|
|
||||||
// YAML
|
|
||||||
implementation 'com.github.bmoliveira:snake-yaml:v1.18-android'
|
|
||||||
|
|
||||||
// JavaScript engine
|
// JavaScript engine
|
||||||
implementation 'com.squareup.duktape:duktape-android:1.2.0'
|
implementation 'com.squareup.duktape:duktape-android:1.2.0'
|
||||||
|
|
||||||
@ -155,14 +154,15 @@ dependencies {
|
|||||||
implementation 'org.jsoup:jsoup:1.10.2'
|
implementation 'org.jsoup:jsoup:1.10.2'
|
||||||
|
|
||||||
// Job scheduling
|
// Job scheduling
|
||||||
implementation 'com.evernote:android-job:1.2.1'
|
implementation 'com.evernote:android-job:1.2.5'
|
||||||
implementation 'com.google.android.gms:play-services-gcm:11.6.2'
|
implementation 'com.google.android.gms:play-services-gcm:11.8.0'
|
||||||
|
|
||||||
// Changelog
|
// Changelog
|
||||||
implementation 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
|
implementation 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
implementation "com.pushtorefresh.storio:sqlite:1.13.0"
|
implementation 'eu.kanade.storio:storio:1.13.0'
|
||||||
|
implementation 'io.requery:sqlite-android:3.25.2'
|
||||||
|
|
||||||
// Model View Presenter
|
// Model View Presenter
|
||||||
final nucleus_version = '3.0.0'
|
final nucleus_version = '3.0.0'
|
||||||
@ -170,19 +170,19 @@ dependencies {
|
|||||||
implementation "info.android15.nucleus:nucleus-support-v7:$nucleus_version"
|
implementation "info.android15.nucleus:nucleus-support-v7:$nucleus_version"
|
||||||
|
|
||||||
// Dependency injection
|
// Dependency injection
|
||||||
implementation "uy.kohesive.injekt:injekt-core:1.16.1"
|
implementation "com.github.inorichi.injekt:injekt-core:65b0440"
|
||||||
|
|
||||||
// Image library
|
// Image library
|
||||||
final glide_version = '4.3.1'
|
final glide_version = '4.6.1'
|
||||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||||
implementation "com.github.bumptech.glide:okhttp3-integration:$glide_version"
|
implementation "com.github.bumptech.glide:okhttp3-integration:$glide_version"
|
||||||
kapt "com.github.bumptech.glide:compiler:$glide_version"
|
kapt "com.github.bumptech.glide:compiler:$glide_version"
|
||||||
|
|
||||||
// Transformations
|
// Transformations
|
||||||
implementation 'jp.wasabeef:glide-transformations:3.0.1'
|
implementation 'jp.wasabeef:glide-transformations:3.1.1'
|
||||||
|
|
||||||
// Logging
|
// Logging
|
||||||
implementation 'com.jakewharton.timber:timber:4.6.0'
|
implementation 'com.jakewharton.timber:timber:4.6.1'
|
||||||
|
|
||||||
// Crash reports
|
// Crash reports
|
||||||
implementation 'ch.acra:acra:4.9.2'
|
implementation 'ch.acra:acra:4.9.2'
|
||||||
@ -193,19 +193,24 @@ dependencies {
|
|||||||
// UI
|
// UI
|
||||||
implementation 'com.dmitrymalkovich.android:material-design-dimens:1.4'
|
implementation 'com.dmitrymalkovich.android:material-design-dimens:1.4'
|
||||||
implementation 'com.github.dmytrodanylyk.android-process-button:library:1.0.4'
|
implementation 'com.github.dmytrodanylyk.android-process-button:library:1.0.4'
|
||||||
implementation 'eu.davidea:flexible-adapter:5.0.0-rc3'
|
implementation 'eu.davidea:flexible-adapter:5.0.0-rc4'
|
||||||
|
implementation 'eu.davidea:flexible-adapter-ui:1.0.0-b1'
|
||||||
implementation 'com.nononsenseapps:filepicker:2.5.2'
|
implementation 'com.nononsenseapps:filepicker:2.5.2'
|
||||||
implementation 'com.github.amulyakhare:TextDrawable:558677e'
|
implementation 'com.github.amulyakhare:TextDrawable:558677e'
|
||||||
implementation('com.afollestad.material-dialogs:core:0.9.4.7') {
|
implementation 'com.afollestad.material-dialogs:core:0.9.6.0'
|
||||||
exclude group: "com.android.support", module: "support-v13"
|
|
||||||
}
|
|
||||||
implementation 'me.zhanghai.android.systemuihelper:library:1.0.0'
|
implementation 'me.zhanghai.android.systemuihelper:library:1.0.0'
|
||||||
implementation 'com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.0.4'
|
implementation 'com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.0.4'
|
||||||
implementation 'com.github.mthli:Slice:v1.2'
|
implementation 'com.github.mthli:Slice:v1.2'
|
||||||
|
implementation 'me.gujun.android.taggroup:library:1.4@aar'
|
||||||
|
implementation 'com.github.chrisbanes:PhotoView:2.1.3'
|
||||||
|
implementation 'com.github.inorichi:DirectionalViewPager:3acc51a'
|
||||||
|
|
||||||
// Conductor
|
// Conductor
|
||||||
implementation "com.bluelinelabs:conductor:2.1.4"
|
implementation 'com.bluelinelabs:conductor:2.1.5'
|
||||||
implementation 'com.github.inorichi:conductor-support-preference:26.0.2'
|
implementation ("com.bluelinelabs:conductor-support:2.1.5") {
|
||||||
|
exclude group: "com.android.support"
|
||||||
|
}
|
||||||
|
implementation 'com.github.inorichi:conductor-support-preference:27.0.2'
|
||||||
|
|
||||||
// RxBindings
|
// RxBindings
|
||||||
final rxbindings_version = '1.0.1'
|
final rxbindings_version = '1.0.1'
|
||||||
@ -226,13 +231,13 @@ dependencies {
|
|||||||
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||||
|
|
||||||
final coroutines_version = '0.19.1'
|
final coroutines_version = '0.22.2'
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.2.0'
|
ext.kotlin_version = '1.2.71'
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
@ -250,6 +255,11 @@ kotlin {
|
|||||||
coroutines 'enable'
|
coroutines 'enable'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
androidExtensions {
|
androidExtensions {
|
||||||
experimental = true
|
experimental = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (getGradle().getStartParameter().getTaskRequests().toString().contains("Standard")) {
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
}
|
||||||
|
34
app/src/debug/res/drawable/ic_launcher_foreground.xml
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108.0"
|
||||||
|
android:viewportHeight="108.0">
|
||||||
|
<path
|
||||||
|
android:pathData="M14.5,7L86.5,7A7,7 0,0 1,93.5 14L93.5,95A7,7 0,0 1,86.5 102L14.5,102A7,7 0,0 1,7.5 95L7.5,14A7,7 0,0 1,14.5 7z"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:fillColor="#000"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M14.5,7L86.5,7A7,7 0,0 1,93.5 14L93.5,95A7,7 0,0 1,86.5 102L14.5,102A7,7 0,0 1,7.5 95L7.5,14A7,7 0,0 1,14.5 7z"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:fillColor="#455A64"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M7.5,12.01C7.5,9.24 9.74,7 12.5,7L17.5,7L17.5,102L12.5,102C9.74,102 7.5,99.77 7.5,96.99L7.5,12.01Z"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:fillColor="#607D8B"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M54,54.5m-25.5,0a25.5,25.5 0,1 1,51 0a25.5,25.5 0,1 1,-51 0"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:fillColor="#000"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M54,54.5m-25.5,0a25.5,25.5 0,1 1,51 0a25.5,25.5 0,1 1,-51 0"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:fillColor="#CE2828"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M54,54.5m-19.94,0a19.94,19.94 0,1 1,39.87 0a19.94,19.94 0,1 1,-39.87 0"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:fillColor="#FFF"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M52.04,46.3L47.42,46.3C46.14,46.3 44.93,46.23 44.2,46.14L44.2,49.76C45,49.65 46.16,49.6 47.42,49.6L60.58,49.6C61.86,49.6 63.02,49.65 63.82,49.76L63.82,46.14C63.09,46.23 61.86,46.3 60.58,46.3L55.69,46.3L55.69,45.07C55.69,44.43 55.73,43.95 55.82,43.45L51.9,43.45C51.99,44 52.04,44.43 52.04,45.07L52.04,46.3ZM46.78,60.68C45.46,60.68 44.29,60.63 43.45,60.52L43.45,64.14C44.34,64.03 45.46,63.98 46.78,63.98L61.29,63.98C62.57,63.98 63.71,64.03 64.57,64.14L64.57,60.52C63.73,60.63 62.57,60.68 61.29,60.68L58.24,60.68C59.33,58.06 59.99,56.23 60.7,53.91C61.34,51.81 61.34,51.81 61.56,51.13L57.58,50.06C57.51,50.93 57.37,51.52 56.89,53.41C56.19,56.14 55.32,58.74 54.5,60.68L46.78,60.68ZM46.48,51.36C47.55,54.02 48.28,56.53 49.03,60.15L52.66,58.9C51.65,54.98 50.92,52.66 49.94,50.11L46.48,51.36Z"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:fillColor="#000"/>
|
||||||
|
</vector>
|
5
app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@android:color/transparent"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@android:color/transparent"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
BIN
app/src/debug/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.4 KiB |
BIN
app/src/debug/res/mipmap-hdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 5.9 KiB |
BIN
app/src/debug/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/debug/res/mipmap-mdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
app/src/debug/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 4.0 KiB |
BIN
app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 7.7 KiB |
BIN
app/src/debug/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 8.4 KiB |
BIN
app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 8.7 KiB |
BIN
app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 18 KiB |
@ -32,8 +32,7 @@
|
|||||||
<meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts"/>
|
<meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts"/>
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.reader.ReaderActivity"
|
android:name=".ui.reader.ReaderActivity" />
|
||||||
android:theme="@style/Theme.Reader" />
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".widget.CustomLayoutPickerActivity"
|
android:name=".widget.CustomLayoutPickerActivity"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@ -52,6 +51,9 @@
|
|||||||
android:scheme="tachiyomi" />
|
android:scheme="tachiyomi" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".extension.util.ExtensionInstallActivity"
|
||||||
|
android:theme="@android:style/Theme.Translucent.NoTitleBar"/>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="android.support.v4.content.FileProvider"
|
android:name="android.support.v4.content.FileProvider"
|
||||||
@ -63,16 +65,6 @@
|
|||||||
android:resource="@xml/provider_paths" />
|
android:resource="@xml/provider_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
<provider
|
|
||||||
android:name="eu.kanade.tachiyomi.util.ZipContentProvider"
|
|
||||||
android:authorities="${applicationId}.zip-provider"
|
|
||||||
android:exported="false" />
|
|
||||||
|
|
||||||
<provider
|
|
||||||
android:name="eu.kanade.tachiyomi.util.RarContentProvider"
|
|
||||||
android:authorities="${applicationId}.rar-provider"
|
|
||||||
android:exported="false" />
|
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".data.notification.NotificationReceiver"
|
android:name=".data.notification.NotificationReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
@ -86,7 +78,7 @@
|
|||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".data.updater.UpdateDownloaderService"
|
android:name=".data.updater.UpdaterService"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
@ -8,7 +8,7 @@ import com.evernote.android.job.JobManager
|
|||||||
import eu.kanade.tachiyomi.data.backup.BackupCreatorJob
|
import eu.kanade.tachiyomi.data.backup.BackupCreatorJob
|
||||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
||||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||||
import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob
|
import eu.kanade.tachiyomi.data.updater.UpdaterJob
|
||||||
import eu.kanade.tachiyomi.util.LocaleHelper
|
import eu.kanade.tachiyomi.util.LocaleHelper
|
||||||
import org.acra.ACRA
|
import org.acra.ACRA
|
||||||
import org.acra.annotation.ReportsCrashes
|
import org.acra.annotation.ReportsCrashes
|
||||||
@ -28,11 +28,11 @@ open class App : Application() {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
|
||||||
|
|
||||||
Injekt = InjektScope(DefaultRegistrar())
|
Injekt = InjektScope(DefaultRegistrar())
|
||||||
Injekt.importModule(AppModule(this))
|
Injekt.importModule(AppModule(this))
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
|
|
||||||
|
|
||||||
setupAcra()
|
setupAcra()
|
||||||
setupJobManager()
|
setupJobManager()
|
||||||
setupNotificationChannels()
|
setupNotificationChannels()
|
||||||
@ -57,13 +57,17 @@ open class App : Application() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected open fun setupJobManager() {
|
protected open fun setupJobManager() {
|
||||||
JobManager.create(this).addJobCreator { tag ->
|
try {
|
||||||
when (tag) {
|
JobManager.create(this).addJobCreator { tag ->
|
||||||
LibraryUpdateJob.TAG -> LibraryUpdateJob()
|
when (tag) {
|
||||||
UpdateCheckerJob.TAG -> UpdateCheckerJob()
|
LibraryUpdateJob.TAG -> LibraryUpdateJob()
|
||||||
BackupCreatorJob.TAG -> BackupCreatorJob()
|
UpdaterJob.TAG -> UpdaterJob()
|
||||||
else -> null
|
BackupCreatorJob.TAG -> BackupCreatorJob()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w("Can't initialize job manager")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,34 +8,55 @@ import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
|||||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
|
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import eu.kanade.tachiyomi.source.SourceManager
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
import uy.kohesive.injekt.api.InjektModule
|
import rx.Observable
|
||||||
import uy.kohesive.injekt.api.InjektRegistrar
|
import rx.schedulers.Schedulers
|
||||||
import uy.kohesive.injekt.api.addSingletonFactory
|
import uy.kohesive.injekt.api.*
|
||||||
|
|
||||||
class AppModule(val app: Application) : InjektModule {
|
class AppModule(val app: Application) : InjektModule {
|
||||||
|
|
||||||
override fun InjektRegistrar.registerInjectables() {
|
override fun InjektRegistrar.registerInjectables() {
|
||||||
|
|
||||||
addSingletonFactory { PreferencesHelper(app) }
|
addSingleton(app)
|
||||||
|
|
||||||
addSingletonFactory { DatabaseHelper(app) }
|
addSingletonFactory { PreferencesHelper(app) }
|
||||||
|
|
||||||
addSingletonFactory { ChapterCache(app) }
|
addSingletonFactory { DatabaseHelper(app) }
|
||||||
|
|
||||||
addSingletonFactory { CoverCache(app) }
|
addSingletonFactory { ChapterCache(app) }
|
||||||
|
|
||||||
addSingletonFactory { NetworkHelper(app) }
|
addSingletonFactory { CoverCache(app) }
|
||||||
|
|
||||||
addSingletonFactory { SourceManager(app) }
|
addSingletonFactory { NetworkHelper(app) }
|
||||||
|
|
||||||
addSingletonFactory { DownloadManager(app) }
|
addSingletonFactory { SourceManager(app).also { get<ExtensionManager>().init(it) } }
|
||||||
|
|
||||||
addSingletonFactory { TrackManager(app) }
|
addSingletonFactory { ExtensionManager(app) }
|
||||||
|
|
||||||
addSingletonFactory { Gson() }
|
addSingletonFactory { DownloadManager(app) }
|
||||||
|
|
||||||
|
addSingletonFactory { TrackManager(app) }
|
||||||
|
|
||||||
|
addSingletonFactory { Gson() }
|
||||||
|
|
||||||
|
// Asynchronously init expensive components for a faster cold start
|
||||||
|
|
||||||
|
rxAsync { get<PreferencesHelper>() }
|
||||||
|
|
||||||
|
rxAsync { get<NetworkHelper>() }
|
||||||
|
|
||||||
|
rxAsync { get<SourceManager>() }
|
||||||
|
|
||||||
|
rxAsync { get<DatabaseHelper>() }
|
||||||
|
|
||||||
|
rxAsync { get<DownloadManager>() }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
private fun rxAsync(block: () -> Unit) {
|
||||||
|
Observable.fromCallable { block() }.subscribeOn(Schedulers.computation()).subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
@ -3,7 +3,7 @@ package eu.kanade.tachiyomi
|
|||||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob
|
import eu.kanade.tachiyomi.data.updater.UpdaterJob
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
object Migrations {
|
object Migrations {
|
||||||
@ -25,7 +25,7 @@ object Migrations {
|
|||||||
if (oldVersion < 14) {
|
if (oldVersion < 14) {
|
||||||
// Restore jobs after upgrading to evernote's job scheduler.
|
// Restore jobs after upgrading to evernote's job scheduler.
|
||||||
if (BuildConfig.INCLUDE_UPDATER && preferences.automaticUpdates()) {
|
if (BuildConfig.INCLUDE_UPDATER && preferences.automaticUpdates()) {
|
||||||
UpdateCheckerJob.setupTask()
|
UpdaterJob.setupTask()
|
||||||
}
|
}
|
||||||
LibraryUpdateJob.setupTask()
|
LibraryUpdateJob.setupTask()
|
||||||
}
|
}
|
||||||
|
@ -4,17 +4,8 @@ import android.app.IntentService
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.github.salomonbrys.kotson.set
|
|
||||||
import com.google.gson.JsonArray
|
import com.google.gson.JsonArray
|
||||||
import com.google.gson.JsonObject
|
|
||||||
import com.hippo.unifile.UniFile
|
|
||||||
import eu.kanade.tachiyomi.data.backup.models.Backup
|
|
||||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
|
|
||||||
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGAS
|
|
||||||
import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.util.sendLocalBroadcast
|
|
||||||
import timber.log.Timber
|
|
||||||
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -26,8 +17,6 @@ class BackupCreateService : IntentService(NAME) {
|
|||||||
// Name of class
|
// Name of class
|
||||||
private const val NAME = "BackupCreateService"
|
private const val NAME = "BackupCreateService"
|
||||||
|
|
||||||
// Backup called from job
|
|
||||||
private const val EXTRA_IS_JOB = "$ID.$NAME.EXTRA_IS_JOB"
|
|
||||||
// Options for backup
|
// Options for backup
|
||||||
private const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS"
|
private const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS"
|
||||||
|
|
||||||
@ -48,12 +37,10 @@ class BackupCreateService : IntentService(NAME) {
|
|||||||
* @param context context of application
|
* @param context context of application
|
||||||
* @param uri path of Uri
|
* @param uri path of Uri
|
||||||
* @param flags determines what to backup
|
* @param flags determines what to backup
|
||||||
* @param isJob backup called from job
|
|
||||||
*/
|
*/
|
||||||
fun makeBackup(context: Context, uri: Uri, flags: Int, isJob: Boolean = false) {
|
fun makeBackup(context: Context, uri: Uri, flags: Int) {
|
||||||
val intent = Intent(context, BackupCreateService::class.java).apply {
|
val intent = Intent(context, BackupCreateService::class.java).apply {
|
||||||
putExtra(BackupConst.EXTRA_URI, uri)
|
putExtra(BackupConst.EXTRA_URI, uri)
|
||||||
putExtra(EXTRA_IS_JOB, isJob)
|
|
||||||
putExtra(EXTRA_FLAGS, flags)
|
putExtra(EXTRA_FLAGS, flags)
|
||||||
}
|
}
|
||||||
context.startService(intent)
|
context.startService(intent)
|
||||||
@ -68,95 +55,9 @@ class BackupCreateService : IntentService(NAME) {
|
|||||||
|
|
||||||
// Get values
|
// Get values
|
||||||
val uri = intent.getParcelableExtra<Uri>(BackupConst.EXTRA_URI)
|
val uri = intent.getParcelableExtra<Uri>(BackupConst.EXTRA_URI)
|
||||||
val isJob = intent.getBooleanExtra(EXTRA_IS_JOB, false)
|
|
||||||
val flags = intent.getIntExtra(EXTRA_FLAGS, 0)
|
val flags = intent.getIntExtra(EXTRA_FLAGS, 0)
|
||||||
// Create backup
|
// Create backup
|
||||||
createBackupFromApp(uri, flags, isJob)
|
backupManager.createBackup(uri, flags, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
}
|
||||||
* Create backup Json file from database
|
|
||||||
*
|
|
||||||
* @param uri path of Uri
|
|
||||||
* @param isJob backup called from job
|
|
||||||
*/
|
|
||||||
private fun createBackupFromApp(uri: Uri, flags: Int, isJob: Boolean) {
|
|
||||||
// Create root object
|
|
||||||
val root = JsonObject()
|
|
||||||
|
|
||||||
// Create manga array
|
|
||||||
val mangaEntries = JsonArray()
|
|
||||||
|
|
||||||
// Create category array
|
|
||||||
val categoryEntries = JsonArray()
|
|
||||||
|
|
||||||
// Add value's to root
|
|
||||||
root[VERSION] = Backup.CURRENT_VERSION
|
|
||||||
root[MANGAS] = mangaEntries
|
|
||||||
root[CATEGORIES] = categoryEntries
|
|
||||||
|
|
||||||
backupManager.databaseHelper.inTransaction {
|
|
||||||
// Get manga from database
|
|
||||||
val mangas = backupManager.getFavoriteManga()
|
|
||||||
|
|
||||||
// Backup library manga and its dependencies
|
|
||||||
mangas.forEach { manga ->
|
|
||||||
mangaEntries.add(backupManager.backupMangaObject(manga, flags))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backup categories
|
|
||||||
if ((flags and BACKUP_CATEGORY_MASK) == BACKUP_CATEGORY) {
|
|
||||||
backupManager.backupCategories(categoryEntries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// When BackupCreatorJob
|
|
||||||
if (isJob) {
|
|
||||||
// Get dir of file and create
|
|
||||||
var dir = UniFile.fromUri(this, uri)
|
|
||||||
dir = dir.createDirectory("automatic")
|
|
||||||
|
|
||||||
// Delete older backups
|
|
||||||
val numberOfBackups = backupManager.numberOfBackups()
|
|
||||||
val backupRegex = Regex("""tachiyomi_\d+-\d+-\d+_\d+-\d+.json""")
|
|
||||||
dir.listFiles { _, filename -> backupRegex.matches(filename) }
|
|
||||||
.orEmpty()
|
|
||||||
.sortedByDescending { it.name }
|
|
||||||
.drop(numberOfBackups - 1)
|
|
||||||
.forEach { it.delete() }
|
|
||||||
|
|
||||||
// Create new file to place backup
|
|
||||||
val newFile = dir.createFile(Backup.getDefaultFilename())
|
|
||||||
?: throw Exception("Couldn't create backup file")
|
|
||||||
|
|
||||||
newFile.openOutputStream().bufferedWriter().use {
|
|
||||||
backupManager.parser.toJson(root, it)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val file = UniFile.fromUri(this, uri)
|
|
||||||
?: throw Exception("Couldn't create backup file")
|
|
||||||
file.openOutputStream().bufferedWriter().use {
|
|
||||||
backupManager.parser.toJson(root, it)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show completed dialog
|
|
||||||
val intent = Intent(BackupConst.INTENT_FILTER).apply {
|
|
||||||
putExtra(BackupConst.ACTION, BackupConst.ACTION_BACKUP_COMPLETED_DIALOG)
|
|
||||||
putExtra(BackupConst.EXTRA_URI, file.uri.toString())
|
|
||||||
}
|
|
||||||
sendLocalBroadcast(intent)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e)
|
|
||||||
if (!isJob) {
|
|
||||||
// Show error dialog
|
|
||||||
val intent = Intent(BackupConst.INTENT_FILTER).apply {
|
|
||||||
putExtra(BackupConst.ACTION, BackupConst.ACTION_ERROR_BACKUP_DIALOG)
|
|
||||||
putExtra(BackupConst.EXTRA_ERROR_MESSAGE, e.message)
|
|
||||||
}
|
|
||||||
sendLocalBroadcast(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -13,9 +13,10 @@ class BackupCreatorJob : Job() {
|
|||||||
|
|
||||||
override fun onRunJob(params: Params): Result {
|
override fun onRunJob(params: Params): Result {
|
||||||
val preferences = Injekt.get<PreferencesHelper>()
|
val preferences = Injekt.get<PreferencesHelper>()
|
||||||
|
val backupManager = BackupManager(context)
|
||||||
val uri = Uri.parse(preferences.backupsDirectory().getOrDefault())
|
val uri = Uri.parse(preferences.backupsDirectory().getOrDefault())
|
||||||
val flags = BackupCreateService.BACKUP_ALL
|
val flags = BackupCreateService.BACKUP_ALL
|
||||||
BackupCreateService.makeBackup(context, uri, flags, true)
|
backupManager.createBackup(uri, flags, true)
|
||||||
return Result.SUCCESS
|
return Result.SUCCESS
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,4 +39,4 @@ class BackupCreatorJob : Job() {
|
|||||||
JobManager.instance().cancelAllForTag(TAG)
|
JobManager.instance().cancelAllForTag(TAG)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
package eu.kanade.tachiyomi.data.backup
|
package eu.kanade.tachiyomi.data.backup
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import com.github.salomonbrys.kotson.*
|
import com.github.salomonbrys.kotson.*
|
||||||
import com.google.gson.*
|
import com.google.gson.*
|
||||||
|
import com.hippo.unifile.UniFile
|
||||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY
|
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY
|
||||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY_MASK
|
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY_MASK
|
||||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER
|
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER
|
||||||
@ -11,6 +14,7 @@ import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HIST
|
|||||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY_MASK
|
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY_MASK
|
||||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK
|
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK
|
||||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK_MASK
|
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK_MASK
|
||||||
|
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
|
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
|
||||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS
|
import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS
|
||||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CURRENT_VERSION
|
import eu.kanade.tachiyomi.data.backup.models.Backup.CURRENT_VERSION
|
||||||
@ -26,8 +30,10 @@ import eu.kanade.tachiyomi.data.preference.getOrDefault
|
|||||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.source.SourceManager
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
|
import eu.kanade.tachiyomi.util.sendLocalBroadcast
|
||||||
import eu.kanade.tachiyomi.util.syncChaptersWithSource
|
import eu.kanade.tachiyomi.util.syncChaptersWithSource
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
import timber.log.Timber
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
|
class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
|
||||||
@ -85,6 +91,92 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
|
|||||||
else -> throw Exception("Json version unknown")
|
else -> throw Exception("Json version unknown")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create backup Json file from database
|
||||||
|
*
|
||||||
|
* @param uri path of Uri
|
||||||
|
* @param isJob backup called from job
|
||||||
|
*/
|
||||||
|
fun createBackup(uri: Uri, flags: Int, isJob: Boolean) {
|
||||||
|
// Create root object
|
||||||
|
val root = JsonObject()
|
||||||
|
|
||||||
|
// Create manga array
|
||||||
|
val mangaEntries = JsonArray()
|
||||||
|
|
||||||
|
// Create category array
|
||||||
|
val categoryEntries = JsonArray()
|
||||||
|
|
||||||
|
// Add value's to root
|
||||||
|
root[Backup.VERSION] = Backup.CURRENT_VERSION
|
||||||
|
root[Backup.MANGAS] = mangaEntries
|
||||||
|
root[CATEGORIES] = categoryEntries
|
||||||
|
|
||||||
|
databaseHelper.inTransaction {
|
||||||
|
// Get manga from database
|
||||||
|
val mangas = getFavoriteManga()
|
||||||
|
|
||||||
|
// Backup library manga and its dependencies
|
||||||
|
mangas.forEach { manga ->
|
||||||
|
mangaEntries.add(backupMangaObject(manga, flags))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup categories
|
||||||
|
if ((flags and BACKUP_CATEGORY_MASK) == BACKUP_CATEGORY) {
|
||||||
|
backupCategories(categoryEntries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// When BackupCreatorJob
|
||||||
|
if (isJob) {
|
||||||
|
// Get dir of file and create
|
||||||
|
var dir = UniFile.fromUri(context, uri)
|
||||||
|
dir = dir.createDirectory("automatic")
|
||||||
|
|
||||||
|
// Delete older backups
|
||||||
|
val numberOfBackups = numberOfBackups()
|
||||||
|
val backupRegex = Regex("""tachiyomi_\d+-\d+-\d+_\d+-\d+.json""")
|
||||||
|
dir.listFiles { _, filename -> backupRegex.matches(filename) }
|
||||||
|
.orEmpty()
|
||||||
|
.sortedByDescending { it.name }
|
||||||
|
.drop(numberOfBackups - 1)
|
||||||
|
.forEach { it.delete() }
|
||||||
|
|
||||||
|
// Create new file to place backup
|
||||||
|
val newFile = dir.createFile(Backup.getDefaultFilename())
|
||||||
|
?: throw Exception("Couldn't create backup file")
|
||||||
|
|
||||||
|
newFile.openOutputStream().bufferedWriter().use {
|
||||||
|
parser.toJson(root, it)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val file = UniFile.fromUri(context, uri)
|
||||||
|
?: throw Exception("Couldn't create backup file")
|
||||||
|
file.openOutputStream().bufferedWriter().use {
|
||||||
|
parser.toJson(root, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show completed dialog
|
||||||
|
val intent = Intent(BackupConst.INTENT_FILTER).apply {
|
||||||
|
putExtra(BackupConst.ACTION, BackupConst.ACTION_BACKUP_COMPLETED_DIALOG)
|
||||||
|
putExtra(BackupConst.EXTRA_URI, file.uri.toString())
|
||||||
|
}
|
||||||
|
context.sendLocalBroadcast(intent)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e)
|
||||||
|
if (!isJob) {
|
||||||
|
// Show error dialog
|
||||||
|
val intent = Intent(BackupConst.INTENT_FILTER).apply {
|
||||||
|
putExtra(BackupConst.ACTION, BackupConst.ACTION_ERROR_BACKUP_DIALOG)
|
||||||
|
putExtra(BackupConst.EXTRA_ERROR_MESSAGE, e.message)
|
||||||
|
}
|
||||||
|
context.sendLocalBroadcast(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backup the categories of library
|
* Backup the categories of library
|
||||||
*
|
*
|
||||||
@ -310,8 +402,11 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
|
|||||||
for (dbTrack in dbTracks) {
|
for (dbTrack in dbTracks) {
|
||||||
if (track.sync_id == dbTrack.sync_id) {
|
if (track.sync_id == dbTrack.sync_id) {
|
||||||
// The sync is already in the db, only update its fields
|
// The sync is already in the db, only update its fields
|
||||||
if (track.remote_id != dbTrack.remote_id) {
|
if (track.media_id != dbTrack.media_id) {
|
||||||
dbTrack.remote_id = track.remote_id
|
dbTrack.media_id = track.media_id
|
||||||
|
}
|
||||||
|
if (track.library_id != dbTrack.library_id) {
|
||||||
|
dbTrack.library_id = track.library_id
|
||||||
}
|
}
|
||||||
dbTrack.last_chapter_read = Math.max(dbTrack.last_chapter_read, track.last_chapter_read)
|
dbTrack.last_chapter_read = Math.max(dbTrack.last_chapter_read, track.last_chapter_read)
|
||||||
isInDatabase = true
|
isInDatabase = true
|
||||||
|
@ -33,7 +33,8 @@ import timber.log.Timber
|
|||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
import java.util.concurrent.ExecutorService
|
import java.util.concurrent.ExecutorService
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
@ -295,7 +296,7 @@ class BackupRestoreService : Service() {
|
|||||||
categories: List<String>, history: List<DHistory>,
|
categories: List<String>, history: List<DHistory>,
|
||||||
tracks: List<Track>): Observable<Manga>? {
|
tracks: List<Track>): Observable<Manga>? {
|
||||||
// Get source
|
// Get source
|
||||||
val source = backupManager.sourceManager.get(manga.source) ?: return null
|
val source = backupManager.sourceManager.getOrStub(manga.source)
|
||||||
val dbManga = backupManager.getMangaFromDatabase(manga)
|
val dbManga = backupManager.getMangaFromDatabase(manga)
|
||||||
|
|
||||||
return if (dbManga == null) {
|
return if (dbManga == null) {
|
||||||
@ -441,4 +442,4 @@ class BackupRestoreService : Service() {
|
|||||||
sendLocalBroadcast(intent)
|
sendLocalBroadcast(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.data.backup.serializer
|
package eu.kanade.tachiyomi.data.backup.serializer
|
||||||
|
|
||||||
|
import android.telecom.DisconnectCause.REMOTE
|
||||||
import com.github.salomonbrys.kotson.typeAdapter
|
import com.github.salomonbrys.kotson.typeAdapter
|
||||||
import com.google.gson.TypeAdapter
|
import com.google.gson.TypeAdapter
|
||||||
import com.google.gson.stream.JsonToken
|
import com.google.gson.stream.JsonToken
|
||||||
@ -11,9 +12,11 @@ import eu.kanade.tachiyomi.data.database.models.TrackImpl
|
|||||||
object TrackTypeAdapter {
|
object TrackTypeAdapter {
|
||||||
|
|
||||||
private const val SYNC = "s"
|
private const val SYNC = "s"
|
||||||
private const val REMOTE = "r"
|
private const val MEDIA = "r"
|
||||||
|
private const val LIBRARY = "ml"
|
||||||
private const val TITLE = "t"
|
private const val TITLE = "t"
|
||||||
private const val LAST_READ = "l"
|
private const val LAST_READ = "l"
|
||||||
|
private const val TRACKING_URL = "u"
|
||||||
|
|
||||||
fun build(): TypeAdapter<TrackImpl> {
|
fun build(): TypeAdapter<TrackImpl> {
|
||||||
return typeAdapter {
|
return typeAdapter {
|
||||||
@ -23,10 +26,14 @@ object TrackTypeAdapter {
|
|||||||
value(it.title)
|
value(it.title)
|
||||||
name(SYNC)
|
name(SYNC)
|
||||||
value(it.sync_id)
|
value(it.sync_id)
|
||||||
name(REMOTE)
|
name(MEDIA)
|
||||||
value(it.remote_id)
|
value(it.media_id)
|
||||||
|
name(LIBRARY)
|
||||||
|
value(it.library_id)
|
||||||
name(LAST_READ)
|
name(LAST_READ)
|
||||||
value(it.last_chapter_read)
|
value(it.last_chapter_read)
|
||||||
|
name(TRACKING_URL)
|
||||||
|
value(it.tracking_url)
|
||||||
endObject()
|
endObject()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,8 +47,10 @@ object TrackTypeAdapter {
|
|||||||
when (name) {
|
when (name) {
|
||||||
TITLE -> track.title = nextString()
|
TITLE -> track.title = nextString()
|
||||||
SYNC -> track.sync_id = nextInt()
|
SYNC -> track.sync_id = nextInt()
|
||||||
REMOTE -> track.remote_id = nextInt()
|
MEDIA -> track.media_id = nextInt()
|
||||||
|
LIBRARY -> track.library_id = nextLong()
|
||||||
LAST_READ -> track.last_chapter_read = nextInt()
|
LAST_READ -> track.last_chapter_read = nextInt()
|
||||||
|
TRACKING_URL -> track.tracking_url = nextString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
package eu.kanade.tachiyomi.data.database
|
package eu.kanade.tachiyomi.data.database
|
||||||
|
|
||||||
|
import android.arch.persistence.db.SupportSQLiteOpenHelper
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite
|
import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite
|
||||||
import eu.kanade.tachiyomi.data.database.mappers.*
|
import eu.kanade.tachiyomi.data.database.mappers.*
|
||||||
import eu.kanade.tachiyomi.data.database.models.*
|
import eu.kanade.tachiyomi.data.database.models.*
|
||||||
import eu.kanade.tachiyomi.data.database.queries.*
|
import eu.kanade.tachiyomi.data.database.queries.*
|
||||||
|
import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class provides operations to manage the database through its interfaces.
|
* This class provides operations to manage the database through its interfaces.
|
||||||
@ -12,8 +14,13 @@ import eu.kanade.tachiyomi.data.database.queries.*
|
|||||||
open class DatabaseHelper(context: Context)
|
open class DatabaseHelper(context: Context)
|
||||||
: MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries {
|
: MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries {
|
||||||
|
|
||||||
|
private val configuration = SupportSQLiteOpenHelper.Configuration.builder(context)
|
||||||
|
.name(DbOpenCallback.DATABASE_NAME)
|
||||||
|
.callback(DbOpenCallback())
|
||||||
|
.build()
|
||||||
|
|
||||||
override val db = DefaultStorIOSQLite.builder()
|
override val db = DefaultStorIOSQLite.builder()
|
||||||
.sqliteOpenHelper(DbOpenHelper(context))
|
.sqliteOpenHelper(RequerySQLiteOpenHelperFactory().create(configuration))
|
||||||
.addTypeMapping(Manga::class.java, MangaTypeMapping())
|
.addTypeMapping(Manga::class.java, MangaTypeMapping())
|
||||||
.addTypeMapping(Chapter::class.java, ChapterTypeMapping())
|
.addTypeMapping(Chapter::class.java, ChapterTypeMapping())
|
||||||
.addTypeMapping(Track::class.java, TrackTypeMapping())
|
.addTypeMapping(Track::class.java, TrackTypeMapping())
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
package eu.kanade.tachiyomi.data.database
|
package eu.kanade.tachiyomi.data.database
|
||||||
|
|
||||||
|
import android.arch.persistence.db.SupportSQLiteDatabase
|
||||||
|
import android.arch.persistence.db.SupportSQLiteOpenHelper
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.sqlite.SQLiteDatabase
|
import android.database.sqlite.SQLiteDatabase
|
||||||
import android.database.sqlite.SQLiteOpenHelper
|
import android.database.sqlite.SQLiteOpenHelper
|
||||||
import eu.kanade.tachiyomi.data.database.tables.*
|
import eu.kanade.tachiyomi.data.database.tables.*
|
||||||
|
|
||||||
class DbOpenHelper(context: Context)
|
class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
|
||||||
: SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
/**
|
||||||
@ -17,10 +18,10 @@ class DbOpenHelper(context: Context)
|
|||||||
/**
|
/**
|
||||||
* Version of the database.
|
* Version of the database.
|
||||||
*/
|
*/
|
||||||
const val DATABASE_VERSION = 5
|
const val DATABASE_VERSION = 8
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(db: SQLiteDatabase) = with(db) {
|
override fun onCreate(db: SupportSQLiteDatabase) = with(db) {
|
||||||
execSQL(MangaTable.createTableQuery)
|
execSQL(MangaTable.createTableQuery)
|
||||||
execSQL(ChapterTable.createTableQuery)
|
execSQL(ChapterTable.createTableQuery)
|
||||||
execSQL(TrackTable.createTableQuery)
|
execSQL(TrackTable.createTableQuery)
|
||||||
@ -30,12 +31,13 @@ class DbOpenHelper(context: Context)
|
|||||||
|
|
||||||
// DB indexes
|
// DB indexes
|
||||||
execSQL(MangaTable.createUrlIndexQuery)
|
execSQL(MangaTable.createUrlIndexQuery)
|
||||||
execSQL(MangaTable.createFavoriteIndexQuery)
|
execSQL(MangaTable.createLibraryIndexQuery)
|
||||||
execSQL(ChapterTable.createMangaIdIndexQuery)
|
execSQL(ChapterTable.createMangaIdIndexQuery)
|
||||||
|
execSQL(ChapterTable.createUnreadChaptersIndexQuery)
|
||||||
execSQL(HistoryTable.createChapterIdIndexQuery)
|
execSQL(HistoryTable.createChapterIdIndexQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||||
if (oldVersion < 2) {
|
if (oldVersion < 2) {
|
||||||
db.execSQL(ChapterTable.sourceOrderUpdateQuery)
|
db.execSQL(ChapterTable.sourceOrderUpdateQuery)
|
||||||
|
|
||||||
@ -54,9 +56,20 @@ class DbOpenHelper(context: Context)
|
|||||||
if (oldVersion < 5) {
|
if (oldVersion < 5) {
|
||||||
db.execSQL(ChapterTable.addScanlator)
|
db.execSQL(ChapterTable.addScanlator)
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 6) {
|
||||||
|
db.execSQL(TrackTable.addTrackingUrl)
|
||||||
|
}
|
||||||
|
if (oldVersion < 7) {
|
||||||
|
db.execSQL(TrackTable.addLibraryId)
|
||||||
|
}
|
||||||
|
if (oldVersion < 8) {
|
||||||
|
db.execSQL("DROP INDEX IF EXISTS mangas_favorite_index")
|
||||||
|
db.execSQL(MangaTable.createLibraryIndexQuery)
|
||||||
|
db.execSQL(ChapterTable.createUnreadChaptersIndexQuery)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onConfigure(db: SQLiteDatabase) {
|
override fun onConfigure(db: SupportSQLiteDatabase) {
|
||||||
db.setForeignKeyConstraintsEnabled(true)
|
db.setForeignKeyConstraintsEnabled(true)
|
||||||
}
|
}
|
||||||
|
|
@ -13,13 +13,15 @@ import eu.kanade.tachiyomi.data.database.models.Track
|
|||||||
import eu.kanade.tachiyomi.data.database.models.TrackImpl
|
import eu.kanade.tachiyomi.data.database.models.TrackImpl
|
||||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_ID
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_ID
|
||||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_LAST_CHAPTER_READ
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_LAST_CHAPTER_READ
|
||||||
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_LIBRARY_ID
|
||||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MANGA_ID
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MANGA_ID
|
||||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_REMOTE_ID
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MEDIA_ID
|
||||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SCORE
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SCORE
|
||||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_STATUS
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_STATUS
|
||||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SYNC_ID
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SYNC_ID
|
||||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TITLE
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TITLE
|
||||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TOTAL_CHAPTERS
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TOTAL_CHAPTERS
|
||||||
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TRACKING_URL
|
||||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable.TABLE
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable.TABLE
|
||||||
|
|
||||||
class TrackTypeMapping : SQLiteTypeMapping<Track>(
|
class TrackTypeMapping : SQLiteTypeMapping<Track>(
|
||||||
@ -40,16 +42,19 @@ class TrackPutResolver : DefaultPutResolver<Track>() {
|
|||||||
.whereArgs(obj.id)
|
.whereArgs(obj.id)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
override fun mapToContentValues(obj: Track) = ContentValues(9).apply {
|
override fun mapToContentValues(obj: Track) = ContentValues(10).apply {
|
||||||
put(COL_ID, obj.id)
|
put(COL_ID, obj.id)
|
||||||
put(COL_MANGA_ID, obj.manga_id)
|
put(COL_MANGA_ID, obj.manga_id)
|
||||||
put(COL_SYNC_ID, obj.sync_id)
|
put(COL_SYNC_ID, obj.sync_id)
|
||||||
put(COL_REMOTE_ID, obj.remote_id)
|
put(COL_MEDIA_ID, obj.media_id)
|
||||||
|
put(COL_LIBRARY_ID, obj.library_id)
|
||||||
put(COL_TITLE, obj.title)
|
put(COL_TITLE, obj.title)
|
||||||
put(COL_LAST_CHAPTER_READ, obj.last_chapter_read)
|
put(COL_LAST_CHAPTER_READ, obj.last_chapter_read)
|
||||||
put(COL_TOTAL_CHAPTERS, obj.total_chapters)
|
put(COL_TOTAL_CHAPTERS, obj.total_chapters)
|
||||||
put(COL_STATUS, obj.status)
|
put(COL_STATUS, obj.status)
|
||||||
|
put(COL_TRACKING_URL, obj.tracking_url)
|
||||||
put(COL_SCORE, obj.score)
|
put(COL_SCORE, obj.score)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,12 +64,14 @@ class TrackGetResolver : DefaultGetResolver<Track>() {
|
|||||||
id = cursor.getLong(cursor.getColumnIndex(COL_ID))
|
id = cursor.getLong(cursor.getColumnIndex(COL_ID))
|
||||||
manga_id = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID))
|
manga_id = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID))
|
||||||
sync_id = cursor.getInt(cursor.getColumnIndex(COL_SYNC_ID))
|
sync_id = cursor.getInt(cursor.getColumnIndex(COL_SYNC_ID))
|
||||||
remote_id = cursor.getInt(cursor.getColumnIndex(COL_REMOTE_ID))
|
media_id = cursor.getInt(cursor.getColumnIndex(COL_MEDIA_ID))
|
||||||
|
library_id = cursor.getLong(cursor.getColumnIndex(COL_LIBRARY_ID))
|
||||||
title = cursor.getString(cursor.getColumnIndex(COL_TITLE))
|
title = cursor.getString(cursor.getColumnIndex(COL_TITLE))
|
||||||
last_chapter_read = cursor.getInt(cursor.getColumnIndex(COL_LAST_CHAPTER_READ))
|
last_chapter_read = cursor.getInt(cursor.getColumnIndex(COL_LAST_CHAPTER_READ))
|
||||||
total_chapters = cursor.getInt(cursor.getColumnIndex(COL_TOTAL_CHAPTERS))
|
total_chapters = cursor.getInt(cursor.getColumnIndex(COL_TOTAL_CHAPTERS))
|
||||||
status = cursor.getInt(cursor.getColumnIndex(COL_STATUS))
|
status = cursor.getInt(cursor.getColumnIndex(COL_STATUS))
|
||||||
score = cursor.getFloat(cursor.getColumnIndex(COL_SCORE))
|
score = cursor.getFloat(cursor.getColumnIndex(COL_SCORE))
|
||||||
|
tracking_url = cursor.getString(cursor.getColumnIndex(COL_TRACKING_URL))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10,7 +10,9 @@ interface Track : Serializable {
|
|||||||
|
|
||||||
var sync_id: Int
|
var sync_id: Int
|
||||||
|
|
||||||
var remote_id: Int
|
var media_id: Int
|
||||||
|
|
||||||
|
var library_id: Long?
|
||||||
|
|
||||||
var title: String
|
var title: String
|
||||||
|
|
||||||
@ -22,6 +24,8 @@ interface Track : Serializable {
|
|||||||
|
|
||||||
var status: Int
|
var status: Int
|
||||||
|
|
||||||
|
var tracking_url: String
|
||||||
|
|
||||||
fun copyPersonalFrom(other: Track) {
|
fun copyPersonalFrom(other: Track) {
|
||||||
last_chapter_read = other.last_chapter_read
|
last_chapter_read = other.last_chapter_read
|
||||||
score = other.score
|
score = other.score
|
||||||
@ -29,7 +33,6 @@ interface Track : Serializable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun create(serviceId: Int): Track = TrackImpl().apply {
|
fun create(serviceId: Int): Track = TrackImpl().apply {
|
||||||
sync_id = serviceId
|
sync_id = serviceId
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,9 @@ class TrackImpl : Track {
|
|||||||
|
|
||||||
override var sync_id: Int = 0
|
override var sync_id: Int = 0
|
||||||
|
|
||||||
override var remote_id: Int = 0
|
override var media_id: Int = 0
|
||||||
|
|
||||||
|
override var library_id: Long? = null
|
||||||
|
|
||||||
override lateinit var title: String
|
override lateinit var title: String
|
||||||
|
|
||||||
@ -20,6 +22,8 @@ class TrackImpl : Track {
|
|||||||
|
|
||||||
override var status: Int = 0
|
override var status: Int = 0
|
||||||
|
|
||||||
|
override var tracking_url: String = ""
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
if (other == null || javaClass != other.javaClass) return false
|
if (other == null || javaClass != other.javaClass) return false
|
||||||
@ -28,13 +32,13 @@ class TrackImpl : Track {
|
|||||||
|
|
||||||
if (manga_id != other.manga_id) return false
|
if (manga_id != other.manga_id) return false
|
||||||
if (sync_id != other.sync_id) return false
|
if (sync_id != other.sync_id) return false
|
||||||
return remote_id == other.remote_id
|
return media_id == other.media_id
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
override fun hashCode(): Int {
|
||||||
var result = (manga_id xor manga_id.ushr(32)).toInt()
|
var result = (manga_id xor manga_id.ushr(32)).toInt()
|
||||||
result = 31 * result + sync_id
|
result = 31 * result + sync_id
|
||||||
result = 31 * result + remote_id
|
result = 31 * result + media_id
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,9 +6,7 @@ import com.pushtorefresh.storio.sqlite.queries.RawQuery
|
|||||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||||
import eu.kanade.tachiyomi.data.database.models.LibraryManga
|
import eu.kanade.tachiyomi.data.database.models.LibraryManga
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.database.resolvers.LibraryMangaGetResolver
|
import eu.kanade.tachiyomi.data.database.resolvers.*
|
||||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaFlagsPutResolver
|
|
||||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaLastUpdatedPutResolver
|
|
||||||
import eu.kanade.tachiyomi.data.database.tables.CategoryTable
|
import eu.kanade.tachiyomi.data.database.tables.CategoryTable
|
||||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable
|
import eu.kanade.tachiyomi.data.database.tables.ChapterTable
|
||||||
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable
|
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable
|
||||||
@ -74,6 +72,16 @@ interface MangaQueries : DbProvider {
|
|||||||
.withPutResolver(MangaLastUpdatedPutResolver())
|
.withPutResolver(MangaLastUpdatedPutResolver())
|
||||||
.prepare()
|
.prepare()
|
||||||
|
|
||||||
|
fun updateMangaFavorite(manga: Manga) = db.put()
|
||||||
|
.`object`(manga)
|
||||||
|
.withPutResolver(MangaFavoritePutResolver())
|
||||||
|
.prepare()
|
||||||
|
|
||||||
|
fun updateMangaViewer(manga: Manga) = db.put()
|
||||||
|
.`object`(manga)
|
||||||
|
.withPutResolver(MangaViewerPutResolver())
|
||||||
|
.prepare()
|
||||||
|
|
||||||
fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare()
|
fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare()
|
||||||
|
|
||||||
fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare()
|
fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare()
|
||||||
@ -102,4 +110,4 @@ interface MangaQueries : DbProvider {
|
|||||||
|
|
||||||
fun getTotalChapterManga() = db.get().listOfObjects(Manga::class.java)
|
fun getTotalChapterManga() = db.get().listOfObjects(Manga::class.java)
|
||||||
.withQuery(RawQuery.builder().query(getTotalChapterMangaQuery()).observesTables(MangaTable.TABLE).build()).prepare();
|
.withQuery(RawQuery.builder().query(getTotalChapterMangaQuery()).observesTables(MangaTable.TABLE).build()).prepare();
|
||||||
}
|
}
|
||||||
|
@ -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 MangaFavoritePutResolver : PutResolver<Manga>() {
|
||||||
|
|
||||||
|
override fun performPut(db: StorIOSQLite, manga: Manga) = db.inTransactionReturn {
|
||||||
|
val updateQuery = mapToUpdateQuery(manga)
|
||||||
|
val contentValues = mapToContentValues(manga)
|
||||||
|
|
||||||
|
val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
|
||||||
|
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder()
|
||||||
|
.table(MangaTable.TABLE)
|
||||||
|
.where("${MangaTable.COL_ID} = ?")
|
||||||
|
.whereArgs(manga.id)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
|
||||||
|
put(MangaTable.COL_FAVORITE, manga.favorite)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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.Manga
|
||||||
|
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||||
|
|
||||||
|
class MangaViewerPutResolver : PutResolver<Manga>() {
|
||||||
|
|
||||||
|
override fun performPut(db: StorIOSQLite, manga: Manga) = db.inTransactionReturn {
|
||||||
|
val updateQuery = mapToUpdateQuery(manga)
|
||||||
|
val contentValues = mapToContentValues(manga)
|
||||||
|
|
||||||
|
val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
|
||||||
|
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder()
|
||||||
|
.table(MangaTable.TABLE)
|
||||||
|
.where("${MangaTable.COL_ID} = ?")
|
||||||
|
.whereArgs(manga.id)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
|
||||||
|
put(MangaTable.COL_VIEWER, manga.viewer)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -49,6 +49,10 @@ object ChapterTable {
|
|||||||
val createMangaIdIndexQuery: String
|
val createMangaIdIndexQuery: String
|
||||||
get() = "CREATE INDEX ${TABLE}_${COL_MANGA_ID}_index ON $TABLE($COL_MANGA_ID)"
|
get() = "CREATE INDEX ${TABLE}_${COL_MANGA_ID}_index ON $TABLE($COL_MANGA_ID)"
|
||||||
|
|
||||||
|
val createUnreadChaptersIndexQuery: String
|
||||||
|
get() = "CREATE INDEX ${TABLE}_unread_by_manga_index ON $TABLE($COL_MANGA_ID, $COL_READ) " +
|
||||||
|
"WHERE $COL_READ = 0"
|
||||||
|
|
||||||
val sourceOrderUpdateQuery: String
|
val sourceOrderUpdateQuery: String
|
||||||
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SOURCE_ORDER INTEGER DEFAULT 0"
|
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SOURCE_ORDER INTEGER DEFAULT 0"
|
||||||
|
|
||||||
|
@ -60,6 +60,7 @@ object MangaTable {
|
|||||||
val createUrlIndexQuery: String
|
val createUrlIndexQuery: String
|
||||||
get() = "CREATE INDEX ${TABLE}_${COL_URL}_index ON $TABLE($COL_URL)"
|
get() = "CREATE INDEX ${TABLE}_${COL_URL}_index ON $TABLE($COL_URL)"
|
||||||
|
|
||||||
val createFavoriteIndexQuery: String
|
val createLibraryIndexQuery: String
|
||||||
get() = "CREATE INDEX ${TABLE}_${COL_FAVORITE}_index ON $TABLE($COL_FAVORITE)"
|
get() = "CREATE INDEX library_${COL_FAVORITE}_index ON $TABLE($COL_FAVORITE) " +
|
||||||
|
"WHERE $COL_FAVORITE = 1"
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,9 @@ object TrackTable {
|
|||||||
|
|
||||||
const val COL_SYNC_ID = "sync_id"
|
const val COL_SYNC_ID = "sync_id"
|
||||||
|
|
||||||
const val COL_REMOTE_ID = "remote_id"
|
const val COL_MEDIA_ID = "remote_id"
|
||||||
|
|
||||||
|
const val COL_LIBRARY_ID = "library_id"
|
||||||
|
|
||||||
const val COL_TITLE = "title"
|
const val COL_TITLE = "title"
|
||||||
|
|
||||||
@ -22,20 +24,29 @@ object TrackTable {
|
|||||||
|
|
||||||
const val COL_TOTAL_CHAPTERS = "total_chapters"
|
const val COL_TOTAL_CHAPTERS = "total_chapters"
|
||||||
|
|
||||||
|
const val COL_TRACKING_URL = "remote_url"
|
||||||
|
|
||||||
val createTableQuery: String
|
val createTableQuery: String
|
||||||
get() = """CREATE TABLE $TABLE(
|
get() = """CREATE TABLE $TABLE(
|
||||||
$COL_ID INTEGER NOT NULL PRIMARY KEY,
|
$COL_ID INTEGER NOT NULL PRIMARY KEY,
|
||||||
$COL_MANGA_ID INTEGER NOT NULL,
|
$COL_MANGA_ID INTEGER NOT NULL,
|
||||||
$COL_SYNC_ID INTEGER NOT NULL,
|
$COL_SYNC_ID INTEGER NOT NULL,
|
||||||
$COL_REMOTE_ID INTEGER NOT NULL,
|
$COL_MEDIA_ID INTEGER NOT NULL,
|
||||||
|
$COL_LIBRARY_ID INTEGER,
|
||||||
$COL_TITLE TEXT NOT NULL,
|
$COL_TITLE TEXT NOT NULL,
|
||||||
$COL_LAST_CHAPTER_READ INTEGER NOT NULL,
|
$COL_LAST_CHAPTER_READ INTEGER NOT NULL,
|
||||||
$COL_TOTAL_CHAPTERS INTEGER NOT NULL,
|
$COL_TOTAL_CHAPTERS INTEGER NOT NULL,
|
||||||
$COL_STATUS INTEGER NOT NULL,
|
$COL_STATUS INTEGER NOT NULL,
|
||||||
$COL_SCORE FLOAT NOT NULL,
|
$COL_SCORE FLOAT NOT NULL,
|
||||||
|
$COL_TRACKING_URL TEXT NOT NULL,
|
||||||
UNIQUE ($COL_MANGA_ID, $COL_SYNC_ID) ON CONFLICT REPLACE,
|
UNIQUE ($COL_MANGA_ID, $COL_SYNC_ID) ON CONFLICT REPLACE,
|
||||||
FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID})
|
FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID})
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
)"""
|
)"""
|
||||||
|
|
||||||
|
val addTrackingUrl: String
|
||||||
|
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_TRACKING_URL TEXT DEFAULT ''"
|
||||||
|
|
||||||
|
val addLibraryId: String
|
||||||
|
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_LIBRARY_ID INTEGER NULL"
|
||||||
}
|
}
|
||||||
|
@ -23,10 +23,12 @@ import java.util.concurrent.TimeUnit
|
|||||||
* @param sourceManager the source manager.
|
* @param sourceManager the source manager.
|
||||||
* @param preferences the preferences of the app.
|
* @param preferences the preferences of the app.
|
||||||
*/
|
*/
|
||||||
class DownloadCache(private val context: Context,
|
class DownloadCache(
|
||||||
private val provider: DownloadProvider,
|
private val context: Context,
|
||||||
private val sourceManager: SourceManager = Injekt.get(),
|
private val provider: DownloadProvider,
|
||||||
private val preferences: PreferencesHelper = Injekt.get()) {
|
private val sourceManager: SourceManager,
|
||||||
|
private val preferences: PreferencesHelper = Injekt.get()
|
||||||
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The interval after which this cache should be invalidated. 1 hour shouldn't cause major
|
* The interval after which this cache should be invalidated. 1 hour shouldn't cause major
|
||||||
@ -194,6 +196,24 @@ class DownloadCache(private val context: Context,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a list of chapters that have been deleted from this cache.
|
||||||
|
*
|
||||||
|
* @param chapters the list of chapter to remove.
|
||||||
|
* @param manga the manga of the chapter.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun removeChapters(chapters: List<Chapter>, manga: Manga) {
|
||||||
|
val sourceDir = rootDir.files[manga.source] ?: return
|
||||||
|
val mangaDir = sourceDir.files[provider.getMangaDirName(manga)] ?: return
|
||||||
|
for (chapter in chapters) {
|
||||||
|
val chapterDirName = provider.getChapterDirName(chapter)
|
||||||
|
if (chapterDirName in mangaDir.files) {
|
||||||
|
mangaDir.files -= chapterDirName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a manga that has been deleted from this cache.
|
* Removes a manga that has been deleted from this cache.
|
||||||
*
|
*
|
||||||
|
@ -7,8 +7,10 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
|
|||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is used to manage chapter downloads in the application. It must be instantiated once
|
* This class is used to manage chapter downloads in the application. It must be instantiated once
|
||||||
@ -19,6 +21,11 @@ import rx.Observable
|
|||||||
*/
|
*/
|
||||||
class DownloadManager(context: Context) {
|
class DownloadManager(context: Context) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sources manager.
|
||||||
|
*/
|
||||||
|
private val sourceManager by injectLazy<SourceManager>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads provider, used to retrieve the folders where the chapters are or should be stored.
|
* Downloads provider, used to retrieve the folders where the chapters are or should be stored.
|
||||||
*/
|
*/
|
||||||
@ -27,12 +34,17 @@ class DownloadManager(context: Context) {
|
|||||||
/**
|
/**
|
||||||
* Cache of downloaded chapters.
|
* Cache of downloaded chapters.
|
||||||
*/
|
*/
|
||||||
private val cache = DownloadCache(context, provider)
|
private val cache = DownloadCache(context, provider, sourceManager)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloader whose only task is to download chapters.
|
* Downloader whose only task is to download chapters.
|
||||||
*/
|
*/
|
||||||
private val downloader = Downloader(context, provider, cache)
|
private val downloader = Downloader(context, provider, cache, sourceManager)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue to delay the deletion of a list of chapters until triggered.
|
||||||
|
*/
|
||||||
|
private val pendingDeleter = DownloadPendingDeleter(context)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads queue, where the pending chapters are stored.
|
* Downloads queue, where the pending chapters are stored.
|
||||||
@ -146,15 +158,20 @@ class DownloadManager(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes the directory of a downloaded chapter.
|
* Deletes the directories of a list of downloaded chapters.
|
||||||
*
|
*
|
||||||
* @param chapter the chapter to delete.
|
* @param chapters the list of chapters to delete.
|
||||||
* @param manga the manga of the chapter.
|
* @param manga the manga of the chapters.
|
||||||
* @param source the source of the chapter.
|
* @param source the source of the chapters.
|
||||||
*/
|
*/
|
||||||
fun deleteChapter(chapter: Chapter, manga: Manga, source: Source) {
|
fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source) {
|
||||||
provider.findChapterDir(chapter, manga, source)?.delete()
|
queue.remove(chapters)
|
||||||
cache.removeChapter(chapter, manga)
|
val chapterDirs = provider.findChapterDirs(chapters, manga, source)
|
||||||
|
chapterDirs.forEach { it.delete() }
|
||||||
|
cache.removeChapters(chapters, manga)
|
||||||
|
if (cache.getDownloadCount(manga) == 0) { // Delete manga directory if empty
|
||||||
|
chapterDirs.firstOrNull()?.parentFile?.delete()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -164,7 +181,30 @@ class DownloadManager(context: Context) {
|
|||||||
* @param source the source of the manga.
|
* @param source the source of the manga.
|
||||||
*/
|
*/
|
||||||
fun deleteManga(manga: Manga, source: Source) {
|
fun deleteManga(manga: Manga, source: Source) {
|
||||||
|
queue.remove(manga)
|
||||||
provider.findMangaDir(manga, source)?.delete()
|
provider.findMangaDir(manga, source)?.delete()
|
||||||
cache.removeManga(manga)
|
cache.removeManga(manga)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a list of chapters to be deleted later.
|
||||||
|
*
|
||||||
|
* @param chapters the list of chapters to delete.
|
||||||
|
* @param manga the manga of the chapters.
|
||||||
|
*/
|
||||||
|
fun enqueueDeleteChapters(chapters: List<Chapter>, manga: Manga) {
|
||||||
|
pendingDeleter.addChapters(chapters, manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers the execution of the deletion of pending chapters.
|
||||||
|
*/
|
||||||
|
fun deletePendingChapters() {
|
||||||
|
val pendingChapters = pendingDeleter.getPendingChapters()
|
||||||
|
for ((manga, chapters) in pendingChapters) {
|
||||||
|
val source = sourceManager.get(manga.source) ?: continue
|
||||||
|
deleteChapters(chapters, manga, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -36,19 +36,13 @@ internal class DownloadNotifier(private val context: Context) {
|
|||||||
* The size of queue on start download.
|
* The size of queue on start download.
|
||||||
*/
|
*/
|
||||||
var initialQueueSize = 0
|
var initialQueueSize = 0
|
||||||
get() = field
|
|
||||||
set(value) {
|
set(value) {
|
||||||
if (value != 0){
|
if (value != 0) {
|
||||||
isSingleChapter = (value == 1)
|
isSingleChapter = (value == 1)
|
||||||
}
|
}
|
||||||
field = value
|
field = value
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Simultaneous download setting > 1.
|
|
||||||
*/
|
|
||||||
var multipleDownloadThreads = false
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updated when error is thrown
|
* Updated when error is thrown
|
||||||
*/
|
*/
|
||||||
@ -91,36 +85,10 @@ internal class DownloadNotifier(private val context: Context) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when download progress changes.
|
* Called when download progress changes.
|
||||||
* Note: Only accepted when multi download active.
|
|
||||||
*
|
|
||||||
* @param queue the queue containing downloads.
|
|
||||||
*/
|
|
||||||
fun onProgressChange(queue: DownloadQueue) {
|
|
||||||
if (multipleDownloadThreads) {
|
|
||||||
doOnProgressChange(null, queue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when download progress changes.
|
|
||||||
* Note: Only accepted when single download active.
|
|
||||||
*
|
*
|
||||||
* @param download download object containing download information.
|
* @param download download object containing download information.
|
||||||
* @param queue the queue containing downloads.
|
|
||||||
*/
|
*/
|
||||||
fun onProgressChange(download: Download, queue: DownloadQueue) {
|
fun onProgressChange(download: Download) {
|
||||||
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) {
|
|
||||||
// Create notification
|
// Create notification
|
||||||
with(notification) {
|
with(notification) {
|
||||||
// Check if first call.
|
// Check if first call.
|
||||||
@ -131,30 +99,19 @@ internal class DownloadNotifier(private val context: Context) {
|
|||||||
// Open download manager when clicked
|
// Open download manager when clicked
|
||||||
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
|
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
|
||||||
isDownloading = true
|
isDownloading = true
|
||||||
|
// Pause action
|
||||||
|
addAction(R.drawable.ic_av_pause_grey_24dp_img,
|
||||||
|
context.getString(R.string.action_pause),
|
||||||
|
NotificationReceiver.pauseDownloadsPendingBroadcast(context))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (multipleDownloadThreads) {
|
val title = download.manga.title.chop(15)
|
||||||
setContentTitle(context.getString(R.string.app_name))
|
val quotedTitle = Pattern.quote(title)
|
||||||
|
val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "")
|
||||||
// Reset the queue size if the download progress is negative
|
setContentTitle("$title - $chapter".chop(30))
|
||||||
if ((initialQueueSize - queue.size) < 0)
|
setContentText(context.getString(R.string.chapter_downloading_progress)
|
||||||
initialQueueSize = queue.size
|
.format(download.downloadedImages, download.pages!!.size))
|
||||||
|
setProgress(download.pages!!.size, download.downloadedImages, false)
|
||||||
setContentText(context.getString(R.string.chapter_downloading_progress)
|
|
||||||
.format(initialQueueSize - queue.size, initialQueueSize))
|
|
||||||
setProgress(initialQueueSize, initialQueueSize - queue.size, false)
|
|
||||||
} else {
|
|
||||||
download?.let {
|
|
||||||
val title = it.manga.title.chop(15)
|
|
||||||
val quotedTitle = Pattern.quote(title)
|
|
||||||
val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "")
|
|
||||||
setContentTitle("$title - $chapter".chop(30))
|
|
||||||
setContentText(context.getString(R.string.chapter_downloading_progress)
|
|
||||||
.format(it.downloadedImages, it.pages!!.size))
|
|
||||||
setProgress(it.pages!!.size, it.downloadedImages, false)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Displays the progress bar on notification
|
// Displays the progress bar on notification
|
||||||
notification.show()
|
notification.show()
|
||||||
|
@ -0,0 +1,180 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.download
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.github.salomonbrys.kotson.fromJson
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class used to keep a list of chapters for future deletion.
|
||||||
|
*
|
||||||
|
* @param context the application context.
|
||||||
|
*/
|
||||||
|
class DownloadPendingDeleter(context: Context) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gson instance to encode and decode chapters.
|
||||||
|
*/
|
||||||
|
private val gson by injectLazy<Gson>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preferences used to store the list of chapters to delete.
|
||||||
|
*/
|
||||||
|
private val prefs = context.getSharedPreferences("chapters_to_delete", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last added chapter, used to avoid decoding from the preference too often.
|
||||||
|
*/
|
||||||
|
private var lastAddedEntry: Entry? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a list of chapters for future deletion.
|
||||||
|
*
|
||||||
|
* @param chapters the chapters to be deleted.
|
||||||
|
* @param manga the manga of the chapters.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun addChapters(chapters: List<Chapter>, manga: Manga) {
|
||||||
|
val lastEntry = lastAddedEntry
|
||||||
|
|
||||||
|
val newEntry = if (lastEntry != null && lastEntry.manga.id == manga.id) {
|
||||||
|
// Append new chapters
|
||||||
|
val newChapters = lastEntry.chapters.addUniqueById(chapters)
|
||||||
|
|
||||||
|
// If no chapters were added, do nothing
|
||||||
|
if (newChapters.size == lastEntry.chapters.size) return
|
||||||
|
|
||||||
|
// Last entry matches the manga, reuse it to avoid decoding json from preferences
|
||||||
|
lastEntry.copy(chapters = newChapters)
|
||||||
|
} else {
|
||||||
|
val existingEntry = prefs.getString(manga.id!!.toString(), null)
|
||||||
|
if (existingEntry != null) {
|
||||||
|
// Existing entry found on preferences, decode json and add the new chapter
|
||||||
|
val savedEntry = gson.fromJson<Entry>(existingEntry)
|
||||||
|
|
||||||
|
// Append new chapters
|
||||||
|
val newChapters = savedEntry.chapters.addUniqueById(chapters)
|
||||||
|
|
||||||
|
// If no chapters were added, do nothing
|
||||||
|
if (newChapters.size == savedEntry.chapters.size) return
|
||||||
|
|
||||||
|
savedEntry.copy(chapters = newChapters)
|
||||||
|
} else {
|
||||||
|
// No entry has been found yet, create a new one
|
||||||
|
Entry(chapters.map { it.toEntry() }, manga.toEntry())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current state
|
||||||
|
val json = gson.toJson(newEntry)
|
||||||
|
prefs.edit().putString(newEntry.manga.id.toString(), json).apply()
|
||||||
|
lastAddedEntry = newEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the list of chapters to be deleted grouped by its manga.
|
||||||
|
*
|
||||||
|
* Note: the returned list of manga and chapters only contain basic information needed by the
|
||||||
|
* downloader, so don't use them for anything else.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun getPendingChapters(): Map<Manga, List<Chapter>> {
|
||||||
|
val entries = decodeAll()
|
||||||
|
prefs.edit().clear().apply()
|
||||||
|
lastAddedEntry = null
|
||||||
|
|
||||||
|
return entries.associate { entry ->
|
||||||
|
entry.manga.toModel() to entry.chapters.map { it.toModel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes all the chapters from preferences.
|
||||||
|
*/
|
||||||
|
private fun decodeAll(): List<Entry> {
|
||||||
|
return prefs.all.values.mapNotNull { rawEntry ->
|
||||||
|
try {
|
||||||
|
(rawEntry as? String)?.let { gson.fromJson<Entry>(it) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy of chapter entries ensuring no duplicates by chapter id.
|
||||||
|
*/
|
||||||
|
private fun List<ChapterEntry>.addUniqueById(chapters: List<Chapter>): List<ChapterEntry> {
|
||||||
|
val newList = toMutableList()
|
||||||
|
for (chapter in chapters) {
|
||||||
|
if (none { it.id == chapter.id }) {
|
||||||
|
newList.add(chapter.toEntry())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newList
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class used to save an entry of chapters with their manga into preferences.
|
||||||
|
*/
|
||||||
|
private data class Entry(
|
||||||
|
val chapters: List<ChapterEntry>,
|
||||||
|
val manga: MangaEntry
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class used to save an entry for a chapter into preferences.
|
||||||
|
*/
|
||||||
|
private data class ChapterEntry(
|
||||||
|
val id: Long,
|
||||||
|
val url: String,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class used to save an entry for a manga into preferences.
|
||||||
|
*/
|
||||||
|
private data class MangaEntry(
|
||||||
|
val id: Long,
|
||||||
|
val url: String,
|
||||||
|
val title: String,
|
||||||
|
val source: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a manga entry from a manga model.
|
||||||
|
*/
|
||||||
|
private fun Manga.toEntry(): MangaEntry {
|
||||||
|
return MangaEntry(id!!, url, title, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a chapter entry from a chapter model.
|
||||||
|
*/
|
||||||
|
private fun Chapter.toEntry(): ChapterEntry {
|
||||||
|
return ChapterEntry(id!!, url, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a manga model from a manga entry.
|
||||||
|
*/
|
||||||
|
private fun MangaEntry.toModel(): Manga {
|
||||||
|
return Manga.create(url, title, source).also {
|
||||||
|
it.id = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a chapter model from a chapter entry.
|
||||||
|
*/
|
||||||
|
private fun ChapterEntry.toModel(): Chapter {
|
||||||
|
return Chapter.create().also {
|
||||||
|
it.id = id
|
||||||
|
it.url = url
|
||||||
|
it.name = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -81,6 +81,18 @@ class DownloadProvider(private val context: Context) {
|
|||||||
return mangaDir?.findFile(getChapterDirName(chapter))
|
return mangaDir?.findFile(getChapterDirName(chapter))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of downloaded directories for the chapters that exist.
|
||||||
|
*
|
||||||
|
* @param chapters the chapters to query.
|
||||||
|
* @param manga the manga of the chapter.
|
||||||
|
* @param source the source of the chapter.
|
||||||
|
*/
|
||||||
|
fun findChapterDirs(chapters: List<Chapter>, manga: Manga, source: Source): List<UniFile> {
|
||||||
|
val mangaDir = findMangaDir(manga, source) ?: return emptyList()
|
||||||
|
return chapters.mapNotNull { mangaDir.findFile(getChapterDirName(it)) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the download directory name for a source.
|
* Returns the download directory name for a source.
|
||||||
*
|
*
|
||||||
@ -108,4 +120,4 @@ class DownloadProvider(private val context: Context) {
|
|||||||
return DiskUtil.buildValidFilename(chapter.name)
|
return DiskUtil.buildValidFilename(chapter.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,20 @@
|
|||||||
package eu.kanade.tachiyomi.data.download
|
package eu.kanade.tachiyomi.data.download
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.NetworkInfo.State.CONNECTED
|
import android.net.NetworkInfo.State.CONNECTED
|
||||||
import android.net.NetworkInfo.State.DISCONNECTED
|
import android.net.NetworkInfo.State.DISCONNECTED
|
||||||
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
|
import android.support.v4.app.NotificationCompat
|
||||||
import com.github.pwittchen.reactivenetwork.library.Connectivity
|
import com.github.pwittchen.reactivenetwork.library.Connectivity
|
||||||
import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork
|
import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork
|
||||||
import com.jakewharton.rxrelay.BehaviorRelay
|
import com.jakewharton.rxrelay.BehaviorRelay
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.util.connectivityManager
|
import eu.kanade.tachiyomi.util.connectivityManager
|
||||||
import eu.kanade.tachiyomi.util.plusAssign
|
import eu.kanade.tachiyomi.util.plusAssign
|
||||||
@ -41,7 +45,12 @@ class DownloadService : Service() {
|
|||||||
* @param context the application context.
|
* @param context the application context.
|
||||||
*/
|
*/
|
||||||
fun start(context: Context) {
|
fun start(context: Context) {
|
||||||
context.startService(Intent(context, DownloadService::class.java))
|
val intent = Intent(context, DownloadService::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
|
context.startService(intent)
|
||||||
|
} else {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -81,6 +90,7 @@ class DownloadService : Service() {
|
|||||||
*/
|
*/
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
startForeground(Notifications.ID_DOWNLOAD_CHAPTER, getPlaceholderNotification())
|
||||||
runningRelay.call(true)
|
runningRelay.call(true)
|
||||||
subscriptions = CompositeSubscription()
|
subscriptions = CompositeSubscription()
|
||||||
listenDownloaderState()
|
listenDownloaderState()
|
||||||
@ -176,4 +186,10 @@ class DownloadService : Service() {
|
|||||||
if (!isHeld) acquire()
|
if (!isHeld) acquire()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getPlaceholderNotification(): Notification {
|
||||||
|
return NotificationCompat.Builder(this, Notifications.CHANNEL_DOWNLOADER)
|
||||||
|
.setContentTitle(getString(R.string.download_notifier_downloader_title))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,10 @@ import uy.kohesive.injekt.injectLazy
|
|||||||
*
|
*
|
||||||
* @param context the application context.
|
* @param context the application context.
|
||||||
*/
|
*/
|
||||||
class DownloadStore(context: Context) {
|
class DownloadStore(
|
||||||
|
context: Context,
|
||||||
|
private val sourceManager: SourceManager
|
||||||
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preference file where active downloads are stored.
|
* Preference file where active downloads are stored.
|
||||||
@ -26,11 +29,6 @@ class DownloadStore(context: Context) {
|
|||||||
*/
|
*/
|
||||||
private val gson: Gson by injectLazy()
|
private val gson: Gson by injectLazy()
|
||||||
|
|
||||||
/**
|
|
||||||
* Source manager.
|
|
||||||
*/
|
|
||||||
private val sourceManager: SourceManager by injectLazy()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Database helper.
|
* Database helper.
|
||||||
*/
|
*/
|
||||||
@ -83,7 +81,7 @@ class DownloadStore(context: Context) {
|
|||||||
fun restore(): List<Download> {
|
fun restore(): List<Download> {
|
||||||
val objs = preferences.all
|
val objs = preferences.all
|
||||||
.mapNotNull { it.value as? String }
|
.mapNotNull { it.value as? String }
|
||||||
.map { deserialize(it) }
|
.mapNotNull { deserialize(it) }
|
||||||
.sortedBy { it.order }
|
.sortedBy { it.order }
|
||||||
|
|
||||||
val downloads = mutableListOf<Download>()
|
val downloads = mutableListOf<Download>()
|
||||||
@ -119,8 +117,12 @@ class DownloadStore(context: Context) {
|
|||||||
*
|
*
|
||||||
* @param string the download as string.
|
* @param string the download as string.
|
||||||
*/
|
*/
|
||||||
private fun deserialize(string: String): DownloadObject {
|
private fun deserialize(string: String): DownloadObject? {
|
||||||
return gson.fromJson(string, DownloadObject::class.java)
|
return try {
|
||||||
|
gson.fromJson(string, DownloadObject::class.java)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -132,4 +134,4 @@ class DownloadStore(context: Context) {
|
|||||||
*/
|
*/
|
||||||
data class DownloadObject(val mangaId: Long, val chapterId: Long, val order: Int)
|
data class DownloadObject(val mangaId: Long, val chapterId: Long, val order: Int)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -9,8 +9,6 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
|
|||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.download.model.Download
|
import eu.kanade.tachiyomi.data.download.model.Download
|
||||||
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
|
||||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
|
||||||
import eu.kanade.tachiyomi.source.SourceManager
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
@ -21,10 +19,8 @@ import okhttp3.Response
|
|||||||
import rx.Observable
|
import rx.Observable
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
import rx.android.schedulers.AndroidSchedulers
|
||||||
import rx.schedulers.Schedulers
|
import rx.schedulers.Schedulers
|
||||||
import rx.subjects.BehaviorSubject
|
|
||||||
import rx.subscriptions.CompositeSubscription
|
import rx.subscriptions.CompositeSubscription
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import uy.kohesive.injekt.injectLazy
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is the one in charge of downloading chapters.
|
* This class is the one in charge of downloading chapters.
|
||||||
@ -38,31 +34,25 @@ import uy.kohesive.injekt.injectLazy
|
|||||||
* @param context the application context.
|
* @param context the application context.
|
||||||
* @param provider the downloads directory provider.
|
* @param provider the downloads directory provider.
|
||||||
* @param cache the downloads cache, used to add the downloads to the cache after their completion.
|
* @param cache the downloads cache, used to add the downloads to the cache after their completion.
|
||||||
|
* @param sourceManager the source manager.
|
||||||
*/
|
*/
|
||||||
class Downloader(private val context: Context,
|
class Downloader(
|
||||||
private val provider: DownloadProvider,
|
private val context: Context,
|
||||||
private val cache: DownloadCache) {
|
private val provider: DownloadProvider,
|
||||||
|
private val cache: DownloadCache,
|
||||||
|
private val sourceManager: SourceManager
|
||||||
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store for persisting downloads across restarts.
|
* Store for persisting downloads across restarts.
|
||||||
*/
|
*/
|
||||||
private val store = DownloadStore(context)
|
private val store = DownloadStore(context, sourceManager)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue where active downloads are kept.
|
* Queue where active downloads are kept.
|
||||||
*/
|
*/
|
||||||
val queue = DownloadQueue(store)
|
val queue = DownloadQueue(store)
|
||||||
|
|
||||||
/**
|
|
||||||
* Source manager.
|
|
||||||
*/
|
|
||||||
private val sourceManager: SourceManager by injectLazy()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preferences.
|
|
||||||
*/
|
|
||||||
private val preferences: PreferencesHelper by injectLazy()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notifier for the downloader state and progress.
|
* Notifier for the downloader state and progress.
|
||||||
*/
|
*/
|
||||||
@ -73,11 +63,6 @@ class Downloader(private val context: Context,
|
|||||||
*/
|
*/
|
||||||
private val subscriptions = CompositeSubscription()
|
private val subscriptions = CompositeSubscription()
|
||||||
|
|
||||||
/**
|
|
||||||
* Subject to do a live update of the number of simultaneous downloads.
|
|
||||||
*/
|
|
||||||
private val threadsSubject = BehaviorSubject.create<Int>()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relay to send a list of downloads to the downloader.
|
* Relay to send a list of downloads to the downloader.
|
||||||
*/
|
*/
|
||||||
@ -116,9 +101,6 @@ class Downloader(private val context: Context,
|
|||||||
val pending = queue.filter { it.status != Download.DOWNLOADED }
|
val pending = queue.filter { it.status != Download.DOWNLOADED }
|
||||||
pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE }
|
pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE }
|
||||||
|
|
||||||
// Show download notification when simultaneous download > 1.
|
|
||||||
notifier.onProgressChange(queue)
|
|
||||||
|
|
||||||
downloadsRelay.call(pending)
|
downloadsRelay.call(pending)
|
||||||
return !pending.isEmpty()
|
return !pending.isEmpty()
|
||||||
}
|
}
|
||||||
@ -185,14 +167,8 @@ class Downloader(private val context: Context,
|
|||||||
|
|
||||||
subscriptions.clear()
|
subscriptions.clear()
|
||||||
|
|
||||||
subscriptions += preferences.downloadThreads().asObservable()
|
subscriptions += downloadsRelay.concatMapIterable { it }
|
||||||
.subscribe {
|
.concatMap { downloadChapter(it).subscribeOn(Schedulers.io()) }
|
||||||
threadsSubject.onNext(it)
|
|
||||||
notifier.multipleDownloadThreads = it > 1
|
|
||||||
}
|
|
||||||
|
|
||||||
subscriptions += downloadsRelay.flatMap { Observable.from(it) }
|
|
||||||
.lift(DynamicConcurrentMergeOperator<Download, Download>({ downloadChapter(it) }, threadsSubject))
|
|
||||||
.onBackpressureBuffer()
|
.onBackpressureBuffer()
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe({ completeDownload(it)
|
.subscribe({ completeDownload(it)
|
||||||
@ -250,15 +226,9 @@ class Downloader(private val context: Context,
|
|||||||
// Initialize queue size.
|
// Initialize queue size.
|
||||||
notifier.initialQueueSize = queue.size
|
notifier.initialQueueSize = queue.size
|
||||||
|
|
||||||
// Initial multi-thread
|
|
||||||
notifier.multipleDownloadThreads = preferences.downloadThreads().getOrDefault() > 1
|
|
||||||
|
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
// Send the list of downloads to the downloader.
|
// Send the list of downloads to the downloader.
|
||||||
downloadsRelay.call(chaptersToQueue)
|
downloadsRelay.call(chaptersToQueue)
|
||||||
} else {
|
|
||||||
// Show initial notification.
|
|
||||||
notifier.onProgressChange(queue)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start downloader if needed
|
// Start downloader if needed
|
||||||
@ -273,7 +243,7 @@ class Downloader(private val context: Context,
|
|||||||
*
|
*
|
||||||
* @param download the chapter to be downloaded.
|
* @param download the chapter to be downloaded.
|
||||||
*/
|
*/
|
||||||
private fun downloadChapter(download: Download): Observable<Download> {
|
private fun downloadChapter(download: Download): Observable<Download> = Observable.defer {
|
||||||
val chapterDirname = provider.getChapterDirName(download.chapter)
|
val chapterDirname = provider.getChapterDirName(download.chapter)
|
||||||
val mangaDir = provider.getMangaDir(download.manga, download.source)
|
val mangaDir = provider.getMangaDir(download.manga, download.source)
|
||||||
val tmpDir = mangaDir.createDirectory("${chapterDirname}_tmp")
|
val tmpDir = mangaDir.createDirectory("${chapterDirname}_tmp")
|
||||||
@ -292,7 +262,7 @@ class Downloader(private val context: Context,
|
|||||||
Observable.just(download.pages!!)
|
Observable.just(download.pages!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
return pageListObservable
|
pageListObservable
|
||||||
.doOnNext { _ ->
|
.doOnNext { _ ->
|
||||||
// Delete all temporary (unfinished) files
|
// Delete all temporary (unfinished) files
|
||||||
tmpDir.listFiles()
|
tmpDir.listFiles()
|
||||||
@ -307,7 +277,7 @@ class Downloader(private val context: Context,
|
|||||||
// Start downloading images, consider we can have downloaded images already
|
// Start downloading images, consider we can have downloaded images already
|
||||||
.concatMap { page -> getOrDownloadImage(page, download, tmpDir) }
|
.concatMap { page -> getOrDownloadImage(page, download, tmpDir) }
|
||||||
// Do when page is downloaded.
|
// Do when page is downloaded.
|
||||||
.doOnNext { notifier.onProgressChange(download, queue) }
|
.doOnNext { notifier.onProgressChange(download) }
|
||||||
.toList()
|
.toList()
|
||||||
.map { _ -> download }
|
.map { _ -> download }
|
||||||
// Do after download completes
|
// Do after download completes
|
||||||
@ -318,7 +288,7 @@ class Downloader(private val context: Context,
|
|||||||
notifier.onError(error.message, download.chapter.name)
|
notifier.onError(error.message, download.chapter.name)
|
||||||
download
|
download
|
||||||
}
|
}
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -408,7 +378,7 @@ class Downloader(private val context: Context,
|
|||||||
// Else guess from the uri.
|
// Else guess from the uri.
|
||||||
?: context.contentResolver.getType(file.uri)
|
?: context.contentResolver.getType(file.uri)
|
||||||
// Else read magic numbers.
|
// Else read magic numbers.
|
||||||
?: DiskUtil.findImageMime { file.openInputStream() }
|
?: ImageUtil.findImageType { file.openInputStream() }?.mime
|
||||||
|
|
||||||
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
|
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
|
||||||
}
|
}
|
||||||
@ -448,7 +418,6 @@ class Downloader(private val context: Context,
|
|||||||
if (download.status == Download.DOWNLOADED) {
|
if (download.status == Download.DOWNLOADED) {
|
||||||
// remove downloaded chapter from queue
|
// remove downloaded chapter from queue
|
||||||
queue.remove(download)
|
queue.remove(download)
|
||||||
notifier.onProgressChange(queue)
|
|
||||||
}
|
}
|
||||||
if (areAllDownloadsFinished()) {
|
if (areAllDownloadsFinished()) {
|
||||||
if (notifier.isSingleChapter && !notifier.errorThrown) {
|
if (notifier.isSingleChapter && !notifier.errorThrown) {
|
||||||
@ -465,4 +434,4 @@ class Downloader(private val context: Context,
|
|||||||
return queue.none { it.status <= Download.DOWNLOADING }
|
return queue.none { it.status <= Download.DOWNLOADING }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.download.model
|
|||||||
|
|
||||||
import com.jakewharton.rxrelay.PublishRelay
|
import com.jakewharton.rxrelay.PublishRelay
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadStore
|
import eu.kanade.tachiyomi.data.download.DownloadStore
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
@ -40,6 +41,14 @@ class DownloadQueue(
|
|||||||
find { it.chapter.id == chapter.id }?.let { remove(it) }
|
find { it.chapter.id == chapter.id }?.let { remove(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun remove(chapters: List<Chapter>) {
|
||||||
|
for (chapter in chapters) { remove(chapter) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(manga: Manga) {
|
||||||
|
filter { it.manga.id == manga.id }.forEach { remove(it) }
|
||||||
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
queue.forEach { download ->
|
queue.forEach { download ->
|
||||||
download.setStatusSubject(null)
|
download.setStatusSubject(null)
|
||||||
|
@ -79,7 +79,7 @@ class MangaModelLoader : ModelLoader<Manga, InputStream> {
|
|||||||
* @param height the height of the view where the resource will be loaded.
|
* @param height the height of the view where the resource will be loaded.
|
||||||
*/
|
*/
|
||||||
override fun buildLoadData(manga: Manga, width: Int, height: Int,
|
override fun buildLoadData(manga: Manga, width: Int, height: Int,
|
||||||
options: Options?): ModelLoader.LoadData<InputStream>? {
|
options: Options): ModelLoader.LoadData<InputStream>? {
|
||||||
// Check thumbnail is not null or empty
|
// Check thumbnail is not null or empty
|
||||||
val url = manga.thumbnail_url
|
val url = manga.thumbnail_url
|
||||||
if (url == null || url.isEmpty()) {
|
if (url == null || url.isEmpty()) {
|
||||||
|
@ -0,0 +1,74 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.glide
|
||||||
|
|
||||||
|
import com.bumptech.glide.Priority
|
||||||
|
import com.bumptech.glide.load.DataSource
|
||||||
|
import com.bumptech.glide.load.Options
|
||||||
|
import com.bumptech.glide.load.data.DataFetcher
|
||||||
|
import com.bumptech.glide.load.model.ModelLoader
|
||||||
|
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||||
|
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||||
|
import com.bumptech.glide.signature.ObjectKey
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
class PassthroughModelLoader : ModelLoader<InputStream, InputStream> {
|
||||||
|
|
||||||
|
override fun buildLoadData(
|
||||||
|
model: InputStream,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
options: Options
|
||||||
|
): ModelLoader.LoadData<InputStream>? {
|
||||||
|
return ModelLoader.LoadData(ObjectKey(model), Fetcher(model))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handles(model: InputStream): Boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
class Fetcher(private val stream: InputStream) : DataFetcher<InputStream> {
|
||||||
|
|
||||||
|
override fun getDataClass(): Class<InputStream> {
|
||||||
|
return InputStream::class.java
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cleanup() {
|
||||||
|
try {
|
||||||
|
stream.close()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDataSource(): DataSource {
|
||||||
|
return DataSource.LOCAL
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancel() {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loadData(
|
||||||
|
priority: Priority,
|
||||||
|
callback: DataFetcher.DataCallback<in InputStream>
|
||||||
|
) {
|
||||||
|
callback.onDataReady(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory class for creating [PassthroughModelLoader] instances.
|
||||||
|
*/
|
||||||
|
class Factory : ModelLoaderFactory<InputStream, InputStream> {
|
||||||
|
|
||||||
|
override fun build(
|
||||||
|
multiFactory: MultiModelLoaderFactory
|
||||||
|
): ModelLoader<InputStream, InputStream> {
|
||||||
|
return PassthroughModelLoader()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun teardown() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -37,5 +37,7 @@ class TachiGlideModule : AppGlideModule() {
|
|||||||
|
|
||||||
registry.replace(GlideUrl::class.java, InputStream::class.java, networkFactory)
|
registry.replace(GlideUrl::class.java, InputStream::class.java, networkFactory)
|
||||||
registry.append(Manga::class.java, InputStream::class.java, MangaModelLoader.Factory())
|
registry.append(Manga::class.java, InputStream::class.java, MangaModelLoader.Factory())
|
||||||
|
registry.append(InputStream::class.java, InputStream::class.java, PassthroughModelLoader
|
||||||
|
.Factory())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -304,9 +304,6 @@ class LibraryUpdateService(
|
|||||||
}
|
}
|
||||||
// Add manga with new chapters to the list.
|
// Add manga with new chapters to the list.
|
||||||
.doOnNext { manga ->
|
.doOnNext { manga ->
|
||||||
// Set last updated time
|
|
||||||
manga.last_update = Date().time
|
|
||||||
db.updateLastUpdated(manga).executeAsBlocking()
|
|
||||||
// Add to the list
|
// Add to the list
|
||||||
newUpdates.add(manga)
|
newUpdates.add(manga)
|
||||||
}
|
}
|
||||||
@ -463,6 +460,7 @@ class LibraryUpdateService(
|
|||||||
if (newUpdates.size > 1) {
|
if (newUpdates.size > 1) {
|
||||||
setContentText(getString(R.string.notification_new_chapters_text, newUpdates.size))
|
setContentText(getString(R.string.notification_new_chapters_text, newUpdates.size))
|
||||||
setStyle(NotificationCompat.BigTextStyle().bigText(newUpdates.joinToString("\n")))
|
setStyle(NotificationCompat.BigTextStyle().bigText(newUpdates.joinToString("\n")))
|
||||||
|
setNumber(newUpdates.size)
|
||||||
} else {
|
} else {
|
||||||
setContentText(newUpdates.first())
|
setContentText(newUpdates.first())
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.notification
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||||
import eu.kanade.tachiyomi.util.getUriCompat
|
import eu.kanade.tachiyomi.util.getUriCompat
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@ -43,11 +44,10 @@ object NotificationHandler {
|
|||||||
* Returns [PendingIntent] that prompts user with apk install intent
|
* Returns [PendingIntent] that prompts user with apk install intent
|
||||||
*
|
*
|
||||||
* @param context context
|
* @param context context
|
||||||
* @param file file of apk that is installed
|
* @param uri uri of apk that is installed
|
||||||
*/
|
*/
|
||||||
fun installApkPendingActivity(context: Context, file: File): PendingIntent {
|
fun installApkPendingActivity(context: Context, uri: Uri): PendingIntent {
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
val uri = file.getUriCompat(context)
|
|
||||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
}
|
}
|
||||||
|
@ -38,6 +38,11 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
ACTION_DISMISS_NOTIFICATION -> dismissNotification(context, intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1))
|
ACTION_DISMISS_NOTIFICATION -> dismissNotification(context, intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1))
|
||||||
// Resume the download service
|
// Resume the download service
|
||||||
ACTION_RESUME_DOWNLOADS -> DownloadService.start(context)
|
ACTION_RESUME_DOWNLOADS -> DownloadService.start(context)
|
||||||
|
// Pause the download service
|
||||||
|
ACTION_PAUSE_DOWNLOADS -> {
|
||||||
|
DownloadService.stop(context)
|
||||||
|
downloadManager.pauseDownloads()
|
||||||
|
}
|
||||||
// Clear the download queue
|
// Clear the download queue
|
||||||
ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue(true)
|
ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue(true)
|
||||||
// Show message notification created
|
// Show message notification created
|
||||||
@ -159,6 +164,9 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
// Called to resume downloads.
|
// Called to resume downloads.
|
||||||
private const val ACTION_RESUME_DOWNLOADS = "$ID.$NAME.ACTION_RESUME_DOWNLOADS"
|
private const val ACTION_RESUME_DOWNLOADS = "$ID.$NAME.ACTION_RESUME_DOWNLOADS"
|
||||||
|
|
||||||
|
// Called to pause downloads.
|
||||||
|
private const val ACTION_PAUSE_DOWNLOADS = "$ID.$NAME.ACTION_PAUSE_DOWNLOADS"
|
||||||
|
|
||||||
// Called to clear downloads.
|
// Called to clear downloads.
|
||||||
private const val ACTION_CLEAR_DOWNLOADS = "$ID.$NAME.ACTION_CLEAR_DOWNLOADS"
|
private const val ACTION_CLEAR_DOWNLOADS = "$ID.$NAME.ACTION_CLEAR_DOWNLOADS"
|
||||||
|
|
||||||
@ -190,6 +198,19 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns [PendingIntent] that pauses the download queue
|
||||||
|
*
|
||||||
|
* @param context context of application
|
||||||
|
* @return [PendingIntent]
|
||||||
|
*/
|
||||||
|
internal fun pauseDownloadsPendingBroadcast(context: Context): PendingIntent {
|
||||||
|
val intent = Intent(context, NotificationReceiver::class.java).apply {
|
||||||
|
action = ACTION_PAUSE_DOWNLOADS
|
||||||
|
}
|
||||||
|
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a [PendingIntent] that clears the download queue
|
* Returns a [PendingIntent] that clears the download queue
|
||||||
*
|
*
|
||||||
@ -203,7 +224,7 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun shortcutCreatedBroadcast(context: Context) : PendingIntent {
|
internal fun shortcutCreatedBroadcast(context: Context): PendingIntent {
|
||||||
val intent = Intent(context, NotificationReceiver::class.java).apply {
|
val intent = Intent(context, NotificationReceiver::class.java).apply {
|
||||||
action = ACTION_SHORTCUT_CREATED
|
action = ACTION_SHORTCUT_CREATED
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,48 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.preference
|
||||||
|
|
||||||
|
import android.support.v7.preference.PreferenceDataStore
|
||||||
|
|
||||||
|
class EmptyPreferenceDataStore : PreferenceDataStore() {
|
||||||
|
|
||||||
|
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putBoolean(key: String?, value: Boolean) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getInt(key: String?, defValue: Int): Int {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putInt(key: String?, value: Int) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLong(key: String?, defValue: Long): Long {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putLong(key: String?, value: Long) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFloat(key: String?, defValue: Float): Float {
|
||||||
|
return 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putFloat(key: String?, value: Float) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getString(key: String?, defValue: String?): String? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putString(key: String?, value: String?) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getStringSet(key: String?, defValues: Set<String>?): Set<String>? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putStringSet(key: String?, values: Set<String>?) {
|
||||||
|
}
|
||||||
|
}
|
@ -11,6 +11,8 @@ object PreferenceKeys {
|
|||||||
|
|
||||||
const val enableTransitions = "pref_enable_transitions_key"
|
const val enableTransitions = "pref_enable_transitions_key"
|
||||||
|
|
||||||
|
const val doubleTapAnimationSpeed = "pref_double_tap_anim_speed"
|
||||||
|
|
||||||
const val showPageNumber = "pref_show_page_number_key"
|
const val showPageNumber = "pref_show_page_number_key"
|
||||||
|
|
||||||
const val fullscreen = "fullscreen"
|
const val fullscreen = "fullscreen"
|
||||||
@ -29,16 +31,18 @@ object PreferenceKeys {
|
|||||||
|
|
||||||
const val imageScaleType = "pref_image_scale_type_key"
|
const val imageScaleType = "pref_image_scale_type_key"
|
||||||
|
|
||||||
const val imageDecoder = "image_decoder"
|
|
||||||
|
|
||||||
const val zoomStart = "pref_zoom_start_key"
|
const val zoomStart = "pref_zoom_start_key"
|
||||||
|
|
||||||
const val readerTheme = "pref_reader_theme_key"
|
const val readerTheme = "pref_reader_theme_key"
|
||||||
|
|
||||||
const val cropBorders = "crop_borders"
|
const val cropBorders = "crop_borders"
|
||||||
|
|
||||||
|
const val cropBordersWebtoon = "crop_borders_webtoon"
|
||||||
|
|
||||||
const val readWithTapping = "reader_tap"
|
const val readWithTapping = "reader_tap"
|
||||||
|
|
||||||
|
const val readWithLongTap = "reader_long_tap"
|
||||||
|
|
||||||
const val readWithVolumeKeys = "reader_volume_keys"
|
const val readWithVolumeKeys = "reader_volume_keys"
|
||||||
|
|
||||||
const val readWithVolumeKeysInverted = "reader_volume_keys_inverted"
|
const val readWithVolumeKeysInverted = "reader_volume_keys_inverted"
|
||||||
@ -51,8 +55,6 @@ object PreferenceKeys {
|
|||||||
|
|
||||||
const val autoUpdateTrack = "pref_auto_update_manga_sync_key"
|
const val autoUpdateTrack = "pref_auto_update_manga_sync_key"
|
||||||
|
|
||||||
const val askUpdateTrack = "pref_ask_update_manga_sync_key"
|
|
||||||
|
|
||||||
const val lastUsedCatalogueSource = "last_catalogue_source"
|
const val lastUsedCatalogueSource = "last_catalogue_source"
|
||||||
|
|
||||||
const val lastUsedCategory = "last_used_category"
|
const val lastUsedCategory = "last_used_category"
|
||||||
@ -65,8 +67,6 @@ object PreferenceKeys {
|
|||||||
|
|
||||||
const val downloadsDirectory = "download_directory"
|
const val downloadsDirectory = "download_directory"
|
||||||
|
|
||||||
const val downloadThreads = "pref_download_slots_key"
|
|
||||||
|
|
||||||
const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key"
|
const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key"
|
||||||
|
|
||||||
const val numberOfBackups = "backup_slots"
|
const val numberOfBackups = "backup_slots"
|
||||||
@ -107,10 +107,14 @@ object PreferenceKeys {
|
|||||||
|
|
||||||
const val downloadBadge = "display_download_badge"
|
const val downloadBadge = "display_download_badge"
|
||||||
|
|
||||||
|
@Deprecated("Use the preferences of the source")
|
||||||
fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId"
|
fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId"
|
||||||
|
|
||||||
|
@Deprecated("Use the preferences of the source")
|
||||||
fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId"
|
fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId"
|
||||||
|
|
||||||
|
fun sourceSharedPref(sourceId: Long) = "source_$sourceId"
|
||||||
|
|
||||||
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
|
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
|
||||||
|
|
||||||
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"
|
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"
|
||||||
|
@ -39,6 +39,8 @@ class PreferencesHelper(val context: Context) {
|
|||||||
|
|
||||||
fun pageTransitions() = rxPrefs.getBoolean(Keys.enableTransitions, true)
|
fun pageTransitions() = rxPrefs.getBoolean(Keys.enableTransitions, true)
|
||||||
|
|
||||||
|
fun doubleTapAnimSpeed() = rxPrefs.getInteger(Keys.doubleTapAnimationSpeed, 500)
|
||||||
|
|
||||||
fun showPageNumber() = rxPrefs.getBoolean(Keys.showPageNumber, true)
|
fun showPageNumber() = rxPrefs.getBoolean(Keys.showPageNumber, true)
|
||||||
|
|
||||||
fun fullscreen() = rxPrefs.getBoolean(Keys.fullscreen, true)
|
fun fullscreen() = rxPrefs.getBoolean(Keys.fullscreen, true)
|
||||||
@ -57,16 +59,18 @@ class PreferencesHelper(val context: Context) {
|
|||||||
|
|
||||||
fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1)
|
fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1)
|
||||||
|
|
||||||
fun imageDecoder() = rxPrefs.getInteger(Keys.imageDecoder, 0)
|
|
||||||
|
|
||||||
fun zoomStart() = rxPrefs.getInteger(Keys.zoomStart, 1)
|
fun zoomStart() = rxPrefs.getInteger(Keys.zoomStart, 1)
|
||||||
|
|
||||||
fun readerTheme() = rxPrefs.getInteger(Keys.readerTheme, 0)
|
fun readerTheme() = rxPrefs.getInteger(Keys.readerTheme, 0)
|
||||||
|
|
||||||
fun cropBorders() = rxPrefs.getBoolean(Keys.cropBorders, false)
|
fun cropBorders() = rxPrefs.getBoolean(Keys.cropBorders, false)
|
||||||
|
|
||||||
|
fun cropBordersWebtoon() = rxPrefs.getBoolean(Keys.cropBordersWebtoon, false)
|
||||||
|
|
||||||
fun readWithTapping() = rxPrefs.getBoolean(Keys.readWithTapping, true)
|
fun readWithTapping() = rxPrefs.getBoolean(Keys.readWithTapping, true)
|
||||||
|
|
||||||
|
fun readWithLongTap() = rxPrefs.getBoolean(Keys.readWithLongTap, true)
|
||||||
|
|
||||||
fun readWithVolumeKeys() = rxPrefs.getBoolean(Keys.readWithVolumeKeys, false)
|
fun readWithVolumeKeys() = rxPrefs.getBoolean(Keys.readWithVolumeKeys, false)
|
||||||
|
|
||||||
fun readWithVolumeKeysInverted() = rxPrefs.getBoolean(Keys.readWithVolumeKeysInverted, false)
|
fun readWithVolumeKeysInverted() = rxPrefs.getBoolean(Keys.readWithVolumeKeysInverted, false)
|
||||||
@ -79,8 +83,6 @@ class PreferencesHelper(val context: Context) {
|
|||||||
|
|
||||||
fun autoUpdateTrack() = prefs.getBoolean(Keys.autoUpdateTrack, true)
|
fun autoUpdateTrack() = prefs.getBoolean(Keys.autoUpdateTrack, true)
|
||||||
|
|
||||||
fun askUpdateTrack() = prefs.getBoolean(Keys.askUpdateTrack, false)
|
|
||||||
|
|
||||||
fun lastUsedCatalogueSource() = rxPrefs.getLong(Keys.lastUsedCatalogueSource, -1)
|
fun lastUsedCatalogueSource() = rxPrefs.getLong(Keys.lastUsedCatalogueSource, -1)
|
||||||
|
|
||||||
fun lastUsedCategory() = rxPrefs.getInteger(Keys.lastUsedCategory, 0)
|
fun lastUsedCategory() = rxPrefs.getInteger(Keys.lastUsedCategory, 0)
|
||||||
@ -115,14 +117,12 @@ class PreferencesHelper(val context: Context) {
|
|||||||
|
|
||||||
fun trackToken(sync: TrackService) = rxPrefs.getString(Keys.trackToken(sync.id), "")
|
fun trackToken(sync: TrackService) = rxPrefs.getString(Keys.trackToken(sync.id), "")
|
||||||
|
|
||||||
fun anilistScoreType() = rxPrefs.getInteger("anilist_score_type", 0)
|
fun anilistScoreType() = rxPrefs.getString("anilist_score_type", "POINT_10")
|
||||||
|
|
||||||
fun backupsDirectory() = rxPrefs.getString(Keys.backupDirectory, defaultBackupDir.toString())
|
fun backupsDirectory() = rxPrefs.getString(Keys.backupDirectory, defaultBackupDir.toString())
|
||||||
|
|
||||||
fun downloadsDirectory() = rxPrefs.getString(Keys.downloadsDirectory, defaultDownloadsDir.toString())
|
fun downloadsDirectory() = rxPrefs.getString(Keys.downloadsDirectory, defaultDownloadsDir.toString())
|
||||||
|
|
||||||
fun downloadThreads() = rxPrefs.getInteger(Keys.downloadThreads, 1)
|
|
||||||
|
|
||||||
fun downloadOnlyOverWifi() = prefs.getBoolean(Keys.downloadOnlyOverWifi, true)
|
fun downloadOnlyOverWifi() = prefs.getBoolean(Keys.downloadOnlyOverWifi, true)
|
||||||
|
|
||||||
fun numberOfBackups() = rxPrefs.getInteger(Keys.numberOfBackups, 1)
|
fun numberOfBackups() = rxPrefs.getInteger(Keys.numberOfBackups, 1)
|
||||||
@ -165,4 +165,7 @@ class PreferencesHelper(val context: Context) {
|
|||||||
|
|
||||||
fun defaultCategory() = prefs.getInt(Keys.defaultCategory, -1)
|
fun defaultCategory() = prefs.getInt(Keys.defaultCategory, -1)
|
||||||
|
|
||||||
|
fun migrateFlags() = rxPrefs.getInteger("migrate_flags", Int.MAX_VALUE)
|
||||||
|
|
||||||
|
fun trustedSignatures() = rxPrefs.getStringSet("trusted_signatures", emptySet())
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,55 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.preference
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.support.v7.preference.PreferenceDataStore
|
||||||
|
|
||||||
|
class SharedPreferencesDataStore(private val prefs: SharedPreferences) : PreferenceDataStore() {
|
||||||
|
|
||||||
|
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
|
||||||
|
return prefs.getBoolean(key, defValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putBoolean(key: String?, value: Boolean) {
|
||||||
|
prefs.edit().putBoolean(key, value).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getInt(key: String?, defValue: Int): Int {
|
||||||
|
return prefs.getInt(key, defValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putInt(key: String?, value: Int) {
|
||||||
|
prefs.edit().putInt(key, value).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLong(key: String?, defValue: Long): Long {
|
||||||
|
return prefs.getLong(key, defValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putLong(key: String?, value: Long) {
|
||||||
|
prefs.edit().putLong(key, value).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFloat(key: String?, defValue: Float): Float {
|
||||||
|
return prefs.getFloat(key, defValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putFloat(key: String?, value: Float) {
|
||||||
|
prefs.edit().putFloat(key, value).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getString(key: String?, defValue: String?): String? {
|
||||||
|
return prefs.getString(key, defValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putString(key: String?, value: String?) {
|
||||||
|
prefs.edit().putString(key, value).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String> {
|
||||||
|
return prefs.getStringSet(key, defValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putStringSet(key: String?, values: MutableSet<String>?) {
|
||||||
|
prefs.edit().putStringSet(key, values).apply()
|
||||||
|
}
|
||||||
|
}
|
@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.track
|
|||||||
import android.support.annotation.CallSuper
|
import android.support.annotation.CallSuper
|
||||||
import android.support.annotation.DrawableRes
|
import android.support.annotation.DrawableRes
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@ -44,7 +45,7 @@ abstract class TrackService(val id: Int) {
|
|||||||
|
|
||||||
abstract fun bind(track: Track): Observable<Track>
|
abstract fun bind(track: Track): Observable<Track>
|
||||||
|
|
||||||
abstract fun search(query: String): Observable<List<Track>>
|
abstract fun search(query: String): Observable<List<TrackSearch>>
|
||||||
|
|
||||||
abstract fun refresh(track: Track): Observable<Track>
|
abstract fun refresh(track: Track): Observable<Track>
|
||||||
|
|
||||||
@ -59,12 +60,11 @@ abstract class TrackService(val id: Int) {
|
|||||||
get() = !getUsername().isEmpty() &&
|
get() = !getUsername().isEmpty() &&
|
||||||
!getPassword().isEmpty()
|
!getPassword().isEmpty()
|
||||||
|
|
||||||
fun getUsername() = preferences.trackUsername(this)
|
fun getUsername() = preferences.trackUsername(this)!!
|
||||||
|
|
||||||
fun getPassword() = preferences.trackPassword(this)
|
fun getPassword() = preferences.trackPassword(this)!!
|
||||||
|
|
||||||
fun saveCredentials(username: String, password: String) {
|
fun saveCredentials(username: String, password: String) {
|
||||||
preferences.setTrackCredentials(this, username, password)
|
preferences.setTrackCredentials(this, username, password)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -2,12 +2,15 @@ package eu.kanade.tachiyomi.data.track.anilist
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import com.google.gson.Gson
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
import eu.kanade.tachiyomi.data.track.TrackService
|
import eu.kanade.tachiyomi.data.track.TrackService
|
||||||
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import rx.Completable
|
import rx.Completable
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
||||||
|
|
||||||
@ -16,24 +19,45 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
const val COMPLETED = 2
|
const val COMPLETED = 2
|
||||||
const val ON_HOLD = 3
|
const val ON_HOLD = 3
|
||||||
const val DROPPED = 4
|
const val DROPPED = 4
|
||||||
const val PLAN_TO_READ = 5
|
const val PLANNING = 5
|
||||||
|
const val REPEATING = 6
|
||||||
|
|
||||||
const val DEFAULT_STATUS = READING
|
const val DEFAULT_STATUS = READING
|
||||||
const val DEFAULT_SCORE = 0
|
const val DEFAULT_SCORE = 0
|
||||||
|
|
||||||
|
const val POINT_100 = "POINT_100"
|
||||||
|
const val POINT_10 = "POINT_10"
|
||||||
|
const val POINT_10_DECIMAL = "POINT_10_DECIMAL"
|
||||||
|
const val POINT_5 = "POINT_5"
|
||||||
|
const val POINT_3 = "POINT_3"
|
||||||
}
|
}
|
||||||
|
|
||||||
override val name = "AniList"
|
override val name = "AniList"
|
||||||
|
|
||||||
private val interceptor by lazy { AnilistInterceptor(getPassword()) }
|
private val gson: Gson by injectLazy()
|
||||||
|
|
||||||
|
private val interceptor by lazy { AnilistInterceptor(this, getPassword()) }
|
||||||
|
|
||||||
private val api by lazy { AnilistApi(client, interceptor) }
|
private val api by lazy { AnilistApi(client, interceptor) }
|
||||||
|
|
||||||
|
private val scorePreference = preferences.anilistScoreType()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// If the preference is an int from APIv1, logout user to force using APIv2
|
||||||
|
try {
|
||||||
|
scorePreference.get()
|
||||||
|
} catch (e: ClassCastException) {
|
||||||
|
logout()
|
||||||
|
scorePreference.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun getLogo() = R.drawable.al
|
override fun getLogo() = R.drawable.al
|
||||||
|
|
||||||
override fun getLogoColor() = Color.rgb(18, 25, 35)
|
override fun getLogoColor() = Color.rgb(18, 25, 35)
|
||||||
|
|
||||||
override fun getStatusList(): List<Int> {
|
override fun getStatusList(): List<Int> {
|
||||||
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
|
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getStatus(status: Int): String = with(context) {
|
override fun getStatus(status: Int): String = with(context) {
|
||||||
@ -42,50 +66,61 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
COMPLETED -> getString(R.string.completed)
|
COMPLETED -> getString(R.string.completed)
|
||||||
ON_HOLD -> getString(R.string.on_hold)
|
ON_HOLD -> getString(R.string.on_hold)
|
||||||
DROPPED -> getString(R.string.dropped)
|
DROPPED -> getString(R.string.dropped)
|
||||||
PLAN_TO_READ -> getString(R.string.plan_to_read)
|
PLANNING -> getString(R.string.plan_to_read)
|
||||||
|
REPEATING -> getString(R.string.repeating)
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getScoreList(): List<String> {
|
override fun getScoreList(): List<String> {
|
||||||
return when (preferences.anilistScoreType().getOrDefault()) {
|
return when (scorePreference.getOrDefault()) {
|
||||||
// 10 point
|
// 10 point
|
||||||
0 -> IntRange(0, 10).map(Int::toString)
|
POINT_10 -> IntRange(0, 10).map(Int::toString)
|
||||||
// 100 point
|
// 100 point
|
||||||
1 -> IntRange(0, 100).map(Int::toString)
|
POINT_100 -> IntRange(0, 100).map(Int::toString)
|
||||||
// 5 stars
|
// 5 stars
|
||||||
2 -> IntRange(0, 5).map { "$it ★" }
|
POINT_5 -> IntRange(0, 5).map { "$it ★" }
|
||||||
// Smiley
|
// Smiley
|
||||||
3 -> listOf("-", "😦", "😐", "😊")
|
POINT_3 -> listOf("-", "😦", "😐", "😊")
|
||||||
// 10 point decimal
|
// 10 point decimal
|
||||||
4 -> IntRange(0, 100).map { (it / 10f).toString() }
|
POINT_10_DECIMAL -> IntRange(0, 100).map { (it / 10f).toString() }
|
||||||
else -> throw Exception("Unknown score type")
|
else -> throw Exception("Unknown score type")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun indexToScore(index: Int): Float {
|
override fun indexToScore(index: Int): Float {
|
||||||
return when (preferences.anilistScoreType().getOrDefault()) {
|
return when (scorePreference.getOrDefault()) {
|
||||||
// 10 point
|
// 10 point
|
||||||
0 -> index * 10f
|
POINT_10 -> index * 10f
|
||||||
// 100 point
|
// 100 point
|
||||||
1 -> index.toFloat()
|
POINT_100 -> index.toFloat()
|
||||||
// 5 stars
|
// 5 stars
|
||||||
2 -> index * 20f
|
POINT_5 -> when {
|
||||||
|
index == 0 -> 0f
|
||||||
|
else -> index * 20f - 10f
|
||||||
|
}
|
||||||
// Smiley
|
// Smiley
|
||||||
3 -> index * 30f
|
POINT_3 -> when {
|
||||||
|
index == 0 -> 0f
|
||||||
|
else -> index * 25f + 10f
|
||||||
|
}
|
||||||
// 10 point decimal
|
// 10 point decimal
|
||||||
4 -> index.toFloat()
|
POINT_10_DECIMAL -> index.toFloat()
|
||||||
else -> throw Exception("Unknown score type")
|
else -> throw Exception("Unknown score type")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun displayScore(track: Track): String {
|
override fun displayScore(track: Track): String {
|
||||||
val score = track.score
|
val score = track.score
|
||||||
return when (preferences.anilistScoreType().getOrDefault()) {
|
|
||||||
2 -> "${(score / 20).toInt()} ★"
|
return when (scorePreference.getOrDefault()) {
|
||||||
3 -> when {
|
POINT_5 -> when {
|
||||||
|
score == 0f -> "0 ★"
|
||||||
|
else -> "${((score + 10) / 20).toInt()} ★"
|
||||||
|
}
|
||||||
|
POINT_3 -> when {
|
||||||
score == 0f -> "0"
|
score == 0f -> "0"
|
||||||
score <= 30 -> "😦"
|
score <= 35 -> "😦"
|
||||||
score <= 60 -> "😐"
|
score <= 60 -> "😐"
|
||||||
else -> "😊"
|
else -> "😊"
|
||||||
}
|
}
|
||||||
@ -101,15 +136,26 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
|
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
|
||||||
track.status = COMPLETED
|
track.status = COMPLETED
|
||||||
}
|
}
|
||||||
|
// If user was using API v1 fetch library_id
|
||||||
|
if (track.library_id == null || track.library_id!! == 0L){
|
||||||
|
return api.findLibManga(track, getUsername().toInt()).flatMap {
|
||||||
|
if (it == null) {
|
||||||
|
throw Exception("$track not found on user library")
|
||||||
|
}
|
||||||
|
track.library_id = it.library_id
|
||||||
|
api.updateLibManga(track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return api.updateLibManga(track)
|
return api.updateLibManga(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun bind(track: Track): Observable<Track> {
|
override fun bind(track: Track): Observable<Track> {
|
||||||
return api.findLibManga(track, getUsername())
|
return api.findLibManga(track, getUsername().toInt())
|
||||||
.flatMap { remoteTrack ->
|
.flatMap { remoteTrack ->
|
||||||
if (remoteTrack != null) {
|
if (remoteTrack != null) {
|
||||||
track.copyPersonalFrom(remoteTrack)
|
track.copyPersonalFrom(remoteTrack)
|
||||||
|
track.library_id = remoteTrack.library_id
|
||||||
update(track)
|
update(track)
|
||||||
} else {
|
} else {
|
||||||
// Set default fields if it's not found in the list
|
// Set default fields if it's not found in the list
|
||||||
@ -120,12 +166,12 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun search(query: String): Observable<List<Track>> {
|
override fun search(query: String): Observable<List<TrackSearch>> {
|
||||||
return api.search(query)
|
return api.search(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun refresh(track: Track): Observable<Track> {
|
override fun refresh(track: Track): Observable<Track> {
|
||||||
return api.getLibManga(track, getUsername())
|
return api.getLibManga(track, getUsername().toInt())
|
||||||
.map { remoteTrack ->
|
.map { remoteTrack ->
|
||||||
track.copyPersonalFrom(remoteTrack)
|
track.copyPersonalFrom(remoteTrack)
|
||||||
track.total_chapters = remoteTrack.total_chapters
|
track.total_chapters = remoteTrack.total_chapters
|
||||||
@ -135,26 +181,34 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
|
|
||||||
override fun login(username: String, password: String) = login(password)
|
override fun login(username: String, password: String) = login(password)
|
||||||
|
|
||||||
fun login(authCode: String): Completable {
|
fun login(token: String): Completable {
|
||||||
return api.login(authCode)
|
val oauth = api.createOAuth(token)
|
||||||
// Save the token in the interceptor.
|
interceptor.setAuth(oauth)
|
||||||
.doOnNext { interceptor.setAuth(it) }
|
return api.getCurrentUser().map { (username, scoreType) ->
|
||||||
// Obtain the authenticated user from the API.
|
scorePreference.set(scoreType)
|
||||||
.zipWith(api.getCurrentUser().map { pair ->
|
saveCredentials(username.toString(), oauth.access_token)
|
||||||
preferences.anilistScoreType().set(pair.second)
|
}.doOnError{
|
||||||
pair.first
|
logout()
|
||||||
}, { oauth, user -> Pair(user, oauth.refresh_token!!) })
|
}.toCompletable()
|
||||||
// Save service credentials (username and refresh token).
|
|
||||||
.doOnNext { saveCredentials(it.first, it.second) }
|
|
||||||
// Logout on any error.
|
|
||||||
.doOnError { logout() }
|
|
||||||
.toCompletable()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun logout() {
|
override fun logout() {
|
||||||
super.logout()
|
super.logout()
|
||||||
|
preferences.trackToken(this).set(null)
|
||||||
interceptor.setAuth(null)
|
interceptor.setAuth(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun saveOAuth(oAuth: OAuth?) {
|
||||||
|
preferences.trackToken(this).set(gson.toJson(oAuth))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadOAuth(): OAuth? {
|
||||||
|
return try {
|
||||||
|
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,161 +1,286 @@
|
|||||||
package eu.kanade.tachiyomi.data.track.anilist
|
package eu.kanade.tachiyomi.data.track.anilist
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.github.salomonbrys.kotson.int
|
import com.github.salomonbrys.kotson.*
|
||||||
import com.github.salomonbrys.kotson.string
|
|
||||||
import com.google.gson.JsonObject
|
import com.google.gson.JsonObject
|
||||||
|
import com.google.gson.JsonParser
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.network.POST
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import okhttp3.FormBody
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
|
import okhttp3.MediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.Request
|
||||||
import retrofit2.Response
|
import okhttp3.RequestBody
|
||||||
import retrofit2.Retrofit
|
|
||||||
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
|
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
|
||||||
import retrofit2.http.*
|
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
|
|
||||||
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||||
|
|
||||||
private val rest = restBuilder()
|
private val parser = JsonParser()
|
||||||
.client(client.newBuilder().addInterceptor(interceptor).build())
|
private val jsonMime = MediaType.parse("application/json; charset=utf-8")
|
||||||
.build()
|
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||||
.create(Rest::class.java)
|
|
||||||
|
|
||||||
fun addLibManga(track: Track): Observable<Track> {
|
fun addLibManga(track: Track): Observable<Track> {
|
||||||
return rest.addLibManga(track.remote_id, track.last_chapter_read, track.toAnilistStatus())
|
val query = """
|
||||||
.map { response ->
|
mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) {
|
||||||
response.body()?.close()
|
SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status)
|
||||||
if (!response.isSuccessful) {
|
{ id status } }
|
||||||
throw Exception("Could not add manga")
|
"""
|
||||||
|
val variables = jsonObject(
|
||||||
|
"mangaId" to track.media_id,
|
||||||
|
"progress" to track.last_chapter_read,
|
||||||
|
"status" to track.toAnilistStatus()
|
||||||
|
)
|
||||||
|
val payload = jsonObject(
|
||||||
|
"query" to query,
|
||||||
|
"variables" to variables
|
||||||
|
)
|
||||||
|
val body = RequestBody.create(jsonMime, payload.toString())
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(apiUrl)
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
return authClient.newCall(request)
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { netResponse ->
|
||||||
|
val responseBody = netResponse.body()?.string().orEmpty()
|
||||||
|
netResponse.close()
|
||||||
|
if (responseBody.isEmpty()) {
|
||||||
|
throw Exception("Null Response")
|
||||||
}
|
}
|
||||||
|
val response = parser.parse(responseBody).obj
|
||||||
|
track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong
|
||||||
track
|
track
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateLibManga(track: Track): Observable<Track> {
|
fun updateLibManga(track: Track): Observable<Track> {
|
||||||
return rest.updateLibManga(track.remote_id, track.last_chapter_read, track.toAnilistStatus(),
|
val query = """
|
||||||
track.toAnilistScore())
|
mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) {
|
||||||
.map { response ->
|
SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) {
|
||||||
response.body()?.close()
|
id
|
||||||
if (!response.isSuccessful) {
|
status
|
||||||
throw Exception("Could not update manga")
|
progress
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
"""
|
||||||
|
val variables = jsonObject(
|
||||||
|
"listId" to track.library_id,
|
||||||
|
"progress" to track.last_chapter_read,
|
||||||
|
"status" to track.toAnilistStatus(),
|
||||||
|
"score" to track.score.toInt()
|
||||||
|
)
|
||||||
|
val payload = jsonObject(
|
||||||
|
"query" to query,
|
||||||
|
"variables" to variables
|
||||||
|
)
|
||||||
|
val body = RequestBody.create(jsonMime, payload.toString())
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(apiUrl)
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
return authClient.newCall(request)
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map {
|
||||||
track
|
track
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun search(query: String): Observable<List<Track>> {
|
fun search(search: String): Observable<List<TrackSearch>> {
|
||||||
return rest.search(query, 1)
|
val query = """
|
||||||
.map { list ->
|
query Search(${'$'}query: String) {
|
||||||
list.filter { it.type != "Novel" }.map { it.toTrack() }
|
Page (perPage: 50) {
|
||||||
|
media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
|
||||||
|
id
|
||||||
|
title {
|
||||||
|
romaji
|
||||||
|
}
|
||||||
|
coverImage {
|
||||||
|
large
|
||||||
|
}
|
||||||
|
type
|
||||||
|
status
|
||||||
|
chapters
|
||||||
|
description
|
||||||
|
startDate {
|
||||||
|
year
|
||||||
|
month
|
||||||
|
day
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onErrorReturn { emptyList() }
|
"""
|
||||||
}
|
val variables = jsonObject(
|
||||||
|
"query" to search
|
||||||
fun getList(username: String): Observable<List<Track>> {
|
)
|
||||||
return rest.getLib(username)
|
val payload = jsonObject(
|
||||||
.map { lib ->
|
"query" to query,
|
||||||
lib.flatten().map { it.toTrack() }
|
"variables" to variables
|
||||||
|
)
|
||||||
|
val body = RequestBody.create(jsonMime, payload.toString())
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(apiUrl)
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
return authClient.newCall(request)
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { netResponse ->
|
||||||
|
val responseBody = netResponse.body()?.string().orEmpty()
|
||||||
|
if (responseBody.isEmpty()) {
|
||||||
|
throw Exception("Null Response")
|
||||||
|
}
|
||||||
|
val response = parser.parse(responseBody).obj
|
||||||
|
val data = response["data"]!!.obj
|
||||||
|
val page = data["Page"].obj
|
||||||
|
val media = page["media"].array
|
||||||
|
val entries = media.map { jsonToALManga(it.obj) }
|
||||||
|
entries.map { it.toTrack() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findLibManga(track: Track, username: String) : Observable<Track?> {
|
|
||||||
// TODO avoid getting the entire list
|
fun findLibManga(track: Track, userid: Int) : Observable<Track?> {
|
||||||
return getList(username)
|
val query = """
|
||||||
.map { list -> list.find { it.remote_id == track.remote_id } }
|
query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
|
||||||
|
Page {
|
||||||
|
mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
scoreRaw: score(format: POINT_100)
|
||||||
|
progress
|
||||||
|
media{
|
||||||
|
id
|
||||||
|
title {
|
||||||
|
romaji
|
||||||
|
}
|
||||||
|
coverImage {
|
||||||
|
large
|
||||||
|
}
|
||||||
|
type
|
||||||
|
status
|
||||||
|
chapters
|
||||||
|
description
|
||||||
|
startDate {
|
||||||
|
year
|
||||||
|
month
|
||||||
|
day
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
val variables = jsonObject(
|
||||||
|
"id" to userid,
|
||||||
|
"manga_id" to track.media_id
|
||||||
|
)
|
||||||
|
val payload = jsonObject(
|
||||||
|
"query" to query,
|
||||||
|
"variables" to variables
|
||||||
|
)
|
||||||
|
val body = RequestBody.create(jsonMime, payload.toString())
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(apiUrl)
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
return authClient.newCall(request)
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { netResponse ->
|
||||||
|
val responseBody = netResponse.body()?.string().orEmpty()
|
||||||
|
if (responseBody.isEmpty()) {
|
||||||
|
throw Exception("Null Response")
|
||||||
|
}
|
||||||
|
val response = parser.parse(responseBody).obj
|
||||||
|
val data = response["data"]!!.obj
|
||||||
|
val page = data["Page"].obj
|
||||||
|
val media = page["mediaList"].array
|
||||||
|
val entries = media.map { jsonToALUserManga(it.obj) }
|
||||||
|
entries.firstOrNull()?.toTrack()
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLibManga(track: Track, username: String): Observable<Track> {
|
fun getLibManga(track: Track, userid: Int): Observable<Track> {
|
||||||
return findLibManga(track, username)
|
return findLibManga(track, userid)
|
||||||
.map { it ?: throw Exception("Could not find manga") }
|
.map { it ?: throw Exception("Could not find manga") }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun login(authCode: String): Observable<OAuth> {
|
fun createOAuth(token: String): OAuth {
|
||||||
return restBuilder()
|
return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
|
||||||
.client(client)
|
}
|
||||||
|
|
||||||
|
fun getCurrentUser(): Observable<Pair<Int, String>> {
|
||||||
|
val query = """
|
||||||
|
query User
|
||||||
|
{
|
||||||
|
Viewer {
|
||||||
|
id
|
||||||
|
mediaListOptions {
|
||||||
|
scoreFormat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
val payload = jsonObject(
|
||||||
|
"query" to query
|
||||||
|
)
|
||||||
|
val body = RequestBody.create(jsonMime, payload.toString())
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(apiUrl)
|
||||||
|
.post(body)
|
||||||
.build()
|
.build()
|
||||||
.create(Rest::class.java)
|
return authClient.newCall(request)
|
||||||
.requestAccessToken(authCode)
|
.asObservableSuccess()
|
||||||
|
.map { netResponse ->
|
||||||
|
val responseBody = netResponse.body()?.string().orEmpty()
|
||||||
|
if (responseBody.isEmpty()) {
|
||||||
|
throw Exception("Null Response")
|
||||||
|
}
|
||||||
|
val response = parser.parse(responseBody).obj
|
||||||
|
val data = response["data"]!!.obj
|
||||||
|
val viewer = data["Viewer"].obj
|
||||||
|
Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentUser(): Observable<Pair<String, Int>> {
|
fun jsonToALManga(struct: JsonObject): ALManga{
|
||||||
return rest.getCurrentUser()
|
val date = try {
|
||||||
.map { it["id"].string to it["score_type"].int }
|
val date = Calendar.getInstance()
|
||||||
|
date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1,
|
||||||
|
struct["startDate"]["day"].nullInt ?: 0)
|
||||||
|
date.timeInMillis
|
||||||
|
} catch (_: Exception) {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
|
||||||
|
return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString,
|
||||||
|
struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString,
|
||||||
|
date, struct["chapters"].nullInt ?: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restBuilder() = Retrofit.Builder()
|
fun jsonToALUserManga(struct: JsonObject): ALUserManga{
|
||||||
.baseUrl(baseUrl)
|
return ALUserManga(struct["id"].asLong, struct["status"].asString, struct["scoreRaw"].asInt, struct["progress"].asInt, jsonToALManga(struct["media"].obj) )
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
|
||||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
|
||||||
|
|
||||||
private interface Rest {
|
|
||||||
|
|
||||||
@FormUrlEncoded
|
|
||||||
@POST("auth/access_token")
|
|
||||||
fun requestAccessToken(
|
|
||||||
@Field("code") code: String,
|
|
||||||
@Field("grant_type") grant_type: String = "authorization_code",
|
|
||||||
@Field("client_id") client_id: String = clientId,
|
|
||||||
@Field("client_secret") client_secret: String = clientSecret,
|
|
||||||
@Field("redirect_uri") redirect_uri: String = clientUrl
|
|
||||||
) : Observable<OAuth>
|
|
||||||
|
|
||||||
@GET("user")
|
|
||||||
fun getCurrentUser(): Observable<JsonObject>
|
|
||||||
|
|
||||||
@GET("manga/search/{query}")
|
|
||||||
fun search(
|
|
||||||
@Path("query") query: String,
|
|
||||||
@Query("page") page: Int
|
|
||||||
): Observable<List<ALManga>>
|
|
||||||
|
|
||||||
@GET("user/{username}/mangalist")
|
|
||||||
fun getLib(
|
|
||||||
@Path("username") username: String
|
|
||||||
): Observable<ALUserLists>
|
|
||||||
|
|
||||||
@FormUrlEncoded
|
|
||||||
@PUT("mangalist")
|
|
||||||
fun addLibManga(
|
|
||||||
@Field("id") id: Int,
|
|
||||||
@Field("chapters_read") chapters_read: Int,
|
|
||||||
@Field("list_status") list_status: String
|
|
||||||
) : Observable<Response<ResponseBody>>
|
|
||||||
|
|
||||||
@FormUrlEncoded
|
|
||||||
@PUT("mangalist")
|
|
||||||
fun updateLibManga(
|
|
||||||
@Field("id") id: Int,
|
|
||||||
@Field("chapters_read") chapters_read: Int,
|
|
||||||
@Field("list_status") list_status: String,
|
|
||||||
@Field("score") score_raw: String
|
|
||||||
) : Observable<Response<ResponseBody>>
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val clientId = "tachiyomi-hrtje"
|
private const val clientId = "385"
|
||||||
private const val clientSecret = "nlGB5OmgE9YWq5dr3gIDbTQV0C"
|
|
||||||
private const val clientUrl = "tachiyomi://anilist-auth"
|
private const val clientUrl = "tachiyomi://anilist-auth"
|
||||||
private const val baseUrl = "https://anilist.co/api/"
|
private const val apiUrl = "https://graphql.anilist.co/"
|
||||||
|
private const val baseUrl = "https://anilist.co/api/v2/"
|
||||||
|
private const val baseMangaUrl = "https://anilist.co/manga/"
|
||||||
|
|
||||||
fun authUrl() = Uri.parse("${baseUrl}auth/authorize").buildUpon()
|
fun mangaUrl(mediaId: Int): String {
|
||||||
.appendQueryParameter("grant_type", "authorization_code")
|
return baseMangaUrl + mediaId
|
||||||
|
}
|
||||||
|
|
||||||
|
fun authUrl() = Uri.parse("${baseUrl}oauth/authorize").buildUpon()
|
||||||
.appendQueryParameter("client_id", clientId)
|
.appendQueryParameter("client_id", clientId)
|
||||||
.appendQueryParameter("redirect_uri", clientUrl)
|
.appendQueryParameter("response_type", "token")
|
||||||
.appendQueryParameter("response_type", "code")
|
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun refreshTokenRequest(token: String) = POST("${baseUrl}auth/access_token",
|
|
||||||
body = FormBody.Builder()
|
|
||||||
.add("grant_type", "refresh_token")
|
|
||||||
.add("client_id", clientId)
|
|
||||||
.add("client_secret", clientSecret)
|
|
||||||
.add("refresh_token", token)
|
|
||||||
.build())
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
package eu.kanade.tachiyomi.data.track.anilist
|
package eu.kanade.tachiyomi.data.track.anilist
|
||||||
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
|
||||||
class AnilistInterceptor(private var refreshToken: String?) : Interceptor {
|
|
||||||
|
class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Interceptor {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OAuth object used for authenticated requests.
|
* OAuth object used for authenticated requests.
|
||||||
@ -20,24 +20,21 @@ class AnilistInterceptor(private var refreshToken: String?) : Interceptor {
|
|||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val originalRequest = chain.request()
|
val originalRequest = chain.request()
|
||||||
|
|
||||||
if (refreshToken.isNullOrEmpty()) {
|
if (token.isNullOrEmpty()) {
|
||||||
throw Exception("Not authenticated with Anilist")
|
throw Exception("Not authenticated with Anilist")
|
||||||
}
|
}
|
||||||
|
if (oauth == null){
|
||||||
|
oauth = anilist.loadOAuth()
|
||||||
|
}
|
||||||
// Refresh access token if null or expired.
|
// Refresh access token if null or expired.
|
||||||
if (oauth == null || oauth!!.isExpired()) {
|
if (oauth!!.isExpired()) {
|
||||||
val response = chain.proceed(AnilistApi.refreshTokenRequest(refreshToken!!))
|
anilist.logout()
|
||||||
oauth = if (response.isSuccessful) {
|
throw Exception("Token expired")
|
||||||
Gson().fromJson(response.body()!!.string(), OAuth::class.java)
|
|
||||||
} else {
|
|
||||||
response.close()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Throw on null auth.
|
// Throw on null auth.
|
||||||
if (oauth == null) {
|
if (oauth == null) {
|
||||||
throw Exception("Access token wasn't refreshed")
|
throw Exception("No authentication token")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the authorization header to the original request.
|
// Add the authorization header to the original request.
|
||||||
@ -53,8 +50,9 @@ class AnilistInterceptor(private var refreshToken: String?) : Interceptor {
|
|||||||
* and the oauth object.
|
* and the oauth object.
|
||||||
*/
|
*/
|
||||||
fun setAuth(oauth: OAuth?) {
|
fun setAuth(oauth: OAuth?) {
|
||||||
refreshToken = oauth?.refresh_token
|
token = oauth?.access_token
|
||||||
this.oauth = oauth
|
this.oauth = oauth
|
||||||
|
anilist.saveOAuth(oauth)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -4,68 +4,87 @@ import eu.kanade.tachiyomi.data.database.models.Track
|
|||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
data class ALManga(
|
data class ALManga(
|
||||||
val id: Int,
|
val media_id: Int,
|
||||||
val title_romaji: String,
|
val title_romaji: String,
|
||||||
|
val image_url_lge: String,
|
||||||
|
val description: String?,
|
||||||
val type: String,
|
val type: String,
|
||||||
|
val publishing_status: String,
|
||||||
|
val start_date_fuzzy: Long,
|
||||||
val total_chapters: Int) {
|
val total_chapters: Int) {
|
||||||
|
|
||||||
fun toTrack() = Track.create(TrackManager.ANILIST).apply {
|
fun toTrack() = TrackSearch.create(TrackManager.ANILIST).apply {
|
||||||
remote_id = this@ALManga.id
|
media_id = this@ALManga.media_id
|
||||||
title = title_romaji
|
title = title_romaji
|
||||||
total_chapters = this@ALManga.total_chapters
|
total_chapters = this@ALManga.total_chapters
|
||||||
|
cover_url = image_url_lge
|
||||||
|
summary = description ?: ""
|
||||||
|
tracking_url = AnilistApi.mangaUrl(media_id)
|
||||||
|
publishing_status = this@ALManga.publishing_status
|
||||||
|
publishing_type = type
|
||||||
|
if (start_date_fuzzy != 0L) {
|
||||||
|
start_date = try {
|
||||||
|
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||||
|
outputDf.format(start_date_fuzzy)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ALUserManga(
|
data class ALUserManga(
|
||||||
val id: Int,
|
val library_id: Long,
|
||||||
val list_status: String,
|
val list_status: String,
|
||||||
val score_raw: Int,
|
val score_raw: Int,
|
||||||
val chapters_read: Int,
|
val chapters_read: Int,
|
||||||
val manga: ALManga) {
|
val manga: ALManga) {
|
||||||
|
|
||||||
fun toTrack() = Track.create(TrackManager.ANILIST).apply {
|
fun toTrack() = Track.create(TrackManager.ANILIST).apply {
|
||||||
remote_id = manga.id
|
media_id = manga.media_id
|
||||||
status = toTrackStatus()
|
status = toTrackStatus()
|
||||||
score = score_raw.toFloat()
|
score = score_raw.toFloat()
|
||||||
last_chapter_read = chapters_read
|
last_chapter_read = chapters_read
|
||||||
|
library_id = this@ALUserManga.library_id
|
||||||
|
total_chapters = manga.total_chapters
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toTrackStatus() = when (list_status) {
|
fun toTrackStatus() = when (list_status) {
|
||||||
"reading" -> Anilist.READING
|
"CURRENT" -> Anilist.READING
|
||||||
"completed" -> Anilist.COMPLETED
|
"COMPLETED" -> Anilist.COMPLETED
|
||||||
"on-hold" -> Anilist.ON_HOLD
|
"PAUSED" -> Anilist.ON_HOLD
|
||||||
"dropped" -> Anilist.DROPPED
|
"DROPPED" -> Anilist.DROPPED
|
||||||
"plan to read" -> Anilist.PLAN_TO_READ
|
"PLANNING" -> Anilist.PLANNING
|
||||||
|
"REPEATING" -> Anilist.REPEATING
|
||||||
else -> throw NotImplementedError("Unknown status")
|
else -> throw NotImplementedError("Unknown status")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ALUserLists(val lists: Map<String, List<ALUserManga>>) {
|
|
||||||
|
|
||||||
fun flatten() = lists.values.flatten()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Track.toAnilistStatus() = when (status) {
|
fun Track.toAnilistStatus() = when (status) {
|
||||||
Anilist.READING -> "reading"
|
Anilist.READING -> "CURRENT"
|
||||||
Anilist.COMPLETED -> "completed"
|
Anilist.COMPLETED -> "COMPLETED"
|
||||||
Anilist.ON_HOLD -> "on-hold"
|
Anilist.ON_HOLD -> "PAUSED"
|
||||||
Anilist.DROPPED -> "dropped"
|
Anilist.DROPPED -> "DROPPED"
|
||||||
Anilist.PLAN_TO_READ -> "plan to read"
|
Anilist.PLANNING -> "PLANNING"
|
||||||
|
Anilist.REPEATING -> "REPEATING"
|
||||||
else -> throw NotImplementedError("Unknown status")
|
else -> throw NotImplementedError("Unknown status")
|
||||||
}
|
}
|
||||||
|
|
||||||
private val preferences: PreferencesHelper by injectLazy()
|
private val preferences: PreferencesHelper by injectLazy()
|
||||||
|
|
||||||
fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().getOrDefault()) {
|
fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().getOrDefault()) {
|
||||||
// 10 point
|
// 10 point
|
||||||
0 -> (score.toInt() / 10).toString()
|
"POINT_10" -> (score.toInt() / 10).toString()
|
||||||
// 100 point
|
// 100 point
|
||||||
1 -> score.toInt().toString()
|
"POINT_100" -> score.toInt().toString()
|
||||||
// 5 stars
|
// 5 stars
|
||||||
2 -> when {
|
"POINT_5" -> when {
|
||||||
score == 0f -> "0"
|
score == 0f -> "0"
|
||||||
score < 30 -> "1"
|
score < 30 -> "1"
|
||||||
score < 50 -> "2"
|
score < 50 -> "2"
|
||||||
@ -73,14 +92,14 @@ fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().getOrD
|
|||||||
score < 90 -> "4"
|
score < 90 -> "4"
|
||||||
else -> "5"
|
else -> "5"
|
||||||
}
|
}
|
||||||
// Smiley
|
// Smiley
|
||||||
3 -> when {
|
"POINT_3" -> when {
|
||||||
score == 0f -> "0"
|
score == 0f -> "0"
|
||||||
score <= 30 -> ":("
|
score <= 35 -> ":("
|
||||||
score <= 60 -> ":|"
|
score <= 60 -> ":|"
|
||||||
else -> ":)"
|
else -> ":)"
|
||||||
}
|
}
|
||||||
// 10 point decimal
|
// 10 point decimal
|
||||||
4 -> (score / 10).toString()
|
"POINT_10_DECIMAL" -> (score / 10).toString()
|
||||||
else -> throw Exception("Unknown score type")
|
else -> throw Exception("Unknown score type")
|
||||||
}
|
}
|
||||||
|
@ -4,8 +4,7 @@ data class OAuth(
|
|||||||
val access_token: String,
|
val access_token: String,
|
||||||
val token_type: String,
|
val token_type: String,
|
||||||
val expires: Long,
|
val expires: Long,
|
||||||
val expires_in: Long,
|
val expires_in: Long) {
|
||||||
val refresh_token: String?) {
|
|
||||||
|
|
||||||
fun isExpired() = System.currentTimeMillis() > expires
|
fun isExpired() = System.currentTimeMillis() > expires
|
||||||
}
|
}
|
@ -6,6 +6,7 @@ import com.google.gson.Gson
|
|||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.track.TrackService
|
import eu.kanade.tachiyomi.data.track.TrackService
|
||||||
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import rx.Completable
|
import rx.Completable
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
@ -86,7 +87,7 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
.flatMap { remoteTrack ->
|
.flatMap { remoteTrack ->
|
||||||
if (remoteTrack != null) {
|
if (remoteTrack != null) {
|
||||||
track.copyPersonalFrom(remoteTrack)
|
track.copyPersonalFrom(remoteTrack)
|
||||||
track.remote_id = remoteTrack.remote_id
|
track.media_id = remoteTrack.media_id
|
||||||
update(track)
|
update(track)
|
||||||
} else {
|
} else {
|
||||||
track.score = DEFAULT_SCORE
|
track.score = DEFAULT_SCORE
|
||||||
@ -96,7 +97,7 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun search(query: String): Observable<List<Track>> {
|
override fun search(query: String): Observable<List<TrackSearch>> {
|
||||||
return api.search(query)
|
return api.search(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,4 +141,4 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import com.github.salomonbrys.kotson.*
|
|||||||
import com.google.gson.GsonBuilder
|
import com.google.gson.GsonBuilder
|
||||||
import com.google.gson.JsonObject
|
import com.google.gson.JsonObject
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import eu.kanade.tachiyomi.network.POST
|
import eu.kanade.tachiyomi.network.POST
|
||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@ -15,43 +16,60 @@ import rx.Observable
|
|||||||
|
|
||||||
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
|
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
|
||||||
|
|
||||||
|
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||||
|
|
||||||
private val rest = Retrofit.Builder()
|
private val rest = Retrofit.Builder()
|
||||||
.baseUrl(baseUrl)
|
.baseUrl(baseUrl)
|
||||||
.client(client.newBuilder().addInterceptor(interceptor).build())
|
.client(authClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create()))
|
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create()))
|
||||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||||
.build()
|
.build()
|
||||||
.create(KitsuApi.Rest::class.java)
|
.create(KitsuApi.Rest::class.java)
|
||||||
|
|
||||||
|
private val searchRest = Retrofit.Builder()
|
||||||
|
.baseUrl(algoliaKeyUrl)
|
||||||
|
.client(authClient)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||||
|
.build()
|
||||||
|
.create(KitsuApi.SearchKeyRest::class.java)
|
||||||
|
|
||||||
|
private val algoliaRest = Retrofit.Builder()
|
||||||
|
.baseUrl(algoliaUrl)
|
||||||
|
.client(client)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||||
|
.build()
|
||||||
|
.create(KitsuApi.AgoliaSearchRest::class.java)
|
||||||
|
|
||||||
fun addLibManga(track: Track, userId: String): Observable<Track> {
|
fun addLibManga(track: Track, userId: String): Observable<Track> {
|
||||||
return Observable.defer {
|
return Observable.defer {
|
||||||
// @formatter:off
|
// @formatter:off
|
||||||
val data = jsonObject(
|
val data = jsonObject(
|
||||||
"type" to "libraryEntries",
|
"type" to "libraryEntries",
|
||||||
"attributes" to jsonObject(
|
"attributes" to jsonObject(
|
||||||
"status" to track.toKitsuStatus(),
|
"status" to track.toKitsuStatus(),
|
||||||
"progress" to track.last_chapter_read
|
"progress" to track.last_chapter_read
|
||||||
),
|
|
||||||
"relationships" to jsonObject(
|
|
||||||
"user" to jsonObject(
|
|
||||||
"data" to jsonObject(
|
|
||||||
"id" to userId,
|
|
||||||
"type" to "users"
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
"media" to jsonObject(
|
"relationships" to jsonObject(
|
||||||
"data" to jsonObject(
|
"user" to jsonObject(
|
||||||
"id" to track.remote_id,
|
"data" to jsonObject(
|
||||||
"type" to "manga"
|
"id" to userId,
|
||||||
)
|
"type" to "users"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"media" to jsonObject(
|
||||||
|
"data" to jsonObject(
|
||||||
|
"id" to track.media_id,
|
||||||
|
"type" to "manga"
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
)
|
)
|
||||||
// @formatter:on
|
|
||||||
|
|
||||||
rest.addLibManga(jsonObject("data" to data))
|
rest.addLibManga(jsonObject("data" to data))
|
||||||
.map { json ->
|
.map { json ->
|
||||||
track.remote_id = json["data"]["id"].int
|
track.media_id = json["data"]["id"].int
|
||||||
track
|
track
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -61,33 +79,46 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
|||||||
return Observable.defer {
|
return Observable.defer {
|
||||||
// @formatter:off
|
// @formatter:off
|
||||||
val data = jsonObject(
|
val data = jsonObject(
|
||||||
"type" to "libraryEntries",
|
"type" to "libraryEntries",
|
||||||
"id" to track.remote_id,
|
"id" to track.media_id,
|
||||||
"attributes" to jsonObject(
|
"attributes" to jsonObject(
|
||||||
"status" to track.toKitsuStatus(),
|
"status" to track.toKitsuStatus(),
|
||||||
"progress" to track.last_chapter_read,
|
"progress" to track.last_chapter_read,
|
||||||
"ratingTwenty" to track.toKitsuScore()
|
"ratingTwenty" to track.toKitsuScore()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
// @formatter:on
|
// @formatter:on
|
||||||
|
|
||||||
rest.updateLibManga(track.remote_id, jsonObject("data" to data))
|
rest.updateLibManga(track.media_id, jsonObject("data" to data))
|
||||||
.map { track }
|
.map { track }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun search(query: String): Observable<List<Track>> {
|
|
||||||
return rest.search(query)
|
fun search(query: String): Observable<List<TrackSearch>> {
|
||||||
|
return searchRest
|
||||||
|
.getKey().map { json ->
|
||||||
|
json["media"].asJsonObject["key"].string
|
||||||
|
}.flatMap { key ->
|
||||||
|
algoliaSearch(key, query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun algoliaSearch(key: String, query: String): Observable<List<TrackSearch>> {
|
||||||
|
val jsonObject = jsonObject("params" to "query=$query$algoliaFilter")
|
||||||
|
return algoliaRest
|
||||||
|
.getSearchQuery(algoliaAppId, key, jsonObject)
|
||||||
.map { json ->
|
.map { json ->
|
||||||
val data = json["data"].array
|
val data = json["hits"].array
|
||||||
data.map { KitsuManga(it.obj) }
|
data.map { KitsuSearchManga(it.obj) }
|
||||||
.filter { it.type != "novel" }
|
.filter { it.subType != "novel" }
|
||||||
.map { it.toTrack() }
|
.map { it.toTrack() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findLibManga(track: Track, userId: String): Observable<Track?> {
|
fun findLibManga(track: Track, userId: String): Observable<Track?> {
|
||||||
return rest.findLibManga(track.remote_id, userId)
|
return rest.findLibManga(track.media_id, userId)
|
||||||
.map { json ->
|
.map { json ->
|
||||||
val data = json["data"].array
|
val data = json["data"].array
|
||||||
if (data.size() > 0) {
|
if (data.size() > 0) {
|
||||||
@ -100,7 +131,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getLibManga(track: Track): Observable<Track> {
|
fun getLibManga(track: Track): Observable<Track> {
|
||||||
return rest.getLibManga(track.remote_id)
|
return rest.getLibManga(track.media_id)
|
||||||
.map { json ->
|
.map { json ->
|
||||||
val data = json["data"].array
|
val data = json["data"].array
|
||||||
if (data.size() > 0) {
|
if (data.size() > 0) {
|
||||||
@ -142,10 +173,6 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
|||||||
@Body data: JsonObject
|
@Body data: JsonObject
|
||||||
): Observable<JsonObject>
|
): Observable<JsonObject>
|
||||||
|
|
||||||
@GET("manga")
|
|
||||||
fun search(
|
|
||||||
@Query("filter[text]", encoded = true) query: String
|
|
||||||
): Observable<JsonObject>
|
|
||||||
|
|
||||||
@GET("library-entries")
|
@GET("library-entries")
|
||||||
fun findLibManga(
|
fun findLibManga(
|
||||||
@ -167,6 +194,16 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private interface SearchKeyRest {
|
||||||
|
@GET("media/")
|
||||||
|
fun getKey(): Observable<JsonObject>
|
||||||
|
}
|
||||||
|
|
||||||
|
private interface AgoliaSearchRest {
|
||||||
|
@POST("query/")
|
||||||
|
fun getSearchQuery(@Header("X-Algolia-Application-Id") appid: String, @Header("X-Algolia-API-Key") key: String, @Body json: JsonObject): Observable<JsonObject>
|
||||||
|
}
|
||||||
|
|
||||||
private interface LoginRest {
|
private interface LoginRest {
|
||||||
|
|
||||||
@FormUrlEncoded
|
@FormUrlEncoded
|
||||||
@ -186,6 +223,16 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
|||||||
private const val clientSecret = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
|
private const val clientSecret = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
|
||||||
private const val baseUrl = "https://kitsu.io/api/edge/"
|
private const val baseUrl = "https://kitsu.io/api/edge/"
|
||||||
private const val loginUrl = "https://kitsu.io/api/"
|
private const val loginUrl = "https://kitsu.io/api/"
|
||||||
|
private const val baseMangaUrl = "https://kitsu.io/manga/"
|
||||||
|
private const val algoliaKeyUrl = "https://kitsu.io/api/edge/algolia-keys/"
|
||||||
|
private const val algoliaUrl = "https://AWQO5J657S-dsn.algolia.net/1/indexes/production_media/"
|
||||||
|
private const val algoliaAppId = "AWQO5J657S"
|
||||||
|
private const val algoliaFilter = "&facetFilters=%5B%22kind%3Amanga%22%5D&attributesToRetrieve=%5B%22synopsis%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D"
|
||||||
|
|
||||||
|
|
||||||
|
fun mangaUrl(remoteId: Int): String {
|
||||||
|
return baseMangaUrl + remoteId
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fun refreshTokenRequest(token: String) = POST("${loginUrl}oauth/token",
|
fun refreshTokenRequest(token: String) = POST("${loginUrl}oauth/token",
|
||||||
@ -198,4 +245,4 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -5,29 +5,66 @@ import com.github.salomonbrys.kotson.*
|
|||||||
import com.google.gson.JsonObject
|
import com.google.gson.JsonObject
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
open class KitsuManga(obj: JsonObject) {
|
class KitsuSearchManga(obj: JsonObject) {
|
||||||
val id by obj.byInt
|
val id by obj.byInt
|
||||||
val canonicalTitle by obj["attributes"].byString
|
private val canonicalTitle by obj.byString
|
||||||
val chapterCount = obj["attributes"].obj.get("chapterCount").nullInt
|
private val chapterCount = obj.get("chapterCount").nullInt
|
||||||
val type = obj["attributes"].obj.get("mangaType").nullString
|
val subType = obj.get("subtype").nullString
|
||||||
|
val original = obj.get("posterImage").nullObj?.get("original")?.asString
|
||||||
|
private val synopsis by obj.byString
|
||||||
|
private var startDate = obj.get("startDate").nullString?.let {
|
||||||
|
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||||
|
outputDf.format(Date(it!!.toLong() * 1000))
|
||||||
|
}
|
||||||
|
private val endDate = obj.get("endDate").nullString
|
||||||
|
|
||||||
|
|
||||||
@CallSuper
|
@CallSuper
|
||||||
open fun toTrack() = Track.create(TrackManager.KITSU).apply {
|
open fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
|
||||||
remote_id = this@KitsuManga.id
|
media_id = this@KitsuSearchManga.id
|
||||||
title = canonicalTitle
|
title = canonicalTitle
|
||||||
total_chapters = chapterCount ?: 0
|
total_chapters = chapterCount ?: 0
|
||||||
|
cover_url = original ?: ""
|
||||||
|
summary = synopsis
|
||||||
|
tracking_url = KitsuApi.mangaUrl(media_id)
|
||||||
|
if (endDate == null) {
|
||||||
|
publishing_status = "Publishing"
|
||||||
|
} else {
|
||||||
|
publishing_status = "Finished"
|
||||||
|
}
|
||||||
|
publishing_type = subType ?: ""
|
||||||
|
start_date = startDate ?: ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class KitsuLibManga(obj: JsonObject, manga: JsonObject) : KitsuManga(manga) {
|
|
||||||
val remoteId by obj.byInt("id")
|
class KitsuLibManga(obj: JsonObject, manga: JsonObject) {
|
||||||
|
val id by manga.byInt
|
||||||
|
private val canonicalTitle by manga["attributes"].byString
|
||||||
|
private val chapterCount = manga["attributes"].obj.get("chapterCount").nullInt
|
||||||
|
val type = manga["attributes"].obj.get("mangaType").nullString.orEmpty()
|
||||||
|
val original by manga["attributes"].obj["posterImage"].byString
|
||||||
|
private val synopsis by manga["attributes"].byString
|
||||||
|
private val startDate = manga["attributes"].obj.get("startDate").nullString.orEmpty()
|
||||||
|
private val libraryId by obj.byInt("id")
|
||||||
val status by obj["attributes"].byString
|
val status by obj["attributes"].byString
|
||||||
val ratingTwenty = obj["attributes"].obj.get("ratingTwenty").nullString
|
private val ratingTwenty = obj["attributes"].obj.get("ratingTwenty").nullString
|
||||||
val progress by obj["attributes"].byInt
|
val progress by obj["attributes"].byInt
|
||||||
|
|
||||||
override fun toTrack() = super.toTrack().apply {
|
open fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
|
||||||
remote_id = remoteId
|
media_id = libraryId
|
||||||
|
title = canonicalTitle
|
||||||
|
total_chapters = chapterCount ?: 0
|
||||||
|
cover_url = original
|
||||||
|
summary = synopsis
|
||||||
|
tracking_url = KitsuApi.mangaUrl(media_id)
|
||||||
|
publishing_status = this@KitsuLibManga.status
|
||||||
|
publishing_type = type
|
||||||
|
start_date = startDate
|
||||||
status = toTrackStatus()
|
status = toTrackStatus()
|
||||||
score = ratingTwenty?.let { it.toInt() / 2f } ?: 0f
|
score = ratingTwenty?.let { it.toInt() / 2f } ?: 0f
|
||||||
last_chapter_read = progress
|
last_chapter_read = progress
|
||||||
|
@ -0,0 +1,64 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.model
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
|
|
||||||
|
class TrackSearch : Track {
|
||||||
|
|
||||||
|
override var id: Long? = null
|
||||||
|
|
||||||
|
override var manga_id: Long = 0
|
||||||
|
|
||||||
|
override var sync_id: Int = 0
|
||||||
|
|
||||||
|
override var media_id: Int = 0
|
||||||
|
|
||||||
|
override var library_id: Long? = null
|
||||||
|
|
||||||
|
override lateinit var title: String
|
||||||
|
|
||||||
|
override var last_chapter_read: Int = 0
|
||||||
|
|
||||||
|
override var total_chapters: Int = 0
|
||||||
|
|
||||||
|
override var score: Float = 0f
|
||||||
|
|
||||||
|
override var status: Int = 0
|
||||||
|
|
||||||
|
override lateinit var tracking_url: String
|
||||||
|
|
||||||
|
var cover_url: String = ""
|
||||||
|
|
||||||
|
var summary: String = ""
|
||||||
|
|
||||||
|
var publishing_status: String = ""
|
||||||
|
|
||||||
|
var publishing_type: String = ""
|
||||||
|
|
||||||
|
var start_date: String = ""
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other == null || javaClass != other.javaClass) return false
|
||||||
|
|
||||||
|
other as Track
|
||||||
|
|
||||||
|
if (manga_id != other.manga_id) return false
|
||||||
|
if (sync_id != other.sync_id) return false
|
||||||
|
return media_id == other.media_id
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = (manga_id xor manga_id.ushr(32)).toInt()
|
||||||
|
result = 31 * result + sync_id
|
||||||
|
result = 31 * result + media_id
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun create(serviceId: Int): TrackSearch = TrackSearch().apply {
|
||||||
|
sync_id = serviceId
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -4,9 +4,12 @@ import android.content.Context
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
import eu.kanade.tachiyomi.data.track.TrackService
|
import eu.kanade.tachiyomi.data.track.TrackService
|
||||||
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import rx.Completable
|
import rx.Completable
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
|
class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
|
||||||
|
|
||||||
@ -20,9 +23,13 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
|
|
||||||
const val DEFAULT_STATUS = READING
|
const val DEFAULT_STATUS = READING
|
||||||
const val DEFAULT_SCORE = 0
|
const val DEFAULT_SCORE = 0
|
||||||
|
|
||||||
|
const val BASE_URL = "https://myanimelist.net"
|
||||||
|
const val USER_SESSION_COOKIE = "MALSESSIONID"
|
||||||
|
const val LOGGED_IN_COOKIE = "is_logged_in"
|
||||||
}
|
}
|
||||||
|
|
||||||
private val api by lazy { MyanimelistApi(client, getUsername(), getPassword()) }
|
private val api by lazy { MyanimelistApi(client) }
|
||||||
|
|
||||||
override val name: String
|
override val name: String
|
||||||
get() = "MyAnimeList"
|
get() = "MyAnimeList"
|
||||||
@ -55,7 +62,7 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun add(track: Track): Observable<Track> {
|
override fun add(track: Track): Observable<Track> {
|
||||||
return api.addLibManga(track)
|
return api.addLibManga(track, getCSRF())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun update(track: Track): Observable<Track> {
|
override fun update(track: Track): Observable<Track> {
|
||||||
@ -63,11 +70,11 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
track.status = COMPLETED
|
track.status = COMPLETED
|
||||||
}
|
}
|
||||||
|
|
||||||
return api.updateLibManga(track)
|
return api.updateLibManga(track, getCSRF())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun bind(track: Track): Observable<Track> {
|
override fun bind(track: Track): Observable<Track> {
|
||||||
return api.findLibManga(track, getUsername())
|
return api.findLibManga(track, getCSRF())
|
||||||
.flatMap { remoteTrack ->
|
.flatMap { remoteTrack ->
|
||||||
if (remoteTrack != null) {
|
if (remoteTrack != null) {
|
||||||
track.copyPersonalFrom(remoteTrack)
|
track.copyPersonalFrom(remoteTrack)
|
||||||
@ -81,12 +88,12 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun search(query: String): Observable<List<Track>> {
|
override fun search(query: String): Observable<List<TrackSearch>> {
|
||||||
return api.search(query, getUsername())
|
return api.search(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun refresh(track: Track): Observable<Track> {
|
override fun refresh(track: Track): Observable<Track> {
|
||||||
return api.getLibManga(track, getUsername())
|
return api.getLibManga(track, getCSRF())
|
||||||
.map { remoteTrack ->
|
.map { remoteTrack ->
|
||||||
track.copyPersonalFrom(remoteTrack)
|
track.copyPersonalFrom(remoteTrack)
|
||||||
track.total_chapters = remoteTrack.total_chapters
|
track.total_chapters = remoteTrack.total_chapters
|
||||||
@ -95,10 +102,40 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun login(username: String, password: String): Completable {
|
override fun login(username: String, password: String): Completable {
|
||||||
|
logout()
|
||||||
|
|
||||||
return api.login(username, password)
|
return api.login(username, password)
|
||||||
|
.doOnNext { csrf -> saveCSRF(csrf) }
|
||||||
.doOnNext { saveCredentials(username, password) }
|
.doOnNext { saveCredentials(username, password) }
|
||||||
.doOnError { logout() }
|
.doOnError { logout() }
|
||||||
.toCompletable()
|
.toCompletable()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun logout() {
|
||||||
|
super.logout()
|
||||||
|
preferences.trackToken(this).delete()
|
||||||
|
networkService.cookies.remove(URI(BASE_URL))
|
||||||
|
}
|
||||||
|
|
||||||
|
override val isLogged: Boolean
|
||||||
|
get() = !getUsername().isEmpty() &&
|
||||||
|
!getPassword().isEmpty() &&
|
||||||
|
checkCookies(URI(BASE_URL)) &&
|
||||||
|
!getCSRF().isEmpty()
|
||||||
|
|
||||||
|
private fun getCSRF(): String = preferences.trackToken(this).getOrDefault()
|
||||||
|
|
||||||
|
private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf)
|
||||||
|
|
||||||
|
private fun checkCookies(uri: URI): Boolean {
|
||||||
|
var ckCount = 0
|
||||||
|
|
||||||
|
for (ck in networkService.cookies.get(uri)) {
|
||||||
|
if (ck.name() == USER_SESSION_COOKIE || ck.name() == LOGGED_IN_COOKIE)
|
||||||
|
ckCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
return ckCount == 2
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
package eu.kanade.tachiyomi.data.track.myanimelist
|
package eu.kanade.tachiyomi.data.track.myanimelist
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Xml
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.POST
|
import eu.kanade.tachiyomi.network.POST
|
||||||
import eu.kanade.tachiyomi.network.asObservable
|
import eu.kanade.tachiyomi.network.asObservable
|
||||||
@ -11,177 +11,266 @@ import eu.kanade.tachiyomi.network.asObservableSuccess
|
|||||||
import eu.kanade.tachiyomi.util.selectInt
|
import eu.kanade.tachiyomi.util.selectInt
|
||||||
import eu.kanade.tachiyomi.util.selectText
|
import eu.kanade.tachiyomi.util.selectText
|
||||||
import okhttp3.*
|
import okhttp3.*
|
||||||
|
import org.json.JSONObject
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
import org.xmlpull.v1.XmlSerializer
|
import org.jsoup.nodes.Document
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
import org.jsoup.parser.Parser
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import java.io.StringWriter
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.util.zip.GZIPInputStream
|
||||||
|
|
||||||
class MyanimelistApi(private val client: OkHttpClient, username: String, password: String) {
|
|
||||||
|
|
||||||
private var headers = createHeaders(username, password)
|
class MyanimelistApi(private val client: OkHttpClient) {
|
||||||
|
|
||||||
fun addLibManga(track: Track): Observable<Track> {
|
fun addLibManga(track: Track, csrf: String): Observable<Track> {
|
||||||
return Observable.defer {
|
return Observable.defer {
|
||||||
client.newCall(POST(getAddUrl(track), headers, getMangaPostPayload(track)))
|
client.newCall(POST(url = getAddUrl(), body = getMangaPostPayload(track, csrf)))
|
||||||
.asObservableSuccess()
|
.asObservableSuccess()
|
||||||
.map { track }
|
.map { track }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateLibManga(track: Track): Observable<Track> {
|
fun updateLibManga(track: Track, csrf: String): Observable<Track> {
|
||||||
return Observable.defer {
|
return Observable.defer {
|
||||||
client.newCall(POST(getUpdateUrl(track), headers, getMangaPostPayload(track)))
|
client.newCall(POST(url = getUpdateUrl(), body = getMangaPostPayload(track, csrf)))
|
||||||
.asObservableSuccess()
|
.asObservableSuccess()
|
||||||
.map { track }
|
.map { track }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun search(query: String, username: String): Observable<List<Track>> {
|
fun search(query: String): Observable<List<TrackSearch>> {
|
||||||
return if (query.startsWith(PREFIX_MY)) {
|
return client.newCall(GET(getSearchUrl(query)))
|
||||||
val realQuery = query.substring(PREFIX_MY.length).toLowerCase().trim()
|
|
||||||
getList(username)
|
|
||||||
.flatMap { Observable.from(it) }
|
|
||||||
.filter { realQuery in it.title.toLowerCase() }
|
|
||||||
.toList()
|
|
||||||
} else {
|
|
||||||
client.newCall(GET(getSearchUrl(query), headers))
|
|
||||||
.asObservable()
|
|
||||||
.map { Jsoup.parse(it.body()!!.string()) }
|
|
||||||
.flatMap { Observable.from(it.select("entry")) }
|
|
||||||
.filter { it.select("type").text() != "Novel" }
|
|
||||||
.map {
|
|
||||||
Track.create(TrackManager.MYANIMELIST).apply {
|
|
||||||
title = it.selectText("title")!!
|
|
||||||
remote_id = it.selectInt("id")
|
|
||||||
total_chapters = it.selectInt("chapters")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.toList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getList(username: String): Observable<List<Track>> {
|
|
||||||
return client
|
|
||||||
.newCall(GET(getListUrl(username), headers))
|
|
||||||
.asObservable()
|
.asObservable()
|
||||||
.map { Jsoup.parse(it.body()!!.string()) }
|
.flatMap { response ->
|
||||||
.flatMap { Observable.from(it.select("manga")) }
|
Observable.from(Jsoup.parse(response.consumeBody())
|
||||||
.map {
|
.select("div.js-categories-seasonal.js-block-list.list")
|
||||||
Track.create(TrackManager.MYANIMELIST).apply {
|
.select("table").select("tbody")
|
||||||
title = it.selectText("series_title")!!
|
.select("tr").drop(1))
|
||||||
remote_id = it.selectInt("series_mangadb_id")
|
}
|
||||||
last_chapter_read = it.selectInt("my_read_chapters")
|
.filter { row ->
|
||||||
status = it.selectInt("my_status")
|
row.select(TD)[2].text() != "Novel"
|
||||||
score = it.selectInt("my_score").toFloat()
|
}
|
||||||
total_chapters = it.selectInt("series_chapters")
|
.map { row ->
|
||||||
|
TrackSearch.create(TrackManager.MYANIMELIST).apply {
|
||||||
|
title = row.searchTitle()
|
||||||
|
media_id = row.searchMediaId()
|
||||||
|
total_chapters = row.searchTotalChapters()
|
||||||
|
summary = row.searchSummary()
|
||||||
|
cover_url = row.searchCoverUrl()
|
||||||
|
tracking_url = mangaUrl(media_id)
|
||||||
|
publishing_status = row.searchPublishingStatus()
|
||||||
|
publishing_type = row.searchPublishingType()
|
||||||
|
start_date = row.searchStartDate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findLibManga(track: Track, username: String): Observable<Track?> {
|
private fun getList(csrf: String): Observable<List<TrackSearch>> {
|
||||||
return getList(username)
|
return getListUrl(csrf)
|
||||||
.map { list -> list.find { it.remote_id == track.remote_id } }
|
.flatMap { url ->
|
||||||
|
getListXml(url)
|
||||||
|
}
|
||||||
|
.flatMap { doc ->
|
||||||
|
Observable.from(doc.select("manga"))
|
||||||
|
}
|
||||||
|
.map { it ->
|
||||||
|
TrackSearch.create(TrackManager.MYANIMELIST).apply {
|
||||||
|
title = it.selectText("manga_title")!!
|
||||||
|
media_id = it.selectInt("manga_mangadb_id")
|
||||||
|
last_chapter_read = it.selectInt("my_read_chapters")
|
||||||
|
status = getStatus(it.selectText("my_status")!!)
|
||||||
|
score = it.selectInt("my_score").toFloat()
|
||||||
|
total_chapters = it.selectInt("manga_chapters")
|
||||||
|
tracking_url = mangaUrl(media_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLibManga(track: Track, username: String): Observable<Track> {
|
private fun getListXml(url: String): Observable<Document> {
|
||||||
return findLibManga(track, username)
|
return client.newCall(GET(url))
|
||||||
|
.asObservable()
|
||||||
|
.map { response ->
|
||||||
|
Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findLibManga(track: Track, csrf: String): Observable<Track?> {
|
||||||
|
return getList(csrf)
|
||||||
|
.map { list -> list.find { it.media_id == track.media_id } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLibManga(track: Track, csrf: String): Observable<Track> {
|
||||||
|
return findLibManga(track, csrf)
|
||||||
.map { it ?: throw Exception("Could not find manga") }
|
.map { it ?: throw Exception("Could not find manga") }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun login(username: String, password: String): Observable<Response> {
|
fun login(username: String, password: String): Observable<String> {
|
||||||
headers = createHeaders(username, password)
|
return getSessionInfo()
|
||||||
return client.newCall(GET(getLoginUrl(), headers))
|
.flatMap { csrf ->
|
||||||
.asObservable()
|
login(username, password, csrf)
|
||||||
.doOnNext { response ->
|
|
||||||
response.close()
|
|
||||||
if (response.code() != 200) throw Exception("Login error")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getMangaPostPayload(track: Track): RequestBody {
|
private fun getSessionInfo(): Observable<String> {
|
||||||
val data = xml {
|
return client.newCall(GET(getLoginUrl()))
|
||||||
element(ENTRY_TAG) {
|
.asObservable()
|
||||||
if (track.last_chapter_read != 0) {
|
.map { response ->
|
||||||
text(CHAPTER_TAG, track.last_chapter_read.toString())
|
Jsoup.parse(response.consumeBody())
|
||||||
|
.select("meta[name=csrf_token]")
|
||||||
|
.attr("content")
|
||||||
}
|
}
|
||||||
text(STATUS_TAG, track.status.toString())
|
}
|
||||||
text(SCORE_TAG, track.score.toString())
|
|
||||||
|
private fun login(username: String, password: String, csrf: String): Observable<String> {
|
||||||
|
return client.newCall(POST(url = getLoginUrl(), body = getLoginPostBody(username, password, csrf)))
|
||||||
|
.asObservable()
|
||||||
|
.map { response ->
|
||||||
|
response.use {
|
||||||
|
if (response.priorResponse()?.code() != 302) throw Exception("Authentication error")
|
||||||
|
}
|
||||||
|
csrf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getLoginPostBody(username: String, password: String, csrf: String): RequestBody {
|
||||||
|
return FormBody.Builder()
|
||||||
|
.add("user_name", username)
|
||||||
|
.add("password", password)
|
||||||
|
.add("cookie", "1")
|
||||||
|
.add("sublogin", "Login")
|
||||||
|
.add("submit", "1")
|
||||||
|
.add(CSRF, csrf)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getExportPostBody(csrf: String): RequestBody {
|
||||||
|
return FormBody.Builder()
|
||||||
|
.add("type", "2")
|
||||||
|
.add("subexport", "Export My List")
|
||||||
|
.add(CSRF, csrf)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getMangaPostPayload(track: Track, csrf: String): RequestBody {
|
||||||
|
val body = JSONObject()
|
||||||
|
.put("manga_id", track.media_id)
|
||||||
|
.put("status", track.status)
|
||||||
|
.put("score", track.score)
|
||||||
|
.put("num_read_chapters", track.last_chapter_read)
|
||||||
|
.put(CSRF, csrf)
|
||||||
|
|
||||||
|
return RequestBody.create(MediaType.parse("application/json; charset=utf-8"), body.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getLoginUrl() = Uri.parse(baseUrl).buildUpon()
|
||||||
|
.appendPath("login.php")
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
private fun getSearchUrl(query: String): String {
|
||||||
|
val col = "c[]"
|
||||||
|
return Uri.parse(baseUrl).buildUpon()
|
||||||
|
.appendPath("manga.php")
|
||||||
|
.appendQueryParameter("q", query)
|
||||||
|
.appendQueryParameter(col, "a")
|
||||||
|
.appendQueryParameter(col, "b")
|
||||||
|
.appendQueryParameter(col, "c")
|
||||||
|
.appendQueryParameter(col, "d")
|
||||||
|
.appendQueryParameter(col, "e")
|
||||||
|
.appendQueryParameter(col, "g")
|
||||||
|
.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getExportListUrl() = Uri.parse(baseUrl).buildUpon()
|
||||||
|
.appendPath("panel.php")
|
||||||
|
.appendQueryParameter("go", "export")
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
private fun getListUrl(csrf: String): Observable<String> {
|
||||||
|
return client.newCall(POST(url = getExportListUrl(), body = getExportPostBody(csrf)))
|
||||||
|
.asObservable()
|
||||||
|
.map {response ->
|
||||||
|
baseUrl + Jsoup.parse(response.consumeBody())
|
||||||
|
.select("div.goodresult")
|
||||||
|
.select("a")
|
||||||
|
.attr("href")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getUpdateUrl() = Uri.parse(baseModifyListUrl).buildUpon()
|
||||||
|
.appendPath("edit.json")
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
private fun getAddUrl() = Uri.parse(baseModifyListUrl).buildUpon()
|
||||||
|
.appendPath( "add.json")
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
private fun Response.consumeBody(): String? {
|
||||||
|
use {
|
||||||
|
if (it.code() != 200) throw Exception("Login error")
|
||||||
|
return it.body()?.string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Response.consumeXmlBody(): String? {
|
||||||
|
use { res ->
|
||||||
|
if (res.code() != 200) throw Exception("Export list error")
|
||||||
|
BufferedReader(InputStreamReader(GZIPInputStream(res.body()?.source()?.inputStream()))).use { reader ->
|
||||||
|
val sb = StringBuilder()
|
||||||
|
reader.forEachLine { line ->
|
||||||
|
sb.append(line)
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return FormBody.Builder()
|
|
||||||
.add("data", data)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun xml(block: XmlSerializer.() -> Unit): String {
|
|
||||||
val x = Xml.newSerializer()
|
|
||||||
val writer = StringWriter()
|
|
||||||
|
|
||||||
with(x) {
|
|
||||||
setOutput(writer)
|
|
||||||
startDocument("UTF-8", false)
|
|
||||||
block()
|
|
||||||
endDocument()
|
|
||||||
}
|
|
||||||
|
|
||||||
return writer.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun XmlSerializer.element(tag: String, block: XmlSerializer.() -> Unit) {
|
|
||||||
startTag("", tag)
|
|
||||||
block()
|
|
||||||
endTag("", tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun XmlSerializer.text(tag: String, body: String) {
|
|
||||||
startTag("", tag)
|
|
||||||
text(body)
|
|
||||||
endTag("", tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getLoginUrl() = Uri.parse(baseUrl).buildUpon()
|
|
||||||
.appendEncodedPath("api/account/verify_credentials.xml")
|
|
||||||
.toString()
|
|
||||||
|
|
||||||
fun getSearchUrl(query: String) = Uri.parse(baseUrl).buildUpon()
|
|
||||||
.appendEncodedPath("api/manga/search.xml")
|
|
||||||
.appendQueryParameter("q", query)
|
|
||||||
.toString()
|
|
||||||
|
|
||||||
fun getListUrl(username: String) = Uri.parse(baseUrl).buildUpon()
|
|
||||||
.appendPath("malappinfo.php")
|
|
||||||
.appendQueryParameter("u", username)
|
|
||||||
.appendQueryParameter("status", "all")
|
|
||||||
.appendQueryParameter("type", "manga")
|
|
||||||
.toString()
|
|
||||||
|
|
||||||
fun getUpdateUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
|
|
||||||
.appendEncodedPath("api/mangalist/update")
|
|
||||||
.appendPath("${track.remote_id}.xml")
|
|
||||||
.toString()
|
|
||||||
|
|
||||||
fun getAddUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
|
|
||||||
.appendEncodedPath("api/mangalist/add")
|
|
||||||
.appendPath("${track.remote_id}.xml")
|
|
||||||
.toString()
|
|
||||||
|
|
||||||
fun createHeaders(username: String, password: String): Headers {
|
|
||||||
return Headers.Builder()
|
|
||||||
.add("Authorization", Credentials.basic(username, password))
|
|
||||||
.add("User-Agent", "api-indiv-9F93C52A963974CF674325391990191C")
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val baseUrl = "https://myanimelist.net"
|
const val baseUrl = "https://myanimelist.net"
|
||||||
|
private const val baseMangaUrl = "$baseUrl/manga/"
|
||||||
|
private const val baseModifyListUrl = "$baseUrl/ownlist/manga"
|
||||||
|
|
||||||
private val ENTRY_TAG = "entry"
|
fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId
|
||||||
private val CHAPTER_TAG = "chapter"
|
|
||||||
private val SCORE_TAG = "score"
|
|
||||||
private val STATUS_TAG = "status"
|
|
||||||
|
|
||||||
const val PREFIX_MY = "my:"
|
fun Element.searchTitle() = select("strong").text()!!
|
||||||
|
|
||||||
|
fun Element.searchTotalChapters() = if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt()
|
||||||
|
|
||||||
|
fun Element.searchCoverUrl() = select("img")
|
||||||
|
.attr("data-src")
|
||||||
|
.split("\\?")[0]
|
||||||
|
.replace("/r/50x70/", "/")
|
||||||
|
|
||||||
|
fun Element.searchMediaId() = select("div.picSurround")
|
||||||
|
.select("a").attr("id")
|
||||||
|
.replace("sarea", "")
|
||||||
|
.toInt()
|
||||||
|
|
||||||
|
fun Element.searchSummary() = select("div.pt4")
|
||||||
|
.first()
|
||||||
|
.ownText()!!
|
||||||
|
|
||||||
|
fun Element.searchPublishingStatus() = if (select(TD).last().text() == "-") PUBLISHING else FINISHED
|
||||||
|
|
||||||
|
fun Element.searchPublishingType() = select(TD)[2].text()!!
|
||||||
|
|
||||||
|
fun Element.searchStartDate() = select(TD)[6].text()!!
|
||||||
|
|
||||||
|
fun getStatus(status: String) = when (status) {
|
||||||
|
"Reading" -> 1
|
||||||
|
"Completed" -> 2
|
||||||
|
"On-Hold" -> 3
|
||||||
|
"Dropped" -> 4
|
||||||
|
"Plan to Read" -> 6
|
||||||
|
else -> 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const val CSRF = "csrf_token"
|
||||||
|
const val TD = "td"
|
||||||
|
private const val FINISHED = "Finished"
|
||||||
|
private const val PUBLISHING = "Publishing"
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -11,8 +11,8 @@ import com.google.gson.annotations.SerializedName
|
|||||||
* @param assets assets of latest release.
|
* @param assets assets of latest release.
|
||||||
*/
|
*/
|
||||||
class GithubRelease(@SerializedName("tag_name") val version: String,
|
class GithubRelease(@SerializedName("tag_name") val version: String,
|
||||||
@SerializedName("body") val changeLog: String,
|
@SerializedName("body") val changeLog: String,
|
||||||
@SerializedName("assets") val assets: List<Assets>) {
|
@SerializedName("assets") private val assets: List<Assets>) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get download link of latest release from the assets.
|
* Get download link of latest release from the assets.
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
package eu.kanade.tachiyomi.data.updater
|
package eu.kanade.tachiyomi.data.updater
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
|
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used to connect with the Github API.
|
* Used to connect with the Github API.
|
||||||
@ -17,6 +20,7 @@ interface GithubService {
|
|||||||
.baseUrl("https://api.github.com")
|
.baseUrl("https://api.github.com")
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||||
|
.client(Injekt.get<NetworkHelper>().client)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return restAdapter.create(GithubService::class.java)
|
return restAdapter.create(GithubService::class.java)
|
||||||
|
@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.data.updater
|
|||||||
import eu.kanade.tachiyomi.BuildConfig
|
import eu.kanade.tachiyomi.BuildConfig
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
|
||||||
class GithubUpdateChecker() {
|
class GithubUpdateChecker {
|
||||||
|
|
||||||
private val service: GithubService = GithubService.create()
|
private val service: GithubService = GithubService.create()
|
||||||
|
|
||||||
|
@ -3,5 +3,5 @@ package eu.kanade.tachiyomi.data.updater
|
|||||||
sealed class GithubUpdateResult {
|
sealed class GithubUpdateResult {
|
||||||
|
|
||||||
class NewUpdate(val release: GithubRelease): GithubUpdateResult()
|
class NewUpdate(val release: GithubRelease): GithubUpdateResult()
|
||||||
class NoNewUpdate(): GithubUpdateResult()
|
class NoNewUpdate : GithubUpdateResult()
|
||||||
}
|
}
|
@ -1,147 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.data.updater
|
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.support.v4.app.NotificationCompat
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.data.notification.NotificationHandler
|
|
||||||
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
|
||||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
|
||||||
import eu.kanade.tachiyomi.util.notificationManager
|
|
||||||
import java.io.File
|
|
||||||
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Local [BroadcastReceiver] that runs on UI thread
|
|
||||||
* Notification calls from [UpdateDownloaderService] should be made from here.
|
|
||||||
*/
|
|
||||||
internal class UpdateDownloaderReceiver(val context: Context) : BroadcastReceiver() {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val NAME = "UpdateDownloaderReceiver"
|
|
||||||
|
|
||||||
// Called to show initial notification.
|
|
||||||
internal const val NOTIFICATION_UPDATER_INITIAL = "$ID.$NAME.UPDATER_INITIAL"
|
|
||||||
|
|
||||||
// Called to show progress notification.
|
|
||||||
internal const val NOTIFICATION_UPDATER_PROGRESS = "$ID.$NAME.UPDATER_PROGRESS"
|
|
||||||
|
|
||||||
// Called to show install notification.
|
|
||||||
internal const val NOTIFICATION_UPDATER_INSTALL = "$ID.$NAME.UPDATER_INSTALL"
|
|
||||||
|
|
||||||
// Called to show error notification
|
|
||||||
internal const val NOTIFICATION_UPDATER_ERROR = "$ID.$NAME.UPDATER_ERROR"
|
|
||||||
|
|
||||||
// Value containing action of BroadcastReceiver
|
|
||||||
internal const val EXTRA_ACTION = "$ID.$NAME.ACTION"
|
|
||||||
|
|
||||||
// Value containing progress
|
|
||||||
internal const val EXTRA_PROGRESS = "$ID.$NAME.PROGRESS"
|
|
||||||
|
|
||||||
// Value containing apk path
|
|
||||||
internal const val EXTRA_APK_PATH = "$ID.$NAME.APK_PATH"
|
|
||||||
|
|
||||||
// Value containing apk url
|
|
||||||
internal const val EXTRA_APK_URL = "$ID.$NAME.APK_URL"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Notification shown to user
|
|
||||||
*/
|
|
||||||
private val notification = NotificationCompat.Builder(context, Notifications.CHANNEL_COMMON)
|
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
when (intent.getStringExtra(EXTRA_ACTION)) {
|
|
||||||
NOTIFICATION_UPDATER_INITIAL -> basicNotification()
|
|
||||||
NOTIFICATION_UPDATER_PROGRESS -> updateProgress(intent.getIntExtra(EXTRA_PROGRESS, 0))
|
|
||||||
NOTIFICATION_UPDATER_INSTALL -> installNotification(intent.getStringExtra(EXTRA_APK_PATH))
|
|
||||||
NOTIFICATION_UPDATER_ERROR -> errorNotification(intent.getStringExtra(EXTRA_APK_URL))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called to show basic notification
|
|
||||||
*/
|
|
||||||
private fun basicNotification() {
|
|
||||||
// Create notification
|
|
||||||
with(notification) {
|
|
||||||
setContentTitle(context.getString(R.string.app_name))
|
|
||||||
setContentText(context.getString(R.string.update_check_notification_download_in_progress))
|
|
||||||
setSmallIcon(android.R.drawable.stat_sys_download)
|
|
||||||
setOngoing(true)
|
|
||||||
}
|
|
||||||
notification.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called to show progress notification
|
|
||||||
*
|
|
||||||
* @param progress progress of download
|
|
||||||
*/
|
|
||||||
private fun updateProgress(progress: Int) {
|
|
||||||
with(notification) {
|
|
||||||
setProgress(100, progress, false)
|
|
||||||
setOnlyAlertOnce(true)
|
|
||||||
}
|
|
||||||
notification.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called to show install notification
|
|
||||||
*
|
|
||||||
* @param path path of file
|
|
||||||
*/
|
|
||||||
private fun installNotification(path: String) {
|
|
||||||
// Prompt the user to install the new update.
|
|
||||||
with(notification) {
|
|
||||||
setContentText(context.getString(R.string.update_check_notification_download_complete))
|
|
||||||
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
|
||||||
setOnlyAlertOnce(false)
|
|
||||||
setProgress(0, 0, false)
|
|
||||||
// Install action
|
|
||||||
setContentIntent(NotificationHandler.installApkPendingActivity(context, File(path)))
|
|
||||||
addAction(R.drawable.ic_system_update_grey_24dp_img,
|
|
||||||
context.getString(R.string.action_install),
|
|
||||||
NotificationHandler.installApkPendingActivity(context, File(path)))
|
|
||||||
// Cancel action
|
|
||||||
addAction(R.drawable.ic_clear_grey_24dp_img,
|
|
||||||
context.getString(R.string.action_cancel),
|
|
||||||
NotificationReceiver.dismissNotificationPendingBroadcast(context, Notifications.ID_UPDATER))
|
|
||||||
}
|
|
||||||
notification.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called to show error notification
|
|
||||||
*
|
|
||||||
* @param url url of apk
|
|
||||||
*/
|
|
||||||
private fun errorNotification(url: String) {
|
|
||||||
// Prompt the user to retry the download.
|
|
||||||
with(notification) {
|
|
||||||
setContentText(context.getString(R.string.update_check_notification_download_error))
|
|
||||||
setSmallIcon(android.R.drawable.stat_sys_warning)
|
|
||||||
setOnlyAlertOnce(false)
|
|
||||||
setProgress(0, 0, false)
|
|
||||||
// Retry action
|
|
||||||
addAction(R.drawable.ic_refresh_grey_24dp_img,
|
|
||||||
context.getString(R.string.action_retry),
|
|
||||||
UpdateDownloaderService.downloadApkPendingService(context, url))
|
|
||||||
// Cancel action
|
|
||||||
addAction(R.drawable.ic_clear_grey_24dp_img,
|
|
||||||
context.getString(R.string.action_cancel),
|
|
||||||
NotificationReceiver.dismissNotificationPendingBroadcast(context, Notifications.ID_UPDATER))
|
|
||||||
}
|
|
||||||
notification.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows a notification from this builder.
|
|
||||||
*
|
|
||||||
* @param id the id of the notification.
|
|
||||||
*/
|
|
||||||
private fun NotificationCompat.Builder.show(id: Int = Notifications.ID_UPDATER) {
|
|
||||||
context.notificationManager.notify(id, build())
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,67 +1,67 @@
|
|||||||
package eu.kanade.tachiyomi.data.updater
|
package eu.kanade.tachiyomi.data.updater
|
||||||
|
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.support.v4.app.NotificationCompat
|
import android.support.v4.app.NotificationCompat
|
||||||
import com.evernote.android.job.Job
|
import com.evernote.android.job.Job
|
||||||
import com.evernote.android.job.JobManager
|
import com.evernote.android.job.JobManager
|
||||||
import com.evernote.android.job.JobRequest
|
import com.evernote.android.job.JobRequest
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||||
import eu.kanade.tachiyomi.util.notificationManager
|
import eu.kanade.tachiyomi.util.notificationManager
|
||||||
|
|
||||||
class UpdateCheckerJob : Job() {
|
class UpdaterJob : Job() {
|
||||||
|
|
||||||
override fun onRunJob(params: Params): Result {
|
override fun onRunJob(params: Params): Result {
|
||||||
return GithubUpdateChecker()
|
return GithubUpdateChecker()
|
||||||
.checkForUpdate()
|
.checkForUpdate()
|
||||||
.map { result ->
|
.map { result ->
|
||||||
if (result is GithubUpdateResult.NewUpdate) {
|
if (result is GithubUpdateResult.NewUpdate) {
|
||||||
val url = result.release.downloadLink
|
val url = result.release.downloadLink
|
||||||
|
|
||||||
val intent = Intent(context, UpdateDownloaderService::class.java).apply {
|
val intent = Intent(context, UpdaterService::class.java).apply {
|
||||||
putExtra(UpdateDownloaderService.EXTRA_DOWNLOAD_URL, url)
|
putExtra(UpdaterService.EXTRA_DOWNLOAD_URL, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
NotificationCompat.Builder(context, Notifications.CHANNEL_COMMON).update {
|
NotificationCompat.Builder(context, Notifications.CHANNEL_COMMON).update {
|
||||||
setContentTitle(context.getString(R.string.app_name))
|
setContentTitle(context.getString(R.string.app_name))
|
||||||
setContentText(context.getString(R.string.update_check_notification_update_available))
|
setContentText(context.getString(R.string.update_check_notification_update_available))
|
||||||
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||||
// Download action
|
// Download action
|
||||||
addAction(android.R.drawable.stat_sys_download_done,
|
addAction(android.R.drawable.stat_sys_download_done,
|
||||||
context.getString(R.string.action_download),
|
context.getString(R.string.action_download),
|
||||||
PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
|
PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Job.Result.SUCCESS
|
Job.Result.SUCCESS
|
||||||
}
|
}
|
||||||
.onErrorReturn { Job.Result.FAILURE }
|
.onErrorReturn { Job.Result.FAILURE }
|
||||||
// Sadly, the task needs to be synchronous.
|
// Sadly, the task needs to be synchronous.
|
||||||
.toBlocking()
|
.toBlocking()
|
||||||
.single()
|
.single()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun NotificationCompat.Builder.update(block: NotificationCompat.Builder.() -> Unit) {
|
fun NotificationCompat.Builder.update(block: NotificationCompat.Builder.() -> Unit) {
|
||||||
block()
|
block()
|
||||||
context.notificationManager.notify(Notifications.ID_UPDATER, build())
|
context.notificationManager.notify(Notifications.ID_UPDATER, build())
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val TAG = "UpdateChecker"
|
const val TAG = "UpdateChecker"
|
||||||
|
|
||||||
fun setupTask() {
|
fun setupTask() {
|
||||||
JobRequest.Builder(TAG)
|
JobRequest.Builder(TAG)
|
||||||
.setPeriodic(24 * 60 * 60 * 1000, 60 * 60 * 1000)
|
.setPeriodic(24 * 60 * 60 * 1000, 60 * 60 * 1000)
|
||||||
.setRequiredNetworkType(JobRequest.NetworkType.CONNECTED)
|
.setRequiredNetworkType(JobRequest.NetworkType.CONNECTED)
|
||||||
.setRequirementsEnforced(true)
|
.setRequirementsEnforced(true)
|
||||||
.setUpdateCurrent(true)
|
.setUpdateCurrent(true)
|
||||||
.build()
|
.build()
|
||||||
.schedule()
|
.schedule()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelTask() {
|
fun cancelTask() {
|
||||||
JobManager.instance().cancelAllForTag(TAG)
|
JobManager.instance().cancelAllForTag(TAG)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -0,0 +1,109 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.updater
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.support.v4.app.NotificationCompat
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.data.notification.NotificationHandler
|
||||||
|
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
||||||
|
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||||
|
import eu.kanade.tachiyomi.util.notificationManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DownloadNotifier is used to show notifications when downloading and update.
|
||||||
|
*
|
||||||
|
* @param context context of application.
|
||||||
|
*/
|
||||||
|
internal class UpdaterNotifier(private val context: Context) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder to manage notifications.
|
||||||
|
*/
|
||||||
|
private val notification by lazy {
|
||||||
|
NotificationCompat.Builder(context, Notifications.CHANNEL_COMMON)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call to show notification.
|
||||||
|
*
|
||||||
|
* @param id id of the notification channel.
|
||||||
|
*/
|
||||||
|
private fun NotificationCompat.Builder.show(id: Int = Notifications.ID_UPDATER) {
|
||||||
|
context.notificationManager.notify(id, build())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call when apk download starts.
|
||||||
|
*
|
||||||
|
* @param title tile of notification.
|
||||||
|
*/
|
||||||
|
fun onDownloadStarted(title: String) {
|
||||||
|
with(notification) {
|
||||||
|
setContentTitle(title)
|
||||||
|
setContentText(context.getString(R.string.update_check_notification_download_in_progress))
|
||||||
|
setSmallIcon(android.R.drawable.stat_sys_download)
|
||||||
|
setOngoing(true)
|
||||||
|
}
|
||||||
|
notification.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call when apk download progress changes.
|
||||||
|
*
|
||||||
|
* @param progress progress of download (xx%/100).
|
||||||
|
*/
|
||||||
|
fun onProgressChange(progress: Int) {
|
||||||
|
with(notification) {
|
||||||
|
setProgress(100, progress, false)
|
||||||
|
setOnlyAlertOnce(true)
|
||||||
|
}
|
||||||
|
notification.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call when apk download is finished.
|
||||||
|
*
|
||||||
|
* @param uri path location of apk.
|
||||||
|
*/
|
||||||
|
fun onDownloadFinished(uri: Uri) {
|
||||||
|
with(notification) {
|
||||||
|
setContentText(context.getString(R.string.update_check_notification_download_complete))
|
||||||
|
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||||
|
setOnlyAlertOnce(false)
|
||||||
|
setProgress(0, 0, false)
|
||||||
|
// Install action
|
||||||
|
setContentIntent(NotificationHandler.installApkPendingActivity(context, uri))
|
||||||
|
addAction(R.drawable.ic_system_update_grey_24dp_img,
|
||||||
|
context.getString(R.string.action_install),
|
||||||
|
NotificationHandler.installApkPendingActivity(context, uri))
|
||||||
|
// Cancel action
|
||||||
|
addAction(R.drawable.ic_clear_grey_24dp_img,
|
||||||
|
context.getString(R.string.action_cancel),
|
||||||
|
NotificationReceiver.dismissNotificationPendingBroadcast(context, Notifications.ID_UPDATER))
|
||||||
|
}
|
||||||
|
notification.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call when apk download throws a error
|
||||||
|
*
|
||||||
|
* @param url web location of apk to download.
|
||||||
|
*/
|
||||||
|
fun onDownloadError(url: String) {
|
||||||
|
with(notification) {
|
||||||
|
setContentText(context.getString(R.string.update_check_notification_download_error))
|
||||||
|
setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||||
|
setOnlyAlertOnce(false)
|
||||||
|
setProgress(0, 0, false)
|
||||||
|
// Retry action
|
||||||
|
addAction(R.drawable.ic_refresh_grey_24dp_img,
|
||||||
|
context.getString(R.string.action_retry),
|
||||||
|
UpdaterService.downloadApkPendingService(context, url))
|
||||||
|
// Cancel action
|
||||||
|
addAction(R.drawable.ic_clear_grey_24dp_img,
|
||||||
|
context.getString(R.string.action_cancel),
|
||||||
|
NotificationReceiver.dismissNotificationPendingBroadcast(context, Notifications.ID_UPDATER))
|
||||||
|
}
|
||||||
|
notification.show(Notifications.ID_UPDATER)
|
||||||
|
}
|
||||||
|
}
|
@ -2,52 +2,37 @@ package eu.kanade.tachiyomi.data.updater
|
|||||||
|
|
||||||
import android.app.IntentService
|
import android.app.IntentService
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
|
||||||
import eu.kanade.tachiyomi.BuildConfig
|
import eu.kanade.tachiyomi.BuildConfig
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import eu.kanade.tachiyomi.network.ProgressListener
|
import eu.kanade.tachiyomi.network.ProgressListener
|
||||||
import eu.kanade.tachiyomi.network.newCallWithProgress
|
import eu.kanade.tachiyomi.network.newCallWithProgress
|
||||||
import eu.kanade.tachiyomi.util.registerLocalReceiver
|
import eu.kanade.tachiyomi.util.getUriCompat
|
||||||
import eu.kanade.tachiyomi.util.saveTo
|
import eu.kanade.tachiyomi.util.saveTo
|
||||||
import eu.kanade.tachiyomi.util.sendLocalBroadcastSync
|
|
||||||
import eu.kanade.tachiyomi.util.unregisterLocalReceiver
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.java.name) {
|
class UpdaterService : IntentService(UpdaterService::class.java.name) {
|
||||||
/**
|
/**
|
||||||
* Network helper
|
* Network helper
|
||||||
*/
|
*/
|
||||||
private val network: NetworkHelper by injectLazy()
|
private val network: NetworkHelper by injectLazy()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Local [BroadcastReceiver] that runs on UI thread
|
* Notifier for the updater state and progress.
|
||||||
*/
|
*/
|
||||||
private val updaterNotificationReceiver = UpdateDownloaderReceiver(this)
|
private val notifier by lazy { UpdaterNotifier(this) }
|
||||||
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
// Register receiver
|
|
||||||
registerLocalReceiver(updaterNotificationReceiver, IntentFilter(INTENT_FILTER_NAME))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
// Unregister receiver
|
|
||||||
unregisterLocalReceiver(updaterNotificationReceiver)
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onHandleIntent(intent: Intent?) {
|
override fun onHandleIntent(intent: Intent?) {
|
||||||
if (intent == null) return
|
if (intent == null) return
|
||||||
|
|
||||||
|
val title = intent.getStringExtra(EXTRA_DOWNLOAD_TITLE) ?: getString(R.string.app_name)
|
||||||
val url = intent.getStringExtra(EXTRA_DOWNLOAD_URL) ?: return
|
val url = intent.getStringExtra(EXTRA_DOWNLOAD_URL) ?: return
|
||||||
downloadApk(url)
|
downloadApk(title, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -55,9 +40,9 @@ class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.jav
|
|||||||
*
|
*
|
||||||
* @param url url location of file
|
* @param url url location of file
|
||||||
*/
|
*/
|
||||||
fun downloadApk(url: String) {
|
private fun downloadApk(title: String, url: String) {
|
||||||
// Show notification download starting.
|
// Show notification download starting.
|
||||||
sendInitialBroadcast()
|
notifier.onDownloadStarted(title)
|
||||||
|
|
||||||
val progressListener = object : ProgressListener {
|
val progressListener = object : ProgressListener {
|
||||||
|
|
||||||
@ -73,7 +58,7 @@ class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.jav
|
|||||||
if (progress > savedProgress && currentTime - 200 > lastTick) {
|
if (progress > savedProgress && currentTime - 200 > lastTick) {
|
||||||
savedProgress = progress
|
savedProgress = progress
|
||||||
lastTick = currentTime
|
lastTick = currentTime
|
||||||
sendProgressBroadcast(progress)
|
notifier.onProgressChange(progress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -91,80 +76,32 @@ class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.jav
|
|||||||
response.close()
|
response.close()
|
||||||
throw Exception("Unsuccessful response")
|
throw Exception("Unsuccessful response")
|
||||||
}
|
}
|
||||||
sendInstallBroadcast(apkFile.absolutePath)
|
notifier.onDownloadFinished(apkFile.getUriCompat(this))
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
Timber.e(error)
|
Timber.e(error)
|
||||||
sendErrorBroadcast(url)
|
notifier.onDownloadError(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Show notification download starting.
|
|
||||||
*/
|
|
||||||
private fun sendInitialBroadcast() {
|
|
||||||
val intent = Intent(INTENT_FILTER_NAME).apply {
|
|
||||||
putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_INITIAL)
|
|
||||||
}
|
|
||||||
sendLocalBroadcastSync(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show notification progress changed
|
|
||||||
*
|
|
||||||
* @param progress progress of download
|
|
||||||
*/
|
|
||||||
private fun sendProgressBroadcast(progress: Int) {
|
|
||||||
val intent = Intent(INTENT_FILTER_NAME).apply {
|
|
||||||
putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_PROGRESS)
|
|
||||||
putExtra(UpdateDownloaderReceiver.EXTRA_PROGRESS, progress)
|
|
||||||
}
|
|
||||||
sendLocalBroadcastSync(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show install notification.
|
|
||||||
*
|
|
||||||
* @param path location of file
|
|
||||||
*/
|
|
||||||
private fun sendInstallBroadcast(path: String){
|
|
||||||
val intent = Intent(INTENT_FILTER_NAME).apply {
|
|
||||||
putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_INSTALL)
|
|
||||||
putExtra(UpdateDownloaderReceiver.EXTRA_APK_PATH, path)
|
|
||||||
}
|
|
||||||
sendLocalBroadcastSync(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show error notification.
|
|
||||||
*
|
|
||||||
* @param url url of file
|
|
||||||
*/
|
|
||||||
private fun sendErrorBroadcast(url: String){
|
|
||||||
val intent = Intent(INTENT_FILTER_NAME).apply {
|
|
||||||
putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_ERROR)
|
|
||||||
putExtra(UpdateDownloaderReceiver.EXTRA_APK_URL, url)
|
|
||||||
}
|
|
||||||
sendLocalBroadcastSync(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
|
||||||
* Name of Local BroadCastReceiver.
|
|
||||||
*/
|
|
||||||
private val INTENT_FILTER_NAME = UpdateDownloaderService::class.java.name
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download url.
|
* Download url.
|
||||||
*/
|
*/
|
||||||
internal const val EXTRA_DOWNLOAD_URL = "${BuildConfig.APPLICATION_ID}.UpdateDownloaderService.DOWNLOAD_URL"
|
internal const val EXTRA_DOWNLOAD_URL = "${BuildConfig.APPLICATION_ID}.UpdaterService.DOWNLOAD_URL"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download title
|
||||||
|
*/
|
||||||
|
internal const val EXTRA_DOWNLOAD_TITLE = "${BuildConfig.APPLICATION_ID}.UpdaterService.DOWNLOAD_TITLE"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads a new update and let the user install the new version from a notification.
|
* Downloads a new update and let the user install the new version from a notification.
|
||||||
* @param context the application context.
|
* @param context the application context.
|
||||||
* @param url the url to the new update.
|
* @param url the url to the new update.
|
||||||
*/
|
*/
|
||||||
fun downloadUpdate(context: Context, url: String) {
|
fun downloadUpdate(context: Context, url: String, title: String = context.getString(R.string.app_name)) {
|
||||||
val intent = Intent(context, UpdateDownloaderService::class.java).apply {
|
val intent = Intent(context, UpdaterService::class.java).apply {
|
||||||
|
putExtra(EXTRA_DOWNLOAD_TITLE, title)
|
||||||
putExtra(EXTRA_DOWNLOAD_URL, url)
|
putExtra(EXTRA_DOWNLOAD_URL, url)
|
||||||
}
|
}
|
||||||
context.startService(intent)
|
context.startService(intent)
|
||||||
@ -177,7 +114,7 @@ class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.jav
|
|||||||
* @return [PendingIntent]
|
* @return [PendingIntent]
|
||||||
*/
|
*/
|
||||||
internal fun downloadApkPendingService(context: Context, url: String): PendingIntent {
|
internal fun downloadApkPendingService(context: Context, url: String): PendingIntent {
|
||||||
val intent = Intent(context, UpdateDownloaderService::class.java).apply {
|
val intent = Intent(context, UpdaterService::class.java).apply {
|
||||||
putExtra(EXTRA_DOWNLOAD_URL, url)
|
putExtra(EXTRA_DOWNLOAD_URL, url)
|
||||||
}
|
}
|
||||||
return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
@ -0,0 +1,334 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.jakewharton.rxrelay.BehaviorRelay
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
|
import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
|
||||||
|
import eu.kanade.tachiyomi.extension.model.Extension
|
||||||
|
import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||||
|
import eu.kanade.tachiyomi.extension.model.LoadResult
|
||||||
|
import eu.kanade.tachiyomi.extension.util.ExtensionInstallReceiver
|
||||||
|
import eu.kanade.tachiyomi.extension.util.ExtensionInstaller
|
||||||
|
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
|
||||||
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
|
import eu.kanade.tachiyomi.util.launchNow
|
||||||
|
import kotlinx.coroutines.experimental.async
|
||||||
|
import rx.Observable
|
||||||
|
import rx.android.schedulers.AndroidSchedulers
|
||||||
|
import rx.schedulers.Schedulers
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The manager of extensions installed as another apk which extend the available sources. It handles
|
||||||
|
* the retrieval of remotely available extensions as well as installing, updating and removing them.
|
||||||
|
* To avoid malicious distribution, every extension must be signed and it will only be loaded if its
|
||||||
|
* signature is trusted, otherwise the user will be prompted with a warning to trust it before being
|
||||||
|
* loaded.
|
||||||
|
*
|
||||||
|
* @param context The application context.
|
||||||
|
* @param preferences The application preferences.
|
||||||
|
*/
|
||||||
|
class ExtensionManager(
|
||||||
|
private val context: Context,
|
||||||
|
private val preferences: PreferencesHelper = Injekt.get()
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API where all the available extensions can be found.
|
||||||
|
*/
|
||||||
|
private val api = ExtensionGithubApi()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The installer which installs, updates and uninstalls the extensions.
|
||||||
|
*/
|
||||||
|
private val installer by lazy { ExtensionInstaller(context) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay used to notify the installed extensions.
|
||||||
|
*/
|
||||||
|
private val installedExtensionsRelay = BehaviorRelay.create<List<Extension.Installed>>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of the currently installed extensions.
|
||||||
|
*/
|
||||||
|
var installedExtensions = emptyList<Extension.Installed>()
|
||||||
|
private set(value) {
|
||||||
|
field = value
|
||||||
|
installedExtensionsRelay.call(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay used to notify the available extensions.
|
||||||
|
*/
|
||||||
|
private val availableExtensionsRelay = BehaviorRelay.create<List<Extension.Available>>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of the currently available extensions.
|
||||||
|
*/
|
||||||
|
var availableExtensions = emptyList<Extension.Available>()
|
||||||
|
private set(value) {
|
||||||
|
field = value
|
||||||
|
availableExtensionsRelay.call(value)
|
||||||
|
setUpdateFieldOfInstalledExtensions(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay used to notify the untrusted extensions.
|
||||||
|
*/
|
||||||
|
private val untrustedExtensionsRelay = BehaviorRelay.create<List<Extension.Untrusted>>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of the currently untrusted extensions.
|
||||||
|
*/
|
||||||
|
var untrustedExtensions = emptyList<Extension.Untrusted>()
|
||||||
|
private set(value) {
|
||||||
|
field = value
|
||||||
|
untrustedExtensionsRelay.call(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The source manager where the sources of the extensions are added.
|
||||||
|
*/
|
||||||
|
private lateinit var sourceManager: SourceManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes this manager with the given source manager.
|
||||||
|
*/
|
||||||
|
fun init(sourceManager: SourceManager) {
|
||||||
|
this.sourceManager = sourceManager
|
||||||
|
initExtensions()
|
||||||
|
ExtensionInstallReceiver(InstallationListener()).register(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads and registers the installed extensions.
|
||||||
|
*/
|
||||||
|
private fun initExtensions() {
|
||||||
|
val extensions = ExtensionLoader.loadExtensions(context)
|
||||||
|
|
||||||
|
installedExtensions = extensions
|
||||||
|
.filterIsInstance<LoadResult.Success>()
|
||||||
|
.map { it.extension }
|
||||||
|
installedExtensions
|
||||||
|
.flatMap { it.sources }
|
||||||
|
// overwrite is needed until the bundled sources are removed
|
||||||
|
.forEach { sourceManager.registerSource(it, true) }
|
||||||
|
|
||||||
|
untrustedExtensions = extensions
|
||||||
|
.filterIsInstance<LoadResult.Untrusted>()
|
||||||
|
.map { it.extension }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the relay of the installed extensions as an observable.
|
||||||
|
*/
|
||||||
|
fun getInstalledExtensionsObservable(): Observable<List<Extension.Installed>> {
|
||||||
|
return installedExtensionsRelay.asObservable()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the relay of the available extensions as an observable.
|
||||||
|
*/
|
||||||
|
fun getAvailableExtensionsObservable(): Observable<List<Extension.Available>> {
|
||||||
|
return availableExtensionsRelay.asObservable()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the relay of the untrusted extensions as an observable.
|
||||||
|
*/
|
||||||
|
fun getUntrustedExtensionsObservable(): Observable<List<Extension.Untrusted>> {
|
||||||
|
return untrustedExtensionsRelay.asObservable()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the available extensions in the [api] and updates [availableExtensions].
|
||||||
|
*/
|
||||||
|
fun findAvailableExtensions() {
|
||||||
|
api.findExtensions()
|
||||||
|
.onErrorReturn { emptyList() }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe { availableExtensions = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the update field of the installed extensions with the given [availableExtensions].
|
||||||
|
*
|
||||||
|
* @param availableExtensions The list of extensions given by the [api].
|
||||||
|
*/
|
||||||
|
private fun setUpdateFieldOfInstalledExtensions(availableExtensions: List<Extension.Available>) {
|
||||||
|
val mutInstalledExtensions = installedExtensions.toMutableList()
|
||||||
|
var changed = false
|
||||||
|
|
||||||
|
for ((index, installedExt) in mutInstalledExtensions.withIndex()) {
|
||||||
|
val pkgName = installedExt.pkgName
|
||||||
|
val availableExt = availableExtensions.find { it.pkgName == pkgName } ?: continue
|
||||||
|
|
||||||
|
val hasUpdate = availableExt.versionCode > installedExt.versionCode
|
||||||
|
if (installedExt.hasUpdate != hasUpdate) {
|
||||||
|
mutInstalledExtensions[index] = installedExt.copy(hasUpdate = hasUpdate)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
installedExtensions = mutInstalledExtensions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an observable of the installation process for the given extension. It will complete
|
||||||
|
* once the extension is installed or throws an error. The process will be canceled if
|
||||||
|
* unsubscribed before its completion.
|
||||||
|
*
|
||||||
|
* @param extension The extension to be installed.
|
||||||
|
*/
|
||||||
|
fun installExtension(extension: Extension.Available): Observable<InstallStep> {
|
||||||
|
return installer.downloadAndInstall(api.getApkUrl(extension), extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an observable of the installation process for the given extension. It will complete
|
||||||
|
* once the extension is updated or throws an error. The process will be canceled if
|
||||||
|
* unsubscribed before its completion.
|
||||||
|
*
|
||||||
|
* @param extension The extension to be updated.
|
||||||
|
*/
|
||||||
|
fun updateExtension(extension: Extension.Installed): Observable<InstallStep> {
|
||||||
|
val availableExt = availableExtensions.find { it.pkgName == extension.pkgName }
|
||||||
|
?: return Observable.empty()
|
||||||
|
return installExtension(availableExt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the result of the installation of an extension.
|
||||||
|
*
|
||||||
|
* @param downloadId The id of the download.
|
||||||
|
* @param result Whether the extension was installed or not.
|
||||||
|
*/
|
||||||
|
fun setInstallationResult(downloadId: Long, result: Boolean) {
|
||||||
|
installer.setInstallationResult(downloadId, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uninstalls the extension that matches the given package name.
|
||||||
|
*
|
||||||
|
* @param pkgName The package name of the application to uninstall.
|
||||||
|
*/
|
||||||
|
fun uninstallExtension(pkgName: String) {
|
||||||
|
installer.uninstallApk(pkgName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the given signature to the list of trusted signatures. It also loads in background the
|
||||||
|
* extensions that match this signature.
|
||||||
|
*
|
||||||
|
* @param signature The signature to whitelist.
|
||||||
|
*/
|
||||||
|
fun trustSignature(signature: String) {
|
||||||
|
val untrustedSignatures = untrustedExtensions.map { it.signatureHash }.toSet()
|
||||||
|
if (signature !in untrustedSignatures) return
|
||||||
|
|
||||||
|
ExtensionLoader.trustedSignatures += signature
|
||||||
|
val preference = preferences.trustedSignatures()
|
||||||
|
preference.set(preference.getOrDefault() + signature)
|
||||||
|
|
||||||
|
val nowTrustedExtensions = untrustedExtensions.filter { it.signatureHash == signature }
|
||||||
|
untrustedExtensions -= nowTrustedExtensions
|
||||||
|
|
||||||
|
val ctx = context
|
||||||
|
launchNow {
|
||||||
|
nowTrustedExtensions
|
||||||
|
.map { extension ->
|
||||||
|
async { ExtensionLoader.loadExtensionFromPkgName(ctx, extension.pkgName) }
|
||||||
|
}
|
||||||
|
.map { it.await() }
|
||||||
|
.forEach { result ->
|
||||||
|
if (result is LoadResult.Success) {
|
||||||
|
registerNewExtension(result.extension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the given extension in this and the source managers.
|
||||||
|
*
|
||||||
|
* @param extension The extension to be registered.
|
||||||
|
*/
|
||||||
|
private fun registerNewExtension(extension: Extension.Installed) {
|
||||||
|
installedExtensions += extension
|
||||||
|
extension.sources.forEach { sourceManager.registerSource(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the given updated extension in this and the source managers previously removing
|
||||||
|
* the outdated ones.
|
||||||
|
*
|
||||||
|
* @param extension The extension to be registered.
|
||||||
|
*/
|
||||||
|
private fun registerUpdatedExtension(extension: Extension.Installed) {
|
||||||
|
val mutInstalledExtensions = installedExtensions.toMutableList()
|
||||||
|
val oldExtension = mutInstalledExtensions.find { it.pkgName == extension.pkgName }
|
||||||
|
if (oldExtension != null) {
|
||||||
|
mutInstalledExtensions -= oldExtension
|
||||||
|
extension.sources.forEach { sourceManager.unregisterSource(it) }
|
||||||
|
}
|
||||||
|
mutInstalledExtensions += extension
|
||||||
|
installedExtensions = mutInstalledExtensions
|
||||||
|
extension.sources.forEach { sourceManager.registerSource(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregisters the extension in this and the source managers given its package name. Note this
|
||||||
|
* method is called for every uninstalled application in the system.
|
||||||
|
*
|
||||||
|
* @param pkgName The package name of the uninstalled application.
|
||||||
|
*/
|
||||||
|
private fun unregisterExtension(pkgName: String) {
|
||||||
|
val installedExtension = installedExtensions.find { it.pkgName == pkgName }
|
||||||
|
if (installedExtension != null) {
|
||||||
|
installedExtensions -= installedExtension
|
||||||
|
installedExtension.sources.forEach { sourceManager.unregisterSource(it) }
|
||||||
|
}
|
||||||
|
val untrustedExtension = untrustedExtensions.find { it.pkgName == pkgName }
|
||||||
|
if (untrustedExtension != null) {
|
||||||
|
untrustedExtensions -= untrustedExtension
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listener which receives events of the extensions being installed, updated or removed.
|
||||||
|
*/
|
||||||
|
private inner class InstallationListener : ExtensionInstallReceiver.Listener {
|
||||||
|
|
||||||
|
override fun onExtensionInstalled(extension: Extension.Installed) {
|
||||||
|
registerNewExtension(extension.withUpdateCheck())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onExtensionUpdated(extension: Extension.Installed) {
|
||||||
|
registerUpdatedExtension(extension.withUpdateCheck())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onExtensionUntrusted(extension: Extension.Untrusted) {
|
||||||
|
untrustedExtensions += extension
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPackageUninstalled(pkgName: String) {
|
||||||
|
unregisterExtension(pkgName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension method to set the update field of an installed extension.
|
||||||
|
*/
|
||||||
|
private fun Extension.Installed.withUpdateCheck(): Extension.Installed {
|
||||||
|
val availableExt = availableExtensions.find { it.pkgName == pkgName }
|
||||||
|
if (availableExt != null && availableExt.versionCode > versionCode) {
|
||||||
|
return copy(hasUpdate = true)
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,55 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.api
|
||||||
|
|
||||||
|
import com.github.salomonbrys.kotson.fromJson
|
||||||
|
import com.github.salomonbrys.kotson.get
|
||||||
|
import com.github.salomonbrys.kotson.int
|
||||||
|
import com.github.salomonbrys.kotson.string
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.JsonArray
|
||||||
|
import eu.kanade.tachiyomi.extension.model.Extension
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
|
import okhttp3.Response
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
internal class ExtensionGithubApi {
|
||||||
|
|
||||||
|
private val network: NetworkHelper by injectLazy()
|
||||||
|
|
||||||
|
private val client get() = network.client
|
||||||
|
|
||||||
|
private val gson: Gson by injectLazy()
|
||||||
|
|
||||||
|
private val repoUrl = "https://raw.githubusercontent.com/inorichi/tachiyomi-extensions/repo"
|
||||||
|
|
||||||
|
fun findExtensions(): Observable<List<Extension.Available>> {
|
||||||
|
val call = GET("$repoUrl/index.json")
|
||||||
|
|
||||||
|
return client.newCall(call).asObservableSuccess()
|
||||||
|
.map(::parseResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseResponse(response: Response): List<Extension.Available> {
|
||||||
|
val text = response.body()?.use { it.string() } ?: return emptyList()
|
||||||
|
|
||||||
|
val json = gson.fromJson<JsonArray>(text)
|
||||||
|
|
||||||
|
return json.map { element ->
|
||||||
|
val name = element["name"].string.substringAfter("Tachiyomi: ")
|
||||||
|
val pkgName = element["pkg"].string
|
||||||
|
val apkName = element["apk"].string
|
||||||
|
val versionName = element["version"].string
|
||||||
|
val versionCode = element["code"].int
|
||||||
|
val lang = element["lang"].string
|
||||||
|
val icon = "$repoUrl/icon/${apkName.replace(".apk", ".png")}"
|
||||||
|
|
||||||
|
Extension.Available(name, pkgName, versionName, versionCode, lang, apkName, icon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getApkUrl(extension: Extension.Available): String {
|
||||||
|
return "$repoUrl/apk/${extension.apkName}"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,36 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.model
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.Source
|
||||||
|
|
||||||
|
sealed class Extension {
|
||||||
|
|
||||||
|
abstract val name: String
|
||||||
|
abstract val pkgName: String
|
||||||
|
abstract val versionName: String
|
||||||
|
abstract val versionCode: Int
|
||||||
|
abstract val lang: String?
|
||||||
|
|
||||||
|
data class Installed(override val name: String,
|
||||||
|
override val pkgName: String,
|
||||||
|
override val versionName: String,
|
||||||
|
override val versionCode: Int,
|
||||||
|
val sources: List<Source>,
|
||||||
|
override val lang: String,
|
||||||
|
val hasUpdate: Boolean = false) : Extension()
|
||||||
|
|
||||||
|
data class Available(override val name: String,
|
||||||
|
override val pkgName: String,
|
||||||
|
override val versionName: String,
|
||||||
|
override val versionCode: Int,
|
||||||
|
override val lang: String,
|
||||||
|
val apkName: String,
|
||||||
|
val iconUrl: String) : Extension()
|
||||||
|
|
||||||
|
data class Untrusted(override val name: String,
|
||||||
|
override val pkgName: String,
|
||||||
|
override val versionName: String,
|
||||||
|
override val versionCode: Int,
|
||||||
|
val signatureHash: String,
|
||||||
|
override val lang: String? = null) : Extension()
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.model
|
||||||
|
|
||||||
|
enum class InstallStep {
|
||||||
|
Pending, Downloading, Installing, Installed, Error;
|
||||||
|
|
||||||
|
fun isCompleted(): Boolean {
|
||||||
|
return this == Installed || this == Error
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.model
|
||||||
|
|
||||||
|
sealed class LoadResult {
|
||||||
|
|
||||||
|
class Success(val extension: Extension.Installed) : LoadResult()
|
||||||
|
class Untrusted(val extension: Extension.Untrusted) : LoadResult()
|
||||||
|
class Error(val message: String? = null) : LoadResult() {
|
||||||
|
constructor(exception: Throwable) : this(exception.message)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,52 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.util
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||||
|
import eu.kanade.tachiyomi.util.toast
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity used to install extensions, because we can only receive the result of the installation
|
||||||
|
* with [startActivityForResult], which we need to update the UI.
|
||||||
|
*/
|
||||||
|
class ExtensionInstallActivity : Activity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
val installIntent = Intent(Intent.ACTION_INSTALL_PACKAGE)
|
||||||
|
.setDataAndType(intent.data, intent.type)
|
||||||
|
.putExtra(Intent.EXTRA_RETURN_RESULT, true)
|
||||||
|
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
|
||||||
|
try {
|
||||||
|
startActivityForResult(installIntent, INSTALL_REQUEST_CODE)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
// Either install package can't be found (probably bots) or there's a security exception
|
||||||
|
// with the download manager. Nothing we can workaround.
|
||||||
|
toast(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
|
if (requestCode == INSTALL_REQUEST_CODE) {
|
||||||
|
checkInstallationResult(resultCode)
|
||||||
|
}
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkInstallationResult(resultCode: Int) {
|
||||||
|
val downloadId = intent.extras.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID)
|
||||||
|
val success = resultCode == RESULT_OK
|
||||||
|
|
||||||
|
val extensionManager = Injekt.get<ExtensionManager>()
|
||||||
|
extensionManager.setInstallationResult(downloadId, success)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val INSTALL_REQUEST_CODE = 500
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,114 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.util
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import eu.kanade.tachiyomi.extension.model.Extension
|
||||||
|
import eu.kanade.tachiyomi.extension.model.LoadResult
|
||||||
|
import eu.kanade.tachiyomi.util.launchNow
|
||||||
|
import kotlinx.coroutines.experimental.async
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast receiver that listens for the system's packages installed, updated or removed, and only
|
||||||
|
* notifies the given [listener] when the package is an extension.
|
||||||
|
*
|
||||||
|
* @param listener The listener that should be notified of extension installation events.
|
||||||
|
*/
|
||||||
|
internal class ExtensionInstallReceiver(private val listener: Listener) :
|
||||||
|
BroadcastReceiver() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers this broadcast receiver
|
||||||
|
*/
|
||||||
|
fun register(context: Context) {
|
||||||
|
context.registerReceiver(this, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the intent filter this receiver should subscribe to.
|
||||||
|
*/
|
||||||
|
private val filter get() = IntentFilter().apply {
|
||||||
|
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||||
|
addAction(Intent.ACTION_PACKAGE_REPLACED)
|
||||||
|
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||||
|
addDataScheme("package")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when one of the events of the [filter] is received. When the package is an extension,
|
||||||
|
* it's loaded in background and it notifies the [listener] when finished.
|
||||||
|
*/
|
||||||
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
|
if (intent == null) return
|
||||||
|
|
||||||
|
when (intent.action) {
|
||||||
|
Intent.ACTION_PACKAGE_ADDED -> {
|
||||||
|
if (!isReplacing(intent)) launchNow {
|
||||||
|
val result = getExtensionFromIntent(context, intent)
|
||||||
|
when (result) {
|
||||||
|
is LoadResult.Success -> listener.onExtensionInstalled(result.extension)
|
||||||
|
is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Intent.ACTION_PACKAGE_REPLACED -> {
|
||||||
|
launchNow {
|
||||||
|
val result = getExtensionFromIntent(context, intent)
|
||||||
|
when (result) {
|
||||||
|
is LoadResult.Success -> listener.onExtensionUpdated(result.extension)
|
||||||
|
// Not needed as a package can't be upgraded if the signature is different
|
||||||
|
is LoadResult.Untrusted -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Intent.ACTION_PACKAGE_REMOVED -> {
|
||||||
|
if (!isReplacing(intent)) {
|
||||||
|
val pkgName = getPackageNameFromIntent(intent)
|
||||||
|
if (pkgName != null) {
|
||||||
|
listener.onPackageUninstalled(pkgName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if this package is performing an update.
|
||||||
|
*
|
||||||
|
* @param intent The intent that triggered the event.
|
||||||
|
*/
|
||||||
|
private fun isReplacing(intent: Intent): Boolean {
|
||||||
|
return intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the extension triggered by the given intent.
|
||||||
|
*
|
||||||
|
* @param context The application context.
|
||||||
|
* @param intent The intent containing the package name of the extension.
|
||||||
|
*/
|
||||||
|
private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): LoadResult {
|
||||||
|
val pkgName = getPackageNameFromIntent(intent) ?:
|
||||||
|
return LoadResult.Error("Package name not found")
|
||||||
|
return async { ExtensionLoader.loadExtensionFromPkgName(context, pkgName) }.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the package name of the installed, updated or removed application.
|
||||||
|
*/
|
||||||
|
private fun getPackageNameFromIntent(intent: Intent?): String? {
|
||||||
|
return intent?.data?.encodedSchemeSpecificPart ?: return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listener that receives extension installation events.
|
||||||
|
*/
|
||||||
|
interface Listener {
|
||||||
|
fun onExtensionInstalled(extension: Extension.Installed)
|
||||||
|
fun onExtensionUpdated(extension: Extension.Installed)
|
||||||
|
fun onExtensionUntrusted(extension: Extension.Untrusted)
|
||||||
|
fun onPackageUninstalled(pkgName: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,247 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.util
|
||||||
|
|
||||||
|
import android.app.DownloadManager
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import com.jakewharton.rxrelay.PublishRelay
|
||||||
|
import eu.kanade.tachiyomi.extension.model.Extension
|
||||||
|
import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||||
|
import eu.kanade.tachiyomi.util.getUriCompat
|
||||||
|
import rx.Observable
|
||||||
|
import rx.android.schedulers.AndroidSchedulers
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The installer which installs, updates and uninstalls the extensions.
|
||||||
|
*
|
||||||
|
* @param context The application context.
|
||||||
|
*/
|
||||||
|
internal class ExtensionInstaller(private val context: Context) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The system's download manager
|
||||||
|
*/
|
||||||
|
private val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The broadcast receiver which listens to download completion events.
|
||||||
|
*/
|
||||||
|
private val downloadReceiver = DownloadCompletionReceiver()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The currently requested downloads, with the package name (unique id) as key, and the id
|
||||||
|
* returned by the download manager.
|
||||||
|
*/
|
||||||
|
private val activeDownloads = hashMapOf<String, Long>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay used to notify the installation step of every download.
|
||||||
|
*/
|
||||||
|
private val downloadsRelay = PublishRelay.create<Pair<Long, InstallStep>>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the given extension to the downloads queue and returns an observable containing its
|
||||||
|
* step in the installation process.
|
||||||
|
*
|
||||||
|
* @param url The url of the apk.
|
||||||
|
* @param extension The extension to install.
|
||||||
|
*/
|
||||||
|
fun downloadAndInstall(url: String, extension: Extension) = Observable.defer {
|
||||||
|
val pkgName = extension.pkgName
|
||||||
|
|
||||||
|
val oldDownload = activeDownloads[pkgName]
|
||||||
|
if (oldDownload != null) {
|
||||||
|
deleteDownload(pkgName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the receiver after removing (and unregistering) the previous download
|
||||||
|
downloadReceiver.register()
|
||||||
|
|
||||||
|
val request = DownloadManager.Request(Uri.parse(url))
|
||||||
|
.setTitle(extension.name)
|
||||||
|
.setMimeType(APK_MIME)
|
||||||
|
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||||
|
|
||||||
|
val id = downloadManager.enqueue(request)
|
||||||
|
activeDownloads[pkgName] = id
|
||||||
|
|
||||||
|
downloadsRelay.filter { it.first == id }
|
||||||
|
.map { it.second }
|
||||||
|
// Poll download status
|
||||||
|
.mergeWith(pollStatus(id))
|
||||||
|
// Force an error if the download takes more than 3 minutes
|
||||||
|
.mergeWith(Observable.timer(3, TimeUnit.MINUTES).map { InstallStep.Error })
|
||||||
|
// Stop when the application is installed or errors
|
||||||
|
.takeUntil { it.isCompleted() }
|
||||||
|
// Always notify on main thread
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
// Always remove the download when unsubscribed
|
||||||
|
.doOnUnsubscribe { deleteDownload(pkgName) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an observable that polls the given download id for its status every second, as the
|
||||||
|
* manager doesn't have any notification system. It'll stop once the download finishes.
|
||||||
|
*
|
||||||
|
* @param id The id of the download to poll.
|
||||||
|
*/
|
||||||
|
private fun pollStatus(id: Long): Observable<InstallStep> {
|
||||||
|
val query = DownloadManager.Query().setFilterById(id)
|
||||||
|
|
||||||
|
return Observable.interval(0, 1, TimeUnit.SECONDS)
|
||||||
|
// Get the current download status
|
||||||
|
.map {
|
||||||
|
downloadManager.query(query).use { cursor ->
|
||||||
|
cursor.moveToFirst()
|
||||||
|
cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ignore duplicate results
|
||||||
|
.distinctUntilChanged()
|
||||||
|
// Stop polling when the download fails or finishes
|
||||||
|
.takeUntil { it == DownloadManager.STATUS_SUCCESSFUL || it == DownloadManager.STATUS_FAILED }
|
||||||
|
// Map to our model
|
||||||
|
.flatMap { status ->
|
||||||
|
when (status) {
|
||||||
|
DownloadManager.STATUS_PENDING -> Observable.just(InstallStep.Pending)
|
||||||
|
DownloadManager.STATUS_RUNNING -> Observable.just(InstallStep.Downloading)
|
||||||
|
else -> Observable.empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts an intent to install the extension at the given uri.
|
||||||
|
*
|
||||||
|
* @param uri The uri of the extension to install.
|
||||||
|
*/
|
||||||
|
fun installApk(downloadId: Long, uri: Uri) {
|
||||||
|
val intent = Intent(context, ExtensionInstallActivity::class.java)
|
||||||
|
.setDataAndType(uri, APK_MIME)
|
||||||
|
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
||||||
|
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts an intent to uninstall the extension by the given package name.
|
||||||
|
*
|
||||||
|
* @param pkgName The package name of the extension to uninstall
|
||||||
|
*/
|
||||||
|
fun uninstallApk(pkgName: String) {
|
||||||
|
val packageUri = Uri.parse("package:$pkgName")
|
||||||
|
val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, packageUri)
|
||||||
|
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the result of the installation of an extension.
|
||||||
|
*
|
||||||
|
* @param downloadId The id of the download.
|
||||||
|
* @param result Whether the extension was installed or not.
|
||||||
|
*/
|
||||||
|
fun setInstallationResult(downloadId: Long, result: Boolean) {
|
||||||
|
val step = if (result) InstallStep.Installed else InstallStep.Error
|
||||||
|
downloadsRelay.call(downloadId to step)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the download for the given package name.
|
||||||
|
*
|
||||||
|
* @param pkgName The package name of the download to delete.
|
||||||
|
*/
|
||||||
|
fun deleteDownload(pkgName: String) {
|
||||||
|
val downloadId = activeDownloads.remove(pkgName)
|
||||||
|
if (downloadId != null) {
|
||||||
|
downloadManager.remove(downloadId)
|
||||||
|
}
|
||||||
|
if (activeDownloads.isEmpty()) {
|
||||||
|
downloadReceiver.unregister()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receiver that listens to download status events.
|
||||||
|
*/
|
||||||
|
private inner class DownloadCompletionReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this receiver is currently registered.
|
||||||
|
*/
|
||||||
|
private var isRegistered = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers this receiver if it's not already.
|
||||||
|
*/
|
||||||
|
fun register() {
|
||||||
|
if (isRegistered) return
|
||||||
|
isRegistered = true
|
||||||
|
|
||||||
|
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
|
||||||
|
context.registerReceiver(this, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregisters this receiver if it's not already.
|
||||||
|
*/
|
||||||
|
fun unregister() {
|
||||||
|
if (!isRegistered) return
|
||||||
|
isRegistered = false
|
||||||
|
|
||||||
|
context.unregisterReceiver(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a download event is received. It looks for the download in the current active
|
||||||
|
* downloads and notifies its installation step.
|
||||||
|
*/
|
||||||
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
|
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0) ?: return
|
||||||
|
|
||||||
|
// Avoid events for downloads we didn't request
|
||||||
|
if (id !in activeDownloads.values) return
|
||||||
|
|
||||||
|
val uri = downloadManager.getUriForDownloadedFile(id)
|
||||||
|
|
||||||
|
// Set next installation step
|
||||||
|
if (uri != null) {
|
||||||
|
downloadsRelay.call(id to InstallStep.Installing)
|
||||||
|
} else {
|
||||||
|
Timber.e("Couldn't locate downloaded APK")
|
||||||
|
downloadsRelay.call(id to InstallStep.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Due to a bug in Android versions prior to N, the installer can't open files that do
|
||||||
|
// not contain the extension in the path, even if you specify the correct MIME.
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||||
|
val query = DownloadManager.Query().setFilterById(id)
|
||||||
|
downloadManager.query(query).use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val uriCompat = File(cursor.getString(cursor.getColumnIndex(
|
||||||
|
DownloadManager.COLUMN_LOCAL_FILENAME))).getUriCompat(context)
|
||||||
|
installApk(id, uriCompat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
installApk(id, uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val APK_MIME = "application/vnd.android.package-archive"
|
||||||
|
const val EXTRA_DOWNLOAD_ID = "ExtensionInstaller.extra.DOWNLOAD_ID"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,183 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.util
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import dalvik.system.PathClassLoader
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
|
import eu.kanade.tachiyomi.extension.model.Extension
|
||||||
|
import eu.kanade.tachiyomi.extension.model.LoadResult
|
||||||
|
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||||
|
import eu.kanade.tachiyomi.source.Source
|
||||||
|
import eu.kanade.tachiyomi.source.SourceFactory
|
||||||
|
import eu.kanade.tachiyomi.util.Hash
|
||||||
|
import kotlinx.coroutines.experimental.async
|
||||||
|
import kotlinx.coroutines.experimental.runBlocking
|
||||||
|
import timber.log.Timber
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class that handles the loading of the extensions installed in the system.
|
||||||
|
*/
|
||||||
|
@SuppressLint("PackageManagerGetSignatures")
|
||||||
|
internal object ExtensionLoader {
|
||||||
|
|
||||||
|
private const val EXTENSION_FEATURE = "tachiyomi.extension"
|
||||||
|
private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
|
||||||
|
private const val LIB_VERSION_MIN = 1
|
||||||
|
private const val LIB_VERSION_MAX = 1
|
||||||
|
|
||||||
|
private const val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of the trusted signatures.
|
||||||
|
*/
|
||||||
|
var trustedSignatures = mutableSetOf<String>() +
|
||||||
|
Injekt.get<PreferencesHelper>().trustedSignatures().getOrDefault() +
|
||||||
|
// inorichi's key
|
||||||
|
"7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a list of all the installed extensions initialized concurrently.
|
||||||
|
*
|
||||||
|
* @param context The application context.
|
||||||
|
*/
|
||||||
|
fun loadExtensions(context: Context): List<LoadResult> {
|
||||||
|
val pkgManager = context.packageManager
|
||||||
|
val installedPkgs = pkgManager.getInstalledPackages(PACKAGE_FLAGS)
|
||||||
|
val extPkgs = installedPkgs.filter { isPackageAnExtension(it) }
|
||||||
|
|
||||||
|
if (extPkgs.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
// Load each extension concurrently and wait for completion
|
||||||
|
return runBlocking {
|
||||||
|
val deferred = extPkgs.map {
|
||||||
|
async { loadExtension(context, it.packageName, it) }
|
||||||
|
}
|
||||||
|
deferred.map { it.await() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to load an extension from the given package name. It checks if the extension
|
||||||
|
* contains the required feature flag before trying to load it.
|
||||||
|
*/
|
||||||
|
fun loadExtensionFromPkgName(context: Context, pkgName: String): LoadResult {
|
||||||
|
val pkgInfo = try {
|
||||||
|
context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS)
|
||||||
|
} catch (error: PackageManager.NameNotFoundException) {
|
||||||
|
// Unlikely, but the package may have been uninstalled at this point
|
||||||
|
return LoadResult.Error(error)
|
||||||
|
}
|
||||||
|
if (!isPackageAnExtension(pkgInfo)) {
|
||||||
|
return LoadResult.Error("Tried to load a package that wasn't a extension")
|
||||||
|
}
|
||||||
|
return loadExtension(context, pkgName, pkgInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads an extension given its package name.
|
||||||
|
*
|
||||||
|
* @param context The application context.
|
||||||
|
* @param pkgName The package name of the extension to load.
|
||||||
|
* @param pkgInfo The package info of the extension.
|
||||||
|
*/
|
||||||
|
private fun loadExtension(context: Context, pkgName: String, pkgInfo: PackageInfo): LoadResult {
|
||||||
|
val pkgManager = context.packageManager
|
||||||
|
|
||||||
|
val appInfo = try {
|
||||||
|
pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA)
|
||||||
|
} catch (error: PackageManager.NameNotFoundException) {
|
||||||
|
// Unlikely, but the package may have been uninstalled at this point
|
||||||
|
return LoadResult.Error(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
val extName = pkgManager.getApplicationLabel(appInfo)?.toString()
|
||||||
|
.orEmpty().substringAfter("Tachiyomi: ")
|
||||||
|
val versionName = pkgInfo.versionName
|
||||||
|
val versionCode = pkgInfo.versionCode
|
||||||
|
|
||||||
|
// Validate lib version
|
||||||
|
val majorLibVersion = versionName.substringBefore('.').toInt()
|
||||||
|
if (majorLibVersion < LIB_VERSION_MIN || majorLibVersion > LIB_VERSION_MAX) {
|
||||||
|
val exception = Exception("Lib version is $majorLibVersion, while only versions " +
|
||||||
|
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed")
|
||||||
|
Timber.w(exception)
|
||||||
|
return LoadResult.Error(exception)
|
||||||
|
}
|
||||||
|
|
||||||
|
val signatureHash = getSignatureHash(pkgInfo)
|
||||||
|
|
||||||
|
if (signatureHash == null) {
|
||||||
|
return LoadResult.Error("Package $pkgName isn't signed")
|
||||||
|
} else if (signatureHash !in trustedSignatures) {
|
||||||
|
val extension = Extension.Untrusted(extName, pkgName, versionName, versionCode, signatureHash)
|
||||||
|
Timber.w("Extension $pkgName isn't trusted")
|
||||||
|
return LoadResult.Untrusted(extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
val classLoader = PathClassLoader(appInfo.sourceDir, null, context.classLoader)
|
||||||
|
|
||||||
|
val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS)
|
||||||
|
.split(";")
|
||||||
|
.map {
|
||||||
|
val sourceClass = it.trim()
|
||||||
|
if (sourceClass.startsWith("."))
|
||||||
|
pkgInfo.packageName + sourceClass
|
||||||
|
else
|
||||||
|
sourceClass
|
||||||
|
}
|
||||||
|
.flatMap {
|
||||||
|
try {
|
||||||
|
val obj = Class.forName(it, false, classLoader).newInstance()
|
||||||
|
when (obj) {
|
||||||
|
is Source -> listOf(obj)
|
||||||
|
is SourceFactory -> obj.createSources()
|
||||||
|
else -> throw Exception("Unknown source class type! ${obj.javaClass}")
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Timber.e(e, "Extension load error: $extName.")
|
||||||
|
return LoadResult.Error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val langs = sources.filterIsInstance<CatalogueSource>()
|
||||||
|
.map { it.lang }
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
val lang = when (langs.size) {
|
||||||
|
0 -> ""
|
||||||
|
1 -> langs.first()
|
||||||
|
else -> "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
val extension = Extension.Installed(extName, pkgName, versionName, versionCode, sources, lang)
|
||||||
|
return LoadResult.Success(extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given package is an extension.
|
||||||
|
*
|
||||||
|
* @param pkgInfo The package info of the application.
|
||||||
|
*/
|
||||||
|
private fun isPackageAnExtension(pkgInfo: PackageInfo): Boolean {
|
||||||
|
return pkgInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the signature hash of the package or null if it's not signed.
|
||||||
|
*
|
||||||
|
* @param pkgInfo The package info of the application.
|
||||||
|
*/
|
||||||
|
private fun getSignatureHash(pkgInfo: PackageInfo): String? {
|
||||||
|
val signatures = pkgInfo.signatures
|
||||||
|
return if (signatures != null && !signatures.isEmpty()) {
|
||||||
|
Hash.sha256(signatures.first().toByteArray())
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,10 +1,8 @@
|
|||||||
package eu.kanade.tachiyomi.network
|
package eu.kanade.tachiyomi.network
|
||||||
|
|
||||||
import com.squareup.duktape.Duktape
|
import com.squareup.duktape.Duktape
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.*
|
||||||
import okhttp3.Interceptor
|
import java.io.IOException
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
|
||||||
|
|
||||||
class CloudflareInterceptor : Interceptor {
|
class CloudflareInterceptor : Interceptor {
|
||||||
|
|
||||||
@ -14,13 +12,21 @@ class CloudflareInterceptor : Interceptor {
|
|||||||
|
|
||||||
private val challengePattern = Regex("""name="jschl_vc" value="(\w+)"""")
|
private val challengePattern = Regex("""name="jschl_vc" value="(\w+)"""")
|
||||||
|
|
||||||
|
private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare")
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val response = chain.proceed(chain.request())
|
val response = chain.proceed(chain.request())
|
||||||
|
|
||||||
// Check if Cloudflare anti-bot is on
|
// Check if Cloudflare anti-bot is on
|
||||||
if (response.code() == 503 && "cloudflare-nginx" == response.header("Server")) {
|
if (response.code() == 503 && response.header("Server") in serverCheck) {
|
||||||
return chain.proceed(resolveChallenge(response))
|
return try {
|
||||||
|
chain.proceed(resolveChallenge(response))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
|
||||||
|
// we don't crash the entire app
|
||||||
|
throw IOException(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
@ -41,32 +47,33 @@ class CloudflareInterceptor : Interceptor {
|
|||||||
val pass = passPattern.find(content)?.groups?.get(1)?.value
|
val pass = passPattern.find(content)?.groups?.get(1)?.value
|
||||||
|
|
||||||
if (operation == null || challenge == null || pass == null) {
|
if (operation == null || challenge == null || pass == null) {
|
||||||
throw RuntimeException("Failed resolving Cloudflare challenge")
|
throw Exception("Failed resolving Cloudflare challenge")
|
||||||
}
|
}
|
||||||
|
|
||||||
val js = operation
|
val js = operation
|
||||||
.replace(Regex("""a\.value =(.+?) \+.*"""), "$1")
|
.replace(Regex("""a\.value = (.+ \+ t\.length(\).toFixed\(10\))?).+"""), "$1")
|
||||||
.replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "")
|
.replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "")
|
||||||
|
.replace("t.length", "${domain.length}")
|
||||||
.replace("\n", "")
|
.replace("\n", "")
|
||||||
|
|
||||||
val result = (duktape.evaluate(js) as Double).toInt()
|
val result = duktape.evaluate(js) as String
|
||||||
|
|
||||||
val answer = "${result + domain.length}"
|
|
||||||
|
|
||||||
val cloudflareUrl = HttpUrl.parse("${url.scheme()}://$domain/cdn-cgi/l/chk_jschl")!!
|
val cloudflareUrl = HttpUrl.parse("${url.scheme()}://$domain/cdn-cgi/l/chk_jschl")!!
|
||||||
.newBuilder()
|
.newBuilder()
|
||||||
.addQueryParameter("jschl_vc", challenge)
|
.addQueryParameter("jschl_vc", challenge)
|
||||||
.addQueryParameter("pass", pass)
|
.addQueryParameter("pass", pass)
|
||||||
.addQueryParameter("jschl_answer", answer)
|
.addQueryParameter("jschl_answer", result)
|
||||||
.toString()
|
.toString()
|
||||||
|
|
||||||
val cloudflareHeaders = originalRequest.headers()
|
val cloudflareHeaders = originalRequest.headers()
|
||||||
.newBuilder()
|
.newBuilder()
|
||||||
.add("Referer", url.toString())
|
.add("Referer", url.toString())
|
||||||
|
.add("Accept", "text/html,application/xhtml+xml,application/xml")
|
||||||
|
.add("Accept-Language", "en")
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return GET(cloudflareUrl, cloudflareHeaders)
|
return GET(cloudflareUrl, cloudflareHeaders, cache = CacheControl.Builder().build())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,25 @@
|
|||||||
package eu.kanade.tachiyomi.network
|
package eu.kanade.tachiyomi.network
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
|
import okhttp3.CipherSuite
|
||||||
|
import okhttp3.ConnectionSpec
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.TlsVersion
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.Socket
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
import java.security.KeyManagementException
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.NoSuchAlgorithmException
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.SSLSocket
|
||||||
|
import javax.net.ssl.SSLSocketFactory
|
||||||
|
import javax.net.ssl.TrustManagerFactory
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
class NetworkHelper(context: Context) {
|
class NetworkHelper(context: Context) {
|
||||||
|
|
||||||
@ -16,16 +32,7 @@ class NetworkHelper(context: Context) {
|
|||||||
val client = OkHttpClient.Builder()
|
val client = OkHttpClient.Builder()
|
||||||
.cookieJar(cookieManager)
|
.cookieJar(cookieManager)
|
||||||
.cache(Cache(cacheDir, cacheSize))
|
.cache(Cache(cacheDir, cacheSize))
|
||||||
.build()
|
.enableTLS12()
|
||||||
|
|
||||||
val forceCacheClient = client.newBuilder()
|
|
||||||
.addNetworkInterceptor { chain ->
|
|
||||||
val originalResponse = chain.proceed(chain.request())
|
|
||||||
originalResponse.newBuilder()
|
|
||||||
.removeHeader("Pragma")
|
|
||||||
.header("Cache-Control", "max-age=600")
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val cloudflareClient = client.newBuilder()
|
val cloudflareClient = client.newBuilder()
|
||||||
@ -35,4 +42,87 @@ class NetworkHelper(context: Context) {
|
|||||||
val cookies: PersistentCookieStore
|
val cookies: PersistentCookieStore
|
||||||
get() = cookieManager.store
|
get() = cookieManager.store
|
||||||
|
|
||||||
|
private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder {
|
||||||
|
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||||
|
trustManagerFactory.init(null as KeyStore?)
|
||||||
|
val trustManagers = trustManagerFactory.trustManagers
|
||||||
|
if (trustManagers.size == 1 && trustManagers[0] is X509TrustManager) {
|
||||||
|
class TLSSocketFactory @Throws(KeyManagementException::class, NoSuchAlgorithmException::class)
|
||||||
|
constructor() : SSLSocketFactory() {
|
||||||
|
|
||||||
|
private val internalSSLSocketFactory: SSLSocketFactory
|
||||||
|
|
||||||
|
init {
|
||||||
|
val context = SSLContext.getInstance("TLS")
|
||||||
|
context.init(null, null, null)
|
||||||
|
internalSSLSocketFactory = context.socketFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDefaultCipherSuites(): Array<String> {
|
||||||
|
return internalSSLSocketFactory.defaultCipherSuites
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSupportedCipherSuites(): Array<String> {
|
||||||
|
return internalSSLSocketFactory.supportedCipherSuites
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun createSocket(): Socket? {
|
||||||
|
return enableTLSOnSocket(internalSSLSocketFactory.createSocket())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket? {
|
||||||
|
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class, UnknownHostException::class)
|
||||||
|
override fun createSocket(host: String, port: Int): Socket? {
|
||||||
|
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class, UnknownHostException::class)
|
||||||
|
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket? {
|
||||||
|
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun createSocket(host: InetAddress, port: Int): Socket? {
|
||||||
|
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket? {
|
||||||
|
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableTLSOnSocket(socket: Socket?): Socket? {
|
||||||
|
if (socket != null && socket is SSLSocket) {
|
||||||
|
socket.enabledProtocols = socket.supportedProtocols
|
||||||
|
}
|
||||||
|
return socket
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sslSocketFactory(TLSSocketFactory(), trustManagers[0] as X509TrustManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
val specCompat = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
|
||||||
|
.tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0)
|
||||||
|
.cipherSuites(
|
||||||
|
*ConnectionSpec.MODERN_TLS.cipherSuites().orEmpty().toTypedArray(),
|
||||||
|
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
|
||||||
|
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val specs = listOf(specCompat, ConnectionSpec.CLEARTEXT)
|
||||||
|
connectionSpecs(specs)
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -60,6 +60,11 @@ class PersistentCookieStore(context: Context) {
|
|||||||
cookieMap.clear()
|
cookieMap.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun remove(uri: URI) {
|
||||||
|
prefs.edit().remove(uri.host).apply()
|
||||||
|
cookieMap.remove(uri.host)
|
||||||
|
}
|
||||||
|
|
||||||
fun get(url: HttpUrl) = get(url.uri().host)
|
fun get(url: HttpUrl) = get(url.uri().host)
|
||||||
|
|
||||||
fun get(uri: URI) = get(uri.host)
|
fun get(uri: URI) = get(uri.host)
|
||||||
|
@ -0,0 +1,8 @@
|
|||||||
|
package eu.kanade.tachiyomi.source
|
||||||
|
|
||||||
|
import android.support.v7.preference.PreferenceScreen
|
||||||
|
|
||||||
|
interface ConfigurableSource : Source {
|
||||||
|
|
||||||
|
fun setupPreferenceScreen(screen: PreferenceScreen)
|
||||||
|
}
|
@ -1,24 +1,22 @@
|
|||||||
package eu.kanade.tachiyomi.source
|
package eu.kanade.tachiyomi.source
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.source.model.*
|
import eu.kanade.tachiyomi.source.model.*
|
||||||
import eu.kanade.tachiyomi.util.ChapterRecognition
|
import eu.kanade.tachiyomi.util.ChapterRecognition
|
||||||
import eu.kanade.tachiyomi.util.DiskUtil
|
import eu.kanade.tachiyomi.util.DiskUtil
|
||||||
import eu.kanade.tachiyomi.util.RarContentProvider
|
import eu.kanade.tachiyomi.util.EpubFile
|
||||||
import eu.kanade.tachiyomi.util.ZipContentProvider
|
import eu.kanade.tachiyomi.util.ImageUtil
|
||||||
import junrar.Archive
|
import junrar.Archive
|
||||||
import junrar.rarfile.FileHeader
|
import junrar.rarfile.FileHeader
|
||||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
||||||
import org.jsoup.Jsoup
|
|
||||||
import org.jsoup.nodes.Document
|
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.util.*
|
import java.util.Comparator
|
||||||
|
import java.util.Locale
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.zip.ZipEntry
|
import java.util.zip.ZipEntry
|
||||||
import java.util.zip.ZipFile
|
import java.util.zip.ZipFile
|
||||||
@ -107,15 +105,11 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
|||||||
if (thumbnail_url == null) {
|
if (thumbnail_url == null) {
|
||||||
val chapters = fetchChapterList(this).toBlocking().first()
|
val chapters = fetchChapterList(this).toBlocking().first()
|
||||||
if (chapters.isNotEmpty()) {
|
if (chapters.isNotEmpty()) {
|
||||||
val uri = fetchPageList(chapters.last()).toBlocking().first().firstOrNull()?.uri
|
try {
|
||||||
if (uri != null) {
|
val dest = updateCover(chapters.last(), this)
|
||||||
val input = context.contentResolver.openInputStream(uri)
|
thumbnail_url = dest?.absolutePath
|
||||||
try {
|
} catch (e: Exception) {
|
||||||
val dest = updateCover(context, this, input)
|
Timber.e(e)
|
||||||
thumbnail_url = dest?.absolutePath
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -135,7 +129,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
|||||||
val chapters = getBaseDirectories(context)
|
val chapters = getBaseDirectories(context)
|
||||||
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
|
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
|
||||||
.flatten()
|
.flatten()
|
||||||
.filter { it.isDirectory || isSupportedFormat(it.extension) }
|
.filter { it.isDirectory || isSupportedFile(it.extension) }
|
||||||
.map { chapterFile ->
|
.map { chapterFile ->
|
||||||
SChapter.create().apply {
|
SChapter.create().apply {
|
||||||
url = "${manga.url}/${chapterFile.name}"
|
url = "${manga.url}/${chapterFile.name}"
|
||||||
@ -150,7 +144,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
|||||||
ChapterRecognition.parseChapterNumber(this, manga)
|
ChapterRecognition.parseChapterNumber(this, manga)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sortedWith(Comparator<SChapter> { c1, c2 ->
|
.sortedWith(Comparator { c1, c2 ->
|
||||||
val c = c2.chapter_number.compareTo(c1.chapter_number)
|
val c = c2.chapter_number.compareTo(c1.chapter_number)
|
||||||
if (c == 0) comparator.compare(c2.name, c1.name) else c
|
if (c == 0) comparator.compare(c2.name, c1.name) else c
|
||||||
})
|
})
|
||||||
@ -159,160 +153,90 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||||
|
return Observable.error(Exception("Unused"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isSupportedFile(extension: String): Boolean {
|
||||||
|
return extension.toLowerCase() in setOf("zip", "rar", "cbr", "cbz", "epub")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getFormat(chapter: SChapter): Format {
|
||||||
val baseDirs = getBaseDirectories(context)
|
val baseDirs = getBaseDirectories(context)
|
||||||
|
|
||||||
for (dir in baseDirs) {
|
for (dir in baseDirs) {
|
||||||
val chapFile = File(dir, chapter.url)
|
val chapFile = File(dir, chapter.url)
|
||||||
if (!chapFile.exists()) continue
|
if (!chapFile.exists()) continue
|
||||||
|
|
||||||
return Observable.just(getLoader(chapFile).load())
|
return getFormat(chapFile)
|
||||||
}
|
}
|
||||||
|
throw Exception("Chapter not found")
|
||||||
return Observable.error(Exception("Chapter not found"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isSupportedFormat(extension: String): Boolean {
|
private fun getFormat(file: File): Format {
|
||||||
return extension.equals("zip", true) || extension.equals("cbz", true)
|
|
||||||
|| extension.equals("rar", true) || extension.equals("cbr", true)
|
|
||||||
|| extension.equals("epub", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getLoader(file: File): Loader {
|
|
||||||
val extension = file.extension
|
val extension = file.extension
|
||||||
return if (file.isDirectory) {
|
return if (file.isDirectory) {
|
||||||
DirectoryLoader(file)
|
Format.Directory(file)
|
||||||
} else if (extension.equals("zip", true) || extension.equals("cbz", true)) {
|
} else if (extension.equals("zip", true) || extension.equals("cbz", true)) {
|
||||||
ZipLoader(file)
|
Format.Zip(file)
|
||||||
} else if (extension.equals("epub", true)) {
|
|
||||||
EpubLoader(file)
|
|
||||||
} else if (extension.equals("rar", true) || extension.equals("cbr", true)) {
|
} else if (extension.equals("rar", true) || extension.equals("cbr", true)) {
|
||||||
RarLoader(file)
|
Format.Rar(file)
|
||||||
|
} else if (extension.equals("epub", true)) {
|
||||||
|
Format.Epub(file)
|
||||||
} else {
|
} else {
|
||||||
throw Exception("Invalid chapter format")
|
throw Exception("Invalid chapter format")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateCover(chapter: SChapter, manga: SManga): File? {
|
||||||
|
val format = getFormat(chapter)
|
||||||
|
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
||||||
|
return when (format) {
|
||||||
|
is Format.Directory -> {
|
||||||
|
val entry = format.file.listFiles()
|
||||||
|
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
||||||
|
.find { !it.isDirectory && ImageUtil.isImage(it.name, { FileInputStream(it) }) }
|
||||||
|
|
||||||
|
entry?.let { updateCover(context, manga, it.inputStream())}
|
||||||
|
}
|
||||||
|
is Format.Zip -> {
|
||||||
|
ZipFile(format.file).use { zip ->
|
||||||
|
val entry = zip.entries().toList()
|
||||||
|
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
||||||
|
.find { !it.isDirectory && ImageUtil.isImage(it.name, { zip.getInputStream(it) }) }
|
||||||
|
|
||||||
|
entry?.let { updateCover(context, manga, zip.getInputStream(it) )}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Format.Rar -> {
|
||||||
|
Archive(format.file).use { archive ->
|
||||||
|
val entry = archive.fileHeaders
|
||||||
|
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
|
||||||
|
.find { !it.isDirectory && ImageUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) }
|
||||||
|
|
||||||
|
entry?.let { updateCover(context, manga, archive.getInputStream(it) )}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Format.Epub -> {
|
||||||
|
EpubFile(format.file).use { epub ->
|
||||||
|
val entry = epub.getImagesFromPages()
|
||||||
|
.firstOrNull()
|
||||||
|
?.let { epub.getEntry(it) }
|
||||||
|
|
||||||
|
entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true))
|
private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true))
|
||||||
|
|
||||||
override fun getFilterList() = FilterList(OrderBy())
|
override fun getFilterList() = FilterList(OrderBy())
|
||||||
|
|
||||||
interface Loader {
|
sealed class Format {
|
||||||
fun load(): List<Page>
|
data class Directory(val file: File) : Format()
|
||||||
|
data class Zip(val file: File) : Format()
|
||||||
|
data class Rar(val file: File): Format()
|
||||||
|
data class Epub(val file: File) : Format()
|
||||||
}
|
}
|
||||||
|
|
||||||
class DirectoryLoader(val file: File) : Loader {
|
}
|
||||||
override fun load(): List<Page> {
|
|
||||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
|
||||||
return file.listFiles()
|
|
||||||
.filter { !it.isDirectory && DiskUtil.isImage(it.name, { FileInputStream(it) }) }
|
|
||||||
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
|
||||||
.map { Uri.fromFile(it) }
|
|
||||||
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ZipLoader(val file: File) : Loader {
|
|
||||||
override fun load(): List<Page> {
|
|
||||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
|
||||||
return ZipFile(file).use { zip ->
|
|
||||||
zip.entries().toList()
|
|
||||||
.filter { !it.isDirectory && DiskUtil.isImage(it.name, { zip.getInputStream(it) }) }
|
|
||||||
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
|
||||||
.map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/${it.name}") }
|
|
||||||
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RarLoader(val file: File) : Loader {
|
|
||||||
override fun load(): List<Page> {
|
|
||||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
|
||||||
return Archive(file).use { archive ->
|
|
||||||
archive.fileHeaders
|
|
||||||
.filter { !it.isDirectory && DiskUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) }
|
|
||||||
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
|
|
||||||
.map { Uri.parse("content://${RarContentProvider.PROVIDER}${file.absolutePath}!-/${it.fileNameString}") }
|
|
||||||
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class EpubLoader(val file: File) : Loader {
|
|
||||||
|
|
||||||
override fun load(): List<Page> {
|
|
||||||
ZipFile(file).use { zip ->
|
|
||||||
val allEntries = zip.entries().toList()
|
|
||||||
val ref = getPackageHref(zip)
|
|
||||||
val doc = getPackageDocument(zip, ref)
|
|
||||||
val pages = getPagesFromDocument(doc)
|
|
||||||
val hrefs = getHrefMap(ref, allEntries.map { it.name })
|
|
||||||
return getImagesFromPages(zip, pages, hrefs)
|
|
||||||
.map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/$it") }
|
|
||||||
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the path to the package document.
|
|
||||||
*/
|
|
||||||
private fun getPackageHref(zip: ZipFile): String {
|
|
||||||
val meta = zip.getEntry("META-INF/container.xml")
|
|
||||||
if (meta != null) {
|
|
||||||
val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") }
|
|
||||||
val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path")
|
|
||||||
if (path != null) {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "OEBPS/content.opf"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the package document where all the files are listed.
|
|
||||||
*/
|
|
||||||
private fun getPackageDocument(zip: ZipFile, ref: String): Document {
|
|
||||||
val entry = zip.getEntry(ref)
|
|
||||||
return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all the pages from the epub.
|
|
||||||
*/
|
|
||||||
private fun getPagesFromDocument(document: Document): List<String> {
|
|
||||||
val pages = document.select("manifest > item")
|
|
||||||
.filter { "application/xhtml+xml" == it.attr("media-type") }
|
|
||||||
.associateBy { it.attr("id") }
|
|
||||||
|
|
||||||
val spine = document.select("spine > itemref").map { it.attr("idref") }
|
|
||||||
return spine.mapNotNull { pages[it] }.map { it.attr("href") }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all the images contained in every page from the epub.
|
|
||||||
*/
|
|
||||||
private fun getImagesFromPages(zip: ZipFile, pages: List<String>, hrefs: Map<String, String>): List<String> {
|
|
||||||
return pages.map { page ->
|
|
||||||
val entry = zip.getEntry(hrefs[page])
|
|
||||||
val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
|
|
||||||
document.getElementsByTag("img").mapNotNull { hrefs[it.attr("src")] }
|
|
||||||
}.flatten()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a map with a relative url as key and abolute url as path.
|
|
||||||
*/
|
|
||||||
private fun getHrefMap(packageHref: String, entries: List<String>): Map<String, String> {
|
|
||||||
val lastSlashPos = packageHref.lastIndexOf('/')
|
|
||||||
if (lastSlashPos < 0) {
|
|
||||||
return entries.associateBy { it }
|
|
||||||
}
|
|
||||||
return entries.associateBy { entry ->
|
|
||||||
if (entry.isNotBlank() && entry.length > lastSlashPos) {
|
|
||||||
entry.substring(lastSlashPos + 1)
|
|
||||||
} else {
|
|
||||||
entry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,50 +1,50 @@
|
|||||||
package eu.kanade.tachiyomi.source
|
package eu.kanade.tachiyomi.source
|
||||||
|
|
||||||
import android.Manifest.permission.READ_EXTERNAL_STORAGE
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.ApplicationInfo
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Environment
|
|
||||||
import dalvik.system.PathClassLoader
|
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.source.online.YamlHttpSource
|
|
||||||
import eu.kanade.tachiyomi.source.online.english.*
|
import eu.kanade.tachiyomi.source.online.english.*
|
||||||
import eu.kanade.tachiyomi.source.online.german.WieManga
|
import eu.kanade.tachiyomi.source.online.german.WieManga
|
||||||
import eu.kanade.tachiyomi.source.online.russian.Mangachan
|
import eu.kanade.tachiyomi.source.online.russian.Mangachan
|
||||||
import eu.kanade.tachiyomi.source.online.russian.Mintmanga
|
import eu.kanade.tachiyomi.source.online.russian.Mintmanga
|
||||||
import eu.kanade.tachiyomi.source.online.russian.Readmanga
|
import eu.kanade.tachiyomi.source.online.russian.Readmanga
|
||||||
import eu.kanade.tachiyomi.util.hasPermission
|
import rx.Observable
|
||||||
import org.yaml.snakeyaml.Yaml
|
|
||||||
import timber.log.Timber
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
open class SourceManager(private val context: Context) {
|
open class SourceManager(private val context: Context) {
|
||||||
|
|
||||||
private val sourcesMap = mutableMapOf<Long, Source>()
|
private val sourcesMap = mutableMapOf<Long, Source>()
|
||||||
|
|
||||||
|
private val stubSourcesMap = mutableMapOf<Long, StubSource>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
createSources()
|
createInternalSources().forEach { registerSource(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
open fun get(sourceKey: Long): Source? {
|
open fun get(sourceKey: Long): Source? {
|
||||||
return sourcesMap[sourceKey]
|
return sourcesMap[sourceKey]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getOrStub(sourceKey: Long): Source {
|
||||||
|
return sourcesMap[sourceKey] ?: stubSourcesMap.getOrPut(sourceKey) {
|
||||||
|
StubSource(sourceKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getOnlineSources() = sourcesMap.values.filterIsInstance<HttpSource>()
|
fun getOnlineSources() = sourcesMap.values.filterIsInstance<HttpSource>()
|
||||||
|
|
||||||
fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>()
|
fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>()
|
||||||
|
|
||||||
private fun createSources() {
|
internal fun registerSource(source: Source, overwrite: Boolean = false) {
|
||||||
createExtensionSources().forEach { registerSource(it) }
|
if (overwrite || !sourcesMap.containsKey(source.id)) {
|
||||||
createYamlSources().forEach { registerSource(it) }
|
sourcesMap[source.id] = source
|
||||||
createInternalSources().forEach { registerSource(it) }
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun registerSource(source: Source, overwrite: Boolean = false) {
|
internal fun unregisterSource(source: Source) {
|
||||||
if (overwrite || !sourcesMap.containsKey(source.id)) {
|
sourcesMap.remove(source.id)
|
||||||
sourcesMap.put(source.id, source)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createInternalSources(): List<Source> = listOf(
|
private fun createInternalSources(): List<Source> = listOf(
|
||||||
@ -61,91 +61,29 @@ open class SourceManager(private val context: Context) {
|
|||||||
WieManga()
|
WieManga()
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun createYamlSources(): List<Source> {
|
private inner class StubSource(override val id: Long) : Source {
|
||||||
val sources = mutableListOf<Source>()
|
|
||||||
|
|
||||||
val parsersDir = File(Environment.getExternalStorageDirectory().absolutePath +
|
override val name: String
|
||||||
File.separator + context.getString(R.string.app_name), "parsers")
|
get() = id.toString()
|
||||||
|
|
||||||
if (parsersDir.exists() && context.hasPermission(READ_EXTERNAL_STORAGE)) {
|
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||||
val yaml = Yaml()
|
return Observable.error(getSourceNotInstalledException())
|
||||||
for (file in parsersDir.listFiles().filter { it.extension == "yml" }) {
|
|
||||||
try {
|
|
||||||
val map = file.inputStream().use { yaml.loadAs(it, Map::class.java) }
|
|
||||||
sources.add(YamlHttpSource(map))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e("Error loading source from file. Bad format?", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sources
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createExtensionSources(): List<Source> {
|
|
||||||
val pkgManager = context.packageManager
|
|
||||||
val flags = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES
|
|
||||||
val installedPkgs = pkgManager.getInstalledPackages(flags)
|
|
||||||
val extPkgs = installedPkgs.filter { it.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE } }
|
|
||||||
|
|
||||||
val sources = mutableListOf<Source>()
|
|
||||||
for (pkgInfo in extPkgs) {
|
|
||||||
val appInfo = pkgManager.getApplicationInfo(pkgInfo.packageName,
|
|
||||||
PackageManager.GET_META_DATA) ?: continue
|
|
||||||
|
|
||||||
val extName = pkgManager.getApplicationLabel(appInfo).toString()
|
|
||||||
.substringAfter("Tachiyomi: ")
|
|
||||||
val version = pkgInfo.versionName
|
|
||||||
val sourceClasses = appInfo.metaData.getString(METADATA_SOURCE_CLASS)
|
|
||||||
.split(";")
|
|
||||||
.map {
|
|
||||||
val sourceClass = it.trim()
|
|
||||||
if(sourceClass.startsWith("."))
|
|
||||||
pkgInfo.packageName + sourceClass
|
|
||||||
else
|
|
||||||
sourceClass
|
|
||||||
}
|
|
||||||
|
|
||||||
val extension = Extension(extName, appInfo, version, sourceClasses)
|
|
||||||
try {
|
|
||||||
sources += loadExtension(extension)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e("Extension load error: $extName.", e)
|
|
||||||
} catch (e: LinkageError) {
|
|
||||||
Timber.e("Extension load error: $extName.", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sources
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadExtension(ext: Extension): List<Source> {
|
|
||||||
// Validate lib version
|
|
||||||
val majorLibVersion = ext.version.substringBefore('.').toInt()
|
|
||||||
if (majorLibVersion < LIB_VERSION_MIN || majorLibVersion > LIB_VERSION_MAX) {
|
|
||||||
throw Exception("Lib version is $majorLibVersion, while only versions "
|
|
||||||
+ "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val classLoader = PathClassLoader(ext.appInfo.sourceDir, null, context.classLoader)
|
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||||
return ext.sourceClasses.flatMap {
|
return Observable.error(getSourceNotInstalledException())
|
||||||
val obj = Class.forName(it, false, classLoader).newInstance()
|
}
|
||||||
when(obj) {
|
|
||||||
is Source -> listOf(obj)
|
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||||
is SourceFactory -> obj.createSources()
|
return Observable.error(getSourceNotInstalledException())
|
||||||
else -> throw Exception("Unknown source class type!")
|
}
|
||||||
}
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSourceNotInstalledException(): Exception {
|
||||||
|
return Exception(context.getString(R.string.source_not_installed, id.toString()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Extension(val name: String,
|
|
||||||
val appInfo: ApplicationInfo,
|
|
||||||
val version: String,
|
|
||||||
val sourceClasses: List<String>)
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val EXTENSION_FEATURE = "tachiyomi.extension"
|
|
||||||
const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
|
|
||||||
const val LIB_VERSION_MIN = 1
|
|
||||||
const val LIB_VERSION_MAX = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -2,21 +2,18 @@ package eu.kanade.tachiyomi.source.model
|
|||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import eu.kanade.tachiyomi.network.ProgressListener
|
import eu.kanade.tachiyomi.network.ProgressListener
|
||||||
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
|
|
||||||
import rx.subjects.Subject
|
import rx.subjects.Subject
|
||||||
|
|
||||||
class Page(
|
open class Page(
|
||||||
val index: Int,
|
val index: Int,
|
||||||
val url: String = "",
|
val url: String = "",
|
||||||
var imageUrl: String? = null,
|
var imageUrl: String? = null,
|
||||||
@Transient var uri: Uri? = null
|
@Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions
|
||||||
) : ProgressListener {
|
) : ProgressListener {
|
||||||
|
|
||||||
val number: Int
|
val number: Int
|
||||||
get() = index + 1
|
get() = index + 1
|
||||||
|
|
||||||
@Transient lateinit var chapter: ReaderChapter
|
|
||||||
|
|
||||||
@Transient @Volatile var status: Int = 0
|
@Transient @Volatile var status: Int = 0
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
package eu.kanade.tachiyomi.source.online
|
package eu.kanade.tachiyomi.source.online
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
@ -28,10 +27,12 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
*/
|
*/
|
||||||
protected val network: NetworkHelper by injectLazy()
|
protected val network: NetworkHelper by injectLazy()
|
||||||
|
|
||||||
/**
|
// /**
|
||||||
* Preferences helper.
|
// * Preferences that a source may need.
|
||||||
*/
|
// */
|
||||||
protected val preferences: PreferencesHelper by injectLazy()
|
// val preferences: SharedPreferences by lazy {
|
||||||
|
// Injekt.get<Application>().getSharedPreferences("source_$id", Context.MODE_PRIVATE)
|
||||||
|
// }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base url of the website without the trailing slash, like: http://mysite.com
|
* Base url of the website without the trailing slash, like: http://mysite.com
|
||||||
|