Compare commits
175 Commits
Author | SHA1 | Date | |
---|---|---|---|
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 | |||
f287d313c3 | |||
e745836404 | |||
08baf798aa | |||
8bcb14c65d | |||
d94dc68830 | |||
297fed6aef | |||
d690d6e0e3 | |||
9ba8d88b07 | |||
34a40b0131 | |||
182bf5f2bd | |||
04638535d8 | |||
d87c8428fe | |||
166fb9a8e4 | |||
28a21d0b8f | |||
d1d1d60c30 | |||
80fd49d60b | |||
34eb1331a3 | |||
bff329a329 | |||
604929d002 | |||
4a9151e4aa | |||
020cc89576 | |||
25898d34ca | |||
6394388714 | |||
d4101c7bdf | |||
c93bf89cbe | |||
88d1f29fe2 | |||
c437a33f2a | |||
e3259f39f1 | |||
cb357b0a16 | |||
a7faf445c4 | |||
82a08f24c0 | |||
afa89ac125 | |||
2060b5cd34 | |||
d69730a333 | |||
9714a30148 | |||
23c0f2c313 | |||
5c4139be45 | |||
6b1a3a20e5 | |||
4ae00c80ca | |||
827792c4f0 | |||
abbe700dac | |||
1347bfe243 | |||
a76ee95b6d | |||
f3689f09cd | |||
d545cfd38c | |||
3631a9fac2 | |||
aee4ad2d3f | |||
f88c86c799 | |||
60ac27e401 | |||
d0567de4e6 | |||
ca30fd6088 | |||
1470e9d5ca | |||
f45efe2aa8 | |||
5b6c475817 | |||
4abd2d709f | |||
d97aff85b3 | |||
deec65446f | |||
5c662b1ae1 | |||
f648940388 | |||
5aae17754f | |||
0ef0f6ece1 | |||
bfd46f28e0 | |||
eaece18afc | |||
67d39b037c | |||
dd3f5a146d | |||
9fdc5b4b9d | |||
1f32d13698 | |||
886b1019ed | |||
3e8ed8a171 | |||
8307daee63 | |||
9b40d10352 | |||
f2a06eab37 | |||
74fd70416f | |||
b85c164195 | |||
75c41b645a | |||
54c8b3ef29 | |||
56bde40035 | |||
ff4a015baa | |||
0db4fcc27e | |||
f3080b6277 | |||
69cbbd5811 | |||
0b85760939 | |||
03f3a4805f |
14
.github/ISSUE_TEMPLATE.md
vendored
@ -1 +1,13 @@
|
||||
**Please read https://github.com/inorichi/tachiyomi/blob/master/.github/CONTRIBUTING.md before posting**
|
||||
**Please fill out this form and remove the first two lines before posting.**
|
||||
**If your issue is a request for a catalogue it belongs here https://github.com/inorichi/tachiyomi-extensions/**
|
||||
**App version:**
|
||||
|
||||
**Issue/Request:**
|
||||
|
||||
**Steps to reproduce (if applicable)**
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**Other details:**
|
BIN
.github/readme-images/app-icon.png
vendored
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
.github/readme-images/screens.png
vendored
Normal file
After Width: | Height: | Size: 730 KiB |
81
.travis.yml
@ -1,41 +1,58 @@
|
||||
language: android
|
||||
android:
|
||||
components:
|
||||
- platform-tools
|
||||
- tools
|
||||
|
||||
# The BuildTools version used by your project
|
||||
- build-tools-25.0.1
|
||||
- android-25
|
||||
- extra-android-m2repository
|
||||
- extra-google-m2repository
|
||||
- extra-android-support
|
||||
- extra-google-google_play_services
|
||||
|
||||
- build-tools-27.0.3
|
||||
- android-27
|
||||
- extra-android-m2repository
|
||||
- extra-google-m2repository
|
||||
- extra-android-support
|
||||
- extra-google-google_play_services
|
||||
licenses:
|
||||
- android-sdk-license-.+
|
||||
- '.+'
|
||||
|
||||
jdk:
|
||||
- oraclejdk8
|
||||
|
||||
before_script:
|
||||
- chmod +x gradlew
|
||||
|
||||
- android-sdk-license-.+
|
||||
before_install:
|
||||
- mkdir "$ANDROID_HOME/licenses" || true
|
||||
- echo -e "\n8933bad161af4178b1185d1a37fbf41ea5269c55" > "$ANDROID_HOME/licenses/android-sdk-license"
|
||||
- echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_HOME/licenses/android-sdk-preview-license"
|
||||
|
||||
#Build, and run tests
|
||||
script: "./gradlew clean buildStandardDebug"
|
||||
sudo: false
|
||||
|
||||
- yes | sdkmanager "platforms;android-27" # workaround for accepting the license
|
||||
- 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;
|
||||
tar xf secrets.tar;
|
||||
mv debug.keystore "$HOME/.android";
|
||||
fi
|
||||
- git clone https://github.com/urho3d/android-ndk.git $HOME/android-ndk-root
|
||||
- export ANDROID_NDK_HOME=$HOME/android-ndk-root
|
||||
- mkdir "$ANDROID_HOME/licenses" || true
|
||||
- echo -e "\n8933bad161af4178b1185d1a37fbf41ea5269c55" > "$ANDROID_HOME/licenses/android-sdk-license"
|
||||
- echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_HOME/licenses/android-sdk-preview-license"
|
||||
script: ".travis/build.sh"
|
||||
before_cache:
|
||||
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
|
||||
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
|
||||
- rm -fr $HOME/.gradle/caches/*/plugin-resolution/
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/.gradle/caches/
|
||||
- $HOME/.gradle/wrapper/
|
||||
- "$HOME/.gradle/caches/"
|
||||
- "$HOME/.gradle/wrapper/"
|
||||
- "$HOME/.android/build-cache"
|
||||
deploy:
|
||||
- provider: releases
|
||||
api_key:
|
||||
secure: qmS9SyMq8xPDqaY83rvAFyZcvic24lGBj3MFt22RhVJzIXAAN/vqL1R70PnNiCF7CE+R7PaDlBpwjxDMBiuh0QQNc1oX6cgepUbro4/Nt7NFFfCvKXaFdR1cSgYouhuHmy0SS0/alrcfhQ2bPwcm1/vAOiSa8Wu7hsXhCcxbFyEbXZVD11QZmiffEM0py+OeuqOFo2JxZaGRu2z04E/u5TWep1ZEuhFRCC87PGgFqABgg6jYYebQOUZADG/0G8581HTGU0mdwueYsiA35ncRzpV2V8DajEEBd5wOe5d8SyMtE+6Qs5PD9KcXAqGGe4QRmrJMX5EcLQaLZf/Qd5s9SFZVHb1aJIw/y05w4L5dlVpsjx5WuUAYAVg7Ol5UawofFo/hYkYCNmfub67wJQdHSIxPif7V6YeON6RQQMpc5GBYY9eA6ZxhrdA2m7eyoOT3jcbdaVJwC0jMGhn26hkgJfTo1LfAUs85Cs3BrK8w6Poqc/Jb+4Y0NhdGIKgO5tS3vY54cTJVVrQTq4/XmME4ZnzOX3HaOqzfyt/6M4gEQMvaeFksxwoFhocV7wfaCq9ps/Kdq2dl4KwoqRV2WqVaauqzCP4XPSlVLaJqztsw0wboupcaZepWJ2a/6j9IrKo1pEnyeHF5y+k0SUAxL0X8iKZ0uPxsgoVrlNtqXJWNGvA=
|
||||
file: tachiyomi-v*.apk
|
||||
file_glob: true
|
||||
skip_cleanup: true
|
||||
on:
|
||||
tags: true
|
||||
repo: inorichi/tachiyomi
|
||||
- provider: script
|
||||
script: ".travis/deploy.sh"
|
||||
skip_cleanup: true
|
||||
on:
|
||||
branch: master
|
||||
condition: "-z $TRAVIS_TAG"
|
||||
repo: inorichi/tachiyomi
|
||||
env:
|
||||
- GRADLE_OPTS="-XX:MaxPermSize=1024m -XX:+CMSClassUnloadingEnabled -XX:+HeapDumpOnOutOfMemoryError -Xmx2048m"
|
||||
global:
|
||||
- secure: Ita1+tzo7P5IC2yqU3KgRcXt+5DTpP0103Hx/ECYi42/7rLt+TC7PMjl2yH3Z189+tGwLq0Ol1KJ2Z5Rn3q7EaQgD0+WRkH/ijtrjKoVh7dyItIBp7yowZpA0TJHQ4EZpGSxZakKbIP4di8XMxJ2+5VzEivYUt04LCUpzugemL6b6XOfUmOZykVxV2UDAlPPggklITYBXkHUa0mwJhjS1aPPeeR3PhVXomkqfuOJOKejPXXXJope9fhAnmopHA7ISfjIrTuwDVQJqNSuco+O9kQShmlu0C8pob1vFGPEDvafaDS8SZ9A6gKT1ZfgSUqVmvDbx0WLX8XugBLrQedtZv63esOa1WUyGhgFVpeJjexlszXlhyfP1gH5QbzRr+EiSaagCyjf9II2veLAtU5cFY+nj6KBdKQsazIMRHf8SAQlWASyJYMED/N9RnUFxSf1rnLGqiY2ezjycx4ieFj7vhlbTgyao1GHjjR9cwNuntdMYWhY8+Vc7Fctmzm46xOyyz9oJGdyim76Y4w4MZvQNKeZOBAjdEgX6cXBk15scoM2Vj9ENox+MKZLaKRawXg2U1ujK+bWAQkXiVvPriv05/JtYsNUft8qAsm+69vtohDsUW7Wu0bBIKDL+v0W30ty1PpyNehBB2OquUE7fp53gitOmYl7TyuxktkMY8PXKKU=
|
||||
- secure: NABCfigMUVM/9TLALYBpQLp/p3rG6MbH5y34/oqCSej/oh2u0nyhFSi8veS0lFpDIcv0TZvxHJXoSA0zeZijb1fUU8jYVNT2azuPWE6Gu7sf0TfBeCvulqbgLMoaA6JuWbEbZwHcxpKHg4vLSMjNk+ZP4v2dffI6A620fxLltxxhTpsYkYYsfKG857CpQtdgN/HqcOaxyvzXFmWWyVWHala1uMcMeXZCwgnlVAqau9o0bsU092txSmHqoesHoAinidSCTCmTlEqp/1AFaYQTbxmnfNC1yLgzxWAlJcS3NWzNo3ellMvKjsiIGn3JJpAjTGcyf3yPsvhs1cY3MZbmJYVyKH5HbnkA5ms6mx0DDJ2UOs5H2dmED82m14+hu62Xb8XN8zAdq+bySNSwgsGzvr1PT74pT4BW1T+D7L1xvUe6k1enZ38GIMJbJPhBybRQazhjKPmXRB30Thxoqe5VqU8UeiXHAEps7JYAWUR1PLZvEYQb6MWurmTxs9be/OTwrUT1LDiqeJZg6XkDGgQwuR2YBaQJHJD17Piq6q1BUX8abhK6wzAAYVqyGvpmUCmQCtHZgenE6ulwcKChzBv4n97OjE21LGWnbNF5ViUhfAbGcKOVufd1chZsfbkJ7a3tHYCfLnxHUIhKvHk26f5Em8h68D0wQkPnkcVVkfh7XpI=
|
||||
- secure: C93UADV5aR0zhLCLwR6tCyz+fwUYslZbhjBl3PHQp+0boIGS/Be2UQTFHp/NB9mQmhWqbwqHoAVFENZFytV04ePgOuNtMFcjAIfnzm19Am7iRAMFixD45pF/CuYYfLupElkAcSequtAzN0g4G0sQ5KR1hibaDIoz9kfA2YcUAMaZ4T5bhCr8os/xA2nOlmvPDWsRWCFBYkSpnmbsSsgYAhulA/V5bSNAWnp9LPw3CBLibW3WsVP4wuhZAkXznKwn/mHT31kfQlpMH3qNhXpsN9huUkZ/k8QWeakcHJKugung0Z2T1StK8rlI8OrJstVcwueHTa2ses4f5VbhWog/Z8HDkdll9W9RM/QqXjNDtOVBt/SPuhCp4k2rvJixFUxzvSqgSWQvQnbHwjWxIUVVyHtnb0/zc/S9ONZG14TOwB/+Lkgacb85PNszurZ2f3mH0O6slIh1mH+5d9J4+L976ot4nTPlK1OtothagVyKGOrn9HycrPk/MjftIJuElHzo7NEJd/wRPqIb5y12iZN1RSPriU+itg1uSAVP891/o3peJyuqY9WSB7dYwgDJos6dDvbr19emtdyxkQR+eAb5duyH6s4R58wh1kJ1d4zu0X6uSnF4AIc+6teKkN24rSXcqB/hrcojS49jgLy5P0/CVsUbYZPI/tx8E/IJfr8m36E=
|
||||
- secure: mawwBvllvESc/mp+JHvncq1iUhiC7nyokPgXmOehffc0K3byMLs2L25HjNsU6EnXG9Lcae1cfP8S9bWLquU2C3kpAkLBUpjEbdx7K0654uvs7Rrvb5hcTRHwjzaEVmVaBFX4ROcjUhBYny/Wjj/YENCkSWpkfcMd1esFbVsO+fOLyaAPvrb6auKY7H+pUSqlEwaEnrkYeBBZIHa7KqwL4g5DHbq6K368tjmval/wBzwMB0V8V3dt/ik8RMVDtKPrik4Bu0V9UmXZUIo/a06ii/CM82ekFRh3eUb0DKkdkmYbdH6MBMoLTfQtMa6A4luXaA0oycAnTX3OGB5MWIjK39KhWRavh6ybSIt4aHKoolxzH8Zgmk7xMhFSot/laX5q5IzjZu5KU6F2SmdV0kcQugM8oAjANFySetPvY1q7nZ8pM+NO1xKS/mH0w4vChhdJFD1mw7aCoh8FdeUf0Eym2+pp5Q9uAisWMmNn5XN8/fL5q6PzAxkXmkedfrr1N61FmIL6EKx8qiWpOUNlRRTIMJ4GMhCyckCF6cNxDkBItp52c+Hmkbn+ZEInEyX6gpjYVm3xyEi0Z5kLCi/fMX2nBNczc5BuGLzzmJnITv4ovpeYn2/vPvHbaPgPC4LqDK3AjlpVadMZk/M5Egn+hWY7Mni57CmpZD+SpxUbbsItI0c=
|
||||
- secure: PJPDkUg1zc57brxUvNpSh+Q3ZEaGpBqZzwDavqslkn0WmjBTLrE6/OG7TFHKNmO+P56qFl+pMEKqThxqR3+4bWEeEx8ykkixDVzxNJMmws+7A7ImJ75iQyB6giMW/4tykVMMHgIPNAdcnI8VOWn0LGHnpFWUd70yoyAGX8s6cspHCKgcuWMA3GS410KJfHpyd0B9/QS7ZyWzSETW7zSPyLPa81SBO95EhOF3TOGZYLt/mBhdtU3YGFs4k9fZ8jDDcm9XmBfqVlUhb8HiZcxJiZDdRvxODERfNnwc47uaJk6+kxGDzIW2uAxrMXXVKkG04GeMOokXoR9kW1Hl2JmoyySLKLZmB7I/XEtVWdzZw16mWi+4zmhjLhfB0phSW+/5I+0VtZZ6jO031J5FL/JqVrcq1ws/aw4QlaOdPUco/x2u4LNHyYYgOi5arD9xSyu6IRy0jCC4Xa1zuqM5adGJX+rZyVfKZ0TxOW661HTxlo8COtkB2i0WR2deZGVN75ooCAEO8DauQoUcFH1OelahmPtzVs1/6ZczuxGdp9ED7ZQq9NHEOsOdUGCj/D79Dm1hWFQsIsslnnGYWitAycNCgEwmlt2Q6fbrv2CJrmLqZ9a9r3AhzxoHn9Qx1GyuyfhZJzm/6Ff2kcOjma2kcz13KUwTxdW+2G5dDCotK3f7aiI=
|
||||
- secure: FIIZfEEYfjNMKODs33Czh603CYVn6LRrzpFNIiPHYTb8iQWv9qAYhsg4FpHfOjDikokTwb5X/h8G7AX93Z0xKyyDi75ACT11oPeTNTArDdcmdDVlOYBvYHc2Ci7pMW5r8LGejB7Y3mWM8uKyA3oKvneEFutB65vO3JVZvFWrm03Lmqqe7+mA4qNqNqTbN7R7fmk5b7zt7A3DHvDu0JPTbSSUwpso/p2I5WJYjrf71I7YMQwIFLoMfplC1onVA3EFS3lZsF65zE+xVRy34AKa41iZAMbhVDyqUHEnx6L0dwEdn2Z5XLlK0ov1+qLTLlQsBE4Knre6TNkWMfktk7MKA+ch8RYxvEYLODhQkIrOkLSNWhZPhdaT+xD4fr0RCKSHo6uWRC4aofsJx8wSqb8ZL4j2zopUp9VisMOI202UEnvFDBtOkVGJSxxYbFjifIB7NCJBn788w+3k+k4IbOg537VdyoK2PMBR8/TDdjImWhWHY1i7+345ejwmzHL7ZPfb6GTNnQTWkajT77/n6Yk41twR5vvegOSTKuuO++WN/pUks4PGqtcQe9fnSfx2OcOq1ofLiG+JDorJ7z8kHSG13wHLq+QYMDayQbyJEYpDzmn/w3Ou1s2o0a7A41+cIkRzAgH9y3v4lgjp9GcMP2S74ZPA7OecWbFSexM7tL/dYxY=
|
||||
- secure: DKCGc4E9PKeTX68r9pbbNg5qITsN0bApQ1m0x8xdEoi8GLRKVMYNn6ahoAxvy1YsBXC9Zlt5++gLmUV1I1JyDMyJXMr/lZrp4oarW0xWpTBmn3HzOph/K2W4i/fTGgMFieumPEbQIFOnU3JSjK6UJB8qVGEXD2OqS7A//EdrGDbAYVDL3ZTKE6JUlTNHgaKaNHhn+Dq4aBLTSYPwlLyqo+WNBVUUCKCHOq62ULF8MpX5YGaPFNxKYzircV7HpF1hCbV31dmpkeYT9xztra5V0SIBM27jAcQqGmtHH2mhx1sLu+gjhFJbbtY6cggA9EedzYYLDx/NPmgfyuOJfyVbSwTF3vhDUYfskqc1THWpwOSKO0Ry+8/xYb9crxg+FSwuI5hnfkIFk9woBvRGBhjto3/1buMNY9dSFiWtEbN6Let8e747l0wIGJCpJxSeh7vn7F1mWjixhf9GX1+V9BrUvGTd3XJDNb9cVnafYa1RTj8BLteA4HBza7Z9R3dvG4YWp16L/94UuaTzgAQfERLTZGopQth/hsaVTlYesJmJLF70lGM+W83y3YuNkSaX1zQ5FAIvp7oH0O16t7ISm6GprUFwN2Uox7AAbPZdWHxJbly+D+yCFNcqS3Bz9mV3YCLo690Sy1ePNHr+nCseVfBMo7OYyavSS/EjPWfEy65Wq04=
|
||||
|
20
.travis/build.sh
Executable file
@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
|
||||
git fetch --unshallow #required for commit count
|
||||
|
||||
if [ -z "$TRAVIS_TAG" ]; then
|
||||
./gradlew clean assembleStandardDebug
|
||||
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
export ARTIFACT="tachiyomi-r${COMMIT_COUNT}.apk"
|
||||
|
||||
mv app/build/outputs/apk/standard/debug/app-standard-debug.apk $ARTIFACT
|
||||
else
|
||||
./gradlew clean assembleStandardRelease
|
||||
|
||||
TOOLS="$(ls -d ${ANDROID_HOME}/build-tools/* | tail -1)"
|
||||
export ARTIFACT="tachiyomi-${TRAVIS_TAG}.apk"
|
||||
|
||||
${TOOLS}/zipalign -v -p 4 app/build/outputs/apk/standard/release/app-standard-release-unsigned.apk app-aligned.apk
|
||||
${TOOLS}/apksigner sign --ks $STORE_PATH --ks-key-alias $STORE_ALIAS --ks-pass env:STORE_PASS --key-pass env:KEY_PASS --out $ARTIFACT app-aligned.apk
|
||||
fi
|
15
.travis/deploy.sh
Executable file
@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
pattern="tachiyomi-r*"
|
||||
files=( $pattern )
|
||||
export ARTIFACT="${files[0]}"
|
||||
|
||||
if [ -z "$ARTIFACT" ]; then
|
||||
echo "Artifact not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export SSHOPTIONS="-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i ${DEPLOY_KEY}"
|
||||
|
||||
scp $SSHOPTIONS $ARTIFACT $DEPLOY_USER@$DEPLOY_HOST:builds/
|
||||
ssh $SSHOPTIONS $DEPLOY_USER@$DEPLOY_HOST ln -sf $ARTIFACT builds/latest
|
BIN
.travis/secrets.tar.enc
Normal file
71
README.md
@ -1,25 +1,70 @@
|
||||
| Build | Download | F-Droid | Contribute | Contact |
|
||||
|-------|----------|---------|------------|---------|
|
||||
| [](https://teamcity.kanade.eu/project.html?projectId=tachiyomi) [](https://travis-ci.org/inorichi/tachiyomi) | [](https://github.com/inorichi/tachiyomi/releases) [](http://tachiyomi.kanade.eu/latest/app-debug.apk) | [](https://f-droid.org/repository/browse/?fdid=eu.kanade.tachiyomi) [](//github.com/inorichi/tachiyomi/wiki/FDroid-for-dev-versions) | [](https://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) | [](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/2dDQBv2) |
|
||||
|
||||
### **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.
|
||||
|
||||
Keep in mind it's still a beta, so expect it to crash sometimes.
|
||||

|
||||
|
||||
# Features
|
||||
## Features
|
||||
|
||||
* Online and offline reading
|
||||
* Configurable reader with multiple viewers and settings
|
||||
* MyAnimeList support
|
||||
* Resume from the next unread chapter
|
||||
* Chapter filtering
|
||||
* Schedule searching for updates
|
||||
Features include:
|
||||
* Online reading from sources such as KissManga, MangaFox, [and more](https://github.com/inorichi/tachiyomi-extensions)
|
||||
* Local reading of downloaded manga
|
||||
* Configurable reader with multiple viewers, reading directions and other settings
|
||||
* MyAnimeList, AniList, and Kitsu support
|
||||
* Categories to organize your library
|
||||
* Light and dark themes
|
||||
* Schedule updating your library for new chapters
|
||||
* Create backups locally or to your cloud service of choice
|
||||
|
||||
## Download
|
||||
Get the app from our [releases page](https://github.com/inorichi/tachiyomi/releases) or from [f-droid](https://f-droid.org/packages/eu.kanade.tachiyomi/).
|
||||
|
||||
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/FDroid-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/WrBkRk4)
|
||||
|
||||
</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
|
||||
|
||||
|
212
app/build.gradle
@ -3,10 +3,10 @@ import java.text.SimpleDateFormat
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'com.github.zellius.shortcut-helper'
|
||||
|
||||
if (file("custom.gradle").exists()) {
|
||||
apply from: "custom.gradle"
|
||||
}
|
||||
shortcutHelper.filePath = './shortcuts.xml'
|
||||
|
||||
ext {
|
||||
// Git is needed in your system PATH for these commands to work.
|
||||
@ -29,17 +29,17 @@ ext {
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion 25
|
||||
buildToolsVersion "25.0.2"
|
||||
compileSdkVersion 27
|
||||
buildToolsVersion '27.0.3'
|
||||
publishNonDefault true
|
||||
|
||||
defaultConfig {
|
||||
applicationId "eu.kanade.tachiyomi"
|
||||
minSdkVersion 16
|
||||
targetSdkVersion 25
|
||||
targetSdkVersion 27
|
||||
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
||||
versionCode 25
|
||||
versionName "0.6.2"
|
||||
versionCode 35
|
||||
versionName "0.7.2"
|
||||
|
||||
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
|
||||
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
|
||||
@ -67,15 +67,20 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions "default"
|
||||
|
||||
productFlavors {
|
||||
standard {
|
||||
buildConfigField "boolean", "INCLUDE_UPDATER", "true"
|
||||
dimension "default"
|
||||
}
|
||||
fdroid {
|
||||
dimension "default"
|
||||
}
|
||||
dev {
|
||||
minSdkVersion 21
|
||||
resConfigs "en", "xxhdpi"
|
||||
dimension "default"
|
||||
}
|
||||
}
|
||||
|
||||
@ -97,127 +102,137 @@ android {
|
||||
dependencies {
|
||||
|
||||
// Modified dependencies
|
||||
compile 'com.github.inorichi:subsampling-scale-image-view:01e5385'
|
||||
compile 'com.github.inorichi:tachimage:68cd311'
|
||||
compile 'com.github.inorichi:junrar-android:634c1f5'
|
||||
implementation 'com.github.inorichi:subsampling-scale-image-view:81b9d68'
|
||||
implementation 'com.github.inorichi:junrar-android:634c1f5'
|
||||
|
||||
// Android support library
|
||||
final support_library_version = '25.4.0'
|
||||
compile "com.android.support:support-v4:$support_library_version"
|
||||
compile "com.android.support:appcompat-v7:$support_library_version"
|
||||
compile "com.android.support:cardview-v7:$support_library_version"
|
||||
compile "com.android.support:design:$support_library_version"
|
||||
compile "com.android.support:recyclerview-v7:$support_library_version"
|
||||
compile "com.android.support:support-annotations:$support_library_version"
|
||||
compile "com.android.support:customtabs:$support_library_version"
|
||||
final support_library_version = '27.0.2'
|
||||
implementation "com.android.support:support-v4:$support_library_version"
|
||||
implementation "com.android.support:appcompat-v7:$support_library_version"
|
||||
implementation "com.android.support:cardview-v7:$support_library_version"
|
||||
implementation "com.android.support:design:$support_library_version"
|
||||
implementation "com.android.support:recyclerview-v7:$support_library_version"
|
||||
implementation "com.android.support:preference-v7:$support_library_version"
|
||||
implementation "com.android.support:support-annotations:$support_library_version"
|
||||
implementation "com.android.support:customtabs:$support_library_version"
|
||||
|
||||
compile 'com.android.support.constraint:constraint-layout:1.0.2'
|
||||
implementation 'com.android.support.constraint:constraint-layout:1.1.0-beta6'
|
||||
|
||||
compile 'com.android.support:multidex:1.0.1'
|
||||
implementation 'com.android.support:multidex:1.0.2'
|
||||
|
||||
// ReactiveX
|
||||
compile 'io.reactivex:rxandroid:1.2.1'
|
||||
compile 'io.reactivex:rxjava:1.3.0'
|
||||
compile 'com.jakewharton.rxrelay:rxrelay:1.2.0'
|
||||
compile 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
|
||||
compile 'com.github.pwittchen:reactivenetwork:0.7.0'
|
||||
implementation 'io.reactivex:rxandroid:1.2.1'
|
||||
implementation 'io.reactivex:rxjava:1.3.6'
|
||||
implementation 'com.jakewharton.rxrelay:rxrelay:1.2.0'
|
||||
implementation 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
|
||||
implementation 'com.github.pwittchen:reactivenetwork:0.7.0'
|
||||
|
||||
// Network client
|
||||
compile "com.squareup.okhttp3:okhttp:3.8.1"
|
||||
compile 'com.squareup.okio:okio:1.13.0'
|
||||
implementation "com.squareup.okhttp3:okhttp:3.9.1"
|
||||
implementation 'com.squareup.okio:okio:1.14.0'
|
||||
|
||||
// REST
|
||||
final retrofit_version = '2.3.0'
|
||||
compile "com.squareup.retrofit2:retrofit:$retrofit_version"
|
||||
compile "com.squareup.retrofit2:converter-gson:$retrofit_version"
|
||||
compile "com.squareup.retrofit2:adapter-rxjava:$retrofit_version"
|
||||
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
|
||||
implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"
|
||||
implementation "com.squareup.retrofit2:adapter-rxjava:$retrofit_version"
|
||||
|
||||
// JSON
|
||||
compile 'com.google.code.gson:gson:2.8.1'
|
||||
compile 'com.github.salomonbrys.kotson:kotson:2.5.0'
|
||||
|
||||
// YAML
|
||||
compile 'com.github.bmoliveira:snake-yaml:v1.18-android'
|
||||
implementation 'com.google.code.gson:gson:2.8.2'
|
||||
implementation 'com.github.salomonbrys.kotson:kotson:2.5.0'
|
||||
|
||||
// JavaScript engine
|
||||
compile 'com.squareup.duktape:duktape-android:1.1.0'
|
||||
implementation 'com.squareup.duktape:duktape-android:1.2.0'
|
||||
|
||||
// Disk
|
||||
compile 'com.jakewharton:disklrucache:2.0.2'
|
||||
compile 'com.github.seven332:unifile:1.0.0'
|
||||
implementation 'com.jakewharton:disklrucache:2.0.2'
|
||||
implementation 'com.github.inorichi:unifile:e9ee588'
|
||||
|
||||
// HTML parser
|
||||
compile 'org.jsoup:jsoup:1.10.2'
|
||||
implementation 'org.jsoup:jsoup:1.10.2'
|
||||
|
||||
// Job scheduling
|
||||
compile 'com.evernote:android-job:1.1.11'
|
||||
compile 'com.google.android.gms:play-services-gcm:11.0.1'
|
||||
implementation 'com.evernote:android-job:1.2.4'
|
||||
implementation 'com.google.android.gms:play-services-gcm:11.8.0'
|
||||
|
||||
// Changelog
|
||||
compile 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
|
||||
implementation 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
|
||||
|
||||
// Database
|
||||
compile "com.pushtorefresh.storio:sqlite:1.13.0"
|
||||
implementation "com.pushtorefresh.storio:sqlite:1.13.0"
|
||||
|
||||
// Model View Presenter
|
||||
final nucleus_version = '3.0.0'
|
||||
compile "info.android15.nucleus:nucleus:$nucleus_version"
|
||||
compile "info.android15.nucleus:nucleus-support-v7:$nucleus_version"
|
||||
implementation "info.android15.nucleus:nucleus:$nucleus_version"
|
||||
implementation "info.android15.nucleus:nucleus-support-v7:$nucleus_version"
|
||||
|
||||
// Dependency injection
|
||||
compile "uy.kohesive.injekt:injekt-core:1.16.1"
|
||||
implementation "com.github.inorichi.injekt:injekt-core:65b0440"
|
||||
|
||||
// Image library
|
||||
compile 'com.github.bumptech.glide:glide:3.8.0'
|
||||
compile 'com.github.bumptech.glide:okhttp3-integration:1.5.0@aar'
|
||||
final glide_version = '4.6.1'
|
||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||
implementation "com.github.bumptech.glide:okhttp3-integration:$glide_version"
|
||||
kapt "com.github.bumptech.glide:compiler:$glide_version"
|
||||
|
||||
// Transformations
|
||||
compile 'jp.wasabeef:glide-transformations:2.0.2'
|
||||
implementation 'jp.wasabeef:glide-transformations:3.1.1'
|
||||
|
||||
// Logging
|
||||
compile 'com.jakewharton.timber:timber:4.5.1'
|
||||
implementation 'com.jakewharton.timber:timber:4.6.1'
|
||||
|
||||
// Crash reports
|
||||
compile 'ch.acra:acra:4.9.2'
|
||||
implementation 'ch.acra:acra:4.9.2'
|
||||
|
||||
// Sort
|
||||
compile 'com.github.gpanther:java-nat-sort:natural-comparator-1.1'
|
||||
implementation 'com.github.gpanther:java-nat-sort:natural-comparator-1.1'
|
||||
|
||||
// UI
|
||||
compile 'com.dmitrymalkovich.android:material-design-dimens:1.4'
|
||||
compile 'com.github.dmytrodanylyk.android-process-button:library:1.0.4'
|
||||
compile 'eu.davidea:flexible-adapter:5.0.0-rc1'
|
||||
compile 'com.nononsenseapps:filepicker:2.5.2'
|
||||
compile 'com.github.amulyakhare:TextDrawable:558677e'
|
||||
compile 'com.afollestad.material-dialogs:core:0.9.4.5'
|
||||
compile 'me.zhanghai.android.systemuihelper:library:1.0.0'
|
||||
compile 'com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.0.4'
|
||||
implementation 'com.dmitrymalkovich.android:material-design-dimens:1.4'
|
||||
implementation 'com.github.dmytrodanylyk.android-process-button:library:1.0.4'
|
||||
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.github.amulyakhare:TextDrawable:558677e'
|
||||
implementation 'com.afollestad.material-dialogs:core:0.9.6.0'
|
||||
implementation 'me.zhanghai.android.systemuihelper:library:1.0.0'
|
||||
implementation 'com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.0.4'
|
||||
implementation 'com.github.mthli:Slice:v1.2'
|
||||
implementation 'me.gujun.android.taggroup:library:1.4@aar'
|
||||
|
||||
// Conductor
|
||||
compile "com.bluelinelabs:conductor:2.1.4"
|
||||
compile 'com.github.inorichi:conductor-support-preference:9e36460'
|
||||
implementation "com.github.inorichi.Conductor:conductor:05c4d4d"
|
||||
implementation ("com.bluelinelabs:conductor-support:2.1.5-SNAPSHOT") {
|
||||
exclude group: "com.bluelinelabs", module: "conductor"
|
||||
}
|
||||
implementation 'com.github.inorichi:conductor-support-preference:27.0.2'
|
||||
|
||||
// RxBindings
|
||||
final rxbindings_version = '1.0.1'
|
||||
compile "com.jakewharton.rxbinding:rxbinding-kotlin:$rxbindings_version"
|
||||
compile "com.jakewharton.rxbinding:rxbinding-appcompat-v7-kotlin:$rxbindings_version"
|
||||
compile "com.jakewharton.rxbinding:rxbinding-support-v4-kotlin:$rxbindings_version"
|
||||
compile "com.jakewharton.rxbinding:rxbinding-recyclerview-v7-kotlin:$rxbindings_version"
|
||||
implementation "com.jakewharton.rxbinding:rxbinding-kotlin:$rxbindings_version"
|
||||
implementation "com.jakewharton.rxbinding:rxbinding-appcompat-v7-kotlin:$rxbindings_version"
|
||||
implementation "com.jakewharton.rxbinding:rxbinding-support-v4-kotlin:$rxbindings_version"
|
||||
implementation "com.jakewharton.rxbinding:rxbinding-recyclerview-v7-kotlin:$rxbindings_version"
|
||||
|
||||
// Tests
|
||||
testCompile 'junit:junit:4.12'
|
||||
testCompile 'org.assertj:assertj-core:1.7.1'
|
||||
testCompile 'org.mockito:mockito-core:1.10.19'
|
||||
testImplementation 'junit:junit:4.12'
|
||||
testImplementation 'org.assertj:assertj-core:1.7.1'
|
||||
testImplementation 'org.mockito:mockito-core:1.10.19'
|
||||
|
||||
final robolectric_version = '3.1.4'
|
||||
testCompile "org.robolectric:robolectric:$robolectric_version"
|
||||
testCompile "org.robolectric:shadows-multidex:$robolectric_version"
|
||||
testCompile "org.robolectric:shadows-play-services:$robolectric_version"
|
||||
testImplementation "org.robolectric:robolectric:$robolectric_version"
|
||||
testImplementation "org.robolectric:shadows-multidex:$robolectric_version"
|
||||
testImplementation "org.robolectric:shadows-play-services:$robolectric_version"
|
||||
|
||||
compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
|
||||
final coroutines_version = '0.22.2'
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
|
||||
}
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.1.4'
|
||||
ext.kotlin_version = '1.2.30'
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
@ -230,49 +245,12 @@ repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
// Workaround to force a support lib version
|
||||
configurations.all {
|
||||
resolutionStrategy.eachDependency { details ->
|
||||
def requested = details.requested
|
||||
if (requested.group == 'com.android.support') {
|
||||
if (!requested.name.startsWith("multidex")) {
|
||||
details.useVersion '25.4.0'
|
||||
}
|
||||
}
|
||||
kotlin {
|
||||
experimental {
|
||||
coroutines 'enable'
|
||||
}
|
||||
}
|
||||
|
||||
// add support for placeholders in resource files
|
||||
//https://code.google.com/p/android/issues/detail?id=69224
|
||||
def replacePlaceholdersInFile(basePath, fileName, placeholders) {
|
||||
def file = new File(basePath, fileName);
|
||||
|
||||
if (!file.exists()) {
|
||||
logger.quiet("Unable to replace placeholders in " + file.toString() + ". File cannot be found.")
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Replacing placeholders in " + file.toString())
|
||||
logger.debug("Placeholders: " + placeholders.toString())
|
||||
|
||||
def content = file.getText('UTF-8')
|
||||
|
||||
placeholders.each { entry ->
|
||||
content = content.replaceAll("\\\$\\{${entry.key}\\}", entry.value)
|
||||
}
|
||||
|
||||
file.write(content, 'UTF-8')
|
||||
androidExtensions {
|
||||
experimental = true
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
android.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
output.processResources.doFirst {
|
||||
// prepare placeholder map from manifestPlaceholders including applicationId placeholder
|
||||
def placeholders = variant.mergedFlavor.manifestPlaceholders + [applicationId: variant.applicationId]
|
||||
|
||||
replacePlaceholdersInFile(resDir, 'xml-v25/shortcuts.xml', placeholders)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
2
app/proguard-rules.pro
vendored
@ -6,6 +6,7 @@
|
||||
|
||||
-keep class com.hippo.image.** { *; }
|
||||
-keep interface com.hippo.image.** { *; }
|
||||
-dontwarn nucleus.view.NucleusActionBarActivity
|
||||
|
||||
# Extensions may require methods unused in the core app
|
||||
-keep class org.jsoup.** { *; }
|
||||
@ -23,6 +24,7 @@
|
||||
# Glide specific rules #
|
||||
# https://github.com/bumptech/glide
|
||||
-keep public class * implements com.bumptech.glide.module.GlideModule
|
||||
-keep public class * extends com.bumptech.glide.AppGlideModule
|
||||
-keep public enum com.bumptech.glide.load.resource.bitmap.ImageHeaderParser$** {
|
||||
**[] $VALUES;
|
||||
public *;
|
||||
|
@ -9,8 +9,7 @@
|
||||
android:shortcutShortLabel="@string/label_library">
|
||||
<intent
|
||||
android:action="eu.kanade.tachiyomi.SHOW_LIBRARY"
|
||||
android:targetClass="eu.kanade.tachiyomi.ui.main.MainActivity"
|
||||
android:targetPackage="${applicationId}" />
|
||||
android:targetClass="eu.kanade.tachiyomi.ui.main.MainActivity" />
|
||||
</shortcut>
|
||||
<shortcut
|
||||
android:enabled="true"
|
||||
@ -21,8 +20,7 @@
|
||||
android:shortcutShortLabel="@string/short_recent_updates">
|
||||
<intent
|
||||
android:action="eu.kanade.tachiyomi.SHOW_RECENTLY_UPDATED"
|
||||
android:targetClass="eu.kanade.tachiyomi.ui.main.MainActivity"
|
||||
android:targetPackage="${applicationId}" />
|
||||
android:targetClass="eu.kanade.tachiyomi.ui.main.MainActivity" />
|
||||
</shortcut>
|
||||
<shortcut
|
||||
android:enabled="true"
|
||||
@ -33,8 +31,7 @@
|
||||
android:shortcutShortLabel="@string/label_recent_manga">
|
||||
<intent
|
||||
android:action="eu.kanade.tachiyomi.SHOW_RECENTLY_READ"
|
||||
android:targetClass="eu.kanade.tachiyomi.ui.main.MainActivity"
|
||||
android:targetPackage="${applicationId}" />
|
||||
android:targetClass="eu.kanade.tachiyomi.ui.main.MainActivity" />
|
||||
</shortcut>
|
||||
<shortcut
|
||||
android:enabled="true"
|
||||
@ -45,7 +42,6 @@
|
||||
android:shortcutShortLabel="@string/label_catalogues">
|
||||
<intent
|
||||
android:action="eu.kanade.tachiyomi.SHOW_CATALOGUES"
|
||||
android:targetClass="eu.kanade.tachiyomi.ui.main.MainActivity"
|
||||
android:targetPackage="${applicationId}" />
|
||||
android:targetClass="eu.kanade.tachiyomi.ui.main.MainActivity" />
|
||||
</shortcut>
|
||||
</shortcuts>
|
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 |
@ -8,6 +8,7 @@
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
|
||||
|
||||
<application
|
||||
@ -24,11 +25,11 @@
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts"/>
|
||||
|
||||
<!--suppress AndroidDomInspection -->
|
||||
<meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts"/>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.reader.ReaderActivity"
|
||||
@ -51,6 +52,9 @@
|
||||
android:scheme="tachiyomi" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".extension.util.ExtensionInstallActivity"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar"/>
|
||||
|
||||
<provider
|
||||
android:name="android.support.v4.content.FileProvider"
|
||||
@ -85,7 +89,7 @@
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".data.updater.UpdateDownloaderService"
|
||||
android:name=".data.updater.UpdaterService"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
@ -96,10 +100,6 @@
|
||||
android:name=".data.backup.BackupRestoreService"
|
||||
android:exported="false"/>
|
||||
|
||||
<meta-data
|
||||
android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule"
|
||||
android:value="GlideModule" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
@ -7,7 +7,8 @@ import android.support.multidex.MultiDex
|
||||
import com.evernote.android.job.JobManager
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreatorJob
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
||||
import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.data.updater.UpdaterJob
|
||||
import eu.kanade.tachiyomi.util.LocaleHelper
|
||||
import org.acra.ACRA
|
||||
import org.acra.annotation.ReportsCrashes
|
||||
@ -27,13 +28,14 @@ open class App : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
|
||||
|
||||
Injekt = InjektScope(DefaultRegistrar())
|
||||
Injekt.importModule(AppModule(this))
|
||||
|
||||
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
|
||||
|
||||
setupAcra()
|
||||
setupJobManager()
|
||||
setupNotificationChannels()
|
||||
|
||||
LocaleHelper.updateConfiguration(this, resources.configuration)
|
||||
}
|
||||
@ -58,11 +60,15 @@ open class App : Application() {
|
||||
JobManager.create(this).addJobCreator { tag ->
|
||||
when (tag) {
|
||||
LibraryUpdateJob.TAG -> LibraryUpdateJob()
|
||||
UpdateCheckerJob.TAG -> UpdateCheckerJob()
|
||||
UpdaterJob.TAG -> UpdaterJob()
|
||||
BackupCreatorJob.TAG -> BackupCreatorJob()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun setupNotificationChannels() {
|
||||
Notifications.createChannels(this)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -8,34 +8,55 @@ import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import uy.kohesive.injekt.api.InjektModule
|
||||
import uy.kohesive.injekt.api.InjektRegistrar
|
||||
import uy.kohesive.injekt.api.addSingletonFactory
|
||||
import rx.Observable
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.api.*
|
||||
|
||||
class AppModule(val app: Application) : InjektModule {
|
||||
|
||||
override fun InjektRegistrar.registerInjectables() {
|
||||
|
||||
addSingletonFactory { PreferencesHelper(app) }
|
||||
addSingleton(app)
|
||||
|
||||
addSingletonFactory { DatabaseHelper(app) }
|
||||
addSingletonFactory { PreferencesHelper(app) }
|
||||
|
||||
addSingletonFactory { ChapterCache(app) }
|
||||
addSingletonFactory { DatabaseHelper(app) }
|
||||
|
||||
addSingletonFactory { CoverCache(app) }
|
||||
addSingletonFactory { ChapterCache(app) }
|
||||
|
||||
addSingletonFactory { NetworkHelper(app) }
|
||||
addSingletonFactory { CoverCache(app) }
|
||||
|
||||
addSingletonFactory { SourceManager(app) }
|
||||
addSingletonFactory { NetworkHelper(app) }
|
||||
|
||||
addSingletonFactory { DownloadManager(app) }
|
||||
addSingletonFactory { SourceManager(app).also { get<ExtensionManager>().init(it) } }
|
||||
|
||||
addSingletonFactory { TrackManager(app) }
|
||||
addSingletonFactory { ExtensionManager(app) }
|
||||
|
||||
addSingletonFactory { Gson() }
|
||||
addSingletonFactory { DownloadManager(app) }
|
||||
|
||||
addSingletonFactory { TrackManager(app) }
|
||||
|
||||
addSingletonFactory { Gson() }
|
||||
|
||||
// Asynchronously init expensive components for a faster cold start
|
||||
|
||||
rxAsync { get<PreferencesHelper>() }
|
||||
|
||||
rxAsync { get<NetworkHelper>() }
|
||||
|
||||
rxAsync { get<SourceManager>() }
|
||||
|
||||
rxAsync { get<DatabaseHelper>() }
|
||||
|
||||
rxAsync { get<DownloadManager>() }
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
private fun rxAsync(block: () -> Unit) {
|
||||
Observable.fromCallable { block() }.subscribeOn(Schedulers.computation()).subscribe()
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,10 +0,0 @@
|
||||
package eu.kanade.tachiyomi
|
||||
|
||||
object Constants {
|
||||
const val NOTIFICATION_LIBRARY_PROGRESS_ID = 1
|
||||
const val NOTIFICATION_LIBRARY_RESULT_ID = 2
|
||||
const val NOTIFICATION_UPDATER_ID = 3
|
||||
const val NOTIFICATION_DOWNLOAD_CHAPTER_ID = 4
|
||||
const val NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID = 5
|
||||
const val NOTIFICATION_DOWNLOAD_IMAGE_ID = 6
|
||||
}
|
@ -3,7 +3,7 @@ package eu.kanade.tachiyomi
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob
|
||||
import eu.kanade.tachiyomi.data.updater.UpdaterJob
|
||||
import java.io.File
|
||||
|
||||
object Migrations {
|
||||
@ -25,7 +25,7 @@ object Migrations {
|
||||
if (oldVersion < 14) {
|
||||
// Restore jobs after upgrading to evernote's job scheduler.
|
||||
if (BuildConfig.INCLUDE_UPDATER && preferences.automaticUpdates()) {
|
||||
UpdateCheckerJob.setupTask()
|
||||
UpdaterJob.setupTask()
|
||||
}
|
||||
LibraryUpdateJob.setupTask()
|
||||
}
|
||||
@ -45,6 +45,16 @@ object Migrations {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion < 26) {
|
||||
// Delete external chapter cache dir.
|
||||
val extCache = context.externalCacheDir
|
||||
if (extCache != null) {
|
||||
val chapterCache = File(extCache, "chapter_disk_cache")
|
||||
if (chapterCache.exists()) {
|
||||
chapterCache.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
@ -4,17 +4,8 @@ import android.app.IntentService
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.github.salomonbrys.kotson.set
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGAS
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.util.sendLocalBroadcast
|
||||
import timber.log.Timber
|
||||
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
||||
|
||||
/**
|
||||
@ -26,8 +17,6 @@ class BackupCreateService : IntentService(NAME) {
|
||||
// Name of class
|
||||
private const val NAME = "BackupCreateService"
|
||||
|
||||
// Backup called from job
|
||||
private const val EXTRA_IS_JOB = "$ID.$NAME.EXTRA_IS_JOB"
|
||||
// Options for backup
|
||||
private const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS"
|
||||
|
||||
@ -46,14 +35,12 @@ class BackupCreateService : IntentService(NAME) {
|
||||
* Make a backup from library
|
||||
*
|
||||
* @param context context of application
|
||||
* @param path path of Uri
|
||||
* @param uri path of Uri
|
||||
* @param flags determines what to backup
|
||||
* @param isJob backup called from job
|
||||
*/
|
||||
fun makeBackup(context: Context, uri: Uri, flags: Int, isJob: Boolean = false) {
|
||||
fun makeBackup(context: Context, uri: Uri, flags: Int) {
|
||||
val intent = Intent(context, BackupCreateService::class.java).apply {
|
||||
putExtra(BackupConst.EXTRA_URI, uri)
|
||||
putExtra(EXTRA_IS_JOB, isJob)
|
||||
putExtra(EXTRA_FLAGS, flags)
|
||||
}
|
||||
context.startService(intent)
|
||||
@ -68,97 +55,9 @@ class BackupCreateService : IntentService(NAME) {
|
||||
|
||||
// Get values
|
||||
val uri = intent.getParcelableExtra<Uri>(BackupConst.EXTRA_URI)
|
||||
val isJob = intent.getBooleanExtra(EXTRA_IS_JOB, false)
|
||||
val flags = intent.getIntExtra(EXTRA_FLAGS, 0)
|
||||
// Create backup
|
||||
createBackupFromApp(uri, flags, isJob)
|
||||
backupManager.createBackup(uri, flags, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create backup Json file from database
|
||||
*
|
||||
* @param uri path of Uri
|
||||
* @param isJob backup called from job
|
||||
*/
|
||||
fun createBackupFromApp(uri: Uri, flags: Int, isJob: Boolean) {
|
||||
// Create root object
|
||||
val root = JsonObject()
|
||||
|
||||
// Create information object
|
||||
val information = JsonObject()
|
||||
|
||||
// Create manga array
|
||||
val mangaEntries = JsonArray()
|
||||
|
||||
// Create category array
|
||||
val categoryEntries = JsonArray()
|
||||
|
||||
// Add value's to root
|
||||
root[VERSION] = Backup.CURRENT_VERSION
|
||||
root[MANGAS] = mangaEntries
|
||||
root[CATEGORIES] = categoryEntries
|
||||
|
||||
backupManager.databaseHelper.inTransaction {
|
||||
// Get manga from database
|
||||
val mangas = backupManager.getFavoriteManga()
|
||||
|
||||
// Backup library manga and its dependencies
|
||||
mangas.forEach { manga ->
|
||||
mangaEntries.add(backupManager.backupMangaObject(manga, flags))
|
||||
}
|
||||
|
||||
// Backup categories
|
||||
if ((flags and BACKUP_CATEGORY_MASK) == BACKUP_CATEGORY) {
|
||||
backupManager.backupCategories(categoryEntries)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// When BackupCreatorJob
|
||||
if (isJob) {
|
||||
// Get dir of file
|
||||
val dir = UniFile.fromUri(this, uri)
|
||||
|
||||
// Delete older backups
|
||||
val numberOfBackups = backupManager.numberOfBackups()
|
||||
val backupRegex = Regex("""tachiyomi_\d+-\d+-\d+_\d+-\d+.json""")
|
||||
dir.listFiles { _, filename -> backupRegex.matches(filename) }
|
||||
.orEmpty()
|
||||
.sortedByDescending { it.name }
|
||||
.drop(numberOfBackups - 1)
|
||||
.forEach { it.delete() }
|
||||
|
||||
// Create new file to place backup
|
||||
val newFile = dir.createFile(Backup.getDefaultFilename())
|
||||
?: throw Exception("Couldn't create backup file")
|
||||
|
||||
newFile.openOutputStream().bufferedWriter().use {
|
||||
backupManager.parser.toJson(root, it)
|
||||
}
|
||||
} else {
|
||||
val file = UniFile.fromUri(this, uri)
|
||||
?: throw Exception("Couldn't create backup file")
|
||||
file.openOutputStream().bufferedWriter().use {
|
||||
backupManager.parser.toJson(root, it)
|
||||
}
|
||||
|
||||
// Show completed dialog
|
||||
val intent = Intent(BackupConst.INTENT_FILTER).apply {
|
||||
putExtra(BackupConst.ACTION, BackupConst.ACTION_BACKUP_COMPLETED_DIALOG)
|
||||
putExtra(BackupConst.EXTRA_URI, file.uri.toString())
|
||||
}
|
||||
sendLocalBroadcast(intent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
if (!isJob) {
|
||||
// Show error dialog
|
||||
val intent = Intent(BackupConst.INTENT_FILTER).apply {
|
||||
putExtra(BackupConst.ACTION, BackupConst.ACTION_ERROR_BACKUP_DIALOG)
|
||||
putExtra(BackupConst.EXTRA_ERROR_MESSAGE, e.message)
|
||||
}
|
||||
sendLocalBroadcast(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8,15 +8,15 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.File
|
||||
|
||||
class BackupCreatorJob : Job() {
|
||||
|
||||
override fun onRunJob(params: Params): Result {
|
||||
val preferences = Injekt.get<PreferencesHelper>()
|
||||
val uri = Uri.fromFile(File(preferences.backupsDirectory().getOrDefault()))
|
||||
val backupManager = BackupManager(context)
|
||||
val uri = Uri.parse(preferences.backupsDirectory().getOrDefault())
|
||||
val flags = BackupCreateService.BACKUP_ALL
|
||||
BackupCreateService.makeBackup(context, uri, flags, true)
|
||||
backupManager.createBackup(uri, flags, true)
|
||||
return Result.SUCCESS
|
||||
}
|
||||
|
||||
@ -29,7 +29,6 @@ class BackupCreatorJob : Job() {
|
||||
if (interval > 0) {
|
||||
JobRequest.Builder(TAG)
|
||||
.setPeriodic(interval * 60 * 60 * 1000L, 10 * 60 * 1000)
|
||||
.setPersisted(true)
|
||||
.setUpdateCurrent(true)
|
||||
.build()
|
||||
.schedule()
|
||||
@ -40,4 +39,4 @@ class BackupCreatorJob : Job() {
|
||||
JobManager.instance().cancelAllForTag(TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,11 @@
|
||||
package eu.kanade.tachiyomi.data.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.github.salomonbrys.kotson.*
|
||||
import com.google.gson.*
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY_MASK
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER
|
||||
@ -11,6 +14,7 @@ import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HIST
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY_MASK
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK_MASK
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CURRENT_VERSION
|
||||
@ -23,10 +27,13 @@ import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.*
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.util.sendLocalBroadcast
|
||||
import eu.kanade.tachiyomi.util.syncChaptersWithSource
|
||||
import rx.Observable
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
|
||||
@ -41,6 +48,11 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
|
||||
*/
|
||||
internal val sourceManager: SourceManager by injectLazy()
|
||||
|
||||
/**
|
||||
* Tracking manager
|
||||
*/
|
||||
internal val trackManager: TrackManager by injectLazy()
|
||||
|
||||
/**
|
||||
* Version of parser
|
||||
*/
|
||||
@ -67,17 +79,101 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
|
||||
parser = initParser()
|
||||
}
|
||||
|
||||
private fun initParser(): Gson {
|
||||
return when (version) {
|
||||
1 -> GsonBuilder().create()
|
||||
2 -> GsonBuilder()
|
||||
.registerTypeAdapter<MangaImpl>(MangaTypeAdapter.build())
|
||||
.registerTypeHierarchyAdapter<ChapterImpl>(ChapterTypeAdapter.build())
|
||||
.registerTypeAdapter<CategoryImpl>(CategoryTypeAdapter.build())
|
||||
.registerTypeAdapter<DHistory>(HistoryTypeAdapter.build())
|
||||
.registerTypeHierarchyAdapter<TrackImpl>(TrackTypeAdapter.build())
|
||||
.create()
|
||||
else -> throw Exception("Json version unknown")
|
||||
private fun initParser(): Gson = when (version) {
|
||||
1 -> GsonBuilder().create()
|
||||
2 -> GsonBuilder()
|
||||
.registerTypeAdapter<MangaImpl>(MangaTypeAdapter.build())
|
||||
.registerTypeHierarchyAdapter<ChapterImpl>(ChapterTypeAdapter.build())
|
||||
.registerTypeAdapter<CategoryImpl>(CategoryTypeAdapter.build())
|
||||
.registerTypeAdapter<DHistory>(HistoryTypeAdapter.build())
|
||||
.registerTypeHierarchyAdapter<TrackImpl>(TrackTypeAdapter.build())
|
||||
.create()
|
||||
else -> throw Exception("Json version unknown")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -300,23 +396,26 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
|
||||
val trackToUpdate = ArrayList<Track>()
|
||||
|
||||
for (track in tracks) {
|
||||
var isInDatabase = false
|
||||
for (dbTrack in dbTracks) {
|
||||
if (track.sync_id == dbTrack.sync_id) {
|
||||
// The sync is already in the db, only update its fields
|
||||
if (track.remote_id != dbTrack.remote_id) {
|
||||
dbTrack.remote_id = track.remote_id
|
||||
val service = trackManager.getService(track.sync_id)
|
||||
if (service != null && service.isLogged) {
|
||||
var isInDatabase = false
|
||||
for (dbTrack in dbTracks) {
|
||||
if (track.sync_id == dbTrack.sync_id) {
|
||||
// The sync is already in the db, only update its fields
|
||||
if (track.remote_id != dbTrack.remote_id) {
|
||||
dbTrack.remote_id = track.remote_id
|
||||
}
|
||||
dbTrack.last_chapter_read = Math.max(dbTrack.last_chapter_read, track.last_chapter_read)
|
||||
isInDatabase = true
|
||||
trackToUpdate.add(dbTrack)
|
||||
break
|
||||
}
|
||||
dbTrack.last_chapter_read = Math.max(dbTrack.last_chapter_read, track.last_chapter_read)
|
||||
isInDatabase = true
|
||||
trackToUpdate.add(dbTrack)
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!isInDatabase) {
|
||||
// Insert new sync. Let the db assign the id
|
||||
track.id = null
|
||||
trackToUpdate.add(track)
|
||||
if (!isInDatabase) {
|
||||
// Insert new sync. Let the db assign the id
|
||||
track.id = null
|
||||
trackToUpdate.add(track)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update database
|
||||
@ -361,32 +460,29 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
|
||||
*
|
||||
* @return [Manga], null if not found
|
||||
*/
|
||||
internal fun getMangaFromDatabase(manga: Manga): Manga? {
|
||||
return databaseHelper.getManga(manga.url, manga.source).executeAsBlocking()
|
||||
}
|
||||
internal fun getMangaFromDatabase(manga: Manga): Manga? =
|
||||
databaseHelper.getManga(manga.url, manga.source).executeAsBlocking()
|
||||
|
||||
/**
|
||||
* Returns list containing manga from library
|
||||
*
|
||||
* @return [Manga] from library
|
||||
*/
|
||||
internal fun getFavoriteManga(): List<Manga> {
|
||||
return databaseHelper.getFavoriteMangas().executeAsBlocking()
|
||||
}
|
||||
internal fun getFavoriteManga(): List<Manga> =
|
||||
databaseHelper.getFavoriteMangas().executeAsBlocking()
|
||||
|
||||
/**
|
||||
* Inserts manga and returns id
|
||||
*
|
||||
* @return id of [Manga], null if not found
|
||||
*/
|
||||
internal fun insertManga(manga: Manga): Long? {
|
||||
return databaseHelper.insertManga(manga).executeAsBlocking().insertedId()
|
||||
}
|
||||
internal fun insertManga(manga: Manga): Long? =
|
||||
databaseHelper.insertManga(manga).executeAsBlocking().insertedId()
|
||||
|
||||
/**
|
||||
* Inserts list of chapters
|
||||
*/
|
||||
internal fun insertChapters(chapters: List<Chapter>) {
|
||||
private fun insertChapters(chapters: List<Chapter>) {
|
||||
databaseHelper.updateChaptersBackup(chapters).executeAsBlocking()
|
||||
}
|
||||
|
||||
@ -395,7 +491,5 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
|
||||
*
|
||||
* @return number of backups selected by user
|
||||
*/
|
||||
fun numberOfBackups(): Int {
|
||||
return preferences.numberOfBackups().getOrDefault()
|
||||
}
|
||||
fun numberOfBackups(): Int = preferences.numberOfBackups().getOrDefault()
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION
|
||||
import eu.kanade.tachiyomi.data.backup.models.DHistory
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.*
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.util.chop
|
||||
import eu.kanade.tachiyomi.util.isServiceRunning
|
||||
@ -49,9 +50,8 @@ class BackupRestoreService : Service() {
|
||||
* @param context the application context.
|
||||
* @return true if the service is running, false otherwise.
|
||||
*/
|
||||
fun isRunning(context: Context): Boolean {
|
||||
return context.isServiceRunning(BackupRestoreService::class.java)
|
||||
}
|
||||
private fun isRunning(context: Context): Boolean =
|
||||
context.isServiceRunning(BackupRestoreService::class.java)
|
||||
|
||||
/**
|
||||
* Starts a service to restore a backup from Json
|
||||
@ -113,7 +113,13 @@ class BackupRestoreService : Service() {
|
||||
*/
|
||||
private val db: DatabaseHelper by injectLazy()
|
||||
|
||||
lateinit var executor: ExecutorService
|
||||
/**
|
||||
* Tracking manager
|
||||
*/
|
||||
internal val trackManager: TrackManager by injectLazy()
|
||||
|
||||
|
||||
private lateinit var executor: ExecutorService
|
||||
|
||||
/**
|
||||
* Method called when the service is created. It injects dependencies and acquire the wake lock.
|
||||
@ -142,9 +148,7 @@ class BackupRestoreService : Service() {
|
||||
/**
|
||||
* This method needs to be implemented, but it's not used/needed.
|
||||
*/
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
return null
|
||||
}
|
||||
override fun onBind(intent: Intent): IBinder? = null
|
||||
|
||||
/**
|
||||
* Method called when the service receives an intent.
|
||||
@ -164,7 +168,7 @@ class BackupRestoreService : Service() {
|
||||
|
||||
subscription = Observable.using(
|
||||
{ db.lowLevel().beginTransaction() },
|
||||
{ getRestoreObservable(uri).doOnNext{ db.lowLevel().setTransactionSuccessful() } },
|
||||
{ getRestoreObservable(uri).doOnNext { db.lowLevel().setTransactionSuccessful() } },
|
||||
{ executor.execute { db.lowLevel().endTransaction() } })
|
||||
.doAfterTerminate { stopSelf(startId) }
|
||||
.subscribeOn(Schedulers.from(executor))
|
||||
@ -182,29 +186,33 @@ class BackupRestoreService : Service() {
|
||||
private fun getRestoreObservable(uri: Uri): Observable<List<Manga>> {
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
val reader = JsonReader(contentResolver.openInputStream(uri).bufferedReader())
|
||||
val json = JsonParser().parse(reader).asJsonObject
|
||||
return Observable.just(Unit)
|
||||
.map {
|
||||
val reader = JsonReader(contentResolver.openInputStream(uri).bufferedReader())
|
||||
val json = JsonParser().parse(reader).asJsonObject
|
||||
|
||||
// Get parser version
|
||||
val version = json.get(VERSION)?.asInt ?: 1
|
||||
// Get parser version
|
||||
val version = json.get(VERSION)?.asInt ?: 1
|
||||
|
||||
// Initialize manager
|
||||
backupManager = BackupManager(this, version)
|
||||
// Initialize manager
|
||||
backupManager = BackupManager(this, version)
|
||||
|
||||
val mangasJson = json.get(MANGAS).asJsonArray
|
||||
val mangasJson = json.get(MANGAS).asJsonArray
|
||||
|
||||
restoreAmount = mangasJson.size() + 1 // +1 for categories
|
||||
restoreProgress = 0
|
||||
errors.clear()
|
||||
restoreAmount = mangasJson.size() + 1 // +1 for categories
|
||||
restoreProgress = 0
|
||||
errors.clear()
|
||||
|
||||
// Restore categories
|
||||
json.get(CATEGORIES)?.let {
|
||||
backupManager.restoreCategories(it.asJsonArray)
|
||||
restoreProgress += 1
|
||||
showRestoreProgress(restoreProgress, restoreAmount, "Categories added", errors.size)
|
||||
}
|
||||
// Restore categories
|
||||
json.get(CATEGORIES)?.let {
|
||||
backupManager.restoreCategories(it.asJsonArray)
|
||||
restoreProgress += 1
|
||||
showRestoreProgress(restoreProgress, restoreAmount, "Categories added", errors.size)
|
||||
}
|
||||
|
||||
return Observable.from(mangasJson)
|
||||
mangasJson
|
||||
}
|
||||
.flatMap { Observable.from(it) }
|
||||
.concatMap {
|
||||
val obj = it.asJsonObject
|
||||
val manga = backupManager.parser.fromJson<MangaImpl>(obj.get(MANGA))
|
||||
@ -290,14 +298,14 @@ class BackupRestoreService : Service() {
|
||||
val source = backupManager.sourceManager.get(manga.source) ?: return null
|
||||
val dbManga = backupManager.getMangaFromDatabase(manga)
|
||||
|
||||
if (dbManga == null) {
|
||||
return if (dbManga == null) {
|
||||
// Manga not in database
|
||||
return mangaFetchObservable(source, manga, chapters, categories, history, tracks)
|
||||
mangaFetchObservable(source, manga, chapters, categories, history, tracks)
|
||||
} else { // Manga in database
|
||||
// Copy information from manga already in database
|
||||
backupManager.restoreMangaNoFetch(manga, dbManga)
|
||||
// Fetch rest of manga information
|
||||
return mangaNoFetchObservable(source, manga, chapters, categories, history, tracks)
|
||||
mangaNoFetchObservable(source, manga, chapters, categories, history, tracks)
|
||||
}
|
||||
}
|
||||
|
||||
@ -317,20 +325,18 @@ class BackupRestoreService : Service() {
|
||||
manga
|
||||
}
|
||||
.filter { it.id != null }
|
||||
.flatMap { manga ->
|
||||
chapterFetchObservable(source, manga, chapters)
|
||||
.flatMap {
|
||||
chapterFetchObservable(source, it, chapters)
|
||||
// Convert to the manga that contains new chapters.
|
||||
.map { manga }
|
||||
}
|
||||
.doOnNext {
|
||||
// Restore categories
|
||||
backupManager.restoreCategoriesForManga(it, categories)
|
||||
|
||||
// Restore history
|
||||
backupManager.restoreHistoryForManga(history)
|
||||
|
||||
// Restore tracking
|
||||
backupManager.restoreTrackForManga(it, tracks)
|
||||
restoreExtraForManga(it, categories, history, tracks)
|
||||
}
|
||||
.flatMap {
|
||||
trackingFetchObservable(it, tracks)
|
||||
// Convert to the manga that contains new chapters.
|
||||
.map { manga }
|
||||
}
|
||||
.doOnCompleted {
|
||||
restoreProgress += 1
|
||||
@ -352,14 +358,12 @@ class BackupRestoreService : Service() {
|
||||
}
|
||||
}
|
||||
.doOnNext {
|
||||
// Restore categories
|
||||
backupManager.restoreCategoriesForManga(it, categories)
|
||||
|
||||
// Restore history
|
||||
backupManager.restoreHistoryForManga(history)
|
||||
|
||||
// Restore tracking
|
||||
backupManager.restoreTrackForManga(it, tracks)
|
||||
restoreExtraForManga(it, categories, history, tracks)
|
||||
}
|
||||
.flatMap { manga ->
|
||||
trackingFetchObservable(manga, tracks)
|
||||
// Convert to the manga that contains new chapters.
|
||||
.map { manga }
|
||||
}
|
||||
.doOnCompleted {
|
||||
restoreProgress += 1
|
||||
@ -367,6 +371,17 @@ class BackupRestoreService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun restoreExtraForManga(manga: Manga, categories: List<String>, history: List<DHistory>, tracks: List<Track>) {
|
||||
// Restore categories
|
||||
backupManager.restoreCategoriesForManga(manga, categories)
|
||||
|
||||
// Restore history
|
||||
backupManager.restoreHistoryForManga(history)
|
||||
|
||||
// Restore tracking
|
||||
backupManager.restoreTrackForManga(manga, tracks)
|
||||
}
|
||||
|
||||
/**
|
||||
* [Observable] that fetches chapter information
|
||||
*
|
||||
@ -379,10 +394,33 @@ class BackupRestoreService : Service() {
|
||||
// If there's any error, return empty update and continue.
|
||||
.onErrorReturn {
|
||||
errors.add(Date() to "${manga.title} - ${it.message}")
|
||||
Pair(emptyList<Chapter>(), emptyList<Chapter>())
|
||||
Pair(emptyList(), emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [Observable] that refreshes tracking information
|
||||
* @param manga manga that needs updating.
|
||||
* @param tracks list containing tracks from restore file.
|
||||
* @return [Observable] that contains updated track item
|
||||
*/
|
||||
private fun trackingFetchObservable(manga: Manga, tracks: List<Track>): Observable<Track> {
|
||||
return Observable.from(tracks)
|
||||
.concatMap { track ->
|
||||
val service = trackManager.getService(track.sync_id)
|
||||
if (service != null && service.isLogged) {
|
||||
service.refresh(track)
|
||||
.doOnNext { db.insertTrack(it).executeAsBlocking() }
|
||||
.onErrorReturn {
|
||||
errors.add(Date() to "${manga.title} - ${it.message}")
|
||||
track
|
||||
}
|
||||
} else {
|
||||
errors.add(Date() to "${manga.title} - ${service?.name} not logged in")
|
||||
Observable.empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to update dialog in [BackupConst]
|
||||
|
@ -14,6 +14,7 @@ object TrackTypeAdapter {
|
||||
private const val REMOTE = "r"
|
||||
private const val TITLE = "t"
|
||||
private const val LAST_READ = "l"
|
||||
private const val TRACKING_URL = "u"
|
||||
|
||||
fun build(): TypeAdapter<TrackImpl> {
|
||||
return typeAdapter {
|
||||
@ -27,6 +28,8 @@ object TrackTypeAdapter {
|
||||
value(it.remote_id)
|
||||
name(LAST_READ)
|
||||
value(it.last_chapter_read)
|
||||
name(TRACKING_URL)
|
||||
value(it.tracking_url)
|
||||
endObject()
|
||||
}
|
||||
|
||||
@ -42,6 +45,7 @@ object TrackTypeAdapter {
|
||||
SYNC -> track.sync_id = nextInt()
|
||||
REMOTE -> track.remote_id = nextInt()
|
||||
LAST_READ -> track.last_chapter_read = nextInt()
|
||||
TRACKING_URL -> track.tracking_url = nextString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -44,13 +44,8 @@ class ChapterCache(private val context: Context) {
|
||||
/** Google Json class used for parsing JSON files. */
|
||||
private val gson: Gson by injectLazy()
|
||||
|
||||
/** Parent directory of the cache. Ensure not null and not root directory or fallback
|
||||
* to internal cache directory. **/
|
||||
private val basePath = context.externalCacheDir?.takeIf { it.absolutePath.length > 1 }
|
||||
?: context.cacheDir
|
||||
|
||||
/** Cache class used for cache management. */
|
||||
private val diskCache = DiskLruCache.open(File(basePath, PARAMETER_CACHE_DIRECTORY),
|
||||
private val diskCache = DiskLruCache.open(File(context.cacheDir, PARAMETER_CACHE_DIRECTORY),
|
||||
PARAMETER_APP_VERSION,
|
||||
PARAMETER_VALUE_COUNT,
|
||||
PARAMETER_CACHE_SIZE)
|
||||
@ -86,10 +81,10 @@ class ChapterCache(private val context: Context) {
|
||||
|
||||
try {
|
||||
// Remove the extension from the file to get the key of the cache
|
||||
val key = file.substring(0, file.lastIndexOf("."))
|
||||
val key = file.substringBeforeLast(".")
|
||||
// Remove file from cache.
|
||||
return diskCache.remove(key)
|
||||
} catch (e: IOException) {
|
||||
} catch (e: Exception) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
@ -20,7 +20,8 @@ class CoverCache(private val context: Context) {
|
||||
/**
|
||||
* Cache directory used for cache management.
|
||||
*/
|
||||
private val cacheDir = context.getExternalFilesDir("covers")
|
||||
private val cacheDir = context.getExternalFilesDir("covers") ?:
|
||||
File(context.filesDir, "covers").also { it.mkdirs() }
|
||||
|
||||
/**
|
||||
* Returns the cover from cache.
|
||||
|
@ -17,7 +17,7 @@ class DbOpenHelper(context: Context)
|
||||
/**
|
||||
* Version of the database.
|
||||
*/
|
||||
const val DATABASE_VERSION = 5
|
||||
const val DATABASE_VERSION = 6
|
||||
}
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) = with(db) {
|
||||
@ -54,6 +54,9 @@ class DbOpenHelper(context: Context)
|
||||
if (oldVersion < 5) {
|
||||
db.execSQL(ChapterTable.addScanlator)
|
||||
}
|
||||
if (oldVersion < 6) {
|
||||
db.execSQL(TrackTable.addTrackingUrl)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConfigure(db: SQLiteDatabase) {
|
||||
|
@ -65,9 +65,8 @@ class MangaPutResolver : DefaultPutResolver<Manga>() {
|
||||
}
|
||||
}
|
||||
|
||||
open class MangaGetResolver : DefaultGetResolver<Manga>() {
|
||||
|
||||
override fun mapFromCursor(cursor: Cursor): Manga = MangaImpl().apply {
|
||||
interface BaseMangaGetResolver {
|
||||
fun mapBaseFromCursor(manga: Manga, cursor: Cursor) = manga.apply {
|
||||
id = cursor.getLong(cursor.getColumnIndex(COL_ID))
|
||||
source = cursor.getLong(cursor.getColumnIndex(COL_SOURCE))
|
||||
url = cursor.getString(cursor.getColumnIndex(COL_URL))
|
||||
@ -86,6 +85,13 @@ open class MangaGetResolver : DefaultGetResolver<Manga>() {
|
||||
}
|
||||
}
|
||||
|
||||
open class MangaGetResolver : DefaultGetResolver<Manga>(), BaseMangaGetResolver {
|
||||
|
||||
override fun mapFromCursor(cursor: Cursor): Manga {
|
||||
return mapBaseFromCursor(MangaImpl(), cursor)
|
||||
}
|
||||
}
|
||||
|
||||
class MangaDeleteResolver : DefaultDeleteResolver<Manga>() {
|
||||
|
||||
override fun mapToDeleteQuery(obj: Manga) = DeleteQuery.builder()
|
||||
|
@ -20,6 +20,7 @@ import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_STATUS
|
||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SYNC_ID
|
||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TITLE
|
||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TOTAL_CHAPTERS
|
||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TRACKING_URL
|
||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable.TABLE
|
||||
|
||||
class TrackTypeMapping : SQLiteTypeMapping<Track>(
|
||||
@ -40,7 +41,7 @@ class TrackPutResolver : DefaultPutResolver<Track>() {
|
||||
.whereArgs(obj.id)
|
||||
.build()
|
||||
|
||||
override fun mapToContentValues(obj: Track) = ContentValues(9).apply {
|
||||
override fun mapToContentValues(obj: Track) = ContentValues(10).apply {
|
||||
put(COL_ID, obj.id)
|
||||
put(COL_MANGA_ID, obj.manga_id)
|
||||
put(COL_SYNC_ID, obj.sync_id)
|
||||
@ -49,7 +50,9 @@ class TrackPutResolver : DefaultPutResolver<Track>() {
|
||||
put(COL_LAST_CHAPTER_READ, obj.last_chapter_read)
|
||||
put(COL_TOTAL_CHAPTERS, obj.total_chapters)
|
||||
put(COL_STATUS, obj.status)
|
||||
put(COL_TRACKING_URL, obj.tracking_url)
|
||||
put(COL_SCORE, obj.score)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,6 +68,7 @@ class TrackGetResolver : DefaultGetResolver<Track>() {
|
||||
total_chapters = cursor.getInt(cursor.getColumnIndex(COL_TOTAL_CHAPTERS))
|
||||
status = cursor.getInt(cursor.getColumnIndex(COL_STATUS))
|
||||
score = cursor.getFloat(cursor.getColumnIndex(COL_SCORE))
|
||||
tracking_url = cursor.getString(cursor.getColumnIndex(COL_TRACKING_URL))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,9 @@
|
||||
package eu.kanade.tachiyomi.data.database.models
|
||||
|
||||
class LibraryManga : MangaImpl() {
|
||||
|
||||
var unread: Int = 0
|
||||
|
||||
var category: Int = 0
|
||||
|
||||
}
|
@ -16,10 +16,6 @@ interface Manga : SManga {
|
||||
|
||||
var chapter_flags: Int
|
||||
|
||||
var unread: Int
|
||||
|
||||
var category: Int
|
||||
|
||||
fun setChapterOrder(order: Int) {
|
||||
setFlags(order, SORT_MASK)
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
package eu.kanade.tachiyomi.data.database.models
|
||||
|
||||
class MangaImpl : Manga {
|
||||
open class MangaImpl : Manga {
|
||||
|
||||
override var id: Long? = null
|
||||
|
||||
@ -32,10 +32,6 @@ class MangaImpl : Manga {
|
||||
|
||||
override var chapter_flags: Int = 0
|
||||
|
||||
@Transient override var unread: Int = 0
|
||||
|
||||
@Transient override var category: Int = 0
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || javaClass != other.javaClass) return false
|
||||
|
@ -22,6 +22,8 @@ interface Track : Serializable {
|
||||
|
||||
var status: Int
|
||||
|
||||
var tracking_url: String
|
||||
|
||||
fun copyPersonalFrom(other: Track) {
|
||||
last_chapter_read = other.last_chapter_read
|
||||
score = other.score
|
||||
@ -29,7 +31,6 @@ interface Track : Serializable {
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(serviceId: Int): Track = TrackImpl().apply {
|
||||
sync_id = serviceId
|
||||
}
|
||||
|
@ -20,6 +20,8 @@ class TrackImpl : Track {
|
||||
|
||||
override var status: Int = 0
|
||||
|
||||
override var tracking_url: String = ""
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || javaClass != other.javaClass) return false
|
||||
|
@ -4,8 +4,10 @@ import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.Query
|
||||
import com.pushtorefresh.storio.sqlite.queries.RawQuery
|
||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.models.LibraryManga
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.LibraryMangaGetResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaFavoritePutResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaFlagsPutResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaLastUpdatedPutResolver
|
||||
import eu.kanade.tachiyomi.data.database.tables.CategoryTable
|
||||
@ -23,7 +25,7 @@ interface MangaQueries : DbProvider {
|
||||
.prepare()
|
||||
|
||||
fun getLibraryMangas() = db.get()
|
||||
.listOfObjects(Manga::class.java)
|
||||
.listOfObjects(LibraryManga::class.java)
|
||||
.withQuery(RawQuery.builder()
|
||||
.query(libraryQuery)
|
||||
.observesTables(MangaTable.TABLE, ChapterTable.TABLE, MangaCategoryTable.TABLE, CategoryTable.TABLE)
|
||||
@ -73,6 +75,11 @@ interface MangaQueries : DbProvider {
|
||||
.withPutResolver(MangaLastUpdatedPutResolver())
|
||||
.prepare()
|
||||
|
||||
fun updateMangaFavorite(manga: Manga) = db.put()
|
||||
.`object`(manga)
|
||||
.withPutResolver(MangaFavoritePutResolver())
|
||||
.prepare()
|
||||
|
||||
fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare()
|
||||
|
||||
fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare()
|
||||
|
@ -1,24 +1,23 @@
|
||||
package eu.kanade.tachiyomi.data.database.resolvers
|
||||
|
||||
import android.database.Cursor
|
||||
import eu.kanade.tachiyomi.data.database.mappers.MangaGetResolver
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
||||
import eu.kanade.tachiyomi.data.database.mappers.BaseMangaGetResolver
|
||||
import eu.kanade.tachiyomi.data.database.models.LibraryManga
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||
|
||||
class LibraryMangaGetResolver : MangaGetResolver() {
|
||||
class LibraryMangaGetResolver : DefaultGetResolver<LibraryManga>(), BaseMangaGetResolver {
|
||||
|
||||
companion object {
|
||||
val INSTANCE = LibraryMangaGetResolver()
|
||||
}
|
||||
|
||||
override fun mapFromCursor(cursor: Cursor): Manga {
|
||||
val manga = super.mapFromCursor(cursor)
|
||||
override fun mapFromCursor(cursor: Cursor): LibraryManga {
|
||||
val manga = LibraryManga()
|
||||
|
||||
val unreadColumn = cursor.getColumnIndex(MangaTable.COL_UNREAD)
|
||||
manga.unread = cursor.getInt(unreadColumn)
|
||||
|
||||
val categoryColumn = cursor.getColumnIndex(MangaTable.COL_CATEGORY)
|
||||
manga.category = cursor.getInt(categoryColumn)
|
||||
mapBaseFromCursor(manga, cursor)
|
||||
manga.unread = cursor.getInt(cursor.getColumnIndex(MangaTable.COL_UNREAD))
|
||||
manga.category = cursor.getInt(cursor.getColumnIndex(MangaTable.COL_CATEGORY))
|
||||
|
||||
return manga
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -22,6 +22,8 @@ object TrackTable {
|
||||
|
||||
const val COL_TOTAL_CHAPTERS = "total_chapters"
|
||||
|
||||
const val COL_TRACKING_URL = "remote_url"
|
||||
|
||||
val createTableQuery: String
|
||||
get() = """CREATE TABLE $TABLE(
|
||||
$COL_ID INTEGER NOT NULL PRIMARY KEY,
|
||||
@ -33,9 +35,12 @@ object TrackTable {
|
||||
$COL_TOTAL_CHAPTERS INTEGER NOT NULL,
|
||||
$COL_STATUS INTEGER NOT NULL,
|
||||
$COL_SCORE FLOAT NOT NULL,
|
||||
$COL_TRACKING_URL TEXT NOT NULL,
|
||||
UNIQUE ($COL_MANGA_ID, $COL_SYNC_ID) ON CONFLICT REPLACE,
|
||||
FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID})
|
||||
ON DELETE CASCADE
|
||||
)"""
|
||||
|
||||
val addTrackingUrl: String
|
||||
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_TRACKING_URL TEXT DEFAULT ''"
|
||||
}
|
||||
|
@ -0,0 +1,252 @@
|
||||
package eu.kanade.tachiyomi.data.download
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Cache where we dump the downloads directory from the filesystem. This class is needed because
|
||||
* directory checking is expensive and it slowdowns the app. The cache is invalidated by the time
|
||||
* defined in [renewInterval] as we don't have any control over the filesystem and the user can
|
||||
* delete the folders at any time without the app noticing.
|
||||
*
|
||||
* @param context the application context.
|
||||
* @param provider the downloads directories provider.
|
||||
* @param sourceManager the source manager.
|
||||
* @param preferences the preferences of the app.
|
||||
*/
|
||||
class DownloadCache(private val context: Context,
|
||||
private val provider: DownloadProvider,
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
private val preferences: PreferencesHelper = Injekt.get()) {
|
||||
|
||||
/**
|
||||
* The interval after which this cache should be invalidated. 1 hour shouldn't cause major
|
||||
* issues, as the cache is only used for UI feedback.
|
||||
*/
|
||||
private val renewInterval = TimeUnit.HOURS.toMillis(1)
|
||||
|
||||
/**
|
||||
* The last time the cache was refreshed.
|
||||
*/
|
||||
private var lastRenew = 0L
|
||||
|
||||
/**
|
||||
* The root directory for downloads.
|
||||
*/
|
||||
private var rootDir = RootDirectory(getDirectoryFromPreference())
|
||||
|
||||
init {
|
||||
preferences.downloadsDirectory().asObservable()
|
||||
.skip(1)
|
||||
.subscribe {
|
||||
lastRenew = 0L // invalidate cache
|
||||
rootDir = RootDirectory(getDirectoryFromPreference())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the downloads directory from the user's preferences.
|
||||
*/
|
||||
private fun getDirectoryFromPreference(): UniFile {
|
||||
val dir = preferences.downloadsDirectory().getOrDefault()
|
||||
return UniFile.fromUri(context, Uri.parse(dir))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the chapter is downloaded.
|
||||
*
|
||||
* @param chapter the chapter to check.
|
||||
* @param manga the manga of the chapter.
|
||||
* @param skipCache whether to skip the directory cache and check in the filesystem.
|
||||
*/
|
||||
fun isChapterDownloaded(chapter: Chapter, manga: Manga, skipCache: Boolean): Boolean {
|
||||
if (skipCache) {
|
||||
val source = sourceManager.get(manga.source) ?: return false
|
||||
return provider.findChapterDir(chapter, manga, source) != null
|
||||
}
|
||||
|
||||
checkRenew()
|
||||
|
||||
val sourceDir = rootDir.files[manga.source]
|
||||
if (sourceDir != null) {
|
||||
val mangaDir = sourceDir.files[provider.getMangaDirName(manga)]
|
||||
if (mangaDir != null) {
|
||||
return provider.getChapterDirName(chapter) in mangaDir.files
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the amount of downloaded chapters for a manga.
|
||||
*
|
||||
* @param manga the manga to check.
|
||||
*/
|
||||
fun getDownloadCount(manga: Manga): Int {
|
||||
checkRenew()
|
||||
|
||||
val sourceDir = rootDir.files[manga.source]
|
||||
if (sourceDir != null) {
|
||||
val mangaDir = sourceDir.files[provider.getMangaDirName(manga)]
|
||||
if (mangaDir != null) {
|
||||
return mangaDir.files.size
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the cache needs a renewal and performs it if needed.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun checkRenew() {
|
||||
if (lastRenew + renewInterval < System.currentTimeMillis()) {
|
||||
renew()
|
||||
lastRenew = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renews the downloads cache.
|
||||
*/
|
||||
private fun renew() {
|
||||
val onlineSources = sourceManager.getOnlineSources()
|
||||
|
||||
val sourceDirs = rootDir.dir.listFiles()
|
||||
.orEmpty()
|
||||
.associate { it.name to SourceDirectory(it) }
|
||||
.mapNotNullKeys { entry ->
|
||||
onlineSources.find { provider.getSourceDirName(it) == entry.key }?.id
|
||||
}
|
||||
|
||||
rootDir.files = sourceDirs
|
||||
|
||||
sourceDirs.values.forEach { sourceDir ->
|
||||
val mangaDirs = sourceDir.dir.listFiles()
|
||||
.orEmpty()
|
||||
.associateNotNullKeys { it.name to MangaDirectory(it) }
|
||||
|
||||
sourceDir.files = mangaDirs
|
||||
|
||||
mangaDirs.values.forEach { mangaDir ->
|
||||
val chapterDirs = mangaDir.dir.listFiles()
|
||||
.orEmpty()
|
||||
.mapNotNull { it.name }
|
||||
.toHashSet()
|
||||
|
||||
mangaDir.files = chapterDirs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a chapter that has just been download to this cache.
|
||||
*
|
||||
* @param chapterDirName the downloaded chapter's directory name.
|
||||
* @param mangaUniFile the directory of the manga.
|
||||
* @param manga the manga of the chapter.
|
||||
*/
|
||||
@Synchronized
|
||||
fun addChapter(chapterDirName: String, mangaUniFile: UniFile, manga: Manga) {
|
||||
// Retrieve the cached source directory or cache a new one
|
||||
var sourceDir = rootDir.files[manga.source]
|
||||
if (sourceDir == null) {
|
||||
val source = sourceManager.get(manga.source) ?: return
|
||||
val sourceUniFile = provider.findSourceDir(source) ?: return
|
||||
sourceDir = SourceDirectory(sourceUniFile)
|
||||
rootDir.files += manga.source to sourceDir
|
||||
}
|
||||
|
||||
// Retrieve the cached manga directory or cache a new one
|
||||
val mangaDirName = provider.getMangaDirName(manga)
|
||||
var mangaDir = sourceDir.files[mangaDirName]
|
||||
if (mangaDir == null) {
|
||||
mangaDir = MangaDirectory(mangaUniFile)
|
||||
sourceDir.files += mangaDirName to mangaDir
|
||||
}
|
||||
|
||||
// Save the chapter directory
|
||||
mangaDir.files += chapterDirName
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a chapter that has been deleted from this cache.
|
||||
*
|
||||
* @param chapter the chapter to remove.
|
||||
* @param manga the manga of the chapter.
|
||||
*/
|
||||
@Synchronized
|
||||
fun removeChapter(chapter: Chapter, manga: Manga) {
|
||||
val sourceDir = rootDir.files[manga.source] ?: return
|
||||
val mangaDir = sourceDir.files[provider.getMangaDirName(manga)] ?: return
|
||||
val chapterDirName = provider.getChapterDirName(chapter)
|
||||
if (chapterDirName in mangaDir.files) {
|
||||
mangaDir.files -= chapterDirName
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a manga that has been deleted from this cache.
|
||||
*
|
||||
* @param manga the manga to remove.
|
||||
*/
|
||||
@Synchronized
|
||||
fun removeManga(manga: Manga) {
|
||||
val sourceDir = rootDir.files[manga.source] ?: return
|
||||
val mangaDirName = provider.getMangaDirName(manga)
|
||||
if (mangaDirName in sourceDir.files) {
|
||||
sourceDir.files -= mangaDirName
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class to store the files under the root downloads directory.
|
||||
*/
|
||||
private class RootDirectory(val dir: UniFile,
|
||||
var files: Map<Long, SourceDirectory> = hashMapOf())
|
||||
|
||||
/**
|
||||
* Class to store the files under a source directory.
|
||||
*/
|
||||
private class SourceDirectory(val dir: UniFile,
|
||||
var files: Map<String, MangaDirectory> = hashMapOf())
|
||||
|
||||
/**
|
||||
* Class to store the files under a manga directory.
|
||||
*/
|
||||
private class MangaDirectory(val dir: UniFile,
|
||||
var files: Set<String> = hashSetOf())
|
||||
|
||||
/**
|
||||
* Returns a new map containing only the key entries of [transform] that are not null.
|
||||
*/
|
||||
private inline fun <K, V, R> Map<out K, V>.mapNotNullKeys(transform: (Map.Entry<K?, V>) -> R?): Map<R, V> {
|
||||
val destination = LinkedHashMap<R, V>()
|
||||
forEach { element -> transform(element)?.let { destination.put(it, element.value) } }
|
||||
return destination
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map from a list containing only the key entries of [transform] that are not null.
|
||||
*/
|
||||
private inline fun <T, K, V> Array<T>.associateNotNullKeys(transform: (T) -> Pair<K?, V>): Map<K, V> {
|
||||
val destination = LinkedHashMap<K, V>()
|
||||
for (element in this) {
|
||||
val (key, value) = transform(element)
|
||||
if (key != null) {
|
||||
destination.put(key, value)
|
||||
}
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
}
|
@ -24,10 +24,15 @@ class DownloadManager(context: Context) {
|
||||
*/
|
||||
private val provider = DownloadProvider(context)
|
||||
|
||||
/**
|
||||
* Cache of downloaded chapters.
|
||||
*/
|
||||
private val cache = DownloadCache(context, provider)
|
||||
|
||||
/**
|
||||
* Downloader whose only task is to download chapters.
|
||||
*/
|
||||
private val downloader = Downloader(context, provider)
|
||||
private val downloader = Downloader(context, provider, cache)
|
||||
|
||||
/**
|
||||
* Downloads queue, where the pending chapters are stored.
|
||||
@ -80,9 +85,10 @@ class DownloadManager(context: Context) {
|
||||
*
|
||||
* @param manga the manga of the chapters.
|
||||
* @param chapters the list of chapters to enqueue.
|
||||
* @param autoStart whether to start the downloader after enqueing the chapters.
|
||||
*/
|
||||
fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
|
||||
downloader.queueChapters(manga, chapters)
|
||||
fun downloadChapters(manga: Manga, chapters: List<Chapter>, autoStart: Boolean = true) {
|
||||
downloader.queueChapters(manga, chapters, autoStart)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -94,7 +100,7 @@ class DownloadManager(context: Context) {
|
||||
* @return an observable containing the list of pages from the chapter.
|
||||
*/
|
||||
fun buildPageList(source: Source, manga: Manga, chapter: Chapter): Observable<List<Page>> {
|
||||
return buildPageList(provider.findChapterDir(source, manga, chapter))
|
||||
return buildPageList(provider.findChapterDir(chapter, manga, source))
|
||||
}
|
||||
|
||||
/**
|
||||
@ -120,61 +126,45 @@ class DownloadManager(context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the directory name for a manga.
|
||||
* Returns true if the chapter is downloaded.
|
||||
*
|
||||
* @param manga the manga to query.
|
||||
*/
|
||||
fun getMangaDirName(manga: Manga): String {
|
||||
return provider.getMangaDirName(manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the directory name for the given chapter.
|
||||
*
|
||||
* @param chapter the chapter to query.
|
||||
*/
|
||||
fun getChapterDirName(chapter: Chapter): String {
|
||||
return provider.getChapterDirName(chapter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the download directory for a source if it exists.
|
||||
*
|
||||
* @param source the source to query.
|
||||
*/
|
||||
fun findSourceDir(source: Source): UniFile? {
|
||||
return provider.findSourceDir(source)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the directory for the given manga, if it exists.
|
||||
*
|
||||
* @param source the source of the manga.
|
||||
* @param manga the manga to query.
|
||||
*/
|
||||
fun findMangaDir(source: Source, manga: Manga): UniFile? {
|
||||
return provider.findMangaDir(source, manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the directory for the given chapter, if it exists.
|
||||
*
|
||||
* @param source the source of the chapter.
|
||||
* @param chapter the chapter to check.
|
||||
* @param manga the manga of the chapter.
|
||||
* @param chapter the chapter to query.
|
||||
* @param skipCache whether to skip the directory cache and check in the filesystem.
|
||||
*/
|
||||
fun findChapterDir(source: Source, manga: Manga, chapter: Chapter): UniFile? {
|
||||
return provider.findChapterDir(source, manga, chapter)
|
||||
fun isChapterDownloaded(chapter: Chapter, manga: Manga, skipCache: Boolean = false): Boolean {
|
||||
return cache.isChapterDownloaded(chapter, manga, skipCache)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the amount of downloaded chapters for a manga.
|
||||
*
|
||||
* @param manga the manga to check.
|
||||
*/
|
||||
fun getDownloadCount(manga: Manga): Int {
|
||||
return cache.getDownloadCount(manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the directory of a downloaded chapter.
|
||||
*
|
||||
* @param source the source of the chapter.
|
||||
* @param manga the manga of the chapter.
|
||||
* @param chapter the chapter to delete.
|
||||
* @param manga the manga of the chapter.
|
||||
* @param source the source of the chapter.
|
||||
*/
|
||||
fun deleteChapter(source: Source, manga: Manga, chapter: Chapter) {
|
||||
provider.findChapterDir(source, manga, chapter)?.delete()
|
||||
fun deleteChapter(chapter: Chapter, manga: Manga, source: Source) {
|
||||
provider.findChapterDir(chapter, manga, source)?.delete()
|
||||
cache.removeChapter(chapter, manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the directory of a downloaded manga.
|
||||
*
|
||||
* @param manga the manga to delete.
|
||||
* @param source the source of the manga.
|
||||
*/
|
||||
fun deleteManga(manga: Manga, source: Source) {
|
||||
provider.findMangaDir(manga, source)?.delete()
|
||||
cache.removeManga(manga)
|
||||
}
|
||||
}
|
||||
|
@ -3,12 +3,12 @@ package eu.kanade.tachiyomi.data.download
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import eu.kanade.tachiyomi.Constants
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.download.model.Download
|
||||
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationHandler
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.util.chop
|
||||
import eu.kanade.tachiyomi.util.notificationManager
|
||||
import java.util.regex.Pattern
|
||||
@ -23,7 +23,7 @@ internal class DownloadNotifier(private val context: Context) {
|
||||
* Notification builder.
|
||||
*/
|
||||
private val notification by lazy {
|
||||
NotificationCompat.Builder(context)
|
||||
NotificationCompat.Builder(context, Notifications.CHANNEL_DOWNLOADER)
|
||||
.setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher))
|
||||
}
|
||||
|
||||
@ -36,7 +36,6 @@ internal class DownloadNotifier(private val context: Context) {
|
||||
* The size of queue on start download.
|
||||
*/
|
||||
var initialQueueSize = 0
|
||||
get() = field
|
||||
set(value) {
|
||||
if (value != 0){
|
||||
isSingleChapter = (value == 1)
|
||||
@ -44,11 +43,6 @@ internal class DownloadNotifier(private val context: Context) {
|
||||
field = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Simultaneous download setting > 1.
|
||||
*/
|
||||
var multipleDownloadThreads = false
|
||||
|
||||
/**
|
||||
* Updated when error is thrown
|
||||
*/
|
||||
@ -69,7 +63,7 @@ internal class DownloadNotifier(private val context: Context) {
|
||||
*
|
||||
* @param id the id of the notification.
|
||||
*/
|
||||
private fun NotificationCompat.Builder.show(id: Int = Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID) {
|
||||
private fun NotificationCompat.Builder.show(id: Int = Notifications.ID_DOWNLOAD_CHAPTER) {
|
||||
context.notificationManager.notify(id, build())
|
||||
}
|
||||
|
||||
@ -86,41 +80,15 @@ internal class DownloadNotifier(private val context: Context) {
|
||||
* those can only be dismissed by the user.
|
||||
*/
|
||||
fun dismiss() {
|
||||
context.notificationManager.cancel(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID)
|
||||
context.notificationManager.cancel(Notifications.ID_DOWNLOAD_CHAPTER)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when download progress changes.
|
||||
* Note: Only accepted when multi download active.
|
||||
*
|
||||
* @param queue the queue containing downloads.
|
||||
*/
|
||||
fun onProgressChange(queue: DownloadQueue) {
|
||||
if (multipleDownloadThreads) {
|
||||
doOnProgressChange(null, queue)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when download progress changes.
|
||||
* Note: Only accepted when single download active.
|
||||
*
|
||||
* @param download download object containing download information.
|
||||
* @param queue the queue containing downloads.
|
||||
*/
|
||||
fun onProgressChange(download: Download, queue: DownloadQueue) {
|
||||
if (!multipleDownloadThreads) {
|
||||
doOnProgressChange(download, queue)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show notification progress of chapter.
|
||||
*
|
||||
* @param download download object containing download information.
|
||||
* @param queue the queue containing downloads.
|
||||
*/
|
||||
private fun doOnProgressChange(download: Download?, queue: DownloadQueue) {
|
||||
fun onProgressChange(download: Download) {
|
||||
// Create notification
|
||||
with(notification) {
|
||||
// Check if first call.
|
||||
@ -133,28 +101,13 @@ internal class DownloadNotifier(private val context: Context) {
|
||||
isDownloading = true
|
||||
}
|
||||
|
||||
if (multipleDownloadThreads) {
|
||||
setContentTitle(context.getString(R.string.app_name))
|
||||
|
||||
// Reset the queue size if the download progress is negative
|
||||
if ((initialQueueSize - queue.size) < 0)
|
||||
initialQueueSize = queue.size
|
||||
|
||||
setContentText(context.getString(R.string.chapter_downloading_progress)
|
||||
.format(initialQueueSize - queue.size, initialQueueSize))
|
||||
setProgress(initialQueueSize, initialQueueSize - queue.size, false)
|
||||
} else {
|
||||
download?.let {
|
||||
val title = it.manga.title.chop(15)
|
||||
val quotedTitle = Pattern.quote(title)
|
||||
val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "")
|
||||
setContentTitle("$title - $chapter".chop(30))
|
||||
setContentText(context.getString(R.string.chapter_downloading_progress)
|
||||
.format(it.downloadedImages, it.pages!!.size))
|
||||
setProgress(it.pages!!.size, it.downloadedImages, false)
|
||||
|
||||
}
|
||||
}
|
||||
val title = download.manga.title.chop(15)
|
||||
val quotedTitle = Pattern.quote(title)
|
||||
val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "")
|
||||
setContentTitle("$title - $chapter".chop(30))
|
||||
setContentText(context.getString(R.string.chapter_downloading_progress)
|
||||
.format(download.downloadedImages, download.pages!!.size))
|
||||
setProgress(download.pages!!.size, download.downloadedImages, false)
|
||||
}
|
||||
// Displays the progress bar on notification
|
||||
notification.show()
|
||||
@ -262,7 +215,7 @@ internal class DownloadNotifier(private val context: Context) {
|
||||
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
|
||||
setProgress(0, 0, false)
|
||||
}
|
||||
notification.show(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID)
|
||||
notification.show(Notifications.ID_DOWNLOAD_CHAPTER_ERROR)
|
||||
|
||||
// Reset download information
|
||||
errorThrown = true
|
||||
|
@ -40,10 +40,10 @@ class DownloadProvider(private val context: Context) {
|
||||
/**
|
||||
* Returns the download directory for a manga. For internal use only.
|
||||
*
|
||||
* @param source the source of the manga.
|
||||
* @param manga the manga to query.
|
||||
* @param source the source of the manga.
|
||||
*/
|
||||
internal fun getMangaDir(source: Source, manga: Manga): UniFile {
|
||||
internal fun getMangaDir(manga: Manga, source: Source): UniFile {
|
||||
return downloadsDir
|
||||
.createDirectory(getSourceDirName(source))
|
||||
.createDirectory(getMangaDirName(manga))
|
||||
@ -61,10 +61,10 @@ class DownloadProvider(private val context: Context) {
|
||||
/**
|
||||
* Returns the download directory for a manga if it exists.
|
||||
*
|
||||
* @param source the source of the manga.
|
||||
* @param manga the manga to query.
|
||||
* @param source the source of the manga.
|
||||
*/
|
||||
fun findMangaDir(source: Source, manga: Manga): UniFile? {
|
||||
fun findMangaDir(manga: Manga, source: Source): UniFile? {
|
||||
val sourceDir = findSourceDir(source)
|
||||
return sourceDir?.findFile(getMangaDirName(manga))
|
||||
}
|
||||
@ -72,12 +72,12 @@ class DownloadProvider(private val context: Context) {
|
||||
/**
|
||||
* Returns the download directory for a chapter if it exists.
|
||||
*
|
||||
* @param source the source of the chapter.
|
||||
* @param manga the manga of the chapter.
|
||||
* @param chapter the chapter to query.
|
||||
* @param manga the manga of the chapter.
|
||||
* @param source the source of the chapter.
|
||||
*/
|
||||
fun findChapterDir(source: Source, manga: Manga, chapter: Chapter): UniFile? {
|
||||
val mangaDir = findMangaDir(source, manga)
|
||||
fun findChapterDir(chapter: Chapter, manga: Manga, source: Source): UniFile? {
|
||||
val mangaDir = findMangaDir(manga, source)
|
||||
return mangaDir?.findFile(getChapterDirName(chapter))
|
||||
}
|
||||
|
||||
|
@ -122,7 +122,7 @@ class DownloadService : Service() {
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({ state -> onNetworkStateChanged(state)
|
||||
}, { error ->
|
||||
}, { _ ->
|
||||
toast(R.string.download_queue_error)
|
||||
stopSelf()
|
||||
})
|
||||
|
@ -9,18 +9,16 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.download.model.Download
|
||||
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.source.online.fetchAllImageUrlsFromPageList
|
||||
import eu.kanade.tachiyomi.util.*
|
||||
import kotlinx.coroutines.experimental.async
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import rx.subjects.BehaviorSubject
|
||||
import rx.subscriptions.CompositeSubscription
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
@ -36,8 +34,13 @@ import uy.kohesive.injekt.injectLazy
|
||||
*
|
||||
* @param context the application context.
|
||||
* @param provider the downloads directory provider.
|
||||
* @param cache the downloads cache, used to add the downloads to the cache after their completion.
|
||||
*/
|
||||
class Downloader(private val context: Context, private val provider: DownloadProvider) {
|
||||
class Downloader(
|
||||
private val context: Context,
|
||||
private val provider: DownloadProvider,
|
||||
private val cache: DownloadCache
|
||||
) {
|
||||
|
||||
/**
|
||||
* Store for persisting downloads across restarts.
|
||||
@ -54,11 +57,6 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
*/
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
|
||||
/**
|
||||
* Preferences.
|
||||
*/
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* Notifier for the downloader state and progress.
|
||||
*/
|
||||
@ -69,11 +67,6 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@ -90,12 +83,10 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
@Volatile private var isRunning: Boolean = false
|
||||
|
||||
init {
|
||||
Observable.fromCallable { store.restore() }
|
||||
.map { downloads -> downloads.filter { isDownloadAllowed(it) } }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({ downloads -> queue.addAll(downloads)
|
||||
}, { error -> Timber.e(error) })
|
||||
launchNow {
|
||||
val chapters = async { store.restore() }
|
||||
queue.addAll(chapters.await())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -114,9 +105,6 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
val pending = queue.filter { it.status != Download.DOWNLOADED }
|
||||
pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE }
|
||||
|
||||
// Show download notification when simultaneous download > 1.
|
||||
notifier.onProgressChange(queue)
|
||||
|
||||
downloadsRelay.call(pending)
|
||||
return !pending.isEmpty()
|
||||
}
|
||||
@ -183,14 +171,8 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
|
||||
subscriptions.clear()
|
||||
|
||||
subscriptions += preferences.downloadThreads().asObservable()
|
||||
.subscribe {
|
||||
threadsSubject.onNext(it)
|
||||
notifier.multipleDownloadThreads = it > 1
|
||||
}
|
||||
|
||||
subscriptions += downloadsRelay.flatMap { Observable.from(it) }
|
||||
.lift(DynamicConcurrentMergeOperator<Download, Download>({ downloadChapter(it) }, threadsSubject))
|
||||
subscriptions += downloadsRelay.concatMapIterable { it }
|
||||
.concatMap { downloadChapter(it).subscribeOn(Schedulers.io()) }
|
||||
.onBackpressureBuffer()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({ completeDownload(it)
|
||||
@ -213,61 +195,51 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a download object for every chapter and adds them to the downloads queue. This method
|
||||
* must be called in the main thread.
|
||||
* Creates a download object for every chapter and adds them to the downloads queue.
|
||||
*
|
||||
* @param manga the manga of the chapters to download.
|
||||
* @param chapters the list of chapters to download.
|
||||
* @param autoStart whether to start the downloader after enqueing the chapters.
|
||||
*/
|
||||
fun queueChapters(manga: Manga, chapters: List<Chapter>) {
|
||||
val source = sourceManager.get(manga.source) as? HttpSource ?: return
|
||||
fun queueChapters(manga: Manga, chapters: List<Chapter>, autoStart: Boolean) = launchUI {
|
||||
val source = sourceManager.get(manga.source) as? HttpSource ?: return@launchUI
|
||||
|
||||
val chaptersToQueue = chapters
|
||||
// Avoid downloading chapters with the same name.
|
||||
.distinctBy { it.name }
|
||||
// Add chapters to queue from the start.
|
||||
.sortedByDescending { it.source_order }
|
||||
// Create a downloader for each one.
|
||||
.map { Download(source, manga, it) }
|
||||
// Filter out those already queued or downloaded.
|
||||
.filter { isDownloadAllowed(it) }
|
||||
// Called in background thread, the operation can be slow with SAF.
|
||||
val chaptersWithoutDir = async {
|
||||
val mangaDir = provider.findMangaDir(manga, source)
|
||||
|
||||
// Return if there's nothing to queue.
|
||||
if (chaptersToQueue.isEmpty())
|
||||
return
|
||||
|
||||
queue.addAll(chaptersToQueue)
|
||||
|
||||
// Initialize queue size.
|
||||
notifier.initialQueueSize = queue.size
|
||||
|
||||
// Initial multi-thread
|
||||
notifier.multipleDownloadThreads = preferences.downloadThreads().getOrDefault() > 1
|
||||
|
||||
if (isRunning) {
|
||||
// Send the list of downloads to the downloader.
|
||||
downloadsRelay.call(chaptersToQueue)
|
||||
} else {
|
||||
// Show initial notification.
|
||||
notifier.onProgressChange(queue)
|
||||
chapters
|
||||
// Avoid downloading chapters with the same name.
|
||||
.distinctBy { it.name }
|
||||
// Filter out those already downloaded.
|
||||
.filter { mangaDir?.findFile(provider.getChapterDirName(it)) == null }
|
||||
// Add chapters to queue from the start.
|
||||
.sortedByDescending { it.source_order }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given download can be queued and downloaded.
|
||||
*
|
||||
* @param download the download to be checked.
|
||||
*/
|
||||
private fun isDownloadAllowed(download: Download): Boolean {
|
||||
// If the chapter is already queued, don't add it again
|
||||
if (queue.any { it.chapter.id == download.chapter.id })
|
||||
return false
|
||||
// Runs in main thread (synchronization needed).
|
||||
val chaptersToQueue = chaptersWithoutDir.await()
|
||||
// Filter out those already enqueued.
|
||||
.filter { chapter -> queue.none { it.chapter.id == chapter.id } }
|
||||
// Create a download for each one.
|
||||
.map { Download(source, manga, it) }
|
||||
|
||||
val dir = provider.findChapterDir(download.source, download.manga, download.chapter)
|
||||
if (dir != null && dir.exists())
|
||||
return false
|
||||
if (chaptersToQueue.isNotEmpty()) {
|
||||
queue.addAll(chaptersToQueue)
|
||||
|
||||
return true
|
||||
// Initialize queue size.
|
||||
notifier.initialQueueSize = queue.size
|
||||
|
||||
if (isRunning) {
|
||||
// Send the list of downloads to the downloader.
|
||||
downloadsRelay.call(chaptersToQueue)
|
||||
}
|
||||
|
||||
// Start downloader if needed
|
||||
if (autoStart) {
|
||||
DownloadService.start(this@Downloader.context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -275,9 +247,9 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
*
|
||||
* @param download the chapter to be downloaded.
|
||||
*/
|
||||
private fun downloadChapter(download: Download): Observable<Download> {
|
||||
private fun downloadChapter(download: Download): Observable<Download> = Observable.defer {
|
||||
val chapterDirname = provider.getChapterDirName(download.chapter)
|
||||
val mangaDir = provider.getMangaDir(download.source, download.manga)
|
||||
val mangaDir = provider.getMangaDir(download.manga, download.source)
|
||||
val tmpDir = mangaDir.createDirectory("${chapterDirname}_tmp")
|
||||
|
||||
val pageListObservable = if (download.pages == null) {
|
||||
@ -294,8 +266,8 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
Observable.just(download.pages!!)
|
||||
}
|
||||
|
||||
return pageListObservable
|
||||
.doOnNext { pages ->
|
||||
pageListObservable
|
||||
.doOnNext { _ ->
|
||||
// Delete all temporary (unfinished) files
|
||||
tmpDir.listFiles()
|
||||
?.filter { it.name!!.endsWith(".tmp") }
|
||||
@ -309,18 +281,18 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
// Start downloading images, consider we can have downloaded images already
|
||||
.concatMap { page -> getOrDownloadImage(page, download, tmpDir) }
|
||||
// Do when page is downloaded.
|
||||
.doOnNext { notifier.onProgressChange(download, queue) }
|
||||
.doOnNext { notifier.onProgressChange(download) }
|
||||
.toList()
|
||||
.map { pages -> download }
|
||||
.map { _ -> download }
|
||||
// Do after download completes
|
||||
.doOnNext { ensureSuccessfulDownload(download, tmpDir, chapterDirname) }
|
||||
.doOnNext { ensureSuccessfulDownload(download, mangaDir, tmpDir, chapterDirname) }
|
||||
// If the page list threw, it will resume here
|
||||
.onErrorReturn { error ->
|
||||
download.status = Download.ERROR
|
||||
notifier.onError(error.message, download.chapter.name)
|
||||
download
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@ -419,10 +391,13 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
* Checks if the download was successful.
|
||||
*
|
||||
* @param download the download to check.
|
||||
* @param mangaDir the manga directory of the download.
|
||||
* @param tmpDir the directory where the download is currently stored.
|
||||
* @param dirname the real (non temporary) directory name of the download.
|
||||
*/
|
||||
private fun ensureSuccessfulDownload(download: Download, tmpDir: UniFile, dirname: String) {
|
||||
private fun ensureSuccessfulDownload(download: Download, mangaDir: UniFile,
|
||||
tmpDir: UniFile, dirname: String) {
|
||||
|
||||
// Ensure that the chapter folder has all the images.
|
||||
val downloadedImages = tmpDir.listFiles().orEmpty().filterNot { it.name!!.endsWith(".tmp") }
|
||||
|
||||
@ -435,6 +410,7 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
// Only rename the directory if it's downloaded.
|
||||
if (download.status == Download.DOWNLOADED) {
|
||||
tmpDir.renameTo(dirname)
|
||||
cache.addChapter(dirname, mangaDir, download.manga)
|
||||
}
|
||||
}
|
||||
|
||||
@ -446,7 +422,6 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
if (download.status == Download.DOWNLOADED) {
|
||||
// remove downloaded chapter from queue
|
||||
queue.remove(download)
|
||||
notifier.onProgressChange(queue)
|
||||
}
|
||||
if (areAllDownloadsFinished()) {
|
||||
if (notifier.isSingleChapter && !notifier.errorThrown) {
|
||||
@ -463,4 +438,4 @@ class Downloader(private val context: Context, private val provider: DownloadPro
|
||||
return queue.none { it.status <= Download.DOWNLOADING }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -66,6 +66,7 @@ class DownloadQueue(
|
||||
val pageStatusSubject = PublishSubject.create<Int>()
|
||||
setPagesSubject(download.pages, pageStatusSubject)
|
||||
return@flatMap pageStatusSubject
|
||||
.onBackpressureBuffer()
|
||||
.filter { it == Page.READY }
|
||||
.map { download }
|
||||
|
||||
|
@ -1,35 +1,51 @@
|
||||
package eu.kanade.tachiyomi.data.glide
|
||||
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
open class FileFetcher(private val file: File) : DataFetcher<InputStream> {
|
||||
|
||||
private var data: InputStream? = null
|
||||
|
||||
override fun loadData(priority: Priority): InputStream {
|
||||
data = file.inputStream()
|
||||
return data!!
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
data?.let { data ->
|
||||
try {
|
||||
data.close()
|
||||
} catch (e: IOException) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
override fun getId(): String {
|
||||
return file.toString()
|
||||
}
|
||||
package eu.kanade.tachiyomi.data.glide
|
||||
|
||||
import android.content.ContentValues.TAG
|
||||
import android.util.Log
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import java.io.*
|
||||
|
||||
open class FileFetcher(private val file: File) : DataFetcher<InputStream> {
|
||||
|
||||
private var data: InputStream? = null
|
||||
|
||||
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in InputStream>) {
|
||||
loadFromFile(callback)
|
||||
}
|
||||
|
||||
protected fun loadFromFile(callback: DataFetcher.DataCallback<in InputStream>) {
|
||||
try {
|
||||
data = FileInputStream(file)
|
||||
} catch (e: FileNotFoundException) {
|
||||
if (Log.isLoggable(TAG, Log.DEBUG)) {
|
||||
Log.d(TAG, "Failed to open file", e)
|
||||
}
|
||||
callback.onLoadFailed(e)
|
||||
return
|
||||
}
|
||||
|
||||
callback.onDataReady(data)
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
try {
|
||||
data?.close()
|
||||
} catch (e: IOException) {
|
||||
// Ignored.
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
override fun getDataClass(): Class<InputStream> {
|
||||
return InputStream::class.java
|
||||
}
|
||||
|
||||
override fun getDataSource(): DataSource {
|
||||
return DataSource.LOCAL
|
||||
}
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
package eu.kanade.tachiyomi.data.glide
|
||||
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* A [DataFetcher] for loading a cover of a library manga.
|
||||
* It tries to load the cover from our custom cache, and if it's not found, it fallbacks to network
|
||||
* and copies the result to the cache.
|
||||
*
|
||||
* @param networkFetcher the network fetcher for this cover.
|
||||
* @param manga the manga of the cover to load.
|
||||
* @param file the file where this cover should be. It may exists or not.
|
||||
*/
|
||||
class LibraryMangaUrlFetcher(private val networkFetcher: DataFetcher<InputStream>,
|
||||
private val manga: Manga,
|
||||
private val file: File)
|
||||
: FileFetcher(file) {
|
||||
|
||||
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in InputStream>) {
|
||||
if (!file.exists()) {
|
||||
networkFetcher.loadData(priority, object : DataFetcher.DataCallback<InputStream> {
|
||||
override fun onDataReady(data: InputStream?) {
|
||||
if (data != null) {
|
||||
val tmpFile = File(file.path + ".tmp")
|
||||
try {
|
||||
// Retrieve destination stream, create parent folders if needed.
|
||||
val output = try {
|
||||
tmpFile.outputStream()
|
||||
} catch (e: FileNotFoundException) {
|
||||
tmpFile.parentFile.mkdirs()
|
||||
tmpFile.outputStream()
|
||||
}
|
||||
|
||||
// Copy the file and rename to the original.
|
||||
data.use { output.use { data.copyTo(output) } }
|
||||
tmpFile.renameTo(file)
|
||||
loadFromFile(callback)
|
||||
} catch (e: Exception) {
|
||||
tmpFile.delete()
|
||||
callback.onLoadFailed(e)
|
||||
}
|
||||
} else {
|
||||
callback.onLoadFailed(Exception("Null data"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoadFailed(e: Exception) {
|
||||
callback.onLoadFailed(e)
|
||||
}
|
||||
|
||||
})
|
||||
} else {
|
||||
loadFromFile(callback)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
super.cleanup()
|
||||
networkFetcher.cleanup()
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
super.cancel()
|
||||
networkFetcher.cancel()
|
||||
}
|
||||
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.glide
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import java.io.File
|
||||
|
||||
open class MangaFileFetcher(private val file: File, private val manga: Manga) : FileFetcher(file) {
|
||||
|
||||
/**
|
||||
* Returns the id for this manga's cover.
|
||||
*
|
||||
* Appending the file's modified date to the url, we can force Glide to skip its memory and disk
|
||||
* lookup step and fetch from our custom cache. This allows us to invalidate Glide's cache when
|
||||
* the file has changed. If the file doesn't exist it will append a 0.
|
||||
*/
|
||||
override fun getId(): String {
|
||||
return manga.thumbnail_url + file.lastModified()
|
||||
}
|
||||
}
|
@ -1,23 +1,24 @@
|
||||
package eu.kanade.tachiyomi.data.glide
|
||||
|
||||
import android.content.Context
|
||||
import android.util.LruCache
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.integration.okhttp3.OkHttpStreamFetcher
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import com.bumptech.glide.load.Options
|
||||
import com.bumptech.glide.load.model.*
|
||||
import com.bumptech.glide.load.model.stream.StreamModelLoader
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
|
||||
/**
|
||||
* A class for loading a cover associated with a [Manga] that can be present in our own cache.
|
||||
* Coupled with [MangaUrlFetcher], this class allows to implement the following flow:
|
||||
* Coupled with [LibraryMangaUrlFetcher], this class allows to implement the following flow:
|
||||
*
|
||||
* - Check in RAM LRU.
|
||||
* - Check in disk LRU.
|
||||
@ -26,7 +27,7 @@ import java.io.InputStream
|
||||
*
|
||||
* @param context the application context.
|
||||
*/
|
||||
class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
|
||||
class MangaModelLoader : ModelLoader<Manga, InputStream> {
|
||||
|
||||
/**
|
||||
* Cover cache where persistent covers are stored.
|
||||
@ -39,16 +40,15 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
|
||||
/**
|
||||
* Base network loader.
|
||||
* Default network client.
|
||||
*/
|
||||
private val baseUrlLoader = Glide.buildModelLoader(GlideUrl::class.java,
|
||||
InputStream::class.java, context)
|
||||
private val defaultClient = Injekt.get<NetworkHelper>().client
|
||||
|
||||
/**
|
||||
* LRU cache whose key is the thumbnail url of the manga, and the value contains the request url
|
||||
* and the file where it should be stored in case the manga is a favorite.
|
||||
*/
|
||||
private val lruCache = LruCache<String, Pair<GlideUrl, File>>(100)
|
||||
private val lruCache = LruCache<GlideUrl, File>(100)
|
||||
|
||||
/**
|
||||
* Map where request headers are stored for a source.
|
||||
@ -60,12 +60,17 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
|
||||
*/
|
||||
class Factory : ModelLoaderFactory<Manga, InputStream> {
|
||||
|
||||
override fun build(context: Context, factories: GenericLoaderFactory)
|
||||
= MangaModelLoader(context)
|
||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<Manga, InputStream> {
|
||||
return MangaModelLoader()
|
||||
}
|
||||
|
||||
override fun teardown() {}
|
||||
}
|
||||
|
||||
override fun handles(model: Manga): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a fetcher for the given manga or null if the url is empty.
|
||||
*
|
||||
@ -73,10 +78,8 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
|
||||
* @param width the width of the view where the resource will be loaded.
|
||||
* @param height the height of the view where the resource will be loaded.
|
||||
*/
|
||||
override fun getResourceFetcher(manga: Manga,
|
||||
width: Int,
|
||||
height: Int): DataFetcher<InputStream>? {
|
||||
|
||||
override fun buildLoadData(manga: Manga, width: Int, height: Int,
|
||||
options: Options): ModelLoader.LoadData<InputStream>? {
|
||||
// Check thumbnail is not null or empty
|
||||
val url = manga.thumbnail_url
|
||||
if (url == null || url.isEmpty()) {
|
||||
@ -85,26 +88,28 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
|
||||
|
||||
if (url.startsWith("http")) {
|
||||
val source = sourceManager.get(manga.source) as? HttpSource
|
||||
|
||||
// Obtain the request url and the file for this url from the LRU cache, or calculate it
|
||||
// and add them to the cache.
|
||||
val (glideUrl, file) = lruCache.get(url) ?:
|
||||
Pair(GlideUrl(url, getHeaders(manga, source)), coverCache.getCoverFile(url)).apply {
|
||||
lruCache.put(url, this)
|
||||
}
|
||||
val glideUrl = GlideUrl(url, getHeaders(manga, source))
|
||||
|
||||
// Get the resource fetcher for this request url.
|
||||
val networkFetcher = source?.let { OkHttpStreamFetcher(it.client, glideUrl) }
|
||||
?: baseUrlLoader.getResourceFetcher(glideUrl, width, height)
|
||||
val networkFetcher = OkHttpStreamFetcher(source?.client ?: defaultClient, glideUrl)
|
||||
|
||||
if (!manga.favorite) {
|
||||
return ModelLoader.LoadData(glideUrl, networkFetcher)
|
||||
}
|
||||
|
||||
// Obtain the file for this url from the LRU cache, or retrieve and add it to the cache.
|
||||
val file = lruCache.getOrPut(glideUrl) { coverCache.getCoverFile(url) }
|
||||
|
||||
val libraryFetcher = LibraryMangaUrlFetcher(networkFetcher, manga, file)
|
||||
|
||||
// Return an instance of the fetcher providing the needed elements.
|
||||
return MangaUrlFetcher(networkFetcher, file, manga)
|
||||
return ModelLoader.LoadData(MangaSignature(manga, file), libraryFetcher)
|
||||
} else {
|
||||
// Get the file from the url, removing the scheme if present.
|
||||
val file = File(url.substringAfter("file://"))
|
||||
|
||||
// Return an instance of the fetcher providing the needed elements.
|
||||
return MangaFileFetcher(file, manga)
|
||||
return ModelLoader.LoadData(MangaSignature(manga, file), FileFetcher(file))
|
||||
}
|
||||
}
|
||||
|
||||
@ -127,4 +132,15 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <K, V> LruCache<K, V>.getOrPut(key: K, defaultValue: () -> V): V {
|
||||
val value = get(key)
|
||||
return if (value == null) {
|
||||
val answer = defaultValue()
|
||||
put(key, answer)
|
||||
answer
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -0,0 +1,27 @@
|
||||
package eu.kanade.tachiyomi.data.glide
|
||||
|
||||
import com.bumptech.glide.load.Key
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
class MangaSignature(manga: Manga, file: File) : Key {
|
||||
|
||||
private val key = manga.thumbnail_url + file.lastModified()
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return if (other is MangaSignature) {
|
||||
key == other.key
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return key.hashCode()
|
||||
}
|
||||
|
||||
override fun updateDiskCacheKey(md: MessageDigest) {
|
||||
md.update(key.toByteArray(Key.CHARSET))
|
||||
}
|
||||
}
|
@ -1,71 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.glide
|
||||
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* A [DataFetcher] for loading a cover of a manga depending on its favorite status.
|
||||
* If the manga is favorite, it tries to load the cover from our cache, and if it's not found, it
|
||||
* fallbacks to network and copies it to the cache.
|
||||
* If the manga is not favorite, it tries to delete the cover from our cache and always fallback
|
||||
* to network for fetching.
|
||||
*
|
||||
* @param networkFetcher the network fetcher for this cover.
|
||||
* @param file the file where this cover should be. It may exists or not.
|
||||
* @param manga the manga of the cover to load.
|
||||
*/
|
||||
class MangaUrlFetcher(private val networkFetcher: DataFetcher<InputStream>,
|
||||
private val file: File,
|
||||
private val manga: Manga)
|
||||
: MangaFileFetcher(file, manga) {
|
||||
|
||||
override fun loadData(priority: Priority): InputStream {
|
||||
if (manga.favorite) {
|
||||
synchronized(file) {
|
||||
if (!file.exists()) {
|
||||
val tmpFile = File(file.path + ".tmp")
|
||||
try {
|
||||
// Retrieve source stream.
|
||||
val input = networkFetcher.loadData(priority)
|
||||
?: throw Exception("Couldn't open source stream")
|
||||
|
||||
// Retrieve destination stream, create parent folders if needed.
|
||||
val output = try {
|
||||
tmpFile.outputStream()
|
||||
} catch (e: FileNotFoundException) {
|
||||
tmpFile.parentFile.mkdirs()
|
||||
tmpFile.outputStream()
|
||||
}
|
||||
|
||||
// Copy the file and rename to the original.
|
||||
input.use { output.use { input.copyTo(output) } }
|
||||
tmpFile.renameTo(file)
|
||||
} catch (e: Exception) {
|
||||
tmpFile.delete()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.loadData(priority)
|
||||
} else {
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
}
|
||||
return networkFetcher.loadData(priority)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
networkFetcher.cancel()
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
super.cleanup()
|
||||
networkFetcher.cleanup()
|
||||
}
|
||||
|
||||
}
|
@ -1,12 +1,18 @@
|
||||
package eu.kanade.tachiyomi.data.glide
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.GlideBuilder
|
||||
import com.bumptech.glide.Registry
|
||||
import com.bumptech.glide.annotation.GlideModule
|
||||
import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
|
||||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
|
||||
import com.bumptech.glide.load.model.GlideUrl
|
||||
import com.bumptech.glide.module.GlideModule
|
||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
|
||||
import com.bumptech.glide.module.AppGlideModule
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import uy.kohesive.injekt.Injekt
|
||||
@ -16,17 +22,20 @@ import java.io.InputStream
|
||||
/**
|
||||
* Class used to update Glide module settings
|
||||
*/
|
||||
class AppGlideModule : GlideModule {
|
||||
@GlideModule
|
||||
class TachiGlideModule : AppGlideModule() {
|
||||
|
||||
override fun applyOptions(context: Context, builder: GlideBuilder) {
|
||||
// Set the cache size of Glide to 15 MiB
|
||||
builder.setDiskCache(InternalCacheDiskCacheFactory(context, 15 * 1024 * 1024))
|
||||
builder.setDiskCache(InternalCacheDiskCacheFactory(context, 50 * 1024 * 1024))
|
||||
builder.setDefaultRequestOptions(RequestOptions().format(DecodeFormat.PREFER_RGB_565))
|
||||
builder.setDefaultTransitionOptions(Drawable::class.java,
|
||||
DrawableTransitionOptions.withCrossFade())
|
||||
}
|
||||
|
||||
override fun registerComponents(context: Context, glide: Glide) {
|
||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||
val networkFactory = OkHttpUrlLoader.Factory(Injekt.get<NetworkHelper>().client)
|
||||
|
||||
glide.register(GlideUrl::class.java, InputStream::class.java, networkFactory)
|
||||
glide.register(Manga::class.java, InputStream::class.java, MangaModelLoader.Factory())
|
||||
registry.replace(GlideUrl::class.java, InputStream::class.java, networkFactory)
|
||||
registry.append(Manga::class.java, InputStream::class.java, MangaModelLoader.Factory())
|
||||
}
|
||||
}
|
@ -34,7 +34,6 @@ class LibraryUpdateJob : Job() {
|
||||
.setRequiredNetworkType(wifiRestriction)
|
||||
.setRequiresCharging(acRestriction)
|
||||
.setRequirementsEnforced(true)
|
||||
.setPersisted(true)
|
||||
.setUpdateCurrent(true)
|
||||
.build()
|
||||
.schedule()
|
||||
|
@ -10,16 +10,17 @@ import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import eu.kanade.tachiyomi.Constants
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.LibraryManga
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.data.download.DownloadService
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
@ -80,10 +81,12 @@ class LibraryUpdateService(
|
||||
/**
|
||||
* Cached progress notification to avoid creating a lot.
|
||||
*/
|
||||
private val progressNotification by lazy { NotificationCompat.Builder(this)
|
||||
private val progressNotification by lazy { NotificationCompat.Builder(this, Notifications.CHANNEL_LIBRARY)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
|
||||
.setLargeIcon(notificationBitmap)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.addAction(R.drawable.ic_clear_grey_24dp_img, getString(android.R.string.cancel), cancelIntent)
|
||||
}
|
||||
|
||||
@ -132,7 +135,11 @@ class LibraryUpdateService(
|
||||
putExtra(KEY_TARGET, target)
|
||||
category?.let { putExtra(KEY_CATEGORY, it.id) }
|
||||
}
|
||||
context.startService(intent)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
context.startService(intent)
|
||||
} else {
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -153,6 +160,7 @@ class LibraryUpdateService(
|
||||
*/
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
startForeground(Notifications.ID_LIBRARY_PROGRESS, progressNotification.build())
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK, "LibraryUpdateService:WakeLock")
|
||||
wakeLock.acquire()
|
||||
@ -224,7 +232,7 @@ class LibraryUpdateService(
|
||||
* @param target the target to update.
|
||||
* @return a list of manga to update
|
||||
*/
|
||||
fun getMangaToUpdate(intent: Intent, target: Target): List<Manga> {
|
||||
fun getMangaToUpdate(intent: Intent, target: Target): List<LibraryManga> {
|
||||
val categoryId = intent.getIntExtra(KEY_CATEGORY, -1)
|
||||
|
||||
var listToUpdate = if (categoryId != -1)
|
||||
@ -255,7 +263,7 @@ class LibraryUpdateService(
|
||||
* @param mangaToUpdate the list to update
|
||||
* @return an observable delivering the progress of each update.
|
||||
*/
|
||||
fun updateChapterList(mangaToUpdate: List<Manga>): Observable<Manga> {
|
||||
fun updateChapterList(mangaToUpdate: List<LibraryManga>): Observable<LibraryManga> {
|
||||
// Initialize the variables holding the progress of the updates.
|
||||
val count = AtomicInteger(0)
|
||||
// List containing new updates
|
||||
@ -279,7 +287,7 @@ class LibraryUpdateService(
|
||||
// If there's any error, return empty update and continue.
|
||||
.onErrorReturn {
|
||||
failedUpdates.add(manga)
|
||||
Pair(emptyList<Chapter>(), emptyList<Chapter>())
|
||||
Pair(emptyList(), emptyList())
|
||||
}
|
||||
// Filter out mangas without new chapters (or failed).
|
||||
.filter { pair -> pair.first.isNotEmpty() }
|
||||
@ -325,7 +333,9 @@ class LibraryUpdateService(
|
||||
val dbChapters = chapters.map {
|
||||
mangaChapters.find { mangaChapter -> mangaChapter.url == it.url }!!
|
||||
}
|
||||
downloadManager.downloadChapters(manga, dbChapters)
|
||||
// We don't want to start downloading while the library is updating, because websites
|
||||
// may don't like it and they could ban the user.
|
||||
downloadManager.downloadChapters(manga, dbChapters, false)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -347,7 +357,7 @@ class LibraryUpdateService(
|
||||
* @param mangaToUpdate the list to update
|
||||
* @return an observable delivering the progress of each update.
|
||||
*/
|
||||
fun updateDetails(mangaToUpdate: List<Manga>): Observable<Manga> {
|
||||
fun updateDetails(mangaToUpdate: List<LibraryManga>): Observable<LibraryManga> {
|
||||
// Initialize the variables holding the progress of the updates.
|
||||
val count = AtomicInteger(0)
|
||||
|
||||
@ -358,7 +368,7 @@ class LibraryUpdateService(
|
||||
// Update the details of the manga.
|
||||
.concatMap { manga ->
|
||||
val source = sourceManager.get(manga.source) as? HttpSource
|
||||
?: return@concatMap Observable.empty<Manga>()
|
||||
?: return@concatMap Observable.empty<LibraryManga>()
|
||||
|
||||
source.fetchMangaDetails(manga)
|
||||
.map { networkManga ->
|
||||
@ -377,7 +387,7 @@ class LibraryUpdateService(
|
||||
* Method that updates the metadata of the connected tracking services. It's called in a
|
||||
* background thread, so it's safe to do heavy operations or network calls here.
|
||||
*/
|
||||
private fun updateTrackings(mangaToUpdate: List<Manga>): Observable<Manga> {
|
||||
private fun updateTrackings(mangaToUpdate: List<LibraryManga>): Observable<LibraryManga> {
|
||||
// Initialize the variables holding the progress of the updates.
|
||||
var count = 0
|
||||
|
||||
@ -417,7 +427,7 @@ class LibraryUpdateService(
|
||||
* @param total the total progress.
|
||||
*/
|
||||
private fun showProgressNotification(manga: Manga, current: Int, total: Int) {
|
||||
notificationManager.notify(Constants.NOTIFICATION_LIBRARY_PROGRESS_ID, progressNotification
|
||||
notificationManager.notify(Notifications.ID_LIBRARY_PROGRESS, progressNotification
|
||||
.setContentTitle(manga.title)
|
||||
.setProgress(total, current, false)
|
||||
.build())
|
||||
@ -434,7 +444,7 @@ class LibraryUpdateService(
|
||||
// Append new chapters from a previous, existing notification
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val previousNotification = notificationManager.activeNotifications
|
||||
.find { it.id == Constants.NOTIFICATION_LIBRARY_RESULT_ID }
|
||||
.find { it.id == Notifications.ID_LIBRARY_RESULT }
|
||||
|
||||
if (previousNotification != null) {
|
||||
val oldUpdates = previousNotification.notification.extras
|
||||
@ -446,7 +456,7 @@ class LibraryUpdateService(
|
||||
}
|
||||
}
|
||||
|
||||
notificationManager.notify(Constants.NOTIFICATION_LIBRARY_RESULT_ID, notification {
|
||||
notificationManager.notify(Notifications.ID_LIBRARY_RESULT, notification(Notifications.CHANNEL_LIBRARY) {
|
||||
setSmallIcon(R.drawable.ic_book_white_24dp)
|
||||
setLargeIcon(notificationBitmap)
|
||||
setContentTitle(getString(R.string.notification_new_chapters))
|
||||
@ -466,7 +476,7 @@ class LibraryUpdateService(
|
||||
* Cancels the progress notification.
|
||||
*/
|
||||
private fun cancelProgressNotification() {
|
||||
notificationManager.cancel(Constants.NOTIFICATION_LIBRARY_PROGRESS_ID)
|
||||
notificationManager.cancel(Notifications.ID_LIBRARY_PROGRESS)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||
import eu.kanade.tachiyomi.util.getUriCompat
|
||||
import java.io.File
|
||||
@ -43,11 +44,10 @@ object NotificationHandler {
|
||||
* Returns [PendingIntent] that prompts user with apk install intent
|
||||
*
|
||||
* @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 uri = file.getUriCompat(context)
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
|
@ -5,7 +5,6 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import eu.kanade.tachiyomi.Constants
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
@ -41,6 +40,8 @@ class NotificationReceiver : BroadcastReceiver() {
|
||||
ACTION_RESUME_DOWNLOADS -> DownloadService.start(context)
|
||||
// Clear the download queue
|
||||
ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue(true)
|
||||
// Show message notification created
|
||||
ACTION_SHORTCUT_CREATED -> context.toast(R.string.shortcut_created)
|
||||
// Launch share activity and dismiss notification
|
||||
ACTION_SHARE_IMAGE -> shareImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION),
|
||||
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1))
|
||||
@ -48,7 +49,7 @@ class NotificationReceiver : BroadcastReceiver() {
|
||||
ACTION_DELETE_IMAGE -> deleteImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION),
|
||||
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1))
|
||||
// Cancel library update and dismiss notification
|
||||
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context, Constants.NOTIFICATION_LIBRARY_PROGRESS_ID)
|
||||
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context, Notifications.ID_LIBRARY_PROGRESS)
|
||||
// Open reader activity
|
||||
ACTION_OPEN_CHAPTER -> {
|
||||
openChapter(context, intent.getLongExtra(EXTRA_MANGA_ID, -1),
|
||||
@ -161,6 +162,9 @@ class NotificationReceiver : BroadcastReceiver() {
|
||||
// Called to clear downloads.
|
||||
private const val ACTION_CLEAR_DOWNLOADS = "$ID.$NAME.ACTION_CLEAR_DOWNLOADS"
|
||||
|
||||
// Called to notify user shortcut is created.
|
||||
private const val ACTION_SHORTCUT_CREATED = "$ID.$NAME.ACTION_SHORTCUT_CREATED"
|
||||
|
||||
// Called to dismiss notification.
|
||||
private const val ACTION_DISMISS_NOTIFICATION = "$ID.$NAME.ACTION_DISMISS_NOTIFICATION"
|
||||
|
||||
@ -199,6 +203,13 @@ class NotificationReceiver : BroadcastReceiver() {
|
||||
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
|
||||
internal fun shortcutCreatedBroadcast(context: Context) : PendingIntent {
|
||||
val intent = Intent(context, NotificationReceiver::class.java).apply {
|
||||
action = ACTION_SHORTCUT_CREATED
|
||||
}
|
||||
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [PendingIntent] that starts a service which dismissed the notification
|
||||
*
|
||||
|
@ -0,0 +1,54 @@
|
||||
package eu.kanade.tachiyomi.data.notification
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.util.notificationManager
|
||||
|
||||
/**
|
||||
* Class to manage the basic information of all the notifications used in the app.
|
||||
*/
|
||||
object Notifications {
|
||||
|
||||
/**
|
||||
* Common notification channel and ids used anywhere.
|
||||
*/
|
||||
const val CHANNEL_COMMON = "common_channel"
|
||||
const val ID_UPDATER = 1
|
||||
const val ID_DOWNLOAD_IMAGE = 2
|
||||
|
||||
/**
|
||||
* Notification channel and ids used by the library updater.
|
||||
*/
|
||||
const val CHANNEL_LIBRARY = "library_channel"
|
||||
const val ID_LIBRARY_PROGRESS = 101
|
||||
const val ID_LIBRARY_RESULT = 102
|
||||
|
||||
/**
|
||||
* Notification channel and ids used by the downloader.
|
||||
*/
|
||||
const val CHANNEL_DOWNLOADER = "downloader_channel"
|
||||
const val ID_DOWNLOAD_CHAPTER = 201
|
||||
const val ID_DOWNLOAD_CHAPTER_ERROR = 202
|
||||
|
||||
/**
|
||||
* Creates the notification channels introduced in Android Oreo.
|
||||
*
|
||||
* @param context The application context.
|
||||
*/
|
||||
fun createChannels(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
|
||||
val channels = listOf(
|
||||
NotificationChannel(CHANNEL_COMMON, context.getString(R.string.channel_common),
|
||||
NotificationManager.IMPORTANCE_LOW),
|
||||
NotificationChannel(CHANNEL_LIBRARY, context.getString(R.string.channel_library),
|
||||
NotificationManager.IMPORTANCE_LOW),
|
||||
NotificationChannel(CHANNEL_DOWNLOADER, context.getString(R.string.channel_downloader),
|
||||
NotificationManager.IMPORTANCE_LOW)
|
||||
)
|
||||
context.notificationManager.createNotificationChannels(channels)
|
||||
}
|
||||
}
|
@ -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 doubleTapAnimationSpeed = "pref_double_tap_anim_speed"
|
||||
|
||||
const val showPageNumber = "pref_show_page_number_key"
|
||||
|
||||
const val fullscreen = "fullscreen"
|
||||
@ -65,8 +67,6 @@ object PreferenceKeys {
|
||||
|
||||
const val downloadsDirectory = "download_directory"
|
||||
|
||||
const val downloadThreads = "pref_download_slots_key"
|
||||
|
||||
const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key"
|
||||
|
||||
const val numberOfBackups = "backup_slots"
|
||||
@ -105,10 +105,16 @@ object PreferenceKeys {
|
||||
|
||||
const val defaultCategory = "default_category"
|
||||
|
||||
const val downloadBadge = "display_download_badge"
|
||||
|
||||
@Deprecated("Use the preferences of the source")
|
||||
fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId"
|
||||
|
||||
@Deprecated("Use the preferences of the source")
|
||||
fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId"
|
||||
|
||||
fun sourceSharedPref(sourceId: Long) = "source_$sourceId"
|
||||
|
||||
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
|
||||
|
||||
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"
|
||||
|
@ -39,6 +39,8 @@ class PreferencesHelper(val context: Context) {
|
||||
|
||||
fun pageTransitions() = rxPrefs.getBoolean(Keys.enableTransitions, true)
|
||||
|
||||
fun doubleTapAnimSpeed() = rxPrefs.getInteger(Keys.doubleTapAnimationSpeed, 500)
|
||||
|
||||
fun showPageNumber() = rxPrefs.getBoolean(Keys.showPageNumber, true)
|
||||
|
||||
fun fullscreen() = rxPrefs.getBoolean(Keys.fullscreen, true)
|
||||
@ -121,8 +123,6 @@ class PreferencesHelper(val context: Context) {
|
||||
|
||||
fun downloadsDirectory() = rxPrefs.getString(Keys.downloadsDirectory, defaultDownloadsDir.toString())
|
||||
|
||||
fun downloadThreads() = rxPrefs.getInteger(Keys.downloadThreads, 1)
|
||||
|
||||
fun downloadOnlyOverWifi() = prefs.getBoolean(Keys.downloadOnlyOverWifi, true)
|
||||
|
||||
fun numberOfBackups() = rxPrefs.getInteger(Keys.numberOfBackups, 1)
|
||||
@ -141,6 +141,8 @@ class PreferencesHelper(val context: Context) {
|
||||
|
||||
fun libraryAsList() = rxPrefs.getBoolean(Keys.libraryAsList, false)
|
||||
|
||||
fun downloadBadge() = rxPrefs.getBoolean(Keys.downloadBadge, false)
|
||||
|
||||
fun filterDownloaded() = rxPrefs.getBoolean(Keys.filterDownloaded, false)
|
||||
|
||||
fun filterUnread() = rxPrefs.getBoolean(Keys.filterUnread, false)
|
||||
@ -163,4 +165,7 @@ class PreferencesHelper(val context: Context) {
|
||||
|
||||
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.DrawableRes
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import okhttp3.OkHttpClient
|
||||
@ -44,7 +45,7 @@ abstract class TrackService(val id: Int) {
|
||||
|
||||
abstract fun bind(track: Track): Observable<Track>
|
||||
|
||||
abstract fun search(query: String): Observable<List<Track>>
|
||||
abstract fun search(query: String): Observable<List<TrackSearch>>
|
||||
|
||||
abstract fun refresh(track: Track): Observable<Track>
|
||||
|
||||
|
@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
|
||||
@ -120,7 +121,7 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
||||
}
|
||||
}
|
||||
|
||||
override fun search(query: String): Observable<List<Track>> {
|
||||
override fun search(query: String): Observable<List<TrackSearch>> {
|
||||
return api.search(query)
|
||||
}
|
||||
|
||||
|
@ -5,6 +5,7 @@ import com.github.salomonbrys.kotson.int
|
||||
import com.github.salomonbrys.kotson.string
|
||||
import com.google.gson.JsonObject
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
@ -46,7 +47,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String): Observable<List<Track>> {
|
||||
fun search(query: String): Observable<List<TrackSearch>> {
|
||||
return rest.search(query, 1)
|
||||
.map { list ->
|
||||
list.filter { it.type != "Novel" }.map { it.toTrack() }
|
||||
@ -140,6 +141,11 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
private const val clientSecret = "nlGB5OmgE9YWq5dr3gIDbTQV0C"
|
||||
private const val clientUrl = "tachiyomi://anilist-auth"
|
||||
private const val baseUrl = "https://anilist.co/api/"
|
||||
private const val baseMangaUrl = "https://anilist.co/manga/"
|
||||
|
||||
fun mangaUrl(remoteId: Int): String {
|
||||
return baseMangaUrl + remoteId
|
||||
}
|
||||
|
||||
fun authUrl() = Uri.parse("${baseUrl}auth/authorize").buildUpon()
|
||||
.appendQueryParameter("grant_type", "authorization_code")
|
||||
|
@ -1,21 +1,44 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist
|
||||
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
data class ALManga(
|
||||
val id: Int,
|
||||
val title_romaji: String,
|
||||
val image_url_lge: String,
|
||||
val description: String?,
|
||||
val type: String,
|
||||
val publishing_status: String,
|
||||
val start_date_fuzzy: String,
|
||||
val total_chapters: Int) {
|
||||
|
||||
fun toTrack() = Track.create(TrackManager.ANILIST).apply {
|
||||
fun toTrack() = TrackSearch.create(TrackManager.ANILIST).apply {
|
||||
remote_id = this@ALManga.id
|
||||
title = title_romaji
|
||||
total_chapters = this@ALManga.total_chapters
|
||||
cover_url = image_url_lge
|
||||
summary = description ?: ""
|
||||
tracking_url = AnilistApi.mangaUrl(remote_id)
|
||||
publishing_status = this@ALManga.publishing_status
|
||||
publishing_type = type
|
||||
if (!start_date_fuzzy.isNullOrBlank()) {
|
||||
start_date = try {
|
||||
val inputDf = SimpleDateFormat("yyyyMMdd", Locale.US)
|
||||
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
val date = inputDf.parse(BuildConfig.BUILD_TIME)
|
||||
outputDf.format(date)
|
||||
} catch (e: Exception) {
|
||||
start_date_fuzzy.orEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,11 +83,11 @@ fun Track.toAnilistStatus() = when (status) {
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().getOrDefault()) {
|
||||
// 10 point
|
||||
// 10 point
|
||||
0 -> (score.toInt() / 10).toString()
|
||||
// 100 point
|
||||
// 100 point
|
||||
1 -> score.toInt().toString()
|
||||
// 5 stars
|
||||
// 5 stars
|
||||
2 -> when {
|
||||
score == 0f -> "0"
|
||||
score < 30 -> "1"
|
||||
@ -73,14 +96,14 @@ fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().getOrD
|
||||
score < 90 -> "4"
|
||||
else -> "5"
|
||||
}
|
||||
// Smiley
|
||||
// Smiley
|
||||
3 -> when {
|
||||
score == 0f -> "0"
|
||||
score <= 30 -> ":("
|
||||
score <= 60 -> ":|"
|
||||
else -> ":)"
|
||||
}
|
||||
// 10 point decimal
|
||||
// 10 point decimal
|
||||
4 -> (score / 10).toString()
|
||||
else -> throw Exception("Unknown score type")
|
||||
}
|
@ -6,6 +6,7 @@ import com.google.gson.Gson
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
@ -96,7 +97,7 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
|
||||
}
|
||||
}
|
||||
|
||||
override fun search(query: String): Observable<List<Track>> {
|
||||
override fun search(query: String): Observable<List<TrackSearch>> {
|
||||
return api.search(query)
|
||||
}
|
||||
|
||||
|
@ -4,6 +4,7 @@ import com.github.salomonbrys.kotson.*
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonObject
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
@ -27,25 +28,25 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
return Observable.defer {
|
||||
// @formatter:off
|
||||
val data = jsonObject(
|
||||
"type" to "libraryEntries",
|
||||
"attributes" to jsonObject(
|
||||
"status" to track.toKitsuStatus(),
|
||||
"progress" to track.last_chapter_read
|
||||
),
|
||||
"relationships" to jsonObject(
|
||||
"user" to jsonObject(
|
||||
"data" to jsonObject(
|
||||
"id" to userId,
|
||||
"type" to "users"
|
||||
)
|
||||
"type" to "libraryEntries",
|
||||
"attributes" to jsonObject(
|
||||
"status" to track.toKitsuStatus(),
|
||||
"progress" to track.last_chapter_read
|
||||
),
|
||||
"media" to jsonObject(
|
||||
"data" to jsonObject(
|
||||
"id" to track.remote_id,
|
||||
"type" to "manga"
|
||||
)
|
||||
"relationships" to jsonObject(
|
||||
"user" to jsonObject(
|
||||
"data" to jsonObject(
|
||||
"id" to userId,
|
||||
"type" to "users"
|
||||
)
|
||||
),
|
||||
"media" to jsonObject(
|
||||
"data" to jsonObject(
|
||||
"id" to track.remote_id,
|
||||
"type" to "manga"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
// @formatter:on
|
||||
|
||||
@ -61,13 +62,13 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
return Observable.defer {
|
||||
// @formatter:off
|
||||
val data = jsonObject(
|
||||
"type" to "libraryEntries",
|
||||
"id" to track.remote_id,
|
||||
"attributes" to jsonObject(
|
||||
"status" to track.toKitsuStatus(),
|
||||
"progress" to track.last_chapter_read,
|
||||
"ratingTwenty" to track.toKitsuScore()
|
||||
)
|
||||
"type" to "libraryEntries",
|
||||
"id" to track.remote_id,
|
||||
"attributes" to jsonObject(
|
||||
"status" to track.toKitsuStatus(),
|
||||
"progress" to track.last_chapter_read,
|
||||
"ratingTwenty" to track.toKitsuScore()
|
||||
)
|
||||
)
|
||||
// @formatter:on
|
||||
|
||||
@ -76,7 +77,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String): Observable<List<Track>> {
|
||||
fun search(query: String): Observable<List<TrackSearch>> {
|
||||
return rest.search(query)
|
||||
.map { json ->
|
||||
val data = json["data"].array
|
||||
@ -186,6 +187,11 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
private const val clientSecret = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
|
||||
private const val baseUrl = "https://kitsu.io/api/edge/"
|
||||
private const val loginUrl = "https://kitsu.io/api/"
|
||||
private const val baseMangaUrl = "https://kitsu.io/manga/"
|
||||
|
||||
fun mangaUrl(remoteId: Int): String {
|
||||
return baseMangaUrl + remoteId
|
||||
}
|
||||
|
||||
|
||||
fun refreshTokenRequest(token: String) = POST("${loginUrl}oauth/token",
|
||||
|
@ -5,24 +5,35 @@ import com.github.salomonbrys.kotson.*
|
||||
import com.google.gson.JsonObject
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
|
||||
open class KitsuManga(obj: JsonObject) {
|
||||
val id by obj.byInt
|
||||
val canonicalTitle by obj["attributes"].byString
|
||||
val chapterCount = obj["attributes"].obj.get("chapterCount").nullInt
|
||||
val type = obj["attributes"].obj.get("mangaType").nullString
|
||||
val type = obj["attributes"].obj.get("mangaType").nullString.orEmpty()
|
||||
val original by obj["attributes"].obj["posterImage"].byString
|
||||
val synopsis by obj["attributes"].byString
|
||||
val startDate = obj["attributes"].obj.get("startDate").nullString.orEmpty()
|
||||
open val status = obj["attributes"].obj.get("status").nullString.orEmpty()
|
||||
|
||||
@CallSuper
|
||||
open fun toTrack() = Track.create(TrackManager.KITSU).apply {
|
||||
open fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
|
||||
remote_id = this@KitsuManga.id
|
||||
title = canonicalTitle
|
||||
total_chapters = chapterCount ?: 0
|
||||
cover_url = original
|
||||
summary = synopsis
|
||||
tracking_url = KitsuApi.mangaUrl(remote_id)
|
||||
publishing_status = this@KitsuManga.status
|
||||
publishing_type = type
|
||||
start_date = startDate.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
class KitsuLibManga(obj: JsonObject, manga: JsonObject) : KitsuManga(manga) {
|
||||
val remoteId by obj.byInt("id")
|
||||
val status by obj["attributes"].byString
|
||||
override val status by obj["attributes"].byString
|
||||
val ratingTwenty = obj["attributes"].obj.get("ratingTwenty").nullString
|
||||
val progress by obj["attributes"].byInt
|
||||
|
||||
|
@ -0,0 +1,62 @@
|
||||
package eu.kanade.tachiyomi.data.track.model
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
|
||||
class TrackSearch : Track {
|
||||
|
||||
override var id: Long? = null
|
||||
|
||||
override var manga_id: Long = 0
|
||||
|
||||
override var sync_id: Int = 0
|
||||
|
||||
override var remote_id: Int = 0
|
||||
|
||||
override lateinit var title: String
|
||||
|
||||
override var last_chapter_read: Int = 0
|
||||
|
||||
override var total_chapters: Int = 0
|
||||
|
||||
override var score: Float = 0f
|
||||
|
||||
override var status: Int = 0
|
||||
|
||||
override lateinit var tracking_url: String
|
||||
|
||||
var cover_url: String = ""
|
||||
|
||||
var summary: String = ""
|
||||
|
||||
var publishing_status: String = ""
|
||||
|
||||
var publishing_type: String = ""
|
||||
|
||||
var start_date: String = ""
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || javaClass != other.javaClass) return false
|
||||
|
||||
other as Track
|
||||
|
||||
if (manga_id != other.manga_id) return false
|
||||
if (sync_id != other.sync_id) return false
|
||||
return remote_id == other.remote_id
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = (manga_id xor manga_id.ushr(32)).toInt()
|
||||
result = 31 * result + sync_id
|
||||
result = 31 * result + remote_id
|
||||
return result
|
||||
}
|
||||
companion object {
|
||||
|
||||
fun create(serviceId: Int): TrackSearch = TrackSearch().apply {
|
||||
sync_id = serviceId
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.graphics.Color
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
@ -81,7 +82,7 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
|
||||
}
|
||||
}
|
||||
|
||||
override fun search(query: String): Observable<List<Track>> {
|
||||
override fun search(query: String): Observable<List<TrackSearch>> {
|
||||
return api.search(query, getUsername())
|
||||
}
|
||||
|
||||
|
@ -4,6 +4,7 @@ import android.net.Uri
|
||||
import android.util.Xml
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.asObservable
|
||||
@ -12,6 +13,7 @@ import eu.kanade.tachiyomi.util.selectInt
|
||||
import eu.kanade.tachiyomi.util.selectText
|
||||
import okhttp3.*
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.parser.Parser
|
||||
import org.xmlpull.v1.XmlSerializer
|
||||
import rx.Observable
|
||||
import java.io.StringWriter
|
||||
@ -36,7 +38,7 @@ class MyanimelistApi(private val client: OkHttpClient, username: String, passwor
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String, username: String): Observable<List<Track>> {
|
||||
fun search(query: String, username: String): Observable<List<TrackSearch>> {
|
||||
return if (query.startsWith(PREFIX_MY)) {
|
||||
val realQuery = query.substring(PREFIX_MY.length).toLowerCase().trim()
|
||||
getList(username)
|
||||
@ -46,34 +48,42 @@ class MyanimelistApi(private val client: OkHttpClient, username: String, passwor
|
||||
} else {
|
||||
client.newCall(GET(getSearchUrl(query), headers))
|
||||
.asObservable()
|
||||
.map { Jsoup.parse(it.body()!!.string()) }
|
||||
.map { Jsoup.parse(Parser.unescapeEntities(it.body()!!.string(), false), "", Parser.xmlParser()) }
|
||||
.flatMap { Observable.from(it.select("entry")) }
|
||||
.filter { it.select("type").text() != "Novel" }
|
||||
.map {
|
||||
Track.create(TrackManager.MYANIMELIST).apply {
|
||||
TrackSearch.create(TrackManager.MYANIMELIST).apply {
|
||||
title = it.selectText("title")!!
|
||||
remote_id = it.selectInt("id")
|
||||
total_chapters = it.selectInt("chapters")
|
||||
summary = it.selectText("synopsis")!!
|
||||
cover_url = it.selectText("image")!!
|
||||
tracking_url = MyanimelistApi.mangaUrl(remote_id)
|
||||
publishing_status = it.selectText("status")!!
|
||||
publishing_type = it.selectText("type")!!
|
||||
start_date = it.selectText("start_date")!!
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
fun getList(username: String): Observable<List<Track>> {
|
||||
fun getList(username: String): Observable<List<TrackSearch>> {
|
||||
return client
|
||||
.newCall(GET(getListUrl(username), headers))
|
||||
.asObservable()
|
||||
.map { Jsoup.parse(it.body()!!.string()) }
|
||||
.map { Jsoup.parse(Parser.unescapeEntities(it.body()!!.string(), false), "", Parser.xmlParser()) }
|
||||
.flatMap { Observable.from(it.select("manga")) }
|
||||
.map {
|
||||
Track.create(TrackManager.MYANIMELIST).apply {
|
||||
TrackSearch.create(TrackManager.MYANIMELIST).apply {
|
||||
title = it.selectText("series_title")!!
|
||||
remote_id = it.selectInt("series_mangadb_id")
|
||||
last_chapter_read = it.selectInt("my_read_chapters")
|
||||
status = it.selectInt("my_status")
|
||||
score = it.selectInt("my_score").toFloat()
|
||||
total_chapters = it.selectInt("series_chapters")
|
||||
cover_url = it.selectText("series_image")!!
|
||||
tracking_url = MyanimelistApi.mangaUrl(remote_id)
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
@ -176,6 +186,11 @@ class MyanimelistApi(private val client: OkHttpClient, username: String, passwor
|
||||
|
||||
companion object {
|
||||
const val baseUrl = "https://myanimelist.net"
|
||||
const val baseMangaUrl = baseUrl + "/manga/"
|
||||
|
||||
fun mangaUrl(remoteId: Int): String {
|
||||
return baseMangaUrl + remoteId
|
||||
}
|
||||
|
||||
private val ENTRY_TAG = "entry"
|
||||
private val CHAPTER_TAG = "chapter"
|
||||
|
@ -11,8 +11,8 @@ import com.google.gson.annotations.SerializedName
|
||||
* @param assets assets of latest release.
|
||||
*/
|
||||
class GithubRelease(@SerializedName("tag_name") val version: String,
|
||||
@SerializedName("body") val changeLog: String,
|
||||
@SerializedName("assets") val assets: List<Assets>) {
|
||||
@SerializedName("body") val changeLog: String,
|
||||
@SerializedName("assets") private val assets: List<Assets>) {
|
||||
|
||||
/**
|
||||
* Get download link of latest release from the assets.
|
||||
|
@ -1,10 +1,13 @@
|
||||
package eu.kanade.tachiyomi.data.updater
|
||||
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import retrofit2.http.GET
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
/**
|
||||
* Used to connect with the Github API.
|
||||
@ -17,6 +20,7 @@ interface GithubService {
|
||||
.baseUrl("https://api.github.com")
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||
.client(Injekt.get<NetworkHelper>().client)
|
||||
.build()
|
||||
|
||||
return restAdapter.create(GithubService::class.java)
|
||||
|
@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.data.updater
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import rx.Observable
|
||||
|
||||
class GithubUpdateChecker() {
|
||||
class GithubUpdateChecker {
|
||||
|
||||
private val service: GithubService = GithubService.create()
|
||||
|
||||
|
@ -3,5 +3,5 @@ package eu.kanade.tachiyomi.data.updater
|
||||
sealed class GithubUpdateResult {
|
||||
|
||||
class NewUpdate(val release: GithubRelease): GithubUpdateResult()
|
||||
class NoNewUpdate(): GithubUpdateResult()
|
||||
class NoNewUpdate : GithubUpdateResult()
|
||||
}
|
@ -1,144 +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.Constants
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationHandler
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
||||
import eu.kanade.tachiyomi.util.notificationManager
|
||||
import java.io.File
|
||||
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
||||
|
||||
/**
|
||||
* Local [BroadcastReceiver] that runs on UI thread
|
||||
* Notification calls from [UpdateDownloaderService] should be made from here.
|
||||
*/
|
||||
internal class UpdateDownloaderReceiver(val context: Context) : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
private const val NAME = "UpdateDownloaderReceiver"
|
||||
|
||||
// Called to show initial notification.
|
||||
internal const val NOTIFICATION_UPDATER_INITIAL = "$ID.$NAME.UPDATER_INITIAL"
|
||||
|
||||
// Called to show progress notification.
|
||||
internal const val NOTIFICATION_UPDATER_PROGRESS = "$ID.$NAME.UPDATER_PROGRESS"
|
||||
|
||||
// Called to show install notification.
|
||||
internal const val NOTIFICATION_UPDATER_INSTALL = "$ID.$NAME.UPDATER_INSTALL"
|
||||
|
||||
// Called to show error notification
|
||||
internal const val NOTIFICATION_UPDATER_ERROR = "$ID.$NAME.UPDATER_ERROR"
|
||||
|
||||
// Value containing action of BroadcastReceiver
|
||||
internal const val EXTRA_ACTION = "$ID.$NAME.ACTION"
|
||||
|
||||
// Value containing progress
|
||||
internal const val EXTRA_PROGRESS = "$ID.$NAME.PROGRESS"
|
||||
|
||||
// Value containing apk path
|
||||
internal const val EXTRA_APK_PATH = "$ID.$NAME.APK_PATH"
|
||||
|
||||
// Value containing apk url
|
||||
internal const val EXTRA_APK_URL = "$ID.$NAME.APK_URL"
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification shown to user
|
||||
*/
|
||||
private val notification = NotificationCompat.Builder(context)
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.getStringExtra(EXTRA_ACTION)) {
|
||||
NOTIFICATION_UPDATER_INITIAL -> basicNotification()
|
||||
NOTIFICATION_UPDATER_PROGRESS -> updateProgress(intent.getIntExtra(EXTRA_PROGRESS, 0))
|
||||
NOTIFICATION_UPDATER_INSTALL -> installNotification(intent.getStringExtra(EXTRA_APK_PATH))
|
||||
NOTIFICATION_UPDATER_ERROR -> errorNotification(intent.getStringExtra(EXTRA_APK_URL))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to show basic notification
|
||||
*/
|
||||
private fun basicNotification() {
|
||||
// Create notification
|
||||
with(notification) {
|
||||
setContentTitle(context.getString(R.string.app_name))
|
||||
setContentText(context.getString(R.string.update_check_notification_download_in_progress))
|
||||
setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
setOngoing(true)
|
||||
}
|
||||
notification.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to show progress notification
|
||||
*
|
||||
* @param progress progress of download
|
||||
*/
|
||||
private fun updateProgress(progress: Int) {
|
||||
with(notification) {
|
||||
setProgress(100, progress, false)
|
||||
}
|
||||
notification.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to show install notification
|
||||
*
|
||||
* @param path path of file
|
||||
*/
|
||||
private fun installNotification(path: String) {
|
||||
// Prompt the user to install the new update.
|
||||
with(notification) {
|
||||
setContentText(context.getString(R.string.update_check_notification_download_complete))
|
||||
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
setProgress(0, 0, false)
|
||||
// Install action
|
||||
setContentIntent(NotificationHandler.installApkPendingActivity(context, File(path)))
|
||||
addAction(R.drawable.ic_system_update_grey_24dp_img,
|
||||
context.getString(R.string.action_install),
|
||||
NotificationHandler.installApkPendingActivity(context, File(path)))
|
||||
// Cancel action
|
||||
addAction(R.drawable.ic_clear_grey_24dp_img,
|
||||
context.getString(R.string.action_cancel),
|
||||
NotificationReceiver.dismissNotificationPendingBroadcast(context, Constants.NOTIFICATION_UPDATER_ID))
|
||||
}
|
||||
notification.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called to show error notification
|
||||
*
|
||||
* @param url url of apk
|
||||
*/
|
||||
private fun errorNotification(url: String) {
|
||||
// Prompt the user to retry the download.
|
||||
with(notification) {
|
||||
setContentText(context.getString(R.string.update_check_notification_download_error))
|
||||
setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||
setProgress(0, 0, false)
|
||||
// Retry action
|
||||
addAction(R.drawable.ic_refresh_grey_24dp_img,
|
||||
context.getString(R.string.action_retry),
|
||||
UpdateDownloaderService.downloadApkPendingService(context, url))
|
||||
// Cancel action
|
||||
addAction(R.drawable.ic_clear_grey_24dp_img,
|
||||
context.getString(R.string.action_cancel),
|
||||
NotificationReceiver.dismissNotificationPendingBroadcast(context, Constants.NOTIFICATION_UPDATER_ID))
|
||||
}
|
||||
notification.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a notification from this builder.
|
||||
*
|
||||
* @param id the id of the notification.
|
||||
*/
|
||||
private fun NotificationCompat.Builder.show(id: Int = Constants.NOTIFICATION_UPDATER_ID) {
|
||||
context.notificationManager.notify(id, build())
|
||||
}
|
||||
}
|
@ -1,68 +1,67 @@
|
||||
package eu.kanade.tachiyomi.data.updater
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import com.evernote.android.job.Job
|
||||
import com.evernote.android.job.JobManager
|
||||
import com.evernote.android.job.JobRequest
|
||||
import eu.kanade.tachiyomi.Constants.NOTIFICATION_UPDATER_ID
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.util.notificationManager
|
||||
|
||||
class UpdateCheckerJob : Job() {
|
||||
|
||||
override fun onRunJob(params: Params): Result {
|
||||
return GithubUpdateChecker()
|
||||
.checkForUpdate()
|
||||
.map { result ->
|
||||
if (result is GithubUpdateResult.NewUpdate) {
|
||||
val url = result.release.downloadLink
|
||||
|
||||
val intent = Intent(context, UpdateDownloaderService::class.java).apply {
|
||||
putExtra(UpdateDownloaderService.EXTRA_DOWNLOAD_URL, url)
|
||||
}
|
||||
|
||||
NotificationCompat.Builder(context).update {
|
||||
setContentTitle(context.getString(R.string.app_name))
|
||||
setContentText(context.getString(R.string.update_check_notification_update_available))
|
||||
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
// Download action
|
||||
addAction(android.R.drawable.stat_sys_download_done,
|
||||
context.getString(R.string.action_download),
|
||||
PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
}
|
||||
}
|
||||
Job.Result.SUCCESS
|
||||
}
|
||||
.onErrorReturn { Job.Result.FAILURE }
|
||||
// Sadly, the task needs to be synchronous.
|
||||
.toBlocking()
|
||||
.single()
|
||||
}
|
||||
|
||||
fun NotificationCompat.Builder.update(block: NotificationCompat.Builder.() -> Unit) {
|
||||
block()
|
||||
context.notificationManager.notify(NOTIFICATION_UPDATER_ID, build())
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "UpdateChecker"
|
||||
|
||||
fun setupTask() {
|
||||
JobRequest.Builder(TAG)
|
||||
.setPeriodic(24 * 60 * 60 * 1000, 60 * 60 * 1000)
|
||||
.setRequiredNetworkType(JobRequest.NetworkType.CONNECTED)
|
||||
.setRequirementsEnforced(true)
|
||||
.setPersisted(true)
|
||||
.setUpdateCurrent(true)
|
||||
.build()
|
||||
.schedule()
|
||||
}
|
||||
|
||||
fun cancelTask() {
|
||||
JobManager.instance().cancelAllForTag(TAG)
|
||||
}
|
||||
}
|
||||
|
||||
package eu.kanade.tachiyomi.data.updater
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import com.evernote.android.job.Job
|
||||
import com.evernote.android.job.JobManager
|
||||
import com.evernote.android.job.JobRequest
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.util.notificationManager
|
||||
|
||||
class UpdaterJob : Job() {
|
||||
|
||||
override fun onRunJob(params: Params): Result {
|
||||
return GithubUpdateChecker()
|
||||
.checkForUpdate()
|
||||
.map { result ->
|
||||
if (result is GithubUpdateResult.NewUpdate) {
|
||||
val url = result.release.downloadLink
|
||||
|
||||
val intent = Intent(context, UpdaterService::class.java).apply {
|
||||
putExtra(UpdaterService.EXTRA_DOWNLOAD_URL, url)
|
||||
}
|
||||
|
||||
NotificationCompat.Builder(context, Notifications.CHANNEL_COMMON).update {
|
||||
setContentTitle(context.getString(R.string.app_name))
|
||||
setContentText(context.getString(R.string.update_check_notification_update_available))
|
||||
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
// Download action
|
||||
addAction(android.R.drawable.stat_sys_download_done,
|
||||
context.getString(R.string.action_download),
|
||||
PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
}
|
||||
}
|
||||
Job.Result.SUCCESS
|
||||
}
|
||||
.onErrorReturn { Job.Result.FAILURE }
|
||||
// Sadly, the task needs to be synchronous.
|
||||
.toBlocking()
|
||||
.single()
|
||||
}
|
||||
|
||||
fun NotificationCompat.Builder.update(block: NotificationCompat.Builder.() -> Unit) {
|
||||
block()
|
||||
context.notificationManager.notify(Notifications.ID_UPDATER, build())
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "UpdateChecker"
|
||||
|
||||
fun setupTask() {
|
||||
JobRequest.Builder(TAG)
|
||||
.setPeriodic(24 * 60 * 60 * 1000, 60 * 60 * 1000)
|
||||
.setRequiredNetworkType(JobRequest.NetworkType.CONNECTED)
|
||||
.setRequirementsEnforced(true)
|
||||
.setUpdateCurrent(true)
|
||||
.build()
|
||||
.schedule()
|
||||
}
|
||||
|
||||
fun cancelTask() {
|
||||
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.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.network.ProgressListener
|
||||
import eu.kanade.tachiyomi.network.newCallWithProgress
|
||||
import eu.kanade.tachiyomi.util.registerLocalReceiver
|
||||
import eu.kanade.tachiyomi.util.getUriCompat
|
||||
import eu.kanade.tachiyomi.util.saveTo
|
||||
import eu.kanade.tachiyomi.util.sendLocalBroadcastSync
|
||||
import eu.kanade.tachiyomi.util.unregisterLocalReceiver
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
|
||||
class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.java.name) {
|
||||
class UpdaterService : IntentService(UpdaterService::class.java.name) {
|
||||
/**
|
||||
* Network helper
|
||||
*/
|
||||
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)
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Register receiver
|
||||
registerLocalReceiver(updaterNotificationReceiver, IntentFilter(INTENT_FILTER_NAME))
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
// Unregister receiver
|
||||
unregisterLocalReceiver(updaterNotificationReceiver)
|
||||
super.onDestroy()
|
||||
}
|
||||
private val notifier by lazy { UpdaterNotifier(this) }
|
||||
|
||||
override fun onHandleIntent(intent: Intent?) {
|
||||
if (intent == null) return
|
||||
|
||||
val title = intent.getStringExtra(EXTRA_DOWNLOAD_TITLE) ?: getString(R.string.app_name)
|
||||
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
|
||||
*/
|
||||
fun downloadApk(url: String) {
|
||||
private fun downloadApk(title: String, url: String) {
|
||||
// Show notification download starting.
|
||||
sendInitialBroadcast()
|
||||
notifier.onDownloadStarted(title)
|
||||
|
||||
val progressListener = object : ProgressListener {
|
||||
|
||||
@ -73,7 +58,7 @@ class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.jav
|
||||
if (progress > savedProgress && currentTime - 200 > lastTick) {
|
||||
savedProgress = progress
|
||||
lastTick = currentTime
|
||||
sendProgressBroadcast(progress)
|
||||
notifier.onProgressChange(progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -91,80 +76,32 @@ class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.jav
|
||||
response.close()
|
||||
throw Exception("Unsuccessful response")
|
||||
}
|
||||
sendInstallBroadcast(apkFile.absolutePath)
|
||||
notifier.onDownloadFinished(apkFile.getUriCompat(this))
|
||||
} catch (error: Exception) {
|
||||
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 {
|
||||
/**
|
||||
* Name of Local BroadCastReceiver.
|
||||
*/
|
||||
private val INTENT_FILTER_NAME = UpdateDownloaderService::class.java.name
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param context the application context.
|
||||
* @param url the url to the new update.
|
||||
*/
|
||||
fun downloadUpdate(context: Context, url: String) {
|
||||
val intent = Intent(context, UpdateDownloaderService::class.java).apply {
|
||||
fun downloadUpdate(context: Context, url: String, title: String = context.getString(R.string.app_name)) {
|
||||
val intent = Intent(context, UpdaterService::class.java).apply {
|
||||
putExtra(EXTRA_DOWNLOAD_TITLE, title)
|
||||
putExtra(EXTRA_DOWNLOAD_URL, url)
|
||||
}
|
||||
context.startService(intent)
|
||||
@ -177,7 +114,7 @@ class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.jav
|
||||
* @return [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)
|
||||
}
|
||||
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,6 +1,7 @@
|
||||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import com.squareup.duktape.Duktape
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
@ -14,12 +15,14 @@ class CloudflareInterceptor : Interceptor {
|
||||
|
||||
private val challengePattern = Regex("""name="jschl_vc" value="(\w+)"""")
|
||||
|
||||
private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare")
|
||||
|
||||
@Synchronized
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val response = chain.proceed(chain.request())
|
||||
|
||||
// Check if Cloudflare anti-bot is on
|
||||
if (response.code() == 503 && "cloudflare-nginx" == response.header("Server")) {
|
||||
if (response.code() == 503 && response.header("Server") in serverCheck) {
|
||||
return chain.proceed(resolveChallenge(response))
|
||||
}
|
||||
|
||||
@ -41,32 +44,33 @@ class CloudflareInterceptor : Interceptor {
|
||||
val pass = passPattern.find(content)?.groups?.get(1)?.value
|
||||
|
||||
if (operation == null || challenge == null || pass == null) {
|
||||
throw RuntimeException("Failed resolving Cloudflare challenge")
|
||||
throw Exception("Failed resolving Cloudflare challenge")
|
||||
}
|
||||
|
||||
val js = operation
|
||||
.replace(Regex("""a\.value =(.+?) \+.*"""), "$1")
|
||||
.replace(Regex("""a\.value = (.+ \+ t\.length).+"""), "$1")
|
||||
.replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "")
|
||||
.replace("t.length", "${domain.length}")
|
||||
.replace("\n", "")
|
||||
|
||||
val result = (duktape.evaluate(js) as Double).toInt()
|
||||
|
||||
val answer = "${result + domain.length}"
|
||||
val result = duktape.evaluate(js) as Double
|
||||
|
||||
val cloudflareUrl = HttpUrl.parse("${url.scheme()}://$domain/cdn-cgi/l/chk_jschl")!!
|
||||
.newBuilder()
|
||||
.addQueryParameter("jschl_vc", challenge)
|
||||
.addQueryParameter("pass", pass)
|
||||
.addQueryParameter("jschl_answer", answer)
|
||||
.addQueryParameter("jschl_answer", "$result")
|
||||
.toString()
|
||||
|
||||
val cloudflareHeaders = originalRequest.headers()
|
||||
.newBuilder()
|
||||
.add("Referer", url.toString())
|
||||
.add("Accept", "text/html,application/xhtml+xml,application/xml")
|
||||
.add("Accept-Language", "en")
|
||||
.build()
|
||||
|
||||
return GET(cloudflareUrl, cloudflareHeaders)
|
||||
return GET(cloudflareUrl, cloudflareHeaders, cache = CacheControl.Builder().build())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|