Compare commits

...

55 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
187 changed files with 12856 additions and 8191 deletions

View File

@ -1,5 +1,7 @@
**Please fill out this form and remove the first two lines before posting.**
**If your issue is a request for a catalogue it belongs here https://github.com/inorichi/tachiyomi-extensions/**
**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:**
@ -10,4 +12,4 @@
2.
3.
**Other details:**
**Other details:**

View File

@ -1,7 +1,7 @@
language: android
android:
components:
- build-tools-27.0.3
- build-tools-28.0.3
- android-27
- extra-android-m2repository
- extra-google-m2repository

View File

@ -30,7 +30,7 @@ ext {
android {
compileSdkVersion 27
buildToolsVersion '27.0.3'
buildToolsVersion '28.0.3'
publishNonDefault true
defaultConfig {
@ -38,8 +38,8 @@ android {
minSdkVersion 16
targetSdkVersion 27
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
versionCode 37
versionName "0.7.4"
versionCode 39
versionName "0.8.1"
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
@ -102,7 +102,7 @@ android {
dependencies {
// Modified dependencies
implementation 'com.github.inorichi:subsampling-scale-image-view:81b9d68'
implementation 'com.github.inorichi:subsampling-scale-image-view:ac0dae7'
implementation 'com.github.inorichi:junrar-android:634c1f5'
// Android support library
@ -116,9 +116,9 @@ dependencies {
implementation "com.android.support:support-annotations:$support_library_version"
implementation "com.android.support:customtabs:$support_library_version"
implementation 'com.android.support.constraint:constraint-layout:1.1.0-beta6'
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'
@ -161,7 +161,8 @@ dependencies {
implementation 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
// 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
final nucleus_version = '3.0.0'
@ -201,11 +202,13 @@ dependencies {
implementation 'com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.0.4'
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
implementation "com.github.inorichi.Conductor:conductor:be8b3c5"
implementation ("com.bluelinelabs:conductor-support:2.1.5-SNAPSHOT") {
exclude group: "com.bluelinelabs", module: "conductor"
implementation 'com.bluelinelabs:conductor:2.1.5'
implementation ("com.bluelinelabs:conductor-support:2.1.5") {
exclude group: "com.android.support"
}
implementation 'com.github.inorichi:conductor-support-preference:27.0.2'
@ -234,7 +237,7 @@ dependencies {
}
buildscript {
ext.kotlin_version = '1.2.30'
ext.kotlin_version = '1.2.71'
repositories {
mavenCentral()
}

View File

@ -32,8 +32,7 @@
<meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts"/>
</activity>
<activity
android:name=".ui.reader.ReaderActivity"
android:theme="@style/Theme.Reader" />
android:name=".ui.reader.ReaderActivity" />
<activity
android:name=".widget.CustomLayoutPickerActivity"
android:label="@string/app_name"
@ -66,16 +65,6 @@
android:resource="@xml/provider_paths" />
</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
android:name=".data.notification.NotificationReceiver"
android:exported="false" />

View File

@ -57,13 +57,17 @@ open class App : Application() {
}
protected open fun setupJobManager() {
JobManager.create(this).addJobCreator { tag ->
when (tag) {
LibraryUpdateJob.TAG -> LibraryUpdateJob()
UpdaterJob.TAG -> UpdaterJob()
BackupCreatorJob.TAG -> BackupCreatorJob()
else -> null
try {
JobManager.create(this).addJobCreator { tag ->
when (tag) {
LibraryUpdateJob.TAG -> LibraryUpdateJob()
UpdaterJob.TAG -> UpdaterJob()
BackupCreatorJob.TAG -> BackupCreatorJob()
else -> null
}
}
} catch (e: Exception) {
Timber.w("Can't initialize job manager")
}
}

View File

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

View File

@ -1,10 +1,12 @@
package eu.kanade.tachiyomi.data.database
import android.arch.persistence.db.SupportSQLiteOpenHelper
import android.content.Context
import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite
import eu.kanade.tachiyomi.data.database.mappers.*
import eu.kanade.tachiyomi.data.database.models.*
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.
@ -12,8 +14,13 @@ import eu.kanade.tachiyomi.data.database.queries.*
open class DatabaseHelper(context: Context)
: 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()
.sqliteOpenHelper(DbOpenHelper(context))
.sqliteOpenHelper(RequerySQLiteOpenHelperFactory().create(configuration))
.addTypeMapping(Manga::class.java, MangaTypeMapping())
.addTypeMapping(Chapter::class.java, ChapterTypeMapping())
.addTypeMapping(Track::class.java, TrackTypeMapping())

View File

@ -1,12 +1,13 @@
package eu.kanade.tachiyomi.data.database
import android.arch.persistence.db.SupportSQLiteDatabase
import android.arch.persistence.db.SupportSQLiteOpenHelper
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import eu.kanade.tachiyomi.data.database.tables.*
class DbOpenHelper(context: Context)
: SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
companion object {
/**
@ -17,10 +18,10 @@ class DbOpenHelper(context: Context)
/**
* Version of the database.
*/
const val DATABASE_VERSION = 7
const val DATABASE_VERSION = 8
}
override fun onCreate(db: SQLiteDatabase) = with(db) {
override fun onCreate(db: SupportSQLiteDatabase) = with(db) {
execSQL(MangaTable.createTableQuery)
execSQL(ChapterTable.createTableQuery)
execSQL(TrackTable.createTableQuery)
@ -30,12 +31,13 @@ class DbOpenHelper(context: Context)
// DB indexes
execSQL(MangaTable.createUrlIndexQuery)
execSQL(MangaTable.createFavoriteIndexQuery)
execSQL(MangaTable.createLibraryIndexQuery)
execSQL(ChapterTable.createMangaIdIndexQuery)
execSQL(ChapterTable.createUnreadChaptersIndexQuery)
execSQL(HistoryTable.createChapterIdIndexQuery)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
if (oldVersion < 2) {
db.execSQL(ChapterTable.sourceOrderUpdateQuery)
@ -60,9 +62,14 @@ class DbOpenHelper(context: Context)
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)
}

View File

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

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
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
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SOURCE_ORDER INTEGER DEFAULT 0"

View File

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

View File

@ -23,10 +23,12 @@ import java.util.concurrent.TimeUnit
* @param sourceManager the source manager.
* @param preferences the preferences of the app.
*/
class DownloadCache(private val context: Context,
private val provider: DownloadProvider,
private val sourceManager: SourceManager = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get()) {
class DownloadCache(
private val context: Context,
private val provider: DownloadProvider,
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
@ -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.
*

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.download.model.DownloadQueue
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.Page
import rx.Observable
import uy.kohesive.injekt.injectLazy
/**
* 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) {
/**
* The sources manager.
*/
private val sourceManager by injectLazy<SourceManager>()
/**
* 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.
*/
private val cache = DownloadCache(context, provider)
private val cache = DownloadCache(context, provider, sourceManager)
/**
* 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.
@ -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 manga the manga of the chapter.
* @param source the source of the chapter.
* @param chapters the list of chapters to delete.
* @param manga the manga of the chapters.
* @param source the source of the chapters.
*/
fun deleteChapter(chapter: Chapter, manga: Manga, source: Source) {
provider.findChapterDir(chapter, manga, source)?.delete()
cache.removeChapter(chapter, manga)
fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source) {
queue.remove(chapters)
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.
*/
fun deleteManga(manga: Manga, source: Source) {
queue.remove(manga)
provider.findMangaDir(manga, source)?.delete()
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

@ -37,7 +37,7 @@ internal class DownloadNotifier(private val context: Context) {
*/
var initialQueueSize = 0
set(value) {
if (value != 0){
if (value != 0) {
isSingleChapter = (value == 1)
}
field = value
@ -99,6 +99,10 @@ internal class DownloadNotifier(private val context: Context) {
// Open download manager when clicked
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
isDownloading = true
// Pause action
addAction(R.drawable.ic_av_pause_grey_24dp_img,
context.getString(R.string.action_pause),
NotificationReceiver.pauseDownloadsPendingBroadcast(context))
}
val title = download.manga.title.chop(15)

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))
}
/**
* 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.
*
@ -108,4 +120,4 @@ class DownloadProvider(private val context: Context) {
return DiskUtil.buildValidFilename(chapter.name)
}
}
}

View File

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

View File

@ -21,7 +21,6 @@ import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import rx.subscriptions.CompositeSubscription
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
/**
* This class is the one in charge of downloading chapters.
@ -35,28 +34,25 @@ import uy.kohesive.injekt.injectLazy
* @param context the application context.
* @param provider the downloads directory provider.
* @param cache the downloads cache, used to add the downloads to the cache after their completion.
* @param sourceManager the source manager.
*/
class Downloader(
private val context: Context,
private val provider: DownloadProvider,
private val cache: DownloadCache
private val cache: DownloadCache,
private val sourceManager: SourceManager
) {
/**
* Store for persisting downloads across restarts.
*/
private val store = DownloadStore(context)
private val store = DownloadStore(context, sourceManager)
/**
* Queue where active downloads are kept.
*/
val queue = DownloadQueue(store)
/**
* Source manager.
*/
private val sourceManager: SourceManager by injectLazy()
/**
* Notifier for the downloader state and progress.
*/
@ -382,7 +378,7 @@ class Downloader(
// Else guess from the uri.
?: context.contentResolver.getType(file.uri)
// Else read magic numbers.
?: DiskUtil.findImageMime { file.openInputStream() }
?: ImageUtil.findImageType { file.openInputStream() }?.mime
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
}

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.download.model
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadStore
import eu.kanade.tachiyomi.source.model.Page
import rx.Observable
@ -40,6 +41,14 @@ class DownloadQueue(
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() {
queue.forEach { download ->
download.setStatusSubject(null)

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

View File

@ -38,6 +38,11 @@ class NotificationReceiver : BroadcastReceiver() {
ACTION_DISMISS_NOTIFICATION -> dismissNotification(context, intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1))
// Resume the download service
ACTION_RESUME_DOWNLOADS -> DownloadService.start(context)
// Pause the download service
ACTION_PAUSE_DOWNLOADS -> {
DownloadService.stop(context)
downloadManager.pauseDownloads()
}
// Clear the download queue
ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue(true)
// Show message notification created
@ -159,6 +164,9 @@ class NotificationReceiver : BroadcastReceiver() {
// Called to 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.
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)
}
/**
* 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
*
@ -203,7 +224,7 @@ class NotificationReceiver : BroadcastReceiver() {
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 {
action = ACTION_SHORTCUT_CREATED
}

View File

@ -31,8 +31,6 @@ object PreferenceKeys {
const val imageScaleType = "pref_image_scale_type_key"
const val imageDecoder = "image_decoder"
const val zoomStart = "pref_zoom_start_key"
const val readerTheme = "pref_reader_theme_key"
@ -43,6 +41,8 @@ object PreferenceKeys {
const val readWithTapping = "reader_tap"
const val readWithLongTap = "reader_long_tap"
const val readWithVolumeKeys = "reader_volume_keys"
const val readWithVolumeKeysInverted = "reader_volume_keys_inverted"
@ -55,8 +55,6 @@ object PreferenceKeys {
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 lastUsedCategory = "last_used_category"

View File

@ -59,8 +59,6 @@ class PreferencesHelper(val context: Context) {
fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1)
fun imageDecoder() = rxPrefs.getInteger(Keys.imageDecoder, 0)
fun zoomStart() = rxPrefs.getInteger(Keys.zoomStart, 1)
fun readerTheme() = rxPrefs.getInteger(Keys.readerTheme, 0)
@ -71,6 +69,8 @@ class PreferencesHelper(val context: Context) {
fun readWithTapping() = rxPrefs.getBoolean(Keys.readWithTapping, true)
fun readWithLongTap() = rxPrefs.getBoolean(Keys.readWithLongTap, true)
fun readWithVolumeKeys() = rxPrefs.getBoolean(Keys.readWithVolumeKeys, false)
fun readWithVolumeKeysInverted() = rxPrefs.getBoolean(Keys.readWithVolumeKeysInverted, false)
@ -83,8 +83,6 @@ class PreferencesHelper(val context: Context) {
fun autoUpdateTrack() = prefs.getBoolean(Keys.autoUpdateTrack, true)
fun askUpdateTrack() = prefs.getBoolean(Keys.askUpdateTrack, false)
fun lastUsedCatalogueSource() = rxPrefs.getLong(Keys.lastUsedCatalogueSource, -1)
fun lastUsedCategory() = rxPrefs.getInteger(Keys.lastUsedCategory, 0)

View File

@ -60,12 +60,11 @@ abstract class TrackService(val id: Int) {
get() = !getUsername().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) {
preferences.setTrackCredentials(this, username, password)
}
}

View File

@ -95,9 +95,15 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
// 100 point
POINT_100 -> index.toFloat()
// 5 stars
POINT_5 -> index * 20f
POINT_5 -> when {
index == 0 -> 0f
else -> index * 20f - 10f
}
// Smiley
POINT_3 -> index * 30f
POINT_3 -> when {
index == 0 -> 0f
else -> index * 25f + 10f
}
// 10 point decimal
POINT_10_DECIMAL -> index.toFloat()
else -> throw Exception("Unknown score type")
@ -108,10 +114,13 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
val score = track.score
return when (scorePreference.getOrDefault()) {
POINT_5 -> "${(score / 20).toInt()}"
POINT_5 -> when {
score == 0f -> "0 ★"
else -> "${((score + 10) / 20).toInt()}"
}
POINT_3 -> when {
score == 0f -> "0"
score <= 30 -> "😦"
score <= 35 -> "😦"
score <= 60 -> "😐"
else -> "😊"
}

View File

@ -12,6 +12,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import rx.Observable
import java.util.Calendar
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
@ -90,8 +91,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
fun search(search: String): Observable<List<TrackSearch>> {
val query = """
query Search(${'$'}query: String) {
Page (perPage: 25) {
media(search: ${'$'}query, type: MANGA, format: MANGA) {
Page (perPage: 50) {
media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
id
title {
romaji
@ -102,6 +103,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
type
status
chapters
description
startDate {
year
month
@ -160,6 +162,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
type
status
chapters
description
startDate {
year
month
@ -244,10 +247,18 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
}
fun jsonToALManga(struct: JsonObject): ALManga{
val date = try {
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,
null, struct["type"].asString, struct["status"].asString,
struct["startDate"]["year"].nullString.orEmpty() + struct["startDate"]["month"].nullString.orEmpty()
+ struct["startDate"]["day"].nullString.orEmpty(), struct["chapters"].nullInt ?: 0)
struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString,
date, struct["chapters"].nullInt ?: 0)
}
fun jsonToALUserManga(struct: JsonObject): ALUserManga{

View File

@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.track.anilist
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
@ -17,7 +16,7 @@ data class ALManga(
val description: String?,
val type: String,
val publishing_status: String,
val start_date_fuzzy: String,
val start_date_fuzzy: Long,
val total_chapters: Int) {
fun toTrack() = TrackSearch.create(TrackManager.ANILIST).apply {
@ -29,14 +28,12 @@ data class ALManga(
tracking_url = AnilistApi.mangaUrl(media_id)
publishing_status = this@ALManga.publishing_status
publishing_type = type
if (!start_date_fuzzy.isNullOrBlank()) {
if (start_date_fuzzy != 0L) {
start_date = try {
val inputDf = SimpleDateFormat("yyyyMMdd", Locale.US)
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
val date = inputDf.parse(BuildConfig.BUILD_TIME)
outputDf.format(date)
outputDf.format(start_date_fuzzy)
} catch (e: Exception) {
start_date_fuzzy.orEmpty()
""
}
}
}
@ -55,6 +52,7 @@ data class ALUserManga(
score = score_raw.toFloat()
last_chapter_read = chapters_read
library_id = this@ALUserManga.library_id
total_chapters = manga.total_chapters
}
fun toTrackStatus() = when (list_status) {
@ -63,6 +61,7 @@ data class ALUserManga(
"PAUSED" -> Anilist.ON_HOLD
"DROPPED" -> Anilist.DROPPED
"PLANNING" -> Anilist.PLANNING
"REPEATING" -> Anilist.REPEATING
else -> throw NotImplementedError("Unknown status")
}
}
@ -96,7 +95,7 @@ fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().getOrD
// Smiley
"POINT_3" -> when {
score == 0f -> "0"
score <= 30 -> ":("
score <= 35 -> ":("
score <= 60 -> ":|"
else -> ":)"
}

View File

@ -16,14 +16,32 @@ import rx.Observable
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
private val rest = Retrofit.Builder()
.baseUrl(baseUrl)
.client(client.newBuilder().addInterceptor(interceptor).build())
.client(authClient)
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create()))
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build()
.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> {
return Observable.defer {
// @formatter:off
@ -48,7 +66,6 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
)
)
)
// @formatter:on
rest.addLibManga(jsonObject("data" to data))
.map { json ->
@ -77,12 +94,25 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
}
}
fun search(query: String): Observable<List<TrackSearch>> {
return rest.search(query)
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 ->
val data = json["data"].array
data.map { KitsuManga(it.obj) }
.filter { it.type != "novel" }
val data = json["hits"].array
data.map { KitsuSearchManga(it.obj) }
.filter { it.subType != "novel" }
.map { it.toTrack() }
}
}
@ -143,10 +173,6 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
@Body data: JsonObject
): Observable<JsonObject>
@GET("manga")
fun search(
@Query("filter[text]", encoded = true) query: String
): Observable<JsonObject>
@GET("library-entries")
fun findLibManga(
@ -168,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 {
@FormUrlEncoded
@ -188,6 +224,11 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
private const val baseUrl = "https://kitsu.io/api/edge/"
private const val loginUrl = "https://kitsu.io/api/"
private const val baseMangaUrl = "https://kitsu.io/manga/"
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

View File

@ -6,39 +6,65 @@ import com.google.gson.JsonObject
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import java.text.SimpleDateFormat
import java.util.*
open class KitsuManga(obj: JsonObject) {
class KitsuSearchManga(obj: JsonObject) {
val id by obj.byInt
val canonicalTitle by obj["attributes"].byString
val chapterCount = obj["attributes"].obj.get("chapterCount").nullInt
val type = obj["attributes"].obj.get("mangaType").nullString.orEmpty()
val original by obj["attributes"].obj["posterImage"].byString
val synopsis by obj["attributes"].byString
val startDate = obj["attributes"].obj.get("startDate").nullString.orEmpty()
open val status = obj["attributes"].obj.get("status").nullString.orEmpty()
private val canonicalTitle by obj.byString
private val chapterCount = obj.get("chapterCount").nullInt
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
open fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
media_id = this@KitsuManga.id
media_id = this@KitsuSearchManga.id
title = canonicalTitle
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) {
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
private val ratingTwenty = obj["attributes"].obj.get("ratingTwenty").nullString
val progress by obj["attributes"].byInt
open fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
media_id = libraryId
title = canonicalTitle
total_chapters = chapterCount ?: 0
cover_url = original
summary = synopsis
tracking_url = KitsuApi.mangaUrl(media_id)
publishing_status = this@KitsuManga.status
publishing_status = this@KitsuLibManga.status
publishing_type = type
start_date = startDate.orEmpty()
}
}
class KitsuLibManga(obj: JsonObject, manga: JsonObject) : KitsuManga(manga) {
val libraryId by obj.byInt("id")
override val status by obj["attributes"].byString
val ratingTwenty = obj["attributes"].obj.get("ratingTwenty").nullString
val progress by obj["attributes"].byInt
override fun toTrack() = super.toTrack().apply {
media_id = libraryId // TODO migrate media ids to library ids
start_date = startDate
status = toTrackStatus()
score = ratingTwenty?.let { it.toInt() / 2f } ?: 0f
last_chapter_read = progress

View File

@ -4,10 +4,12 @@ import android.content.Context
import android.graphics.Color
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable
import rx.Observable
import java.net.URI
class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
@ -21,9 +23,13 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
const val DEFAULT_STATUS = READING
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
get() = "MyAnimeList"
@ -56,7 +62,7 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track)
return api.addLibManga(track, getCSRF())
}
override fun update(track: Track): Observable<Track> {
@ -64,11 +70,11 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
track.status = COMPLETED
}
return api.updateLibManga(track)
return api.updateLibManga(track, getCSRF())
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername())
return api.findLibManga(track, getCSRF())
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
@ -83,11 +89,11 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
}
override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query, getUsername())
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track, getUsername())
return api.getLibManga(track, getCSRF())
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
@ -96,10 +102,40 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
}
override fun login(username: String, password: String): Completable {
logout()
return api.login(username, password)
.doOnNext { csrf -> saveCSRF(csrf) }
.doOnNext { saveCredentials(username, password) }
.doOnError { logout() }
.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,7 +1,6 @@
package eu.kanade.tachiyomi.data.track.myanimelist
import android.net.Uri
import android.util.Xml
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
@ -12,191 +11,266 @@ import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.util.selectInt
import eu.kanade.tachiyomi.util.selectText
import okhttp3.*
import org.json.JSONObject
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.parser.Parser
import org.xmlpull.v1.XmlSerializer
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 {
client.newCall(POST(getAddUrl(track), headers, getMangaPostPayload(track)))
client.newCall(POST(url = getAddUrl(), body = getMangaPostPayload(track, csrf)))
.asObservableSuccess()
.map { track }
}
}
fun updateLibManga(track: Track): Observable<Track> {
fun updateLibManga(track: Track, csrf: String): Observable<Track> {
return Observable.defer {
client.newCall(POST(getUpdateUrl(track), headers, getMangaPostPayload(track)))
client.newCall(POST(url = getUpdateUrl(), body = getMangaPostPayload(track, csrf)))
.asObservableSuccess()
.map { track }
}
}
fun search(query: String, username: String): Observable<List<TrackSearch>> {
return if (query.startsWith(PREFIX_MY)) {
val realQuery = query.substring(PREFIX_MY.length).toLowerCase().trim()
getList(username)
.flatMap { Observable.from(it) }
.filter { realQuery in it.title.toLowerCase() }
.toList()
} else {
client.newCall(GET(getSearchUrl(query), headers))
.asObservable()
.map { Jsoup.parse(Parser.unescapeEntities(it.body()!!.string(), false), "", Parser.xmlParser()) }
.flatMap { Observable.from(it.select("entry")) }
.filter { it.select("type").text() != "Novel" }
.map {
TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("title")!!
media_id = it.selectInt("id")
total_chapters = it.selectInt("chapters")
summary = it.selectText("synopsis")!!
cover_url = it.selectText("image")!!
tracking_url = MyanimelistApi.mangaUrl(media_id)
publishing_status = it.selectText("status")!!
publishing_type = it.selectText("type")!!
start_date = it.selectText("start_date")!!
}
}
.toList()
}
}
fun getList(username: String): Observable<List<TrackSearch>> {
return client
.newCall(GET(getListUrl(username), headers))
fun search(query: String): Observable<List<TrackSearch>> {
return client.newCall(GET(getSearchUrl(query)))
.asObservable()
.map { Jsoup.parse(Parser.unescapeEntities(it.body()!!.string(), false), "", Parser.xmlParser()) }
.flatMap { Observable.from(it.select("manga")) }
.map {
.flatMap { response ->
Observable.from(Jsoup.parse(response.consumeBody())
.select("div.js-categories-seasonal.js-block-list.list")
.select("table").select("tbody")
.select("tr").drop(1))
}
.filter { row ->
row.select(TD)[2].text() != "Novel"
}
.map { row ->
TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("series_title")!!
media_id = it.selectInt("series_mangadb_id")
last_chapter_read = it.selectInt("my_read_chapters")
status = it.selectInt("my_status")
score = it.selectInt("my_score").toFloat()
total_chapters = it.selectInt("series_chapters")
cover_url = it.selectText("series_image")!!
tracking_url = MyanimelistApi.mangaUrl(media_id)
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()
}
fun findLibManga(track: Track, username: String): Observable<Track?> {
return getList(username)
private fun getList(csrf: String): Observable<List<TrackSearch>> {
return getListUrl(csrf)
.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()
}
private fun getListXml(url: String): Observable<Document> {
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, username: String): Observable<Track> {
return findLibManga(track, username)
fun getLibManga(track: Track, csrf: String): Observable<Track> {
return findLibManga(track, csrf)
.map { it ?: throw Exception("Could not find manga") }
}
fun login(username: String, password: String): Observable<Response> {
headers = createHeaders(username, password)
return client.newCall(GET(getLoginUrl(), headers))
.asObservable()
.doOnNext { response ->
response.close()
if (response.code() != 200) throw Exception("Login error")
fun login(username: String, password: String): Observable<String> {
return getSessionInfo()
.flatMap { csrf ->
login(username, password, csrf)
}
}
private fun getMangaPostPayload(track: Track): RequestBody {
val data = xml {
element(ENTRY_TAG) {
if (track.last_chapter_read != 0) {
text(CHAPTER_TAG, track.last_chapter_read.toString())
private fun getSessionInfo(): Observable<String> {
return client.newCall(GET(getLoginUrl()))
.asObservable()
.map { response ->
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.media_id}.xml")
.toString()
fun getAddUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/mangalist/add")
.appendPath("${track.media_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 {
const val baseUrl = "https://myanimelist.net"
const val baseMangaUrl = baseUrl + "/manga/"
private const val baseMangaUrl = "$baseUrl/manga/"
private const val baseModifyListUrl = "$baseUrl/ownlist/manga"
fun mangaUrl(remoteId: Int): String {
return baseMangaUrl + remoteId
}
fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId
private val ENTRY_TAG = "entry"
private val CHAPTER_TAG = "chapter"
private val SCORE_TAG = "score"
private val STATUS_TAG = "status"
fun Element.searchTitle() = select("strong").text()!!
const val PREFIX_MY = "my:"
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

@ -1,11 +1,8 @@
package eu.kanade.tachiyomi.network
import com.squareup.duktape.Duktape
import okhttp3.CacheControl
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import okhttp3.*
import java.io.IOException
class CloudflareInterceptor : Interceptor {
@ -23,7 +20,13 @@ class CloudflareInterceptor : Interceptor {
// Check if Cloudflare anti-bot is on
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
@ -48,18 +51,18 @@ class CloudflareInterceptor : Interceptor {
}
val js = operation
.replace(Regex("""a\.value = (.+ \+ t\.length).+"""), "$1")
.replace(Regex("""a\.value = (.+ \+ t\.length(\).toFixed\(10\))?).+"""), "$1")
.replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "")
.replace("t.length", "${domain.length}")
.replace("\n", "")
val result = duktape.evaluate(js) as Double
val result = duktape.evaluate(js) as String
val cloudflareUrl = HttpUrl.parse("${url.scheme()}://$domain/cdn-cgi/l/chk_jschl")!!
.newBuilder()
.addQueryParameter("jschl_vc", challenge)
.addQueryParameter("pass", pass)
.addQueryParameter("jschl_answer", "$result")
.addQueryParameter("jschl_answer", result)
.toString()
val cloudflareHeaders = originalRequest.headers()

View File

@ -60,6 +60,11 @@ class PersistentCookieStore(context: Context) {
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(uri: URI) = get(uri.host)

View File

@ -1,24 +1,22 @@
package eu.kanade.tachiyomi.source
import android.content.Context
import android.net.Uri
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.util.ChapterRecognition
import eu.kanade.tachiyomi.util.DiskUtil
import eu.kanade.tachiyomi.util.RarContentProvider
import eu.kanade.tachiyomi.util.ZipContentProvider
import eu.kanade.tachiyomi.util.EpubFile
import eu.kanade.tachiyomi.util.ImageUtil
import junrar.Archive
import junrar.rarfile.FileHeader
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import rx.Observable
import timber.log.Timber
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.util.*
import java.util.Comparator
import java.util.Locale
import java.util.concurrent.TimeUnit
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
@ -107,15 +105,11 @@ class LocalSource(private val context: Context) : CatalogueSource {
if (thumbnail_url == null) {
val chapters = fetchChapterList(this).toBlocking().first()
if (chapters.isNotEmpty()) {
val uri = fetchPageList(chapters.last()).toBlocking().first().firstOrNull()?.uri
if (uri != null) {
val input = context.contentResolver.openInputStream(uri)
try {
val dest = updateCover(context, this, input)
thumbnail_url = dest?.absolutePath
} catch (e: Exception) {
Timber.e(e)
}
try {
val dest = updateCover(chapters.last(), this)
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)
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
.flatten()
.filter { it.isDirectory || isSupportedFormat(it.extension) }
.filter { it.isDirectory || isSupportedFile(it.extension) }
.map { chapterFile ->
SChapter.create().apply {
url = "${manga.url}/${chapterFile.name}"
@ -150,7 +144,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
ChapterRecognition.parseChapterNumber(this, manga)
}
}
.sortedWith(Comparator<SChapter> { c1, c2 ->
.sortedWith(Comparator { c1, c2 ->
val c = c2.chapter_number.compareTo(c1.chapter_number)
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>> {
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)
for (dir in baseDirs) {
val chapFile = File(dir, chapter.url)
if (!chapFile.exists()) continue
return Observable.just(getLoader(chapFile).load())
return getFormat(chapFile)
}
return Observable.error(Exception("Chapter not found"))
throw Exception("Chapter not found")
}
private fun isSupportedFormat(extension: String): Boolean {
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 {
private fun getFormat(file: File): Format {
val extension = file.extension
return if (file.isDirectory) {
DirectoryLoader(file)
Format.Directory(file)
} else if (extension.equals("zip", true) || extension.equals("cbz", true)) {
ZipLoader(file)
} else if (extension.equals("epub", true)) {
EpubLoader(file)
Format.Zip(file)
} 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 {
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))
override fun getFilterList() = FilterList(OrderBy())
interface Loader {
fun load(): List<Page>
sealed class Format {
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,17 +1,24 @@
package eu.kanade.tachiyomi.source
import android.content.Context
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.english.*
import eu.kanade.tachiyomi.source.online.german.WieManga
import eu.kanade.tachiyomi.source.online.russian.Mangachan
import eu.kanade.tachiyomi.source.online.russian.Mintmanga
import eu.kanade.tachiyomi.source.online.russian.Readmanga
import rx.Observable
open class SourceManager(private val context: Context) {
private val sourcesMap = mutableMapOf<Long, Source>()
private val stubSourcesMap = mutableMapOf<Long, StubSource>()
init {
createInternalSources().forEach { registerSource(it) }
}
@ -20,13 +27,19 @@ open class SourceManager(private val context: Context) {
return sourcesMap[sourceKey]
}
fun getOrStub(sourceKey: Long): Source {
return sourcesMap[sourceKey] ?: stubSourcesMap.getOrPut(sourceKey) {
StubSource(sourceKey)
}
}
fun getOnlineSources() = sourcesMap.values.filterIsInstance<HttpSource>()
fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>()
internal fun registerSource(source: Source, overwrite: Boolean = false) {
if (overwrite || !sourcesMap.containsKey(source.id)) {
sourcesMap.put(source.id, source)
sourcesMap[source.id] = source
}
}
@ -47,4 +60,30 @@ open class SourceManager(private val context: Context) {
Mangasee(),
WieManga()
)
private inner class StubSource(override val id: Long) : Source {
override val name: String
get() = id.toString()
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return Observable.error(getSourceNotInstalledException())
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return Observable.error(getSourceNotInstalledException())
}
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return Observable.error(getSourceNotInstalledException())
}
override fun toString(): String {
return name
}
private fun getSourceNotInstalledException(): Exception {
return Exception(context.getString(R.string.source_not_installed, id.toString()))
}
}
}

View File

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

View File

@ -1,88 +1,15 @@
package eu.kanade.tachiyomi.source.online
import android.net.Uri
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.source.model.Page
import rx.Observable
import uy.kohesive.injekt.injectLazy
// TODO: this should be handled with a different approach.
/**
* Chapter cache.
*/
private val chapterCache: ChapterCache by injectLazy()
/**
* Returns an observable with the page list for a chapter. It tries to return the page list from
* the local cache, otherwise fallbacks to network.
*
* @param chapter the chapter whose page list has to be fetched.
*/
fun HttpSource.fetchPageListFromCacheThenNet(chapter: Chapter): Observable<List<Page>> {
return chapterCache
.getPageListFromCache(chapter)
.onErrorResumeNext { fetchPageList(chapter) }
}
/**
* Returns an observable of the page with the downloaded image.
*
* @param page the page whose source image has to be downloaded.
*/
fun HttpSource.fetchImageFromCacheThenNet(page: Page): Observable<Page> {
return if (page.imageUrl.isNullOrEmpty())
getImageUrl(page).flatMap { getCachedImage(it) }
else
getCachedImage(page)
}
fun HttpSource.getImageUrl(page: Page): Observable<Page> {
page.status = Page.LOAD_PAGE
return fetchImageUrl(page)
.doOnError { page.status = Page.ERROR }
.onErrorReturn { null }
.doOnNext { page.imageUrl = it }
.map { page }
}
/**
* Returns an observable of the page that gets the image from the chapter or fallbacks to
* network and copies it to the cache calling [cacheImage].
*
* @param page the page.
*/
fun HttpSource.getCachedImage(page: Page): Observable<Page> {
val imageUrl = page.imageUrl ?: return Observable.just(page)
return Observable.just(page)
.flatMap {
if (!chapterCache.isImageInCache(imageUrl)) {
cacheImage(page)
} else {
Observable.just(page)
}
}
.doOnNext {
page.uri = Uri.fromFile(chapterCache.getImageFile(imageUrl))
page.status = Page.READY
}
.doOnError { page.status = Page.ERROR }
.onErrorReturn { page }
}
/**
* Returns an observable of the page that downloads the image to [ChapterCache].
*
* @param page the page.
*/
private fun HttpSource.cacheImage(page: Page): Observable<Page> {
page.status = Page.DOWNLOAD_IMAGE
return fetchImage(page)
.doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) }
.map { page }
.doOnError { page.status = Page.ERROR }
.onErrorReturn { null }
.doOnNext { page.imageUrl = it }
.map { page }
}
fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> {

View File

@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@ -29,6 +30,11 @@ class Kissmanga : ParsedHttpSource() {
override val client: OkHttpClient = network.cloudflareClient
override fun headersBuilder(): Headers.Builder {
return Headers.Builder()
.add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; WOW64) Gecko/20100101 Firefox/60")
}
override fun popularMangaSelector() = "table.listing tr:gt(1)"
override fun latestUpdatesSelector() = "table.listing tr:gt(1)"
@ -156,7 +162,7 @@ class Kissmanga : ParsedHttpSource() {
// There are two functions in an inline script needed to decrypt the urls. We find and
// execute them.
var p = Pattern.compile("(.*CryptoJS.*)")
var p = Pattern.compile("(var.*CryptoJS.*)")
var m = p.matcher(body)
while (m.find()) {
it.evaluate(m.group(1))
@ -244,4 +250,4 @@ class Kissmanga : ParsedHttpSource() {
Genre("Yaoi"),
Genre("Yuri")
)
}
}

View File

@ -18,7 +18,7 @@ class Mangasee : ParsedHttpSource() {
override val name = "Mangasee"
override val baseUrl = "http://mangaseeonline.net"
override val baseUrl = "http://mangaseeonline.us"
override val lang = "en"
@ -246,4 +246,4 @@ class Mangasee : ParsedHttpSource() {
Genre("Yuri")
)
}
}

View File

@ -4,6 +4,7 @@ import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
@ -36,7 +37,7 @@ class Mintmanga : ParsedHttpSource() {
override fun popularMangaFromElement(element: Element): SManga {
val manga = SManga.create()
manga.thumbnail_url = element.select("img.lazy").first().attr("data-original")
manga.thumbnail_url = element.select("img.lazy").first()?.attr("data-original")
element.select("h3 > a").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.attr("title")
@ -52,8 +53,25 @@ class Mintmanga : ParsedHttpSource() {
override fun latestUpdatesNextPageSelector() = "a.nextLink"
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val genres = filters.filterIsInstance<Genre>().joinToString("&") { it.id + arrayOf("=", "=in", "=ex")[it.state] }
return GET("$baseUrl/search/advanced?q=$query&$genres", headers)
val url = HttpUrl.parse("$baseUrl/search/advanced")!!.newBuilder()
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
when (filter) {
is GenreList -> filter.state.forEach { genre ->
if (genre.state != Filter.TriState.STATE_IGNORE) {
url.addQueryParameter(genre.id, arrayOf("=", "=in", "=ex")[genre.state])
}
}
is Category -> filter.state.forEach { category ->
if (category.state != Filter.TriState.STATE_IGNORE) {
url.addQueryParameter(category.id, arrayOf("=", "=in", "=ex")[category.state])
}
}
}
}
if (!query.isEmpty()) {
url.addQueryParameter("q", query)
}
return GET(url.toString().replace("=%3D", "="), headers)
}
override fun searchMangaSelector() = popularMangaSelector()
@ -61,7 +79,7 @@ class Mintmanga : ParsedHttpSource() {
override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element)
// max 200 results
override fun searchMangaNextPageSelector() = null
override fun searchMangaNextPageSelector(): Nothing? = null
override fun mangaDetailsParse(document: Document): SManga {
val infoElement = document.select("div.leftContent").first()
@ -95,16 +113,16 @@ class Mintmanga : ParsedHttpSource() {
} else {
chapter.name = urlText
}
chapter.date_upload = element.select("td:eq(1)").first()?.text()?.let {
chapter.date_upload = element.select("td.hidden-xxs").last()?.text()?.let {
SimpleDateFormat("dd/MM/yy", Locale.US).parse(it).time
} ?: 0
return chapter
}
override fun prepareNewChapter(chapter: SChapter, manga: SManga) {
val basic = Regex("""\s([0-9]+)(\s-\s)([0-9]+)\s*""")
val extra = Regex("""\s([0-9]+\sЭкстра)\s*""")
val single = Regex("""\sСингл\s*""")
val basic = Regex("""\s*([0-9]+)(\s-\s)([0-9]+)\s*""")
val extra = Regex("""\s*([0-9]+\sЭкстра)\s*""")
val single = Regex("""\s*Сингл\s*""")
when {
basic.containsMatchIn(chapter.name) -> {
basic.find(chapter.name)?.let {
@ -133,7 +151,12 @@ class Mintmanga : ParsedHttpSource() {
var i = 0
while (m.find()) {
val urlParts = m.group().replace("[\"\']+".toRegex(), "").split(',')
pages.add(Page(i++, "", urlParts[1] + urlParts[0] + urlParts[2]))
val url = if (urlParts[1].isEmpty() && urlParts[2].startsWith("/static/")) {
baseUrl + urlParts[2]
} else {
urlParts[1] + urlParts[0] + urlParts[2]
}
pages.add(Page(i++, "", url))
}
return pages
}
@ -153,13 +176,34 @@ class Mintmanga : ParsedHttpSource() {
}
private class Genre(name: String, val id: String) : Filter.TriState(name)
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
private class Category(categories: List<Genre>) : Filter.Group<Genre>("Category", categories)
/* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")]
* .map(el => `Genre("${el.textContent.trim()}", $"{el.getAttribute('onclick')
/* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")]
* .map(el => `Genre("${el.textContent.trim()}", "${el.getAttribute('onclick')
* .substr(31,el.getAttribute('onclick').length-33)"})`).join(',\n')
* on http://mintmanga.com/search/advanced
*/
override fun getFilterList() = FilterList(
Category(getCategoryList()),
GenreList(getGenreList())
)
private fun getCategoryList() = listOf(
Genre("В цвете", "el_4614"),
Genre("Веб", "el_1355"),
Genre("Выпуск приостановлен", "el_5232"),
Genre("Ёнкома", "el_2741"),
Genre("Комикс западный", "el_1903"),
Genre("Комикс русский", "el_2173"),
Genre("Манхва", "el_1873"),
Genre("Маньхуа", "el_1875"),
Genre("Не Яой", "el_1874"),
Genre("Ранобэ", "el_5688"),
Genre("Сборник", "el_1348")
)
private fun getGenreList() = listOf(
Genre("арт", "el_2220"),
Genre("бара", "el_1353"),
Genre("боевик", "el_1346"),

View File

@ -4,6 +4,7 @@ import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
@ -36,7 +37,7 @@ class Readmanga : ParsedHttpSource() {
override fun popularMangaFromElement(element: Element): SManga {
val manga = SManga.create()
manga.thumbnail_url = element.select("img.lazy").first().attr("data-original")
manga.thumbnail_url = element.select("img.lazy").first()?.attr("data-original")
element.select("h3 > a").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.attr("title")
@ -52,8 +53,25 @@ class Readmanga : ParsedHttpSource() {
override fun latestUpdatesNextPageSelector() = "a.nextLink"
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val genres = filters.filterIsInstance<Genre>().joinToString("&") { it.id + arrayOf("=", "=in", "=ex")[it.state] }
return GET("$baseUrl/search/advanced?q=$query&$genres", headers)
val url = HttpUrl.parse("$baseUrl/search/advanced")!!.newBuilder()
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
when (filter) {
is GenreList -> filter.state.forEach { genre ->
if (genre.state != Filter.TriState.STATE_IGNORE) {
url.addQueryParameter(genre.id, arrayOf("=", "=in", "=ex")[genre.state])
}
}
is Category -> filter.state.forEach { category ->
if (category.state != Filter.TriState.STATE_IGNORE) {
url.addQueryParameter(category.id, arrayOf("=", "=in", "=ex")[category.state])
}
}
}
}
if (!query.isEmpty()) {
url.addQueryParameter("q", query)
}
return GET(url.toString().replace("=%3D", "="), headers)
}
override fun searchMangaSelector() = popularMangaSelector()
@ -61,7 +79,7 @@ class Readmanga : ParsedHttpSource() {
override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element)
// max 200 results
override fun searchMangaNextPageSelector() = null
override fun searchMangaNextPageSelector(): Nothing? = null
override fun mangaDetailsParse(document: Document): SManga {
val infoElement = document.select("div.leftContent").first()
@ -95,16 +113,16 @@ class Readmanga : ParsedHttpSource() {
} else {
chapter.name = urlText
}
chapter.date_upload = element.select("td:eq(1)").first()?.text()?.let {
chapter.date_upload = element.select("td.hidden-xxs").last()?.text()?.let {
SimpleDateFormat("dd/MM/yy", Locale.US).parse(it).time
} ?: 0
return chapter
}
override fun prepareNewChapter(chapter: SChapter, manga: SManga) {
val basic = Regex("""\s([0-9]+)(\s-\s)([0-9]+)\s*""")
val extra = Regex("""\s([0-9]+\sЭкстра)\s*""")
val single = Regex("""\sСингл\s*""")
val basic = Regex("""\s*([0-9]+)(\s-\s)([0-9]+)\s*""")
val extra = Regex("""\s*([0-9]+\sЭкстра)\s*""")
val single = Regex("""\s*Сингл\s*""")
when {
basic.containsMatchIn(chapter.name) -> {
basic.find(chapter.name)?.let {
@ -133,7 +151,12 @@ class Readmanga : ParsedHttpSource() {
var i = 0
while (m.find()) {
val urlParts = m.group().replace("[\"\']+".toRegex(), "").split(',')
pages.add(Page(i++, "", urlParts[1] + urlParts[0] + urlParts[2]))
val url = if (urlParts[1].isEmpty() && urlParts[2].startsWith("/static/")) {
baseUrl + urlParts[2]
} else {
urlParts[1] + urlParts[0] + urlParts[2]
}
pages.add(Page(i++, "", url))
}
return pages
}
@ -153,6 +176,8 @@ class Readmanga : ParsedHttpSource() {
}
private class Genre(name: String, val id: String) : Filter.TriState(name)
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
private class Category(categories: List<Genre>) : Filter.Group<Genre>("Category", categories)
/* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")]
* .map(el => `Genre("${el.textContent.trim()}", $"{el.getAttribute('onclick')
@ -160,6 +185,23 @@ class Readmanga : ParsedHttpSource() {
* on http://readmanga.me/search/advanced
*/
override fun getFilterList() = FilterList(
Category(getCategoryList()),
GenreList(getGenreList())
)
private fun getCategoryList() = listOf(
Genre("В цвете", "el_7290"),
Genre("Веб", "el_2160"),
Genre("Выпуск приостановлен", "el_8033"),
Genre("Ёнкома", "el_2161"),
Genre("Комикс западный", "el_3515"),
Genre("Манхва", "el_3001"),
Genre("Маньхуа", "el_3002"),
Genre("Ранобэ", "el_8575"),
Genre("Сборник", "el_2157")
)
private fun getGenreList() = listOf(
Genre("арт", "el_5685"),
Genre("боевик", "el_2155"),
Genre("боевые искусства", "el_2143"),

View File

@ -42,9 +42,8 @@ class CatalogueGridHolder(private val view: View, private val adapter: FlexibleA
.load(manga)
.diskCacheStrategy(DiskCacheStrategy.DATA)
.centerCrop()
.skipMemoryCache(true)
.placeholder(android.R.color.transparent)
.into(StateImageViewTarget(thumbnail, progress))
}
}
}
}

View File

@ -44,7 +44,6 @@ class CatalogueListHolder(private val view: View, adapter: FlexibleAdapter<*>) :
.centerCrop()
.circleCrop()
.dontAnimate()
.skipMemoryCache(true)
.placeholder(android.R.color.transparent)
.into(thumbnail)
}

View File

@ -45,8 +45,11 @@ class CatalogueSearchHolder(view: View, val adapter: CatalogueSearchAdapter) :
val source = item.source
val results = item.results
// Set Title witch country code if available.
title.text = if (!source.lang.isEmpty()) "${source.name} (${source.lang})" else source.name
val titlePrefix = if (item.highlighted) "" else ""
val langSuffix = if (source.lang.isNotEmpty()) " (${source.lang})" else ""
// Set Title with country code if available.
title.text = titlePrefix + source.name + langSuffix
when {
results == null -> {
@ -93,5 +96,4 @@ class CatalogueSearchHolder(view: View, val adapter: CatalogueSearchAdapter) :
return null
}
}

View File

@ -9,9 +9,11 @@ import eu.kanade.tachiyomi.source.CatalogueSource
/**
* Item that contains search result information.
*
* @param source contains information about search result.
* @param source the source for the search results.
* @param results the search results.
* @param highlighted whether this search item should be highlighted/marked in the catalogue search view.
*/
class CatalogueSearchItem(val source: CatalogueSource, val results: List<CatalogueSearchCardItem>?)
class CatalogueSearchItem(val source: CatalogueSource, val results: List<CatalogueSearchCardItem>?, val highlighted: Boolean = false)
: AbstractFlexibleItem<CatalogueSearchHolder>() {
/**
@ -61,4 +63,4 @@ class CatalogueSearchItem(val source: CatalogueSource, val results: List<Catalog
return source.id.toInt()
}
}
}

View File

@ -98,7 +98,14 @@ open class CatalogueSearchPresenter(
}
/**
* Initiates a search for mnaga per catalogue.
* Creates a catalogue search item
*/
protected open fun createCatalogueSearchItem(source: CatalogueSource, results: List<CatalogueSearchCardItem>?): CatalogueSearchItem {
return CatalogueSearchItem(source, results)
}
/**
* Initiates a search for manga per catalogue.
*
* @param query query on which to search.
*/
@ -113,7 +120,7 @@ open class CatalogueSearchPresenter(
initializeFetchImageSubscription()
// Create items with the initial state
val initialItems = sources.map { CatalogueSearchItem(it, null) }
val initialItems = sources.map { createCatalogueSearchItem(it, null) }
var items = initialItems
fetchSourcesSubscription?.unsubscribe()
@ -125,7 +132,7 @@ open class CatalogueSearchPresenter(
.map { it.mangas.take(10) } // Get at most 10 manga from search result.
.map { it.map { networkToLocalManga(it, source.id) } } // Convert to local manga.
.doOnNext { fetchImage(it, source) } // Load manga covers.
.map { CatalogueSearchItem(source, it.map { CatalogueSearchCardItem(it) }) }
.map { createCatalogueSearchItem(source, it.map { CatalogueSearchCardItem(it) }) }
}, 5)
.observeOn(AndroidSchedulers.mainThread())
// Update matching source with the obtained results
@ -212,4 +219,4 @@ open class CatalogueSearchPresenter(
}
return localManga
}
}
}

View File

@ -8,7 +8,7 @@ import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import kotlinx.android.synthetic.main.extension_card_header.*
class ExtensionGroupHolder(view: View, adapter: FlexibleAdapter<*>) :
BaseFlexibleViewHolder(view, adapter, true) {
BaseFlexibleViewHolder(view, adapter) {
@SuppressLint("SetTextI18n")
fun bind(item: ExtensionGroupItem) {

View File

@ -25,7 +25,9 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.IOException
import java.io.InputStream
import java.util.*
import java.util.ArrayList
import java.util.Collections
import java.util.Comparator
/**
* Class containing library information.
@ -113,9 +115,6 @@ class LibraryPresenter(
val filterCompleted = preferences.filterCompleted().getOrDefault()
val filterFn: (LibraryItem) -> Boolean = f@ { item ->
// Filter out manga without source.
sourceManager.get(item.manga.source) ?: return@f false
// Filter when there isn't unread chapters.
if (filterUnread && item.manga.unread == 0) {
return@f false
@ -127,6 +126,10 @@ class LibraryPresenter(
// Filter when there are no downloads.
if (filterDownloaded) {
// Local manga are always downloaded
if (item.manga.source == LocalSource.ID) {
return@f true
}
// Don't bother with directory checking if download count has been set.
if (item.downloadCount != -1) {
return@f item.downloadCount > 0
@ -197,8 +200,8 @@ class LibraryPresenter(
manga1TotalChapter.compareTo(mange2TotalChapter)
}
LibrarySort.SOURCE -> {
val source1Name = sourceManager.get(i1.manga.source)?.name ?: ""
val source2Name = sourceManager.get(i2.manga.source)?.name ?: ""
val source1Name = sourceManager.getOrStub(i1.manga.source).name
val source2Name = sourceManager.getOrStub(i2.manga.source).name
source1Name.compareTo(source2Name)
}
else -> throw Exception("Unknown sorting mode")

View File

@ -50,6 +50,7 @@ class MainActivity : BaseActivity() {
setTheme(when (preferences.theme()) {
2 -> R.style.Theme_Tachiyomi_Dark
3 -> R.style.Theme_Tachiyomi_Amoled
4 -> R.style.Theme_Tachiyomi_DarkBlue
else -> R.style.Theme_Tachiyomi
})
super.onCreate(savedInstanceState)

View File

@ -34,7 +34,7 @@ import kotlinx.android.synthetic.main.manga_controller.*
import rx.Subscription
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.*
import java.util.Date
class MangaController : RxController, TabbedController {
@ -44,7 +44,7 @@ class MangaController : RxController, TabbedController {
}) {
this.manga = manga
if (manga != null) {
source = Injekt.get<SourceManager>().get(manga.source)
source = Injekt.get<SourceManager>().getOrStub(manga.source)
}
}

View File

@ -9,6 +9,7 @@ import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
@ -20,7 +21,7 @@ import rx.schedulers.Schedulers
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.*
import java.util.Date
/**
* Presenter of [ChaptersController].
@ -179,7 +180,7 @@ class ChaptersPresenter(
observable = observable.filter { it.read }
}
if (onlyDownloaded()) {
observable = observable.filter { it.isDownloaded }
observable = observable.filter { it.isDownloaded || it.manga.source == LocalSource.ID }
}
if (onlyBookmarked()) {
observable = observable.filter { it.bookmark }
@ -271,9 +272,8 @@ class ChaptersPresenter(
* @param chapters the list of chapters to delete.
*/
fun deleteChapters(chapters: List<ChapterItem>) {
Observable.from(chapters)
.doOnNext { deleteChapter(it) }
.toList()
Observable.just(chapters)
.doOnNext { deleteChaptersInternal(chapters) }
.doOnNext { if (onlyDownloaded()) refreshChapters() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
@ -283,14 +283,15 @@ class ChaptersPresenter(
}
/**
* Deletes a chapter from disk. This method is called in a background thread.
* @param chapter the chapter to delete.
* Deletes a list of chapters from disk. This method is called in a background thread.
* @param chapters the chapters to delete.
*/
private fun deleteChapter(chapter: ChapterItem) {
downloadManager.queue.remove(chapter)
downloadManager.deleteChapter(chapter, manga, source)
chapter.status = Download.NOT_DOWNLOADED
chapter.download = null
private fun deleteChaptersInternal(chapters: List<ChapterItem>) {
downloadManager.deleteChapters(chapters, manga, source)
chapters.forEach {
it.status = Download.NOT_DOWNLOADED
it.download = null
}
}
/**

View File

@ -311,10 +311,9 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
val source = presenter.source as? HttpSource ?: return
try {
val url = source.mangaDetailsRequest(presenter.manga).url().toString()
val title = presenter.manga.title
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, context.getString(R.string.share_text, title, url))
putExtra(Intent.EXTRA_TEXT, url)
}
startActivity(Intent.createChooser(intent, context.getString(R.string.action_share)))
} catch (e: Exception) {
@ -356,8 +355,9 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
/**
* Update swipe refresh to start showing refresh in progress spinner.
*/
fun onFetchMangaError() {
fun onFetchMangaError(error: Throwable) {
setRefreshing(false)
activity?.toast(error.message)
}
/**

View File

@ -90,9 +90,7 @@ class MangaInfoPresenter(
.doOnNext { sendMangaToView() }
.subscribeFirst({ view, _ ->
view.onFetchMangaDone()
}, { view, _ ->
view.onFetchMangaError()
})
}, MangaInfoController::onFetchMangaError)
}
/**

View File

@ -71,7 +71,7 @@ class MigrationPresenter(
private fun findSourcesWithManga(library: List<Manga>): List<SourceItem> {
val header = SelectionHeader()
return library.map { it.source }.toSet()
.mapNotNull { if (it != LocalSource.ID) sourceManager.get(it) else null }
.mapNotNull { if (it != LocalSource.ID) sourceManager.getOrStub(it) else null }
.map { SourceItem(it, header) }
}

View File

@ -2,6 +2,8 @@ package eu.kanade.tachiyomi.ui.migration
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchCardItem
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchItem
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchPresenter
class SearchPresenter(
@ -10,8 +12,13 @@ class SearchPresenter(
) : CatalogueSearchPresenter(initialQuery) {
override fun getEnabledSources(): List<CatalogueSource> {
// Filter out the source of the selected manga
// Put the source of the selected manga at the top
return super.getEnabledSources()
.filterNot { it.id == manga.source }
.sortedByDescending { it.id == manga.source }
}
}
override fun createCatalogueSearchItem(source: CatalogueSource, results: List<CatalogueSearchCardItem>?): CatalogueSearchItem {
//Set the catalogue search item as highlighted if the source matches that of the selected manga
return CatalogueSearchItem(source, results, source.id == manga.source)
}
}

View File

@ -0,0 +1,36 @@
package eu.kanade.tachiyomi.ui.reader
import eu.kanade.tachiyomi.data.database.models.Chapter
/**
* Load strategy using the source order. This is the default ordering.
*/
class ChapterLoadBySource {
fun get(allChapters: List<Chapter>): List<Chapter> {
return allChapters.sortedByDescending { it.source_order }
}
}
/**
* Load strategy using unique chapter numbers with same scanlator preference.
*/
class ChapterLoadByNumber {
fun get(allChapters: List<Chapter>, selectedChapter: Chapter): List<Chapter> {
val chapters = mutableListOf<Chapter>()
val chaptersByNumber = allChapters.groupBy { it.chapter_number }
for ((number, chaptersForNumber) in chaptersByNumber) {
val preferredChapter = when {
// Make sure the selected chapter is always present
number == selectedChapter.chapter_number -> selectedChapter
// If there is only one chapter for this number, use it
chaptersForNumber.size == 1 -> chaptersForNumber.first()
// Prefer a chapter of the same scanlator as the selected
else -> chaptersForNumber.find { it.scanlator == selectedChapter.scanlator }
?: chaptersForNumber.first()
}
chapters.add(preferredChapter)
}
return chapters.sortedBy { it.chapter_number }
}
}

View File

@ -1,140 +0,0 @@
package eu.kanade.tachiyomi.ui.reader
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.fetchImageFromCacheThenNet
import eu.kanade.tachiyomi.source.online.fetchPageListFromCacheThenNet
import eu.kanade.tachiyomi.util.plusAssign
import rx.Observable
import rx.schedulers.Schedulers
import rx.subscriptions.CompositeSubscription
import timber.log.Timber
import java.util.concurrent.PriorityBlockingQueue
import java.util.concurrent.atomic.AtomicInteger
class ChapterLoader(
private val downloadManager: DownloadManager,
private val manga: Manga,
private val source: Source
) {
private val queue = PriorityBlockingQueue<PriorityPage>()
private val subscriptions = CompositeSubscription()
fun init() {
prepareOnlineReading()
}
fun restart() {
cleanup()
init()
}
fun cleanup() {
subscriptions.clear()
queue.clear()
}
private fun prepareOnlineReading() {
if (source !is HttpSource) return
subscriptions += Observable.defer { Observable.just(queue.take().page) }
.filter { it.status == Page.QUEUE }
.concatMap { source.fetchImageFromCacheThenNet(it) }
.repeat()
.subscribeOn(Schedulers.io())
.subscribe({
}, { error ->
if (error !is InterruptedException) {
Timber.e(error)
}
})
}
fun loadChapter(chapter: ReaderChapter) = Observable.just(chapter)
.flatMap {
if (chapter.pages == null)
retrievePageList(chapter)
else
Observable.just(chapter.pages!!)
}
.doOnNext { pages ->
if (pages.isEmpty()) {
throw Exception("Page list is empty")
}
// Now that the number of pages is known, fix the requested page if the last one
// was requested.
if (chapter.requestedPage == -1) {
chapter.requestedPage = pages.lastIndex
}
loadPages(chapter)
}
.map { chapter }
private fun retrievePageList(chapter: ReaderChapter) = Observable.just(chapter)
.flatMap {
// Check if the chapter is downloaded.
chapter.isDownloaded = downloadManager.isChapterDownloaded(chapter, manga, true)
if (chapter.isDownloaded) {
// Fetch the page list from disk.
downloadManager.buildPageList(source, manga, chapter)
} else {
(source as? HttpSource)?.fetchPageListFromCacheThenNet(chapter)
?: source.fetchPageList(chapter)
}
}
.doOnNext { pages ->
chapter.pages = pages
pages.forEach { it.chapter = chapter }
}
private fun loadPages(chapter: ReaderChapter) {
if (!chapter.isDownloaded) {
loadOnlinePages(chapter)
}
}
private fun loadOnlinePages(chapter: ReaderChapter) {
chapter.pages?.let { pages ->
val startPage = chapter.requestedPage
val pagesToLoad = if (startPage == 0)
pages
else
pages.drop(startPage)
pagesToLoad.forEach { queue.offer(PriorityPage(it, 0)) }
}
}
fun loadPriorizedPage(page: Page) {
queue.offer(PriorityPage(page, 1))
}
fun retryPage(page: Page) {
queue.offer(PriorityPage(page, 2))
}
private data class PriorityPage(val page: Page, val priority: Int): Comparable<PriorityPage> {
companion object {
private val idGenerator = AtomicInteger()
}
private val identifier = idGenerator.incrementAndGet()
override fun compareTo(other: PriorityPage): Int {
val p = other.priority.compareTo(priority)
return if (p != 0) p else identifier.compareTo(other.identifier)
}
}
}

View File

@ -12,8 +12,13 @@ import android.text.style.ScaleXSpan
import android.util.AttributeSet
import android.widget.TextView
class PageIndicatorTextView(context: Context, attrs: AttributeSet? = null) :
AppCompatTextView(context, attrs) {
/**
* Page indicator found at the bottom of the reader
*/
class PageIndicatorTextView(
context: Context,
attrs: AttributeSet? = null
) : AppCompatTextView(context, attrs) {
private val fillColor = Color.rgb(235, 235, 235)
private val strokeColor = Color.rgb(45, 45, 45)
@ -53,4 +58,4 @@ class PageIndicatorTextView(context: Context, attrs: AttributeSet? = null) :
isAccessible = true
}!!
}
}
}

View File

@ -1,13 +0,0 @@
package eu.kanade.tachiyomi.ui.reader
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.source.model.Page
class ReaderChapter(c: Chapter) : Chapter by c {
@Transient var pages: List<Page>? = null
var isDownloaded: Boolean = false
var requestedPage: Int = 0
}

View File

@ -1,19 +1,19 @@
package eu.kanade.tachiyomi.ui.reader
import android.app.Dialog
import android.graphics.Color
import android.os.Bundle
import android.support.annotation.ColorInt
import android.support.v4.app.DialogFragment
import android.support.design.widget.BottomSheetBehavior
import android.support.design.widget.BottomSheetDialog
import android.view.View
import android.view.ViewGroup
import android.widget.SeekBar
import com.afollestad.materialdialogs.MaterialDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.util.plusAssign
import eu.kanade.tachiyomi.widget.SimpleSeekBarListener
import kotlinx.android.synthetic.main.reader_custom_filter_dialog.view.*
import kotlinx.android.synthetic.main.reader_color_filter.*
import kotlinx.android.synthetic.main.reader_color_filter_sheet.*
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.subscriptions.CompositeSubscription
@ -21,33 +21,18 @@ import uy.kohesive.injekt.injectLazy
import java.util.concurrent.TimeUnit
/**
* Custom dialog which can be used to set overlay value's
* Color filter sheet to toggle custom filter and brightness overlay.
*/
class ReaderCustomFilterDialog : DialogFragment() {
class ReaderColorFilterSheet(activity: ReaderActivity) : BottomSheetDialog(activity) {
companion object {
/** Integer mask of alpha value **/
private const val ALPHA_MASK: Long = 0xFF000000
/** Integer mask of red value **/
private const val RED_MASK: Long = 0x00FF0000
/** Integer mask of green value **/
private const val GREEN_MASK: Long = 0x0000FF00
/** Integer mask of blue value **/
private const val BLUE_MASK: Long = 0x000000FF
}
/**
* Provides operations to manage preferences
*/
private val preferences by injectLazy<PreferencesHelper>()
private var behavior: BottomSheetBehavior<*>? = null
/**
* Subscription used for filter overlay
* Subscriptions used for this dialog
*/
private lateinit var subscriptions: CompositeSubscription
private val subscriptions = CompositeSubscription()
/**
* Subscription used for custom brightness overlay
@ -59,34 +44,18 @@ class ReaderCustomFilterDialog : DialogFragment() {
*/
private var customFilterColorSubscription: Subscription? = null
/**
* This method will be called after onCreate(Bundle)
* @param savedState The last saved instance state of the Fragment.
*/
override fun onCreateDialog(savedState: Bundle?): Dialog {
val dialog = MaterialDialog.Builder(activity!!)
.customView(R.layout.reader_custom_filter_dialog, false)
.positiveText(android.R.string.ok)
.build()
init {
val view = activity.layoutInflater.inflate(R.layout.reader_color_filter_sheet, null)
setContentView(view)
subscriptions = CompositeSubscription()
onViewCreated(dialog.view, savedState)
behavior = BottomSheetBehavior.from(view.parent as ViewGroup)
return dialog
}
/**
* Called immediately after onCreateView()
* @param view The View returned by onCreateDialog.
* @param savedInstanceState If non-null, this fragment is being re-constructed
*/
override fun onViewCreated(view: View, savedInstanceState: Bundle?) = with(view) {
// Initialize subscriptions.
subscriptions += preferences.colorFilter().asObservable()
.subscribe { setColorFilter(it, view) }
.subscribe { setColorFilter(it, view) }
subscriptions += preferences.customBrightness().asObservable()
.subscribe { setCustomBrightness(it, view) }
.subscribe { setCustomBrightness(it, view) }
// Get color and update values
val color = preferences.colorFilterValue().getOrDefault()
@ -154,7 +123,19 @@ class ReaderCustomFilterDialog : DialogFragment() {
}
}
})
}
override fun onStart() {
super.onStart()
behavior?.skipCollapsed = true
behavior?.state = BottomSheetBehavior.STATE_EXPANDED
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
subscriptions.unsubscribe()
customBrightnessSubscription = null
customFilterColorSubscription = null
}
/**
@ -210,8 +191,8 @@ class ReaderCustomFilterDialog : DialogFragment() {
private fun setCustomBrightness(enabled: Boolean, view: View) {
if (enabled) {
customBrightnessSubscription = preferences.customBrightnessValue().asObservable()
.sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribe { setCustomBrightnessValue(it, view) }
.sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribe { setCustomBrightnessValue(it, view) }
subscriptions.add(customBrightnessSubscription)
} else {
@ -249,13 +230,13 @@ class ReaderCustomFilterDialog : DialogFragment() {
private fun setColorFilter(enabled: Boolean, view: View) {
if (enabled) {
customFilterColorSubscription = preferences.colorFilterValue().asObservable()
.sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribe { setColorFilterValue(it, view) }
.sample(100, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribe { setColorFilterValue(it, view) }
subscriptions.add(customFilterColorSubscription)
} else {
customFilterColorSubscription?.let { subscriptions.remove(it) }
view.color_overlay.visibility = View.GONE
color_overlay.visibility = View.GONE
}
setColorFilterSeekBar(enabled, view)
}
@ -319,12 +300,18 @@ class ReaderCustomFilterDialog : DialogFragment() {
return color and 0xFF
}
/**
* Called when dialog is dismissed
*/
override fun onDestroyView() {
subscriptions.unsubscribe()
super.onDestroyView()
private companion object {
/** Integer mask of alpha value **/
const val ALPHA_MASK: Long = 0xFF000000
/** Integer mask of red value **/
const val RED_MASK: Long = 0x00FF0000
/** Integer mask of green value **/
const val GREEN_MASK: Long = 0x0000FF00
/** Integer mask of blue value **/
const val BLUE_MASK: Long = 0x000000FF
}
}
}

View File

@ -1,6 +0,0 @@
package eu.kanade.tachiyomi.ui.reader
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
class ReaderEvent(val manga: Manga, val chapter: Chapter)

View File

@ -0,0 +1,74 @@
package eu.kanade.tachiyomi.ui.reader
import android.os.Bundle
import android.support.design.widget.BottomSheetDialog
import android.view.ViewGroup
import com.afollestad.materialdialogs.MaterialDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import kotlinx.android.synthetic.main.reader_page_sheet.*
/**
* Sheet to show when a page is long clicked.
*/
class ReaderPageSheet(
private val activity: ReaderActivity,
private val page: ReaderPage
) : BottomSheetDialog(activity) {
/**
* View used on this sheet.
*/
private val view = activity.layoutInflater.inflate(R.layout.reader_page_sheet, null)
init {
setContentView(view)
set_as_cover_layout.setOnClickListener { setAsCover() }
share_layout.setOnClickListener { share() }
save_layout.setOnClickListener { save() }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val width = context.resources.getDimensionPixelSize(R.dimen.bottom_sheet_width)
if (width > 0) {
window?.setLayout(width, ViewGroup.LayoutParams.MATCH_PARENT)
}
}
/**
* Sets the image of this page as the cover of the manga.
*/
private fun setAsCover() {
if (page.status != Page.READY) return
MaterialDialog.Builder(activity)
.content(activity.getString(R.string.confirm_set_image_as_cover))
.positiveText(android.R.string.yes)
.negativeText(android.R.string.no)
.onPositive { _, _ ->
activity.setAsCover(page)
dismiss()
}
.show()
}
/**
* Shares the image of this page with external apps.
*/
private fun share() {
activity.shareImage(page)
dismiss()
}
/**
* Saves the image of this page on external storage.
*/
private fun save() {
activity.saveImage(page)
dismiss()
}
}

View File

@ -0,0 +1,45 @@
package eu.kanade.tachiyomi.ui.reader
import android.content.Context
import android.graphics.Canvas
import android.support.v7.widget.AppCompatSeekBar
import android.util.AttributeSet
import android.view.MotionEvent
/**
* Seekbar to show current chapter progress.
*/
class ReaderSeekBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : AppCompatSeekBar(context, attrs) {
/**
* Whether the seekbar should draw from right to left.
*/
var isRTL = false
/**
* Draws the seekbar, translating the canvas if using a right to left reader.
*/
override fun draw(canvas: Canvas) {
if (isRTL) {
val px = width / 2f
val py = height / 2f
canvas.scale(-1f, 1f, px, py)
}
super.draw(canvas)
}
/**
* Handles touch events, translating coordinates if using a right to left reader.
*/
override fun onTouchEvent(event: MotionEvent): Boolean {
if (isRTL) {
event.setLocation(width - event.x, event.y)
}
return super.onTouchEvent(event)
}
}

View File

@ -1,119 +0,0 @@
package eu.kanade.tachiyomi.ui.reader
import android.app.Dialog
import android.os.Bundle
import android.support.v4.app.DialogFragment
import android.view.View
import com.afollestad.materialdialogs.MaterialDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.util.plusAssign
import eu.kanade.tachiyomi.util.visibleIf
import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener
import kotlinx.android.synthetic.main.reader_settings_dialog.view.*
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.injectLazy
import java.util.concurrent.TimeUnit.MILLISECONDS
class ReaderSettingsDialog : DialogFragment() {
private val preferences by injectLazy<PreferencesHelper>()
private lateinit var subscriptions: CompositeSubscription
override fun onCreateDialog(savedState: Bundle?): Dialog {
val dialog = MaterialDialog.Builder(activity!!)
.title(R.string.label_settings)
.customView(R.layout.reader_settings_dialog, true)
.positiveText(android.R.string.ok)
.build()
subscriptions = CompositeSubscription()
onViewCreated(dialog.view, savedState)
return dialog
}
override fun onViewCreated(view: View, savedState: Bundle?) = with(view) {
viewer.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
subscriptions += Observable.timer(250, MILLISECONDS, AndroidSchedulers.mainThread())
.subscribe {
val readerActivity = activity as? ReaderActivity
if (readerActivity != null) {
readerActivity.presenter.updateMangaViewer(position)
readerActivity.recreate()
}
}
}
viewer.setSelection((activity as ReaderActivity).presenter.manga.viewer, false)
rotation_mode.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
subscriptions += Observable.timer(250, MILLISECONDS)
.subscribe {
preferences.rotation().set(position + 1)
}
}
rotation_mode.setSelection(preferences.rotation().getOrDefault() - 1, false)
scale_type.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
preferences.imageScaleType().set(position + 1)
}
scale_type.setSelection(preferences.imageScaleType().getOrDefault() - 1, false)
zoom_start.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
preferences.zoomStart().set(position + 1)
}
zoom_start.setSelection(preferences.zoomStart().getOrDefault() - 1, false)
image_decoder.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
preferences.imageDecoder().set(position)
}
image_decoder.setSelection(preferences.imageDecoder().getOrDefault(), false)
background_color.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
preferences.readerTheme().set(position)
}
background_color.setSelection(preferences.readerTheme().getOrDefault(), false)
show_page_number.isChecked = preferences.showPageNumber().getOrDefault()
show_page_number.setOnCheckedChangeListener { _, isChecked ->
preferences.showPageNumber().set(isChecked)
}
fullscreen.isChecked = preferences.fullscreen().getOrDefault()
fullscreen.setOnCheckedChangeListener { _, isChecked ->
preferences.fullscreen().set(isChecked)
}
crop_borders.isChecked = preferences.cropBorders().getOrDefault()
crop_borders.setOnCheckedChangeListener { _, isChecked ->
preferences.cropBorders().set(isChecked)
}
crop_borders_webtoon.isChecked = preferences.cropBordersWebtoon().getOrDefault()
crop_borders_webtoon.setOnCheckedChangeListener { _, isChecked ->
preferences.cropBordersWebtoon().set(isChecked)
}
val readerActivity = activity as? ReaderActivity
val isWebtoonViewer = if (readerActivity != null) {
val mangaViewer = readerActivity.presenter.manga.viewer
val viewer = if (mangaViewer == 0) preferences.defaultViewer() else mangaViewer
viewer == ReaderActivity.WEBTOON
} else {
false
}
crop_borders.visibleIf { !isWebtoonViewer }
crop_borders_webtoon.visibleIf { isWebtoonViewer }
}
override fun onDestroyView() {
subscriptions.unsubscribe()
super.onDestroyView()
}
}

View File

@ -0,0 +1,105 @@
package eu.kanade.tachiyomi.ui.reader
import android.os.Bundle
import android.support.design.widget.BottomSheetDialog
import android.support.v4.widget.NestedScrollView
import android.widget.CompoundButton
import android.widget.Spinner
import com.f2prateek.rx.preferences.Preference
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerViewer
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
import eu.kanade.tachiyomi.util.visible
import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener
import kotlinx.android.synthetic.main.reader_settings_sheet.*
import uy.kohesive.injekt.injectLazy
/**
* Sheet to show reader and viewer preferences.
*/
class ReaderSettingsSheet(private val activity: ReaderActivity) : BottomSheetDialog(activity) {
/**
* Preferences helper.
*/
private val preferences by injectLazy<PreferencesHelper>()
init {
// Use activity theme for this layout
val view = activity.layoutInflater.inflate(R.layout.reader_settings_sheet, null)
val scroll = NestedScrollView(activity)
scroll.addView(view)
setContentView(scroll)
}
/**
* Called when the sheet is created. It initializes the listeners and values of the preferences.
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
initGeneralPreferences()
when (activity.viewer) {
is PagerViewer -> initPagerPreferences()
is WebtoonViewer -> initWebtoonPreferences()
}
}
/**
* Init general reader preferences.
*/
private fun initGeneralPreferences() {
viewer.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
activity.presenter.setMangaViewer(position)
}
viewer.setSelection(activity.presenter.manga?.viewer ?: 0, false)
rotation_mode.bindToPreference(preferences.rotation(), 1)
background_color.bindToPreference(preferences.readerTheme())
show_page_number.bindToPreference(preferences.showPageNumber())
fullscreen.bindToPreference(preferences.fullscreen())
keepscreen.bindToPreference(preferences.keepScreenOn())
long_tap.bindToPreference(preferences.readWithLongTap())
}
/**
* Init the preferences for the pager reader.
*/
private fun initPagerPreferences() {
pager_prefs_group.visible()
scale_type.bindToPreference(preferences.imageScaleType(), 1)
zoom_start.bindToPreference(preferences.zoomStart(), 1)
crop_borders.bindToPreference(preferences.cropBorders())
page_transitions.bindToPreference(preferences.pageTransitions())
}
/**
* Init the preferences for the webtoon reader.
*/
private fun initWebtoonPreferences() {
webtoon_prefs_group.visible()
crop_borders_webtoon.bindToPreference(preferences.cropBordersWebtoon())
}
/**
* Binds a checkbox or switch view with a boolean preference.
*/
private fun CompoundButton.bindToPreference(pref: Preference<Boolean>) {
isChecked = pref.getOrDefault()
setOnCheckedChangeListener { _, isChecked -> pref.set(isChecked) }
}
/**
* Binds a spinner to an int preference with an optional offset for the value.
*/
private fun Spinner.bindToPreference(pref: Preference<Int>, offset: Int = 0) {
onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
pref.set(position + offset)
}
setSelection(pref.getOrDefault() - offset, false)
}
}

View File

@ -16,6 +16,7 @@ import java.io.File
* Class used to show BigPictureStyle notifications
*/
class SaveImageNotifier(private val context: Context) {
/**
* Notification builder.
*/
@ -35,12 +36,12 @@ class SaveImageNotifier(private val context: Context) {
*/
fun onComplete(file: File) {
val bitmap = GlideApp.with(context)
.asBitmap()
.load(file)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
.submit(720, 1280)
.get()
.asBitmap()
.load(file)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
.submit(720, 1280)
.get()
if (bitmap != null) {
showCompleteNotification(file, bitmap)

View File

@ -0,0 +1,84 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import rx.Completable
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import timber.log.Timber
/**
* Loader used to retrieve the [PageLoader] for a given chapter.
*/
class ChapterLoader(
private val downloadManager: DownloadManager,
private val manga: Manga,
private val source: Source
) {
/**
* Returns a completable that assigns the page loader and loads the its pages. It just
* completes if the chapter is already loaded.
*/
fun loadChapter(chapter: ReaderChapter): Completable {
if (chapter.state is ReaderChapter.State.Loaded) {
return Completable.complete()
}
return Observable.just(chapter)
.doOnNext { chapter.state = ReaderChapter.State.Loading }
.observeOn(Schedulers.io())
.flatMap {
Timber.d("Loading pages for ${chapter.chapter.name}")
val loader = getPageLoader(it)
chapter.pageLoader = loader
loader.getPages().take(1).doOnNext { pages ->
pages.forEach { it.chapter = chapter }
}
}
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { pages ->
if (pages.isEmpty()) {
throw Exception("Page list is empty")
}
chapter.state = ReaderChapter.State.Loaded(pages)
// If the chapter is partially read, set the starting page to the last the user read
// otherwise use the requested page.
if (!chapter.chapter.read) {
chapter.requestedPage = chapter.chapter.last_page_read
}
}
.toCompletable()
.doOnError { chapter.state = ReaderChapter.State.Error(it) }
}
/**
* Returns the page loader to use for this [chapter].
*/
private fun getPageLoader(chapter: ReaderChapter): PageLoader {
val isDownloaded = downloadManager.isChapterDownloaded(chapter.chapter, manga, true)
return when {
isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager)
source is HttpSource -> HttpPageLoader(chapter, source)
source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
when (format) {
is LocalSource.Format.Directory -> DirectoryPageLoader(format.file)
is LocalSource.Format.Zip -> ZipPageLoader(format.file)
is LocalSource.Format.Rar -> RarPageLoader(format.file)
is LocalSource.Format.Epub -> EpubPageLoader(format.file)
}
}
else -> error("Loader not implemented")
}
}
}

View File

@ -0,0 +1,43 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.ImageUtil
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import rx.Observable
import java.io.File
import java.io.FileInputStream
/**
* Loader used to load a chapter from a directory given on [file].
*/
class DirectoryPageLoader(val file: File) : PageLoader() {
/**
* Returns an observable containing the pages found on this directory ordered with a natural
* comparator.
*/
override fun getPages(): Observable<List<ReaderPage>> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return file.listFiles()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
.mapIndexed { i, file ->
val streamFn = { FileInputStream(file) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
.let { Observable.just(it) }
}
/**
* Returns an observable that emits a ready state.
*/
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.just(Page.READY)
}
}

View File

@ -0,0 +1,48 @@
package eu.kanade.tachiyomi.ui.reader.loader
import android.app.Application
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import rx.Observable
import uy.kohesive.injekt.injectLazy
/**
* Loader used to load a chapter from the downloaded chapters.
*/
class DownloadPageLoader(
private val chapter: ReaderChapter,
private val manga: Manga,
private val source: Source,
private val downloadManager: DownloadManager
) : PageLoader() {
/**
* The application context. Needed to open input streams.
*/
private val context by injectLazy<Application>()
/**
* Returns an observable containing the pages found on this downloaded chapter.
*/
override fun getPages(): Observable<List<ReaderPage>> {
return downloadManager.buildPageList(source, manga, chapter.chapter)
.map { pages ->
pages.map { page ->
ReaderPage(page.index, page.url, page.imageUrl, {
context.contentResolver.openInputStream(page.uri)
}).apply {
status = Page.READY
}
}
}
}
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.just(Page.READY) // TODO maybe check if file still exists?
}
}

View File

@ -0,0 +1,54 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.EpubFile
import rx.Observable
import java.io.File
/**
* Loader used to load a chapter from a .epub file.
*/
class EpubPageLoader(file: File) : PageLoader() {
/**
* The epub file.
*/
private val epub = EpubFile(file)
/**
* Recycles this loader and the open zip.
*/
override fun recycle() {
super.recycle()
epub.close()
}
/**
* Returns an observable containing the pages found on this zip archive ordered with a natural
* comparator.
*/
override fun getPages(): Observable<List<ReaderPage>> {
return epub.getImagesFromPages()
.mapIndexed { i, path ->
val streamFn = { epub.getInputStream(epub.getEntry(path)!!) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
.let { Observable.just(it) }
}
/**
* Returns an observable that emits a ready state unless the loader was recycled.
*/
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.just(if (isRecycled) {
Page.ERROR
} else {
Page.READY
})
}
}

View File

@ -0,0 +1,226 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.plusAssign
import rx.Completable
import rx.Observable
import rx.schedulers.Schedulers
import rx.subjects.PublishSubject
import rx.subjects.SerializedSubject
import rx.subscriptions.CompositeSubscription
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.PriorityBlockingQueue
import java.util.concurrent.atomic.AtomicInteger
/**
* Loader used to load chapters from an online source.
*/
class HttpPageLoader(
private val chapter: ReaderChapter,
private val source: HttpSource,
private val chapterCache: ChapterCache = Injekt.get()
) : PageLoader() {
/**
* A queue used to manage requests one by one while allowing priorities.
*/
private val queue = PriorityBlockingQueue<PriorityPage>()
/**
* Current active subscriptions.
*/
private val subscriptions = CompositeSubscription()
init {
subscriptions += Observable.defer { Observable.just(queue.take().page) }
.filter { it.status == Page.QUEUE }
.concatMap { source.fetchImageFromCacheThenNet(it) }
.repeat()
.subscribeOn(Schedulers.io())
.subscribe({
}, { error ->
if (error !is InterruptedException) {
Timber.e(error)
}
})
}
/**
* Recycles this loader and the active subscriptions and queue.
*/
override fun recycle() {
super.recycle()
subscriptions.unsubscribe()
queue.clear()
// Cache current page list progress for online chapters to allow a faster reopen
val pages = chapter.pages
if (pages != null) {
Completable
.fromAction {
// Convert to pages without reader information
val pagesToSave = pages.map { Page(it.index, it.url, it.imageUrl) }
chapterCache.putPageListToCache(chapter.chapter, pagesToSave)
}
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
}
}
/**
* Returns an observable with the page list for a chapter. It tries to return the page list from
* the local cache, otherwise fallbacks to network.
*/
override fun getPages(): Observable<List<ReaderPage>> {
return chapterCache
.getPageListFromCache(chapter.chapter)
.onErrorResumeNext { source.fetchPageList(chapter.chapter) }
.map { pages ->
pages.mapIndexed { index, page -> // Don't trust sources and use our own indexing
ReaderPage(index, page.url, page.imageUrl)
}
}
}
/**
* Returns an observable that loads a page through the queue and listens to its result to
* emit new states. It handles re-enqueueing pages if they were evicted from the cache.
*/
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.defer {
val imageUrl = page.imageUrl
// Check if the image has been deleted
if (page.status == Page.READY && imageUrl != null && !chapterCache.isImageInCache(imageUrl)) {
page.status = Page.QUEUE
}
// Automatically retry failed pages when subscribed to this page
if (page.status == Page.ERROR) {
page.status = Page.QUEUE
}
val statusSubject = SerializedSubject(PublishSubject.create<Int>())
page.setStatusSubject(statusSubject)
if (page.status == Page.QUEUE) {
queue.offer(PriorityPage(page, 1))
}
preloadNextPages(page, 4)
statusSubject.startWith(page.status)
}
}
/**
* Preloads the given [amount] of pages after the [currentPage] with a lower priority.
*/
private fun preloadNextPages(currentPage: ReaderPage, amount: Int) {
val pageIndex = currentPage.index
val pages = currentPage.chapter.pages ?: return
if (pageIndex == pages.lastIndex) return
val nextPages = pages.subList(pageIndex + 1, Math.min(pageIndex + 1 + amount, pages.size))
for (nextPage in nextPages) {
if (nextPage.status == Page.QUEUE) {
queue.offer(PriorityPage(nextPage, 0))
}
}
}
/**
* Retries a page. This method is only called from user interaction on the viewer.
*/
override fun retryPage(page: ReaderPage) {
if (page.status == Page.ERROR) {
page.status = Page.QUEUE
}
queue.offer(PriorityPage(page, 2))
}
/**
* Data class used to keep ordering of pages in order to maintain priority.
*/
private data class PriorityPage(
val page: ReaderPage,
val priority: Int
): Comparable<PriorityPage> {
companion object {
private val idGenerator = AtomicInteger()
}
private val identifier = idGenerator.incrementAndGet()
override fun compareTo(other: PriorityPage): Int {
val p = other.priority.compareTo(priority)
return if (p != 0) p else identifier.compareTo(other.identifier)
}
}
/**
* Returns an observable of the page with the downloaded image.
*
* @param page the page whose source image has to be downloaded.
*/
private fun HttpSource.fetchImageFromCacheThenNet(page: ReaderPage): Observable<ReaderPage> {
return if (page.imageUrl.isNullOrEmpty())
getImageUrl(page).flatMap { getCachedImage(it) }
else
getCachedImage(page)
}
private fun HttpSource.getImageUrl(page: ReaderPage): Observable<ReaderPage> {
page.status = Page.LOAD_PAGE
return fetchImageUrl(page)
.doOnError { page.status = Page.ERROR }
.onErrorReturn { null }
.doOnNext { page.imageUrl = it }
.map { page }
}
/**
* Returns an observable of the page that gets the image from the chapter or fallbacks to
* network and copies it to the cache calling [cacheImage].
*
* @param page the page.
*/
private fun HttpSource.getCachedImage(page: ReaderPage): Observable<ReaderPage> {
val imageUrl = page.imageUrl ?: return Observable.just(page)
return Observable.just(page)
.flatMap {
if (!chapterCache.isImageInCache(imageUrl)) {
cacheImage(page)
} else {
Observable.just(page)
}
}
.doOnNext {
page.stream = { chapterCache.getImageFile(imageUrl).inputStream() }
page.status = Page.READY
}
.doOnError { page.status = Page.ERROR }
.onErrorReturn { page }
}
/**
* Returns an observable of the page that downloads the image to [ChapterCache].
*
* @param page the page.
*/
private fun HttpSource.cacheImage(page: ReaderPage): Observable<ReaderPage> {
page.status = Page.DOWNLOAD_IMAGE
return fetchImage(page)
.doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) }
.map { page }
}
}

View File

@ -0,0 +1,46 @@
package eu.kanade.tachiyomi.ui.reader.loader
import android.support.annotation.CallSuper
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import rx.Observable
/**
* A loader used to load pages into the reader. Any open resources must be cleaned up when the
* method [recycle] is called.
*/
abstract class PageLoader {
/**
* Whether this loader has been already recycled.
*/
var isRecycled = false
private set
/**
* Recycles this loader. Implementations must override this method to clean up any active
* resources.
*/
@CallSuper
open fun recycle() {
isRecycled = true
}
/**
* Returns an observable containing the list of pages of a chapter. Only the first emission
* will be used.
*/
abstract fun getPages(): Observable<List<ReaderPage>>
/**
* Returns an observable that should inform of the progress of the page (see the Page class
* for the available states)
*/
abstract fun getPage(page: ReaderPage): Observable<Int>
/**
* Retries the given [page] in case it failed to load. This method only makes sense when an
* online source is used.
*/
open fun retryPage(page: ReaderPage) {}
}

View File

@ -0,0 +1,89 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.ImageUtil
import junrar.Archive
import junrar.rarfile.FileHeader
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import rx.Observable
import java.io.File
import java.io.InputStream
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.util.concurrent.Executors
/**
* Loader used to load a chapter from a .rar or .cbr file.
*/
class RarPageLoader(file: File) : PageLoader() {
/**
* The rar archive to load pages from.
*/
private val archive = Archive(file)
/**
* Pool for copying compressed files to an input stream.
*/
private val pool = Executors.newFixedThreadPool(1)
/**
* Recycles this loader and the open archive.
*/
override fun recycle() {
super.recycle()
archive.close()
pool.shutdown()
}
/**
* Returns an observable containing the pages found on this rar archive ordered with a natural
* comparator.
*/
override fun getPages(): Observable<List<ReaderPage>> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return archive.fileHeaders
.filter { !it.isDirectory && ImageUtil.isImage(it.fileNameString) { archive.getInputStream(it) } }
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
.mapIndexed { i, header ->
val streamFn = { getStream(header) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
.let { Observable.just(it) }
}
/**
* Returns an observable that emits a ready state unless the loader was recycled.
*/
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.just(if (isRecycled) {
Page.ERROR
} else {
Page.READY
})
}
/**
* Returns an input stream for the given [header].
*/
private fun getStream(header: FileHeader): InputStream {
val pipeIn = PipedInputStream()
val pipeOut = PipedOutputStream(pipeIn)
pool.execute {
try {
pipeOut.use {
archive.extractFile(header, it)
}
} catch (e: Exception) {
}
}
return pipeIn
}
}

View File

@ -0,0 +1,60 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.ImageUtil
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import rx.Observable
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
/**
* Loader used to load a chapter from a .zip or .cbz file.
*/
class ZipPageLoader(file: File) : PageLoader() {
/**
* The zip file to load pages from.
*/
private val zip = ZipFile(file)
/**
* Recycles this loader and the open zip.
*/
override fun recycle() {
super.recycle()
zip.close()
}
/**
* Returns an observable containing the pages found on this zip archive ordered with a natural
* comparator.
*/
override fun getPages(): Observable<List<ReaderPage>> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return zip.entries().toList()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
.mapIndexed { i, entry ->
val streamFn = { zip.getInputStream(entry) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
.let { Observable.just(it) }
}
/**
* Returns an observable that emits a ready state unless the loader was recycled.
*/
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.just(if (isRecycled) {
Page.ERROR
} else {
Page.READY
})
}
}

View File

@ -0,0 +1,33 @@
package eu.kanade.tachiyomi.ui.reader.model
sealed class ChapterTransition {
abstract val from: ReaderChapter
abstract val to: ReaderChapter?
class Prev(
override val from: ReaderChapter, override val to: ReaderChapter?
) : ChapterTransition()
class Next(
override val from: ReaderChapter, override val to: ReaderChapter?
) : ChapterTransition()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ChapterTransition) return false
if (from == other.from && to == other.to) return true
if (from == other.to && to == other.from) return true
return false
}
override fun hashCode(): Int {
var result = from.hashCode()
result = 31 * result + (to?.hashCode() ?: 0)
return result
}
override fun toString(): String {
return "${javaClass.simpleName}(from=${from.chapter.url}, to=${to?.chapter?.url})"
}
}

View File

@ -0,0 +1,53 @@
package eu.kanade.tachiyomi.ui.reader.model
import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
import timber.log.Timber
data class ReaderChapter(val chapter: Chapter) {
var state: State =
State.Wait
set(value) {
field = value
stateRelay.call(value)
}
private val stateRelay by lazy { BehaviorRelay.create(state) }
val stateObserver by lazy { stateRelay.asObservable() }
val pages: List<ReaderPage>?
get() = (state as? State.Loaded)?.pages
var pageLoader: PageLoader? = null
var requestedPage: Int = 0
var references = 0
private set
fun ref() {
references++
}
fun unref() {
references--
if (references == 0) {
if (pageLoader != null) {
Timber.d("Recycling chapter ${chapter.name}")
}
pageLoader?.recycle()
pageLoader = null
state = State.Wait
}
}
sealed class State {
object Wait : State()
object Loading : State()
class Error(val error: Throwable) : State()
class Loaded(val pages: List<ReaderPage>) : State()
}
}

View File

@ -0,0 +1,15 @@
package eu.kanade.tachiyomi.ui.reader.model
import eu.kanade.tachiyomi.source.model.Page
import java.io.InputStream
class ReaderPage(
index: Int,
url: String = "",
imageUrl: String? = null,
var stream: (() -> InputStream)? = null
) : Page(index, url, imageUrl, null) {
lateinit var chapter: ReaderChapter
}

View File

@ -0,0 +1,21 @@
package eu.kanade.tachiyomi.ui.reader.model
data class ViewerChapters(
val currChapter: ReaderChapter,
val prevChapter: ReaderChapter?,
val nextChapter: ReaderChapter?
) {
fun ref() {
currChapter.ref()
prevChapter?.ref()
nextChapter?.ref()
}
fun unref() {
currChapter.unref()
prevChapter?.unref()
nextChapter?.unref()
}
}

View File

@ -0,0 +1,46 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
/**
* Interface for implementing a viewer.
*/
interface BaseViewer {
/**
* Returns the view this viewer uses.
*/
fun getView(): View
/**
* Destroys this viewer. Called when leaving the reader or swapping viewers.
*/
fun destroy() {}
/**
* Tells this viewer to set the given [chapters] as active.
*/
fun setChapters(chapters: ViewerChapters)
/**
* Tells this viewer to move to the given [page].
*/
fun moveToPage(page: ReaderPage)
/**
* Called from the containing activity when a key [event] is received. It should return true
* if the event was handled, false otherwise.
*/
fun handleKeyEvent(event: KeyEvent): Boolean
/**
* Called from the containing activity when a generic motion [event] is received. It should
* return true if the event was handled, false otherwise.
*/
fun handleGenericMotionEvent(event: MotionEvent): Boolean
}

View File

@ -0,0 +1,74 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.os.Handler
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ViewConfiguration
/**
* A custom gesture detector that also implements an on long tap confirmed, because the built-in
* one conflicts with the quick scale feature.
*/
open class GestureDetectorWithLongTap(
context: Context,
listener: Listener
) : GestureDetector(context, listener) {
private val handler = Handler()
private val slop = ViewConfiguration.get(context).scaledTouchSlop
private val longTapTime = ViewConfiguration.getLongPressTimeout().toLong()
private val doubleTapTime = ViewConfiguration.getDoubleTapTimeout().toLong()
private var downX = 0f
private var downY = 0f
private var lastUp = 0L
private var lastDownEvent: MotionEvent? = null
/**
* Runnable to execute when a long tap is confirmed.
*/
private val longTapFn = Runnable { listener.onLongTapConfirmed(lastDownEvent!!) }
override fun onTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
lastDownEvent?.recycle()
lastDownEvent = MotionEvent.obtain(ev)
// This is the key difference with the built-in detector. We have to ignore the
// event if the last up and current down are too close in time (double tap).
if (ev.downTime - lastUp > doubleTapTime) {
downX = ev.rawX
downY = ev.rawY
handler.postDelayed(longTapFn, longTapTime)
}
}
MotionEvent.ACTION_MOVE -> {
if (Math.abs(ev.rawX - downX) > slop || Math.abs(ev.rawY - downY) > slop) {
handler.removeCallbacks(longTapFn)
}
}
MotionEvent.ACTION_UP -> {
lastUp = ev.eventTime
handler.removeCallbacks(longTapFn)
}
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_POINTER_DOWN -> {
handler.removeCallbacks(longTapFn)
}
}
return super.onTouchEvent(ev)
}
/**
* Custom listener to also include a long tap confirmed
*/
open class Listener : SimpleOnGestureListener() {
/**
* Notified when a long tap occurs with the initial on down [ev] that triggered it.
*/
open fun onLongTapConfirmed(ev: MotionEvent) {
}
}
}

View File

@ -0,0 +1,218 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.view.View
import android.view.animation.Animation
import android.view.animation.DecelerateInterpolator
import android.view.animation.LinearInterpolator
import android.view.animation.RotateAnimation
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.getResourceColor
/**
* A custom progress bar that always rotates while being determinate. By always rotating we give
* the feedback to the user that the application isn't 'stuck', and by making it determinate the
* user also approximately knows how much the operation will take.
*/
class ReaderProgressBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
/**
* The current sweep angle. It always starts at 10% because otherwise the bar and the rotation
* wouldn't be visible.
*/
private var sweepAngle = 10f
/**
* Whether the parent views are also visible.
*/
private var aggregatedIsVisible = false
/**
* The paint to use to draw the progress bar.
*/
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = context.getResourceColor(R.attr.colorAccent)
isAntiAlias = true
strokeCap = Paint.Cap.ROUND
style = Paint.Style.STROKE
}
/**
* The rectangle of the canvas where the progress bar should be drawn. This is calculated on
* layout.
*/
private val ovalRect = RectF()
/**
* The rotation animation to use while the progress bar is visible.
*/
private val rotationAnimation by lazy {
RotateAnimation(0f, 360f,
Animation.RELATIVE_TO_SELF, 0.5f,
Animation.RELATIVE_TO_SELF, 0.5f
).apply {
interpolator = LinearInterpolator()
repeatCount = Animation.INFINITE
duration = 4000
}
}
/**
* Called when the view is layout. The position and thickness of the progress bar is calculated.
*/
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
val diameter = Math.min(width, height)
val thickness = diameter / 10f
val pad = thickness / 2f
ovalRect.set(pad, pad, diameter - pad, diameter - pad)
paint.strokeWidth = thickness
}
/**
* Called when the view is being drawn. An arc is drawn with the calculated rectangle. The
* animation will take care of rotation.
*/
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawArc(ovalRect, -90f, sweepAngle, false, paint)
}
/**
* Calculates the sweep angle to use from the progress.
*/
private fun calcSweepAngleFromProgress(progress: Int): Float {
return 360f / 100 * progress
}
/**
* Called when this view is attached to window. It starts the rotation animation.
*/
override fun onAttachedToWindow() {
super.onAttachedToWindow()
startAnimation()
}
/**
* Called when this view is detached to window. It stops the rotation animation.
*/
override fun onDetachedFromWindow() {
stopAnimation()
super.onDetachedFromWindow()
}
/**
* Called when the aggregated visibility of this view changes. It also starts of stops the
* rotation animation according to [isVisible].
*/
override fun onVisibilityAggregated(isVisible: Boolean) {
super.onVisibilityAggregated(isVisible)
if (isVisible != aggregatedIsVisible) {
aggregatedIsVisible = isVisible
// let's be nice with the UI thread
if (isVisible) {
startAnimation()
} else {
stopAnimation()
}
}
}
/**
* Starts the rotation animation if needed.
*/
private fun startAnimation() {
if (visibility != View.VISIBLE || windowVisibility != View.VISIBLE || animation != null) {
return
}
animation = rotationAnimation
animation.start()
}
/**
* Stops the rotation animation if needed.
*/
private fun stopAnimation() {
clearAnimation()
}
/**
* Hides this progress bar with an optional fade out if [animate] is true.
*/
fun hide(animate: Boolean = false) {
if (visibility == View.GONE) return
if (!animate) {
visibility = View.GONE
} else {
ObjectAnimator.ofFloat(this, "alpha", 1f, 0f).apply {
interpolator = DecelerateInterpolator()
duration = 1000
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
visibility = View.GONE
alpha = 1f
}
override fun onAnimationCancel(animation: Animator?) {
alpha = 1f
}
})
start()
}
}
}
/**
* Completes this progress bar and fades out the view.
*/
fun completeAndFadeOut() {
setRealProgress(100)
hide(true)
}
/**
* Set progress of the circular progress bar ensuring a min max range in order to notice the
* rotation animation.
*/
fun setProgress(progress: Int) {
// Scale progress in [10, 95] range
val scaledProgress = 85 * progress / 100 + 10
setRealProgress(scaledProgress)
}
/**
* Sets the real progress of the circular progress bar. Note that if this progres is 0 or
* 100, the rotation animation won't be noticed by the user because nothing changes in the
* canvas.
*/
private fun setRealProgress(progress: Int) {
ValueAnimator.ofFloat(sweepAngle, calcSweepAngleFromProgress(progress)).apply {
interpolator = DecelerateInterpolator()
duration = 250
addUpdateListener { valueAnimator ->
sweepAngle = valueAnimator.animatedValue as Float
invalidate()
}
start()
}
}
}

View File

@ -1,253 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.base
import android.support.v4.app.Fragment
import com.davemorrissey.labs.subscaleview.decoder.*
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
import java.util.*
/**
* Base reader containing the common data that can be used by its implementations. It does not
* contain any UI related action.
*/
abstract class BaseReader : Fragment() {
companion object {
/**
* Image decoder.
*/
const val IMAGE_DECODER = 0
/**
* Rapid decoder.
*/
const val RAPID_DECODER = 1
/**
* Skia decoder.
*/
const val SKIA_DECODER = 2
}
/**
* List of chapters added in the reader.
*/
private val chapters = ArrayList<ReaderChapter>()
/**
* List of pages added in the reader. It can contain pages from more than one chapter.
*/
var pages: MutableList<Page> = ArrayList()
private set
/**
* Current visible position of [pages].
*/
var currentPage: Int = 0
protected set
/**
* Region decoder class to use.
*/
lateinit var regionDecoderClass: Class<out ImageRegionDecoder>
private set
/**
* Bitmap decoder class to use.
*/
lateinit var bitmapDecoderClass: Class<out ImageDecoder>
private set
/**
* Whether tap navigation is enabled or not.
*/
val tappingEnabled by lazy { readerActivity.preferences.readWithTapping().getOrDefault() }
/**
* Whether the reader has requested to append a chapter. Used with seamless mode to avoid
* restarting requests when changing pages.
*/
private var hasRequestedNextChapter: Boolean = false
/**
* Returns the active page.
*/
fun getActivePage(): Page? {
return pages.getOrNull(currentPage)
}
/**
* Called when a page changes. Implementations must call this method.
*
* @param position the new current page.
*/
fun onPageChanged(position: Int) {
val oldPage = pages[currentPage]
val newPage = pages[position]
val oldChapter = oldPage.chapter
val newChapter = newPage.chapter
// Update page indicator and seekbar
readerActivity.onPageChanged(newPage)
// Active chapter has changed.
if (oldChapter.id != newChapter.id) {
readerActivity.onEnterChapter(newPage.chapter, newPage.index)
}
// Request next chapter only when the conditions are met.
if (pages.size - position < 5 && chapters.last().id == newChapter.id
&& readerActivity.presenter.hasNextChapter() && !hasRequestedNextChapter) {
hasRequestedNextChapter = true
readerActivity.presenter.appendNextChapter()
}
currentPage = position
}
/**
* Sets the active page.
*
* @param page the page to display.
*/
fun setActivePage(page: Page) {
setActivePage(getPageIndex(page))
}
/**
* Searchs for the index of a page in the current list without requiring them to be the same
* object.
*
* @param search the page to search.
* @return the index of the page in [pages] or 0 if it's not found.
*/
fun getPageIndex(search: Page): Int {
for ((index, page) in pages.withIndex()) {
if (page.index == search.index && page.chapter.id == search.chapter.id) {
return index
}
}
return 0
}
/**
* Called from the presenter when the page list of a chapter is ready. This method is called
* on every [onResume], so we add some logic to avoid duplicating chapters.
*
* @param chapter the chapter to set.
* @param currentPage the initial page to display.
*/
fun onPageListReady(chapter: ReaderChapter, currentPage: Page) {
if (!chapters.contains(chapter)) {
// if we reset the loaded page we also need to reset the loaded chapters
chapters.clear()
chapters.add(chapter)
pages = ArrayList(chapter.pages)
onChapterSet(chapter, currentPage)
} else {
setActivePage(currentPage)
}
}
/**
* Called from the presenter when the page list of a chapter to append is ready. This method is
* called on every [onResume], so we add some logic to avoid duplicating chapters.
*
* @param chapter the chapter to append.
*/
fun onPageListAppendReady(chapter: ReaderChapter) {
if (!chapters.contains(chapter)) {
hasRequestedNextChapter = false
chapters.add(chapter)
pages.addAll(chapter.pages!!)
onChapterAppended(chapter)
}
}
/**
* Sets the active page.
*
* @param pageNumber the index of the page from [pages].
*/
abstract fun setActivePage(pageNumber: Int)
/**
* Called when a new chapter is set in [BaseReader].
*
* @param chapter the chapter set.
* @param currentPage the initial page to display.
*/
abstract fun onChapterSet(chapter: ReaderChapter, currentPage: Page)
/**
* Called when a chapter is appended in [BaseReader].
*
* @param chapter the chapter appended.
*/
abstract fun onChapterAppended(chapter: ReaderChapter)
/**
* Moves pages to right. Implementations decide how to move (by a page, by some distance...).
*/
abstract fun moveRight()
/**
* Moves pages to left. Implementations decide how to move (by a page, by some distance...).
*/
abstract fun moveLeft()
/**
* Moves pages down. Implementations decide how to move (by a page, by some distance...).
*/
open fun moveDown() {
moveRight()
}
/**
* Moves pages up. Implementations decide how to move (by a page, by some distance...).
*/
open fun moveUp() {
moveLeft()
}
/**
* Method the implementations can call to show a menu with options for the given page.
*/
fun onLongClick(page: Page?): Boolean {
if (isAdded && page != null) {
readerActivity.onLongClick(page)
}
return true
}
/**
* Sets the active decoder class.
*
* @param value the decoder class to use.
*/
fun setDecoderClass(value: Int) {
when (value) {
IMAGE_DECODER -> {
bitmapDecoderClass = IImageDecoder::class.java
regionDecoderClass = IImageRegionDecoder::class.java
}
RAPID_DECODER -> {
bitmapDecoderClass = RapidImageDecoder::class.java
regionDecoderClass = RapidImageRegionDecoder::class.java
}
SKIA_DECODER -> {
bitmapDecoderClass = SkiaImageDecoder::class.java
regionDecoderClass = SkiaImageRegionDecoder::class.java
}
}
}
/**
* Property to get the reader activity.
*/
val readerActivity: ReaderActivity
get() = activity as ReaderActivity
}

View File

@ -1,40 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.base
import android.net.Uri
import android.support.v4.content.ContextCompat
import android.view.View
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import kotlinx.android.synthetic.main.reader_page_decode_error.view.*
class PageDecodeErrorLayout(
val view: View,
val page: Page,
val theme: Int,
val retryListener: () -> Unit
) {
init {
val textColor = if (theme == ReaderActivity.BLACK_THEME)
ContextCompat.getColor(view.context, R.color.textColorSecondaryDark)
else
ContextCompat.getColor(view.context, R.color.textColorSecondaryLight)
view.decode_error_text.setTextColor(textColor)
view.decode_retry.setOnClickListener {
retryListener()
}
view.decode_open_browser.setOnClickListener {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, Uri.parse(page.imageUrl))
view.context.startActivity(intent)
}
if (page.imageUrl == null) {
view.decode_open_browser.visibility = View.GONE
}
}
}

View File

@ -1,6 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
interface OnChapterBoundariesOutListener {
fun onFirstPageOutEvent()
fun onLastPageOutEvent()
}

View File

@ -1,276 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.content.Context
import android.graphics.PointF
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.widget.FrameLayout
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.viewer.base.PageDecodeErrorLayout
import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.RightToLeftReader
import eu.kanade.tachiyomi.ui.reader.viewer.pager.vertical.VerticalReader
import eu.kanade.tachiyomi.util.inflate
import kotlinx.android.synthetic.main.reader_pager_item.view.*
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.subjects.PublishSubject
import rx.subjects.SerializedSubject
import java.util.concurrent.TimeUnit
class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null)
: FrameLayout(context, attrs) {
/**
* Page of a chapter.
*/
lateinit var page: Page
/**
* Subscription for status changes of the page.
*/
private var statusSubscription: Subscription? = null
/**
* Subscription for progress changes of the page.
*/
private var progressSubscription: Subscription? = null
/**
* Layout of decode error.
*/
private var decodeErrorLayout: View? = null
fun initialize(reader: PagerReader, page: Page) {
val activity = reader.activity as ReaderActivity
when (activity.readerTheme) {
ReaderActivity.BLACK_THEME -> progress_text.setTextColor(reader.whiteColor)
ReaderActivity.WHITE_THEME -> progress_text.setTextColor(reader.blackColor)
}
if (reader is RightToLeftReader) {
rotation = -180f
}
with(image_view) {
setMaxTileSize((reader.activity as ReaderActivity).maxBitmapSize)
setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_FIXED)
setDoubleTapZoomDuration(reader.doubleTapAnimDuration.toInt())
setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
setMinimumScaleType(reader.scaleType)
setMinimumDpi(90)
setMinimumTileDpi(180)
setRegionDecoderClass(reader.regionDecoderClass)
setBitmapDecoderClass(reader.bitmapDecoderClass)
setVerticalScrollingParent(reader is VerticalReader)
setCropBorders(reader.cropBorders)
setOnTouchListener { _, motionEvent -> reader.gestureDetector.onTouchEvent(motionEvent) }
setOnLongClickListener { reader.onLongClick(page) }
setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() {
override fun onReady() {
onImageDecoded(reader)
}
override fun onImageLoadError(e: Exception) {
onImageDecodeError(reader)
}
})
}
retry_button.setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_UP) {
activity.presenter.retryPage(page)
}
true
}
this.page = page
observeStatus()
}
override fun onDetachedFromWindow() {
unsubscribeProgress()
unsubscribeStatus()
image_view.setOnTouchListener(null)
image_view.setOnImageEventListener(null)
super.onDetachedFromWindow()
}
/**
* Observes the status of the page and notify the changes.
*
* @see processStatus
*/
private fun observeStatus() {
statusSubscription?.unsubscribe()
val statusSubject = SerializedSubject(PublishSubject.create<Int>())
page.setStatusSubject(statusSubject)
statusSubscription = statusSubject.startWith(page.status)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { processStatus(it) }
}
/**
* Observes the progress of the page and updates view.
*/
private fun observeProgress() {
progressSubscription?.unsubscribe()
progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS)
.map { page.progress }
.distinctUntilChanged()
.onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { progress ->
progress_text.text = if (progress > 0) {
context.getString(R.string.download_progress, progress)
} else {
context.getString(R.string.downloading)
}
}
}
/**
* Called when the status of the page changes.
*
* @param status the new status of the page.
*/
private fun processStatus(status: Int) {
when (status) {
Page.QUEUE -> setQueued()
Page.LOAD_PAGE -> setLoading()
Page.DOWNLOAD_IMAGE -> {
observeProgress()
setDownloading()
}
Page.READY -> {
setImage()
unsubscribeProgress()
}
Page.ERROR -> {
setError()
unsubscribeProgress()
}
}
}
/**
* Unsubscribes from the status subscription.
*/
private fun unsubscribeStatus() {
page.setStatusSubject(null)
statusSubscription?.unsubscribe()
statusSubscription = null
}
/**
* Unsubscribes from the progress subscription.
*/
private fun unsubscribeProgress() {
progressSubscription?.unsubscribe()
progressSubscription = null
}
/**
* Called when the page is queued.
*/
private fun setQueued() {
progress_container.visibility = View.VISIBLE
progress_text.visibility = View.INVISIBLE
retry_button.visibility = View.GONE
decodeErrorLayout?.let {
removeView(it)
decodeErrorLayout = null
}
}
/**
* Called when the page is loading.
*/
private fun setLoading() {
progress_container.visibility = View.VISIBLE
progress_text.visibility = View.VISIBLE
progress_text.setText(R.string.downloading)
}
/**
* Called when the page is downloading.
*/
private fun setDownloading() {
progress_container.visibility = View.VISIBLE
progress_text.visibility = View.VISIBLE
}
/**
* Called when the page is ready.
*/
private fun setImage() {
val uri = page.uri
if (uri == null) {
page.status = Page.ERROR
return
}
val file = UniFile.fromUri(context, uri)
if (!file.exists()) {
page.status = Page.ERROR
return
}
progress_text.visibility = View.INVISIBLE
image_view.setImage(ImageSource.uri(file.uri))
}
/**
* Called when the page has an error.
*/
private fun setError() {
progress_container.visibility = View.GONE
retry_button.visibility = View.VISIBLE
}
/**
* Called when the image is decoded and going to be displayed.
*/
private fun onImageDecoded(reader: PagerReader) {
progress_container.visibility = View.GONE
with(image_view) {
when (reader.zoomType) {
PagerReader.ALIGN_LEFT -> setScaleAndCenter(scale, PointF(0f, 0f))
PagerReader.ALIGN_RIGHT -> setScaleAndCenter(scale, PointF(sWidth.toFloat(), 0f))
PagerReader.ALIGN_CENTER -> setScaleAndCenter(scale, center.apply { y = 0f })
}
}
}
/**
* Called when an image fails to decode.
*/
private fun onImageDecodeError(reader: PagerReader) {
progress_container.visibility = View.GONE
if (decodeErrorLayout != null || !reader.isAdded) return
val activity = reader.activity as ReaderActivity
val layout = inflate(R.layout.reader_page_decode_error)
PageDecodeErrorLayout(layout, page, activity.readerTheme, {
if (reader.isAdded) {
activity.presenter.retryPage(page)
}
})
decodeErrorLayout = layout
addView(layout)
}
}

View File

@ -1,28 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager;
import android.support.v4.view.PagerAdapter;
import android.view.ViewGroup;
import rx.functions.Action1;
public interface Pager {
void setId(int id);
void setLayoutParams(ViewGroup.LayoutParams layoutParams);
void setOffscreenPageLimit(int limit);
int getCurrentItem();
void setCurrentItem(int item, boolean smoothScroll);
int getWidth();
int getHeight();
PagerAdapter getAdapter();
void setAdapter(PagerAdapter adapter);
void setOnChapterBoundariesOutListener(OnChapterBoundariesOutListener listener);
void setOnPageChangeListener(Action1<Integer> onPageChanged);
void clearOnPageChangeListeners();
}

View File

@ -0,0 +1,108 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.content.Context
import android.support.v4.view.DirectionalViewPager
import android.view.HapticFeedbackConstants
import android.view.KeyEvent
import android.view.MotionEvent
import eu.kanade.tachiyomi.ui.reader.viewer.GestureDetectorWithLongTap
/**
* Pager implementation that listens for tap and long tap and allows temporarily disabling touch
* events in order to work with child views that need to disable touch events on this parent. The
* pager can also be declared to be vertical by creating it with [isHorizontal] to false.
*/
open class Pager(
context: Context,
isHorizontal: Boolean = true
) : DirectionalViewPager(context, isHorizontal) {
/**
* Tap listener function to execute when a tap is detected.
*/
var tapListener: ((MotionEvent) -> Unit)? = null
/**
* Long tap listener function to execute when a long tap is detected.
*/
var longTapListener: ((MotionEvent) -> Boolean)? = null
/**
* Gesture listener that implements tap and long tap events.
*/
private val gestureListener = object : GestureDetectorWithLongTap.Listener() {
override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
tapListener?.invoke(ev)
return true
}
override fun onLongTapConfirmed(ev: MotionEvent) {
val listener = longTapListener
if (listener != null && listener.invoke(ev)) {
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}
}
}
/**
* Gesture detector which handles motion events.
*/
private val gestureDetector = GestureDetectorWithLongTap(context, gestureListener)
/**
* Whether the gesture detector is currently enabled.
*/
private var isGestureDetectorEnabled = true
/**
* Dispatches a touch event.
*/
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
val handled = super.dispatchTouchEvent(ev)
if (isGestureDetectorEnabled) {
gestureDetector.onTouchEvent(ev)
}
return handled
}
/**
* Whether the given [ev] should be intercepted. Only used to prevent crashes when child
* views manipulate [requestDisallowInterceptTouchEvent].
*/
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
return try {
super.onInterceptTouchEvent(ev)
} catch (e: IllegalArgumentException) {
false
}
}
/**
* Handles a touch event. Only used to prevent crashes when child views manipulate
* [requestDisallowInterceptTouchEvent].
*/
override fun onTouchEvent(ev: MotionEvent): Boolean {
return try {
super.onTouchEvent(ev)
} catch (e: IllegalArgumentException) {
false
}
}
/**
* Executes the given key event when this pager has focus. Just do nothing because the reader
* already dispatches key events to the viewer and has more control than this method.
*/
override fun executeKeyEvent(event: KeyEvent): Boolean {
// Disable viewpager's default key event handling
return false
}
/**
* Enables or disables the gesture detector.
*/
fun setGestureDetectorEnabled(enabled: Boolean) {
isGestureDetectorEnabled = enabled
}
}

