Compare commits

...

95 Commits

Author SHA1 Message Date
ba674935f4 Release 0.8.4 2019-04-13 15:10:44 +02:00
a053d55fbc Disable proguard 2019-04-13 14:57:58 +02:00
38ba8852a3 Release 0.8.3 2019-04-13 14:18:10 +02:00
3533359fae Use single task activity 2019-04-13 13:09:01 +02:00
0a988d1c69 Enable new translations 2019-04-12 19:19:35 +02:00
5f9e65cc9b Fix lint issues on new strings 2019-04-12 19:07:41 +02:00
026188268d Translations (#1886)
* Translated using Weblate (Czech)

Currently translated at 99.8% (426 of 427 strings)

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

* Translated using Weblate (Hindi)

Currently translated at 99.8% (426 of 427 strings)

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

* Translated using Weblate (Korean)

Currently translated at 79.2% (338 of 427 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (427 of 427 strings)

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

* Translated using Weblate (Korean)

Currently translated at 81.5% (348 of 427 strings)

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

Translated using Weblate (Korean)

Currently translated at 81.5% (348 of 427 strings)

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

* Translated using Weblate (Korean)

Currently translated at 94.1% (402 of 427 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (427 of 427 strings)

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

* Translated using Weblate (Hindi)

Currently translated at 99.8% (426 of 427 strings)

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

* Translated using Weblate (Korean)

Currently translated at 95.3% (407 of 427 strings)

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

* Translated using Weblate (Dutch)

Currently translated at 100.0% (427 of 427 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.8% (426 of 427 strings)

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

* Added translation using Weblate (Sardinian)

* Translated using Weblate (Sardinian)

Currently translated at 52.7% (225 of 427 strings)

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

* Added translation using Weblate (Filipino)

* Translated using Weblate (Filipino)

Currently translated at 5.2% (22 of 427 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (427 of 427 strings)

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

* Translated using Weblate (Filipino)

Currently translated at 11.2% (48 of 427 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (427 of 427 strings)

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

* Translated using Weblate (Turkish)

Currently translated at 100.0% (427 of 427 strings)

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

* Translated using Weblate (Sardinian)

Currently translated at 56.4% (241 of 427 strings)

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

* Translated using Weblate (Portuguese)

Currently translated at 81.5% (348 of 427 strings)

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

* Translated using Weblate (Portuguese)

Currently translated at 81.5% (348 of 427 strings)

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

* Translated using Weblate (Serbian)

Currently translated at 77.5% (331 of 427 strings)

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

* Translated using Weblate (Sardinian)

Currently translated at 71.7% (306 of 427 strings)

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (427 of 427 strings)

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

* Translated using Weblate (Russian)

Currently translated at 100.0% (427 of 427 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (427 of 427 strings)

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

* Translated using Weblate (Sardinian)

Currently translated at 100.0% (427 of 427 strings)

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

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (427 of 427 strings)

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/bg/
(cherry picked from commit ab854420b5e52f145146da9df5f2f4ff2ee283f1)
2019-04-12 19:04:44 +02:00
0e3464457c Remove internal sources 2019-04-12 19:05:18 +02:00
56195434e7 Add intent filter for external queries 2019-04-12 18:40:04 +02:00
ba2194f435 Load urls inside webview 2019-04-12 17:29:02 +02:00
e7df172da1 Provide default web view client so that redirections work 2019-04-08 10:13:58 +02:00
e7606e6dca Add option to open manga details in a WebView 2019-04-08 02:08:40 +02:00
8d4c0f505c Fix shared files not deleted from internal cache 2019-04-07 14:58:40 +02:00
8f2878a841 Added search intent handler and Google Search Action, for the global search (#1787)
* Added search intent handler

* Added support for Google Search actions
2019-04-03 10:25:52 +02:00
77296348a0 add option to skip chapters marked read (#1791) 2019-04-03 10:22:32 +02:00
a62a7d5330 Feature/shikomori track (#1905)
* Add shikomori track

* Fix char 'M'

* Fix date in search
2019-04-03 10:14:37 +02:00
bf60aae9d8 Fix crashes below L 2019-04-03 09:47:07 +02:00
ecc1520100 Use OkHttp to solve the challenge 2019-04-02 00:26:03 +02:00
f1f6a2b341 Test solving Cloudflare's challenge with WebView 2019-04-01 17:20:13 +02:00
55bf1c31a6 Set explicit autobackup rules 2019-04-01 17:14:37 +02:00
e47dd3d587 Add 32-bit color mode to reader settings (#1941)
* add ARGB_8888 mode to reader settings

* Only show option on Oreo or later.
Only show option in settings screen.
2019-03-30 14:21:35 +01:00
af0e3a278f Fix discord link (#1951)
Fix discord link
2019-03-30 06:39:10 -04:00
493ad93957 Release 0.8.2 2019-03-27 13:21:44 +01:00
dbe8f3cfbe Fix bug with update lib and parse chapters (#1927)
* Fix bug with update lib and parse chapters

* Fix else condition
2019-03-25 14:53:17 +01:00
08cdac968d Fix strings and add new languages 2019-03-25 14:52:07 +01:00
f12d5ba689 Storio imported from Jitpack. Also fix an issue with the progress bar animation on the reader 2019-03-22 23:06:05 +01:00
0afd77d110 Update ISSUE_TEMPLATE.md 2019-03-22 19:26:04 +01:00
7551941ef2 [Cloudflare] Fix recent CF JS Challenge error that calls DOM (#1919)
* [Cloudflare] Fix recent CF JS Challenge error that calls DOM

* Replace `atob` to pure js version. (was node.js API which invalid)

* Use `atob` as native function `Base64.decode()``

* Use okio Base64 decoder instead of Android one.
2019-03-22 19:25:21 +01:00
9ca0307e1c [Cloudflare] Fix 503 due to missing value in js challenge. (#1913)
Related issues: inorichi/tachiyomi-extensions#951
2019-03-21 08:48:03 +01:00
9a6f8be28c Remove F-Droid on README.md (#1891)
* Remove F-Droid on README.md

Seems F-Droid is not updated anymore.

* Remove unnecessary whitespace
2019-03-18 14:58:50 +01:00
9baf3b5a09 Release 0.8.1 2019-03-15 16:50:13 +01:00
ca3f0873f3 Extract hardcoded strings from layouts 2019-03-15 08:48:12 +01:00
adb0201449 Translations (#1750)
* Translated using Weblate (Indonesian)

Currently translated at 100.0% (425 of 425 strings)

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

* Added translation using Weblate (Czech)

* Translated using Weblate (Czech)

Currently translated at 45.6% (194 of 425 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (425 of 425 strings)

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

* Added translation using Weblate (Greek)

* Translated using Weblate (Greek)

Currently translated at 99.8% (424 of 425 strings)

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

* Translated using Weblate (Italian)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Romanian)

Currently translated at 100.0% (425 of 425 strings)

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

* Added translation using Weblate (Swedish)

* Translated using Weblate (Swedish)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Czech)

Currently translated at 45.6% (194 of 425 strings)

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

* Translated using Weblate (Italian)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Romanian)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Russian)

Currently translated at 98.1% (417 of 425 strings)

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

* Translated using Weblate (Russian)

Currently translated at 98.4% (418 of 425 strings)

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

* Translated using Weblate (Russian)

Currently translated at 98.4% (418 of 425 strings)

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

* Translated using Weblate (Italian)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Portuguese)

Currently translated at 80.5% (342 of 425 strings)

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

* Translated using Weblate (Portuguese)

Currently translated at 80.5% (342 of 425 strings)

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

* Translated using Weblate (Portuguese)

Currently translated at 80.5% (342 of 425 strings)

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

* Translated using Weblate (Russian)

Currently translated at 98.8% (420 of 425 strings)

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

* Translated using Weblate (Russian)

Currently translated at 98.8% (420 of 425 strings)

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

* Translated using Weblate (Hindi)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Russian)

Currently translated at 99.3% (422 of 425 strings)

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

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Bulgarian)

Currently translated at 97.9% (416 of 425 strings)

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

* Translated using Weblate (Bengali)

Currently translated at 99.5% (423 of 425 strings)

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

* Translated using Weblate (Czech)

Currently translated at 80.7% (343 of 425 strings)

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

* Translated using Weblate (Russian)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Russian)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Russian)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Russian)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Russian)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Russian)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Russian)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Czech)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Vietnamese)

Currently translated at 86.8% (369 of 425 strings)

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

* Translated using Weblate (Russian)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Japanese)

Currently translated at 43.1% (183 of 425 strings)

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

* Translated using Weblate (Bulgarian)

Currently translated at 99.8% (424 of 425 strings)

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

* Translated using Weblate (Greek)

Currently translated at 99.8% (424 of 425 strings)

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

* Translated using Weblate (Bengali)

Currently translated at 99.8% (424 of 425 strings)

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

* Translated using Weblate (Vietnamese)

Currently translated at 88.0% (374 of 425 strings)

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

* Translated using Weblate (English)

Currently translated at 100.0% (425 of 425 strings)

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

* Added translation using Weblate (Norwegian Bokmål)

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 31.8% (135 of 425 strings)

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

* Translated using Weblate (Hindi)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Vietnamese)

Currently translated at 90.8% (386 of 425 strings)

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

* Added translation using Weblate (Thai)

* Translated using Weblate (Thai)

Currently translated at 2.4% (10 of 425 strings)

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

* Translated using Weblate (Arabic)

Currently translated at 99.8% (424 of 425 strings)

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

* Translated using Weblate (Czech)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Polish)

Currently translated at 100.0% (425 of 425 strings)

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

* Added translation using Weblate (Catalan)

* Translated using Weblate (Catalan)

Currently translated at 19.1% (81 of 425 strings)

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

* Translated using Weblate (Polish)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Catalan)

Currently translated at 32.9% (140 of 425 strings)

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

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Catalan)

Currently translated at 45.9% (195 of 425 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Portuguese)

Currently translated at 81.2% (345 of 425 strings)

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

* Translated using Weblate (Hungarian)

Currently translated at 39.8% (169 of 425 strings)

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

* Added translation using Weblate (Serbian)

* Translated using Weblate (Serbian)

Currently translated at 57.2% (243 of 425 strings)

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

* Translated using Weblate (Serbian)

Currently translated at 75.1% (319 of 425 strings)

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

* Translated using Weblate (Dutch)

Currently translated at 87.3% (371 of 425 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (425 of 425 strings)

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

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Italian)

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Italian)

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Polish)

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Indonesian)

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Indonesian)

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 90.8% (379 of 417 strings)

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

* Translated using Weblate (French)

Currently translated at 97.3% (406 of 417 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.0% (409 of 417 strings)

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

* Translated using Weblate (Russian)

Currently translated at 97.6% (407 of 417 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Hindi)

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Polish)

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Italian)

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Arabic)

Currently translated at 100.0% (417 of 417 strings)

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

* Added translation using Weblate (Turkish)

* Translated using Weblate (Turkish)

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Portuguese)

Currently translated at 67.6% (282 of 417 strings)

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

* Update translation files

Updated by Clean-up translation files hook in Weblate.

* Translated using Weblate (Turkish)

Currently translated at 100.0% (424 of 424 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (424 of 424 strings)

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

* Translated using Weblate (Bengali)

Currently translated at 97.4% (413 of 424 strings)

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

* Translated using Weblate (Bulgarian)

Currently translated at 98.5% (418 of 424 strings)

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

* Translated using Weblate (Hindi)

Currently translated at 100.0% (424 of 424 strings)

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

* Translated using Weblate (Arabic)

Currently translated at 98.1% (416 of 424 strings)

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

* Added translation using Weblate (Chinese (Simplified))

* Translated using Weblate (Chinese (Simplified))

Currently translated at 17.4% (74 of 424 strings)

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

* Added translation using Weblate (Thai)

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (424 of 424 strings)

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

* Translated using Weblate (Russian)

Currently translated at 99.0% (420 of 424 strings)

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

* Translated using Weblate (Malay)

Currently translated at 86.5% (367 of 424 strings)

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

* Translated using Weblate (Italian)

Currently translated at 100.0% (424 of 424 strings)

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

* Translated using Weblate (Dutch)

Currently translated at 87.5% (371 of 424 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 93.3% (396 of 424 strings)

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

* Translated using Weblate (Portuguese)

Currently translated at 75.2% (319 of 424 strings)

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

* Translated using Weblate (Japanese)

Currently translated at 8.9% (38 of 424 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (424 of 424 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (424 of 424 strings)

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

* Deleted translation using Weblate (Thai)

* Translated using Weblate (Polish)

Currently translated at 100.0% (424 of 424 strings)

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 19.8% (84 of 424 strings)

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (424 of 424 strings)

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (424 of 424 strings)

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Hindi)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Turkish)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Italian)

Currently translated at 100.0% (425 of 425 strings)

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

* Added translation using Weblate (Ukrainian)

* Translated using Weblate (Ukrainian)

Currently translated at 50.4% (214 of 425 strings)

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

* Translated using Weblate (Arabic)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Portuguese)

Currently translated at 75.3% (320 of 425 strings)

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

* Translated using Weblate (Portuguese)

Currently translated at 75.3% (320 of 425 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Russian)

Currently translated at 97.6% (415 of 425 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 54.6% (232 of 425 strings)

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

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (425 of 425 strings)

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

* Translated using Weblate (Hungarian)

Currently translated at 39.3% (167 of 425 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (425 of 425 strings)

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

* remove unused import

* Reuse existing token preference

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

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

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

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

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

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Italian)

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Italian)

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Polish)

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Indonesian)

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Indonesian)

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 90.8% (379 of 417 strings)

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

* Translated using Weblate (French)

Currently translated at 97.3% (406 of 417 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.0% (409 of 417 strings)

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

* Translated using Weblate (Russian)

Currently translated at 97.6% (407 of 417 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Hindi)

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Polish)

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Italian)

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Arabic)

Currently translated at 100.0% (417 of 417 strings)

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

* Added translation using Weblate (Turkish)

* Translated using Weblate (Turkish)

Currently translated at 100.0% (417 of 417 strings)

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

* Translated using Weblate (Portuguese)

Currently translated at 67.6% (282 of 417 strings)

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

* Update translation files

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

* Indicate which source is currently selected during migration

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

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

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

* Add utility methods

* Update dependencies

* Add new reader

* Update tracking services. Extract transition strings into resources

* Restore delete read chapters

* Documentation and some minor changes

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

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

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

* remove extra line

* fixed end date bug
added filtering out novel back in

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

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

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

* Update AnilistModels.kt

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

* vanity url

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

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

* Code style formatting

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

* Close netResponse after read

* Refactor remote_id into media_id and library_id

* DB: Refactor RemoteId

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

* Remove logging interceptor

* Compatability and sql simplification

* Fix score and minor improvements

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

* Added github link to About page (Fixed)

Fixed based on jogerj's comment in #1389

* Changed Github link to correct URL.

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

thought it would be cool to have hyperlinks to the sites

* Update README.md
2018-05-04 16:35:34 +02:00
8874fe973c Bugfixes 2018-04-30 18:31:31 +02:00
230 changed files with 14998 additions and 11012 deletions

View File

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

View File

@ -1,7 +1,11 @@
**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:**
**Android version:**
**Issue/Request:**
**Steps to reproduce (if applicable)**
@ -10,4 +14,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

@ -1,6 +1,6 @@
| Build | Stable | Dev | Contribute | Contact |
|-------|----------|---------|------------|---------|
| [![Travis](https://img.shields.io/travis/inorichi/tachiyomi.svg)](https://travis-ci.org/inorichi/tachiyomi) | [![stable release](https://img.shields.io/github/release/inorichi/tachiyomi.svg?maxAge=3600&label=download%20(autoupdate%20included))](https://github.com/inorichi/tachiyomi/releases) | [![latest dev build](https://img.shields.io/badge/download-latest%20build-blue.svg)](http://tachiyomi.kanade.eu/latest) [![fdroid dev](https://img.shields.io/badge/autoupdate-wiki-blue.svg)](//github.com/inorichi/tachiyomi/wiki/F-Droid-for-dev-versions) | [![Translation status](https://hosted.weblate.org/widgets/tachiyomi/-/svg-badge.svg)](https://hosted.weblate.org/engage/tachiyomi/?utm_source=widget) | [![Discord](https://img.shields.io/discord/349436576037732353.svg)](https://discord.gg/2dDQBv2) |
| [![Travis](https://img.shields.io/travis/inorichi/tachiyomi.svg)](https://travis-ci.org/inorichi/tachiyomi) | [![stable release](https://img.shields.io/github/release/inorichi/tachiyomi.svg?maxAge=3600&label=download%20(autoupdate%20included))](https://github.com/inorichi/tachiyomi/releases) | [![latest dev build](https://img.shields.io/badge/download-latest%20build-blue.svg)](http://tachiyomi.kanade.eu/latest) | [![Translation status](https://hosted.weblate.org/widgets/tachiyomi/-/svg-badge.svg)](https://hosted.weblate.org/engage/tachiyomi/?utm_source=widget) | [![Discord](https://img.shields.io/discord/349436576037732353.svg)](https://discord.gg/tachiyomi) |
# ![app icon](./.github/readme-images/app-icon.png)Tachiyomi
@ -14,16 +14,16 @@ Features include:
* Online reading from sources such as KissManga, MangaFox, [and more](https://github.com/inorichi/tachiyomi-extensions)
* Local reading of downloaded manga
* Configurable reader with multiple viewers, reading directions and other settings
* MyAnimeList, AniList, and Kitsu support
* [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), and [Kitsu](https://kitsu.io/explore/anime) support
* Categories to organize your library
* Light and dark themes
* Schedule updating your library for new chapters
* Create backups locally or to your cloud service of choice
* Create backups locally to read offline or to your desired cloud service
## Download
Get the app from our [releases page](https://github.com/inorichi/tachiyomi/releases).
If you want to try new features before they get to the stable release, you can download the dev version [here](http://tachiyomi.kanade.eu/latest) (auto-updates not included), or add our [F-Droid repo](https://github.com/inorichi/tachiyomi/wiki/F-Droid-for-dev-versions).
If you want to try new features before they get to the stable release, you can download the dev version [here](http://tachiyomi.kanade.eu/latest). (auto-updates not included)
## Issues, Feature Requests and Contributing
@ -32,7 +32,7 @@ Please make sure to read the full guidelines. Your issue may be closed without w
<details><summary>Issues</summary>
1. **Before reporting a new issue, take a look at the [FAQ](https://github.com/inorichi/tachiyomi/wiki/FAQ), the [changelog](https://github.com/inorichi/tachiyomi/releases) and the already opened [issues](https://github.com/inorichi/tachiyomi/issues).**
2. If you are unsure, ask here: [![Discord](https://img.shields.io/discord/349436576037732353.svg)](https://discord.gg/WrBkRk4)
2. If you are unsure, ask here: [![Discord](https://img.shields.io/discord/349436576037732353.svg)](https://discord.gg/tachiyomi)
</details>
@ -64,7 +64,7 @@ Catalogue requests should be created at https://github.com/inorichi/tachiyomi-ex
## FAQ
[See our wiki.](https://github.com/inorichi/tachiyomi/wiki/FAQ)
You can also reach out to us on [Discord](https://discord.gg/WrBkRk4).
You can also reach out to us on [Discord](https://discord.gg/tachiyomi).
## License

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 36
versionName "0.7.3"
versionCode 41
versionName "0.8.4"
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
@ -48,6 +48,8 @@ android {
vectorDrawables.useSupportLibrary = true
multiDexEnabled true
ndk {
abiFilters "armeabi-v7a", "arm64-v8a", "x86"
}
@ -57,13 +59,6 @@ android {
debug {
versionNameSuffix "-${getCommitCount()}"
applicationIdSuffix ".debug"
multiDexEnabled true
}
release {
minifyEnabled true
shrinkResources true
multiDexEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
@ -102,7 +97,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,11 +111,11 @@ 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:12.0.1'
standardImplementation 'com.google.firebase:firebase-core:11.8.0'
// ReactiveX
implementation 'io.reactivex:rxandroid:1.2.1'
@ -130,7 +125,7 @@ dependencies {
implementation 'com.github.pwittchen:reactivenetwork:0.7.0'
// Network client
implementation "com.squareup.okhttp3:okhttp:3.9.1"
implementation "com.squareup.okhttp3:okhttp:3.10.0"
implementation 'com.squareup.okio:okio:1.14.0'
// REST
@ -154,14 +149,17 @@ dependencies {
implementation 'org.jsoup:jsoup:1.10.2'
// Job scheduling
implementation 'com.evernote:android-job:1.2.4'
implementation 'com.google.android.gms:play-services-gcm:12.0.1'
implementation 'com.evernote:android-job:1.2.5'
implementation 'com.google.android.gms:play-services-gcm:11.8.0'
// Changelog
implementation 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
// Database
implementation "com.pushtorefresh.storio:sqlite:1.13.0"
implementation 'android.arch.persistence:db:1.0.0'
implementation 'com.github.inorichi.storio:storio-common:8be19de@aar'
implementation 'com.github.inorichi.storio:storio-sqlite:8be19de@aar'
implementation 'io.requery:sqlite-android:3.25.2'
// Model View Presenter
final nucleus_version = '3.0.0'
@ -201,11 +199,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 +234,7 @@ dependencies {
}
buildscript {
ext.kotlin_version = '1.2.30'
ext.kotlin_version = '1.2.71'
repositories {
mavenCentral()
}

View File

@ -14,6 +14,7 @@
<application
android:name=".App"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
@ -22,18 +23,26 @@
android:theme="@style/Theme.Tachiyomi">
<activity
android:name=".ui.main.MainActivity"
android:launchMode="singleTop">
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
<action android:name="com.google.android.gms.actions.SEARCH_ACTION"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
<intent-filter>
<action android:name="eu.kanade.tachiyomi.SEARCH" />
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
<meta-data android:name="android.app.searchable" android:resource="@xml/searchable"/>
<!--suppress AndroidDomInspection -->
<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"
@ -52,6 +61,21 @@
android:scheme="tachiyomi" />
</intent-filter>
</activity>
<activity
android:name=".ui.setting.ShikomoriLoginActivity"
android:label="Shikomori">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="shikimori-auth"
android:scheme="tachiyomi" />
</intent-filter>
</activity>
<activity
android:name=".extension.util.ExtensionInstallActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar"/>
@ -66,16 +90,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

@ -42,9 +42,7 @@ open class App : Application() {
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
if (BuildConfig.DEBUG) {
MultiDex.install(this)
}
MultiDex.install(this)
}
override fun onConfigurationChanged(newConfig: Configuration) {
@ -57,13 +55,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

@ -402,8 +402,11 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
for (dbTrack in dbTracks) {
if (track.sync_id == dbTrack.sync_id) {
// The sync is already in the db, only update its fields
if (track.remote_id != dbTrack.remote_id) {
dbTrack.remote_id = track.remote_id
if (track.media_id != dbTrack.media_id) {
dbTrack.media_id = track.media_id
}
if (track.library_id != dbTrack.library_id) {
dbTrack.library_id = track.library_id
}
dbTrack.last_chapter_read = Math.max(dbTrack.last_chapter_read, track.last_chapter_read)
isInDatabase = true

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

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 = 6
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)
@ -57,9 +59,17 @@ class DbOpenHelper(context: Context)
if (oldVersion < 6) {
db.execSQL(TrackTable.addTrackingUrl)
}
if (oldVersion < 7) {
db.execSQL(TrackTable.addLibraryId)
}
if (oldVersion < 8) {
db.execSQL("DROP INDEX IF EXISTS mangas_favorite_index")
db.execSQL(MangaTable.createLibraryIndexQuery)
db.execSQL(ChapterTable.createUnreadChaptersIndexQuery)
}
}
override fun onConfigure(db: SQLiteDatabase) {
override fun onConfigure(db: SupportSQLiteDatabase) {
db.setForeignKeyConstraintsEnabled(true)
}

View File

@ -13,8 +13,9 @@ import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.database.models.TrackImpl
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_ID
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_LAST_CHAPTER_READ
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_LIBRARY_ID
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MANGA_ID
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_REMOTE_ID
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MEDIA_ID
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SCORE
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_STATUS
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SYNC_ID
@ -45,7 +46,8 @@ class TrackPutResolver : DefaultPutResolver<Track>() {
put(COL_ID, obj.id)
put(COL_MANGA_ID, obj.manga_id)
put(COL_SYNC_ID, obj.sync_id)
put(COL_REMOTE_ID, obj.remote_id)
put(COL_MEDIA_ID, obj.media_id)
put(COL_LIBRARY_ID, obj.library_id)
put(COL_TITLE, obj.title)
put(COL_LAST_CHAPTER_READ, obj.last_chapter_read)
put(COL_TOTAL_CHAPTERS, obj.total_chapters)
@ -62,7 +64,8 @@ class TrackGetResolver : DefaultGetResolver<Track>() {
id = cursor.getLong(cursor.getColumnIndex(COL_ID))
manga_id = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID))
sync_id = cursor.getInt(cursor.getColumnIndex(COL_SYNC_ID))
remote_id = cursor.getInt(cursor.getColumnIndex(COL_REMOTE_ID))
media_id = cursor.getInt(cursor.getColumnIndex(COL_MEDIA_ID))
library_id = cursor.getLong(cursor.getColumnIndex(COL_LIBRARY_ID))
title = cursor.getString(cursor.getColumnIndex(COL_TITLE))
last_chapter_read = cursor.getInt(cursor.getColumnIndex(COL_LAST_CHAPTER_READ))
total_chapters = cursor.getInt(cursor.getColumnIndex(COL_TOTAL_CHAPTERS))

View File

@ -10,7 +10,9 @@ interface Track : Serializable {
var sync_id: Int
var remote_id: Int
var media_id: Int
var library_id: Long?
var title: String

View File

@ -8,7 +8,9 @@ class TrackImpl : Track {
override var sync_id: Int = 0
override var remote_id: Int = 0
override var media_id: Int = 0
override var library_id: Long? = null
override lateinit var title: String
@ -30,13 +32,13 @@ class TrackImpl : Track {
if (manga_id != other.manga_id) return false
if (sync_id != other.sync_id) return false
return remote_id == other.remote_id
return media_id == other.media_id
}
override fun hashCode(): Int {
var result = (manga_id xor manga_id.ushr(32)).toInt()
result = 31 * result + sync_id
result = 31 * result + remote_id
result = 31 * result + media_id
return result
}

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

@ -10,7 +10,9 @@ object TrackTable {
const val COL_SYNC_ID = "sync_id"
const val COL_REMOTE_ID = "remote_id"
const val COL_MEDIA_ID = "remote_id"
const val COL_LIBRARY_ID = "library_id"
const val COL_TITLE = "title"
@ -29,7 +31,8 @@ object TrackTable {
$COL_ID INTEGER NOT NULL PRIMARY KEY,
$COL_MANGA_ID INTEGER NOT NULL,
$COL_SYNC_ID INTEGER NOT NULL,
$COL_REMOTE_ID INTEGER NOT NULL,
$COL_MEDIA_ID INTEGER NOT NULL,
$COL_LIBRARY_ID INTEGER,
$COL_TITLE TEXT NOT NULL,
$COL_LAST_CHAPTER_READ INTEGER NOT NULL,
$COL_TOTAL_CHAPTERS INTEGER NOT NULL,
@ -43,4 +46,7 @@ object TrackTable {
val addTrackingUrl: String
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_TRACKING_URL TEXT DEFAULT ''"
val addLibraryId: String
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_LIBRARY_ID INTEGER NULL"
}

View File

@ -23,10 +23,12 @@ import java.util.concurrent.TimeUnit
* @param sourceManager the source manager.
* @param 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

@ -15,6 +15,8 @@ object PreferenceKeys {
const val showPageNumber = "pref_show_page_number_key"
const val trueColor = "pref_true_color_key"
const val fullscreen = "fullscreen"
const val keepScreenOn = "pref_keep_screen_on_key"
@ -31,8 +33,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 +43,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 +57,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"
@ -107,6 +107,8 @@ object PreferenceKeys {
const val defaultCategory = "default_category"
const val skipRead = "skip_read"
const val downloadBadge = "display_download_badge"
@Deprecated("Use the preferences of the source")

View File

@ -43,6 +43,8 @@ class PreferencesHelper(val context: Context) {
fun showPageNumber() = rxPrefs.getBoolean(Keys.showPageNumber, true)
fun trueColor() = rxPrefs.getBoolean(Keys.trueColor, false)
fun fullscreen() = rxPrefs.getBoolean(Keys.fullscreen, true)
fun keepScreenOn() = rxPrefs.getBoolean(Keys.keepScreenOn, true)
@ -59,8 +61,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 +71,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 +85,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)
@ -119,7 +119,7 @@ class PreferencesHelper(val context: Context) {
fun trackToken(sync: TrackService) = rxPrefs.getString(Keys.trackToken(sync.id), "")
fun anilistScoreType() = rxPrefs.getInteger("anilist_score_type", 0)
fun anilistScoreType() = rxPrefs.getString("anilist_score_type", "POINT_10")
fun backupsDirectory() = rxPrefs.getString(Keys.backupDirectory, defaultBackupDir.toString())
@ -167,6 +167,8 @@ class PreferencesHelper(val context: Context) {
fun defaultCategory() = prefs.getInt(Keys.defaultCategory, -1)
fun skipRead() = prefs.getBoolean(Keys.skipRead, false)
fun migrateFlags() = rxPrefs.getInteger("migrate_flags", Int.MAX_VALUE)
fun trustedSignatures() = rxPrefs.getStringSet("trusted_signatures", emptySet())

View File

@ -4,6 +4,7 @@ import android.content.Context
import eu.kanade.tachiyomi.data.track.anilist.Anilist
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist
import eu.kanade.tachiyomi.data.track.shikomori.Shikomori
class TrackManager(private val context: Context) {
@ -11,6 +12,7 @@ class TrackManager(private val context: Context) {
const val MYANIMELIST = 1
const val ANILIST = 2
const val KITSU = 3
const val SHIKOMORI = 4
}
val myAnimeList = Myanimelist(context, MYANIMELIST)
@ -19,7 +21,9 @@ class TrackManager(private val context: Context) {
val kitsu = Kitsu(context, KITSU)
val services = listOf(myAnimeList, aniList, kitsu)
val shikomori = Shikomori(context, SHIKOMORI)
val services = listOf(myAnimeList, aniList, kitsu, shikomori)
fun getService(id: Int) = services.find { it.id == id }

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

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

View File

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

View File

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

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
@ -11,72 +10,69 @@ import java.text.SimpleDateFormat
import java.util.*
data class ALManga(
val id: Int,
val media_id: Int,
val title_romaji: String,
val image_url_lge: String,
val description: String?,
val type: String,
val publishing_status: String,
val start_date_fuzzy: String,
val start_date_fuzzy: Long,
val total_chapters: Int) {
fun toTrack() = TrackSearch.create(TrackManager.ANILIST).apply {
remote_id = this@ALManga.id
media_id = this@ALManga.media_id
title = title_romaji
total_chapters = this@ALManga.total_chapters
cover_url = image_url_lge
summary = description ?: ""
tracking_url = AnilistApi.mangaUrl(remote_id)
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()
""
}
}
}
}
data class ALUserManga(
val id: Int,
val library_id: Long,
val list_status: String,
val score_raw: Int,
val chapters_read: Int,
val manga: ALManga) {
fun toTrack() = Track.create(TrackManager.ANILIST).apply {
remote_id = manga.id
media_id = manga.media_id
status = toTrackStatus()
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) {
"reading" -> Anilist.READING
"completed" -> Anilist.COMPLETED
"on-hold" -> Anilist.ON_HOLD
"dropped" -> Anilist.DROPPED
"plan to read" -> Anilist.PLAN_TO_READ
"CURRENT" -> Anilist.READING
"COMPLETED" -> Anilist.COMPLETED
"PAUSED" -> Anilist.ON_HOLD
"DROPPED" -> Anilist.DROPPED
"PLANNING" -> Anilist.PLANNING
"REPEATING" -> Anilist.REPEATING
else -> throw NotImplementedError("Unknown status")
}
}
data class ALUserLists(val lists: Map<String, List<ALUserManga>>) {
fun flatten() = lists.values.flatten()
}
fun Track.toAnilistStatus() = when (status) {
Anilist.READING -> "reading"
Anilist.COMPLETED -> "completed"
Anilist.ON_HOLD -> "on-hold"
Anilist.DROPPED -> "dropped"
Anilist.PLAN_TO_READ -> "plan to read"
Anilist.READING -> "CURRENT"
Anilist.COMPLETED -> "COMPLETED"
Anilist.ON_HOLD -> "PAUSED"
Anilist.DROPPED -> "DROPPED"
Anilist.PLANNING -> "PLANNING"
Anilist.REPEATING -> "REPEATING"
else -> throw NotImplementedError("Unknown status")
}
@ -84,11 +80,11 @@ private val preferences: PreferencesHelper by injectLazy()
fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().getOrDefault()) {
// 10 point
0 -> (score.toInt() / 10).toString()
"POINT_10" -> (score.toInt() / 10).toString()
// 100 point
1 -> score.toInt().toString()
"POINT_100" -> score.toInt().toString()
// 5 stars
2 -> when {
"POINT_5" -> when {
score == 0f -> "0"
score < 30 -> "1"
score < 50 -> "2"
@ -97,13 +93,13 @@ fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().getOrD
else -> "5"
}
// Smiley
3 -> when {
"POINT_3" -> when {
score == 0f -> "0"
score <= 30 -> ":("
score <= 35 -> ":("
score <= 60 -> ":|"
else -> ":)"
}
// 10 point decimal
4 -> (score / 10).toString()
"POINT_10_DECIMAL" -> (score / 10).toString()
else -> throw Exception("Unknown score type")
}
}

View File

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

View File

@ -87,7 +87,7 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.remote_id = remoteTrack.remote_id
track.media_id = remoteTrack.media_id
update(track)
} else {
track.score = DEFAULT_SCORE
@ -141,4 +141,4 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
}
}
}
}

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
@ -42,17 +60,16 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
),
"media" to jsonObject(
"data" to jsonObject(
"id" to track.remote_id,
"id" to track.media_id,
"type" to "manga"
)
)
)
)
// @formatter:on
rest.addLibManga(jsonObject("data" to data))
.map { json ->
track.remote_id = json["data"]["id"].int
track.media_id = json["data"]["id"].int
track
}
}
@ -63,7 +80,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
// @formatter:off
val data = jsonObject(
"type" to "libraryEntries",
"id" to track.remote_id,
"id" to track.media_id,
"attributes" to jsonObject(
"status" to track.toKitsuStatus(),
"progress" to track.last_chapter_read,
@ -72,23 +89,36 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
)
// @formatter:on
rest.updateLibManga(track.remote_id, jsonObject("data" to data))
rest.updateLibManga(track.media_id, jsonObject("data" to data))
.map { track }
}
}
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() }
}
}
fun findLibManga(track: Track, userId: String): Observable<Track?> {
return rest.findLibManga(track.remote_id, userId)
return rest.findLibManga(track.media_id, userId)
.map { json ->
val data = json["data"].array
if (data.size() > 0) {
@ -101,7 +131,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
}
fun getLibManga(track: Track): Observable<Track> {
return rest.getLibManga(track.remote_id)
return rest.getLibManga(track.media_id)
.map { json ->
val data = json["data"].array
if (data.size() > 0) {
@ -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
@ -204,4 +245,4 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
}
}
}

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 {
remote_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(remote_id)
publishing_status = this@KitsuManga.status
tracking_url = KitsuApi.mangaUrl(media_id)
publishing_status = this@KitsuLibManga.status
publishing_type = type
start_date = startDate.orEmpty()
}
}
class KitsuLibManga(obj: JsonObject, manga: JsonObject) : KitsuManga(manga) {
val remoteId 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 {
remote_id = remoteId
start_date = startDate
status = toTrackStatus()
score = ratingTwenty?.let { it.toInt() / 2f } ?: 0f
last_chapter_read = progress

View File

@ -10,7 +10,9 @@ class TrackSearch : Track {
override var sync_id: Int = 0
override var remote_id: Int = 0
override var media_id: Int = 0
override var library_id: Long? = null
override lateinit var title: String
@ -42,13 +44,13 @@ class TrackSearch : Track {
if (manga_id != other.manga_id) return false
if (sync_id != other.sync_id) return false
return remote_id == other.remote_id
return media_id == other.media_id
}
override fun hashCode(): Int {
var result = (manga_id xor manga_id.ushr(32)).toInt()
result = 31 * result + sync_id
result = 31 * result + remote_id
result = 31 * result + media_id
return result
}
companion object {

View File

@ -4,8 +4,10 @@ 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 okhttp3.HttpUrl
import rx.Completable
import rx.Observable
@ -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.cookieManager.remove(HttpUrl.parse(BASE_URL)!!)
}
override val isLogged: Boolean
get() = !getUsername().isEmpty() &&
!getPassword().isEmpty() &&
checkCookies() &&
!getCSRF().isEmpty()
private fun getCSRF(): String = preferences.trackToken(this).getOrDefault()
private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf)
private fun checkCookies(): Boolean {
var ckCount = 0
val url = HttpUrl.parse(BASE_URL)!!
for (ck in networkService.cookieManager.get(url)) {
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")!!
remote_id = it.selectInt("id")
total_chapters = it.selectInt("chapters")
summary = it.selectText("synopsis")!!
cover_url = it.selectText("image")!!
tracking_url = MyanimelistApi.mangaUrl(remote_id)
publishing_status = it.selectText("status")!!
publishing_type = it.selectText("type")!!
start_date = it.selectText("start_date")!!
}
}
.toList()
}
}
fun getList(username: String): Observable<List<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")!!
remote_id = it.selectInt("series_mangadb_id")
last_chapter_read = it.selectInt("my_read_chapters")
status = it.selectInt("my_status")
score = it.selectInt("my_score").toFloat()
total_chapters = it.selectInt("series_chapters")
cover_url = it.selectText("series_image")!!
tracking_url = MyanimelistApi.mangaUrl(remote_id)
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)
.map { list -> list.find { it.remote_id == track.remote_id } }
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()
}
fun getLibManga(track: Track, username: String): Observable<Track> {
return findLibManga(track, username)
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, 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.remote_id}.xml")
.toString()
fun getAddUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/mangalist/add")
.appendPath("${track.remote_id}.xml")
.toString()
fun createHeaders(username: String, password: String): Headers {
return Headers.Builder()
.add("Authorization", Credentials.basic(username, password))
.add("User-Agent", "api-indiv-9F93C52A963974CF674325391990191C")
.build()
}
companion object {
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

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.data.track.shikomori
data class OAuth(
val access_token: String,
val token_type: String,
val created_at: Long,
val expires_in: Long,
val refresh_token: String?) {
// Access token lives 1 day
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
}

View File

@ -0,0 +1,138 @@
package eu.kanade.tachiyomi.data.track.shikomori
import android.content.Context
import android.graphics.Color
import com.google.gson.Gson
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
class Shikomori(private val context: Context, id: Int) : TrackService(id) {
override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString)
}
override fun displayScore(track: Track): String {
return track.score.toInt().toString()
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track, getUsername())
}
override fun update(track: Track): Observable<Track> {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED
}
return api.updateLibManga(track, getUsername())
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername())
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id
update(track)
} else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
add(track)
}
}
}
override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername())
.map { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
}
track
}
}
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLANNING = 5
const val REPEATING = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
}
override val name = "Shikomori"
private val gson: Gson by injectLazy()
private val interceptor by lazy { ShikomoriInterceptor(this, gson) }
private val api by lazy { ShikomoriApi(client, interceptor) }
override fun getLogo() = R.drawable.shikomori
override fun getLogoColor() = Color.rgb(40, 40, 40)
override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING)
}
override fun getStatus(status: Int): String = with(context) {
when (status) {
READING -> getString(R.string.reading)
COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped)
PLANNING -> getString(R.string.plan_to_read)
REPEATING -> getString(R.string.repeating)
else -> ""
}
}
override fun login(username: String, password: String) = login(password)
fun login(code: String): Completable {
return api.accessToken(code).map { oauth: OAuth? ->
interceptor.newAuth(oauth)
if (oauth != null) {
val user = api.getCurrentUser()
saveCredentials(user.toString(), oauth.access_token)
}
}.doOnError {
logout()
}.toCompletable()
}
fun saveToken(oauth: OAuth?) {
val json = gson.toJson(oauth)
preferences.trackToken(this).set(json)
}
fun restoreToken(): OAuth? {
return try {
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
} catch (e: Exception) {
null
}
}
override fun logout() {
super.logout()
preferences.trackToken(this).set(null)
interceptor.newAuth(null)
}
}

View File

@ -0,0 +1,189 @@
package eu.kanade.tachiyomi.data.track.shikomori
import android.net.Uri
import com.github.salomonbrys.kotson.array
import com.github.salomonbrys.kotson.jsonObject
import com.github.salomonbrys.kotson.nullString
import com.github.salomonbrys.kotson.obj
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess
import okhttp3.*
import rx.Observable
import uy.kohesive.injekt.injectLazy
class ShikomoriApi(private val client: OkHttpClient, interceptor: ShikomoriInterceptor) {
private val gson: Gson by injectLazy()
private val parser = JsonParser()
private val jsonime = MediaType.parse("application/json; charset=utf-8")
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun addLibManga(track: Track, user_id: String): Observable<Track> {
val payload = jsonObject(
"user_rate" to jsonObject(
"user_id" to user_id,
"target_id" to track.media_id,
"target_type" to "Manga",
"chapters" to track.last_chapter_read,
"score" to track.score.toInt(),
"status" to track.toShikomoriStatus()
)
)
val body = RequestBody.create(jsonime, payload.toString())
val request = Request.Builder()
.url("$apiUrl/v2/user_rates")
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map {
track
}
}
fun updateLibManga(track: Track, user_id: String): Observable<Track> = addLibManga(track, user_id)
fun search(search: String): Observable<List<TrackSearch>> {
val url = Uri.parse("$apiUrl/mangas").buildUpon()
.appendQueryParameter("order", "popularity")
.appendQueryParameter("search", search)
.appendQueryParameter("limit", "20")
.build()
val request = Request.Builder()
.url(url.toString())
.get()
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body()?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = parser.parse(responseBody).array
response.map { jsonToSearch(it.obj) }
}
}
private fun jsonToSearch(obj: JsonObject): TrackSearch {
return TrackSearch.create(TrackManager.SHIKOMORI).apply {
media_id = obj["id"].asInt
title = obj["name"].asString
total_chapters = obj["chapters"].asInt
cover_url = baseUrl + obj["image"].obj["preview"].asString
summary = ""
tracking_url = baseUrl + obj["url"].asString
publishing_status = obj["status"].asString
publishing_type = obj["kind"].asString
start_date = obj.get("aired_on").nullString.orEmpty()
}
}
private fun jsonToTrack(obj: JsonObject): Track {
return Track.create(TrackManager.SHIKOMORI).apply {
media_id = obj["id"].asInt
title = ""
last_chapter_read = obj["chapters"].asInt
total_chapters = obj["chapters"].asInt
score = (obj["score"].asInt).toFloat()
status = toTrackStatus(obj["status"].asString)
}
}
fun findLibManga(track: Track, user_id: String): Observable<Track?> {
val url = Uri.parse("$apiUrl/v2/user_rates").buildUpon()
.appendQueryParameter("user_id", user_id)
.appendQueryParameter("target_id", track.media_id.toString())
.appendQueryParameter("target_type", "Manga")
.build()
val request = Request.Builder()
.url(url.toString())
.get()
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body()?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = parser.parse(responseBody).array
if (response.size() > 1) {
throw Exception("Too much mangas in response")
}
val entry = response.map {
jsonToTrack(it.obj)
}
entry.firstOrNull()
}
}
fun getCurrentUser(): Int {
val user = authClient.newCall(GET("$apiUrl/users/whoami")).execute().body()?.string()
return parser.parse(user).obj["id"].asInt
}
fun accessToken(code: String): Observable<OAuth> {
return client.newCall(accessTokenRequest(code)).asObservableSuccess().map { netResponse ->
val responseBody = netResponse.body()?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
gson.fromJson(responseBody, OAuth::class.java)
}
}
private fun accessTokenRequest(code: String) = POST(oauthUrl,
body = FormBody.Builder()
.add("grant_type", "authorization_code")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("code", code)
.add("redirect_uri", redirectUrl)
.build()
)
companion object {
private const val clientId = "1aaf4cf232372708e98b5abc813d795b539c5a916dbbfe9ac61bf02a360832cc"
private const val clientSecret = "229942c742dd4cde803125d17d64501d91c0b12e14cb1e5120184d77d67024c0"
private const val baseUrl = "https://shikimori.org"
private const val apiUrl = "https://shikimori.org/api"
private const val oauthUrl = "https://shikimori.org/oauth/token"
private const val loginUrl = "https://shikimori.org/oauth/authorize"
private const val redirectUrl = "tachiyomi://shikimori-auth"
private const val baseMangaUrl = "$apiUrl/mangas"
fun mangaUrl(remoteId: Int): String {
return "$baseMangaUrl/$remoteId"
}
fun authUrl() =
Uri.parse(loginUrl).buildUpon()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("redirect_uri", redirectUrl)
.appendQueryParameter("response_type", "code")
.build()
fun refreshTokenRequest(token: String) = POST(oauthUrl,
body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("refresh_token", token)
.build())
}
}

View File

@ -0,0 +1,43 @@
package eu.kanade.tachiyomi.data.track.shikomori
import com.google.gson.Gson
import okhttp3.Interceptor
import okhttp3.Response
class ShikomoriInterceptor(val shikomori: Shikomori, val gson: Gson) : Interceptor {
/**
* OAuth object used for authenticated requests.
*/
private var oauth: OAuth? = shikomori.restoreToken()
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val currAuth = oauth ?: throw Exception("Not authenticated with Shikomori")
val refreshToken = currAuth.refresh_token!!
// Refresh access token if expired.
if (currAuth.isExpired()) {
val response = chain.proceed(ShikomoriApi.refreshTokenRequest(refreshToken))
if (response.isSuccessful) {
newAuth(gson.fromJson(response.body()!!.string(), OAuth::class.java))
} else {
response.close()
}
}
// Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
.header("User-Agent", "Tachiyomi")
.build()
return chain.proceed(authRequest)
}
fun newAuth(oauth: OAuth?) {
this.oauth = oauth
shikomori.saveToken(oauth)
}
}

View File

@ -0,0 +1,24 @@
package eu.kanade.tachiyomi.data.track.shikomori
import eu.kanade.tachiyomi.data.database.models.Track
fun Track.toShikomoriStatus() = when (status) {
Shikomori.READING -> "watching"
Shikomori.COMPLETED -> "completed"
Shikomori.ON_HOLD -> "on_hold"
Shikomori.DROPPED -> "dropped"
Shikomori.PLANNING -> "planned"
Shikomori.REPEATING -> "rewatching"
else -> throw NotImplementedError("Unknown status")
}
fun toTrackStatus(status: String) = when (status) {
"watching" -> Shikomori.READING
"completed" -> Shikomori.COMPLETED
"on_hold" -> Shikomori.ON_HOLD
"dropped" -> Shikomori.DROPPED
"planned" -> Shikomori.PLANNING
"rewatching" -> Shikomori.REPEATING
else -> throw Exception("Unknown status")
}

View File

@ -0,0 +1,70 @@
package eu.kanade.tachiyomi.network
import android.content.Context
import android.os.Build
import android.webkit.CookieManager
import android.webkit.CookieSyncManager
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
class AndroidCookieJar(context: Context) : CookieJar {
private val manager = CookieManager.getInstance()
private val syncManager by lazy { CookieSyncManager.createInstance(context) }
init {
// Init sync manager when using anything below L
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
syncManager
}
}
override fun saveFromResponse(url: HttpUrl, cookies: MutableList<Cookie>) {
val urlString = url.toString()
for (cookie in cookies) {
manager.setCookie(urlString, cookie.toString())
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
syncManager.sync()
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
return get(url)
}
fun get(url: HttpUrl): List<Cookie> {
val cookies = manager.getCookie(url.toString())
return if (cookies != null && !cookies.isEmpty()) {
cookies.split(";").mapNotNull { Cookie.parse(url, it) }
} else {
emptyList()
}
}
fun remove(url: HttpUrl) {
val cookies = manager.getCookie(url.toString()) ?: return
val domain = ".${url.host()}"
cookies.split(";")
.map { it.substringBefore("=") }
.onEach { manager.setCookie(domain, "$it=;Max-Age=-1") }
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
syncManager.sync()
}
}
fun removeAll() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
manager.removeAllCookies {}
} else {
manager.removeAllCookie()
syncManager.sync()
}
}
}

View File

@ -1,76 +1,154 @@
package eu.kanade.tachiyomi.network
import com.squareup.duktape.Duktape
import okhttp3.CacheControl
import okhttp3.HttpUrl
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import eu.kanade.tachiyomi.util.WebViewClientCompat
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class CloudflareInterceptor : Interceptor {
private val operationPattern = Regex("""setTimeout\(function\(\)\{\s+(var (?:\w,)+f.+?\r?\n[\s\S]+?a\.value =.+?)\r?\n""")
private val passPattern = Regex("""name="pass" value="(.+?)"""")
private val challengePattern = Regex("""name="jschl_vc" value="(\w+)"""")
class CloudflareInterceptor(private val context: Context) : Interceptor {
private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare")
private val handler = Handler(Looper.getMainLooper())
/**
* When this is called, it initializes the WebView if it wasn't already. We use this to avoid
* blocking the main thread too much. If used too often we could consider moving it to the
* Application class.
*/
private val initWebView by lazy {
if (Build.VERSION.SDK_INT >= 17) {
WebSettings.getDefaultUserAgent(context)
} else {
null
}
}
@Synchronized
override fun intercept(chain: Interceptor.Chain): Response {
initWebView
val response = chain.proceed(chain.request())
// Check if Cloudflare anti-bot is on
if (response.code() == 503 && response.header("Server") in serverCheck) {
return chain.proceed(resolveChallenge(response))
try {
response.close()
val solutionRequest = resolveWithWebView(chain.request())
return chain.proceed(solutionRequest)
} 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
}
private fun resolveChallenge(response: Response): Request {
Duktape.create().use { duktape ->
val originalRequest = response.request()
val url = originalRequest.url()
val domain = url.host()
val content = response.body()!!.string()
private fun isChallengeSolutionUrl(url: String): Boolean {
return "chk_jschl" in url
}
// CloudFlare requires waiting 4 seconds before resolving the challenge
Thread.sleep(4000)
@SuppressLint("SetJavaScriptEnabled")
private fun resolveWithWebView(request: Request): Request {
// We need to lock this thread until the WebView finds the challenge solution url, because
// OkHttp doesn't support asynchronous interceptors.
val latch = CountDownLatch(1)
val operation = operationPattern.find(content)?.groups?.get(1)?.value
val challenge = challengePattern.find(content)?.groups?.get(1)?.value
val pass = passPattern.find(content)?.groups?.get(1)?.value
var webView: WebView? = null
var solutionUrl: String? = null
var challengeFound = false
if (operation == null || challenge == null || pass == null) {
throw Exception("Failed resolving Cloudflare challenge")
val origRequestUrl = request.url().toString()
val headers = request.headers().toMultimap().mapValues { it.value.getOrNull(0) ?: "" }
handler.post {
val view = WebView(context)
webView = view
view.settings.javaScriptEnabled = true
view.settings.userAgentString = request.header("User-Agent")
view.webViewClient = object : WebViewClientCompat() {
override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean {
if (isChallengeSolutionUrl(url)) {
solutionUrl = url
latch.countDown()
}
return solutionUrl != null
}
override fun shouldInterceptRequestCompat(
view: WebView,
url: String
): WebResourceResponse? {
if (solutionUrl != null) {
// Intercept any request when we have the solution.
return WebResourceResponse("text/plain", "UTF-8", null)
}
return null
}
override fun onPageFinished(view: WebView, url: String) {
// Http error codes are only received since M
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
url == origRequestUrl && !challengeFound
) {
// The first request didn't return the challenge, abort.
latch.countDown()
}
}
override fun onReceivedErrorCompat(
view: WebView,
errorCode: Int,
description: String?,
failingUrl: String,
isMainFrame: Boolean
) {
if (isMainFrame) {
if (errorCode == 503) {
// Found the cloudflare challenge page.
challengeFound = true
} else {
// Unlock thread, the challenge wasn't found.
latch.countDown()
}
}
}
}
val js = operation
.replace(Regex("""a\.value = (.+ \+ t\.length).+"""), "$1")
.replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "")
.replace("t.length", "${domain.length}")
.replace("\n", "")
val result = duktape.evaluate(js) as Double
val cloudflareUrl = HttpUrl.parse("${url.scheme()}://$domain/cdn-cgi/l/chk_jschl")!!
.newBuilder()
.addQueryParameter("jschl_vc", challenge)
.addQueryParameter("pass", pass)
.addQueryParameter("jschl_answer", "$result")
.toString()
val cloudflareHeaders = originalRequest.headers()
.newBuilder()
.add("Referer", url.toString())
.add("Accept", "text/html,application/xhtml+xml,application/xml")
.add("Accept-Language", "en")
.build()
return GET(cloudflareUrl, cloudflareHeaders, cache = CacheControl.Builder().build())
webView?.loadUrl(origRequestUrl, headers)
}
// Wait a reasonable amount of time to retrieve the solution. The minimum should be
// around 4 seconds but it can take more due to slow networks or server issues.
latch.await(12, TimeUnit.SECONDS)
handler.post {
webView?.stopLoading()
webView?.destroy()
}
val solution = solutionUrl ?: throw Exception("Challenge not found")
return Request.Builder().get()
.url(solution)
.headers(request.headers())
.addHeader("Referer", origRequestUrl)
.addHeader("Accept", "text/html,application/xhtml+xml,application/xml")
.addHeader("Accept-Language", "en")
.build()
}
}

View File

@ -2,8 +2,7 @@ package eu.kanade.tachiyomi.network
import android.content.Context
import android.os.Build
import okhttp3.Cache
import okhttp3.OkHttpClient
import okhttp3.*
import java.io.File
import java.io.IOException
import java.net.InetAddress
@ -12,11 +11,7 @@ import java.net.UnknownHostException
import java.security.KeyManagementException
import java.security.KeyStore
import java.security.NoSuchAlgorithmException
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
import javax.net.ssl.*
class NetworkHelper(context: Context) {
@ -24,7 +19,7 @@ class NetworkHelper(context: Context) {
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
private val cookieManager = PersistentCookieJar(context)
val cookieManager = AndroidCookieJar(context)
val client = OkHttpClient.Builder()
.cookieJar(cookieManager)
@ -33,12 +28,9 @@ class NetworkHelper(context: Context) {
.build()
val cloudflareClient = client.newBuilder()
.addInterceptor(CloudflareInterceptor())
.addInterceptor(CloudflareInterceptor(context))
.build()
val cookies: PersistentCookieStore
get() = cookieManager.store
private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) {
return this
@ -108,6 +100,18 @@ class NetworkHelper(context: Context) {
sslSocketFactory(TLSSocketFactory(), trustManagers[0] as X509TrustManager)
}
val specCompat = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
.tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0)
.cipherSuites(
*ConnectionSpec.MODERN_TLS.cipherSuites().orEmpty().toTypedArray(),
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
)
.build()
val specs = listOf(specCompat, ConnectionSpec.CLEARTEXT)
connectionSpecs(specs)
return this
}
}

View File

@ -1,19 +0,0 @@
package eu.kanade.tachiyomi.network
import android.content.Context
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
class PersistentCookieJar(context: Context) : CookieJar {
val store = PersistentCookieStore(context)
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
store.addAll(url, cookies)
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
return store.get(url)
}
}

View File

@ -1,73 +0,0 @@
package eu.kanade.tachiyomi.network
import android.content.Context
import okhttp3.Cookie
import okhttp3.HttpUrl
import java.net.URI
import java.util.concurrent.ConcurrentHashMap
class PersistentCookieStore(context: Context) {
private val cookieMap = ConcurrentHashMap<String, List<Cookie>>()
private val prefs = context.getSharedPreferences("cookie_store", Context.MODE_PRIVATE)
init {
for ((key, value) in prefs.all) {
@Suppress("UNCHECKED_CAST")
val cookies = value as? Set<String>
if (cookies != null) {
try {
val url = HttpUrl.parse("http://$key") ?: continue
val nonExpiredCookies = cookies.mapNotNull { Cookie.parse(url, it) }
.filter { !it.hasExpired() }
cookieMap.put(key, nonExpiredCookies)
} catch (e: Exception) {
// Ignore
}
}
}
}
@Synchronized
fun addAll(url: HttpUrl, cookies: List<Cookie>) {
val key = url.uri().host
// Append or replace the cookies for this domain.
val cookiesForDomain = cookieMap[key].orEmpty().toMutableList()
for (cookie in cookies) {
// Find a cookie with the same name. Replace it if found, otherwise add a new one.
val pos = cookiesForDomain.indexOfFirst { it.name() == cookie.name() }
if (pos == -1) {
cookiesForDomain.add(cookie)
} else {
cookiesForDomain[pos] = cookie
}
}
cookieMap.put(key, cookiesForDomain)
// Get cookies to be stored in disk
val newValues = cookiesForDomain.asSequence()
.filter { it.persistent() && !it.hasExpired() }
.map(Cookie::toString)
.toSet()
prefs.edit().putStringSet(key, newValues).apply()
}
@Synchronized
fun removeAll() {
prefs.edit().clear().apply()
cookieMap.clear()
}
fun get(url: HttpUrl) = get(url.uri().host)
fun get(uri: URI) = get(uri.host)
private fun get(url: String): List<Cookie> {
return cookieMap[url].orEmpty().filter { !it.hasExpired() }
}
private fun Cookie.hasExpired() = System.currentTimeMillis() >= expiresAt()
}

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,19 @@
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 +22,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
}
}
@ -35,16 +43,32 @@ open class SourceManager(private val context: Context) {
}
private fun createInternalSources(): List<Source> = listOf(
LocalSource(context),
Batoto(),
Mangahere(),
Mangafox(),
Kissmanga(),
Readmanga(),
Mintmanga(),
Mangachan(),
Readmangatoday(),
Mangasee(),
WieManga()
LocalSource(context)
)
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

@ -1,31 +0,0 @@
package eu.kanade.tachiyomi.source.online.english
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import rx.Observable
class Batoto : Source {
override val id: Long = 1
override val name = "Batoto"
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return Observable.error(Exception("RIP Batoto"))
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return Observable.error(Exception("RIP Batoto"))
}
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return Observable.error(Exception("RIP Batoto"))
}
override fun toString(): String {
return "$name (EN)"
}
}

View File

@ -1,247 +0,0 @@
package eu.kanade.tachiyomi.source.online.english
import com.squareup.duktape.Duktape
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import timber.log.Timber
import java.text.SimpleDateFormat
import java.util.regex.Pattern
class Kissmanga : ParsedHttpSource() {
override val id: Long = 4
override val name = "Kissmanga"
override val baseUrl = "http://kissmanga.com"
override val lang = "en"
override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient
override fun popularMangaSelector() = "table.listing tr:gt(1)"
override fun latestUpdatesSelector() = "table.listing tr:gt(1)"
override fun popularMangaRequest(page: Int): Request {
return GET("$baseUrl/MangaList/MostPopular?page=$page", headers)
}
override fun latestUpdatesRequest(page: Int): Request {
return GET("http://kissmanga.com/MangaList/LatestUpdate?page=$page", headers)
}
override fun popularMangaFromElement(element: Element): SManga {
val manga = SManga.create()
element.select("td a:eq(0)").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
val title = it.text()
//check if cloudfire email obfuscation is affecting title name
if (title.contains("[email protected]", true)) {
try {
var str: String = it.html()
//get the number
str = str.substringAfter("data-cfemail=\"")
str = str.substringBefore("\">[email")
val sb = StringBuilder()
//convert number to char
val r = Integer.valueOf(str.substring(0, 2), 16)!!
var i = 2
while (i < str.length) {
val c = (Integer.valueOf(str.substring(i, i + 2), 16) xor r).toChar()
sb.append(c)
i += 2
}
//replace the new word into the title
manga.title = title.replace("[email protected]", sb.toString(), true)
} catch (e: Exception) {
//on error just default to obfuscated title
Timber.e("error parsing [email protected]", e)
manga.title = title
}
} else {
manga.title = title
}
}
return manga
}
override fun latestUpdatesFromElement(element: Element): SManga {
return popularMangaFromElement(element)
}
override fun popularMangaNextPageSelector() = "li > a:contains( Next)"
override fun latestUpdatesNextPageSelector(): String = "ul.pager > li > a:contains(Next)"
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val form = FormBody.Builder().apply {
add("mangaName", query)
for (filter in if (filters.isEmpty()) getFilterList() else filters) {
when (filter) {
is Author -> add("authorArtist", filter.state)
is Status -> add("status", arrayOf("", "Completed", "Ongoing")[filter.state])
is GenreList -> filter.state.forEach { genre -> add("genres", genre.state.toString()) }
}
}
}
return POST("$baseUrl/AdvanceSearch", headers, form.build())
}
override fun searchMangaSelector() = popularMangaSelector()
override fun searchMangaFromElement(element: Element): SManga {
return popularMangaFromElement(element)
}
override fun searchMangaNextPageSelector() = null
override fun mangaDetailsParse(document: Document): SManga {
val infoElement = document.select("div.barContent").first()
val manga = SManga.create()
manga.author = infoElement.select("p:has(span:contains(Author:)) > a").first()?.text()
manga.genre = infoElement.select("p:has(span:contains(Genres:)) > *:gt(0)").text()
manga.description = infoElement.select("p:has(span:contains(Summary:)) ~ p").text()
manga.status = infoElement.select("p:has(span:contains(Status:))").first()?.text().orEmpty().let { parseStatus(it) }
manga.thumbnail_url = document.select(".rightBox:eq(0) img").first()?.attr("src")
return manga
}
fun parseStatus(status: String) = when {
status.contains("Ongoing") -> SManga.ONGOING
status.contains("Completed") -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
override fun chapterListSelector() = "table.listing tr:gt(1)"
override fun chapterFromElement(element: Element): SChapter {
val urlElement = element.select("a").first()
val chapter = SChapter.create()
chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = urlElement.text()
chapter.date_upload = element.select("td:eq(1)").first()?.text()?.let {
SimpleDateFormat("MM/dd/yyyy").parse(it).time
} ?: 0
return chapter
}
override fun pageListRequest(chapter: SChapter) = POST(baseUrl + chapter.url, headers)
override fun pageListParse(response: Response): List<Page> {
val body = response.body()!!.string()
val pages = mutableListOf<Page>()
// Kissmanga now encrypts the urls, so we need to execute these two scripts in JS.
val ca = client.newCall(GET("$baseUrl/Scripts/ca.js", headers)).execute().body()!!.string()
val lo = client.newCall(GET("$baseUrl/Scripts/lo.js", headers)).execute().body()!!.string()
Duktape.create().use {
it.evaluate(ca)
it.evaluate(lo)
// There are two functions in an inline script needed to decrypt the urls. We find and
// execute them.
var p = Pattern.compile("(.*CryptoJS.*)")
var m = p.matcher(body)
while (m.find()) {
it.evaluate(m.group(1))
}
// Finally find all the urls and decrypt them in JS.
p = Pattern.compile("""lstImages.push\((.*)\);""")
m = p.matcher(body)
var i = 0
while (m.find()) {
val url = it.evaluate(m.group(1)) as String
pages.add(Page(i++, "", url))
}
}
return pages
}
override fun pageListParse(document: Document): List<Page> {
throw Exception("Not used")
}
override fun imageUrlRequest(page: Page) = GET(page.url)
override fun imageUrlParse(document: Document) = ""
private class Status : Filter.TriState("Completed")
private class Author : Filter.Text("Author")
private class Genre(name: String) : Filter.TriState(name)
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
override fun getFilterList() = FilterList(
Author(),
Status(),
GenreList(getGenreList())
)
// $("select[name=\"genres\"]").map((i,el) => `Genre("${$(el).next().text().trim()}", ${i})`).get().join(',\n')
// on http://kissmanga.com/AdvanceSearch
private fun getGenreList() = listOf(
Genre("4-Koma"),
Genre("Action"),
Genre("Adult"),
Genre("Adventure"),
Genre("Comedy"),
Genre("Comic"),
Genre("Cooking"),
Genre("Doujinshi"),
Genre("Drama"),
Genre("Ecchi"),
Genre("Fantasy"),
Genre("Gender Bender"),
Genre("Harem"),
Genre("Historical"),
Genre("Horror"),
Genre("Josei"),
Genre("Lolicon"),
Genre("Manga"),
Genre("Manhua"),
Genre("Manhwa"),
Genre("Martial Arts"),
Genre("Mature"),
Genre("Mecha"),
Genre("Medical"),
Genre("Music"),
Genre("Mystery"),
Genre("One shot"),
Genre("Psychological"),
Genre("Romance"),
Genre("School Life"),
Genre("Sci-fi"),
Genre("Seinen"),
Genre("Shotacon"),
Genre("Shoujo"),
Genre("Shoujo Ai"),
Genre("Shounen"),
Genre("Shounen Ai"),
Genre("Slice of Life"),
Genre("Smut"),
Genre("Sports"),
Genre("Supernatural"),
Genre("Tragedy"),
Genre("Webtoon"),
Genre("Yaoi"),
Genre("Yuri")
)
}

View File

@ -1,231 +0,0 @@
package eu.kanade.tachiyomi.source.online.english
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import okhttp3.HttpUrl
import okhttp3.Request
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.*
class Mangafox : ParsedHttpSource() {
override val id: Long = 3
override val name = "Mangafox"
override val baseUrl = "http://mangafox.la"
override val lang = "en"
override val supportsLatest = true
override fun popularMangaSelector() = "div#mangalist > ul.list > li"
override fun popularMangaRequest(page: Int): Request {
val pageStr = if (page != 1) "$page.htm" else ""
return GET("$baseUrl/directory/$pageStr", headers)
}
override fun latestUpdatesSelector() = "div#mangalist > ul.list > li"
override fun latestUpdatesRequest(page: Int): Request {
val pageStr = if (page != 1) "$page.htm" else ""
return GET("$baseUrl/directory/$pageStr?latest")
}
override fun popularMangaFromElement(element: Element): SManga {
val manga = SManga.create()
element.select("a.title").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text()
}
return manga
}
override fun latestUpdatesFromElement(element: Element): SManga {
return popularMangaFromElement(element)
}
override fun popularMangaNextPageSelector() = "a:has(span.next)"
override fun latestUpdatesNextPageSelector() = "a:has(span.next)"
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = HttpUrl.parse("$baseUrl/search.php?name_method=cw&author_method=cw&artist_method=cw&advopts=1")!!.newBuilder().addQueryParameter("name", query)
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
when (filter) {
is Status -> url.addQueryParameter(filter.id, filter.state.toString())
is GenreList -> filter.state.forEach { genre -> url.addQueryParameter(genre.id, genre.state.toString()) }
is TextField -> url.addQueryParameter(filter.key, filter.state)
is Type -> url.addQueryParameter("type", if (filter.state == 0) "" else filter.state.toString())
is OrderBy -> {
url.addQueryParameter("sort", arrayOf("name", "rating", "views", "total_chapters", "last_chapter_time")[filter.state!!.index])
url.addQueryParameter("order", if (filter.state?.ascending == true) "az" else "za")
}
}
}
url.addQueryParameter("page", page.toString())
return GET(url.toString(), headers)
}
override fun searchMangaSelector() = "div#mangalist > ul.list > li"
override fun searchMangaFromElement(element: Element): SManga {
val manga = SManga.create()
element.select("a.title").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text()
}
return manga
}
override fun searchMangaNextPageSelector() = "a:has(span.next)"
override fun mangaDetailsParse(document: Document): SManga {
val infoElement = document.select("div#title").first()
val rowElement = infoElement.select("table > tbody > tr:eq(1)").first()
val sideInfoElement = document.select("#series_info").first()
val licensedElement = document.select("div.warning").first()
val manga = SManga.create()
manga.author = rowElement.select("td:eq(1)").first()?.text()
manga.artist = rowElement.select("td:eq(2)").first()?.text()
manga.genre = rowElement.select("td:eq(3)").first()?.text()
manga.description = infoElement.select("p.summary").first()?.text()
val isLicensed = licensedElement?.text()?.contains("licensed")
if (isLicensed == true) {
manga.status = SManga.LICENSED
} else {
manga.status = sideInfoElement.select(".data").first()?.text().orEmpty().let { parseStatus(it) }
}
manga.thumbnail_url = sideInfoElement.select("div.cover > img").first()?.attr("src")
return manga
}
private fun parseStatus(status: String) = when {
status.contains("Ongoing") -> SManga.ONGOING
status.contains("Completed") -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
override fun chapterListSelector() = "div#chapters li div"
override fun chapterFromElement(element: Element): SChapter {
val urlElement = element.select("a.tips").first()
val chapter = SChapter.create()
chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = element.select("span.title.nowrap").first()?.text()?.let { urlElement.text() + " - " + it } ?: urlElement.text()
chapter.date_upload = element.select("span.date").first()?.text()?.let { parseChapterDate(it) } ?: 0
return chapter
}
private fun parseChapterDate(date: String): Long {
return if ("Today" in date || " ago" in date) {
Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
} else if ("Yesterday" in date) {
Calendar.getInstance().apply {
add(Calendar.DATE, -1)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
} else {
try {
SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).parse(date).time
} catch (e: ParseException) {
0L
}
}
}
override fun pageListParse(document: Document): List<Page> {
val url = document.baseUri().substringBeforeLast('/')
val pages = mutableListOf<Page>()
document.select("select.m").first()?.select("option:not([value=0])")?.forEach {
pages.add(Page(pages.size, "$url/${it.attr("value")}.html"))
}
return pages
}
override fun imageUrlParse(document: Document): String {
val url = document.getElementById("image").attr("src")
return if ("compressed?token=" !in url) {
url
} else {
"http://mangafox.me/media/logo.png"
}
}
private class Status(val id: String = "is_completed") : Filter.TriState("Completed")
private class Genre(name: String, val id: String = "genres[$name]") : Filter.TriState(name)
private class TextField(name: String, val key: String) : Filter.Text(name)
private class Type : Filter.Select<String>("Type", arrayOf("Any", "Japanese Manga", "Korean Manhwa", "Chinese Manhua"))
private class OrderBy : Filter.Sort("Order by",
arrayOf("Series name", "Rating", "Views", "Total chapters", "Last chapter"),
Filter.Sort.Selection(2, false))
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
override fun getFilterList() = FilterList(
TextField("Author", "author"),
TextField("Artist", "artist"),
Type(),
Status(),
OrderBy(),
GenreList(getGenreList())
)
// $('select.genres').map((i,el)=>`Genre("${$(el).next().text().trim()}", "${$(el).attr('name')}")`).get().join(',\n')
// on http://mangafox.me/search.php
private fun getGenreList() = listOf(
Genre("Action"),
Genre("Adult"),
Genre("Adventure"),
Genre("Comedy"),
Genre("Doujinshi"),
Genre("Drama"),
Genre("Ecchi"),
Genre("Fantasy"),
Genre("Gender Bender"),
Genre("Harem"),
Genre("Historical"),
Genre("Horror"),
Genre("Josei"),
Genre("Martial Arts"),
Genre("Mature"),
Genre("Mecha"),
Genre("Mystery"),
Genre("One Shot"),
Genre("Psychological"),
Genre("Romance"),
Genre("School Life"),
Genre("Sci-fi"),
Genre("Seinen"),
Genre("Shoujo"),
Genre("Shoujo Ai"),
Genre("Shounen"),
Genre("Shounen Ai"),
Genre("Slice of Life"),
Genre("Smut"),
Genre("Sports"),
Genre("Supernatural"),
Genre("Tragedy"),
Genre("Webtoons"),
Genre("Yaoi"),
Genre("Yuri")
)
}

View File

@ -1,259 +0,0 @@
package eu.kanade.tachiyomi.source.online.english
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import okhttp3.HttpUrl
import okhttp3.Request
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.security.SecureRandom
import java.security.cert.X509Certificate
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.*
import javax.net.ssl.SSLContext
import javax.net.ssl.X509TrustManager
class Mangahere : ParsedHttpSource() {
override val id: Long = 2
override val name = "Mangahere"
override val baseUrl = "http://www.mangahere.cc"
override val lang = "en"
override val supportsLatest = true
private val trustManager = object : X509TrustManager {
override fun getAcceptedIssuers(): Array<X509Certificate> {
return emptyArray()
}
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
}
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
}
}
private val sslContext = SSLContext.getInstance("SSL").apply {
init(null, arrayOf(trustManager), SecureRandom())
}
override val client = super.client.newBuilder()
.sslSocketFactory(sslContext.socketFactory, trustManager)
.build()
override fun popularMangaSelector() = "div.directory_list > ul > li"
override fun latestUpdatesSelector() = "div.directory_list > ul > li"
override fun popularMangaRequest(page: Int): Request {
return GET("$baseUrl/directory/$page.htm?views.za", headers)
}
override fun latestUpdatesRequest(page: Int): Request {
return GET("$baseUrl/directory/$page.htm?last_chapter_time.za", headers)
}
private fun mangaFromElement(query: String, element: Element): SManga {
val manga = SManga.create()
element.select(query).first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = if (it.hasAttr("title")) it.attr("title") else if (it.hasAttr("rel")) it.attr("rel") else it.text()
}
return manga
}
override fun popularMangaFromElement(element: Element): SManga {
return mangaFromElement("div.title > a", element)
}
override fun latestUpdatesFromElement(element: Element): SManga {
return popularMangaFromElement(element)
}
override fun popularMangaNextPageSelector() = "div.next-page > a.next"
override fun latestUpdatesNextPageSelector() = "div.next-page > a.next"
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = HttpUrl.parse("$baseUrl/search.php?name_method=cw&author_method=cw&artist_method=cw&advopts=1")!!.newBuilder().addQueryParameter("name", query)
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
when (filter) {
is Status -> url.addQueryParameter("is_completed", arrayOf("", "1", "0")[filter.state])
is GenreList -> filter.state.forEach { genre -> url.addQueryParameter(genre.id, genre.state.toString()) }
is TextField -> url.addQueryParameter(filter.key, filter.state)
is Type -> url.addQueryParameter("direction", arrayOf("", "rl", "lr")[filter.state])
is OrderBy -> {
url.addQueryParameter("sort", arrayOf("name", "rating", "views", "total_chapters", "last_chapter_time")[filter.state!!.index])
url.addQueryParameter("order", if (filter.state?.ascending == true) "az" else "za")
}
}
}
url.addQueryParameter("page", page.toString())
return GET(url.toString(), headers)
}
override fun searchMangaSelector() = "div.result_search > dl:has(dt)"
override fun searchMangaFromElement(element: Element): SManga {
return mangaFromElement("a.manga_info", element)
}
override fun searchMangaNextPageSelector() = "div.next-page > a.next"
override fun mangaDetailsParse(document: Document): SManga {
val detailElement = document.select(".manga_detail_top").first()
val infoElement = detailElement.select(".detail_topText").first()
val licensedElement = document.select(".mt10.color_ff00.mb10").first()
val manga = SManga.create()
manga.author = infoElement.select("a[href*=author/]").first()?.text()
manga.artist = infoElement.select("a[href*=artist/]").first()?.text()
manga.genre = infoElement.select("li:eq(3)").first()?.text()?.substringAfter("Genre(s):")
manga.description = infoElement.select("#show").first()?.text()?.substringBeforeLast("Show less")
manga.thumbnail_url = detailElement.select("img.img").first()?.attr("src")
if (licensedElement?.text()?.contains("licensed") == true) {
manga.status = SManga.LICENSED
} else {
manga.status = infoElement.select("li:eq(6)").first()?.text().orEmpty().let { parseStatus(it) }
}
return manga
}
private fun parseStatus(status: String) = when {
status.contains("Ongoing") -> SManga.ONGOING
status.contains("Completed") -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
override fun chapterListSelector() = ".detail_list > ul:not([class]) > li"
override fun chapterFromElement(element: Element): SChapter {
val parentEl = element.select("span.left").first()
val urlElement = parentEl.select("a").first()
var volume = parentEl.select("span.mr6")?.first()?.text()?.trim() ?: ""
if (volume.length > 0) {
volume = " - " + volume
}
var title = parentEl?.textNodes()?.last()?.text()?.trim() ?: ""
if (title.length > 0) {
title = " - " + title
}
val chapter = SChapter.create()
chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = urlElement.text() + volume + title
chapter.date_upload = element.select("span.right").first()?.text()?.let { parseChapterDate(it) } ?: 0
return chapter
}
private fun parseChapterDate(date: String): Long {
return if ("Today" in date) {
Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
} else if ("Yesterday" in date) {
Calendar.getInstance().apply {
add(Calendar.DATE, -1)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
} else {
try {
SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).parse(date).time
} catch (e: ParseException) {
0L
}
}
}
override fun pageListParse(document: Document): List<Page> {
val licensedError = document.select(".mangaread_error > .mt10").first()
if (licensedError != null) {
throw Exception(licensedError.text())
}
val pages = mutableListOf<Page>()
document.select("select.wid60").first()?.getElementsByTag("option")?.forEach {
if (!it.attr("value").contains("featured.html")) {
pages.add(Page(pages.size, "http:" + it.attr("value")))
}
}
pages.getOrNull(0)?.imageUrl = imageUrlParse(document)
return pages
}
override fun imageUrlParse(document: Document) = document.getElementById("image").attr("src")
private class Status : Filter.TriState("Completed")
private class Genre(name: String, val id: String = "genres[$name]") : Filter.TriState(name)
private class TextField(name: String, val key: String) : Filter.Text(name)
private class Type : Filter.Select<String>("Type", arrayOf("Any", "Japanese Manga (read from right to left)", "Korean Manhwa (read from left to right)"))
private class OrderBy : Filter.Sort("Order by",
arrayOf("Series name", "Rating", "Views", "Total chapters", "Last chapter"),
Filter.Sort.Selection(2, false))
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
override fun getFilterList() = FilterList(
TextField("Author", "author"),
TextField("Artist", "artist"),
Type(),
Status(),
OrderBy(),
GenreList(getGenreList())
)
// [...document.querySelectorAll("select[id^='genres'")].map((el,i) => `Genre("${el.nextSibling.nextSibling.textContent.trim()}", "${el.getAttribute('name')}")`).join(',\n')
// http://www.mangahere.co/advsearch.htm
private fun getGenreList() = listOf(
Genre("Action"),
Genre("Adventure"),
Genre("Comedy"),
Genre("Doujinshi"),
Genre("Drama"),
Genre("Ecchi"),
Genre("Fantasy"),
Genre("Gender Bender"),
Genre("Harem"),
Genre("Historical"),
Genre("Horror"),
Genre("Josei"),
Genre("Martial Arts"),
Genre("Mature"),
Genre("Mecha"),
Genre("Mystery"),
Genre("One Shot"),
Genre("Psychological"),
Genre("Romance"),
Genre("School Life"),
Genre("Sci-fi"),
Genre("Seinen"),
Genre("Shoujo"),
Genre("Shoujo Ai"),
Genre("Shounen"),
Genre("Shounen Ai"),
Genre("Slice of Life"),
Genre("Sports"),
Genre("Supernatural"),
Genre("Tragedy"),
Genre("Yaoi"),
Genre("Yuri")
)
}

View File

@ -1,249 +0,0 @@
package eu.kanade.tachiyomi.source.online.english
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.HttpUrl
import okhttp3.Request
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.text.SimpleDateFormat
import java.util.regex.Pattern
class Mangasee : ParsedHttpSource() {
override val id: Long = 9
override val name = "Mangasee"
override val baseUrl = "http://mangaseeonline.net"
override val lang = "en"
override val supportsLatest = true
private val recentUpdatesPattern = Pattern.compile("(.*?)\\s(\\d+\\.?\\d*)\\s?(Completed)?")
private val indexPattern = Pattern.compile("-index-(.*?)-")
private val catalogHeaders = Headers.Builder().apply {
add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)")
add("Host", "mangaseeonline.us")
}.build()
override fun popularMangaSelector() = "div.requested > div.row"
override fun popularMangaRequest(page: Int): Request {
val (body, requestUrl) = convertQueryToPost(page, "$baseUrl/search/request.php?sortBy=popularity&sortOrder=descending")
return POST(requestUrl, catalogHeaders, body.build())
}
override fun popularMangaFromElement(element: Element): SManga {
val manga = SManga.create()
element.select("a.resultLink").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text()
}
return manga
}
override fun popularMangaNextPageSelector() = "button.requestMore"
override fun searchMangaSelector() = "div.requested > div.row"
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = HttpUrl.parse("$baseUrl/search/request.php")!!.newBuilder()
if (!query.isEmpty()) url.addQueryParameter("keyword", query)
val genres = mutableListOf<String>()
val genresNo = mutableListOf<String>()
for (filter in if (filters.isEmpty()) getFilterList() else filters) {
when (filter) {
is Sort -> {
if (filter.state?.index != 0)
url.addQueryParameter("sortBy", if (filter.state?.index == 1) "dateUpdated" else "popularity")
if (filter.state?.ascending != true)
url.addQueryParameter("sortOrder", "descending")
}
is SelectField -> if (filter.state != 0) url.addQueryParameter(filter.key, filter.values[filter.state])
is TextField -> if (!filter.state.isEmpty()) url.addQueryParameter(filter.key, filter.state)
is GenreList -> filter.state.forEach { genre ->
when (genre.state) {
Filter.TriState.STATE_INCLUDE -> genres.add(genre.name)
Filter.TriState.STATE_EXCLUDE -> genresNo.add(genre.name)
}
}
}
}
if (genres.isNotEmpty()) url.addQueryParameter("genre", genres.joinToString(","))
if (genresNo.isNotEmpty()) url.addQueryParameter("genreNo", genresNo.joinToString(","))
val (body, requestUrl) = convertQueryToPost(page, url.toString())
return POST(requestUrl, catalogHeaders, body.build())
}
private fun convertQueryToPost(page: Int, url: String): Pair<FormBody.Builder, String> {
val url = HttpUrl.parse(url)!!
val body = FormBody.Builder().add("page", page.toString())
for (i in 0..url.querySize() - 1) {
body.add(url.queryParameterName(i), url.queryParameterValue(i))
}
val requestUrl = url.scheme() + "://" + url.host() + url.encodedPath()
return Pair(body, requestUrl)
}
override fun searchMangaFromElement(element: Element): SManga {
val manga = SManga.create()
element.select("a.resultLink").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text()
}
return manga
}
override fun searchMangaNextPageSelector() = "button.requestMore"
override fun mangaDetailsParse(document: Document): SManga {
val detailElement = document.select("div.well > div.row").first()
val manga = SManga.create()
manga.author = detailElement.select("a[href^=/search/?author=]").first()?.text()
manga.genre = detailElement.select("span.details > div.row > div:has(b:contains(Genre(s))) > a").map { it.text() }.joinToString()
manga.description = detailElement.select("strong:contains(Description:) + div").first()?.text()
manga.status = detailElement.select("a[href^=/search/?status=]").first()?.text().orEmpty().let { parseStatus(it) }
manga.thumbnail_url = detailElement.select("div > img").first()?.absUrl("src")
return manga
}
private fun parseStatus(status: String) = when {
status.contains("Ongoing (Scan)") -> SManga.ONGOING
status.contains("Complete (Scan)") -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
override fun chapterListSelector() = "div.chapter-list > a"
override fun chapterFromElement(element: Element): SChapter {
val urlElement = element.select("a").first()
val chapter = SChapter.create()
chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = element.select("span.chapterLabel").first().text()?.let { it } ?: ""
chapter.date_upload = element.select("time").first()?.attr("datetime")?.let { parseChapterDate(it) } ?: 0
return chapter
}
private fun parseChapterDate(dateAsString: String): Long {
return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse(dateAsString).time
}
override fun pageListParse(document: Document): List<Page> {
val fullUrl = document.baseUri()
val url = fullUrl.substringBeforeLast('/')
val pages = mutableListOf<Page>()
val series = document.select("input.IndexName").first().attr("value")
val chapter = document.select("span.CurChapter").first().text()
var index = ""
val m = indexPattern.matcher(fullUrl)
if (m.find()) {
val indexNumber = m.group(1)
index = "-index-$indexNumber"
}
document.select("div.ContainerNav").first().select("select.PageSelect > option").forEach {
pages.add(Page(pages.size, "$url/$series-chapter-$chapter$index-page-${pages.size + 1}.html"))
}
pages.getOrNull(0)?.imageUrl = imageUrlParse(document)
return pages
}
override fun imageUrlParse(document: Document): String = document.select("img.CurImage").attr("src")
override fun latestUpdatesNextPageSelector() = "button.requestMore"
override fun latestUpdatesSelector(): String = "a.latestSeries"
override fun latestUpdatesRequest(page: Int): Request {
val url = "http://mangaseeonline.net/home/latest.request.php"
val (body, requestUrl) = convertQueryToPost(page, url)
return POST(requestUrl, catalogHeaders, body.build())
}
override fun latestUpdatesFromElement(element: Element): SManga {
val manga = SManga.create()
element.select("a.latestSeries").first().let {
val chapterUrl = it.attr("href")
val indexOfMangaUrl = chapterUrl.indexOf("-chapter-")
val indexOfLastPath = chapterUrl.lastIndexOf("/")
val mangaUrl = chapterUrl.substring(indexOfLastPath, indexOfMangaUrl)
val defaultText = it.select("p.clamp2").text()
val m = recentUpdatesPattern.matcher(defaultText)
val title = if (m.matches()) m.group(1) else defaultText
manga.setUrlWithoutDomain("/manga" + mangaUrl)
manga.title = title
}
return manga
}
private class Sort : Filter.Sort("Sort", arrayOf("Alphabetically", "Date updated", "Popularity"), Filter.Sort.Selection(2, false))
private class Genre(name: String) : Filter.TriState(name)
private class TextField(name: String, val key: String) : Filter.Text(name)
private class SelectField(name: String, val key: String, values: Array<String>, state: Int = 0) : Filter.Select<String>(name, values, state)
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
override fun getFilterList() = FilterList(
TextField("Years", "year"),
TextField("Author", "author"),
SelectField("Scan Status", "status", arrayOf("Any", "Complete", "Discontinued", "Hiatus", "Incomplete", "Ongoing")),
SelectField("Publish Status", "pstatus", arrayOf("Any", "Cancelled", "Complete", "Discontinued", "Hiatus", "Incomplete", "Ongoing", "Unfinished")),
SelectField("Type", "type", arrayOf("Any", "Doujinshi", "Manga", "Manhua", "Manhwa", "OEL", "One-shot")),
Sort(),
GenreList(getGenreList())
)
// [...document.querySelectorAll("label.triStateCheckBox input")].map(el => `Filter("${el.getAttribute('name')}", "${el.nextSibling.textContent.trim()}")`).join(',\n')
// http://mangasee.co/advanced-search/
private fun getGenreList() = listOf(
Genre("Action"),
Genre("Adult"),
Genre("Adventure"),
Genre("Comedy"),
Genre("Doujinshi"),
Genre("Drama"),
Genre("Ecchi"),
Genre("Fantasy"),
Genre("Gender Bender"),
Genre("Harem"),
Genre("Hentai"),
Genre("Historical"),
Genre("Horror"),
Genre("Josei"),
Genre("Lolicon"),
Genre("Martial Arts"),
Genre("Mature"),
Genre("Mecha"),
Genre("Mystery"),
Genre("Psychological"),
Genre("Romance"),
Genre("School Life"),
Genre("Sci-fi"),
Genre("Seinen"),
Genre("Shotacon"),
Genre("Shoujo"),
Genre("Shoujo Ai"),
Genre("Shounen"),
Genre("Shounen Ai"),
Genre("Slice of Life"),
Genre("Smut"),
Genre("Sports"),
Genre("Supernatural"),
Genre("Tragedy"),
Genre("Yaoi"),
Genre("Yuri")
)
}

View File

@ -1,224 +0,0 @@
package eu.kanade.tachiyomi.source.online.english
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.util.*
class Readmangatoday : ParsedHttpSource() {
override val id: Long = 8
override val name = "ReadMangaToday"
override val baseUrl = "https://www.readmng.com"
override val lang = "en"
override val supportsLatest = true
override val client: OkHttpClient get() = network.cloudflareClient
/**
* Search only returns data with this set
*/
override fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)")
add("X-Requested-With", "XMLHttpRequest")
}
override fun popularMangaRequest(page: Int): Request {
return GET("$baseUrl/hot-manga/$page", headers)
}
override fun latestUpdatesRequest(page: Int): Request {
return GET("$baseUrl/latest-releases/$page", headers)
}
override fun popularMangaSelector() = "div.hot-manga > div.style-list > div.box"
override fun latestUpdatesSelector() = "div.hot-manga > div.style-grid > div.box"
override fun popularMangaFromElement(element: Element): SManga {
val manga = SManga.create()
element.select("div.title > h2 > a").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.attr("title")
}
return manga
}
override fun latestUpdatesFromElement(element: Element): SManga {
return popularMangaFromElement(element)
}
override fun popularMangaNextPageSelector() = "div.hot-manga > ul.pagination > li > a:contains(»)"
override fun latestUpdatesNextPageSelector() = "div.hot-manga > ul.pagination > li > a:contains(»)"
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val builder = okhttp3.FormBody.Builder()
builder.add("manga-name", query)
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
when (filter) {
is TextField -> builder.add(filter.key, filter.state)
is Type -> builder.add("type", arrayOf("all", "japanese", "korean", "chinese")[filter.state])
is Status -> builder.add("status", arrayOf("both", "completed", "ongoing")[filter.state])
is GenreList -> filter.state.forEach { genre ->
when (genre.state) {
Filter.TriState.STATE_INCLUDE -> builder.add("include[]", genre.id.toString())
Filter.TriState.STATE_EXCLUDE -> builder.add("exclude[]", genre.id.toString())
}
}
}
}
return POST("$baseUrl/service/advanced_search", headers, builder.build())
}
override fun searchMangaSelector() = "div.style-list > div.box"
override fun searchMangaFromElement(element: Element): SManga {
val manga = SManga.create()
element.select("div.title > h2 > a").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.attr("title")
}
return manga
}
override fun searchMangaNextPageSelector() = "div.next-page > a.next"
override fun mangaDetailsParse(document: Document): SManga {
val detailElement = document.select("div.movie-meta").first()
val genreElement = detailElement.select("dl.dl-horizontal > dd:eq(5) a")
val manga = SManga.create()
manga.author = document.select("ul.cast-list li.director > ul a").first()?.text()
manga.artist = document.select("ul.cast-list li:not(.director) > ul a").first()?.text()
manga.description = detailElement.select("li.movie-detail").first()?.text()
manga.status = detailElement.select("dl.dl-horizontal > dd:eq(3)").first()?.text().orEmpty().let { parseStatus(it) }
manga.thumbnail_url = detailElement.select("img.img-responsive").first()?.attr("src")
var genres = mutableListOf<String>()
genreElement?.forEach { genres.add(it.text()) }
manga.genre = genres.joinToString(", ")
return manga
}
private fun parseStatus(status: String) = when {
status.contains("Ongoing") -> SManga.ONGOING
status.contains("Completed") -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
override fun chapterListSelector() = "ul.chp_lst > li"
override fun chapterFromElement(element: Element): SChapter {
val urlElement = element.select("a").first()
val chapter = SChapter.create()
chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = urlElement.select("span.val").text()
chapter.date_upload = element.select("span.dte").first()?.text()?.let { parseChapterDate(it) } ?: 0
return chapter
}
private fun parseChapterDate(date: String): Long {
val dateWords: List<String> = date.split(" ")
if (dateWords.size == 3) {
val timeAgo = Integer.parseInt(dateWords[0])
val date: Calendar = Calendar.getInstance()
if (dateWords[1].contains("Minute")) {
date.add(Calendar.MINUTE, -timeAgo)
} else if (dateWords[1].contains("Hour")) {
date.add(Calendar.HOUR_OF_DAY, -timeAgo)
} else if (dateWords[1].contains("Day")) {
date.add(Calendar.DAY_OF_YEAR, -timeAgo)
} else if (dateWords[1].contains("Week")) {
date.add(Calendar.WEEK_OF_YEAR, -timeAgo)
} else if (dateWords[1].contains("Month")) {
date.add(Calendar.MONTH, -timeAgo)
} else if (dateWords[1].contains("Year")) {
date.add(Calendar.YEAR, -timeAgo)
}
return date.timeInMillis
}
return 0L
}
override fun pageListParse(document: Document): List<Page> {
val pages = mutableListOf<Page>()
document.select("ul.list-switcher-2 > li > select.jump-menu").first().getElementsByTag("option").forEach {
pages.add(Page(pages.size, it.attr("value")))
}
pages.getOrNull(0)?.imageUrl = imageUrlParse(document)
return pages
}
override fun imageUrlParse(document: Document) = document.select("#chapter_img").first().attr("src")
private class Status : Filter.TriState("Completed")
private class Genre(name: String, val id: Int) : Filter.TriState(name)
private class TextField(name: String, val key: String) : Filter.Text(name)
private class Type : Filter.Select<String>("Type", arrayOf("All", "Japanese Manga", "Korean Manhwa", "Chinese Manhua"))
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
override fun getFilterList() = FilterList(
TextField("Author", "author-name"),
TextField("Artist", "artist-name"),
Type(),
Status(),
GenreList(getGenreList())
)
// [...document.querySelectorAll("ul.manga-cat span")].map(el => `Genre("${el.nextSibling.textContent.trim()}", ${el.getAttribute('data-id')})`).join(',\n')
// http://www.readmanga.today/advanced-search
private fun getGenreList() = listOf(
Genre("Action", 2),
Genre("Adventure", 4),
Genre("Comedy", 5),
Genre("Doujinshi", 6),
Genre("Drama", 7),
Genre("Ecchi", 8),
Genre("Fantasy", 9),
Genre("Gender Bender", 10),
Genre("Harem", 11),
Genre("Historical", 12),
Genre("Horror", 13),
Genre("Josei", 14),
Genre("Lolicon", 15),
Genre("Martial Arts", 16),
Genre("Mature", 17),
Genre("Mecha", 18),
Genre("Mystery", 19),
Genre("One shot", 20),
Genre("Psychological", 21),
Genre("Romance", 22),
Genre("School Life", 23),
Genre("Sci-fi", 24),
Genre("Seinen", 25),
Genre("Shotacon", 26),
Genre("Shoujo", 27),
Genre("Shoujo Ai", 28),
Genre("Shounen", 29),
Genre("Shounen Ai", 30),
Genre("Slice of Life", 31),
Genre("Smut", 32),
Genre("Sports", 33),
Genre("Supernatural", 34),
Genre("Tragedy", 35),
Genre("Yaoi", 36),
Genre("Yuri", 37)
)
}

View File

@ -1,122 +0,0 @@
package eu.kanade.tachiyomi.source.online.german
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.FilterList
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.ParsedHttpSource
import okhttp3.Request
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.text.SimpleDateFormat
class WieManga : ParsedHttpSource() {
override val id: Long = 10
override val name = "Wie Manga!"
override val baseUrl = "http://www.wiemanga.com"
override val lang = "de"
override val supportsLatest = true
override fun popularMangaSelector() = ".booklist td > div"
override fun latestUpdatesSelector() = ".booklist td > div"
override fun popularMangaRequest(page: Int): Request {
return GET("$baseUrl/list/Hot-Book/", headers)
}
override fun latestUpdatesRequest(page: Int): Request {
return GET("$baseUrl/list/New-Update/", headers)
}
override fun popularMangaFromElement(element: Element): SManga {
val image = element.select("dt img")
val title = element.select("dd a:first-child")
val manga = SManga.create()
manga.setUrlWithoutDomain(title.attr("href"))
manga.title = title.text()
manga.thumbnail_url = image.attr("src")
return manga
}
override fun latestUpdatesFromElement(element: Element): SManga {
return popularMangaFromElement(element)
}
override fun popularMangaNextPageSelector() = null
override fun latestUpdatesNextPageSelector() = null
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
return GET("$baseUrl/search/?wd=$query", headers)
}
override fun searchMangaSelector() = ".searchresult td > div"
override fun searchMangaFromElement(element: Element): SManga {
val image = element.select(".resultimg img")
val title = element.select(".resultbookname")
val manga = SManga.create()
manga.setUrlWithoutDomain(title.attr("href"))
manga.title = title.text()
manga.thumbnail_url = image.attr("src")
return manga
}
override fun searchMangaNextPageSelector() = ".pagetor a.l"
override fun mangaDetailsParse(document: Document): SManga {
val imageElement = document.select(".bookmessgae tr > td:nth-child(1)").first()
val infoElement = document.select(".bookmessgae tr > td:nth-child(2)").first()
val manga = SManga.create()
manga.author = infoElement.select("dd:nth-of-type(2) a").first()?.text()
manga.artist = infoElement.select("dd:nth-of-type(3) a").first()?.text()
manga.description = infoElement.select("dl > dt:last-child").first()?.text()?.replaceFirst("Beschreibung", "")
manga.thumbnail_url = imageElement.select("img").first()?.attr("src")
if (manga.author == "RSS")
manga.author = null
if (manga.artist == "RSS")
manga.artist = null
return manga
}
override fun chapterListSelector() = ".chapterlist tr:not(:first-child)"
override fun chapterFromElement(element: Element): SChapter {
val urlElement = element.select(".col1 a").first()
val dateElement = element.select(".col3 a").first()
val chapter = SChapter.create()
chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = urlElement.text()
chapter.date_upload = dateElement?.text()?.let { parseChapterDate(it) } ?: 0
return chapter
}
private fun parseChapterDate(date: String): Long {
return SimpleDateFormat("yyyy-MM-dd hh:mm:ss").parse(date).time
}
override fun pageListParse(document: Document): List<Page> {
val pages = mutableListOf<Page>()
document.select("select#page").first().select("option").forEach {
pages.add(Page(pages.size, it.attr("value")))
}
return pages
}
override fun imageUrlParse(document: Document) = document.select("img#comicpic").first().attr("src")
}

View File

@ -1,290 +0,0 @@
package eu.kanade.tachiyomi.source.online.russian
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.text.SimpleDateFormat
import java.util.*
class Mangachan : ParsedHttpSource() {
override val id: Long = 7
override val name = "Mangachan"
override val baseUrl = "http://mangachan.me"
override val lang = "ru"
override val supportsLatest = true
override fun popularMangaRequest(page: Int): Request =
GET("$baseUrl/mostfavorites?offset=${20 * (page - 1)}", headers)
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
var pageNum = 1
when {
page < 1 -> pageNum = 1
page >= 1 -> pageNum = page
}
val url = if (query.isNotEmpty()) {
"$baseUrl/?do=search&subaction=search&story=$query&search_start=$pageNum"
} else {
var genres = ""
var order = ""
var statusParam = true
var status = ""
for (filter in if (filters.isEmpty()) getFilterList() else filters) {
when (filter) {
is GenreList -> {
filter.state.forEach { f ->
if (!f.isIgnored()) {
genres += (if (f.isExcluded()) "-" else "") + f.id + '+'
}
}
}
is OrderBy -> {
if (filter.state!!.ascending && filter.state!!.index == 0) {
statusParam = false
}
}
is Status -> status = arrayOf("", "all_done", "end", "ongoing", "new_ch")[filter.state]
}
}
if (genres.isNotEmpty()) {
for (filter in filters) {
when (filter) {
is OrderBy -> {
order = if (filter.state!!.ascending) {
arrayOf("", "&n=favasc", "&n=abcdesc", "&n=chasc")[filter.state!!.index]
} else {
arrayOf("&n=dateasc", "&n=favdesc", "&n=abcasc", "&n=chdesc")[filter.state!!.index]
}
}
}
}
if (statusParam) {
"$baseUrl/tags/${genres.dropLast(1)}$order?offset=${20 * (pageNum - 1)}&status=$status"
} else {
"$baseUrl/tags/$status/${genres.dropLast(1)}/$order?offset=${20 * (pageNum - 1)}"
}
} else {
for (filter in filters) {
when (filter) {
is OrderBy -> {
order = if (filter.state!!.ascending) {
arrayOf("manga/new", "manga/new&n=favasc", "manga/new&n=abcdesc", "manga/new&n=chasc")[filter.state!!.index]
} else {
arrayOf("manga/new&n=dateasc", "mostfavorites", "catalog", "sortch")[filter.state!!.index]
}
}
}
}
if (statusParam) {
"$baseUrl/$order?offset=${20 * (pageNum - 1)}&status=$status"
} else {
"$baseUrl/$order/$status?offset=${20 * (pageNum - 1)}"
}
}
}
return GET(url, headers)
}
override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/newestch?page=$page")
override fun popularMangaSelector() = "div.content_row"
override fun latestUpdatesSelector() = "ul.area_rightNews li"
override fun searchMangaSelector() = popularMangaSelector()
override fun popularMangaFromElement(element: Element): SManga {
val manga = SManga.create()
manga.thumbnail_url = element.select("div.manga_images img").first().attr("src")
element.select("h2 > a").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text()
}
return manga
}
override fun latestUpdatesFromElement(element: Element): SManga {
val manga = SManga.create()
element.select("a:nth-child(1)").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text()
}
return manga
}
override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element)
override fun popularMangaNextPageSelector() = "a:contains(Вперед)"
override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
override fun searchMangaNextPageSelector() = "a:contains(Далее)"
private fun searchGenresNextPageSelector() = popularMangaNextPageSelector()
override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
var hasNextPage = false
val mangas = document.select(searchMangaSelector()).map { element ->
searchMangaFromElement(element)
}
val nextSearchPage = document.select(searchMangaNextPageSelector())
if (nextSearchPage.isNotEmpty()) {
val query = document.select("input#searchinput").first().attr("value")
val pageNum = nextSearchPage.let { selector ->
val onClick = selector.attr("onclick")
onClick?.split("""\\d+""")
}
nextSearchPage.attr("href", "$baseUrl/?do=search&subaction=search&story=$query&search_start=$pageNum")
hasNextPage = true
}
val nextGenresPage = document.select(searchGenresNextPageSelector())
if (nextGenresPage.isNotEmpty()) {
hasNextPage = true
}
return MangasPage(mangas, hasNextPage)
}
override fun mangaDetailsParse(document: Document): SManga {
val infoElement = document.select("table.mangatitle").first()
val descElement = document.select("div#description").first()
val imgElement = document.select("img#cover").first()
val manga = SManga.create()
manga.author = infoElement.select("tr:eq(2) > td:eq(1)").text()
manga.genre = infoElement.select("tr:eq(5) > td:eq(1)").text()
manga.status = parseStatus(infoElement.select("tr:eq(4) > td:eq(1)").text())
manga.description = descElement.textNodes().first().text()
manga.thumbnail_url = imgElement.attr("src")
return manga
}
private fun parseStatus(element: String): Int = when {
element.contains("перевод завершен") -> SManga.COMPLETED
element.contains("перевод продолжается") -> SManga.ONGOING
else -> SManga.UNKNOWN
}
override fun chapterListSelector() = "table.table_cha tr:gt(1)"
override fun chapterFromElement(element: Element): SChapter {
val urlElement = element.select("a").first()
val chapter = SChapter.create()
chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = urlElement.text()
chapter.date_upload = element.select("div.date").first()?.text()?.let {
SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(it).time
} ?: 0
return chapter
}
override fun pageListParse(response: Response): List<Page> {
val html = response.body()!!.string()
val beginIndex = html.indexOf("fullimg\":[") + 10
val endIndex = html.indexOf(",]", beginIndex)
val trimmedHtml = html.substring(beginIndex, endIndex).replace("\"", "")
val pageUrls = trimmedHtml.split(',')
return pageUrls.mapIndexed { i, url -> Page(i, "", url) }
}
override fun pageListParse(document: Document): List<Page> {
throw Exception("Not used")
}
override fun imageUrlParse(document: Document) = ""
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Тэги", genres)
private class Genre(name: String, val id: String = name.replace(' ', '_')) : Filter.TriState(name)
private class Status : Filter.Select<String>("Статус", arrayOf("Все", "Перевод завершен", "Выпуск завершен", "Онгоинг", "Новые главы"))
private class OrderBy : Filter.Sort("Сортировка",
arrayOf("Дата", "Популярность", "Имя", "Главы"),
Filter.Sort.Selection(1, false))
override fun getFilterList() = FilterList(
Status(),
OrderBy(),
GenreList(getGenreList())
)
/* [...document.querySelectorAll("li.sidetag > a:nth-child(1)")]
* .map(el => `Genre("${el.getAttribute('href').substr(6)}")`).join(',\n')
* on http://mangachan.me/
*/
private fun getGenreList() = listOf(
Genre("18_плюс"),
Genre("bdsm"),
Genre("арт"),
Genre("боевик"),
Genre("боевыескусства"),
Genre("вампиры"),
Genre("веб"),
Genre("гарем"),
Genre("гендерная_интрига"),
Genre("героическое_фэнтези"),
Genre("детектив"),
Genre("дзёсэй"),
Genre("додзинси"),
Genre("драма"),
Genre("игра"),
Genre("инцест"),
Genre("искусство"),
Genre("история"),
Genre("киберпанк"),
Genre("кодомо"),
Genre("комедия"),
Genre("литРПГ"),
Genre("махо-сёдзё"),
Genre("меха"),
Genre("мистика"),
Genre("музыка"),
Genre("научная_фантастика"),
Genre("повседневность"),
Genre("постапокалиптика"),
Genre("приключения"),
Genre("психология"),
Genre("романтика"),
Genre("самурайский_боевик"),
Genre("сборник"),
Genre("сверхъестественное"),
Genre("сказка"),
Genre("спорт"),
Genre("супергерои"),
Genre("сэйнэн"),
Genre("сёдзё"),
Genre("сёдзё-ай"),
Genre("сёнэн"),
Genre("сёнэн-ай"),
Genre("тентакли"),
Genre("трагедия"),
Genre("триллер"),
Genre("ужасы"),
Genre("фантастика"),
Genre("фурри"),
Genre("фэнтези"),
Genre("школа"),
Genre("эротика"),
Genre("юри"),
Genre("яой"),
Genre("ёнкома")
)
}

View File

@ -1,207 +0,0 @@
package eu.kanade.tachiyomi.source.online.russian
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import okhttp3.Headers
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.text.SimpleDateFormat
import java.util.*
import java.util.regex.Pattern
class Mintmanga : ParsedHttpSource() {
override val id: Long = 6
override val name = "Mintmanga"
override val baseUrl = "http://mintmanga.com"
override val lang = "ru"
override val supportsLatest = true
override fun popularMangaRequest(page: Int): Request =
GET("$baseUrl/list?sortType=rate&offset=${70 * (page - 1)}&max=70", headers)
override fun latestUpdatesRequest(page: Int): Request =
GET("$baseUrl/list?sortType=updated&offset=${70 * (page - 1)}&max=70", headers)
override fun popularMangaSelector() = "div.tile"
override fun latestUpdatesSelector() = "div.tile"
override fun popularMangaFromElement(element: Element): SManga {
val manga = SManga.create()
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")
}
return manga
}
override fun latestUpdatesFromElement(element: Element): SManga =
popularMangaFromElement(element)
override fun popularMangaNextPageSelector() = "a.nextLink"
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)
}
override fun searchMangaSelector() = popularMangaSelector()
override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element)
// max 200 results
override fun searchMangaNextPageSelector() = null
override fun mangaDetailsParse(document: Document): SManga {
val infoElement = document.select("div.leftContent").first()
val manga = SManga.create()
manga.author = infoElement.select("span.elem_author").first()?.text()
manga.genre = infoElement.select("span.elem_genre").text().replace(" ,", ",")
manga.description = infoElement.select("div.manga-description").text()
manga.status = parseStatus(infoElement.html())
manga.thumbnail_url = infoElement.select("img").attr("data-full")
return manga
}
private fun parseStatus(element: String): Int = when {
element.contains("<h3>Запрещена публикация произведения по копирайту</h3>") -> SManga.LICENSED
element.contains("<h1 class=\"names\"> Сингл") || element.contains("<b>Перевод:</b> завершен") -> SManga.COMPLETED
element.contains("<b>Перевод:</b> продолжается") -> SManga.ONGOING
else -> SManga.UNKNOWN
}
override fun chapterListSelector() = "div.chapters-link tbody tr"
override fun chapterFromElement(element: Element): SChapter {
val urlElement = element.select("a").first()
val urlText = urlElement.text()
val chapter = SChapter.create()
chapter.setUrlWithoutDomain(urlElement.attr("href") + "?mtr=1")
if (urlText.endsWith(" новое")) {
chapter.name = urlText.dropLast(6)
} else {
chapter.name = urlText
}
chapter.date_upload = element.select("td:eq(1)").first()?.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*""")
when {
basic.containsMatchIn(chapter.name) -> {
basic.find(chapter.name)?.let {
val number = it.groups[3]?.value!!
chapter.chapter_number = number.toFloat()
}
}
extra.containsMatchIn(chapter.name) -> // Extra chapters doesn't contain chapter number
chapter.chapter_number = -2f
single.containsMatchIn(chapter.name) -> // Oneshoots, doujinshi and other mangas with one chapter
chapter.chapter_number = 1f
}
}
override fun pageListParse(response: Response): List<Page> {
val html = response.body()!!.string()
val beginIndex = html.indexOf("rm_h.init( [")
val endIndex = html.indexOf("], 0, false);", beginIndex)
val trimmedHtml = html.substring(beginIndex, endIndex)
val p = Pattern.compile("'.*?','.*?',\".*?\"")
val m = p.matcher(trimmedHtml)
val pages = mutableListOf<Page>()
var i = 0
while (m.find()) {
val urlParts = m.group().replace("[\"\']+".toRegex(), "").split(',')
pages.add(Page(i++, "", urlParts[1] + urlParts[0] + urlParts[2]))
}
return pages
}
override fun pageListParse(document: Document): List<Page> {
throw Exception("Not used")
}
override fun imageUrlParse(document: Document) = ""
override fun imageRequest(page: Page): Request {
val imgHeader = Headers.Builder().apply {
add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)")
add("Referer", baseUrl)
}.build()
return GET(page.imageUrl!!, imgHeader)
}
private class Genre(name: String, val id: String) : Filter.TriState(name)
/* [...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(
Genre("арт", "el_2220"),
Genre("бара", "el_1353"),
Genre("боевик", "el_1346"),
Genre("боевые искусства", "el_1334"),
Genre("вампиры", "el_1339"),
Genre("гарем", "el_1333"),
Genre("гендерная интрига", "el_1347"),
Genre("героическое фэнтези", "el_1337"),
Genre("детектив", "el_1343"),
Genre("дзёсэй", "el_1349"),
Genre("додзинси", "el_1332"),
Genre("драма", "el_1310"),
Genre("игра", "el_5229"),
Genre("история", "el_1311"),
Genre("киберпанк", "el_1351"),
Genre("комедия", "el_1328"),
Genre("меха", "el_1318"),
Genre("мистика", "el_1324"),
Genre("научная фантастика", "el_1325"),
Genre("омегаверс", "el_5676"),
Genre("повседневность", "el_1327"),
Genre("постапокалиптика", "el_1342"),
Genre("приключения", "el_1322"),
Genre("психология", "el_1335"),
Genre("романтика", "el_1313"),
Genre("самурайский боевик", "el_1316"),
Genre("сверхъестественное", "el_1350"),
Genre("сёдзё", "el_1314"),
Genre("сёдзё-ай", "el_1320"),
Genre("сёнэн", "el_1326"),
Genre("сёнэн-ай", "el_1330"),
Genre("спорт", "el_1321"),
Genre("сэйнэн", "el_1329"),
Genre("трагедия", "el_1344"),
Genre("триллер", "el_1341"),
Genre("ужасы", "el_1317"),
Genre("фантастика", "el_1331"),
Genre("фэнтези", "el_1323"),
Genre("школа", "el_1319"),
Genre("эротика", "el_1340"),
Genre("этти", "el_1354"),
Genre("юри", "el_1315"),
Genre("яой", "el_1336")
)
}

View File

@ -1,205 +0,0 @@
package eu.kanade.tachiyomi.source.online.russian
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import okhttp3.Headers
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.text.SimpleDateFormat
import java.util.*
import java.util.regex.Pattern
class Readmanga : ParsedHttpSource() {
override val id: Long = 5
override val name = "Readmanga"
override val baseUrl = "http://readmanga.me"
override val lang = "ru"
override val supportsLatest = true
override fun popularMangaSelector() = "div.tile"
override fun latestUpdatesSelector() = "div.tile"
override fun popularMangaRequest(page: Int): Request =
GET("$baseUrl/list?sortType=rate&offset=${70 * (page - 1)}&max=70", headers)
override fun latestUpdatesRequest(page: Int): Request =
GET("$baseUrl/list?sortType=updated&offset=${70 * (page - 1)}&max=70", headers)
override fun popularMangaFromElement(element: Element): SManga {
val manga = SManga.create()
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")
}
return manga
}
override fun latestUpdatesFromElement(element: Element): SManga =
popularMangaFromElement(element)
override fun popularMangaNextPageSelector() = "a.nextLink"
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)
}
override fun searchMangaSelector() = popularMangaSelector()
override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element)
// max 200 results
override fun searchMangaNextPageSelector() = null
override fun mangaDetailsParse(document: Document): SManga {
val infoElement = document.select("div.leftContent").first()
val manga = SManga.create()
manga.author = infoElement.select("span.elem_author").first()?.text()
manga.genre = infoElement.select("span.elem_genre").text().replace(" ,", ",")
manga.description = infoElement.select("div.manga-description").text()
manga.status = parseStatus(infoElement.html())
manga.thumbnail_url = infoElement.select("img").attr("data-full")
return manga
}
private fun parseStatus(element: String): Int = when {
element.contains("<h3>Запрещена публикация произведения по копирайту</h3>") -> SManga.LICENSED
element.contains("<h1 class=\"names\"> Сингл") || element.contains("<b>Перевод:</b> завершен") -> SManga.COMPLETED
element.contains("<b>Перевод:</b> продолжается") -> SManga.ONGOING
else -> SManga.UNKNOWN
}
override fun chapterListSelector() = "div.chapters-link tbody tr"
override fun chapterFromElement(element: Element): SChapter {
val urlElement = element.select("a").first()
val urlText = urlElement.text()
val chapter = SChapter.create()
chapter.setUrlWithoutDomain(urlElement.attr("href") + "?mtr=1")
if (urlText.endsWith(" новое")) {
chapter.name = urlText.dropLast(6)
} else {
chapter.name = urlText
}
chapter.date_upload = element.select("td:eq(1)").first()?.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*""")
when {
basic.containsMatchIn(chapter.name) -> {
basic.find(chapter.name)?.let {
val number = it.groups[3]?.value!!
chapter.chapter_number = number.toFloat()
}
}
extra.containsMatchIn(chapter.name) -> // Extra chapters doesn't contain chapter number
chapter.chapter_number = -2f
single.containsMatchIn(chapter.name) -> // Oneshoots, doujinshi and other mangas with one chapter
chapter.chapter_number = 1f
}
}
override fun pageListParse(response: Response): List<Page> {
val html = response.body()!!.string()
val beginIndex = html.indexOf("rm_h.init( [")
val endIndex = html.indexOf("], 0, false);", beginIndex)
val trimmedHtml = html.substring(beginIndex, endIndex)
val p = Pattern.compile("'.*?','.*?',\".*?\"")
val m = p.matcher(trimmedHtml)
val pages = mutableListOf<Page>()
var i = 0
while (m.find()) {
val urlParts = m.group().replace("[\"\']+".toRegex(), "").split(',')
pages.add(Page(i++, "", urlParts[1] + urlParts[0] + urlParts[2]))
}
return pages
}
override fun pageListParse(document: Document): List<Page> {
throw Exception("Not used")
}
override fun imageUrlParse(document: Document) = ""
override fun imageRequest(page: Page): Request {
val imgHeader = Headers.Builder().apply {
add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)")
add("Referer", baseUrl)
}.build()
return GET(page.imageUrl!!, imgHeader)
}
private class Genre(name: String, val id: String) : Filter.TriState(name)
/* [...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://readmanga.me/search/advanced
*/
override fun getFilterList() = FilterList(
Genre("арт", "el_5685"),
Genre("боевик", "el_2155"),
Genre("боевые искусства", "el_2143"),
Genre("вампиры", "el_2148"),
Genre("гарем", "el_2142"),
Genre("гендерная интрига", "el_2156"),
Genre("героическое фэнтези", "el_2146"),
Genre("детектив", "el_2152"),
Genre("дзёсэй", "el_2158"),
Genre("додзинси", "el_2141"),
Genre("драма", "el_2118"),
Genre("игра", "el_2154"),
Genre("история", "el_2119"),
Genre("киберпанк", "el_8032"),
Genre("кодомо", "el_2137"),
Genre("комедия", "el_2136"),
Genre("махо-сёдзё", "el_2147"),
Genre("меха", "el_2126"),
Genre("мистика", "el_2132"),
Genre("научная фантастика", "el_2133"),
Genre("повседневность", "el_2135"),
Genre("постапокалиптика", "el_2151"),
Genre("приключения", "el_2130"),
Genre("психология", "el_2144"),
Genre("романтика", "el_2121"),
Genre("самурайский боевик", "el_2124"),
Genre("сверхъестественное", "el_2159"),
Genre("сёдзё", "el_2122"),
Genre("сёдзё-ай", "el_2128"),
Genre("сёнэн", "el_2134"),
Genre("сёнэн-ай", "el_2139"),
Genre("спорт", "el_2129"),
Genre("сэйнэн", "el_2138"),
Genre("трагедия", "el_2153"),
Genre("триллер", "el_2150"),
Genre("ужасы", "el_2125"),
Genre("фантастика", "el_2140"),
Genre("фэнтези", "el_2131"),
Genre("школа", "el_2127"),
Genre("этти", "el_2149"),
Genre("юри", "el_2123")
)
}

View File

@ -1,11 +1,21 @@
package eu.kanade.tachiyomi.ui.base.presenter
import android.os.Bundle
import nucleus.presenter.RxPresenter
import nucleus.presenter.delivery.Delivery
import rx.Observable
open class BasePresenter<V> : RxPresenter<V>() {
override fun onCreate(savedState: Bundle?) {
try {
super.onCreate(savedState)
} catch (e: NullPointerException) {
// Swallow this error. This should be fixed in the library but since it's not critical
// (only used by restartables) it should be enough. It saves me a fork.
}
}
/**
* Subscribes an observable with [deliverFirst] and adds it to the presenter's lifecycle
* subscription list.

View File

@ -7,9 +7,9 @@ import eu.kanade.tachiyomi.util.LocaleHelper
import kotlinx.android.synthetic.main.catalogue_main_controller_card.*
class LangHolder(view: View, adapter: FlexibleAdapter<*>) :
BaseFlexibleViewHolder(view, adapter, true) {
BaseFlexibleViewHolder(view, adapter) {
fun bind(item: LangItem) {
title.text = LocaleHelper.getDisplayName(item.code, itemView.context)
}
}
}

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

@ -18,8 +18,10 @@ import kotlinx.android.synthetic.main.catalogue_global_search_controller.*
* This controller should only handle UI actions, IO actions should be done by [CatalogueSearchPresenter]
* [CatalogueSearchCardAdapter.OnMangaClickListener] called when manga is clicked in global search
*/
open class CatalogueSearchController(protected val initialQuery: String? = null) :
NucleusController<CatalogueSearchPresenter>(),
open class CatalogueSearchController(
protected val initialQuery: String? = null,
protected val extensionFilter: String? = null
) : NucleusController<CatalogueSearchPresenter>(),
CatalogueSearchCardAdapter.OnMangaClickListener {
/**
@ -60,7 +62,7 @@ open class CatalogueSearchController(protected val initialQuery: String? = null)
* @return instance of [CatalogueSearchPresenter]
*/
override fun createPresenter(): CatalogueSearchPresenter {
return CatalogueSearchPresenter(initialQuery)
return CatalogueSearchPresenter(initialQuery, extensionFilter)
}
/**
@ -185,4 +187,4 @@ open class CatalogueSearchController(protected val initialQuery: String? = null)
getHolder(source)?.setImage(manga)
}
}
}

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

@ -5,6 +5,7 @@ import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
@ -21,6 +22,7 @@ import rx.subjects.PublishSubject
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
/**
* Presenter of [CatalogueSearchController]
@ -32,6 +34,7 @@ import uy.kohesive.injekt.api.get
*/
open class CatalogueSearchPresenter(
val initialQuery: String? = "",
val initialExtensionFilter: String? = null,
val sourceManager: SourceManager = Injekt.get(),
val db: DatabaseHelper = Injekt.get(),
val preferencesHelper: PreferencesHelper = Injekt.get()
@ -40,7 +43,7 @@ open class CatalogueSearchPresenter(
/**
* Enabled sources.
*/
val sources by lazy { getEnabledSources() }
val sources by lazy { getSourcesToQuery() }
/**
* Query from the view.
@ -63,9 +66,16 @@ open class CatalogueSearchPresenter(
*/
private var fetchImageSubscription: Subscription? = null
private val extensionManager by injectLazy<ExtensionManager>()
private var extensionFilter: String? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
extensionFilter = savedState?.getString(CatalogueSearchPresenter::extensionFilter.name) ?:
initialExtensionFilter
// Perform a search with previous or initial state
search(savedState?.getString(BrowseCataloguePresenter::query.name) ?: initialQuery.orEmpty())
}
@ -78,6 +88,7 @@ open class CatalogueSearchPresenter(
override fun onSave(state: Bundle) {
state.putString(BrowseCataloguePresenter::query.name, query)
state.putString(CatalogueSearchPresenter::extensionFilter.name, extensionFilter)
super.onSave(state)
}
@ -97,8 +108,35 @@ open class CatalogueSearchPresenter(
.sortedBy { "(${it.lang}) ${it.name}" }
}
private fun getSourcesToQuery(): List<CatalogueSource> {
val filter = extensionFilter
val enabledSources = getEnabledSources()
if (filter.isNullOrEmpty()) {
return enabledSources
}
val filterSources = extensionManager.installedExtensions
.filter { it.pkgName == filter }
.flatMap { it.sources }
.filter { it in enabledSources }
.filterIsInstance<CatalogueSource>()
if (filterSources.isEmpty()) {
return enabledSources
}
return filterSources
}
/**
* 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 +151,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 +163,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 +250,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

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.main
import android.animation.ObjectAnimator
import android.app.SearchManager
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
@ -15,6 +16,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
import eu.kanade.tachiyomi.ui.base.controller.*
import eu.kanade.tachiyomi.ui.catalogue.CatalogueController
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController
import eu.kanade.tachiyomi.ui.download.DownloadController
import eu.kanade.tachiyomi.ui.extension.ExtensionController
import eu.kanade.tachiyomi.ui.library.LibraryController
@ -50,6 +52,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)
@ -157,6 +160,29 @@ class MainActivity : BaseActivity() {
setSelectedDrawerItem(R.id.nav_drawer_downloads)
}
}
Intent.ACTION_SEARCH, "com.google.android.gms.actions.SEARCH_ACTION" -> {
//If the intent match the "standard" Android search intent
// or the Google-specific search intent (triggered by saying or typing "search *query* on *Tachiyomi*" in Google Search/Google Assistant)
//Get the search query provided in extras, and if not null, perform a global search with it.
val query = intent.getStringExtra(SearchManager.QUERY)
if (query != null && !query.isEmpty()) {
if (router.backstackSize > 1) {
router.popToRoot()
}
router.pushController(CatalogueSearchController(query).withFadeTransaction())
}
}
INTENT_SEARCH -> {
val query = intent.getStringExtra(INTENT_SEARCH_QUERY)
val filter = intent.getStringExtra(INTENT_SEARCH_FILTER)
if (query != null && !query.isEmpty()) {
if (router.backstackSize > 1) {
router.popToRoot()
}
router.pushController(CatalogueSearchController(query, filter).withFadeTransaction())
}
}
else -> return false
}
return true
@ -241,6 +267,10 @@ class MainActivity : BaseActivity() {
const val SHORTCUT_CATALOGUES = "eu.kanade.tachiyomi.SHOW_CATALOGUES"
const val SHORTCUT_DOWNLOADS = "eu.kanade.tachiyomi.SHOW_DOWNLOADS"
const val SHORTCUT_MANGA = "eu.kanade.tachiyomi.SHOW_MANGA"
const val INTENT_SEARCH = "eu.kanade.tachiyomi.SEARCH"
const val INTENT_SEARCH_QUERY = "query"
const val INTENT_SEARCH_FILTER = "filter"
}
}

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

@ -15,12 +15,7 @@ import android.support.customtabs.CustomTabsIntent
import android.support.v4.content.pm.ShortcutInfoCompat
import android.support.v4.content.pm.ShortcutManagerCompat
import android.support.v4.graphics.drawable.IconCompat
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.*
import android.widget.Toast
import com.afollestad.materialdialogs.MaterialDialog
import com.bumptech.glide.load.engine.DiskCacheStrategy
@ -138,6 +133,7 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_open_in_browser -> openInBrowser()
R.id.action_open_in_web_view -> openInWebView()
R.id.action_share -> shareManga()
R.id.action_add_to_home_screen -> addToHomeScreen()
else -> return super.onOptionsItemSelected(item)
@ -302,6 +298,19 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
}
}
private fun openInWebView() {
val source = presenter.source as? HttpSource ?: return
val url = try {
source.mangaDetailsRequest(presenter.manga).url().toString()
} catch (e: Exception) {
return
}
parentController?.router?.pushController(MangaWebViewController(source.id, url)
.withFadeTransaction())
}
/**
* Called to run Intent with [Intent.ACTION_SEND], which show share dialog.
*/
@ -311,10 +320,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 +364,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

@ -0,0 +1,58 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.WebView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.BaseController
import eu.kanade.tachiyomi.util.WebViewClientCompat
import uy.kohesive.injekt.injectLazy
class MangaWebViewController(bundle: Bundle? = null) : BaseController(bundle) {
private val sourceManager by injectLazy<SourceManager>()
constructor(sourceId: Long, url: String) : this(Bundle().apply {
putLong(SOURCE_KEY, sourceId)
putString(URL_KEY, url)
})
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.manga_info_web_controller, container, false)
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
val source = sourceManager.get(args.getLong(SOURCE_KEY)) as? HttpSource ?: return
val url = args.getString(URL_KEY) ?: return
val headers = source.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }
val web = view as WebView
web.webViewClient = object : WebViewClientCompat() {
override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean {
view.loadUrl(url)
return true
}
}
web.settings.javaScriptEnabled = true
web.settings.userAgentString = source.headers["User-Agent"]
web.loadUrl(url, headers)
}
override fun onDestroyView(view: View) {
val web = view as WebView
web.stopLoading()
web.destroy()
super.onDestroyView(view)
}
private companion object {
const val SOURCE_KEY = "source_key"
const val URL_KEY = "url_key"
}
}

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

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