Compare commits

...

170 Commits

Author SHA1 Message Date
9baf3b5a09 Release 0.8.1 2019-03-15 16:50:13 +01:00
ca3f0873f3 Extract hardcoded strings from layouts 2019-03-15 08:48:12 +01:00
adb0201449 Translations (#1750)
* Translated using Weblate (Indonesian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/id/

* Added translation using Weblate (Czech)

* Translated using Weblate (Czech)

Currently translated at 45.6% (194 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/cs/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/es/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/es/

* Added translation using Weblate (Greek)

* Translated using Weblate (Greek)

Currently translated at 99.8% (424 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/el/

* Translated using Weblate (Italian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/it/

* Translated using Weblate (Romanian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ro/

* Added translation using Weblate (Swedish)

* Translated using Weblate (Swedish)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sv/

* Translated using Weblate (Czech)

Currently translated at 45.6% (194 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/cs/

* Translated using Weblate (Italian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/it/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/zh_Hans/

* Translated using Weblate (Romanian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ro/

* Translated using Weblate (German)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/de/

* Translated using Weblate (Russian)

Currently translated at 98.1% (417 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/

* Translated using Weblate (Russian)

Currently translated at 98.4% (418 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/

* Translated using Weblate (Russian)

Currently translated at 98.4% (418 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/

* Translated using Weblate (Italian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/it/

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/bg/

* Translated using Weblate (Portuguese)

Currently translated at 80.5% (342 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt/

* Translated using Weblate (Portuguese)

Currently translated at 80.5% (342 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt/

* Translated using Weblate (Portuguese)

Currently translated at 80.5% (342 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt/

* Translated using Weblate (Russian)

Currently translated at 98.8% (420 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/

* Translated using Weblate (Russian)

Currently translated at 98.8% (420 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/

* Translated using Weblate (Hindi)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hi/

* Translated using Weblate (Russian)

Currently translated at 99.3% (422 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/bg/

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/bg/

* Translated using Weblate (Bulgarian)

Currently translated at 97.9% (416 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/bg/

* Translated using Weblate (Bengali)

Currently translated at 99.5% (423 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/bn/

* Translated using Weblate (Czech)

Currently translated at 80.7% (343 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/cs/

* Translated using Weblate (Russian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/

* Translated using Weblate (Russian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/

* Translated using Weblate (Russian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/

* Translated using Weblate (Russian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/

* Translated using Weblate (Russian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/

* Translated using Weblate (Russian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/

* Translated using Weblate (Russian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/

* Translated using Weblate (Czech)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/cs/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/es/

* Translated using Weblate (Vietnamese)

Currently translated at 86.8% (369 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/vi/

* Translated using Weblate (Russian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/

* Translated using Weblate (Japanese)

Currently translated at 43.1% (183 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ja/

* Translated using Weblate (Bulgarian)

Currently translated at 99.8% (424 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/bg/

* Translated using Weblate (Greek)

Currently translated at 99.8% (424 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/el/

* Translated using Weblate (Bengali)

Currently translated at 99.8% (424 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/bn/

* Translated using Weblate (Vietnamese)

Currently translated at 88.0% (374 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/vi/

* Translated using Weblate (English)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/en/

* Added translation using Weblate (Norwegian Bokmål)

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 31.8% (135 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/nb_NO/

* Translated using Weblate (Hindi)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hi/

* Translated using Weblate (Vietnamese)

Currently translated at 90.8% (386 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/vi/

* Added translation using Weblate (Thai)

* Translated using Weblate (Thai)

Currently translated at 2.4% (10 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/th/

* Translated using Weblate (Arabic)

Currently translated at 99.8% (424 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ar/

* Translated using Weblate (Czech)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/cs/

* Translated using Weblate (Polish)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pl/

* Added translation using Weblate (Catalan)

* Translated using Weblate (Catalan)

Currently translated at 19.1% (81 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ca/

* Translated using Weblate (Polish)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pl/

* Translated using Weblate (Catalan)

Currently translated at 32.9% (140 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ca/

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/bg/

* Translated using Weblate (Catalan)

Currently translated at 45.9% (195 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ca/

* Translated using Weblate (French)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fr/

* Translated using Weblate (Portuguese)

Currently translated at 81.2% (345 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt/

* Translated using Weblate (Hungarian)

Currently translated at 39.8% (169 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hu/

* Added translation using Weblate (Serbian)

* Translated using Weblate (Serbian)

Currently translated at 57.2% (243 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sr/

* Translated using Weblate (Serbian)

Currently translated at 75.1% (319 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sr/

* Translated using Weblate (Dutch)

Currently translated at 87.3% (371 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/nl/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/es/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/es/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/es/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/es/
2019-03-15 08:41:10 +01:00
cf293642fb Fix Glide exceptions 2019-03-14 21:44:20 +01:00
10e1106760 [Cloudflare] Fix SyntaxError due to recent js challenge changes. (#1876)
From Anorov/cloudflare-scrape#193
Related issues: inorichi/tachiyomi-extensions#894
2019-03-14 17:45:21 +01:00
3f2d375a53 Reduce priority of jcenter repository 2019-03-14 17:32:08 +01:00
f8e121ee06 [Anilist] Fix date parsing error (#1805)
fix #1804
2019-01-28 09:03:03 +01:00
0ee005579b [Anilist] Fix tracking for re-reading status (#1795) 2019-01-12 17:08:13 +01:00
6ecd7fced8 Fix Amoled navigation bar colour on OxygenOS (#1762) 2018-12-07 07:25:06 +01:00
aeaf4d78f8 Bundle SQLite. Fixes tachiyomi not working on KitKat. Making a backup before using this version is recommended, but everything should work. 2018-11-26 13:05:42 +01:00
7baf0ddcc2 Translations (#1747)
* Translated using Weblate (Italian)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/it/

* Translated using Weblate (Italian)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/it/

* Translated using Weblate (Italian)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/it/

* Translated using Weblate (Polish)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pl/

* Translated using Weblate (Indonesian)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/id/

* Translated using Weblate (Indonesian)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/id/

* Translated using Weblate (Spanish)

Currently translated at 90.8% (379 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/es/

* Translated using Weblate (French)

Currently translated at 97.3% (406 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fr/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.0% (409 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt_BR/

* Translated using Weblate (Russian)

Currently translated at 97.6% (407 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt_BR/

* Translated using Weblate (Hindi)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hi/

* Translated using Weblate (Polish)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pl/

* Translated using Weblate (Italian)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/it/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ar/

* Added translation using Weblate (Turkish)

* Translated using Weblate (Turkish)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/tr/

* Translated using Weblate (Portuguese)

Currently translated at 67.6% (282 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt/

* Update translation files

Updated by Clean-up translation files hook in Weblate.

* Translated using Weblate (Turkish)

Currently translated at 100.0% (424 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/tr/

* Translated using Weblate (French)

Currently translated at 100.0% (424 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fr/

* Translated using Weblate (Bengali)

Currently translated at 97.4% (413 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/bn/

* Translated using Weblate (Bulgarian)

Currently translated at 98.5% (418 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/bg/

* Translated using Weblate (Hindi)

Currently translated at 100.0% (424 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hi/

* Translated using Weblate (Arabic)

Currently translated at 98.1% (416 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ar/

* Added translation using Weblate (Chinese (Simplified))

* Translated using Weblate (Chinese (Simplified))

Currently translated at 17.4% (74 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/zh_Hans/

* Added translation using Weblate (Thai)

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (424 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt_BR/

* Translated using Weblate (Russian)

Currently translated at 99.0% (420 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/

* Translated using Weblate (Malay)

Currently translated at 86.5% (367 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ms/

* Translated using Weblate (Italian)

Currently translated at 100.0% (424 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/it/

* Translated using Weblate (Dutch)

Currently translated at 87.5% (371 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/nl/

* Translated using Weblate (Spanish)

Currently translated at 93.3% (396 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/es/

* Translated using Weblate (Portuguese)

Currently translated at 75.2% (319 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt/

* Translated using Weblate (Japanese)

Currently translated at 8.9% (38 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ja/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (424 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/es/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (424 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/es/

* Deleted translation using Weblate (Thai)

* Translated using Weblate (Polish)

Currently translated at 100.0% (424 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pl/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 19.8% (84 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/zh_Hans/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (424 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/zh_Hans/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (424 of 424 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/zh_Hans/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/zh_Hans/

* Translated using Weblate (Hindi)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hi/

* Translated using Weblate (Turkish)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/tr/

* Translated using Weblate (Italian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/it/

* Added translation using Weblate (Ukrainian)

* Translated using Weblate (Ukrainian)

Currently translated at 50.4% (214 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/uk/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ar/

* Translated using Weblate (Portuguese)

Currently translated at 75.3% (320 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt/

* Translated using Weblate (Portuguese)

Currently translated at 75.3% (320 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt_BR/

* Translated using Weblate (Russian)

Currently translated at 97.6% (415 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/

* Translated using Weblate (Ukrainian)

Currently translated at 54.6% (232 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/uk/

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/bg/

* Translated using Weblate (French)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fr/

* Translated using Weblate (Hungarian)

Currently translated at 39.3% (167 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hu/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (425 of 425 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/uk/
2018-11-23 21:47:00 +01:00
d79e141fe5 Fix issue with center zoom position 2018-11-17 16:23:03 +01:00
030071e659 Changed repository order for gradle (#1728) 2018-11-17 10:15:03 +01:00
9cbf226cfd MAL API Workaround (#1647)
* Mal API workaround

* remove unused import

* Reuse existing token preference

* Minor code format
2018-11-11 14:00:47 +01:00
36aabf23e1 Optimize library query 2018-11-09 11:59:17 +01:00
8b67255186 kitsu search fix (#1681) 2018-10-27 19:34:05 +02:00
3186661420 Filter local manga as downloaded (#1674)
* Filter local manga as downloaded

* Filter local manga chapters as downloaded
2018-10-27 19:33:43 +02:00
46896d9e86 Fix potential NPE at cover image selector (#1665) 2018-10-27 19:17:35 +02:00
2c4fd340c8 Restore dark blue theme. Closes #1302 2018-10-27 19:10:11 +02:00
ae6d052978 Update Anilist API search to return 50 results (#1657)
* Update Anilist API search to return 50 results

This will help alleviate not being able to find manga with generic names
such as Monster

* Add description to Anilist search dialogue
2018-10-27 19:02:10 +02:00
974891a085 Allow pausing downloads from progress notification (#1637) 2018-10-27 19:01:56 +02:00
d44cd16682 Release v0.8.0 2018-10-09 16:21:02 +02:00
23e99a3ed8 Add new translations to settings 2018-10-09 16:13:26 +02:00
024a457250 Update kotlin and build tools 2018-10-09 14:36:43 +02:00
788cb843fc Minor fixes when updating the manga viewer 2018-10-09 14:27:00 +02:00
790e0908a3 Better page transition text alignment 2018-10-09 13:46:44 +02:00
7a45cd5b56 Don't use full-width page sheet on big landscape screens 2018-10-09 13:46:27 +02:00
f61a8ce51d Update Android Studio and gradle 2018-10-09 13:44:59 +02:00
fcce29a467 Update mangasee URL (#1633)
The old URL http://mangaseeonline.net is obsolete, the new URL is http://mangaseeonline.us
2018-10-07 12:55:36 +02:00
5f568733f3 AniList 5-star/smiley <-> 100-point values differ from the AniList website (#1631)
* adjusted anilist alternate rating values to match website

* addition to: adjusted anilist alternate rating values to match website
2018-09-27 19:20:37 +02:00
96340de17d Translations (#1593)
* Translated using Weblate (Italian)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/it/

* Translated using Weblate (Italian)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/it/

* Translated using Weblate (Italian)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/it/

* Translated using Weblate (Polish)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pl/

* Translated using Weblate (Indonesian)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/id/

* Translated using Weblate (Indonesian)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/id/

* Translated using Weblate (Spanish)

Currently translated at 90.8% (379 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/es/

* Translated using Weblate (French)

Currently translated at 97.3% (406 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fr/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.0% (409 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt_BR/

* Translated using Weblate (Russian)

Currently translated at 97.6% (407 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt_BR/

* Translated using Weblate (Hindi)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hi/

* Translated using Weblate (Polish)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pl/

* Translated using Weblate (Italian)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/it/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ar/

* Added translation using Weblate (Turkish)

* Translated using Weblate (Turkish)

Currently translated at 100.0% (417 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/tr/

* Translated using Weblate (Portuguese)

Currently translated at 67.6% (282 of 417 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt/

* Update translation files

Updated by Clean-up translation files hook in Weblate.
2018-09-24 19:50:39 +02:00
3611f67fb4 Handle manga info fetch errors in the same way as chapter fetch errors (#1541)
(Using a toast)
2018-09-21 09:51:37 +02:00
353ccbd444 For migration, put the selected source at the top of the search list instead of excluding it (#1542)
* For migration, put the selected source at the top of the search list rather than excluding it

* Indicate which source is currently selected during migration

Currently uses ▶
2018-09-21 09:51:10 +02:00
3c1179d27b Fix NSFW Manga not showing in Kitsu (#1622)
* add interceptor to kitsu

* switch to shared client
2018-09-21 08:20:06 +02:00
e502caee9f Update subsampling library with decoder fixes 2018-09-19 12:04:36 +02:00
da8b870670 Update image decoder library. Remove deprecated ask update tracking setting 2018-09-17 14:50:44 +02:00
6b26859983 Add categories for readmanga/mintmanga (#1607)
* Add categories for readmanga/mintmanga

* Fix pages with site static resources
2018-09-12 17:08:53 +02:00
7afd224aff Fix volume keys intercepted even if the setting was off 2018-09-12 17:08:31 +02:00
62e7bead73 Show menu when there's no next chapter 2018-09-09 17:43:06 +02:00
116f7d1c4a Several reader fixes 2018-09-08 12:46:36 +02:00
18f89cc341 New reader (#1550)
* Delete old reader

* Add utility methods

* Update dependencies

* Add new reader

* Update tracking services. Extract transition strings into resources

* Restore delete read chapters

* Documentation and some minor changes

* Remove content providers for compressed files, they are not needed anymore

* Update subsampling. New changes allow to parse magic numbers and decode tiles with a single stream. Drop support for custom image decoders. Other minor fixes
2018-09-01 17:12:59 +02:00
7c99ae1b3b Set notification number for library updates to number of new updates (#1551) 2018-07-29 23:01:15 +02:00
16dc4d298d Update manga.last_update when any ChapterSourceSync.syncChaptersWithSource() occurs rather than only during LibraryUpdateService.updateChapterList() (#1535)
Viewing a manga's info page for the first time forces a chapter sync.
Prior behavior would cause new chapters to be retrieved for that manga, but with manga.last_update remaining at 0 (until a library update occurred in which chapters were changed).
The new behavior updates last_update any time the chapters are changed via syncChaptersWithSource().
2018-07-07 14:05:02 +02:00
762c378bd6 Kitsu search fix (#1524)
* fixed start date,
fixed filtering of novel

* removed init switched ?.let
2018-07-07 11:35:03 +02:00
515289134e Only include URL in the share functionality 2018-06-30 20:02:04 +02:00
3d1afe7cf2 Show manga with no installed source. Based on PR #1345 2018-06-30 19:55:46 +02:00
fd825b1049 Changed Kitsu to use Algoria search directly (#1514)
* Changed Kitsu to use Algoria search directly, was recommended by the Kitsu Dev team

* remove extra line

* fixed end date bug
added filtering out novel back in

* save the retrofit instances locally for search.
2018-06-30 12:07:37 +02:00
136e90638a Update ISSUE_TEMPLATE.md (#1509)
* Update ISSUE_TEMPLATE.md

Put the extensions/repo information more front and center in the template.

* Update ISSUE_TEMPLATE.md
2018-06-27 11:42:51 +02:00
9bf071132d Update AnilistModels.kt (#1481)
* Update Anilist.kt

* Update AnilistModels.kt

* Update Anilist.kt
2018-06-18 22:32:15 +02:00
014bb2f426 Update date selector and chapter number recognition (#1459)
Close #1455
2018-06-11 12:07:38 +02:00
56927927c8 Update user agent on kissmanga ¯\_(ツ)_/¯ 2018-06-06 12:42:57 +02:00
b19a4d2977 Change AniList search query to show some previously hidden entries. (#1435) 2018-05-28 22:54:41 +02:00
f4b838d8e2 Remove unused string (#1422) 2018-05-26 15:24:44 +02:00
c6cfd24f19 Fix kissmanga not loading for some people after the previous update 2018-05-26 15:24:38 +02:00
10f36f40d6 Bugfix on save instance state. Also improve initial page loading on Kissmanga 2018-05-23 13:16:11 +02:00
9d5cf9163a Release v0.7.4 2018-05-13 11:56:24 +02:00
9abce0cca3 Vanity url (#1408)
* vanity url

* vanity url

* vanity url
2018-05-13 11:36:08 +02:00
c6245f4fa3 Reenable cipher suites after upgrading to okhttp 3.10. Fixes #1411 2018-05-11 15:08:12 +02:00
75fc160204 Update okhttp version 2018-05-05 15:44:17 +02:00
263198dd89 Minor fix 2018-05-05 15:29:08 +02:00
345f96055d Fix indonesian language. Closes #1387 2018-05-05 14:23:34 +02:00
51144aa45e Implement Anilist API v2 (closes #1159) (#1383)
* Implement Anilist API v2 (closes #1159)

Switches to using the Anilist v2 API.
Login is now done by implicit grant and tokens are good for one year.
Users will need to login again after token expiration.
"clientId" on line 289 of AnilistApi.kt should be changed to Tachiyomi's
own client ID number.

* Code style formatting

Revert to kotlin 1.2.30
Use correct client ID
Rename AnilistApi.login to AnilistApi.createOAuth to reflect changed implementation
Rename json mimetype variable from json to jsonMime for clarity
Don't read response if it's ignored
Remove unused parameters from api requests

* Close netResponse after read

* Refactor remote_id into media_id and library_id

* DB: Refactor RemoteId

Refactor RemoteId into library_id and media_id
Implement function to fetch library_id if user is migrating rom APIv1

* Remove logging interceptor

* Compatability and sql simplification

* Fix score and minor improvements

* Revert changes to Kitsu API
2018-05-05 14:05:02 +02:00
86a599d13f Added Github link to about. (#1389)
* Added Github link to about.

* Added github link to About page (Fixed)

Fixed based on jogerj's comment in #1389

* Changed Github link to correct URL.

* Balanced brackets
2018-05-04 16:36:06 +02:00
0cf81e6f7a Update README.md (#1341)
* Update README.md

thought it would be cool to have hyperlinks to the sites

* Update README.md
2018-05-04 16:35:34 +02:00
8874fe973c Bugfixes 2018-04-30 18:31:31 +02:00
f8a03226ee Release v0.7.3 2018-04-28 11:10:29 +02:00
32db1e3045 Run downloader in foreground service 2018-04-28 10:54:27 +02:00
303e6c0102 Reorganize reader settings. Update Conductor version 2018-04-28 10:40:08 +02:00
18883f1ba3 Crop borders for webtoon now have a separate setting. Close #972 2018-04-27 16:55:12 +02:00
5c31271e91 Workaround a crash related to saving instance state and child controllers 2018-04-25 16:26:46 +02:00
00981cf4e8 Include firebase analytics 2018-04-25 13:46:57 +02:00
968f4a69e8 Separate 'en' locale into 'en-US' and 'en-GB' for displaying dates 2018-04-22 13:15:47 +02:00
e7e1a9bf50 Fix #1073 2018-04-15 13:26:33 +02:00
fe1becb001 Update strings.xml (#1324) 2018-04-15 12:59:52 +02:00
7789171c71 Remove F-Droid.org link (#1357)
* remove fdroid.org

* Update README.md

* fix broken links
2018-04-15 12:48:03 +02:00
3fd2222c99 Update russian sources (#1362)
1) moved headerBuilder to imageRequest
2) changed the method of gets thumbnails
3) updated js for genres
4) update genre list
2018-04-15 12:47:39 +02:00
6de36a88c0 Fix tracking search layout 2018-04-13 16:28:09 +02:00
b37685542d Add new translation link (#1355) 2018-04-09 16:25:30 +02:00
a2b1b9e746 Release v0.7.2 2018-04-08 19:10:18 +02:00
8017324033 Fix #1351 2018-04-08 18:58:28 +02:00
7464497c88 Use our OkHttpClient in updates checker. It should fix the updater on KitKat due to TLS 2018-04-06 10:02:01 +02:00
499def3daa Fix downloaded text drawn outside the screen 2018-04-06 08:29:17 +02:00
6931b75cc5 Release v0.7.1 2018-04-05 23:01:32 +02:00
f853610578 Show last update if date > 0 2018-04-05 22:55:23 +02:00
69f51b88bf Fix typo in layout 2018-04-05 22:08:23 +02:00
e0d680201a Update constraint layout & fix broken layouts 2018-04-05 21:50:44 +02:00
1566b8f8b8 Provide accept & accept-language to cloudflare 2018-04-05 19:12:17 +02:00
4bbf78e840 Don't send cache control with cloudflare challenge 2018-04-05 11:58:28 +02:00
7ab16a69df Update travis script 2018-04-05 10:56:16 +02:00
95e60ed775 Update cloudflare interceptor and android studio 2018-04-05 10:36:29 +02:00
d38cd2547a Enable TLS 1.1 and TLS 1.2 on Android KitKat (and older) (#1316)
* Enable TLS 1.1 and TLS 1.2 on Android KitKat (and older)

* enable SSLv3

* use extension function
2018-03-25 17:08:29 +02:00
2159b72e69 Dialog color fix (#1308) 2018-03-14 18:01:30 +01:00
81c23bbf9d Update Batoto toString() method to support downloaded chapters 2018-03-14 12:54:31 +01:00
0d5b8edf31 Release v0.7.0 2018-03-11 12:10:56 +01:00
Sai
fcdb80830b New colors + theme attrs (#1272)
* New colors + theme attrs

Added new colors.xml values and modified some themes.xml fields for more customisability when switching between themes.

* Small fix for dialogs

It should look more distinguishable for the Dark theme now
2018-03-10 20:17:26 +01:00
50b48ab25c Fix info layout + disable tag clicks for now 2018-03-09 22:35:10 +01:00
31b45666b0 Kotlin update 2018-03-09 21:47:02 +01:00
233e76724a Fixes and Tweaks to Info Page (#1198)
* Fixes inorichi/tachiyomi#1194 by putting the name in a auto size

* removed ellipsizing of title

* moved the genre tags inside of the scroll view for inorichi/tachiyomi#1196

* saving my layout options for the night
2018-03-09 21:42:39 +01:00
af637a82c3 Fix subtle bugs when installing/loading extensions 2018-03-09 18:56:02 +01:00
ea32ea11f2 Fixed marked previous as read not deleting chapters (#1283) 2018-03-07 20:02:59 +01:00
1b7a0de745 Added country/region support for locale when displayed for sources (#1240)
* Added country support for locale when displayed for sources

* code review changes/comments fix
2018-03-05 19:46:18 +01:00
50e0cb65d9 Anilist search fix (#1289)
* fixed issue where some anilist results not showing due to null description.

* remove blank line
2018-03-05 19:45:02 +01:00
ba4807f62c Add logging to controller lifecycle to help reproducing bugs 2018-03-04 21:04:41 +01:00
5efc02a238 Update Kissmanga genres (#1278) 2018-03-02 19:38:25 +01:00
8e50ac67bc Bugfixes and extension installation improvements 2018-03-02 18:10:10 +01:00
a3c03e8ceb Fix imports from last commits 2018-02-27 19:07:33 +01:00
5a3e30b30a Update conductor to latest snapshot (with a minor fix) 2018-02-27 19:06:34 +01:00
e3ab90042d Add missing languages in settings (#1275) 2018-02-26 08:47:36 +01:00
f35c15f7d2 [WIP] Translations (#1134)
Translations
2018-02-24 15:39:46 +01:00
32387cd034 Update available extesions whenever the screen is opened 2018-02-24 15:38:19 +01:00
cf5c816483 fix restore from old backup to updated trackimpl. (#1269)
* fix restore from old backup to updated trackimpl.
added backup of tracking url for new backups

* assignment not needed
2018-02-22 21:54:05 +01:00
bf9b9ca54c Change Source Migration menu item to use string resource (#1268) 2018-02-22 11:31:22 +01:00
0ca2ca33c2 add override status back in (#1260) 2018-02-19 08:17:59 +01:00
51f25e96e9 Travis update 2018-02-18 20:20:53 +01:00
1875047638 Forgot the backup manager isn't injected 2018-02-18 20:16:06 +01:00
fa4d61eaf0 Run periodic backups without launching services 2018-02-18 20:14:12 +01:00
49eb638e15 Dependency updates 2018-02-18 20:02:31 +01:00
fc1f290b85 removed extra blank lines (#1259)
fixed results not showing for jellybean
made edit text max line 1 to prevent it newlines being added and moving the edit text into the list view
2018-02-18 19:36:34 +01:00
9194dc0161 Chapter Metadata update (#1257)
* change chapter update to refresh on any metadata change

* moved check into private function
2018-02-18 19:20:05 +01:00
0d480dbf7c Remove debug log 2018-02-18 19:19:59 +01:00
183e83684a Remove batoto from catalogues 2018-02-18 17:39:45 +01:00
7b4ac7998a Remove simultaneous downloads 2018-02-18 17:34:22 +01:00
d75c6b0c36 Fix duplicate entries in source migration. Closes #1190 2018-02-17 19:06:15 +01:00
40b222f8bc Improve tracking search results (#1178)
* initial commit
changed tracking info screen
added ability to click logo to launch website

* added publishing status and type to description.
adjusted layout some

* added start date to track info

* tweaked layout

* tweaked layout

* tweaked layout

* code review changes

* code review changes part 2

* code review changes
2018-02-17 13:04:49 +01:00
aa7dfb7bee Update README.md (#1241)
Include CONTRIBUTING.md guidelines as collapseable elements.
Remove white background from screenshots
Optimize app-icon.png and screens.png using `optipng -o2`
2018-02-17 13:01:46 +01:00
6c1453eb54 Library filter UI change (#1211)
* similar library filter to catalog filter

* removed some commented out code

* code review changes

* fixed accidentally removing title
2018-02-16 15:23:15 +01:00
c1845aec83 Sort extensions by package name. Minor changes to extension installer 2018-02-08 15:16:13 +01:00
eb8479ac9a Timeout the installation of extensions after 10s 2018-02-06 22:11:36 +01:00
636c027298 Fix extensions installer on old Android versions. Fix deadlock on devices with 1-2 cores 2018-02-06 11:42:38 +01:00
02e187f066 Add notice for updating extensions 2018-02-05 22:55:29 +01:00
854112095b Downloading extensions from Github Repo. (#1101)
Downloading extensions from Github Repo.
2018-02-05 22:50:56 +01:00
a71c805959 fix author/artist not showing in mangahere (#1228) 2018-02-05 11:19:24 +01:00
c3ced0d089 adjusted chapters item since Android 16 doesnt support right and left (#1221) 2018-01-31 14:38:05 +01:00
80996ea63e Add page down/page up hardware detection (#1212)
* Added page down and page up key event.  Have it always on since page down and page up buttons are only on readers or keyboards

* moved code to different method

* added spaces back to comments
2018-01-31 14:37:02 +01:00
aff51f8af1 hide latest button when source doesnt support latest (#1217) 2018-01-28 18:37:58 +01:00
ccbb81e9f5 Ask for permission if necessary when browsing local sources. (#1216) 2018-01-28 12:23:40 +01:00
f88dd28c51 Add option to change double tap animation speed in the reader (#974)
* Add option to change double tap animation speed in the reader

* address requests from review
2018-01-26 20:22:31 +01:00
a65a71df5d updated mangahere to show licensed status (#1214) 2018-01-26 17:09:20 +01:00
22f2ecc433 fix genre tags to be delimited correctly (#1215) 2018-01-26 17:09:08 +01:00
7f90ad7847 Fix chapter recognition regex and detail number (#1213)
* Update basic filter for sources that include space between numbers

Wasnts matching on  vol. 1 ch. 10 previously so mangadex last chapter was showing volume number.

* Don't show last chapter number when there are 0 chapters or chapters with no numbers.

This prevents one shots from showing with -1 as last chapter and instead just leaves it blank

* added else to be Unknown instead of blank

* removed empty line
added test case

* switched to null safe ?.

* Revert "switched to null safe ?."

This reverts commit 97a9300d1bedc8e01efb439c180eced8eaa1da5b.
undo

* switched to null safe ?.
2018-01-26 14:32:34 +01:00
1292c0ecea Fix library query being lost 2018-01-25 19:59:15 +01:00
55b7d5025b fixed 3 dot icon (#1209) 2018-01-24 07:19:55 +01:00
6a310bbaa9 Added custom download option (#1185)
* Added custom download option

* Implemented new design. TODO comments (like always...)

* W00t comments

* Implemented code review.

* Fixed commit breaking mistake :O

* Small design fix
2018-01-23 21:18:55 +01:00
bc8753da85 Remove teal background 2018-01-23 19:03:50 +01:00
7f63e318f1 Catalog visuals update 1155 (#1167)
* adjusted search to be lower in navview

* close drawer on search
moved search and reset to bottom

* switched sort icon to arrow

* allow secondary drawer to swipe open and close

* fixed click to collapse for sortgroup, and group item
updated to rc4 flexibleadapter

* added header to drawer

* changed string to Search filters

* collapsed sort group

* fixed arrow size

* added divider line

* fixed vector size

* add divider id and tools text
2018-01-23 18:50:48 +01:00
6c749319cf increase touch area for 3 dot in chapter list (#1205)
* increase touch area for 3 dot in chapter list

* moved 3 dot over and made it vertical

* adjusted location slightly
2018-01-23 18:49:26 +01:00
7a4463e104 fixed alpha not showing for manga in library during global search (#1203) 2018-01-21 19:15:24 +01:00
e1be4ba925 fixed ReadMangaToday search issue (#1200) 2018-01-21 17:24:24 +01:00
34d21c1de3 Information Page Improvements (click to search, copy to clipboard, etc) (#1139)
* adds long click to copy details per inorichi/tachiyomi#1127

* Added the latest update date for inorichi/tachiyomi#1098 and possible fix for inorichi/tachiyomi#1141

* cleanup some mistakes I left

* adds modifications to full name display for inorichi/tachiyomi#1141 and click to search on various information pieces for inorichi/tachiyomi#860

* This modifies how the full title shows up in the info pages and also properly ellipsizes the titles in the catalogue/library list views

* Changes full title layout in horizontal mode

* Adds the tags in using AndroidTagGroup library

* reverting the sdk version in the gradle build

* code cleanup

* added back status update
2018-01-18 19:15:33 +01:00
fae36aebf4 Add referer to readmanga/mintmanga requests header (#1192) 2018-01-17 13:32:54 +01:00
75e828923a Release v0.6.8 2018-01-16 17:02:13 +01:00
b499b87f8c Migration now opens manga on long click 2018-01-15 20:22:07 +01:00
233dbec4b3 Add adaptive icon and a dev variant 2018-01-13 18:15:00 +01:00
d56ff9592e Use a single preference to store migration flags 2018-01-13 13:13:03 +01:00
08f6317beb Add error handling to migrations 2018-01-13 11:47:04 +01:00
a75457ad88 Add a new screen to help migrating manga from sources 2018-01-12 22:02:05 +01:00
b0482003bd Handle ActivityNotFoundException (#1170)
* [SettingsBackupController] Handle ActivityNotFoundException

When using `Intent.ACTION_CREATE_DOCUMENT` on SDK >= Lollipop there is no
guarantee that the ROM supports the built in file picker such as MIUI

* [SettingsBackupController] Add import for ActivityNotFoundException

* Add additional handlers to Android document intents

* Requested review changes

Move `try {`s to top of block
Replace version numbers with `Build.VERSION_CODES.LOLLIPOP`
Break out custom file picker intent to Context extention `Context.getFilePicker`
Rename `val i` to `val intent` to be more clear with variable names

* Add version check to custom file picker after exception
2018-01-12 10:57:21 +01:00
634356e72f Release v0.6.7 2018-01-09 20:42:44 +01:00
6d3cc16ab1 Include minor changes from extensions PR 2018-01-09 12:27:45 +01:00
6027671c09 Address #1154 (#1160)
* change add to library icon add toast

* adjusted toast messages
added toast to catalog long click

* adjusted strings
2018-01-08 14:08:48 +01:00
29d0cb4a15 fixed issue where some sources that use cloudflare use the Server: cloudflare as cloudflare-nginx is deprecated (#1152) 2018-01-08 11:03:37 +01:00
fe7001975a Fix padding in RecyclerViews (#1148) 2018-01-06 18:50:40 +01:00
ac88f1c146 Update README (#1142)
* Update README.md

* add pics to readme

* Update README.md

* change language from stable to just "app"

* Update README.md

* update with feedback

* change test to try

* add link to extentions repo
2018-01-05 22:54:41 +01:00
b5b86218c5 Mangachan advanced support (#1138)
* Mangachan catalogue. Add support for filtering

* MangaChan add support for status
2018-01-04 22:01:42 +01:00
bdcc6e52e6 Small new user improvements (#1143)
- Changed empty library string
- Added empty view for Categories
2018-01-01 14:57:20 +01:00
0eae817aa6 Update MangaChan.kt (#1128)
Remove useless ganres
2017-12-14 13:28:24 +01:00
8994b42760 Remove local broadcast receiver to prevent race conditions (#1123)
* Remove local broadcast receiver to prevent run exceptions.
Added option to set tile for extension update.
2017-12-11 20:01:28 +01:00
6a63ce992a [Mangafox] update mangafox URL for built-in source (#1119) 2017-12-09 13:29:30 +01:00
9ae6285eef Change discord invite link in settings (#1112)
* Change discord invite link in settings

* Change discord link is readme
2017-12-06 08:41:37 +01:00
8f9737f567 Update regexp for pages from Readmanga/Mintmanga (#1111) 2017-12-05 21:21:02 +01:00
382 changed files with 20722 additions and 11028 deletions

View File

@ -1,5 +1,5 @@
1. **Before reporting a new issue, take a look at the [FAQ](https://github.com/inorichi/tachiyomi/wiki/FAQ), the [changelog](https://github.com/inorichi/tachiyomi/releases) and the already opened [issues](https://github.com/inorichi/tachiyomi/issues).** 1. **Before reporting a new issue, take a look at the [FAQ](https://github.com/inorichi/tachiyomi/wiki/FAQ), the [changelog](https://github.com/inorichi/tachiyomi/releases) and the already opened [issues](https://github.com/inorichi/tachiyomi/issues).**
2. If you are unsure, ask here: [![Discord](https://img.shields.io/discord/349436576037732353.svg)](https://discord.gg/WrBkRk4) 2. If you are unsure, ask here: [![Discord](https://img.shields.io/discord/349436576037732353.svg)](https://discord.gg/tachiyomi)
3. What is your type of issue? 3. What is your type of issue?
* [Catalogue request](#catalogue-requests) * [Catalogue request](#catalogue-requests)
* [Bugs](#bugs) * [Bugs](#bugs)

View File

@ -1 +1,15 @@
**Please read https://github.com/inorichi/tachiyomi/blob/master/.github/CONTRIBUTING.md before posting** **DO NOT OPEN ISSUES/REQUESTS RELATING TO EXTENSIONS/CATALOGUES IN THIS REPOSITORY. Open them at the following repository https://github.com/inorichi/tachiyomi-extensions/**
**For all other requests Please fill out the form below and remove the first 3 lines of this template**
**App version:**
**Issue/Request:**
**Steps to reproduce (if applicable)**
1.
2.
3.
**Other details:**

BIN
.github/readme-images/app-icon.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
.github/readme-images/screens.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 KiB

View File

@ -1,8 +1,8 @@
language: android language: android
android: android:
components: components:
- build-tools-27.0.1 - build-tools-28.0.3
- android-26 - android-27
- extra-android-m2repository - extra-android-m2repository
- extra-google-m2repository - extra-google-m2repository
- extra-android-support - extra-android-support
@ -10,6 +10,7 @@ android:
licenses: licenses:
- android-sdk-license-.+ - android-sdk-license-.+
before_install: before_install:
- yes | sdkmanager "platforms;android-27" # workaround for accepting the license
- if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then - if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then
openssl aes-256-cbc -K $encrypted_e56be693d4fd_key -iv $encrypted_e56be693d4fd_iv -in "$PWD/.travis/secrets.tar.enc" -out secrets.tar -d; openssl aes-256-cbc -K $encrypted_e56be693d4fd_key -iv $encrypted_e56be693d4fd_iv -in "$PWD/.travis/secrets.tar.enc" -out secrets.tar -d;
tar xf secrets.tar; tar xf secrets.tar;

View File

@ -2,6 +2,8 @@
git fetch --unshallow #required for commit count git fetch --unshallow #required for commit count
cp .travis/google-services.json app/
if [ -z "$TRAVIS_TAG" ]; then if [ -z "$TRAVIS_TAG" ]; then
./gradlew clean assembleStandardDebug ./gradlew clean assembleStandardDebug

View File

@ -0,0 +1,73 @@
{
"project_info": {
"project_number": "777921915939",
"firebase_url": "https://tachiyomi-47364.firebaseio.com",
"project_id": "tachiyomi-47364",
"storage_bucket": "tachiyomi-47364.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:777921915939:android:36544cd2d96c50c7",
"android_client_info": {
"package_name": "eu.kanade.tachiyomi"
}
},
"oauth_client": [
{
"client_id": "777921915939-9q25jvgbdtpk91daqlk7sa1cbdcg77o6.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyAHr8RxyeiSPC_MxJTnivz-hmdo5oX0QQQ"
}
],
"services": {
"analytics_service": {
"status": 1
},
"appinvite_service": {
"status": 1,
"other_platform_oauth_client": []
},
"ads_service": {
"status": 2
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:777921915939:android:564fdc1d62efd1de",
"android_client_info": {
"package_name": "eu.kanade.tachiyomi.debug"
}
},
"oauth_client": [
{
"client_id": "777921915939-9q25jvgbdtpk91daqlk7sa1cbdcg77o6.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyAHr8RxyeiSPC_MxJTnivz-hmdo5oX0QQQ"
}
],
"services": {
"analytics_service": {
"status": 1
},
"appinvite_service": {
"status": 1,
"other_platform_oauth_client": []
},
"ads_service": {
"status": 2
}
}
}
],
"configuration_version": "1"
}

View File

@ -1,25 +1,70 @@
| Build | Download | F-Droid | Contribute | Contact | | Build | Stable | Dev | Contribute | Contact |
|-------|----------|---------|------------|---------| |-------|----------|---------|------------|---------|
| [![Travis](https://img.shields.io/travis/inorichi/tachiyomi.svg)](https://travis-ci.org/inorichi/tachiyomi) | [![stable release](https://img.shields.io/github/release/inorichi/tachiyomi.svg?maxAge=3600&label=stable)](https://github.com/inorichi/tachiyomi/releases) [![latest dev build](https://img.shields.io/badge/dev-latest%20build-blue.svg)](http://tachiyomi.kanade.eu/latest) | [![fdroid release](https://img.shields.io/badge/stable-f--droid.org-blue.svg)](https://f-droid.org/repository/browse/?fdid=eu.kanade.tachiyomi) [![fdroid dev](https://img.shields.io/badge/dev-wiki-blue.svg)](//github.com/inorichi/tachiyomi/wiki/FDroid-for-dev-versions) | [![Translation status](http://weblate.j2ghz.com/widgets/tachiyomi/-/svg-badge.svg)](https://github.com/inorichi/tachiyomi/wiki/Translation) | [![Discord](https://img.shields.io/discord/349436576037732353.svg)](https://discord.gg/WrBkRk4) | | [![Travis](https://img.shields.io/travis/inorichi/tachiyomi.svg)](https://travis-ci.org/inorichi/tachiyomi) | [![stable release](https://img.shields.io/github/release/inorichi/tachiyomi.svg?maxAge=3600&label=download%20(autoupdate%20included))](https://github.com/inorichi/tachiyomi/releases) | [![latest dev build](https://img.shields.io/badge/download-latest%20build-blue.svg)](http://tachiyomi.kanade.eu/latest) [![fdroid dev](https://img.shields.io/badge/autoupdate-wiki-blue.svg)](//github.com/inorichi/tachiyomi/wiki/F-Droid-for-dev-versions) | [![Translation status](https://hosted.weblate.org/widgets/tachiyomi/-/svg-badge.svg)](https://hosted.weblate.org/engage/tachiyomi/?utm_source=widget) | [![Discord](https://img.shields.io/discord/349436576037732353.svg)](https://discord.gg/tachiyomi) |
### **Contact us on [Discord](https://discord.gg/WrBkRk4)**
If you want to open an issue, please read [contributing guidelines](https://github.com/inorichi/tachiyomi/blob/master/.github/CONTRIBUTING.md). Your issue may be closed otherwise.
***
# ![app icon](./.github/readme-images/app-icon.png)Tachiyomi
Tachiyomi is a free and open source manga reader for Android. Tachiyomi is a free and open source manga reader for Android.
Keep in mind it's still a beta, so expect it to crash sometimes. ![screenshots of app](./.github/readme-images/screens.png)
# Features ## Features
* Online and offline reading Features include:
* Configurable reader with multiple viewers and settings * Online reading from sources such as KissManga, MangaFox, [and more](https://github.com/inorichi/tachiyomi-extensions)
* MyAnimeList support * Local reading of downloaded manga
* Resume from the next unread chapter * Configurable reader with multiple viewers, reading directions and other settings
* Chapter filtering * [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), and [Kitsu](https://kitsu.io/explore/anime) support
* Schedule searching for updates
* Categories to organize your library * Categories to organize your library
* Light and dark themes
* Schedule updating your library for new chapters
* Create backups locally to read offline or to your desired cloud service
## Download
Get the app from our [releases page](https://github.com/inorichi/tachiyomi/releases).
If you want to try new features before they get to the stable release, you can download the dev version [here](http://tachiyomi.kanade.eu/latest) (auto-updates not included), or add our [F-Droid repo](https://github.com/inorichi/tachiyomi/wiki/F-Droid-for-dev-versions).
## Issues, Feature Requests and Contributing
Please make sure to read the full guidelines. Your issue may be closed without warning if you do not.
<details><summary>Issues</summary>
1. **Before reporting a new issue, take a look at the [FAQ](https://github.com/inorichi/tachiyomi/wiki/FAQ), the [changelog](https://github.com/inorichi/tachiyomi/releases) and the already opened [issues](https://github.com/inorichi/tachiyomi/issues).**
2. If you are unsure, ask here: [![Discord](https://img.shields.io/discord/349436576037732353.svg)](https://discord.gg/tachiyomi)
</details>
<details><summary>Bugs</summary>
* Include version (Setting > About > Version)
* If not latest, try updating, it may have already been solved
* Dev version is equal to the number of commits as seen in the main page
* Include steps to reproduce (if not obvious from description)
* Include screenshot (if needed)
* If it could be device-dependent, try reproducing on another device (if possible)
* For large logs use http://pastebin.com/ (or similar)
* Don't group unrelated requests into one issue
DO: https://github.com/inorichi/tachiyomi/issues/24 https://github.com/inorichi/tachiyomi/issues/71
DON'T: https://github.com/inorichi/tachiyomi/issues/75
</details>
<details><summary>Feature Requests</summary>
* Write a detailed issue, explaning what it should do or how. Avoid writing just "like X app does"
* Include screenshot (if needed)
Catalogue requests should be created at https://github.com/inorichi/tachiyomi-extensions, they do not belong in this repository.
</details>
## FAQ
[See our wiki.](https://github.com/inorichi/tachiyomi/wiki/FAQ)
You can also reach out to us on [Discord](https://discord.gg/WrBkRk4).
## License ## License

3
app/.gitignore vendored
View File

@ -1,4 +1,5 @@
/build /build
*iml *iml
*.iml *.iml
custom.gradle custom.gradle
google-services.json

View File

@ -29,17 +29,17 @@ ext {
} }
android { android {
compileSdkVersion 26 compileSdkVersion 27
buildToolsVersion "27.0.1" buildToolsVersion '28.0.3'
publishNonDefault true publishNonDefault true
defaultConfig { defaultConfig {
applicationId "eu.kanade.tachiyomi" applicationId "eu.kanade.tachiyomi"
minSdkVersion 16 minSdkVersion 16
targetSdkVersion 26 targetSdkVersion 27
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
versionCode 29 versionCode 39
versionName "0.6.6" versionName "0.8.1"
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\"" buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\"" buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
@ -102,7 +102,7 @@ android {
dependencies { dependencies {
// Modified dependencies // Modified dependencies
implementation 'com.github.inorichi:subsampling-scale-image-view:c19b883' implementation 'com.github.inorichi:subsampling-scale-image-view:ac0dae7'
implementation 'com.github.inorichi:junrar-android:634c1f5' implementation 'com.github.inorichi:junrar-android:634c1f5'
// Android support library // Android support library
@ -116,20 +116,22 @@ dependencies {
implementation "com.android.support:support-annotations:$support_library_version" implementation "com.android.support:support-annotations:$support_library_version"
implementation "com.android.support:customtabs:$support_library_version" implementation "com.android.support:customtabs:$support_library_version"
implementation 'com.android.support.constraint:constraint-layout:1.0.2' implementation 'com.android.support.constraint:constraint-layout:1.1.2'
implementation 'com.android.support:multidex:1.0.2' implementation 'com.android.support:multidex:1.0.3'
standardImplementation 'com.google.firebase:firebase-core:11.8.0'
// ReactiveX // ReactiveX
implementation 'io.reactivex:rxandroid:1.2.1' implementation 'io.reactivex:rxandroid:1.2.1'
implementation 'io.reactivex:rxjava:1.3.4' implementation 'io.reactivex:rxjava:1.3.6'
implementation 'com.jakewharton.rxrelay:rxrelay:1.2.0' implementation 'com.jakewharton.rxrelay:rxrelay:1.2.0'
implementation 'com.f2prateek.rx.preferences:rx-preferences:1.0.2' implementation 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
implementation 'com.github.pwittchen:reactivenetwork:0.7.0' implementation 'com.github.pwittchen:reactivenetwork:0.7.0'
// Network client // Network client
implementation "com.squareup.okhttp3:okhttp:3.9.1" implementation "com.squareup.okhttp3:okhttp:3.10.0"
implementation 'com.squareup.okio:okio:1.13.0' implementation 'com.squareup.okio:okio:1.14.0'
// REST // REST
final retrofit_version = '2.3.0' final retrofit_version = '2.3.0'
@ -141,9 +143,6 @@ dependencies {
implementation 'com.google.code.gson:gson:2.8.2' implementation 'com.google.code.gson:gson:2.8.2'
implementation 'com.github.salomonbrys.kotson:kotson:2.5.0' implementation 'com.github.salomonbrys.kotson:kotson:2.5.0'
// YAML
implementation 'com.github.bmoliveira:snake-yaml:v1.18-android'
// JavaScript engine // JavaScript engine
implementation 'com.squareup.duktape:duktape-android:1.2.0' implementation 'com.squareup.duktape:duktape-android:1.2.0'
@ -155,14 +154,15 @@ dependencies {
implementation 'org.jsoup:jsoup:1.10.2' implementation 'org.jsoup:jsoup:1.10.2'
// Job scheduling // Job scheduling
implementation 'com.evernote:android-job:1.2.1' implementation 'com.evernote:android-job:1.2.5'
implementation 'com.google.android.gms:play-services-gcm:11.6.2' implementation 'com.google.android.gms:play-services-gcm:11.8.0'
// Changelog // Changelog
implementation 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0' implementation 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
// Database // Database
implementation "com.pushtorefresh.storio:sqlite:1.13.0" implementation 'eu.kanade.storio:storio:1.13.0'
implementation 'io.requery:sqlite-android:3.25.2'
// Model View Presenter // Model View Presenter
final nucleus_version = '3.0.0' final nucleus_version = '3.0.0'
@ -170,19 +170,19 @@ dependencies {
implementation "info.android15.nucleus:nucleus-support-v7:$nucleus_version" implementation "info.android15.nucleus:nucleus-support-v7:$nucleus_version"
// Dependency injection // Dependency injection
implementation "uy.kohesive.injekt:injekt-core:1.16.1" implementation "com.github.inorichi.injekt:injekt-core:65b0440"
// Image library // Image library
final glide_version = '4.3.1' final glide_version = '4.6.1'
implementation "com.github.bumptech.glide:glide:$glide_version" implementation "com.github.bumptech.glide:glide:$glide_version"
implementation "com.github.bumptech.glide:okhttp3-integration:$glide_version" implementation "com.github.bumptech.glide:okhttp3-integration:$glide_version"
kapt "com.github.bumptech.glide:compiler:$glide_version" kapt "com.github.bumptech.glide:compiler:$glide_version"
// Transformations // Transformations
implementation 'jp.wasabeef:glide-transformations:3.0.1' implementation 'jp.wasabeef:glide-transformations:3.1.1'
// Logging // Logging
implementation 'com.jakewharton.timber:timber:4.6.0' implementation 'com.jakewharton.timber:timber:4.6.1'
// Crash reports // Crash reports
implementation 'ch.acra:acra:4.9.2' implementation 'ch.acra:acra:4.9.2'
@ -193,19 +193,24 @@ dependencies {
// UI // UI
implementation 'com.dmitrymalkovich.android:material-design-dimens:1.4' implementation 'com.dmitrymalkovich.android:material-design-dimens:1.4'
implementation 'com.github.dmytrodanylyk.android-process-button:library:1.0.4' implementation 'com.github.dmytrodanylyk.android-process-button:library:1.0.4'
implementation 'eu.davidea:flexible-adapter:5.0.0-rc3' implementation 'eu.davidea:flexible-adapter:5.0.0-rc4'
implementation 'eu.davidea:flexible-adapter-ui:1.0.0-b1'
implementation 'com.nononsenseapps:filepicker:2.5.2' implementation 'com.nononsenseapps:filepicker:2.5.2'
implementation 'com.github.amulyakhare:TextDrawable:558677e' implementation 'com.github.amulyakhare:TextDrawable:558677e'
implementation('com.afollestad.material-dialogs:core:0.9.4.7') { implementation 'com.afollestad.material-dialogs:core:0.9.6.0'
exclude group: "com.android.support", module: "support-v13"
}
implementation 'me.zhanghai.android.systemuihelper:library:1.0.0' implementation 'me.zhanghai.android.systemuihelper:library:1.0.0'
implementation 'com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.0.4' implementation 'com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.0.4'
implementation 'com.github.mthli:Slice:v1.2' implementation 'com.github.mthli:Slice:v1.2'
implementation 'me.gujun.android.taggroup:library:1.4@aar'
implementation 'com.github.chrisbanes:PhotoView:2.1.3'
implementation 'com.github.inorichi:DirectionalViewPager:3acc51a'
// Conductor // Conductor
implementation "com.bluelinelabs:conductor:2.1.4" implementation 'com.bluelinelabs:conductor:2.1.5'
implementation 'com.github.inorichi:conductor-support-preference:26.0.2' implementation ("com.bluelinelabs:conductor-support:2.1.5") {
exclude group: "com.android.support"
}
implementation 'com.github.inorichi:conductor-support-preference:27.0.2'
// RxBindings // RxBindings
final rxbindings_version = '1.0.1' final rxbindings_version = '1.0.1'
@ -226,13 +231,13 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
final coroutines_version = '0.19.1' final coroutines_version = '0.22.2'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
} }
buildscript { buildscript {
ext.kotlin_version = '1.2.0' ext.kotlin_version = '1.2.71'
repositories { repositories {
mavenCentral() mavenCentral()
} }
@ -250,6 +255,11 @@ kotlin {
coroutines 'enable' coroutines 'enable'
} }
} }
androidExtensions { androidExtensions {
experimental = true experimental = true
} }
if (getGradle().getStartParameter().getTaskRequests().toString().contains("Standard")) {
apply plugin: 'com.google.gms.google-services'
}

View 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>

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -32,8 +32,7 @@
<meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts"/> <meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts"/>
</activity> </activity>
<activity <activity
android:name=".ui.reader.ReaderActivity" android:name=".ui.reader.ReaderActivity" />
android:theme="@style/Theme.Reader" />
<activity <activity
android:name=".widget.CustomLayoutPickerActivity" android:name=".widget.CustomLayoutPickerActivity"
android:label="@string/app_name" android:label="@string/app_name"
@ -52,6 +51,9 @@
android:scheme="tachiyomi" /> android:scheme="tachiyomi" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".extension.util.ExtensionInstallActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar"/>
<provider <provider
android:name="android.support.v4.content.FileProvider" android:name="android.support.v4.content.FileProvider"
@ -63,16 +65,6 @@
android:resource="@xml/provider_paths" /> android:resource="@xml/provider_paths" />
</provider> </provider>
<provider
android:name="eu.kanade.tachiyomi.util.ZipContentProvider"
android:authorities="${applicationId}.zip-provider"
android:exported="false" />
<provider
android:name="eu.kanade.tachiyomi.util.RarContentProvider"
android:authorities="${applicationId}.rar-provider"
android:exported="false" />
<receiver <receiver
android:name=".data.notification.NotificationReceiver" android:name=".data.notification.NotificationReceiver"
android:exported="false" /> android:exported="false" />
@ -86,7 +78,7 @@
android:exported="false" /> android:exported="false" />
<service <service
android:name=".data.updater.UpdateDownloaderService" android:name=".data.updater.UpdaterService"
android:exported="false" /> android:exported="false" />
<service <service

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -8,7 +8,7 @@ import com.evernote.android.job.JobManager
import eu.kanade.tachiyomi.data.backup.BackupCreatorJob import eu.kanade.tachiyomi.data.backup.BackupCreatorJob
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob import eu.kanade.tachiyomi.data.updater.UpdaterJob
import eu.kanade.tachiyomi.util.LocaleHelper import eu.kanade.tachiyomi.util.LocaleHelper
import org.acra.ACRA import org.acra.ACRA
import org.acra.annotation.ReportsCrashes import org.acra.annotation.ReportsCrashes
@ -28,11 +28,11 @@ open class App : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
Injekt = InjektScope(DefaultRegistrar()) Injekt = InjektScope(DefaultRegistrar())
Injekt.importModule(AppModule(this)) Injekt.importModule(AppModule(this))
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
setupAcra() setupAcra()
setupJobManager() setupJobManager()
setupNotificationChannels() setupNotificationChannels()
@ -57,13 +57,17 @@ open class App : Application() {
} }
protected open fun setupJobManager() { protected open fun setupJobManager() {
JobManager.create(this).addJobCreator { tag -> try {
when (tag) { JobManager.create(this).addJobCreator { tag ->
LibraryUpdateJob.TAG -> LibraryUpdateJob() when (tag) {
UpdateCheckerJob.TAG -> UpdateCheckerJob() LibraryUpdateJob.TAG -> LibraryUpdateJob()
BackupCreatorJob.TAG -> BackupCreatorJob() UpdaterJob.TAG -> UpdaterJob()
else -> null BackupCreatorJob.TAG -> BackupCreatorJob()
else -> null
}
} }
} catch (e: Exception) {
Timber.w("Can't initialize job manager")
} }
} }

View File

@ -8,34 +8,55 @@ import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import uy.kohesive.injekt.api.InjektModule import rx.Observable
import uy.kohesive.injekt.api.InjektRegistrar import rx.schedulers.Schedulers
import uy.kohesive.injekt.api.addSingletonFactory import uy.kohesive.injekt.api.*
class AppModule(val app: Application) : InjektModule { class AppModule(val app: Application) : InjektModule {
override fun InjektRegistrar.registerInjectables() { override fun InjektRegistrar.registerInjectables() {
addSingletonFactory { PreferencesHelper(app) } addSingleton(app)
addSingletonFactory { DatabaseHelper(app) } addSingletonFactory { PreferencesHelper(app) }
addSingletonFactory { ChapterCache(app) } addSingletonFactory { DatabaseHelper(app) }
addSingletonFactory { CoverCache(app) } addSingletonFactory { ChapterCache(app) }
addSingletonFactory { NetworkHelper(app) } addSingletonFactory { CoverCache(app) }
addSingletonFactory { SourceManager(app) } addSingletonFactory { NetworkHelper(app) }
addSingletonFactory { DownloadManager(app) } addSingletonFactory { SourceManager(app).also { get<ExtensionManager>().init(it) } }
addSingletonFactory { TrackManager(app) } addSingletonFactory { ExtensionManager(app) }
addSingletonFactory { Gson() } addSingletonFactory { DownloadManager(app) }
addSingletonFactory { TrackManager(app) }
addSingletonFactory { Gson() }
// Asynchronously init expensive components for a faster cold start
rxAsync { get<PreferencesHelper>() }
rxAsync { get<NetworkHelper>() }
rxAsync { get<SourceManager>() }
rxAsync { get<DatabaseHelper>() }
rxAsync { get<DownloadManager>() }
} }
} private fun rxAsync(block: () -> Unit) {
Observable.fromCallable { block() }.subscribeOn(Schedulers.computation()).subscribe()
}
}

View File

@ -3,7 +3,7 @@ package eu.kanade.tachiyomi
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob import eu.kanade.tachiyomi.data.updater.UpdaterJob
import java.io.File import java.io.File
object Migrations { object Migrations {
@ -25,7 +25,7 @@ object Migrations {
if (oldVersion < 14) { if (oldVersion < 14) {
// Restore jobs after upgrading to evernote's job scheduler. // Restore jobs after upgrading to evernote's job scheduler.
if (BuildConfig.INCLUDE_UPDATER && preferences.automaticUpdates()) { if (BuildConfig.INCLUDE_UPDATER && preferences.automaticUpdates()) {
UpdateCheckerJob.setupTask() UpdaterJob.setupTask()
} }
LibraryUpdateJob.setupTask() LibraryUpdateJob.setupTask()
} }

View File

@ -4,17 +4,8 @@ import android.app.IntentService
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import com.github.salomonbrys.kotson.set
import com.google.gson.JsonArray import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGAS
import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.util.sendLocalBroadcast
import timber.log.Timber
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
/** /**
@ -26,8 +17,6 @@ class BackupCreateService : IntentService(NAME) {
// Name of class // Name of class
private const val NAME = "BackupCreateService" private const val NAME = "BackupCreateService"
// Backup called from job
private const val EXTRA_IS_JOB = "$ID.$NAME.EXTRA_IS_JOB"
// Options for backup // Options for backup
private const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS" private const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS"
@ -48,12 +37,10 @@ class BackupCreateService : IntentService(NAME) {
* @param context context of application * @param context context of application
* @param uri path of Uri * @param uri path of Uri
* @param flags determines what to backup * @param flags determines what to backup
* @param isJob backup called from job
*/ */
fun makeBackup(context: Context, uri: Uri, flags: Int, isJob: Boolean = false) { fun makeBackup(context: Context, uri: Uri, flags: Int) {
val intent = Intent(context, BackupCreateService::class.java).apply { val intent = Intent(context, BackupCreateService::class.java).apply {
putExtra(BackupConst.EXTRA_URI, uri) putExtra(BackupConst.EXTRA_URI, uri)
putExtra(EXTRA_IS_JOB, isJob)
putExtra(EXTRA_FLAGS, flags) putExtra(EXTRA_FLAGS, flags)
} }
context.startService(intent) context.startService(intent)
@ -68,95 +55,9 @@ class BackupCreateService : IntentService(NAME) {
// Get values // Get values
val uri = intent.getParcelableExtra<Uri>(BackupConst.EXTRA_URI) val uri = intent.getParcelableExtra<Uri>(BackupConst.EXTRA_URI)
val isJob = intent.getBooleanExtra(EXTRA_IS_JOB, false)
val flags = intent.getIntExtra(EXTRA_FLAGS, 0) val flags = intent.getIntExtra(EXTRA_FLAGS, 0)
// Create backup // Create backup
createBackupFromApp(uri, flags, isJob) backupManager.createBackup(uri, flags, false)
} }
/** }
* Create backup Json file from database
*
* @param uri path of Uri
* @param isJob backup called from job
*/
private fun createBackupFromApp(uri: Uri, flags: Int, isJob: Boolean) {
// Create root object
val root = JsonObject()
// Create manga array
val mangaEntries = JsonArray()
// Create category array
val categoryEntries = JsonArray()
// Add value's to root
root[VERSION] = Backup.CURRENT_VERSION
root[MANGAS] = mangaEntries
root[CATEGORIES] = categoryEntries
backupManager.databaseHelper.inTransaction {
// Get manga from database
val mangas = backupManager.getFavoriteManga()
// Backup library manga and its dependencies
mangas.forEach { manga ->
mangaEntries.add(backupManager.backupMangaObject(manga, flags))
}
// Backup categories
if ((flags and BACKUP_CATEGORY_MASK) == BACKUP_CATEGORY) {
backupManager.backupCategories(categoryEntries)
}
}
try {
// When BackupCreatorJob
if (isJob) {
// Get dir of file and create
var dir = UniFile.fromUri(this, uri)
dir = dir.createDirectory("automatic")
// Delete older backups
val numberOfBackups = backupManager.numberOfBackups()
val backupRegex = Regex("""tachiyomi_\d+-\d+-\d+_\d+-\d+.json""")
dir.listFiles { _, filename -> backupRegex.matches(filename) }
.orEmpty()
.sortedByDescending { it.name }
.drop(numberOfBackups - 1)
.forEach { it.delete() }
// Create new file to place backup
val newFile = dir.createFile(Backup.getDefaultFilename())
?: throw Exception("Couldn't create backup file")
newFile.openOutputStream().bufferedWriter().use {
backupManager.parser.toJson(root, it)
}
} else {
val file = UniFile.fromUri(this, uri)
?: throw Exception("Couldn't create backup file")
file.openOutputStream().bufferedWriter().use {
backupManager.parser.toJson(root, it)
}
// Show completed dialog
val intent = Intent(BackupConst.INTENT_FILTER).apply {
putExtra(BackupConst.ACTION, BackupConst.ACTION_BACKUP_COMPLETED_DIALOG)
putExtra(BackupConst.EXTRA_URI, file.uri.toString())
}
sendLocalBroadcast(intent)
}
} catch (e: Exception) {
Timber.e(e)
if (!isJob) {
// Show error dialog
val intent = Intent(BackupConst.INTENT_FILTER).apply {
putExtra(BackupConst.ACTION, BackupConst.ACTION_ERROR_BACKUP_DIALOG)
putExtra(BackupConst.EXTRA_ERROR_MESSAGE, e.message)
}
sendLocalBroadcast(intent)
}
}
}
}

View File

@ -13,9 +13,10 @@ class BackupCreatorJob : Job() {
override fun onRunJob(params: Params): Result { override fun onRunJob(params: Params): Result {
val preferences = Injekt.get<PreferencesHelper>() val preferences = Injekt.get<PreferencesHelper>()
val backupManager = BackupManager(context)
val uri = Uri.parse(preferences.backupsDirectory().getOrDefault()) val uri = Uri.parse(preferences.backupsDirectory().getOrDefault())
val flags = BackupCreateService.BACKUP_ALL val flags = BackupCreateService.BACKUP_ALL
BackupCreateService.makeBackup(context, uri, flags, true) backupManager.createBackup(uri, flags, true)
return Result.SUCCESS return Result.SUCCESS
} }
@ -38,4 +39,4 @@ class BackupCreatorJob : Job() {
JobManager.instance().cancelAllForTag(TAG) JobManager.instance().cancelAllForTag(TAG)
} }
} }
} }

View File

@ -1,8 +1,11 @@
package eu.kanade.tachiyomi.data.backup package eu.kanade.tachiyomi.data.backup
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri
import com.github.salomonbrys.kotson.* import com.github.salomonbrys.kotson.*
import com.google.gson.* import com.google.gson.*
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY_MASK import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY_MASK
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER
@ -11,6 +14,7 @@ import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HIST
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY_MASK import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY_MASK
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK_MASK import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK_MASK
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS
import eu.kanade.tachiyomi.data.backup.models.Backup.CURRENT_VERSION import eu.kanade.tachiyomi.data.backup.models.Backup.CURRENT_VERSION
@ -26,8 +30,10 @@ import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.util.sendLocalBroadcast
import eu.kanade.tachiyomi.util.syncChaptersWithSource import eu.kanade.tachiyomi.util.syncChaptersWithSource
import rx.Observable import rx.Observable
import timber.log.Timber
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
@ -85,6 +91,92 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
else -> throw Exception("Json version unknown") else -> throw Exception("Json version unknown")
} }
/**
* Create backup Json file from database
*
* @param uri path of Uri
* @param isJob backup called from job
*/
fun createBackup(uri: Uri, flags: Int, isJob: Boolean) {
// Create root object
val root = JsonObject()
// Create manga array
val mangaEntries = JsonArray()
// Create category array
val categoryEntries = JsonArray()
// Add value's to root
root[Backup.VERSION] = Backup.CURRENT_VERSION
root[Backup.MANGAS] = mangaEntries
root[CATEGORIES] = categoryEntries
databaseHelper.inTransaction {
// Get manga from database
val mangas = getFavoriteManga()
// Backup library manga and its dependencies
mangas.forEach { manga ->
mangaEntries.add(backupMangaObject(manga, flags))
}
// Backup categories
if ((flags and BACKUP_CATEGORY_MASK) == BACKUP_CATEGORY) {
backupCategories(categoryEntries)
}
}
try {
// When BackupCreatorJob
if (isJob) {
// Get dir of file and create
var dir = UniFile.fromUri(context, uri)
dir = dir.createDirectory("automatic")
// Delete older backups
val numberOfBackups = numberOfBackups()
val backupRegex = Regex("""tachiyomi_\d+-\d+-\d+_\d+-\d+.json""")
dir.listFiles { _, filename -> backupRegex.matches(filename) }
.orEmpty()
.sortedByDescending { it.name }
.drop(numberOfBackups - 1)
.forEach { it.delete() }
// Create new file to place backup
val newFile = dir.createFile(Backup.getDefaultFilename())
?: throw Exception("Couldn't create backup file")
newFile.openOutputStream().bufferedWriter().use {
parser.toJson(root, it)
}
} else {
val file = UniFile.fromUri(context, uri)
?: throw Exception("Couldn't create backup file")
file.openOutputStream().bufferedWriter().use {
parser.toJson(root, it)
}
// Show completed dialog
val intent = Intent(BackupConst.INTENT_FILTER).apply {
putExtra(BackupConst.ACTION, BackupConst.ACTION_BACKUP_COMPLETED_DIALOG)
putExtra(BackupConst.EXTRA_URI, file.uri.toString())
}
context.sendLocalBroadcast(intent)
}
} catch (e: Exception) {
Timber.e(e)
if (!isJob) {
// Show error dialog
val intent = Intent(BackupConst.INTENT_FILTER).apply {
putExtra(BackupConst.ACTION, BackupConst.ACTION_ERROR_BACKUP_DIALOG)
putExtra(BackupConst.EXTRA_ERROR_MESSAGE, e.message)
}
context.sendLocalBroadcast(intent)
}
}
}
/** /**
* Backup the categories of library * Backup the categories of library
* *
@ -310,8 +402,11 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
for (dbTrack in dbTracks) { for (dbTrack in dbTracks) {
if (track.sync_id == dbTrack.sync_id) { if (track.sync_id == dbTrack.sync_id) {
// The sync is already in the db, only update its fields // The sync is already in the db, only update its fields
if (track.remote_id != dbTrack.remote_id) { if (track.media_id != dbTrack.media_id) {
dbTrack.remote_id = track.remote_id dbTrack.media_id = track.media_id
}
if (track.library_id != dbTrack.library_id) {
dbTrack.library_id = track.library_id
} }
dbTrack.last_chapter_read = Math.max(dbTrack.last_chapter_read, track.last_chapter_read) dbTrack.last_chapter_read = Math.max(dbTrack.last_chapter_read, track.last_chapter_read)
isInDatabase = true isInDatabase = true

View File

@ -33,7 +33,8 @@ import timber.log.Timber
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.Date
import java.util.Locale
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
@ -295,7 +296,7 @@ class BackupRestoreService : Service() {
categories: List<String>, history: List<DHistory>, categories: List<String>, history: List<DHistory>,
tracks: List<Track>): Observable<Manga>? { tracks: List<Track>): Observable<Manga>? {
// Get source // Get source
val source = backupManager.sourceManager.get(manga.source) ?: return null val source = backupManager.sourceManager.getOrStub(manga.source)
val dbManga = backupManager.getMangaFromDatabase(manga) val dbManga = backupManager.getMangaFromDatabase(manga)
return if (dbManga == null) { return if (dbManga == null) {
@ -441,4 +442,4 @@ class BackupRestoreService : Service() {
sendLocalBroadcast(intent) sendLocalBroadcast(intent)
} }
} }

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.data.backup.serializer package eu.kanade.tachiyomi.data.backup.serializer
import android.telecom.DisconnectCause.REMOTE
import com.github.salomonbrys.kotson.typeAdapter import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonToken import com.google.gson.stream.JsonToken
@ -11,9 +12,11 @@ import eu.kanade.tachiyomi.data.database.models.TrackImpl
object TrackTypeAdapter { object TrackTypeAdapter {
private const val SYNC = "s" private const val SYNC = "s"
private const val REMOTE = "r" private const val MEDIA = "r"
private const val LIBRARY = "ml"
private const val TITLE = "t" private const val TITLE = "t"
private const val LAST_READ = "l" private const val LAST_READ = "l"
private const val TRACKING_URL = "u"
fun build(): TypeAdapter<TrackImpl> { fun build(): TypeAdapter<TrackImpl> {
return typeAdapter { return typeAdapter {
@ -23,10 +26,14 @@ object TrackTypeAdapter {
value(it.title) value(it.title)
name(SYNC) name(SYNC)
value(it.sync_id) value(it.sync_id)
name(REMOTE) name(MEDIA)
value(it.remote_id) value(it.media_id)
name(LIBRARY)
value(it.library_id)
name(LAST_READ) name(LAST_READ)
value(it.last_chapter_read) value(it.last_chapter_read)
name(TRACKING_URL)
value(it.tracking_url)
endObject() endObject()
} }
@ -40,8 +47,10 @@ object TrackTypeAdapter {
when (name) { when (name) {
TITLE -> track.title = nextString() TITLE -> track.title = nextString()
SYNC -> track.sync_id = nextInt() SYNC -> track.sync_id = nextInt()
REMOTE -> track.remote_id = nextInt() MEDIA -> track.media_id = nextInt()
LIBRARY -> track.library_id = nextLong()
LAST_READ -> track.last_chapter_read = nextInt() LAST_READ -> track.last_chapter_read = nextInt()
TRACKING_URL -> track.tracking_url = nextString()
} }
} }
} }

View File

@ -1,10 +1,12 @@
package eu.kanade.tachiyomi.data.database package eu.kanade.tachiyomi.data.database
import android.arch.persistence.db.SupportSQLiteOpenHelper
import android.content.Context import android.content.Context
import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite
import eu.kanade.tachiyomi.data.database.mappers.* import eu.kanade.tachiyomi.data.database.mappers.*
import eu.kanade.tachiyomi.data.database.models.* import eu.kanade.tachiyomi.data.database.models.*
import eu.kanade.tachiyomi.data.database.queries.* import eu.kanade.tachiyomi.data.database.queries.*
import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
/** /**
* This class provides operations to manage the database through its interfaces. * This class provides operations to manage the database through its interfaces.
@ -12,8 +14,13 @@ import eu.kanade.tachiyomi.data.database.queries.*
open class DatabaseHelper(context: Context) open class DatabaseHelper(context: Context)
: MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries { : MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries {
private val configuration = SupportSQLiteOpenHelper.Configuration.builder(context)
.name(DbOpenCallback.DATABASE_NAME)
.callback(DbOpenCallback())
.build()
override val db = DefaultStorIOSQLite.builder() override val db = DefaultStorIOSQLite.builder()
.sqliteOpenHelper(DbOpenHelper(context)) .sqliteOpenHelper(RequerySQLiteOpenHelperFactory().create(configuration))
.addTypeMapping(Manga::class.java, MangaTypeMapping()) .addTypeMapping(Manga::class.java, MangaTypeMapping())
.addTypeMapping(Chapter::class.java, ChapterTypeMapping()) .addTypeMapping(Chapter::class.java, ChapterTypeMapping())
.addTypeMapping(Track::class.java, TrackTypeMapping()) .addTypeMapping(Track::class.java, TrackTypeMapping())

View File

@ -1,12 +1,13 @@
package eu.kanade.tachiyomi.data.database package eu.kanade.tachiyomi.data.database
import android.arch.persistence.db.SupportSQLiteDatabase
import android.arch.persistence.db.SupportSQLiteOpenHelper
import android.content.Context import android.content.Context
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper import android.database.sqlite.SQLiteOpenHelper
import eu.kanade.tachiyomi.data.database.tables.* import eu.kanade.tachiyomi.data.database.tables.*
class DbOpenHelper(context: Context) class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
: SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
companion object { companion object {
/** /**
@ -17,10 +18,10 @@ class DbOpenHelper(context: Context)
/** /**
* Version of the database. * Version of the database.
*/ */
const val DATABASE_VERSION = 5 const val DATABASE_VERSION = 8
} }
override fun onCreate(db: SQLiteDatabase) = with(db) { override fun onCreate(db: SupportSQLiteDatabase) = with(db) {
execSQL(MangaTable.createTableQuery) execSQL(MangaTable.createTableQuery)
execSQL(ChapterTable.createTableQuery) execSQL(ChapterTable.createTableQuery)
execSQL(TrackTable.createTableQuery) execSQL(TrackTable.createTableQuery)
@ -30,12 +31,13 @@ class DbOpenHelper(context: Context)
// DB indexes // DB indexes
execSQL(MangaTable.createUrlIndexQuery) execSQL(MangaTable.createUrlIndexQuery)
execSQL(MangaTable.createFavoriteIndexQuery) execSQL(MangaTable.createLibraryIndexQuery)
execSQL(ChapterTable.createMangaIdIndexQuery) execSQL(ChapterTable.createMangaIdIndexQuery)
execSQL(ChapterTable.createUnreadChaptersIndexQuery)
execSQL(HistoryTable.createChapterIdIndexQuery) execSQL(HistoryTable.createChapterIdIndexQuery)
} }
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
if (oldVersion < 2) { if (oldVersion < 2) {
db.execSQL(ChapterTable.sourceOrderUpdateQuery) db.execSQL(ChapterTable.sourceOrderUpdateQuery)
@ -54,9 +56,20 @@ class DbOpenHelper(context: Context)
if (oldVersion < 5) { if (oldVersion < 5) {
db.execSQL(ChapterTable.addScanlator) db.execSQL(ChapterTable.addScanlator)
} }
if (oldVersion < 6) {
db.execSQL(TrackTable.addTrackingUrl)
}
if (oldVersion < 7) {
db.execSQL(TrackTable.addLibraryId)
}
if (oldVersion < 8) {
db.execSQL("DROP INDEX IF EXISTS mangas_favorite_index")
db.execSQL(MangaTable.createLibraryIndexQuery)
db.execSQL(ChapterTable.createUnreadChaptersIndexQuery)
}
} }
override fun onConfigure(db: SQLiteDatabase) { override fun onConfigure(db: SupportSQLiteDatabase) {
db.setForeignKeyConstraintsEnabled(true) db.setForeignKeyConstraintsEnabled(true)
} }

View File

@ -13,13 +13,15 @@ import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.database.models.TrackImpl import eu.kanade.tachiyomi.data.database.models.TrackImpl
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_ID import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_ID
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_LAST_CHAPTER_READ import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_LAST_CHAPTER_READ
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_LIBRARY_ID
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MANGA_ID import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MANGA_ID
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_REMOTE_ID import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MEDIA_ID
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SCORE import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SCORE
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_STATUS import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_STATUS
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SYNC_ID import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SYNC_ID
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TITLE import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TITLE
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TOTAL_CHAPTERS import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TOTAL_CHAPTERS
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TRACKING_URL
import eu.kanade.tachiyomi.data.database.tables.TrackTable.TABLE import eu.kanade.tachiyomi.data.database.tables.TrackTable.TABLE
class TrackTypeMapping : SQLiteTypeMapping<Track>( class TrackTypeMapping : SQLiteTypeMapping<Track>(
@ -40,16 +42,19 @@ class TrackPutResolver : DefaultPutResolver<Track>() {
.whereArgs(obj.id) .whereArgs(obj.id)
.build() .build()
override fun mapToContentValues(obj: Track) = ContentValues(9).apply { override fun mapToContentValues(obj: Track) = ContentValues(10).apply {
put(COL_ID, obj.id) put(COL_ID, obj.id)
put(COL_MANGA_ID, obj.manga_id) put(COL_MANGA_ID, obj.manga_id)
put(COL_SYNC_ID, obj.sync_id) put(COL_SYNC_ID, obj.sync_id)
put(COL_REMOTE_ID, obj.remote_id) put(COL_MEDIA_ID, obj.media_id)
put(COL_LIBRARY_ID, obj.library_id)
put(COL_TITLE, obj.title) put(COL_TITLE, obj.title)
put(COL_LAST_CHAPTER_READ, obj.last_chapter_read) put(COL_LAST_CHAPTER_READ, obj.last_chapter_read)
put(COL_TOTAL_CHAPTERS, obj.total_chapters) put(COL_TOTAL_CHAPTERS, obj.total_chapters)
put(COL_STATUS, obj.status) put(COL_STATUS, obj.status)
put(COL_TRACKING_URL, obj.tracking_url)
put(COL_SCORE, obj.score) put(COL_SCORE, obj.score)
} }
} }
@ -59,12 +64,14 @@ class TrackGetResolver : DefaultGetResolver<Track>() {
id = cursor.getLong(cursor.getColumnIndex(COL_ID)) id = cursor.getLong(cursor.getColumnIndex(COL_ID))
manga_id = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID)) manga_id = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID))
sync_id = cursor.getInt(cursor.getColumnIndex(COL_SYNC_ID)) sync_id = cursor.getInt(cursor.getColumnIndex(COL_SYNC_ID))
remote_id = cursor.getInt(cursor.getColumnIndex(COL_REMOTE_ID)) media_id = cursor.getInt(cursor.getColumnIndex(COL_MEDIA_ID))
library_id = cursor.getLong(cursor.getColumnIndex(COL_LIBRARY_ID))
title = cursor.getString(cursor.getColumnIndex(COL_TITLE)) title = cursor.getString(cursor.getColumnIndex(COL_TITLE))
last_chapter_read = cursor.getInt(cursor.getColumnIndex(COL_LAST_CHAPTER_READ)) last_chapter_read = cursor.getInt(cursor.getColumnIndex(COL_LAST_CHAPTER_READ))
total_chapters = cursor.getInt(cursor.getColumnIndex(COL_TOTAL_CHAPTERS)) total_chapters = cursor.getInt(cursor.getColumnIndex(COL_TOTAL_CHAPTERS))
status = cursor.getInt(cursor.getColumnIndex(COL_STATUS)) status = cursor.getInt(cursor.getColumnIndex(COL_STATUS))
score = cursor.getFloat(cursor.getColumnIndex(COL_SCORE)) score = cursor.getFloat(cursor.getColumnIndex(COL_SCORE))
tracking_url = cursor.getString(cursor.getColumnIndex(COL_TRACKING_URL))
} }
} }

View File

@ -10,7 +10,9 @@ interface Track : Serializable {
var sync_id: Int var sync_id: Int
var remote_id: Int var media_id: Int
var library_id: Long?
var title: String var title: String
@ -22,6 +24,8 @@ interface Track : Serializable {
var status: Int var status: Int
var tracking_url: String
fun copyPersonalFrom(other: Track) { fun copyPersonalFrom(other: Track) {
last_chapter_read = other.last_chapter_read last_chapter_read = other.last_chapter_read
score = other.score score = other.score
@ -29,7 +33,6 @@ interface Track : Serializable {
} }
companion object { companion object {
fun create(serviceId: Int): Track = TrackImpl().apply { fun create(serviceId: Int): Track = TrackImpl().apply {
sync_id = serviceId sync_id = serviceId
} }

View File

@ -8,7 +8,9 @@ class TrackImpl : Track {
override var sync_id: Int = 0 override var sync_id: Int = 0
override var remote_id: Int = 0 override var media_id: Int = 0
override var library_id: Long? = null
override lateinit var title: String override lateinit var title: String
@ -20,6 +22,8 @@ class TrackImpl : Track {
override var status: Int = 0 override var status: Int = 0
override var tracking_url: String = ""
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (other == null || javaClass != other.javaClass) return false if (other == null || javaClass != other.javaClass) return false
@ -28,13 +32,13 @@ class TrackImpl : Track {
if (manga_id != other.manga_id) return false if (manga_id != other.manga_id) return false
if (sync_id != other.sync_id) return false if (sync_id != other.sync_id) return false
return remote_id == other.remote_id return media_id == other.media_id
} }
override fun hashCode(): Int { override fun hashCode(): Int {
var result = (manga_id xor manga_id.ushr(32)).toInt() var result = (manga_id xor manga_id.ushr(32)).toInt()
result = 31 * result + sync_id result = 31 * result + sync_id
result = 31 * result + remote_id result = 31 * result + media_id
return result return result
} }

View File

@ -6,9 +6,7 @@ import com.pushtorefresh.storio.sqlite.queries.RawQuery
import eu.kanade.tachiyomi.data.database.DbProvider import eu.kanade.tachiyomi.data.database.DbProvider
import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.resolvers.LibraryMangaGetResolver import eu.kanade.tachiyomi.data.database.resolvers.*
import eu.kanade.tachiyomi.data.database.resolvers.MangaFlagsPutResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaLastUpdatedPutResolver
import eu.kanade.tachiyomi.data.database.tables.CategoryTable import eu.kanade.tachiyomi.data.database.tables.CategoryTable
import eu.kanade.tachiyomi.data.database.tables.ChapterTable import eu.kanade.tachiyomi.data.database.tables.ChapterTable
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable
@ -74,6 +72,16 @@ interface MangaQueries : DbProvider {
.withPutResolver(MangaLastUpdatedPutResolver()) .withPutResolver(MangaLastUpdatedPutResolver())
.prepare() .prepare()
fun updateMangaFavorite(manga: Manga) = db.put()
.`object`(manga)
.withPutResolver(MangaFavoritePutResolver())
.prepare()
fun updateMangaViewer(manga: Manga) = db.put()
.`object`(manga)
.withPutResolver(MangaViewerPutResolver())
.prepare()
fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare() fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare()
fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare() fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare()
@ -102,4 +110,4 @@ interface MangaQueries : DbProvider {
fun getTotalChapterManga() = db.get().listOfObjects(Manga::class.java) fun getTotalChapterManga() = db.get().listOfObjects(Manga::class.java)
.withQuery(RawQuery.builder().query(getTotalChapterMangaQuery()).observesTables(MangaTable.TABLE).build()).prepare(); .withQuery(RawQuery.builder().query(getTotalChapterMangaQuery()).observesTables(MangaTable.TABLE).build()).prepare();
} }

View File

@ -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)
}
}

View File

@ -0,0 +1,32 @@
package eu.kanade.tachiyomi.data.database.resolvers
import android.content.ContentValues
import com.pushtorefresh.storio.sqlite.StorIOSQLite
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
import eu.kanade.tachiyomi.data.database.inTransactionReturn
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.tables.MangaTable
class MangaViewerPutResolver : PutResolver<Manga>() {
override fun performPut(db: StorIOSQLite, manga: Manga) = db.inTransactionReturn {
val updateQuery = mapToUpdateQuery(manga)
val contentValues = mapToContentValues(manga)
val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
}
fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder()
.table(MangaTable.TABLE)
.where("${MangaTable.COL_ID} = ?")
.whereArgs(manga.id)
.build()
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
put(MangaTable.COL_VIEWER, manga.viewer)
}
}

View File

@ -49,6 +49,10 @@ object ChapterTable {
val createMangaIdIndexQuery: String val createMangaIdIndexQuery: String
get() = "CREATE INDEX ${TABLE}_${COL_MANGA_ID}_index ON $TABLE($COL_MANGA_ID)" get() = "CREATE INDEX ${TABLE}_${COL_MANGA_ID}_index ON $TABLE($COL_MANGA_ID)"
val createUnreadChaptersIndexQuery: String
get() = "CREATE INDEX ${TABLE}_unread_by_manga_index ON $TABLE($COL_MANGA_ID, $COL_READ) " +
"WHERE $COL_READ = 0"
val sourceOrderUpdateQuery: String val sourceOrderUpdateQuery: String
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SOURCE_ORDER INTEGER DEFAULT 0" get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SOURCE_ORDER INTEGER DEFAULT 0"

View File

@ -60,6 +60,7 @@ object MangaTable {
val createUrlIndexQuery: String val createUrlIndexQuery: String
get() = "CREATE INDEX ${TABLE}_${COL_URL}_index ON $TABLE($COL_URL)" get() = "CREATE INDEX ${TABLE}_${COL_URL}_index ON $TABLE($COL_URL)"
val createFavoriteIndexQuery: String val createLibraryIndexQuery: String
get() = "CREATE INDEX ${TABLE}_${COL_FAVORITE}_index ON $TABLE($COL_FAVORITE)" get() = "CREATE INDEX library_${COL_FAVORITE}_index ON $TABLE($COL_FAVORITE) " +
"WHERE $COL_FAVORITE = 1"
} }

View File

@ -10,7 +10,9 @@ object TrackTable {
const val COL_SYNC_ID = "sync_id" const val COL_SYNC_ID = "sync_id"
const val COL_REMOTE_ID = "remote_id" const val COL_MEDIA_ID = "remote_id"
const val COL_LIBRARY_ID = "library_id"
const val COL_TITLE = "title" const val COL_TITLE = "title"
@ -22,20 +24,29 @@ object TrackTable {
const val COL_TOTAL_CHAPTERS = "total_chapters" const val COL_TOTAL_CHAPTERS = "total_chapters"
const val COL_TRACKING_URL = "remote_url"
val createTableQuery: String val createTableQuery: String
get() = """CREATE TABLE $TABLE( get() = """CREATE TABLE $TABLE(
$COL_ID INTEGER NOT NULL PRIMARY KEY, $COL_ID INTEGER NOT NULL PRIMARY KEY,
$COL_MANGA_ID INTEGER NOT NULL, $COL_MANGA_ID INTEGER NOT NULL,
$COL_SYNC_ID INTEGER NOT NULL, $COL_SYNC_ID INTEGER NOT NULL,
$COL_REMOTE_ID INTEGER NOT NULL, $COL_MEDIA_ID INTEGER NOT NULL,
$COL_LIBRARY_ID INTEGER,
$COL_TITLE TEXT NOT NULL, $COL_TITLE TEXT NOT NULL,
$COL_LAST_CHAPTER_READ INTEGER NOT NULL, $COL_LAST_CHAPTER_READ INTEGER NOT NULL,
$COL_TOTAL_CHAPTERS INTEGER NOT NULL, $COL_TOTAL_CHAPTERS INTEGER NOT NULL,
$COL_STATUS INTEGER NOT NULL, $COL_STATUS INTEGER NOT NULL,
$COL_SCORE FLOAT NOT NULL, $COL_SCORE FLOAT NOT NULL,
$COL_TRACKING_URL TEXT NOT NULL,
UNIQUE ($COL_MANGA_ID, $COL_SYNC_ID) ON CONFLICT REPLACE, UNIQUE ($COL_MANGA_ID, $COL_SYNC_ID) ON CONFLICT REPLACE,
FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID}) FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID})
ON DELETE CASCADE ON DELETE CASCADE
)""" )"""
val addTrackingUrl: String
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_TRACKING_URL TEXT DEFAULT ''"
val addLibraryId: String
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_LIBRARY_ID INTEGER NULL"
} }

View File

@ -23,10 +23,12 @@ import java.util.concurrent.TimeUnit
* @param sourceManager the source manager. * @param sourceManager the source manager.
* @param preferences the preferences of the app. * @param preferences the preferences of the app.
*/ */
class DownloadCache(private val context: Context, class DownloadCache(
private val provider: DownloadProvider, private val context: Context,
private val sourceManager: SourceManager = Injekt.get(), private val provider: DownloadProvider,
private val preferences: PreferencesHelper = Injekt.get()) { private val sourceManager: SourceManager,
private val preferences: PreferencesHelper = Injekt.get()
) {
/** /**
* The interval after which this cache should be invalidated. 1 hour shouldn't cause major * The interval after which this cache should be invalidated. 1 hour shouldn't cause major
@ -194,6 +196,24 @@ class DownloadCache(private val context: Context,
} }
} }
/**
* Removes a list of chapters that have been deleted from this cache.
*
* @param chapters the list of chapter to remove.
* @param manga the manga of the chapter.
*/
@Synchronized
fun removeChapters(chapters: List<Chapter>, manga: Manga) {
val sourceDir = rootDir.files[manga.source] ?: return
val mangaDir = sourceDir.files[provider.getMangaDirName(manga)] ?: return
for (chapter in chapters) {
val chapterDirName = provider.getChapterDirName(chapter)
if (chapterDirName in mangaDir.files) {
mangaDir.files -= chapterDirName
}
}
}
/** /**
* Removes a manga that has been deleted from this cache. * Removes a manga that has been deleted from this cache.
* *

View File

@ -7,8 +7,10 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.DownloadQueue import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy
/** /**
* This class is used to manage chapter downloads in the application. It must be instantiated once * This class is used to manage chapter downloads in the application. It must be instantiated once
@ -19,6 +21,11 @@ import rx.Observable
*/ */
class DownloadManager(context: Context) { class DownloadManager(context: Context) {
/**
* The sources manager.
*/
private val sourceManager by injectLazy<SourceManager>()
/** /**
* Downloads provider, used to retrieve the folders where the chapters are or should be stored. * Downloads provider, used to retrieve the folders where the chapters are or should be stored.
*/ */
@ -27,12 +34,17 @@ class DownloadManager(context: Context) {
/** /**
* Cache of downloaded chapters. * Cache of downloaded chapters.
*/ */
private val cache = DownloadCache(context, provider) private val cache = DownloadCache(context, provider, sourceManager)
/** /**
* Downloader whose only task is to download chapters. * Downloader whose only task is to download chapters.
*/ */
private val downloader = Downloader(context, provider, cache) private val downloader = Downloader(context, provider, cache, sourceManager)
/**
* Queue to delay the deletion of a list of chapters until triggered.
*/
private val pendingDeleter = DownloadPendingDeleter(context)
/** /**
* Downloads queue, where the pending chapters are stored. * Downloads queue, where the pending chapters are stored.
@ -146,15 +158,20 @@ class DownloadManager(context: Context) {
} }
/** /**
* Deletes the directory of a downloaded chapter. * Deletes the directories of a list of downloaded chapters.
* *
* @param chapter the chapter to delete. * @param chapters the list of chapters to delete.
* @param manga the manga of the chapter. * @param manga the manga of the chapters.
* @param source the source of the chapter. * @param source the source of the chapters.
*/ */
fun deleteChapter(chapter: Chapter, manga: Manga, source: Source) { fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source) {
provider.findChapterDir(chapter, manga, source)?.delete() queue.remove(chapters)
cache.removeChapter(chapter, manga) val chapterDirs = provider.findChapterDirs(chapters, manga, source)
chapterDirs.forEach { it.delete() }
cache.removeChapters(chapters, manga)
if (cache.getDownloadCount(manga) == 0) { // Delete manga directory if empty
chapterDirs.firstOrNull()?.parentFile?.delete()
}
} }
/** /**
@ -164,7 +181,30 @@ class DownloadManager(context: Context) {
* @param source the source of the manga. * @param source the source of the manga.
*/ */
fun deleteManga(manga: Manga, source: Source) { fun deleteManga(manga: Manga, source: Source) {
queue.remove(manga)
provider.findMangaDir(manga, source)?.delete() provider.findMangaDir(manga, source)?.delete()
cache.removeManga(manga) cache.removeManga(manga)
} }
/**
* Adds a list of chapters to be deleted later.
*
* @param chapters the list of chapters to delete.
* @param manga the manga of the chapters.
*/
fun enqueueDeleteChapters(chapters: List<Chapter>, manga: Manga) {
pendingDeleter.addChapters(chapters, manga)
}
/**
* Triggers the execution of the deletion of pending chapters.
*/
fun deletePendingChapters() {
val pendingChapters = pendingDeleter.getPendingChapters()
for ((manga, chapters) in pendingChapters) {
val source = sourceManager.get(manga.source) ?: continue
deleteChapters(chapters, manga, source)
}
}
} }

View File

@ -36,19 +36,13 @@ internal class DownloadNotifier(private val context: Context) {
* The size of queue on start download. * The size of queue on start download.
*/ */
var initialQueueSize = 0 var initialQueueSize = 0
get() = field
set(value) { set(value) {
if (value != 0){ if (value != 0) {
isSingleChapter = (value == 1) isSingleChapter = (value == 1)
} }
field = value field = value
} }
/**
* Simultaneous download setting > 1.
*/
var multipleDownloadThreads = false
/** /**
* Updated when error is thrown * Updated when error is thrown
*/ */
@ -91,36 +85,10 @@ internal class DownloadNotifier(private val context: Context) {
/** /**
* Called when download progress changes. * Called when download progress changes.
* Note: Only accepted when multi download active.
*
* @param queue the queue containing downloads.
*/
fun onProgressChange(queue: DownloadQueue) {
if (multipleDownloadThreads) {
doOnProgressChange(null, queue)
}
}
/**
* Called when download progress changes.
* Note: Only accepted when single download active.
* *
* @param download download object containing download information. * @param download download object containing download information.
* @param queue the queue containing downloads.
*/ */
fun onProgressChange(download: Download, queue: DownloadQueue) { fun onProgressChange(download: Download) {
if (!multipleDownloadThreads) {
doOnProgressChange(download, queue)
}
}
/**
* Show notification progress of chapter.
*
* @param download download object containing download information.
* @param queue the queue containing downloads.
*/
private fun doOnProgressChange(download: Download?, queue: DownloadQueue) {
// Create notification // Create notification
with(notification) { with(notification) {
// Check if first call. // Check if first call.
@ -131,30 +99,19 @@ internal class DownloadNotifier(private val context: Context) {
// Open download manager when clicked // Open download manager when clicked
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
isDownloading = true isDownloading = true
// Pause action
addAction(R.drawable.ic_av_pause_grey_24dp_img,
context.getString(R.string.action_pause),
NotificationReceiver.pauseDownloadsPendingBroadcast(context))
} }
if (multipleDownloadThreads) { val title = download.manga.title.chop(15)
setContentTitle(context.getString(R.string.app_name)) val quotedTitle = Pattern.quote(title)
val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "")
// Reset the queue size if the download progress is negative setContentTitle("$title - $chapter".chop(30))
if ((initialQueueSize - queue.size) < 0) setContentText(context.getString(R.string.chapter_downloading_progress)
initialQueueSize = queue.size .format(download.downloadedImages, download.pages!!.size))
setProgress(download.pages!!.size, download.downloadedImages, false)
setContentText(context.getString(R.string.chapter_downloading_progress)
.format(initialQueueSize - queue.size, initialQueueSize))
setProgress(initialQueueSize, initialQueueSize - queue.size, false)
} else {
download?.let {
val title = it.manga.title.chop(15)
val quotedTitle = Pattern.quote(title)
val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "")
setContentTitle("$title - $chapter".chop(30))
setContentText(context.getString(R.string.chapter_downloading_progress)
.format(it.downloadedImages, it.pages!!.size))
setProgress(it.pages!!.size, it.downloadedImages, false)
}
}
} }
// Displays the progress bar on notification // Displays the progress bar on notification
notification.show() notification.show()

View File

@ -0,0 +1,180 @@
package eu.kanade.tachiyomi.data.download
import android.content.Context
import com.github.salomonbrys.kotson.fromJson
import com.google.gson.Gson
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import uy.kohesive.injekt.injectLazy
/**
* Class used to keep a list of chapters for future deletion.
*
* @param context the application context.
*/
class DownloadPendingDeleter(context: Context) {
/**
* Gson instance to encode and decode chapters.
*/
private val gson by injectLazy<Gson>()
/**
* Preferences used to store the list of chapters to delete.
*/
private val prefs = context.getSharedPreferences("chapters_to_delete", Context.MODE_PRIVATE)
/**
* Last added chapter, used to avoid decoding from the preference too often.
*/
private var lastAddedEntry: Entry? = null
/**
* Adds a list of chapters for future deletion.
*
* @param chapters the chapters to be deleted.
* @param manga the manga of the chapters.
*/
@Synchronized
fun addChapters(chapters: List<Chapter>, manga: Manga) {
val lastEntry = lastAddedEntry
val newEntry = if (lastEntry != null && lastEntry.manga.id == manga.id) {
// Append new chapters
val newChapters = lastEntry.chapters.addUniqueById(chapters)
// If no chapters were added, do nothing
if (newChapters.size == lastEntry.chapters.size) return
// Last entry matches the manga, reuse it to avoid decoding json from preferences
lastEntry.copy(chapters = newChapters)
} else {
val existingEntry = prefs.getString(manga.id!!.toString(), null)
if (existingEntry != null) {
// Existing entry found on preferences, decode json and add the new chapter
val savedEntry = gson.fromJson<Entry>(existingEntry)
// Append new chapters
val newChapters = savedEntry.chapters.addUniqueById(chapters)
// If no chapters were added, do nothing
if (newChapters.size == savedEntry.chapters.size) return
savedEntry.copy(chapters = newChapters)
} else {
// No entry has been found yet, create a new one
Entry(chapters.map { it.toEntry() }, manga.toEntry())
}
}
// Save current state
val json = gson.toJson(newEntry)
prefs.edit().putString(newEntry.manga.id.toString(), json).apply()
lastAddedEntry = newEntry
}
/**
* Returns the list of chapters to be deleted grouped by its manga.
*
* Note: the returned list of manga and chapters only contain basic information needed by the
* downloader, so don't use them for anything else.
*/
@Synchronized
fun getPendingChapters(): Map<Manga, List<Chapter>> {
val entries = decodeAll()
prefs.edit().clear().apply()
lastAddedEntry = null
return entries.associate { entry ->
entry.manga.toModel() to entry.chapters.map { it.toModel() }
}
}
/**
* Decodes all the chapters from preferences.
*/
private fun decodeAll(): List<Entry> {
return prefs.all.values.mapNotNull { rawEntry ->
try {
(rawEntry as? String)?.let { gson.fromJson<Entry>(it) }
} catch (e: Exception) {
null
}
}
}
/**
* Returns a copy of chapter entries ensuring no duplicates by chapter id.
*/
private fun List<ChapterEntry>.addUniqueById(chapters: List<Chapter>): List<ChapterEntry> {
val newList = toMutableList()
for (chapter in chapters) {
if (none { it.id == chapter.id }) {
newList.add(chapter.toEntry())
}
}
return newList
}
/**
* Class used to save an entry of chapters with their manga into preferences.
*/
private data class Entry(
val chapters: List<ChapterEntry>,
val manga: MangaEntry
)
/**
* Class used to save an entry for a chapter into preferences.
*/
private data class ChapterEntry(
val id: Long,
val url: String,
val name: String
)
/**
* Class used to save an entry for a manga into preferences.
*/
private data class MangaEntry(
val id: Long,
val url: String,
val title: String,
val source: Long
)
/**
* Returns a manga entry from a manga model.
*/
private fun Manga.toEntry(): MangaEntry {
return MangaEntry(id!!, url, title, source)
}
/**
* Returns a chapter entry from a chapter model.
*/
private fun Chapter.toEntry(): ChapterEntry {
return ChapterEntry(id!!, url, name)
}
/**
* Returns a manga model from a manga entry.
*/
private fun MangaEntry.toModel(): Manga {
return Manga.create(url, title, source).also {
it.id = id
}
}
/**
* Returns a chapter model from a chapter entry.
*/
private fun ChapterEntry.toModel(): Chapter {
return Chapter.create().also {
it.id = id
it.url = url
it.name = name
}
}
}

View File

@ -81,6 +81,18 @@ class DownloadProvider(private val context: Context) {
return mangaDir?.findFile(getChapterDirName(chapter)) return mangaDir?.findFile(getChapterDirName(chapter))
} }
/**
* Returns a list of downloaded directories for the chapters that exist.
*
* @param chapters the chapters to query.
* @param manga the manga of the chapter.
* @param source the source of the chapter.
*/
fun findChapterDirs(chapters: List<Chapter>, manga: Manga, source: Source): List<UniFile> {
val mangaDir = findMangaDir(manga, source) ?: return emptyList()
return chapters.mapNotNull { mangaDir.findFile(getChapterDirName(it)) }
}
/** /**
* Returns the download directory name for a source. * Returns the download directory name for a source.
* *
@ -108,4 +120,4 @@ class DownloadProvider(private val context: Context) {
return DiskUtil.buildValidFilename(chapter.name) return DiskUtil.buildValidFilename(chapter.name)
} }
} }

View File

@ -1,16 +1,20 @@
package eu.kanade.tachiyomi.data.download package eu.kanade.tachiyomi.data.download
import android.app.Notification
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.NetworkInfo.State.CONNECTED import android.net.NetworkInfo.State.CONNECTED
import android.net.NetworkInfo.State.DISCONNECTED import android.net.NetworkInfo.State.DISCONNECTED
import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import android.support.v4.app.NotificationCompat
import com.github.pwittchen.reactivenetwork.library.Connectivity import com.github.pwittchen.reactivenetwork.library.Connectivity
import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork
import com.jakewharton.rxrelay.BehaviorRelay import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.connectivityManager import eu.kanade.tachiyomi.util.connectivityManager
import eu.kanade.tachiyomi.util.plusAssign import eu.kanade.tachiyomi.util.plusAssign
@ -41,7 +45,12 @@ class DownloadService : Service() {
* @param context the application context. * @param context the application context.
*/ */
fun start(context: Context) { fun start(context: Context) {
context.startService(Intent(context, DownloadService::class.java)) val intent = Intent(context, DownloadService::class.java)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
context.startService(intent)
} else {
context.startForegroundService(intent)
}
} }
/** /**
@ -81,6 +90,7 @@ class DownloadService : Service() {
*/ */
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
startForeground(Notifications.ID_DOWNLOAD_CHAPTER, getPlaceholderNotification())
runningRelay.call(true) runningRelay.call(true)
subscriptions = CompositeSubscription() subscriptions = CompositeSubscription()
listenDownloaderState() listenDownloaderState()
@ -176,4 +186,10 @@ class DownloadService : Service() {
if (!isHeld) acquire() if (!isHeld) acquire()
} }
private fun getPlaceholderNotification(): Notification {
return NotificationCompat.Builder(this, Notifications.CHANNEL_DOWNLOADER)
.setContentTitle(getString(R.string.download_notifier_downloader_title))
.build()
}
} }

View File

@ -14,7 +14,10 @@ import uy.kohesive.injekt.injectLazy
* *
* @param context the application context. * @param context the application context.
*/ */
class DownloadStore(context: Context) { class DownloadStore(
context: Context,
private val sourceManager: SourceManager
) {
/** /**
* Preference file where active downloads are stored. * Preference file where active downloads are stored.
@ -26,11 +29,6 @@ class DownloadStore(context: Context) {
*/ */
private val gson: Gson by injectLazy() private val gson: Gson by injectLazy()
/**
* Source manager.
*/
private val sourceManager: SourceManager by injectLazy()
/** /**
* Database helper. * Database helper.
*/ */
@ -83,7 +81,7 @@ class DownloadStore(context: Context) {
fun restore(): List<Download> { fun restore(): List<Download> {
val objs = preferences.all val objs = preferences.all
.mapNotNull { it.value as? String } .mapNotNull { it.value as? String }
.map { deserialize(it) } .mapNotNull { deserialize(it) }
.sortedBy { it.order } .sortedBy { it.order }
val downloads = mutableListOf<Download>() val downloads = mutableListOf<Download>()
@ -119,8 +117,12 @@ class DownloadStore(context: Context) {
* *
* @param string the download as string. * @param string the download as string.
*/ */
private fun deserialize(string: String): DownloadObject { private fun deserialize(string: String): DownloadObject? {
return gson.fromJson(string, DownloadObject::class.java) return try {
gson.fromJson(string, DownloadObject::class.java)
} catch (e: Exception) {
null
}
} }
/** /**
@ -132,4 +134,4 @@ class DownloadStore(context: Context) {
*/ */
data class DownloadObject(val mangaId: Long, val chapterId: Long, val order: Int) data class DownloadObject(val mangaId: Long, val chapterId: Long, val order: Int)
} }

View File

@ -9,8 +9,6 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
@ -21,10 +19,8 @@ import okhttp3.Response
import rx.Observable import rx.Observable
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
import rx.subjects.BehaviorSubject
import rx.subscriptions.CompositeSubscription import rx.subscriptions.CompositeSubscription
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.injectLazy
/** /**
* This class is the one in charge of downloading chapters. * This class is the one in charge of downloading chapters.
@ -38,31 +34,25 @@ import uy.kohesive.injekt.injectLazy
* @param context the application context. * @param context the application context.
* @param provider the downloads directory provider. * @param provider the downloads directory provider.
* @param cache the downloads cache, used to add the downloads to the cache after their completion. * @param cache the downloads cache, used to add the downloads to the cache after their completion.
* @param sourceManager the source manager.
*/ */
class Downloader(private val context: Context, class Downloader(
private val provider: DownloadProvider, private val context: Context,
private val cache: DownloadCache) { private val provider: DownloadProvider,
private val cache: DownloadCache,
private val sourceManager: SourceManager
) {
/** /**
* Store for persisting downloads across restarts. * Store for persisting downloads across restarts.
*/ */
private val store = DownloadStore(context) private val store = DownloadStore(context, sourceManager)
/** /**
* Queue where active downloads are kept. * Queue where active downloads are kept.
*/ */
val queue = DownloadQueue(store) val queue = DownloadQueue(store)
/**
* Source manager.
*/
private val sourceManager: SourceManager by injectLazy()
/**
* Preferences.
*/
private val preferences: PreferencesHelper by injectLazy()
/** /**
* Notifier for the downloader state and progress. * Notifier for the downloader state and progress.
*/ */
@ -73,11 +63,6 @@ class Downloader(private val context: Context,
*/ */
private val subscriptions = CompositeSubscription() private val subscriptions = CompositeSubscription()
/**
* Subject to do a live update of the number of simultaneous downloads.
*/
private val threadsSubject = BehaviorSubject.create<Int>()
/** /**
* Relay to send a list of downloads to the downloader. * Relay to send a list of downloads to the downloader.
*/ */
@ -116,9 +101,6 @@ class Downloader(private val context: Context,
val pending = queue.filter { it.status != Download.DOWNLOADED } val pending = queue.filter { it.status != Download.DOWNLOADED }
pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE } pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE }
// Show download notification when simultaneous download > 1.
notifier.onProgressChange(queue)
downloadsRelay.call(pending) downloadsRelay.call(pending)
return !pending.isEmpty() return !pending.isEmpty()
} }
@ -185,14 +167,8 @@ class Downloader(private val context: Context,
subscriptions.clear() subscriptions.clear()
subscriptions += preferences.downloadThreads().asObservable() subscriptions += downloadsRelay.concatMapIterable { it }
.subscribe { .concatMap { downloadChapter(it).subscribeOn(Schedulers.io()) }
threadsSubject.onNext(it)
notifier.multipleDownloadThreads = it > 1
}
subscriptions += downloadsRelay.flatMap { Observable.from(it) }
.lift(DynamicConcurrentMergeOperator<Download, Download>({ downloadChapter(it) }, threadsSubject))
.onBackpressureBuffer() .onBackpressureBuffer()
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe({ completeDownload(it) .subscribe({ completeDownload(it)
@ -250,15 +226,9 @@ class Downloader(private val context: Context,
// Initialize queue size. // Initialize queue size.
notifier.initialQueueSize = queue.size notifier.initialQueueSize = queue.size
// Initial multi-thread
notifier.multipleDownloadThreads = preferences.downloadThreads().getOrDefault() > 1
if (isRunning) { if (isRunning) {
// Send the list of downloads to the downloader. // Send the list of downloads to the downloader.
downloadsRelay.call(chaptersToQueue) downloadsRelay.call(chaptersToQueue)
} else {
// Show initial notification.
notifier.onProgressChange(queue)
} }
// Start downloader if needed // Start downloader if needed
@ -273,7 +243,7 @@ class Downloader(private val context: Context,
* *
* @param download the chapter to be downloaded. * @param download the chapter to be downloaded.
*/ */
private fun downloadChapter(download: Download): Observable<Download> { private fun downloadChapter(download: Download): Observable<Download> = Observable.defer {
val chapterDirname = provider.getChapterDirName(download.chapter) val chapterDirname = provider.getChapterDirName(download.chapter)
val mangaDir = provider.getMangaDir(download.manga, download.source) val mangaDir = provider.getMangaDir(download.manga, download.source)
val tmpDir = mangaDir.createDirectory("${chapterDirname}_tmp") val tmpDir = mangaDir.createDirectory("${chapterDirname}_tmp")
@ -292,7 +262,7 @@ class Downloader(private val context: Context,
Observable.just(download.pages!!) Observable.just(download.pages!!)
} }
return pageListObservable pageListObservable
.doOnNext { _ -> .doOnNext { _ ->
// Delete all temporary (unfinished) files // Delete all temporary (unfinished) files
tmpDir.listFiles() tmpDir.listFiles()
@ -307,7 +277,7 @@ class Downloader(private val context: Context,
// Start downloading images, consider we can have downloaded images already // Start downloading images, consider we can have downloaded images already
.concatMap { page -> getOrDownloadImage(page, download, tmpDir) } .concatMap { page -> getOrDownloadImage(page, download, tmpDir) }
// Do when page is downloaded. // Do when page is downloaded.
.doOnNext { notifier.onProgressChange(download, queue) } .doOnNext { notifier.onProgressChange(download) }
.toList() .toList()
.map { _ -> download } .map { _ -> download }
// Do after download completes // Do after download completes
@ -318,7 +288,7 @@ class Downloader(private val context: Context,
notifier.onError(error.message, download.chapter.name) notifier.onError(error.message, download.chapter.name)
download download
} }
.subscribeOn(Schedulers.io())
} }
/** /**
@ -408,7 +378,7 @@ class Downloader(private val context: Context,
// Else guess from the uri. // Else guess from the uri.
?: context.contentResolver.getType(file.uri) ?: context.contentResolver.getType(file.uri)
// Else read magic numbers. // Else read magic numbers.
?: DiskUtil.findImageMime { file.openInputStream() } ?: ImageUtil.findImageType { file.openInputStream() }?.mime
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg" return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
} }
@ -448,7 +418,6 @@ class Downloader(private val context: Context,
if (download.status == Download.DOWNLOADED) { if (download.status == Download.DOWNLOADED) {
// remove downloaded chapter from queue // remove downloaded chapter from queue
queue.remove(download) queue.remove(download)
notifier.onProgressChange(queue)
} }
if (areAllDownloadsFinished()) { if (areAllDownloadsFinished()) {
if (notifier.isSingleChapter && !notifier.errorThrown) { if (notifier.isSingleChapter && !notifier.errorThrown) {
@ -465,4 +434,4 @@ class Downloader(private val context: Context,
return queue.none { it.status <= Download.DOWNLOADING } return queue.none { it.status <= Download.DOWNLOADING }
} }
} }

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.download.model
import com.jakewharton.rxrelay.PublishRelay import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadStore import eu.kanade.tachiyomi.data.download.DownloadStore
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import rx.Observable import rx.Observable
@ -40,6 +41,14 @@ class DownloadQueue(
find { it.chapter.id == chapter.id }?.let { remove(it) } find { it.chapter.id == chapter.id }?.let { remove(it) }
} }
fun remove(chapters: List<Chapter>) {
for (chapter in chapters) { remove(chapter) }
}
fun remove(manga: Manga) {
filter { it.manga.id == manga.id }.forEach { remove(it) }
}
fun clear() { fun clear() {
queue.forEach { download -> queue.forEach { download ->
download.setStatusSubject(null) download.setStatusSubject(null)

View File

@ -79,7 +79,7 @@ class MangaModelLoader : ModelLoader<Manga, InputStream> {
* @param height the height of the view where the resource will be loaded. * @param height the height of the view where the resource will be loaded.
*/ */
override fun buildLoadData(manga: Manga, width: Int, height: Int, override fun buildLoadData(manga: Manga, width: Int, height: Int,
options: Options?): ModelLoader.LoadData<InputStream>? { options: Options): ModelLoader.LoadData<InputStream>? {
// Check thumbnail is not null or empty // Check thumbnail is not null or empty
val url = manga.thumbnail_url val url = manga.thumbnail_url
if (url == null || url.isEmpty()) { if (url == null || url.isEmpty()) {

View File

@ -0,0 +1,74 @@
package eu.kanade.tachiyomi.data.glide
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.data.DataFetcher
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.signature.ObjectKey
import java.io.IOException
import java.io.InputStream
class PassthroughModelLoader : ModelLoader<InputStream, InputStream> {
override fun buildLoadData(
model: InputStream,
width: Int,
height: Int,
options: Options
): ModelLoader.LoadData<InputStream>? {
return ModelLoader.LoadData(ObjectKey(model), Fetcher(model))
}
override fun handles(model: InputStream): Boolean {
return true
}
class Fetcher(private val stream: InputStream) : DataFetcher<InputStream> {
override fun getDataClass(): Class<InputStream> {
return InputStream::class.java
}
override fun cleanup() {
try {
stream.close()
} catch (e: IOException) {
// Do nothing
}
}
override fun getDataSource(): DataSource {
return DataSource.LOCAL
}
override fun cancel() {
// Do nothing
}
override fun loadData(
priority: Priority,
callback: DataFetcher.DataCallback<in InputStream>
) {
callback.onDataReady(stream)
}
}
/**
* Factory class for creating [PassthroughModelLoader] instances.
*/
class Factory : ModelLoaderFactory<InputStream, InputStream> {
override fun build(
multiFactory: MultiModelLoaderFactory
): ModelLoader<InputStream, InputStream> {
return PassthroughModelLoader()
}
override fun teardown() {}
}
}

View File

@ -37,5 +37,7 @@ class TachiGlideModule : AppGlideModule() {
registry.replace(GlideUrl::class.java, InputStream::class.java, networkFactory) registry.replace(GlideUrl::class.java, InputStream::class.java, networkFactory)
registry.append(Manga::class.java, InputStream::class.java, MangaModelLoader.Factory()) registry.append(Manga::class.java, InputStream::class.java, MangaModelLoader.Factory())
registry.append(InputStream::class.java, InputStream::class.java, PassthroughModelLoader
.Factory())
} }
} }

View File

@ -304,9 +304,6 @@ class LibraryUpdateService(
} }
// Add manga with new chapters to the list. // Add manga with new chapters to the list.
.doOnNext { manga -> .doOnNext { manga ->
// Set last updated time
manga.last_update = Date().time
db.updateLastUpdated(manga).executeAsBlocking()
// Add to the list // Add to the list
newUpdates.add(manga) newUpdates.add(manga)
} }
@ -463,6 +460,7 @@ class LibraryUpdateService(
if (newUpdates.size > 1) { if (newUpdates.size > 1) {
setContentText(getString(R.string.notification_new_chapters_text, newUpdates.size)) setContentText(getString(R.string.notification_new_chapters_text, newUpdates.size))
setStyle(NotificationCompat.BigTextStyle().bigText(newUpdates.joinToString("\n"))) setStyle(NotificationCompat.BigTextStyle().bigText(newUpdates.joinToString("\n")))
setNumber(newUpdates.size)
} else { } else {
setContentText(newUpdates.first()) setContentText(newUpdates.first())
} }

View File

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.notification
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.getUriCompat import eu.kanade.tachiyomi.util.getUriCompat
import java.io.File import java.io.File
@ -43,11 +44,10 @@ object NotificationHandler {
* Returns [PendingIntent] that prompts user with apk install intent * Returns [PendingIntent] that prompts user with apk install intent
* *
* @param context context * @param context context
* @param file file of apk that is installed * @param uri uri of apk that is installed
*/ */
fun installApkPendingActivity(context: Context, file: File): PendingIntent { fun installApkPendingActivity(context: Context, uri: Uri): PendingIntent {
val intent = Intent(Intent.ACTION_VIEW).apply { val intent = Intent(Intent.ACTION_VIEW).apply {
val uri = file.getUriCompat(context)
setDataAndType(uri, "application/vnd.android.package-archive") setDataAndType(uri, "application/vnd.android.package-archive")
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
} }

View File

@ -38,6 +38,11 @@ class NotificationReceiver : BroadcastReceiver() {
ACTION_DISMISS_NOTIFICATION -> dismissNotification(context, intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)) ACTION_DISMISS_NOTIFICATION -> dismissNotification(context, intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1))
// Resume the download service // Resume the download service
ACTION_RESUME_DOWNLOADS -> DownloadService.start(context) ACTION_RESUME_DOWNLOADS -> DownloadService.start(context)
// Pause the download service
ACTION_PAUSE_DOWNLOADS -> {
DownloadService.stop(context)
downloadManager.pauseDownloads()
}
// Clear the download queue // Clear the download queue
ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue(true) ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue(true)
// Show message notification created // Show message notification created
@ -159,6 +164,9 @@ class NotificationReceiver : BroadcastReceiver() {
// Called to resume downloads. // Called to resume downloads.
private const val ACTION_RESUME_DOWNLOADS = "$ID.$NAME.ACTION_RESUME_DOWNLOADS" private const val ACTION_RESUME_DOWNLOADS = "$ID.$NAME.ACTION_RESUME_DOWNLOADS"
// Called to pause downloads.
private const val ACTION_PAUSE_DOWNLOADS = "$ID.$NAME.ACTION_PAUSE_DOWNLOADS"
// Called to clear downloads. // Called to clear downloads.
private const val ACTION_CLEAR_DOWNLOADS = "$ID.$NAME.ACTION_CLEAR_DOWNLOADS" private const val ACTION_CLEAR_DOWNLOADS = "$ID.$NAME.ACTION_CLEAR_DOWNLOADS"
@ -190,6 +198,19 @@ class NotificationReceiver : BroadcastReceiver() {
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
} }
/**
* Returns [PendingIntent] that pauses the download queue
*
* @param context context of application
* @return [PendingIntent]
*/
internal fun pauseDownloadsPendingBroadcast(context: Context): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_PAUSE_DOWNLOADS
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
/** /**
* Returns a [PendingIntent] that clears the download queue * Returns a [PendingIntent] that clears the download queue
* *
@ -203,7 +224,7 @@ class NotificationReceiver : BroadcastReceiver() {
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
} }
internal fun shortcutCreatedBroadcast(context: Context) : PendingIntent { internal fun shortcutCreatedBroadcast(context: Context): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply { val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_SHORTCUT_CREATED action = ACTION_SHORTCUT_CREATED
} }

View File

@ -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>?) {
}
}

View File

@ -11,6 +11,8 @@ object PreferenceKeys {
const val enableTransitions = "pref_enable_transitions_key" const val enableTransitions = "pref_enable_transitions_key"
const val doubleTapAnimationSpeed = "pref_double_tap_anim_speed"
const val showPageNumber = "pref_show_page_number_key" const val showPageNumber = "pref_show_page_number_key"
const val fullscreen = "fullscreen" const val fullscreen = "fullscreen"
@ -29,16 +31,18 @@ object PreferenceKeys {
const val imageScaleType = "pref_image_scale_type_key" const val imageScaleType = "pref_image_scale_type_key"
const val imageDecoder = "image_decoder"
const val zoomStart = "pref_zoom_start_key" const val zoomStart = "pref_zoom_start_key"
const val readerTheme = "pref_reader_theme_key" const val readerTheme = "pref_reader_theme_key"
const val cropBorders = "crop_borders" const val cropBorders = "crop_borders"
const val cropBordersWebtoon = "crop_borders_webtoon"
const val readWithTapping = "reader_tap" const val readWithTapping = "reader_tap"
const val readWithLongTap = "reader_long_tap"
const val readWithVolumeKeys = "reader_volume_keys" const val readWithVolumeKeys = "reader_volume_keys"
const val readWithVolumeKeysInverted = "reader_volume_keys_inverted" const val readWithVolumeKeysInverted = "reader_volume_keys_inverted"
@ -51,8 +55,6 @@ object PreferenceKeys {
const val autoUpdateTrack = "pref_auto_update_manga_sync_key" const val autoUpdateTrack = "pref_auto_update_manga_sync_key"
const val askUpdateTrack = "pref_ask_update_manga_sync_key"
const val lastUsedCatalogueSource = "last_catalogue_source" const val lastUsedCatalogueSource = "last_catalogue_source"
const val lastUsedCategory = "last_used_category" const val lastUsedCategory = "last_used_category"
@ -65,8 +67,6 @@ object PreferenceKeys {
const val downloadsDirectory = "download_directory" const val downloadsDirectory = "download_directory"
const val downloadThreads = "pref_download_slots_key"
const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key" const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key"
const val numberOfBackups = "backup_slots" const val numberOfBackups = "backup_slots"
@ -107,10 +107,14 @@ object PreferenceKeys {
const val downloadBadge = "display_download_badge" const val downloadBadge = "display_download_badge"
@Deprecated("Use the preferences of the source")
fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId" fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId"
@Deprecated("Use the preferences of the source")
fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId" fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId"
fun sourceSharedPref(sourceId: Long) = "source_$sourceId"
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId" fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"

View File

@ -39,6 +39,8 @@ class PreferencesHelper(val context: Context) {
fun pageTransitions() = rxPrefs.getBoolean(Keys.enableTransitions, true) fun pageTransitions() = rxPrefs.getBoolean(Keys.enableTransitions, true)
fun doubleTapAnimSpeed() = rxPrefs.getInteger(Keys.doubleTapAnimationSpeed, 500)
fun showPageNumber() = rxPrefs.getBoolean(Keys.showPageNumber, true) fun showPageNumber() = rxPrefs.getBoolean(Keys.showPageNumber, true)
fun fullscreen() = rxPrefs.getBoolean(Keys.fullscreen, true) fun fullscreen() = rxPrefs.getBoolean(Keys.fullscreen, true)
@ -57,16 +59,18 @@ class PreferencesHelper(val context: Context) {
fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1) fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1)
fun imageDecoder() = rxPrefs.getInteger(Keys.imageDecoder, 0)
fun zoomStart() = rxPrefs.getInteger(Keys.zoomStart, 1) fun zoomStart() = rxPrefs.getInteger(Keys.zoomStart, 1)
fun readerTheme() = rxPrefs.getInteger(Keys.readerTheme, 0) fun readerTheme() = rxPrefs.getInteger(Keys.readerTheme, 0)
fun cropBorders() = rxPrefs.getBoolean(Keys.cropBorders, false) fun cropBorders() = rxPrefs.getBoolean(Keys.cropBorders, false)
fun cropBordersWebtoon() = rxPrefs.getBoolean(Keys.cropBordersWebtoon, false)
fun readWithTapping() = rxPrefs.getBoolean(Keys.readWithTapping, true) fun readWithTapping() = rxPrefs.getBoolean(Keys.readWithTapping, true)
fun readWithLongTap() = rxPrefs.getBoolean(Keys.readWithLongTap, true)
fun readWithVolumeKeys() = rxPrefs.getBoolean(Keys.readWithVolumeKeys, false) fun readWithVolumeKeys() = rxPrefs.getBoolean(Keys.readWithVolumeKeys, false)
fun readWithVolumeKeysInverted() = rxPrefs.getBoolean(Keys.readWithVolumeKeysInverted, false) fun readWithVolumeKeysInverted() = rxPrefs.getBoolean(Keys.readWithVolumeKeysInverted, false)
@ -79,8 +83,6 @@ class PreferencesHelper(val context: Context) {
fun autoUpdateTrack() = prefs.getBoolean(Keys.autoUpdateTrack, true) fun autoUpdateTrack() = prefs.getBoolean(Keys.autoUpdateTrack, true)
fun askUpdateTrack() = prefs.getBoolean(Keys.askUpdateTrack, false)
fun lastUsedCatalogueSource() = rxPrefs.getLong(Keys.lastUsedCatalogueSource, -1) fun lastUsedCatalogueSource() = rxPrefs.getLong(Keys.lastUsedCatalogueSource, -1)
fun lastUsedCategory() = rxPrefs.getInteger(Keys.lastUsedCategory, 0) fun lastUsedCategory() = rxPrefs.getInteger(Keys.lastUsedCategory, 0)
@ -115,14 +117,12 @@ class PreferencesHelper(val context: Context) {
fun trackToken(sync: TrackService) = rxPrefs.getString(Keys.trackToken(sync.id), "") fun trackToken(sync: TrackService) = rxPrefs.getString(Keys.trackToken(sync.id), "")
fun anilistScoreType() = rxPrefs.getInteger("anilist_score_type", 0) fun anilistScoreType() = rxPrefs.getString("anilist_score_type", "POINT_10")
fun backupsDirectory() = rxPrefs.getString(Keys.backupDirectory, defaultBackupDir.toString()) fun backupsDirectory() = rxPrefs.getString(Keys.backupDirectory, defaultBackupDir.toString())
fun downloadsDirectory() = rxPrefs.getString(Keys.downloadsDirectory, defaultDownloadsDir.toString()) fun downloadsDirectory() = rxPrefs.getString(Keys.downloadsDirectory, defaultDownloadsDir.toString())
fun downloadThreads() = rxPrefs.getInteger(Keys.downloadThreads, 1)
fun downloadOnlyOverWifi() = prefs.getBoolean(Keys.downloadOnlyOverWifi, true) fun downloadOnlyOverWifi() = prefs.getBoolean(Keys.downloadOnlyOverWifi, true)
fun numberOfBackups() = rxPrefs.getInteger(Keys.numberOfBackups, 1) fun numberOfBackups() = rxPrefs.getInteger(Keys.numberOfBackups, 1)
@ -165,4 +165,7 @@ class PreferencesHelper(val context: Context) {
fun defaultCategory() = prefs.getInt(Keys.defaultCategory, -1) fun defaultCategory() = prefs.getInt(Keys.defaultCategory, -1)
fun migrateFlags() = rxPrefs.getInteger("migrate_flags", Int.MAX_VALUE)
fun trustedSignatures() = rxPrefs.getStringSet("trusted_signatures", emptySet())
} }

View File

@ -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()
}
}

View File

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.track
import android.support.annotation.CallSuper import android.support.annotation.CallSuper
import android.support.annotation.DrawableRes import android.support.annotation.DrawableRes
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -44,7 +45,7 @@ abstract class TrackService(val id: Int) {
abstract fun bind(track: Track): Observable<Track> abstract fun bind(track: Track): Observable<Track>
abstract fun search(query: String): Observable<List<Track>> abstract fun search(query: String): Observable<List<TrackSearch>>
abstract fun refresh(track: Track): Observable<Track> abstract fun refresh(track: Track): Observable<Track>
@ -59,12 +60,11 @@ abstract class TrackService(val id: Int) {
get() = !getUsername().isEmpty() && get() = !getUsername().isEmpty() &&
!getPassword().isEmpty() !getPassword().isEmpty()
fun getUsername() = preferences.trackUsername(this) fun getUsername() = preferences.trackUsername(this)!!
fun getPassword() = preferences.trackPassword(this) fun getPassword() = preferences.trackPassword(this)!!
fun saveCredentials(username: String, password: String) { fun saveCredentials(username: String, password: String) {
preferences.setTrackCredentials(this, username, password) preferences.setTrackCredentials(this, username, password)
} }
} }

View File

@ -2,12 +2,15 @@ package eu.kanade.tachiyomi.data.track.anilist
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import com.google.gson.Gson
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable import rx.Completable
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy
class Anilist(private val context: Context, id: Int) : TrackService(id) { class Anilist(private val context: Context, id: Int) : TrackService(id) {
@ -16,24 +19,45 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
const val COMPLETED = 2 const val COMPLETED = 2
const val ON_HOLD = 3 const val ON_HOLD = 3
const val DROPPED = 4 const val DROPPED = 4
const val PLAN_TO_READ = 5 const val PLANNING = 5
const val REPEATING = 6
const val DEFAULT_STATUS = READING const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0 const val DEFAULT_SCORE = 0
const val POINT_100 = "POINT_100"
const val POINT_10 = "POINT_10"
const val POINT_10_DECIMAL = "POINT_10_DECIMAL"
const val POINT_5 = "POINT_5"
const val POINT_3 = "POINT_3"
} }
override val name = "AniList" override val name = "AniList"
private val interceptor by lazy { AnilistInterceptor(getPassword()) } private val gson: Gson by injectLazy()
private val interceptor by lazy { AnilistInterceptor(this, getPassword()) }
private val api by lazy { AnilistApi(client, interceptor) } private val api by lazy { AnilistApi(client, interceptor) }
private val scorePreference = preferences.anilistScoreType()
init {
// If the preference is an int from APIv1, logout user to force using APIv2
try {
scorePreference.get()
} catch (e: ClassCastException) {
logout()
scorePreference.delete()
}
}
override fun getLogo() = R.drawable.al override fun getLogo() = R.drawable.al
override fun getLogoColor() = Color.rgb(18, 25, 35) override fun getLogoColor() = Color.rgb(18, 25, 35)
override fun getStatusList(): List<Int> { override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING)
} }
override fun getStatus(status: Int): String = with(context) { override fun getStatus(status: Int): String = with(context) {
@ -42,50 +66,61 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
COMPLETED -> getString(R.string.completed) COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold) ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped) DROPPED -> getString(R.string.dropped)
PLAN_TO_READ -> getString(R.string.plan_to_read) PLANNING -> getString(R.string.plan_to_read)
REPEATING -> getString(R.string.repeating)
else -> "" else -> ""
} }
} }
override fun getScoreList(): List<String> { override fun getScoreList(): List<String> {
return when (preferences.anilistScoreType().getOrDefault()) { return when (scorePreference.getOrDefault()) {
// 10 point // 10 point
0 -> IntRange(0, 10).map(Int::toString) POINT_10 -> IntRange(0, 10).map(Int::toString)
// 100 point // 100 point
1 -> IntRange(0, 100).map(Int::toString) POINT_100 -> IntRange(0, 100).map(Int::toString)
// 5 stars // 5 stars
2 -> IntRange(0, 5).map { "$it" } POINT_5 -> IntRange(0, 5).map { "$it" }
// Smiley // Smiley
3 -> listOf("-", "😦", "😐", "😊") POINT_3 -> listOf("-", "😦", "😐", "😊")
// 10 point decimal // 10 point decimal
4 -> IntRange(0, 100).map { (it / 10f).toString() } POINT_10_DECIMAL -> IntRange(0, 100).map { (it / 10f).toString() }
else -> throw Exception("Unknown score type") else -> throw Exception("Unknown score type")
} }
} }
override fun indexToScore(index: Int): Float { override fun indexToScore(index: Int): Float {
return when (preferences.anilistScoreType().getOrDefault()) { return when (scorePreference.getOrDefault()) {
// 10 point // 10 point
0 -> index * 10f POINT_10 -> index * 10f
// 100 point // 100 point
1 -> index.toFloat() POINT_100 -> index.toFloat()
// 5 stars // 5 stars
2 -> index * 20f POINT_5 -> when {
index == 0 -> 0f
else -> index * 20f - 10f
}
// Smiley // Smiley
3 -> index * 30f POINT_3 -> when {
index == 0 -> 0f
else -> index * 25f + 10f
}
// 10 point decimal // 10 point decimal
4 -> index.toFloat() POINT_10_DECIMAL -> index.toFloat()
else -> throw Exception("Unknown score type") else -> throw Exception("Unknown score type")
} }
} }
override fun displayScore(track: Track): String { override fun displayScore(track: Track): String {
val score = track.score val score = track.score
return when (preferences.anilistScoreType().getOrDefault()) {
2 -> "${(score / 20).toInt()}" return when (scorePreference.getOrDefault()) {
3 -> when { POINT_5 -> when {
score == 0f -> "0 ★"
else -> "${((score + 10) / 20).toInt()}"
}
POINT_3 -> when {
score == 0f -> "0" score == 0f -> "0"
score <= 30 -> "😦" score <= 35 -> "😦"
score <= 60 -> "😐" score <= 60 -> "😐"
else -> "😊" else -> "😊"
} }
@ -101,15 +136,26 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED track.status = COMPLETED
} }
// If user was using API v1 fetch library_id
if (track.library_id == null || track.library_id!! == 0L){
return api.findLibManga(track, getUsername().toInt()).flatMap {
if (it == null) {
throw Exception("$track not found on user library")
}
track.library_id = it.library_id
api.updateLibManga(track)
}
}
return api.updateLibManga(track) return api.updateLibManga(track)
} }
override fun bind(track: Track): Observable<Track> { override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername()) return api.findLibManga(track, getUsername().toInt())
.flatMap { remoteTrack -> .flatMap { remoteTrack ->
if (remoteTrack != null) { if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id
update(track) update(track)
} else { } else {
// Set default fields if it's not found in the list // Set default fields if it's not found in the list
@ -120,12 +166,12 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
} }
} }
override fun search(query: String): Observable<List<Track>> { override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query) return api.search(query)
} }
override fun refresh(track: Track): Observable<Track> { override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track, getUsername()) return api.getLibManga(track, getUsername().toInt())
.map { remoteTrack -> .map { remoteTrack ->
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters track.total_chapters = remoteTrack.total_chapters
@ -135,26 +181,34 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
override fun login(username: String, password: String) = login(password) override fun login(username: String, password: String) = login(password)
fun login(authCode: String): Completable { fun login(token: String): Completable {
return api.login(authCode) val oauth = api.createOAuth(token)
// Save the token in the interceptor. interceptor.setAuth(oauth)
.doOnNext { interceptor.setAuth(it) } return api.getCurrentUser().map { (username, scoreType) ->
// Obtain the authenticated user from the API. scorePreference.set(scoreType)
.zipWith(api.getCurrentUser().map { pair -> saveCredentials(username.toString(), oauth.access_token)
preferences.anilistScoreType().set(pair.second) }.doOnError{
pair.first logout()
}, { oauth, user -> Pair(user, oauth.refresh_token!!) }) }.toCompletable()
// Save service credentials (username and refresh token).
.doOnNext { saveCredentials(it.first, it.second) }
// Logout on any error.
.doOnError { logout() }
.toCompletable()
} }
override fun logout() { override fun logout() {
super.logout() super.logout()
preferences.trackToken(this).set(null)
interceptor.setAuth(null) interceptor.setAuth(null)
} }
fun saveOAuth(oAuth: OAuth?) {
preferences.trackToken(this).set(gson.toJson(oAuth))
}
fun loadOAuth(): OAuth? {
return try {
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
} catch (e: Exception) {
null
}
}
} }

View File

@ -1,161 +1,286 @@
package eu.kanade.tachiyomi.data.track.anilist package eu.kanade.tachiyomi.data.track.anilist
import android.net.Uri import android.net.Uri
import com.github.salomonbrys.kotson.int import com.github.salomonbrys.kotson.*
import com.github.salomonbrys.kotson.string
import com.google.gson.JsonObject import com.google.gson.JsonObject
import com.google.gson.JsonParser
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.data.track.model.TrackSearch
import okhttp3.FormBody import eu.kanade.tachiyomi.network.asObservableSuccess
import okhttp3.MediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.ResponseBody import okhttp3.Request
import retrofit2.Response import okhttp3.RequestBody
import retrofit2.Retrofit
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.*
import rx.Observable import rx.Observable
import java.util.Calendar
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
private val rest = restBuilder() private val parser = JsonParser()
.client(client.newBuilder().addInterceptor(interceptor).build()) private val jsonMime = MediaType.parse("application/json; charset=utf-8")
.build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
.create(Rest::class.java)
fun addLibManga(track: Track): Observable<Track> { fun addLibManga(track: Track): Observable<Track> {
return rest.addLibManga(track.remote_id, track.last_chapter_read, track.toAnilistStatus()) val query = """
.map { response -> mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) {
response.body()?.close() SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status)
if (!response.isSuccessful) { { id status } }
throw Exception("Could not add manga") """
val variables = jsonObject(
"mangaId" to track.media_id,
"progress" to track.last_chapter_read,
"status" to track.toAnilistStatus()
)
val payload = jsonObject(
"query" to query,
"variables" to variables
)
val body = RequestBody.create(jsonMime, payload.toString())
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body()?.string().orEmpty()
netResponse.close()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
} }
val response = parser.parse(responseBody).obj
track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong
track track
} }
} }
fun updateLibManga(track: Track): Observable<Track> { fun updateLibManga(track: Track): Observable<Track> {
return rest.updateLibManga(track.remote_id, track.last_chapter_read, track.toAnilistStatus(), val query = """
track.toAnilistScore()) mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) {
.map { response -> SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) {
response.body()?.close() id
if (!response.isSuccessful) { status
throw Exception("Could not update manga") progress
}
} }
"""
val variables = jsonObject(
"listId" to track.library_id,
"progress" to track.last_chapter_read,
"status" to track.toAnilistStatus(),
"score" to track.score.toInt()
)
val payload = jsonObject(
"query" to query,
"variables" to variables
)
val body = RequestBody.create(jsonMime, payload.toString())
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map {
track track
} }
} }
fun search(query: String): Observable<List<Track>> { fun search(search: String): Observable<List<TrackSearch>> {
return rest.search(query, 1) val query = """
.map { list -> query Search(${'$'}query: String) {
list.filter { it.type != "Novel" }.map { it.toTrack() } Page (perPage: 50) {
media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
id
title {
romaji
}
coverImage {
large
}
type
status
chapters
description
startDate {
year
month
day
}
}
}
} }
.onErrorReturn { emptyList() } """
} val variables = jsonObject(
"query" to search
fun getList(username: String): Observable<List<Track>> { )
return rest.getLib(username) val payload = jsonObject(
.map { lib -> "query" to query,
lib.flatten().map { it.toTrack() } "variables" to variables
)
val body = RequestBody.create(jsonMime, payload.toString())
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body()?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = parser.parse(responseBody).obj
val data = response["data"]!!.obj
val page = data["Page"].obj
val media = page["media"].array
val entries = media.map { jsonToALManga(it.obj) }
entries.map { it.toTrack() }
} }
} }
fun findLibManga(track: Track, username: String) : Observable<Track?> {
// TODO avoid getting the entire list fun findLibManga(track: Track, userid: Int) : Observable<Track?> {
return getList(username) val query = """
.map { list -> list.find { it.remote_id == track.remote_id } } query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
Page {
mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) {
id
status
scoreRaw: score(format: POINT_100)
progress
media{
id
title {
romaji
}
coverImage {
large
}
type
status
chapters
description
startDate {
year
month
day
}
}
}
}
}
"""
val variables = jsonObject(
"id" to userid,
"manga_id" to track.media_id
)
val payload = jsonObject(
"query" to query,
"variables" to variables
)
val body = RequestBody.create(jsonMime, payload.toString())
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body()?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = parser.parse(responseBody).obj
val data = response["data"]!!.obj
val page = data["Page"].obj
val media = page["mediaList"].array
val entries = media.map { jsonToALUserManga(it.obj) }
entries.firstOrNull()?.toTrack()
}
} }
fun getLibManga(track: Track, username: String): Observable<Track> { fun getLibManga(track: Track, userid: Int): Observable<Track> {
return findLibManga(track, username) return findLibManga(track, userid)
.map { it ?: throw Exception("Could not find manga") } .map { it ?: throw Exception("Could not find manga") }
} }
fun login(authCode: String): Observable<OAuth> { fun createOAuth(token: String): OAuth {
return restBuilder() return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
.client(client) }
fun getCurrentUser(): Observable<Pair<Int, String>> {
val query = """
query User
{
Viewer {
id
mediaListOptions {
scoreFormat
}
}
}
"""
val payload = jsonObject(
"query" to query
)
val body = RequestBody.create(jsonMime, payload.toString())
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build() .build()
.create(Rest::class.java) return authClient.newCall(request)
.requestAccessToken(authCode) .asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body()?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = parser.parse(responseBody).obj
val data = response["data"]!!.obj
val viewer = data["Viewer"].obj
Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString)
}
} }
fun getCurrentUser(): Observable<Pair<String, Int>> { fun jsonToALManga(struct: JsonObject): ALManga{
return rest.getCurrentUser() val date = try {
.map { it["id"].string to it["score_type"].int } val date = Calendar.getInstance()
date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1,
struct["startDate"]["day"].nullInt ?: 0)
date.timeInMillis
} catch (_: Exception) {
0L
}
return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString,
struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString,
date, struct["chapters"].nullInt ?: 0)
} }
private fun restBuilder() = Retrofit.Builder() fun jsonToALUserManga(struct: JsonObject): ALUserManga{
.baseUrl(baseUrl) return ALUserManga(struct["id"].asLong, struct["status"].asString, struct["scoreRaw"].asInt, struct["progress"].asInt, jsonToALManga(struct["media"].obj) )
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
private interface Rest {
@FormUrlEncoded
@POST("auth/access_token")
fun requestAccessToken(
@Field("code") code: String,
@Field("grant_type") grant_type: String = "authorization_code",
@Field("client_id") client_id: String = clientId,
@Field("client_secret") client_secret: String = clientSecret,
@Field("redirect_uri") redirect_uri: String = clientUrl
) : Observable<OAuth>
@GET("user")
fun getCurrentUser(): Observable<JsonObject>
@GET("manga/search/{query}")
fun search(
@Path("query") query: String,
@Query("page") page: Int
): Observable<List<ALManga>>
@GET("user/{username}/mangalist")
fun getLib(
@Path("username") username: String
): Observable<ALUserLists>
@FormUrlEncoded
@PUT("mangalist")
fun addLibManga(
@Field("id") id: Int,
@Field("chapters_read") chapters_read: Int,
@Field("list_status") list_status: String
) : Observable<Response<ResponseBody>>
@FormUrlEncoded
@PUT("mangalist")
fun updateLibManga(
@Field("id") id: Int,
@Field("chapters_read") chapters_read: Int,
@Field("list_status") list_status: String,
@Field("score") score_raw: String
) : Observable<Response<ResponseBody>>
} }
companion object { companion object {
private const val clientId = "tachiyomi-hrtje" private const val clientId = "385"
private const val clientSecret = "nlGB5OmgE9YWq5dr3gIDbTQV0C"
private const val clientUrl = "tachiyomi://anilist-auth" private const val clientUrl = "tachiyomi://anilist-auth"
private const val baseUrl = "https://anilist.co/api/" private const val apiUrl = "https://graphql.anilist.co/"
private const val baseUrl = "https://anilist.co/api/v2/"
private const val baseMangaUrl = "https://anilist.co/manga/"
fun authUrl() = Uri.parse("${baseUrl}auth/authorize").buildUpon() fun mangaUrl(mediaId: Int): String {
.appendQueryParameter("grant_type", "authorization_code") return baseMangaUrl + mediaId
}
fun authUrl() = Uri.parse("${baseUrl}oauth/authorize").buildUpon()
.appendQueryParameter("client_id", clientId) .appendQueryParameter("client_id", clientId)
.appendQueryParameter("redirect_uri", clientUrl) .appendQueryParameter("response_type", "token")
.appendQueryParameter("response_type", "code")
.build() .build()
fun refreshTokenRequest(token: String) = POST("${baseUrl}auth/access_token",
body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("refresh_token", token)
.build())
} }
} }

View File

@ -1,10 +1,10 @@
package eu.kanade.tachiyomi.data.track.anilist package eu.kanade.tachiyomi.data.track.anilist
import com.google.gson.Gson
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
class AnilistInterceptor(private var refreshToken: String?) : Interceptor {
class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Interceptor {
/** /**
* OAuth object used for authenticated requests. * OAuth object used for authenticated requests.
@ -20,24 +20,21 @@ class AnilistInterceptor(private var refreshToken: String?) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request() val originalRequest = chain.request()
if (refreshToken.isNullOrEmpty()) { if (token.isNullOrEmpty()) {
throw Exception("Not authenticated with Anilist") throw Exception("Not authenticated with Anilist")
} }
if (oauth == null){
oauth = anilist.loadOAuth()
}
// Refresh access token if null or expired. // Refresh access token if null or expired.
if (oauth == null || oauth!!.isExpired()) { if (oauth!!.isExpired()) {
val response = chain.proceed(AnilistApi.refreshTokenRequest(refreshToken!!)) anilist.logout()
oauth = if (response.isSuccessful) { throw Exception("Token expired")
Gson().fromJson(response.body()!!.string(), OAuth::class.java)
} else {
response.close()
null
}
} }
// Throw on null auth. // Throw on null auth.
if (oauth == null) { if (oauth == null) {
throw Exception("Access token wasn't refreshed") throw Exception("No authentication token")
} }
// Add the authorization header to the original request. // Add the authorization header to the original request.
@ -53,8 +50,9 @@ class AnilistInterceptor(private var refreshToken: String?) : Interceptor {
* and the oauth object. * and the oauth object.
*/ */
fun setAuth(oauth: OAuth?) { fun setAuth(oauth: OAuth?) {
refreshToken = oauth?.refresh_token token = oauth?.access_token
this.oauth = oauth this.oauth = oauth
anilist.saveOAuth(oauth)
} }
} }

View File

@ -4,68 +4,87 @@ import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.*
data class ALManga( data class ALManga(
val id: Int, val media_id: Int,
val title_romaji: String, val title_romaji: String,
val image_url_lge: String,
val description: String?,
val type: String, val type: String,
val publishing_status: String,
val start_date_fuzzy: Long,
val total_chapters: Int) { val total_chapters: Int) {
fun toTrack() = Track.create(TrackManager.ANILIST).apply { fun toTrack() = TrackSearch.create(TrackManager.ANILIST).apply {
remote_id = this@ALManga.id media_id = this@ALManga.media_id
title = title_romaji title = title_romaji
total_chapters = this@ALManga.total_chapters total_chapters = this@ALManga.total_chapters
cover_url = image_url_lge
summary = description ?: ""
tracking_url = AnilistApi.mangaUrl(media_id)
publishing_status = this@ALManga.publishing_status
publishing_type = type
if (start_date_fuzzy != 0L) {
start_date = try {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
outputDf.format(start_date_fuzzy)
} catch (e: Exception) {
""
}
}
} }
} }
data class ALUserManga( data class ALUserManga(
val id: Int, val library_id: Long,
val list_status: String, val list_status: String,
val score_raw: Int, val score_raw: Int,
val chapters_read: Int, val chapters_read: Int,
val manga: ALManga) { val manga: ALManga) {
fun toTrack() = Track.create(TrackManager.ANILIST).apply { fun toTrack() = Track.create(TrackManager.ANILIST).apply {
remote_id = manga.id media_id = manga.media_id
status = toTrackStatus() status = toTrackStatus()
score = score_raw.toFloat() score = score_raw.toFloat()
last_chapter_read = chapters_read last_chapter_read = chapters_read
library_id = this@ALUserManga.library_id
total_chapters = manga.total_chapters
} }
fun toTrackStatus() = when (list_status) { fun toTrackStatus() = when (list_status) {
"reading" -> Anilist.READING "CURRENT" -> Anilist.READING
"completed" -> Anilist.COMPLETED "COMPLETED" -> Anilist.COMPLETED
"on-hold" -> Anilist.ON_HOLD "PAUSED" -> Anilist.ON_HOLD
"dropped" -> Anilist.DROPPED "DROPPED" -> Anilist.DROPPED
"plan to read" -> Anilist.PLAN_TO_READ "PLANNING" -> Anilist.PLANNING
"REPEATING" -> Anilist.REPEATING
else -> throw NotImplementedError("Unknown status") else -> throw NotImplementedError("Unknown status")
} }
} }
data class ALUserLists(val lists: Map<String, List<ALUserManga>>) {
fun flatten() = lists.values.flatten()
}
fun Track.toAnilistStatus() = when (status) { fun Track.toAnilistStatus() = when (status) {
Anilist.READING -> "reading" Anilist.READING -> "CURRENT"
Anilist.COMPLETED -> "completed" Anilist.COMPLETED -> "COMPLETED"
Anilist.ON_HOLD -> "on-hold" Anilist.ON_HOLD -> "PAUSED"
Anilist.DROPPED -> "dropped" Anilist.DROPPED -> "DROPPED"
Anilist.PLAN_TO_READ -> "plan to read" Anilist.PLANNING -> "PLANNING"
Anilist.REPEATING -> "REPEATING"
else -> throw NotImplementedError("Unknown status") else -> throw NotImplementedError("Unknown status")
} }
private val preferences: PreferencesHelper by injectLazy() private val preferences: PreferencesHelper by injectLazy()
fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().getOrDefault()) { fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().getOrDefault()) {
// 10 point // 10 point
0 -> (score.toInt() / 10).toString() "POINT_10" -> (score.toInt() / 10).toString()
// 100 point // 100 point
1 -> score.toInt().toString() "POINT_100" -> score.toInt().toString()
// 5 stars // 5 stars
2 -> when { "POINT_5" -> when {
score == 0f -> "0" score == 0f -> "0"
score < 30 -> "1" score < 30 -> "1"
score < 50 -> "2" score < 50 -> "2"
@ -73,14 +92,14 @@ fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().getOrD
score < 90 -> "4" score < 90 -> "4"
else -> "5" else -> "5"
} }
// Smiley // Smiley
3 -> when { "POINT_3" -> when {
score == 0f -> "0" score == 0f -> "0"
score <= 30 -> ":(" score <= 35 -> ":("
score <= 60 -> ":|" score <= 60 -> ":|"
else -> ":)" else -> ":)"
} }
// 10 point decimal // 10 point decimal
4 -> (score / 10).toString() "POINT_10_DECIMAL" -> (score / 10).toString()
else -> throw Exception("Unknown score type") else -> throw Exception("Unknown score type")
} }

View File

@ -4,8 +4,7 @@ data class OAuth(
val access_token: String, val access_token: String,
val token_type: String, val token_type: String,
val expires: Long, val expires: Long,
val expires_in: Long, val expires_in: Long) {
val refresh_token: String?) {
fun isExpired() = System.currentTimeMillis() > expires fun isExpired() = System.currentTimeMillis() > expires
} }

View File

@ -6,6 +6,7 @@ import com.google.gson.Gson
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable import rx.Completable
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@ -86,7 +87,7 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
.flatMap { remoteTrack -> .flatMap { remoteTrack ->
if (remoteTrack != null) { if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.remote_id = remoteTrack.remote_id track.media_id = remoteTrack.media_id
update(track) update(track)
} else { } else {
track.score = DEFAULT_SCORE track.score = DEFAULT_SCORE
@ -96,7 +97,7 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
} }
} }
override fun search(query: String): Observable<List<Track>> { override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query) return api.search(query)
} }
@ -140,4 +141,4 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
} }
} }
} }

View File

@ -4,6 +4,7 @@ import com.github.salomonbrys.kotson.*
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.JsonObject import com.google.gson.JsonObject
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -15,43 +16,60 @@ import rx.Observable
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) { class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
private val rest = Retrofit.Builder() private val rest = Retrofit.Builder()
.baseUrl(baseUrl) .baseUrl(baseUrl)
.client(client.newBuilder().addInterceptor(interceptor).build()) .client(authClient)
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create())) .addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create()))
.addCallAdapterFactory(RxJavaCallAdapterFactory.create()) .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build() .build()
.create(KitsuApi.Rest::class.java) .create(KitsuApi.Rest::class.java)
private val searchRest = Retrofit.Builder()
.baseUrl(algoliaKeyUrl)
.client(authClient)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build()
.create(KitsuApi.SearchKeyRest::class.java)
private val algoliaRest = Retrofit.Builder()
.baseUrl(algoliaUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build()
.create(KitsuApi.AgoliaSearchRest::class.java)
fun addLibManga(track: Track, userId: String): Observable<Track> { fun addLibManga(track: Track, userId: String): Observable<Track> {
return Observable.defer { return Observable.defer {
// @formatter:off // @formatter:off
val data = jsonObject( val data = jsonObject(
"type" to "libraryEntries", "type" to "libraryEntries",
"attributes" to jsonObject( "attributes" to jsonObject(
"status" to track.toKitsuStatus(), "status" to track.toKitsuStatus(),
"progress" to track.last_chapter_read "progress" to track.last_chapter_read
),
"relationships" to jsonObject(
"user" to jsonObject(
"data" to jsonObject(
"id" to userId,
"type" to "users"
)
), ),
"media" to jsonObject( "relationships" to jsonObject(
"data" to jsonObject( "user" to jsonObject(
"id" to track.remote_id, "data" to jsonObject(
"type" to "manga" "id" to userId,
) "type" to "users"
)
),
"media" to jsonObject(
"data" to jsonObject(
"id" to track.media_id,
"type" to "manga"
)
)
) )
)
) )
// @formatter:on
rest.addLibManga(jsonObject("data" to data)) rest.addLibManga(jsonObject("data" to data))
.map { json -> .map { json ->
track.remote_id = json["data"]["id"].int track.media_id = json["data"]["id"].int
track track
} }
} }
@ -61,33 +79,46 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
return Observable.defer { return Observable.defer {
// @formatter:off // @formatter:off
val data = jsonObject( val data = jsonObject(
"type" to "libraryEntries", "type" to "libraryEntries",
"id" to track.remote_id, "id" to track.media_id,
"attributes" to jsonObject( "attributes" to jsonObject(
"status" to track.toKitsuStatus(), "status" to track.toKitsuStatus(),
"progress" to track.last_chapter_read, "progress" to track.last_chapter_read,
"ratingTwenty" to track.toKitsuScore() "ratingTwenty" to track.toKitsuScore()
) )
) )
// @formatter:on // @formatter:on
rest.updateLibManga(track.remote_id, jsonObject("data" to data)) rest.updateLibManga(track.media_id, jsonObject("data" to data))
.map { track } .map { track }
} }
} }
fun search(query: String): Observable<List<Track>> {
return rest.search(query) fun search(query: String): Observable<List<TrackSearch>> {
return searchRest
.getKey().map { json ->
json["media"].asJsonObject["key"].string
}.flatMap { key ->
algoliaSearch(key, query)
}
}
private fun algoliaSearch(key: String, query: String): Observable<List<TrackSearch>> {
val jsonObject = jsonObject("params" to "query=$query$algoliaFilter")
return algoliaRest
.getSearchQuery(algoliaAppId, key, jsonObject)
.map { json -> .map { json ->
val data = json["data"].array val data = json["hits"].array
data.map { KitsuManga(it.obj) } data.map { KitsuSearchManga(it.obj) }
.filter { it.type != "novel" } .filter { it.subType != "novel" }
.map { it.toTrack() } .map { it.toTrack() }
} }
} }
fun findLibManga(track: Track, userId: String): Observable<Track?> { fun findLibManga(track: Track, userId: String): Observable<Track?> {
return rest.findLibManga(track.remote_id, userId) return rest.findLibManga(track.media_id, userId)
.map { json -> .map { json ->
val data = json["data"].array val data = json["data"].array
if (data.size() > 0) { if (data.size() > 0) {
@ -100,7 +131,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
} }
fun getLibManga(track: Track): Observable<Track> { fun getLibManga(track: Track): Observable<Track> {
return rest.getLibManga(track.remote_id) return rest.getLibManga(track.media_id)
.map { json -> .map { json ->
val data = json["data"].array val data = json["data"].array
if (data.size() > 0) { if (data.size() > 0) {
@ -142,10 +173,6 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
@Body data: JsonObject @Body data: JsonObject
): Observable<JsonObject> ): Observable<JsonObject>
@GET("manga")
fun search(
@Query("filter[text]", encoded = true) query: String
): Observable<JsonObject>
@GET("library-entries") @GET("library-entries")
fun findLibManga( fun findLibManga(
@ -167,6 +194,16 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
} }
private interface SearchKeyRest {
@GET("media/")
fun getKey(): Observable<JsonObject>
}
private interface AgoliaSearchRest {
@POST("query/")
fun getSearchQuery(@Header("X-Algolia-Application-Id") appid: String, @Header("X-Algolia-API-Key") key: String, @Body json: JsonObject): Observable<JsonObject>
}
private interface LoginRest { private interface LoginRest {
@FormUrlEncoded @FormUrlEncoded
@ -186,6 +223,16 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
private const val clientSecret = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151" private const val clientSecret = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
private const val baseUrl = "https://kitsu.io/api/edge/" private const val baseUrl = "https://kitsu.io/api/edge/"
private const val loginUrl = "https://kitsu.io/api/" private const val loginUrl = "https://kitsu.io/api/"
private const val baseMangaUrl = "https://kitsu.io/manga/"
private const val algoliaKeyUrl = "https://kitsu.io/api/edge/algolia-keys/"
private const val algoliaUrl = "https://AWQO5J657S-dsn.algolia.net/1/indexes/production_media/"
private const val algoliaAppId = "AWQO5J657S"
private const val algoliaFilter = "&facetFilters=%5B%22kind%3Amanga%22%5D&attributesToRetrieve=%5B%22synopsis%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D"
fun mangaUrl(remoteId: Int): String {
return baseMangaUrl + remoteId
}
fun refreshTokenRequest(token: String) = POST("${loginUrl}oauth/token", fun refreshTokenRequest(token: String) = POST("${loginUrl}oauth/token",
@ -198,4 +245,4 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
} }
} }

View File

@ -5,29 +5,66 @@ import com.github.salomonbrys.kotson.*
import com.google.gson.JsonObject import com.google.gson.JsonObject
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import java.text.SimpleDateFormat
import java.util.*
open class KitsuManga(obj: JsonObject) { class KitsuSearchManga(obj: JsonObject) {
val id by obj.byInt val id by obj.byInt
val canonicalTitle by obj["attributes"].byString private val canonicalTitle by obj.byString
val chapterCount = obj["attributes"].obj.get("chapterCount").nullInt private val chapterCount = obj.get("chapterCount").nullInt
val type = obj["attributes"].obj.get("mangaType").nullString val subType = obj.get("subtype").nullString
val original = obj.get("posterImage").nullObj?.get("original")?.asString
private val synopsis by obj.byString
private var startDate = obj.get("startDate").nullString?.let {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
outputDf.format(Date(it!!.toLong() * 1000))
}
private val endDate = obj.get("endDate").nullString
@CallSuper @CallSuper
open fun toTrack() = Track.create(TrackManager.KITSU).apply { open fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
remote_id = this@KitsuManga.id media_id = this@KitsuSearchManga.id
title = canonicalTitle title = canonicalTitle
total_chapters = chapterCount ?: 0 total_chapters = chapterCount ?: 0
cover_url = original ?: ""
summary = synopsis
tracking_url = KitsuApi.mangaUrl(media_id)
if (endDate == null) {
publishing_status = "Publishing"
} else {
publishing_status = "Finished"
}
publishing_type = subType ?: ""
start_date = startDate ?: ""
} }
} }
class KitsuLibManga(obj: JsonObject, manga: JsonObject) : KitsuManga(manga) {
val remoteId by obj.byInt("id") class KitsuLibManga(obj: JsonObject, manga: JsonObject) {
val id by manga.byInt
private val canonicalTitle by manga["attributes"].byString
private val chapterCount = manga["attributes"].obj.get("chapterCount").nullInt
val type = manga["attributes"].obj.get("mangaType").nullString.orEmpty()
val original by manga["attributes"].obj["posterImage"].byString
private val synopsis by manga["attributes"].byString
private val startDate = manga["attributes"].obj.get("startDate").nullString.orEmpty()
private val libraryId by obj.byInt("id")
val status by obj["attributes"].byString val status by obj["attributes"].byString
val ratingTwenty = obj["attributes"].obj.get("ratingTwenty").nullString private val ratingTwenty = obj["attributes"].obj.get("ratingTwenty").nullString
val progress by obj["attributes"].byInt val progress by obj["attributes"].byInt
override fun toTrack() = super.toTrack().apply { open fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
remote_id = remoteId media_id = libraryId
title = canonicalTitle
total_chapters = chapterCount ?: 0
cover_url = original
summary = synopsis
tracking_url = KitsuApi.mangaUrl(media_id)
publishing_status = this@KitsuLibManga.status
publishing_type = type
start_date = startDate
status = toTrackStatus() status = toTrackStatus()
score = ratingTwenty?.let { it.toInt() / 2f } ?: 0f score = ratingTwenty?.let { it.toInt() / 2f } ?: 0f
last_chapter_read = progress last_chapter_read = progress

View File

@ -0,0 +1,64 @@
package eu.kanade.tachiyomi.data.track.model
import eu.kanade.tachiyomi.data.database.models.Track
class TrackSearch : Track {
override var id: Long? = null
override var manga_id: Long = 0
override var sync_id: Int = 0
override var media_id: Int = 0
override var library_id: Long? = null
override lateinit var title: String
override var last_chapter_read: Int = 0
override var total_chapters: Int = 0
override var score: Float = 0f
override var status: Int = 0
override lateinit var tracking_url: String
var cover_url: String = ""
var summary: String = ""
var publishing_status: String = ""
var publishing_type: String = ""
var start_date: String = ""
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
other as Track
if (manga_id != other.manga_id) return false
if (sync_id != other.sync_id) return false
return media_id == other.media_id
}
override fun hashCode(): Int {
var result = (manga_id xor manga_id.ushr(32)).toInt()
result = 31 * result + sync_id
result = 31 * result + media_id
return result
}
companion object {
fun create(serviceId: Int): TrackSearch = TrackSearch().apply {
sync_id = serviceId
}
}
}

View File

@ -4,9 +4,12 @@ import android.content.Context
import android.graphics.Color import android.graphics.Color
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable import rx.Completable
import rx.Observable import rx.Observable
import java.net.URI
class Myanimelist(private val context: Context, id: Int) : TrackService(id) { class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
@ -20,9 +23,13 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
const val DEFAULT_STATUS = READING const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0 const val DEFAULT_SCORE = 0
const val BASE_URL = "https://myanimelist.net"
const val USER_SESSION_COOKIE = "MALSESSIONID"
const val LOGGED_IN_COOKIE = "is_logged_in"
} }
private val api by lazy { MyanimelistApi(client, getUsername(), getPassword()) } private val api by lazy { MyanimelistApi(client) }
override val name: String override val name: String
get() = "MyAnimeList" get() = "MyAnimeList"
@ -55,7 +62,7 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
} }
override fun add(track: Track): Observable<Track> { override fun add(track: Track): Observable<Track> {
return api.addLibManga(track) return api.addLibManga(track, getCSRF())
} }
override fun update(track: Track): Observable<Track> { override fun update(track: Track): Observable<Track> {
@ -63,11 +70,11 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
track.status = COMPLETED track.status = COMPLETED
} }
return api.updateLibManga(track) return api.updateLibManga(track, getCSRF())
} }
override fun bind(track: Track): Observable<Track> { override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername()) return api.findLibManga(track, getCSRF())
.flatMap { remoteTrack -> .flatMap { remoteTrack ->
if (remoteTrack != null) { if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
@ -81,12 +88,12 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
} }
} }
override fun search(query: String): Observable<List<Track>> { override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query, getUsername()) return api.search(query)
} }
override fun refresh(track: Track): Observable<Track> { override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track, getUsername()) return api.getLibManga(track, getCSRF())
.map { remoteTrack -> .map { remoteTrack ->
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters track.total_chapters = remoteTrack.total_chapters
@ -95,10 +102,40 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
} }
override fun login(username: String, password: String): Completable { override fun login(username: String, password: String): Completable {
logout()
return api.login(username, password) return api.login(username, password)
.doOnNext { csrf -> saveCSRF(csrf) }
.doOnNext { saveCredentials(username, password) } .doOnNext { saveCredentials(username, password) }
.doOnError { logout() } .doOnError { logout() }
.toCompletable() .toCompletable()
} }
override fun logout() {
super.logout()
preferences.trackToken(this).delete()
networkService.cookies.remove(URI(BASE_URL))
}
override val isLogged: Boolean
get() = !getUsername().isEmpty() &&
!getPassword().isEmpty() &&
checkCookies(URI(BASE_URL)) &&
!getCSRF().isEmpty()
private fun getCSRF(): String = preferences.trackToken(this).getOrDefault()
private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf)
private fun checkCookies(uri: URI): Boolean {
var ckCount = 0
for (ck in networkService.cookies.get(uri)) {
if (ck.name() == USER_SESSION_COOKIE || ck.name() == LOGGED_IN_COOKIE)
ckCount++
}
return ckCount == 2
}
} }

View File

@ -1,9 +1,9 @@
package eu.kanade.tachiyomi.data.track.myanimelist package eu.kanade.tachiyomi.data.track.myanimelist
import android.net.Uri import android.net.Uri
import android.util.Xml
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservable import eu.kanade.tachiyomi.network.asObservable
@ -11,177 +11,266 @@ import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.util.selectInt import eu.kanade.tachiyomi.util.selectInt
import eu.kanade.tachiyomi.util.selectText import eu.kanade.tachiyomi.util.selectText
import okhttp3.* import okhttp3.*
import org.json.JSONObject
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.xmlpull.v1.XmlSerializer import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.parser.Parser
import rx.Observable import rx.Observable
import java.io.StringWriter import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.zip.GZIPInputStream
class MyanimelistApi(private val client: OkHttpClient, username: String, password: String) {
private var headers = createHeaders(username, password) class MyanimelistApi(private val client: OkHttpClient) {
fun addLibManga(track: Track): Observable<Track> { fun addLibManga(track: Track, csrf: String): Observable<Track> {
return Observable.defer { return Observable.defer {
client.newCall(POST(getAddUrl(track), headers, getMangaPostPayload(track))) client.newCall(POST(url = getAddUrl(), body = getMangaPostPayload(track, csrf)))
.asObservableSuccess() .asObservableSuccess()
.map { track } .map { track }
} }
} }
fun updateLibManga(track: Track): Observable<Track> { fun updateLibManga(track: Track, csrf: String): Observable<Track> {
return Observable.defer { return Observable.defer {
client.newCall(POST(getUpdateUrl(track), headers, getMangaPostPayload(track))) client.newCall(POST(url = getUpdateUrl(), body = getMangaPostPayload(track, csrf)))
.asObservableSuccess() .asObservableSuccess()
.map { track } .map { track }
} }
} }
fun search(query: String, username: String): Observable<List<Track>> { fun search(query: String): Observable<List<TrackSearch>> {
return if (query.startsWith(PREFIX_MY)) { return client.newCall(GET(getSearchUrl(query)))
val realQuery = query.substring(PREFIX_MY.length).toLowerCase().trim()
getList(username)
.flatMap { Observable.from(it) }
.filter { realQuery in it.title.toLowerCase() }
.toList()
} else {
client.newCall(GET(getSearchUrl(query), headers))
.asObservable()
.map { Jsoup.parse(it.body()!!.string()) }
.flatMap { Observable.from(it.select("entry")) }
.filter { it.select("type").text() != "Novel" }
.map {
Track.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("title")!!
remote_id = it.selectInt("id")
total_chapters = it.selectInt("chapters")
}
}
.toList()
}
}
fun getList(username: String): Observable<List<Track>> {
return client
.newCall(GET(getListUrl(username), headers))
.asObservable() .asObservable()
.map { Jsoup.parse(it.body()!!.string()) } .flatMap { response ->
.flatMap { Observable.from(it.select("manga")) } Observable.from(Jsoup.parse(response.consumeBody())
.map { .select("div.js-categories-seasonal.js-block-list.list")
Track.create(TrackManager.MYANIMELIST).apply { .select("table").select("tbody")
title = it.selectText("series_title")!! .select("tr").drop(1))
remote_id = it.selectInt("series_mangadb_id") }
last_chapter_read = it.selectInt("my_read_chapters") .filter { row ->
status = it.selectInt("my_status") row.select(TD)[2].text() != "Novel"
score = it.selectInt("my_score").toFloat() }
total_chapters = it.selectInt("series_chapters") .map { row ->
TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = row.searchTitle()
media_id = row.searchMediaId()
total_chapters = row.searchTotalChapters()
summary = row.searchSummary()
cover_url = row.searchCoverUrl()
tracking_url = mangaUrl(media_id)
publishing_status = row.searchPublishingStatus()
publishing_type = row.searchPublishingType()
start_date = row.searchStartDate()
} }
} }
.toList() .toList()
} }
fun findLibManga(track: Track, username: String): Observable<Track?> { private fun getList(csrf: String): Observable<List<TrackSearch>> {
return getList(username) return getListUrl(csrf)
.map { list -> list.find { it.remote_id == track.remote_id } } .flatMap { url ->
getListXml(url)
}
.flatMap { doc ->
Observable.from(doc.select("manga"))
}
.map { it ->
TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("manga_title")!!
media_id = it.selectInt("manga_mangadb_id")
last_chapter_read = it.selectInt("my_read_chapters")
status = getStatus(it.selectText("my_status")!!)
score = it.selectInt("my_score").toFloat()
total_chapters = it.selectInt("manga_chapters")
tracking_url = mangaUrl(media_id)
}
}
.toList()
} }
fun getLibManga(track: Track, username: String): Observable<Track> { private fun getListXml(url: String): Observable<Document> {
return findLibManga(track, username) return client.newCall(GET(url))
.asObservable()
.map { response ->
Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
}
}
fun findLibManga(track: Track, csrf: String): Observable<Track?> {
return getList(csrf)
.map { list -> list.find { it.media_id == track.media_id } }
}
fun getLibManga(track: Track, csrf: String): Observable<Track> {
return findLibManga(track, csrf)
.map { it ?: throw Exception("Could not find manga") } .map { it ?: throw Exception("Could not find manga") }
} }
fun login(username: String, password: String): Observable<Response> { fun login(username: String, password: String): Observable<String> {
headers = createHeaders(username, password) return getSessionInfo()
return client.newCall(GET(getLoginUrl(), headers)) .flatMap { csrf ->
.asObservable() login(username, password, csrf)
.doOnNext { response ->
response.close()
if (response.code() != 200) throw Exception("Login error")
} }
} }
private fun getMangaPostPayload(track: Track): RequestBody { private fun getSessionInfo(): Observable<String> {
val data = xml { return client.newCall(GET(getLoginUrl()))
element(ENTRY_TAG) { .asObservable()
if (track.last_chapter_read != 0) { .map { response ->
text(CHAPTER_TAG, track.last_chapter_read.toString()) Jsoup.parse(response.consumeBody())
.select("meta[name=csrf_token]")
.attr("content")
} }
text(STATUS_TAG, track.status.toString()) }
text(SCORE_TAG, track.score.toString())
private fun login(username: String, password: String, csrf: String): Observable<String> {
return client.newCall(POST(url = getLoginUrl(), body = getLoginPostBody(username, password, csrf)))
.asObservable()
.map { response ->
response.use {
if (response.priorResponse()?.code() != 302) throw Exception("Authentication error")
}
csrf
}
}
private fun getLoginPostBody(username: String, password: String, csrf: String): RequestBody {
return FormBody.Builder()
.add("user_name", username)
.add("password", password)
.add("cookie", "1")
.add("sublogin", "Login")
.add("submit", "1")
.add(CSRF, csrf)
.build()
}
private fun getExportPostBody(csrf: String): RequestBody {
return FormBody.Builder()
.add("type", "2")
.add("subexport", "Export My List")
.add(CSRF, csrf)
.build()
}
private fun getMangaPostPayload(track: Track, csrf: String): RequestBody {
val body = JSONObject()
.put("manga_id", track.media_id)
.put("status", track.status)
.put("score", track.score)
.put("num_read_chapters", track.last_chapter_read)
.put(CSRF, csrf)
return RequestBody.create(MediaType.parse("application/json; charset=utf-8"), body.toString())
}
private fun getLoginUrl() = Uri.parse(baseUrl).buildUpon()
.appendPath("login.php")
.toString()
private fun getSearchUrl(query: String): String {
val col = "c[]"
return Uri.parse(baseUrl).buildUpon()
.appendPath("manga.php")
.appendQueryParameter("q", query)
.appendQueryParameter(col, "a")
.appendQueryParameter(col, "b")
.appendQueryParameter(col, "c")
.appendQueryParameter(col, "d")
.appendQueryParameter(col, "e")
.appendQueryParameter(col, "g")
.toString()
}
private fun getExportListUrl() = Uri.parse(baseUrl).buildUpon()
.appendPath("panel.php")
.appendQueryParameter("go", "export")
.toString()
private fun getListUrl(csrf: String): Observable<String> {
return client.newCall(POST(url = getExportListUrl(), body = getExportPostBody(csrf)))
.asObservable()
.map {response ->
baseUrl + Jsoup.parse(response.consumeBody())
.select("div.goodresult")
.select("a")
.attr("href")
}
}
private fun getUpdateUrl() = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath("edit.json")
.toString()
private fun getAddUrl() = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath( "add.json")
.toString()
private fun Response.consumeBody(): String? {
use {
if (it.code() != 200) throw Exception("Login error")
return it.body()?.string()
}
}
private fun Response.consumeXmlBody(): String? {
use { res ->
if (res.code() != 200) throw Exception("Export list error")
BufferedReader(InputStreamReader(GZIPInputStream(res.body()?.source()?.inputStream()))).use { reader ->
val sb = StringBuilder()
reader.forEachLine { line ->
sb.append(line)
}
return sb.toString()
} }
} }
return FormBody.Builder()
.add("data", data)
.build()
}
private inline fun xml(block: XmlSerializer.() -> Unit): String {
val x = Xml.newSerializer()
val writer = StringWriter()
with(x) {
setOutput(writer)
startDocument("UTF-8", false)
block()
endDocument()
}
return writer.toString()
}
private inline fun XmlSerializer.element(tag: String, block: XmlSerializer.() -> Unit) {
startTag("", tag)
block()
endTag("", tag)
}
private fun XmlSerializer.text(tag: String, body: String) {
startTag("", tag)
text(body)
endTag("", tag)
}
fun getLoginUrl() = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/account/verify_credentials.xml")
.toString()
fun getSearchUrl(query: String) = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/manga/search.xml")
.appendQueryParameter("q", query)
.toString()
fun getListUrl(username: String) = Uri.parse(baseUrl).buildUpon()
.appendPath("malappinfo.php")
.appendQueryParameter("u", username)
.appendQueryParameter("status", "all")
.appendQueryParameter("type", "manga")
.toString()
fun getUpdateUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/mangalist/update")
.appendPath("${track.remote_id}.xml")
.toString()
fun getAddUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/mangalist/add")
.appendPath("${track.remote_id}.xml")
.toString()
fun createHeaders(username: String, password: String): Headers {
return Headers.Builder()
.add("Authorization", Credentials.basic(username, password))
.add("User-Agent", "api-indiv-9F93C52A963974CF674325391990191C")
.build()
} }
companion object { companion object {
const val baseUrl = "https://myanimelist.net" const val baseUrl = "https://myanimelist.net"
private const val baseMangaUrl = "$baseUrl/manga/"
private const val baseModifyListUrl = "$baseUrl/ownlist/manga"
private val ENTRY_TAG = "entry" fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId
private val CHAPTER_TAG = "chapter"
private val SCORE_TAG = "score"
private val STATUS_TAG = "status"
const val PREFIX_MY = "my:" fun Element.searchTitle() = select("strong").text()!!
fun Element.searchTotalChapters() = if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt()
fun Element.searchCoverUrl() = select("img")
.attr("data-src")
.split("\\?")[0]
.replace("/r/50x70/", "/")
fun Element.searchMediaId() = select("div.picSurround")
.select("a").attr("id")
.replace("sarea", "")
.toInt()
fun Element.searchSummary() = select("div.pt4")
.first()
.ownText()!!
fun Element.searchPublishingStatus() = if (select(TD).last().text() == "-") PUBLISHING else FINISHED
fun Element.searchPublishingType() = select(TD)[2].text()!!
fun Element.searchStartDate() = select(TD)[6].text()!!
fun getStatus(status: String) = when (status) {
"Reading" -> 1
"Completed" -> 2
"On-Hold" -> 3
"Dropped" -> 4
"Plan to Read" -> 6
else -> 1
}
const val CSRF = "csrf_token"
const val TD = "td"
private const val FINISHED = "Finished"
private const val PUBLISHING = "Publishing"
} }
} }

View File

@ -11,8 +11,8 @@ import com.google.gson.annotations.SerializedName
* @param assets assets of latest release. * @param assets assets of latest release.
*/ */
class GithubRelease(@SerializedName("tag_name") val version: String, class GithubRelease(@SerializedName("tag_name") val version: String,
@SerializedName("body") val changeLog: String, @SerializedName("body") val changeLog: String,
@SerializedName("assets") val assets: List<Assets>) { @SerializedName("assets") private val assets: List<Assets>) {
/** /**
* Get download link of latest release from the assets. * Get download link of latest release from the assets.

View File

@ -1,10 +1,13 @@
package eu.kanade.tachiyomi.data.updater package eu.kanade.tachiyomi.data.updater
import eu.kanade.tachiyomi.network.NetworkHelper
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET import retrofit2.http.GET
import rx.Observable import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/** /**
* Used to connect with the Github API. * Used to connect with the Github API.
@ -17,6 +20,7 @@ interface GithubService {
.baseUrl("https://api.github.com") .baseUrl("https://api.github.com")
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create()) .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.client(Injekt.get<NetworkHelper>().client)
.build() .build()
return restAdapter.create(GithubService::class.java) return restAdapter.create(GithubService::class.java)

View File

@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.data.updater
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import rx.Observable import rx.Observable
class GithubUpdateChecker() { class GithubUpdateChecker {
private val service: GithubService = GithubService.create() private val service: GithubService = GithubService.create()

View File

@ -3,5 +3,5 @@ package eu.kanade.tachiyomi.data.updater
sealed class GithubUpdateResult { sealed class GithubUpdateResult {
class NewUpdate(val release: GithubRelease): GithubUpdateResult() class NewUpdate(val release: GithubRelease): GithubUpdateResult()
class NoNewUpdate(): GithubUpdateResult() class NoNewUpdate : GithubUpdateResult()
} }

View File

@ -1,147 +0,0 @@
package eu.kanade.tachiyomi.data.updater
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.support.v4.app.NotificationCompat
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.notification.NotificationHandler
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.util.notificationManager
import java.io.File
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
/**
* Local [BroadcastReceiver] that runs on UI thread
* Notification calls from [UpdateDownloaderService] should be made from here.
*/
internal class UpdateDownloaderReceiver(val context: Context) : BroadcastReceiver() {
companion object {
private const val NAME = "UpdateDownloaderReceiver"
// Called to show initial notification.
internal const val NOTIFICATION_UPDATER_INITIAL = "$ID.$NAME.UPDATER_INITIAL"
// Called to show progress notification.
internal const val NOTIFICATION_UPDATER_PROGRESS = "$ID.$NAME.UPDATER_PROGRESS"
// Called to show install notification.
internal const val NOTIFICATION_UPDATER_INSTALL = "$ID.$NAME.UPDATER_INSTALL"
// Called to show error notification
internal const val NOTIFICATION_UPDATER_ERROR = "$ID.$NAME.UPDATER_ERROR"
// Value containing action of BroadcastReceiver
internal const val EXTRA_ACTION = "$ID.$NAME.ACTION"
// Value containing progress
internal const val EXTRA_PROGRESS = "$ID.$NAME.PROGRESS"
// Value containing apk path
internal const val EXTRA_APK_PATH = "$ID.$NAME.APK_PATH"
// Value containing apk url
internal const val EXTRA_APK_URL = "$ID.$NAME.APK_URL"
}
/**
* Notification shown to user
*/
private val notification = NotificationCompat.Builder(context, Notifications.CHANNEL_COMMON)
override fun onReceive(context: Context, intent: Intent) {
when (intent.getStringExtra(EXTRA_ACTION)) {
NOTIFICATION_UPDATER_INITIAL -> basicNotification()
NOTIFICATION_UPDATER_PROGRESS -> updateProgress(intent.getIntExtra(EXTRA_PROGRESS, 0))
NOTIFICATION_UPDATER_INSTALL -> installNotification(intent.getStringExtra(EXTRA_APK_PATH))
NOTIFICATION_UPDATER_ERROR -> errorNotification(intent.getStringExtra(EXTRA_APK_URL))
}
}
/**
* Called to show basic notification
*/
private fun basicNotification() {
// Create notification
with(notification) {
setContentTitle(context.getString(R.string.app_name))
setContentText(context.getString(R.string.update_check_notification_download_in_progress))
setSmallIcon(android.R.drawable.stat_sys_download)
setOngoing(true)
}
notification.show()
}
/**
* Called to show progress notification
*
* @param progress progress of download
*/
private fun updateProgress(progress: Int) {
with(notification) {
setProgress(100, progress, false)
setOnlyAlertOnce(true)
}
notification.show()
}
/**
* Called to show install notification
*
* @param path path of file
*/
private fun installNotification(path: String) {
// Prompt the user to install the new update.
with(notification) {
setContentText(context.getString(R.string.update_check_notification_download_complete))
setSmallIcon(android.R.drawable.stat_sys_download_done)
setOnlyAlertOnce(false)
setProgress(0, 0, false)
// Install action
setContentIntent(NotificationHandler.installApkPendingActivity(context, File(path)))
addAction(R.drawable.ic_system_update_grey_24dp_img,
context.getString(R.string.action_install),
NotificationHandler.installApkPendingActivity(context, File(path)))
// Cancel action
addAction(R.drawable.ic_clear_grey_24dp_img,
context.getString(R.string.action_cancel),
NotificationReceiver.dismissNotificationPendingBroadcast(context, Notifications.ID_UPDATER))
}
notification.show()
}
/**
* Called to show error notification
*
* @param url url of apk
*/
private fun errorNotification(url: String) {
// Prompt the user to retry the download.
with(notification) {
setContentText(context.getString(R.string.update_check_notification_download_error))
setSmallIcon(android.R.drawable.stat_sys_warning)
setOnlyAlertOnce(false)
setProgress(0, 0, false)
// Retry action
addAction(R.drawable.ic_refresh_grey_24dp_img,
context.getString(R.string.action_retry),
UpdateDownloaderService.downloadApkPendingService(context, url))
// Cancel action
addAction(R.drawable.ic_clear_grey_24dp_img,
context.getString(R.string.action_cancel),
NotificationReceiver.dismissNotificationPendingBroadcast(context, Notifications.ID_UPDATER))
}
notification.show()
}
/**
* Shows a notification from this builder.
*
* @param id the id of the notification.
*/
private fun NotificationCompat.Builder.show(id: Int = Notifications.ID_UPDATER) {
context.notificationManager.notify(id, build())
}
}

View File

@ -1,67 +1,67 @@
package eu.kanade.tachiyomi.data.updater package eu.kanade.tachiyomi.data.updater
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.support.v4.app.NotificationCompat import android.support.v4.app.NotificationCompat
import com.evernote.android.job.Job import com.evernote.android.job.Job
import com.evernote.android.job.JobManager import com.evernote.android.job.JobManager
import com.evernote.android.job.JobRequest import com.evernote.android.job.JobRequest
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.util.notificationManager import eu.kanade.tachiyomi.util.notificationManager
class UpdateCheckerJob : Job() { class UpdaterJob : Job() {
override fun onRunJob(params: Params): Result { override fun onRunJob(params: Params): Result {
return GithubUpdateChecker() return GithubUpdateChecker()
.checkForUpdate() .checkForUpdate()
.map { result -> .map { result ->
if (result is GithubUpdateResult.NewUpdate) { if (result is GithubUpdateResult.NewUpdate) {
val url = result.release.downloadLink val url = result.release.downloadLink
val intent = Intent(context, UpdateDownloaderService::class.java).apply { val intent = Intent(context, UpdaterService::class.java).apply {
putExtra(UpdateDownloaderService.EXTRA_DOWNLOAD_URL, url) putExtra(UpdaterService.EXTRA_DOWNLOAD_URL, url)
} }
NotificationCompat.Builder(context, Notifications.CHANNEL_COMMON).update { NotificationCompat.Builder(context, Notifications.CHANNEL_COMMON).update {
setContentTitle(context.getString(R.string.app_name)) setContentTitle(context.getString(R.string.app_name))
setContentText(context.getString(R.string.update_check_notification_update_available)) setContentText(context.getString(R.string.update_check_notification_update_available))
setSmallIcon(android.R.drawable.stat_sys_download_done) setSmallIcon(android.R.drawable.stat_sys_download_done)
// Download action // Download action
addAction(android.R.drawable.stat_sys_download_done, addAction(android.R.drawable.stat_sys_download_done,
context.getString(R.string.action_download), context.getString(R.string.action_download),
PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)) PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
} }
} }
Job.Result.SUCCESS Job.Result.SUCCESS
} }
.onErrorReturn { Job.Result.FAILURE } .onErrorReturn { Job.Result.FAILURE }
// Sadly, the task needs to be synchronous. // Sadly, the task needs to be synchronous.
.toBlocking() .toBlocking()
.single() .single()
} }
fun NotificationCompat.Builder.update(block: NotificationCompat.Builder.() -> Unit) { fun NotificationCompat.Builder.update(block: NotificationCompat.Builder.() -> Unit) {
block() block()
context.notificationManager.notify(Notifications.ID_UPDATER, build()) context.notificationManager.notify(Notifications.ID_UPDATER, build())
} }
companion object { companion object {
const val TAG = "UpdateChecker" const val TAG = "UpdateChecker"
fun setupTask() { fun setupTask() {
JobRequest.Builder(TAG) JobRequest.Builder(TAG)
.setPeriodic(24 * 60 * 60 * 1000, 60 * 60 * 1000) .setPeriodic(24 * 60 * 60 * 1000, 60 * 60 * 1000)
.setRequiredNetworkType(JobRequest.NetworkType.CONNECTED) .setRequiredNetworkType(JobRequest.NetworkType.CONNECTED)
.setRequirementsEnforced(true) .setRequirementsEnforced(true)
.setUpdateCurrent(true) .setUpdateCurrent(true)
.build() .build()
.schedule() .schedule()
} }
fun cancelTask() { fun cancelTask() {
JobManager.instance().cancelAllForTag(TAG) JobManager.instance().cancelAllForTag(TAG)
} }
} }
} }

View File

@ -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)
}
}

View File

@ -2,52 +2,37 @@ package eu.kanade.tachiyomi.data.updater
import android.app.IntentService import android.app.IntentService
import android.app.PendingIntent import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.ProgressListener import eu.kanade.tachiyomi.network.ProgressListener
import eu.kanade.tachiyomi.network.newCallWithProgress import eu.kanade.tachiyomi.network.newCallWithProgress
import eu.kanade.tachiyomi.util.registerLocalReceiver import eu.kanade.tachiyomi.util.getUriCompat
import eu.kanade.tachiyomi.util.saveTo import eu.kanade.tachiyomi.util.saveTo
import eu.kanade.tachiyomi.util.sendLocalBroadcastSync
import eu.kanade.tachiyomi.util.unregisterLocalReceiver
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.java.name) { class UpdaterService : IntentService(UpdaterService::class.java.name) {
/** /**
* Network helper * Network helper
*/ */
private val network: NetworkHelper by injectLazy() private val network: NetworkHelper by injectLazy()
/** /**
* Local [BroadcastReceiver] that runs on UI thread * Notifier for the updater state and progress.
*/ */
private val updaterNotificationReceiver = UpdateDownloaderReceiver(this) private val notifier by lazy { UpdaterNotifier(this) }
override fun onCreate() {
super.onCreate()
// Register receiver
registerLocalReceiver(updaterNotificationReceiver, IntentFilter(INTENT_FILTER_NAME))
}
override fun onDestroy() {
// Unregister receiver
unregisterLocalReceiver(updaterNotificationReceiver)
super.onDestroy()
}
override fun onHandleIntent(intent: Intent?) { override fun onHandleIntent(intent: Intent?) {
if (intent == null) return if (intent == null) return
val title = intent.getStringExtra(EXTRA_DOWNLOAD_TITLE) ?: getString(R.string.app_name)
val url = intent.getStringExtra(EXTRA_DOWNLOAD_URL) ?: return val url = intent.getStringExtra(EXTRA_DOWNLOAD_URL) ?: return
downloadApk(url) downloadApk(title, url)
} }
/** /**
@ -55,9 +40,9 @@ class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.jav
* *
* @param url url location of file * @param url url location of file
*/ */
fun downloadApk(url: String) { private fun downloadApk(title: String, url: String) {
// Show notification download starting. // Show notification download starting.
sendInitialBroadcast() notifier.onDownloadStarted(title)
val progressListener = object : ProgressListener { val progressListener = object : ProgressListener {
@ -73,7 +58,7 @@ class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.jav
if (progress > savedProgress && currentTime - 200 > lastTick) { if (progress > savedProgress && currentTime - 200 > lastTick) {
savedProgress = progress savedProgress = progress
lastTick = currentTime lastTick = currentTime
sendProgressBroadcast(progress) notifier.onProgressChange(progress)
} }
} }
} }
@ -91,80 +76,32 @@ class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.jav
response.close() response.close()
throw Exception("Unsuccessful response") throw Exception("Unsuccessful response")
} }
sendInstallBroadcast(apkFile.absolutePath) notifier.onDownloadFinished(apkFile.getUriCompat(this))
} catch (error: Exception) { } catch (error: Exception) {
Timber.e(error) Timber.e(error)
sendErrorBroadcast(url) notifier.onDownloadError(url)
} }
} }
/**
* Show notification download starting.
*/
private fun sendInitialBroadcast() {
val intent = Intent(INTENT_FILTER_NAME).apply {
putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_INITIAL)
}
sendLocalBroadcastSync(intent)
}
/**
* Show notification progress changed
*
* @param progress progress of download
*/
private fun sendProgressBroadcast(progress: Int) {
val intent = Intent(INTENT_FILTER_NAME).apply {
putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_PROGRESS)
putExtra(UpdateDownloaderReceiver.EXTRA_PROGRESS, progress)
}
sendLocalBroadcastSync(intent)
}
/**
* Show install notification.
*
* @param path location of file
*/
private fun sendInstallBroadcast(path: String){
val intent = Intent(INTENT_FILTER_NAME).apply {
putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_INSTALL)
putExtra(UpdateDownloaderReceiver.EXTRA_APK_PATH, path)
}
sendLocalBroadcastSync(intent)
}
/**
* Show error notification.
*
* @param url url of file
*/
private fun sendErrorBroadcast(url: String){
val intent = Intent(INTENT_FILTER_NAME).apply {
putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_ERROR)
putExtra(UpdateDownloaderReceiver.EXTRA_APK_URL, url)
}
sendLocalBroadcastSync(intent)
}
companion object { companion object {
/**
* Name of Local BroadCastReceiver.
*/
private val INTENT_FILTER_NAME = UpdateDownloaderService::class.java.name
/** /**
* Download url. * Download url.
*/ */
internal const val EXTRA_DOWNLOAD_URL = "${BuildConfig.APPLICATION_ID}.UpdateDownloaderService.DOWNLOAD_URL" internal const val EXTRA_DOWNLOAD_URL = "${BuildConfig.APPLICATION_ID}.UpdaterService.DOWNLOAD_URL"
/**
* Download title
*/
internal const val EXTRA_DOWNLOAD_TITLE = "${BuildConfig.APPLICATION_ID}.UpdaterService.DOWNLOAD_TITLE"
/** /**
* Downloads a new update and let the user install the new version from a notification. * Downloads a new update and let the user install the new version from a notification.
* @param context the application context. * @param context the application context.
* @param url the url to the new update. * @param url the url to the new update.
*/ */
fun downloadUpdate(context: Context, url: String) { fun downloadUpdate(context: Context, url: String, title: String = context.getString(R.string.app_name)) {
val intent = Intent(context, UpdateDownloaderService::class.java).apply { val intent = Intent(context, UpdaterService::class.java).apply {
putExtra(EXTRA_DOWNLOAD_TITLE, title)
putExtra(EXTRA_DOWNLOAD_URL, url) putExtra(EXTRA_DOWNLOAD_URL, url)
} }
context.startService(intent) context.startService(intent)
@ -177,7 +114,7 @@ class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.jav
* @return [PendingIntent] * @return [PendingIntent]
*/ */
internal fun downloadApkPendingService(context: Context, url: String): PendingIntent { internal fun downloadApkPendingService(context: Context, url: String): PendingIntent {
val intent = Intent(context, UpdateDownloaderService::class.java).apply { val intent = Intent(context, UpdaterService::class.java).apply {
putExtra(EXTRA_DOWNLOAD_URL, url) putExtra(EXTRA_DOWNLOAD_URL, url)
} }
return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)

View File

@ -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
}
}

View File

@ -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}"
}
}

View File

@ -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()
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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"
}
}

View File

@ -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
}
}
}

View File

@ -1,10 +1,8 @@
package eu.kanade.tachiyomi.network package eu.kanade.tachiyomi.network
import com.squareup.duktape.Duktape import com.squareup.duktape.Duktape
import okhttp3.HttpUrl import okhttp3.*
import okhttp3.Interceptor import java.io.IOException
import okhttp3.Request
import okhttp3.Response
class CloudflareInterceptor : Interceptor { class CloudflareInterceptor : Interceptor {
@ -14,13 +12,21 @@ class CloudflareInterceptor : Interceptor {
private val challengePattern = Regex("""name="jschl_vc" value="(\w+)"""") private val challengePattern = Regex("""name="jschl_vc" value="(\w+)"""")
private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare")
@Synchronized @Synchronized
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request()) val response = chain.proceed(chain.request())
// Check if Cloudflare anti-bot is on // Check if Cloudflare anti-bot is on
if (response.code() == 503 && "cloudflare-nginx" == response.header("Server")) { if (response.code() == 503 && response.header("Server") in serverCheck) {
return chain.proceed(resolveChallenge(response)) return try {
chain.proceed(resolveChallenge(response))
} catch (e: Exception) {
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
// we don't crash the entire app
throw IOException(e)
}
} }
return response return response
@ -41,32 +47,33 @@ class CloudflareInterceptor : Interceptor {
val pass = passPattern.find(content)?.groups?.get(1)?.value val pass = passPattern.find(content)?.groups?.get(1)?.value
if (operation == null || challenge == null || pass == null) { if (operation == null || challenge == null || pass == null) {
throw RuntimeException("Failed resolving Cloudflare challenge") throw Exception("Failed resolving Cloudflare challenge")
} }
val js = operation val js = operation
.replace(Regex("""a\.value =(.+?) \+.*"""), "$1") .replace(Regex("""a\.value = (.+ \+ t\.length(\).toFixed\(10\))?).+"""), "$1")
.replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "") .replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "")
.replace("t.length", "${domain.length}")
.replace("\n", "") .replace("\n", "")
val result = (duktape.evaluate(js) as Double).toInt() val result = duktape.evaluate(js) as String
val answer = "${result + domain.length}"
val cloudflareUrl = HttpUrl.parse("${url.scheme()}://$domain/cdn-cgi/l/chk_jschl")!! val cloudflareUrl = HttpUrl.parse("${url.scheme()}://$domain/cdn-cgi/l/chk_jschl")!!
.newBuilder() .newBuilder()
.addQueryParameter("jschl_vc", challenge) .addQueryParameter("jschl_vc", challenge)
.addQueryParameter("pass", pass) .addQueryParameter("pass", pass)
.addQueryParameter("jschl_answer", answer) .addQueryParameter("jschl_answer", result)
.toString() .toString()
val cloudflareHeaders = originalRequest.headers() val cloudflareHeaders = originalRequest.headers()
.newBuilder() .newBuilder()
.add("Referer", url.toString()) .add("Referer", url.toString())
.add("Accept", "text/html,application/xhtml+xml,application/xml")
.add("Accept-Language", "en")
.build() .build()
return GET(cloudflareUrl, cloudflareHeaders) return GET(cloudflareUrl, cloudflareHeaders, cache = CacheControl.Builder().build())
} }
} }
} }

View File

@ -1,9 +1,25 @@
package eu.kanade.tachiyomi.network package eu.kanade.tachiyomi.network
import android.content.Context import android.content.Context
import android.os.Build
import okhttp3.Cache import okhttp3.Cache
import okhttp3.CipherSuite
import okhttp3.ConnectionSpec
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.TlsVersion
import java.io.File import java.io.File
import java.io.IOException
import java.net.InetAddress
import java.net.Socket
import java.net.UnknownHostException
import java.security.KeyManagementException
import java.security.KeyStore
import java.security.NoSuchAlgorithmException
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
class NetworkHelper(context: Context) { class NetworkHelper(context: Context) {
@ -16,16 +32,7 @@ class NetworkHelper(context: Context) {
val client = OkHttpClient.Builder() val client = OkHttpClient.Builder()
.cookieJar(cookieManager) .cookieJar(cookieManager)
.cache(Cache(cacheDir, cacheSize)) .cache(Cache(cacheDir, cacheSize))
.build() .enableTLS12()
val forceCacheClient = client.newBuilder()
.addNetworkInterceptor { chain ->
val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder()
.removeHeader("Pragma")
.header("Cache-Control", "max-age=600")
.build()
}
.build() .build()
val cloudflareClient = client.newBuilder() val cloudflareClient = client.newBuilder()
@ -35,4 +42,87 @@ class NetworkHelper(context: Context) {
val cookies: PersistentCookieStore val cookies: PersistentCookieStore
get() = cookieManager.store get() = cookieManager.store
private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) {
return this
}
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
trustManagerFactory.init(null as KeyStore?)
val trustManagers = trustManagerFactory.trustManagers
if (trustManagers.size == 1 && trustManagers[0] is X509TrustManager) {
class TLSSocketFactory @Throws(KeyManagementException::class, NoSuchAlgorithmException::class)
constructor() : SSLSocketFactory() {
private val internalSSLSocketFactory: SSLSocketFactory
init {
val context = SSLContext.getInstance("TLS")
context.init(null, null, null)
internalSSLSocketFactory = context.socketFactory
}
override fun getDefaultCipherSuites(): Array<String> {
return internalSSLSocketFactory.defaultCipherSuites
}
override fun getSupportedCipherSuites(): Array<String> {
return internalSSLSocketFactory.supportedCipherSuites
}
@Throws(IOException::class)
override fun createSocket(): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket())
}
@Throws(IOException::class)
override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose))
}
@Throws(IOException::class, UnknownHostException::class)
override fun createSocket(host: String, port: Int): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port))
}
@Throws(IOException::class, UnknownHostException::class)
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort))
}
@Throws(IOException::class)
override fun createSocket(host: InetAddress, port: Int): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port))
}
@Throws(IOException::class)
override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort))
}
private fun enableTLSOnSocket(socket: Socket?): Socket? {
if (socket != null && socket is SSLSocket) {
socket.enabledProtocols = socket.supportedProtocols
}
return socket
}
}
sslSocketFactory(TLSSocketFactory(), trustManagers[0] as X509TrustManager)
}
val specCompat = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
.tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0)
.cipherSuites(
*ConnectionSpec.MODERN_TLS.cipherSuites().orEmpty().toTypedArray(),
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
)
.build()
val specs = listOf(specCompat, ConnectionSpec.CLEARTEXT)
connectionSpecs(specs)
return this
}
} }

View File

@ -60,6 +60,11 @@ class PersistentCookieStore(context: Context) {
cookieMap.clear() cookieMap.clear()
} }
fun remove(uri: URI) {
prefs.edit().remove(uri.host).apply()
cookieMap.remove(uri.host)
}
fun get(url: HttpUrl) = get(url.uri().host) fun get(url: HttpUrl) = get(url.uri().host)
fun get(uri: URI) = get(uri.host) fun get(uri: URI) = get(uri.host)

View File

@ -0,0 +1,8 @@
package eu.kanade.tachiyomi.source
import android.support.v7.preference.PreferenceScreen
interface ConfigurableSource : Source {
fun setupPreferenceScreen(screen: PreferenceScreen)
}

View File

@ -1,24 +1,22 @@
package eu.kanade.tachiyomi.source package eu.kanade.tachiyomi.source
import android.content.Context import android.content.Context
import android.net.Uri
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.* import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.util.ChapterRecognition import eu.kanade.tachiyomi.util.ChapterRecognition
import eu.kanade.tachiyomi.util.DiskUtil import eu.kanade.tachiyomi.util.DiskUtil
import eu.kanade.tachiyomi.util.RarContentProvider import eu.kanade.tachiyomi.util.EpubFile
import eu.kanade.tachiyomi.util.ZipContentProvider import eu.kanade.tachiyomi.util.ImageUtil
import junrar.Archive import junrar.Archive
import junrar.rarfile.FileHeader import junrar.rarfile.FileHeader
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import rx.Observable import rx.Observable
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.InputStream import java.io.InputStream
import java.util.* import java.util.Comparator
import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipFile import java.util.zip.ZipFile
@ -107,15 +105,11 @@ class LocalSource(private val context: Context) : CatalogueSource {
if (thumbnail_url == null) { if (thumbnail_url == null) {
val chapters = fetchChapterList(this).toBlocking().first() val chapters = fetchChapterList(this).toBlocking().first()
if (chapters.isNotEmpty()) { if (chapters.isNotEmpty()) {
val uri = fetchPageList(chapters.last()).toBlocking().first().firstOrNull()?.uri try {
if (uri != null) { val dest = updateCover(chapters.last(), this)
val input = context.contentResolver.openInputStream(uri) thumbnail_url = dest?.absolutePath
try { } catch (e: Exception) {
val dest = updateCover(context, this, input) Timber.e(e)
thumbnail_url = dest?.absolutePath
} catch (e: Exception) {
Timber.e(e)
}
} }
} }
} }
@ -135,7 +129,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
val chapters = getBaseDirectories(context) val chapters = getBaseDirectories(context)
.mapNotNull { File(it, manga.url).listFiles()?.toList() } .mapNotNull { File(it, manga.url).listFiles()?.toList() }
.flatten() .flatten()
.filter { it.isDirectory || isSupportedFormat(it.extension) } .filter { it.isDirectory || isSupportedFile(it.extension) }
.map { chapterFile -> .map { chapterFile ->
SChapter.create().apply { SChapter.create().apply {
url = "${manga.url}/${chapterFile.name}" url = "${manga.url}/${chapterFile.name}"
@ -150,7 +144,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
ChapterRecognition.parseChapterNumber(this, manga) ChapterRecognition.parseChapterNumber(this, manga)
} }
} }
.sortedWith(Comparator<SChapter> { c1, c2 -> .sortedWith(Comparator { c1, c2 ->
val c = c2.chapter_number.compareTo(c1.chapter_number) val c = c2.chapter_number.compareTo(c1.chapter_number)
if (c == 0) comparator.compare(c2.name, c1.name) else c if (c == 0) comparator.compare(c2.name, c1.name) else c
}) })
@ -159,160 +153,90 @@ class LocalSource(private val context: Context) : CatalogueSource {
} }
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> { override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return Observable.error(Exception("Unused"))
}
private fun isSupportedFile(extension: String): Boolean {
return extension.toLowerCase() in setOf("zip", "rar", "cbr", "cbz", "epub")
}
fun getFormat(chapter: SChapter): Format {
val baseDirs = getBaseDirectories(context) val baseDirs = getBaseDirectories(context)
for (dir in baseDirs) { for (dir in baseDirs) {
val chapFile = File(dir, chapter.url) val chapFile = File(dir, chapter.url)
if (!chapFile.exists()) continue if (!chapFile.exists()) continue
return Observable.just(getLoader(chapFile).load()) return getFormat(chapFile)
} }
throw Exception("Chapter not found")
return Observable.error(Exception("Chapter not found"))
} }
private fun isSupportedFormat(extension: String): Boolean { private fun getFormat(file: File): Format {
return extension.equals("zip", true) || extension.equals("cbz", true)
|| extension.equals("rar", true) || extension.equals("cbr", true)
|| extension.equals("epub", true)
}
private fun getLoader(file: File): Loader {
val extension = file.extension val extension = file.extension
return if (file.isDirectory) { return if (file.isDirectory) {
DirectoryLoader(file) Format.Directory(file)
} else if (extension.equals("zip", true) || extension.equals("cbz", true)) { } else if (extension.equals("zip", true) || extension.equals("cbz", true)) {
ZipLoader(file) Format.Zip(file)
} else if (extension.equals("epub", true)) {
EpubLoader(file)
} else if (extension.equals("rar", true) || extension.equals("cbr", true)) { } else if (extension.equals("rar", true) || extension.equals("cbr", true)) {
RarLoader(file) Format.Rar(file)
} else if (extension.equals("epub", true)) {
Format.Epub(file)
} else { } else {
throw Exception("Invalid chapter format") throw Exception("Invalid chapter format")
} }
} }
private fun updateCover(chapter: SChapter, manga: SManga): File? {
val format = getFormat(chapter)
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return when (format) {
is Format.Directory -> {
val entry = format.file.listFiles()
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
.find { !it.isDirectory && ImageUtil.isImage(it.name, { FileInputStream(it) }) }
entry?.let { updateCover(context, manga, it.inputStream())}
}
is Format.Zip -> {
ZipFile(format.file).use { zip ->
val entry = zip.entries().toList()
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
.find { !it.isDirectory && ImageUtil.isImage(it.name, { zip.getInputStream(it) }) }
entry?.let { updateCover(context, manga, zip.getInputStream(it) )}
}
}
is Format.Rar -> {
Archive(format.file).use { archive ->
val entry = archive.fileHeaders
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
.find { !it.isDirectory && ImageUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) }
entry?.let { updateCover(context, manga, archive.getInputStream(it) )}
}
}
is Format.Epub -> {
EpubFile(format.file).use { epub ->
val entry = epub.getImagesFromPages()
.firstOrNull()
?.let { epub.getEntry(it) }
entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
}
}
}
}
private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true)) private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true))
override fun getFilterList() = FilterList(OrderBy()) override fun getFilterList() = FilterList(OrderBy())
interface Loader { sealed class Format {
fun load(): List<Page> data class Directory(val file: File) : Format()
data class Zip(val file: File) : Format()
data class Rar(val file: File): Format()
data class Epub(val file: File) : Format()
} }
class DirectoryLoader(val file: File) : Loader { }
override fun load(): List<Page> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return file.listFiles()
.filter { !it.isDirectory && DiskUtil.isImage(it.name, { FileInputStream(it) }) }
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
.map { Uri.fromFile(it) }
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
}
}
class ZipLoader(val file: File) : Loader {
override fun load(): List<Page> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return ZipFile(file).use { zip ->
zip.entries().toList()
.filter { !it.isDirectory && DiskUtil.isImage(it.name, { zip.getInputStream(it) }) }
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
.map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/${it.name}") }
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
}
}
}
class RarLoader(val file: File) : Loader {
override fun load(): List<Page> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return Archive(file).use { archive ->
archive.fileHeaders
.filter { !it.isDirectory && DiskUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) }
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
.map { Uri.parse("content://${RarContentProvider.PROVIDER}${file.absolutePath}!-/${it.fileNameString}") }
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
}
}
}
class EpubLoader(val file: File) : Loader {
override fun load(): List<Page> {
ZipFile(file).use { zip ->
val allEntries = zip.entries().toList()
val ref = getPackageHref(zip)
val doc = getPackageDocument(zip, ref)
val pages = getPagesFromDocument(doc)
val hrefs = getHrefMap(ref, allEntries.map { it.name })
return getImagesFromPages(zip, pages, hrefs)
.map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/$it") }
.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } }
}
}
/**
* Returns the path to the package document.
*/
private fun getPackageHref(zip: ZipFile): String {
val meta = zip.getEntry("META-INF/container.xml")
if (meta != null) {
val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") }
val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path")
if (path != null) {
return path
}
}
return "OEBPS/content.opf"
}
/**
* Returns the package document where all the files are listed.
*/
private fun getPackageDocument(zip: ZipFile, ref: String): Document {
val entry = zip.getEntry(ref)
return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
}
/**
* Returns all the pages from the epub.
*/
private fun getPagesFromDocument(document: Document): List<String> {
val pages = document.select("manifest > item")
.filter { "application/xhtml+xml" == it.attr("media-type") }
.associateBy { it.attr("id") }
val spine = document.select("spine > itemref").map { it.attr("idref") }
return spine.mapNotNull { pages[it] }.map { it.attr("href") }
}
/**
* Returns all the images contained in every page from the epub.
*/
private fun getImagesFromPages(zip: ZipFile, pages: List<String>, hrefs: Map<String, String>): List<String> {
return pages.map { page ->
val entry = zip.getEntry(hrefs[page])
val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
document.getElementsByTag("img").mapNotNull { hrefs[it.attr("src")] }
}.flatten()
}
/**
* Returns a map with a relative url as key and abolute url as path.
*/
private fun getHrefMap(packageHref: String, entries: List<String>): Map<String, String> {
val lastSlashPos = packageHref.lastIndexOf('/')
if (lastSlashPos < 0) {
return entries.associateBy { it }
}
return entries.associateBy { entry ->
if (entry.isNotBlank() && entry.length > lastSlashPos) {
entry.substring(lastSlashPos + 1)
} else {
entry
}
}
}
}
}

View File

@ -1,50 +1,50 @@
package eu.kanade.tachiyomi.source package eu.kanade.tachiyomi.source
import android.Manifest.permission.READ_EXTERNAL_STORAGE
import android.content.Context import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.os.Environment
import dalvik.system.PathClassLoader
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.YamlHttpSource
import eu.kanade.tachiyomi.source.online.english.* import eu.kanade.tachiyomi.source.online.english.*
import eu.kanade.tachiyomi.source.online.german.WieManga import eu.kanade.tachiyomi.source.online.german.WieManga
import eu.kanade.tachiyomi.source.online.russian.Mangachan import eu.kanade.tachiyomi.source.online.russian.Mangachan
import eu.kanade.tachiyomi.source.online.russian.Mintmanga import eu.kanade.tachiyomi.source.online.russian.Mintmanga
import eu.kanade.tachiyomi.source.online.russian.Readmanga import eu.kanade.tachiyomi.source.online.russian.Readmanga
import eu.kanade.tachiyomi.util.hasPermission import rx.Observable
import org.yaml.snakeyaml.Yaml
import timber.log.Timber
import java.io.File
open class SourceManager(private val context: Context) { open class SourceManager(private val context: Context) {
private val sourcesMap = mutableMapOf<Long, Source>() private val sourcesMap = mutableMapOf<Long, Source>()
private val stubSourcesMap = mutableMapOf<Long, StubSource>()
init { init {
createSources() createInternalSources().forEach { registerSource(it) }
} }
open fun get(sourceKey: Long): Source? { open fun get(sourceKey: Long): Source? {
return sourcesMap[sourceKey] return sourcesMap[sourceKey]
} }
fun getOrStub(sourceKey: Long): Source {
return sourcesMap[sourceKey] ?: stubSourcesMap.getOrPut(sourceKey) {
StubSource(sourceKey)
}
}
fun getOnlineSources() = sourcesMap.values.filterIsInstance<HttpSource>() fun getOnlineSources() = sourcesMap.values.filterIsInstance<HttpSource>()
fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>() fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>()
private fun createSources() { internal fun registerSource(source: Source, overwrite: Boolean = false) {
createExtensionSources().forEach { registerSource(it) } if (overwrite || !sourcesMap.containsKey(source.id)) {
createYamlSources().forEach { registerSource(it) } sourcesMap[source.id] = source
createInternalSources().forEach { registerSource(it) } }
} }
private fun registerSource(source: Source, overwrite: Boolean = false) { internal fun unregisterSource(source: Source) {
if (overwrite || !sourcesMap.containsKey(source.id)) { sourcesMap.remove(source.id)
sourcesMap.put(source.id, source)
}
} }
private fun createInternalSources(): List<Source> = listOf( private fun createInternalSources(): List<Source> = listOf(
@ -61,91 +61,29 @@ open class SourceManager(private val context: Context) {
WieManga() WieManga()
) )
private fun createYamlSources(): List<Source> { private inner class StubSource(override val id: Long) : Source {
val sources = mutableListOf<Source>()
val parsersDir = File(Environment.getExternalStorageDirectory().absolutePath + override val name: String
File.separator + context.getString(R.string.app_name), "parsers") get() = id.toString()
if (parsersDir.exists() && context.hasPermission(READ_EXTERNAL_STORAGE)) { override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
val yaml = Yaml() return Observable.error(getSourceNotInstalledException())
for (file in parsersDir.listFiles().filter { it.extension == "yml" }) {
try {
val map = file.inputStream().use { yaml.loadAs(it, Map::class.java) }
sources.add(YamlHttpSource(map))
} catch (e: Exception) {
Timber.e("Error loading source from file. Bad format?", e)
}
}
}
return sources
}
private fun createExtensionSources(): List<Source> {
val pkgManager = context.packageManager
val flags = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES
val installedPkgs = pkgManager.getInstalledPackages(flags)
val extPkgs = installedPkgs.filter { it.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE } }
val sources = mutableListOf<Source>()
for (pkgInfo in extPkgs) {
val appInfo = pkgManager.getApplicationInfo(pkgInfo.packageName,
PackageManager.GET_META_DATA) ?: continue
val extName = pkgManager.getApplicationLabel(appInfo).toString()
.substringAfter("Tachiyomi: ")
val version = pkgInfo.versionName
val sourceClasses = appInfo.metaData.getString(METADATA_SOURCE_CLASS)
.split(";")
.map {
val sourceClass = it.trim()
if(sourceClass.startsWith("."))
pkgInfo.packageName + sourceClass
else
sourceClass
}
val extension = Extension(extName, appInfo, version, sourceClasses)
try {
sources += loadExtension(extension)
} catch (e: Exception) {
Timber.e("Extension load error: $extName.", e)
} catch (e: LinkageError) {
Timber.e("Extension load error: $extName.", e)
}
}
return sources
}
private fun loadExtension(ext: Extension): List<Source> {
// Validate lib version
val majorLibVersion = ext.version.substringBefore('.').toInt()
if (majorLibVersion < LIB_VERSION_MIN || majorLibVersion > LIB_VERSION_MAX) {
throw Exception("Lib version is $majorLibVersion, while only versions "
+ "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed")
} }
val classLoader = PathClassLoader(ext.appInfo.sourceDir, null, context.classLoader) override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return ext.sourceClasses.flatMap { return Observable.error(getSourceNotInstalledException())
val obj = Class.forName(it, false, classLoader).newInstance() }
when(obj) {
is Source -> listOf(obj) override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
is SourceFactory -> obj.createSources() return Observable.error(getSourceNotInstalledException())
else -> throw Exception("Unknown source class type!") }
}
override fun toString(): String {
return name
}
private fun getSourceNotInstalledException(): Exception {
return Exception(context.getString(R.string.source_not_installed, id.toString()))
} }
} }
class Extension(val name: String,
val appInfo: ApplicationInfo,
val version: String,
val sourceClasses: List<String>)
private companion object {
const val EXTENSION_FEATURE = "tachiyomi.extension"
const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
const val LIB_VERSION_MIN = 1
const val LIB_VERSION_MAX = 1
}
} }

View File

@ -2,21 +2,18 @@ package eu.kanade.tachiyomi.source.model
import android.net.Uri import android.net.Uri
import eu.kanade.tachiyomi.network.ProgressListener import eu.kanade.tachiyomi.network.ProgressListener
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
import rx.subjects.Subject import rx.subjects.Subject
class Page( open class Page(
val index: Int, val index: Int,
val url: String = "", val url: String = "",
var imageUrl: String? = null, var imageUrl: String? = null,
@Transient var uri: Uri? = null @Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions
) : ProgressListener { ) : ProgressListener {
val number: Int val number: Int
get() = index + 1 get() = index + 1
@Transient lateinit var chapter: ReaderChapter
@Transient @Volatile var status: Int = 0 @Transient @Volatile var status: Int = 0
set(value) { set(value) {
field = value field = value

View File

@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.source.online package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.asObservableSuccess
@ -28,10 +27,12 @@ abstract class HttpSource : CatalogueSource {
*/ */
protected val network: NetworkHelper by injectLazy() protected val network: NetworkHelper by injectLazy()
/** // /**
* Preferences helper. // * Preferences that a source may need.
*/ // */
protected val preferences: PreferencesHelper by injectLazy() // val preferences: SharedPreferences by lazy {
// Injekt.get<Application>().getSharedPreferences("source_$id", Context.MODE_PRIVATE)
// }
/** /**
* Base url of the website without the trailing slash, like: http://mysite.com * Base url of the website without the trailing slash, like: http://mysite.com

Some files were not shown because too many files have changed in this diff Show More