View File

@ -0,0 +1,25 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.annotation.SuppressLint
import android.content.Context
import android.support.v7.widget.AppCompatButton
import android.view.MotionEvent
/**
* A button class to be used by child views of the pager viewer. All tap gestures are handled by
* the pager, but this class disables that behavior to allow clickable buttons.
*/
@SuppressLint("ViewConstructor")
class PagerButton(context: Context, viewer: PagerViewer) : AppCompatButton(context) {
init {
setOnTouchListener { _, event ->
viewer.pager.setGestureDetectorEnabled(false)
if (event.actionMasked == MotionEvent.ACTION_UP) {
viewer.pager.setGestureDetectorEnabled(true)
}
false
}
}
}

View File

@ -0,0 +1,113 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import com.f2prateek.rx.preferences.Preference
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.addTo
import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* Configuration used by pager viewers.
*/
class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelper = Injekt.get()) {
private val subscriptions = CompositeSubscription()
var imagePropertyChangedListener: (() -> Unit)? = null
var tappingEnabled = true
private set
var longTapEnabled = true
private set
var volumeKeysEnabled = false
private set
var volumeKeysInverted = false
private set
var usePageTransitions = false
private set
var imageScaleType = 1
private set
var imageZoomType = ZoomType.Left
private set
var imageCropBorders = false
private set
var doubleTapAnimDuration = 500
private set
init {
preferences.readWithTapping()
.register({ tappingEnabled = it })
preferences.readWithLongTap()
.register({ longTapEnabled = it })
preferences.pageTransitions()
.register({ usePageTransitions = it })
preferences.imageScaleType()
.register({ imageScaleType = it }, { imagePropertyChangedListener?.invoke() })
preferences.zoomStart()
.register({ zoomTypeFromPreference(it) }, { imagePropertyChangedListener?.invoke() })
preferences.cropBorders()
.register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() })
preferences.doubleTapAnimSpeed()
.register({ doubleTapAnimDuration = it })
preferences.readWithVolumeKeys()
.register({ volumeKeysEnabled = it })
preferences.readWithVolumeKeysInverted()
.register({ volumeKeysInverted = it })
}
fun unsubscribe() {
subscriptions.unsubscribe()
}
private fun <T> Preference<T>.register(
valueAssignment: (T) -> Unit,
onChanged: (T) -> Unit = {}
) {
asObservable()
.doOnNext(valueAssignment)
.skip(1)
.distinctUntilChanged()
.doOnNext(onChanged)
.subscribe()
.addTo(subscriptions)
}
private fun zoomTypeFromPreference(value: Int) {
imageZoomType = when (value) {
// Auto
1 -> when (viewer) {
is L2RPagerViewer -> ZoomType.Left
is R2LPagerViewer -> ZoomType.Right
else -> ZoomType.Center
}
// Left
2 -> ZoomType.Left
// Right
3 -> ZoomType.Right
// Center
else -> ZoomType.Center
}
}
enum class ZoomType {
Left, Center, Right
}
}

View File

@ -0,0 +1,467 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.PointF
import android.graphics.drawable.Drawable
import android.net.Uri
import android.view.GestureDetector
import android.view.Gravity
import android.view.MotionEvent
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.bumptech.glide.request.transition.NoTransition
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.github.chrisbanes.photoview.PhotoView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.glide.GlideApp
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressBar
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerConfig.ZoomType
import eu.kanade.tachiyomi.util.ImageUtil
import eu.kanade.tachiyomi.util.dpToPx
import eu.kanade.tachiyomi.util.gone
import eu.kanade.tachiyomi.util.visible
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import java.io.InputStream
import java.util.concurrent.TimeUnit
/**
* View of the ViewPager that contains a page of a chapter.
*/
@SuppressLint("ViewConstructor")
class PagerPageHolder(
val viewer: PagerViewer,
val page: ReaderPage
) : FrameLayout(viewer.activity), ViewPagerAdapter.PositionableView {
/**
* Item that identifies this view. Needed by the adapter to not recreate views.
*/
override val item
get() = page
/**
* Loading progress bar to indicate the current progress.
*/
private val progressBar = createProgressBar()
/**
* Image view that supports subsampling on zoom.
*/
private var subsamplingImageView: SubsamplingScaleImageView? = null
/**
* Simple image view only used on GIFs.
*/
private var imageView: ImageView? = null
/**
* Retry button used to allow retrying.
*/
private var retryButton: PagerButton? = null
/**
* Error layout to show when the image fails to decode.
*/
private var decodeErrorLayout: ViewGroup? = null
/**
* Subscription for status changes of the page.
*/
private var statusSubscription: Subscription? = null
/**
* Subscription for progress changes of the page.
*/
private var progressSubscription: Subscription? = null
/**
* Subscription used to read the header of the image. This is needed in order to instantiate
* the appropiate image view depending if the image is animated (GIF).
*/
private var readImageHeaderSubscription: Subscription? = null
init {
addView(progressBar)
observeStatus()
}
/**
* Called when this view is detached from the window. Unsubscribes any active subscription.
*/
@SuppressLint("ClickableViewAccessibility")
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
unsubscribeProgress()
unsubscribeStatus()
unsubscribeReadImageHeader()
subsamplingImageView?.setOnImageEventListener(null)
}
/**
* Observes the status of the page and notify the changes.
*
* @see processStatus
*/
private fun observeStatus() {
statusSubscription?.unsubscribe()
val loader = page.chapter.pageLoader ?: return
statusSubscription = loader.getPage(page)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { processStatus(it) }
}
/**
* Observes the progress of the page and updates view.
*/
private fun observeProgress() {
progressSubscription?.unsubscribe()
progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS)
.map { page.progress }
.distinctUntilChanged()
.onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { value -> progressBar.setProgress(value) }
}
/**
* Called when the status of the page changes.
*
* @param status the new status of the page.
*/
private fun processStatus(status: Int) {
when (status) {
Page.QUEUE -> setQueued()
Page.LOAD_PAGE -> setLoading()
Page.DOWNLOAD_IMAGE -> {
observeProgress()
setDownloading()
}
Page.READY -> {
setImage()
unsubscribeProgress()
}
Page.ERROR -> {
setError()
unsubscribeProgress()
}
}
}
/**
* Unsubscribes from the status subscription.
*/
private fun unsubscribeStatus() {
statusSubscription?.unsubscribe()
statusSubscription = null
}
/**
* Unsubscribes from the progress subscription.
*/
private fun unsubscribeProgress() {
progressSubscription?.unsubscribe()
progressSubscription = null
}
/**
* Unsubscribes from the read image header subscription.
*/
private fun unsubscribeReadImageHeader() {
readImageHeaderSubscription?.unsubscribe()
readImageHeaderSubscription = null
}
/**
* Called when the page is queued.
*/
private fun setQueued() {
progressBar.visible()
retryButton?.gone()
decodeErrorLayout?.gone()
}
/**
* Called when the page is loading.
*/
private fun setLoading() {
progressBar.visible()
retryButton?.gone()
decodeErrorLayout?.gone()
}
/**
* Called when the page is downloading.
*/
private fun setDownloading() {
progressBar.visible()
retryButton?.gone()
decodeErrorLayout?.gone()
}
/**
* Called when the page is ready.
*/
private fun setImage() {
progressBar.visible()
progressBar.completeAndFadeOut()
retryButton?.gone()
decodeErrorLayout?.gone()
unsubscribeReadImageHeader()
val streamFn = page.stream ?: return
var openStream: InputStream? = null
readImageHeaderSubscription = Observable
.fromCallable {
val stream = streamFn().buffered(16)
openStream = stream
ImageUtil.findImageType(stream) == ImageUtil.ImageType.GIF
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { isAnimated ->
if (!isAnimated) {
initSubsamplingImageView().setImage(ImageSource.inputStream(openStream!!))
} else {
initImageView().setImage(openStream!!)
}
}
// Keep the Rx stream alive to close the input stream only when unsubscribed
.flatMap { Observable.never<Unit>() }
.doOnUnsubscribe { openStream?.close() }
.subscribe({}, {})
}
/**
* Called when the page has an error.
*/
private fun setError() {
progressBar.gone()
initRetryButton().visible()
}
/**
* Called when the image is decoded and going to be displayed.
*/
private fun onImageDecoded() {
progressBar.gone()
}
/**
* Called when an image fails to decode.
*/
private fun onImageDecodeError() {
progressBar.gone()
initDecodeErrorLayout().visible()
}
/**
* Creates a new progress bar.
*/
@SuppressLint("PrivateResource")
private fun createProgressBar(): ReaderProgressBar {
return ReaderProgressBar(context, null).apply {
val size = 48.dpToPx
layoutParams = FrameLayout.LayoutParams(size, size).apply {
gravity = Gravity.CENTER
}
}
}
/**
* Initializes a subsampling scale view.
*/
private fun initSubsamplingImageView(): SubsamplingScaleImageView {
if (subsamplingImageView != null) return subsamplingImageView!!
val config = viewer.config
subsamplingImageView = SubsamplingScaleImageView(context).apply {
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
setMaxTileSize(viewer.activity.maxBitmapSize)
setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_FIXED)
setDoubleTapZoomDuration(config.doubleTapAnimDuration)
setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
setMinimumScaleType(config.imageScaleType)
setMinimumDpi(90)
setMinimumTileDpi(180)
setCropBorders(config.imageCropBorders)
setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() {
override fun onReady() {
when (config.imageZoomType) {
ZoomType.Left -> setScaleAndCenter(scale, PointF(0f, 0f))
ZoomType.Right -> setScaleAndCenter(scale, PointF(sWidth.toFloat(), 0f))
ZoomType.Center -> setScaleAndCenter(scale, center.also { it?.y = 0f })
}
onImageDecoded()
}
override fun onImageLoadError(e: Exception) {
onImageDecodeError()
}
})
}
addView(subsamplingImageView)
return subsamplingImageView!!
}
/**
* Initializes an image view, used for GIFs.
*/
private fun initImageView(): ImageView {
if (imageView != null) return imageView!!
imageView = PhotoView(context, null).apply {
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
adjustViewBounds = true
setZoomTransitionDuration(viewer.config.doubleTapAnimDuration)
setScaleLevels(1f, 2f, 3f)
// Force 2 scale levels on double tap
setOnDoubleTapListener(object : GestureDetector.SimpleOnGestureListener() {
override fun onDoubleTap(e: MotionEvent): Boolean {
if (scale > 1f) {
setScale(1f, e.x, e.y, true)
} else {
setScale(2f, e.x, e.y, true)
}
return true
}
})
}
addView(imageView)
return imageView!!
}
/**
* Initializes a button to retry pages.
*/
private fun initRetryButton(): PagerButton {
if (retryButton != null) return retryButton!!
retryButton = PagerButton(context, viewer).apply {
layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
gravity = Gravity.CENTER
}
setText(R.string.action_retry)
setOnClickListener {
page.chapter.pageLoader?.retryPage(page)
}
}
addView(retryButton)
return retryButton!!
}
/**
* Initializes a decode error layout.
*/
private fun initDecodeErrorLayout(): ViewGroup {
if (decodeErrorLayout != null) return decodeErrorLayout!!
val margins = 8.dpToPx
val decodeLayout = LinearLayout(context).apply {
gravity = Gravity.CENTER
orientation = LinearLayout.VERTICAL
}
decodeErrorLayout = decodeLayout
TextView(context).apply {
layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
setMargins(margins, margins, margins, margins)
}
gravity = Gravity.CENTER
setText(R.string.decode_image_error)
decodeLayout.addView(this)
}
PagerButton(context, viewer).apply {
layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
setMargins(margins, margins, margins, margins)
}
setText(R.string.action_retry)
setOnClickListener {
page.chapter.pageLoader?.retryPage(page)
}
decodeLayout.addView(this)
}
val imageUrl = page.imageUrl
if (imageUrl.orEmpty().startsWith("http")) {
PagerButton(context, viewer).apply {
layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
setMargins(margins, margins, margins, margins)
}
setText(R.string.action_open_in_browser)
setOnClickListener {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(imageUrl))
context.startActivity(intent)
}
decodeLayout.addView(this)
}
}
addView(decodeLayout)
return decodeLayout
}
/**
* Extension method to set a [stream] into this ImageView.
*/
private fun ImageView.setImage(stream: InputStream) {
GlideApp.with(this)
.load(stream)
.skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transition(DrawableTransitionOptions.with(NoTransition.getFactory()))
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean
): Boolean {
onImageDecodeError()
return false
}
override fun onResourceReady(
resource: Drawable?,
model: Any?,
target: Target<Drawable>?,
dataSource: DataSource?,
isFirstResource: Boolean
): Boolean {
onImageDecoded()
return false
}
})
.into(this)
}
}

View File

@ -1,326 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.support.v4.content.ContextCompat
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.viewer.base.BaseReader
import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.LeftToRightReader
import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.RightToLeftReader
import rx.subscriptions.CompositeSubscription
/**
* Implementation of a reader based on a ViewPager.
*/
abstract class PagerReader : BaseReader() {
companion object {
/**
* Zoom automatic alignment.
*/
const val ALIGN_AUTO = 1
/**
* Align to left.
*/
const val ALIGN_LEFT = 2
/**
* Align to right.
*/
const val ALIGN_RIGHT = 3
/**
* Align to right.
*/
const val ALIGN_CENTER = 4
/**
* Left side region of the screen. Used for touch events.
*/
const val LEFT_REGION = 0.33f
/**
* Right side region of the screen. Used for touch events.
*/
const val RIGHT_REGION = 0.66f
}
/**
* Generic interface of a ViewPager.
*/
lateinit var pager: Pager
private set
/**
* Adapter of the pager.
*/
lateinit var adapter: PagerReaderAdapter
private set
/**
* Gesture detector for touch events.
*/
val gestureDetector by lazy { GestureDetector(context, ImageGestureListener()) }
/**
* Subscriptions for reader settings.
*/
var subscriptions: CompositeSubscription? = null
private set
/**
* Whether transitions are enabled or not.
*/
var transitions: Boolean = false
private set
/**
* Whether to crop image borders.
*/
var cropBorders: Boolean = false
private set
/**
* Duration of the double tap animation
*/
var doubleTapAnimDuration = 500
private set
/**
* Scale type (fit width, fit screen, etc).
*/
var scaleType = 1
private set
/**
* Zoom type (start position).
*/
var zoomType = 1
private set
/**
* Text color for black theme.
*/
val whiteColor by lazy { ContextCompat.getColor(context!!, R.color.textColorSecondaryDark) }
/**
* Text color for white theme.
*/
val blackColor by lazy { ContextCompat.getColor(context!!, R.color.textColorSecondaryLight) }
/**
* Initializes the pager.
*
* @param pager the pager to initialize.
*/
protected fun initializePager(pager: Pager) {
adapter = PagerReaderAdapter(this)
this.pager = pager.apply {
setLayoutParams(ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT))
setOffscreenPageLimit(1)
setId(R.id.reader_pager)
setOnChapterBoundariesOutListener(object : OnChapterBoundariesOutListener {
override fun onFirstPageOutEvent() {
readerActivity.requestPreviousChapter()
}
override fun onLastPageOutEvent() {
readerActivity.requestNextChapter()
}
})
setOnPageChangeListener { onPageChanged(it) }
}
pager.adapter = adapter
subscriptions = CompositeSubscription().apply {
val preferences = readerActivity.preferences
add(preferences.imageDecoder()
.asObservable()
.doOnNext { setDecoderClass(it) }
.skip(1)
.distinctUntilChanged()
.subscribe { refreshAdapter() })
add(preferences.zoomStart()
.asObservable()
.doOnNext { setZoomStart(it) }
.skip(1)
.distinctUntilChanged()
.subscribe { refreshAdapter() })
add(preferences.imageScaleType()
.asObservable()
.doOnNext { scaleType = it }
.skip(1)
.distinctUntilChanged()
.subscribe { refreshAdapter() })
add(preferences.pageTransitions()
.asObservable()
.subscribe { transitions = it })
add(preferences.cropBorders()
.asObservable()
.doOnNext { cropBorders = it }
.skip(1)
.distinctUntilChanged()
.subscribe { refreshAdapter() })
add(preferences.doubleTapAnimSpeed()
.asObservable()
.subscribe { doubleTapAnimDuration = it })
}
setPagesOnAdapter()
}
override fun onDestroyView() {
pager.clearOnPageChangeListeners()
subscriptions?.unsubscribe()
super.onDestroyView()
}
/**
* Gesture detector for Subsampling Scale Image View.
*/
inner class ImageGestureListener : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
if (isAdded) {
val positionX = e.x
if (positionX < pager.width * LEFT_REGION) {
if (tappingEnabled) moveLeft()
} else if (positionX > pager.width * RIGHT_REGION) {
if (tappingEnabled) moveRight()
} else {
readerActivity.toggleMenu()
}
}
return true
}
}
/**
* Called when a new chapter is set in [BaseReader].
*
* @param chapter the chapter set.
* @param currentPage the initial page to display.
*/
override fun onChapterSet(chapter: ReaderChapter, currentPage: Page) {
this.currentPage = getPageIndex(currentPage) // we might have a new page object
// Make sure the view is already initialized.
if (view != null) {
setPagesOnAdapter()
}
}
/**
* Called when a chapter is appended in [BaseReader].
*
* @param chapter the chapter appended.
*/
override fun onChapterAppended(chapter: ReaderChapter) {
// Make sure the view is already initialized.
if (view != null) {
adapter.pages = pages
}
}
/**
* Sets the pages on the adapter.
*/
protected fun setPagesOnAdapter() {
if (pages.isNotEmpty()) {
// Prevent a wrong active page when changing chapters with the navigation buttons.
val currPage = currentPage
adapter.pages = pages
currentPage = currPage
if (currentPage == pager.currentItem) {
onPageChanged(currentPage)
} else {
setActivePage(currentPage)
}
}
}
/**
* Sets the active page.
*
* @param pageNumber the index of the page from [pages].
*/
override fun setActivePage(pageNumber: Int) {
pager.setCurrentItem(pageNumber, false)
}
/**
* Refresh the adapter.
*/
private fun refreshAdapter() {
pager.adapter = adapter
pager.setCurrentItem(currentPage, false)
}
/**
* Moves a page to the right.
*/
override fun moveRight() {
moveToNext()
}
/**
* Moves a page to the left.
*/
override fun moveLeft() {
moveToPrevious()
}
/**
* Moves to the next page or requests the next chapter if it's the last one.
*/
protected fun moveToNext() {
if (pager.currentItem != pager.adapter.count - 1) {
pager.setCurrentItem(pager.currentItem + 1, transitions)
} else {
readerActivity.requestNextChapter()
}
}
/**
* Moves to the previous page or requests the previous chapter if it's the first one.
*/
protected fun moveToPrevious() {
if (pager.currentItem != 0) {
pager.setCurrentItem(pager.currentItem - 1, transitions)
} else {
readerActivity.requestPreviousChapter()
}
}
/**
* Sets the zoom start position.
*
* @param zoomStart the value stored in preferences.
*/
private fun setZoomStart(zoomStart: Int) {
if (zoomStart == ALIGN_AUTO) {
if (this is LeftToRightReader)
setZoomStart(ALIGN_LEFT)
else if (this is RightToLeftReader)
setZoomStart(ALIGN_RIGHT)
else
setZoomStart(ALIGN_CENTER)
} else {
zoomType = zoomStart
}
}
}

View File

@ -1,47 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.support.v4.view.PagerAdapter
import android.view.View
import android.view.ViewGroup
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.inflate
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
/**
* Adapter of pages for a ViewPager.
*/
class PagerReaderAdapter(private val reader: PagerReader) : ViewPagerAdapter() {
/**
* Pages stored in the adapter.
*/
var pages: List<Page> = emptyList()
set(value) {
field = value
notifyDataSetChanged()
}
override fun createView(container: ViewGroup, position: Int): View {
val view = container.inflate(R.layout.reader_pager_item) as PageView
view.initialize(reader, pages[position])
return view
}
/**
* Returns the number of pages.
*/
override fun getCount(): Int {
return pages.size
}
override fun getItemPosition(obj: Any): Int {
val view = obj as PageView
return if (view.page in pages) {
PagerAdapter.POSITION_UNCHANGED
} else {
PagerAdapter.POSITION_NONE
}
}
}

View File

@ -0,0 +1,207 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.annotation.SuppressLint
import android.graphics.Typeface
import android.support.v7.widget.AppCompatTextView
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.StyleSpan
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.util.dpToPx
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
/**
* View of the ViewPager that contains a chapter transition.
*/
@SuppressLint("ViewConstructor")
class PagerTransitionHolder(
val viewer: PagerViewer,
val transition: ChapterTransition
) : LinearLayout(viewer.activity), ViewPagerAdapter.PositionableView {
/**
* Item that identifies this view. Needed by the adapter to not recreate views.
*/
override val item: Any
get() = transition
/**
* Subscription for status changes of the transition page.
*/
private var statusSubscription: Subscription? = null
/**
* Text view used to display the text of the current and next/prev chapters.
*/
private var textView = TextView(context).apply {
wrapContent()
}
/**
* View container of the current status of the transition page. Child views will be added
* dynamically.
*/
private var pagesContainer = LinearLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
orientation = VERTICAL
gravity = Gravity.CENTER
}
init {
orientation = VERTICAL
gravity = Gravity.CENTER
val sidePadding = 64.dpToPx
setPadding(sidePadding, 0, sidePadding, 0)
addView(textView)
addView(pagesContainer)
when (transition) {
is ChapterTransition.Prev -> bindPrevChapterTransition()
is ChapterTransition.Next -> bindNextChapterTransition()
}
}
/**
* Called when this view is detached from the window. Unsubscribes any active subscription.
*/
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
statusSubscription?.unsubscribe()
statusSubscription = null
}
/**
* Binds a next chapter transition on this view and subscribes to the load status.
*/
private fun bindNextChapterTransition() {
val nextChapter = transition.to
textView.text = if (nextChapter != null) {
SpannableStringBuilder().apply {
append(context.getString(R.string.transition_finished))
setSpan(StyleSpan(Typeface.BOLD), 0, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
append("\n${transition.from.chapter.name}\n\n")
val currSize = length
append(context.getString(R.string.transition_next))
setSpan(StyleSpan(Typeface.BOLD), currSize, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
append("\n${nextChapter.chapter.name}\n\n")
}
} else {
context.getString(R.string.transition_no_next)
}
if (nextChapter != null) {
observeStatus(nextChapter)
}
}
/**
* Binds a previous chapter transition on this view and subscribes to the page load status.
*/
private fun bindPrevChapterTransition() {
val prevChapter = transition.to
textView.text = if (prevChapter != null) {
SpannableStringBuilder().apply {
append(context.getString(R.string.transition_current))
setSpan(StyleSpan(Typeface.BOLD), 0, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
append("\n${transition.from.chapter.name}\n\n")
val currSize = length
append(context.getString(R.string.transition_previous))
setSpan(StyleSpan(Typeface.BOLD), currSize, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
append("\n${prevChapter.chapter.name}\n\n")
}
} else {
context.getString(R.string.transition_no_previous)
}
if (prevChapter != null) {
observeStatus(prevChapter)
}
}
/**
* Observes the status of the page list of the next/previous chapter. Whenever there's a new
* state, the pages container is cleaned up before setting the new state.
*/
private fun observeStatus(chapter: ReaderChapter) {
statusSubscription?.unsubscribe()
statusSubscription = chapter.stateObserver
.observeOn(AndroidSchedulers.mainThread())
.subscribe { state ->
pagesContainer.removeAllViews()
when (state) {
is ReaderChapter.State.Wait -> {}
is ReaderChapter.State.Loading -> setLoading()
is ReaderChapter.State.Error -> setError(state.error)
is ReaderChapter.State.Loaded -> setLoaded()
}
}
}
/**
* Sets the loading state on the pages container.
*/
private fun setLoading() {
val progress = ProgressBar(context, null, android.R.attr.progressBarStyle)
val textView = AppCompatTextView(context).apply {
wrapContent()
setText(R.string.transition_pages_loading)
}
pagesContainer.addView(progress)
pagesContainer.addView(textView)
}
/**
* Sets the loaded state on the pages container.
*/
private fun setLoaded() {
// No additional view is added
}
/**
* Sets the error state on the pages container.
*/
private fun setError(error: Throwable) {
val textView = AppCompatTextView(context).apply {
wrapContent()
text = context.getString(R.string.transition_pages_error, error.message)
}
val retryBtn = PagerButton(context, viewer).apply {
wrapContent()
setText(R.string.action_retry)
setOnClickListener {
val toChapter = transition.to
if (toChapter != null) {
viewer.activity.requestPreloadChapter(toChapter)
}
}
}
pagesContainer.addView(textView)
pagesContainer.addView(retryBtn)
}
/**
* Extension method to set layout params to wrap content on this view.
*/
private fun View.wrapContent() {
layoutParams = ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
}
}

View File

@ -0,0 +1,316 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.support.v4.view.ViewPager
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup.LayoutParams
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer
import timber.log.Timber
/**
* Implementation of a [BaseViewer] to display pages with a [ViewPager].
*/
@Suppress("LeakingThis")
abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
/**
* View pager used by this viewer. It's abstract to implement L2R, R2L and vertical pagers on
* top of this class.
*/
val pager = createPager()
/**
* Configuration used by the pager, like allow taps, scale mode on images, page transitions...
*/
val config = PagerConfig(this)
/**
* Adapter of the pager.
*/
private val adapter = PagerViewerAdapter(this)
/**
* Currently active item. It can be a chapter page or a chapter transition.
*/
private var currentPage: Any? = null
/**
* Viewer chapters to set when the pager enters idle mode. Otherwise, if the view was settling
* or dragging, there'd be a noticeable and annoying jump.
*/
private var awaitingIdleViewerChapters: ViewerChapters? = null
/**
* Whether the view pager is currently in idle mode. It sets the awaiting chapters if setting
* this field to true.
*/
private var isIdle = true
set(value) {
field = value
if (value) {
awaitingIdleViewerChapters?.let {
setChaptersInternal(it)
awaitingIdleViewerChapters = null
}
}
}
init {
pager.visibility = View.GONE // Don't layout the pager yet
pager.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
pager.offscreenPageLimit = 1
pager.id = R.id.reader_pager
pager.adapter = adapter
pager.addOnPageChangeListener(object : ViewPager.SimpleOnPageChangeListener() {
override fun onPageSelected(position: Int) {
val page = adapter.items.getOrNull(position)
if (page != null && currentPage != page) {
currentPage = page
when (page) {
is ReaderPage -> onPageSelected(page, position)
is ChapterTransition -> onTransitionSelected(page)
}
}
}
override fun onPageScrollStateChanged(state: Int) {
isIdle = state == ViewPager.SCROLL_STATE_IDLE
}
})
pager.tapListener = { event ->
val positionX = event.x
when {
positionX < pager.width * 0.33f -> if (config.tappingEnabled) moveLeft()
positionX > pager.width * 0.66f -> if (config.tappingEnabled) moveRight()
else -> activity.toggleMenu()
}
}
pager.longTapListener = f@ {
if (activity.menuVisible || config.longTapEnabled) {
val item = adapter.items.getOrNull(pager.currentItem)
if (item is ReaderPage) {
activity.onPageLongTap(item)
return@f true
}
}
false
}
config.imagePropertyChangedListener = {
refreshAdapter()
}
}
/**
* Creates a new ViewPager.
*/
abstract fun createPager(): Pager
/**
* Returns the view this viewer uses.
*/
override fun getView(): View {
return pager
}
/**
* Destroys this viewer. Called when leaving the reader or swapping viewers.
*/
override fun destroy() {
super.destroy()
config.unsubscribe()
}
/**
* Called from the ViewPager listener when a [page] is marked as active. It notifies the
* activity of the change and requests the preload of the next chapter if this is the last page.
*/
private fun onPageSelected(page: ReaderPage, position: Int) {
val pages = page.chapter.pages!! // Won't be null because it's the loaded chapter
Timber.d("onPageSelected: ${page.number}/${pages.size}")
activity.onPageSelected(page)
if (page === pages.last()) {
Timber.d("Request preload next chapter because we're at the last page")
val transition = adapter.items.getOrNull(position + 1) as? ChapterTransition.Next
if (transition?.to != null) {
activity.requestPreloadChapter(transition.to)
}
}
}
/**
* Called from the ViewPager listener when a [transition] is marked as active. It request the
* preload of the destination chapter of the transition.
*/
private fun onTransitionSelected(transition: ChapterTransition) {
Timber.d("onTransitionSelected: $transition")
val toChapter = transition.to
if (toChapter != null) {
Timber.d("Request preload destination chapter because we're on the transition")
activity.requestPreloadChapter(toChapter)
} else if (transition is ChapterTransition.Next) {
// No more chapters, show menu because the user is probably going to close the reader
activity.showMenu()
}
}
/**
* Tells this viewer to set the given [chapters] as active. If the pager is currently idle,
* it sets the chapters immediately, otherwise they are saved and set when it becomes idle.
*/
override fun setChapters(chapters: ViewerChapters) {
if (isIdle) {
setChaptersInternal(chapters)
} else {
awaitingIdleViewerChapters = chapters
}
}
/**
* Sets the active [chapters] on this pager.
*/
private fun setChaptersInternal(chapters: ViewerChapters) {
Timber.d("setChaptersInternal")
adapter.setChapters(chapters)
// Layout the pager once a chapter is being set
if (pager.visibility == View.GONE) {
Timber.d("Pager first layout")
val pages = chapters.currChapter.pages ?: return
moveToPage(pages[chapters.currChapter.requestedPage])
pager.visibility = View.VISIBLE
}
}
/**
* Tells this viewer to move to the given [page].
*/
override fun moveToPage(page: ReaderPage) {
Timber.d("moveToPage")
val position = adapter.items.indexOf(page)
if (position != -1) {
pager.setCurrentItem(position, true)
} else {
Timber.d("Page $page not found in adapter")
}
}
/**
* Moves to the next page.
*/
open fun moveToNext() {
moveRight()
}
/**
* Moves to the previous page.
*/
open fun moveToPrevious() {
moveLeft()
}
/**
* Moves to the page at the right.
*/
protected open fun moveRight() {
if (pager.currentItem != adapter.count - 1) {
pager.setCurrentItem(pager.currentItem + 1, config.usePageTransitions)
}
}
/**
* Moves to the page at the left.
*/
protected open fun moveLeft() {
if (pager.currentItem != 0) {
pager.setCurrentItem(pager.currentItem - 1, config.usePageTransitions)
}
}
/**
* Moves to the page at the top (or previous).
*/
protected open fun moveUp() {
moveToPrevious()
}
/**
* Moves to the page at the bottom (or next).
*/
protected open fun moveDown() {
moveToNext()
}
/**
* Resets the adapter in order to recreate all the views. Used when a image configuration is
* changed.
*/
private fun refreshAdapter() {
val currentItem = pager.currentItem
pager.adapter = adapter
pager.setCurrentItem(currentItem, false)
}
/**
* Called from the containing activity when a key [event] is received. It should return true
* if the event was handled, false otherwise.
*/
override fun handleKeyEvent(event: KeyEvent): Boolean {
val isUp = event.action == KeyEvent.ACTION_UP
when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN -> {
if (!config.volumeKeysEnabled || activity.menuVisible) {
return false
} else if (isUp) {
if (!config.volumeKeysInverted) moveDown() else moveUp()
}
}
KeyEvent.KEYCODE_VOLUME_UP -> {
if (!config.volumeKeysEnabled || activity.menuVisible) {
return false
} else if (isUp) {
if (!config.volumeKeysInverted) moveUp() else moveDown()
}
}
KeyEvent.KEYCODE_DPAD_RIGHT -> if (isUp) moveRight()
KeyEvent.KEYCODE_DPAD_LEFT -> if (isUp) moveLeft()
KeyEvent.KEYCODE_DPAD_DOWN -> if (isUp) moveDown()
KeyEvent.KEYCODE_DPAD_UP -> if (isUp) moveUp()
KeyEvent.KEYCODE_PAGE_DOWN -> if (isUp) moveDown()
KeyEvent.KEYCODE_PAGE_UP -> if (isUp) moveUp()
KeyEvent.KEYCODE_MENU -> if (isUp) activity.toggleMenu()
else -> return false
}
return true
}
/**
* Called from the containing activity when a generic motion [event] is received. It should
* return true if the event was handled, false otherwise.
*/
override fun handleGenericMotionEvent(event: MotionEvent): Boolean {
if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
when (event.action) {
MotionEvent.ACTION_SCROLL -> {
if (event.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0.0f) {
moveDown()
} else {
moveUp()
}
return true
}
}
}
return false
}
}

View File

@ -0,0 +1,101 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.support.v4.view.PagerAdapter
import android.view.View
import android.view.ViewGroup
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
import timber.log.Timber
/**
* Pager adapter used by this [viewer] to where [ViewerChapters] updates are posted.
*/
class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
/**
* List of currently set items.
*/
var items: List<Any> = emptyList()
private set
/**
* Updates this adapter with the given [chapters]. It handles setting a few pages of the
* next/previous chapter to allow seamless transitions and inverting the pages if the viewer
* has R2L direction.
*/
fun setChapters(chapters: ViewerChapters) {
val newItems = mutableListOf<Any>()
// Add previous chapter pages and transition.
if (chapters.prevChapter != null) {
// We only need to add the last few pages of the previous chapter, because it'll be
// selected as the current chapter when one of those pages is selected.
val prevPages = chapters.prevChapter.pages
if (prevPages != null) {
newItems.addAll(prevPages.takeLast(2))
}
}
newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
// Add current chapter.
val currPages = chapters.currChapter.pages
if (currPages != null) {
newItems.addAll(currPages)
}
// Add next chapter transition and pages.
newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
if (chapters.nextChapter != null) {
// Add at most two pages, because this chapter will be selected before the user can
// swap more pages.
val nextPages = chapters.nextChapter.pages
if (nextPages != null) {
newItems.addAll(nextPages.take(2))
}
}
if (viewer is R2LPagerViewer) {
newItems.reverse()
}
items = newItems
notifyDataSetChanged()
}
/**
* Returns the amount of items of the adapter.
*/
override fun getCount(): Int {
return items.size
}
/**
* Creates a new view for the item at the given [position].
*/
override fun createView(container: ViewGroup, position: Int): View {
val item = items[position]
return when (item) {
is ReaderPage -> PagerPageHolder(viewer, item)
is ChapterTransition -> PagerTransitionHolder(viewer, item)
else -> throw NotImplementedError("Holder for ${item.javaClass} not implemented")
}
}
/**
* Returns the current position of the given [view] on the adapter.
*/
override fun getItemPosition(view: Any): Int {
if (view is PositionableView) {
val position = items.indexOf(view.item)
if (position != -1) {
return position
} else {
Timber.d("Position for ${view.item} not found")
}
}
return PagerAdapter.POSITION_NONE
}
}

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