Compare commits

...

147 Commits

Author SHA1 Message Date
82141cec6e Release 0.8.5 2020-02-29 16:42:11 -05:00
218313428f Add warning on update check for Android 4.x users 2020-02-29 16:30:40 -05:00
44b47b49bc Hide null file path on backup creation (closes #1515)
(cherry picked from commit 48d9ad00e1)
2020-02-29 16:24:19 -05:00
2f69317f5d Enforce maximum extension lib version of 1.2 2020-02-29 13:35:07 -05:00
e1eff7b744 Remove FAB animation files left over from bad cherry picking 2020-02-29 13:23:42 -05:00
3aa12281c3 Avoid crash on loading invalid extension
(cherry picked from commit 460fbb18c7)
2020-02-29 13:19:50 -05:00
72920130c0 CloudflareInterceptor update (#2537) dcd3c709 Mike <51273546+SnakeDoc83@users.noreply.github.com> Jan 25, 2020 at 16:37 2020-02-29 13:17:12 -05:00
0fd00331e1 Directly pass read chapter when updating tracker
(cherry picked from commit b642e019e8)
2020-02-29 13:13:49 -05:00
7a4763ee68 minor reader bugs: (#2491)
- fix preload on last page for R2L reader
 - page 3 bug

(cherry picked from commit 8b0458cdf6)
2020-02-29 13:12:31 -05:00
Jay
647a78b791 Build time now opens changelog
(cherry picked from commit 22bb3463593c060405694da39a0eb1f5ca1d6ba1)
(cherry picked from commit d1db9fb659)
2020-02-29 13:12:21 -05:00
82faa91ce3 Tweak reader seekbar height for Android 5 UI bug (closes #2487)
(cherry picked from commit 81418a7712)
2020-02-29 13:12:04 -05:00
ad664dfb9f Remove clickable attributes from unclickable text in reader
(cherry picked from commit d4c25359bd)
2020-02-29 13:11:58 -05:00
005ac9e732 fix bangumi track will override record to 0 after every track search(bind) (#2486)
* fix bangumi track : the update status api must be called before update chapter api

* fix bangumi track will override record to 0 after every track search(bind)

(cherry picked from commit 427d2fed8c)
2020-02-29 13:11:50 -05:00
d8e3fe542d Remove unused FAB animations
(cherry picked from commit ab2bdfc508)
2020-02-29 13:11:43 -05:00
c40e4f6c5a Provide more human readable error when downloading to invalid directory (#2462)
(cherry picked from commit 13a2d3dfdd)
2020-02-29 13:10:55 -05:00
9bb3195bca Remove up/down animation for FAB, add list padding (#2456) e411f542 arkon <arkon@users.noreply.github.com> Jan 8, 2020 at 21:33 2020-02-29 13:10:25 -05:00
eee0bc4985 Remove unused color resource
(cherry picked from commit ea226a1697)
2020-02-29 13:09:06 -05:00
a24d670f54 Made 'Default' category selectable in global update settings (#2318)
(cherry picked from commit b55814a1c0)
2020-02-29 13:08:51 -05:00
51e049ab78 fix bangumi tracker crash in searching english manga title (#2452) eb5382e0 mutsumi <4182301+mutsumi63@users.noreply.github.com> Jan 6, 2020 at 20:02 2020-02-29 13:08:41 -05:00
01e37dfab8 Remove repository for Conductor snapshot (#2441)
(cherry picked from commit 39d509a756)
2020-02-29 13:07:47 -05:00
74087edebb match transition text used by other readers (#2439) 708525ef Carlos <cargo8005@gmail.com> Jan 5, 2020 at 17:59 2020-02-29 13:07:35 -05:00
db58c9b77f fix DOWNLOADED text showing after chapters are marked as read (#2434) df14e6d4 Carlos <cargo8005@gmail.com> Jan 5, 2020 at 16:36 2020-02-29 13:06:41 -05:00
c4dad1c20b Unix line endings 2020-02-29 13:03:29 -05:00
cae04656b9 Improve Loading Speed When Skipping Pages in a Chapter (#2426)
* cancel queued loads when the page that requested the queue is destroyed

* use page.status for optimizing removal

(cherry picked from commit dd1e6402c9)
2020-02-29 12:59:37 -05:00
aa57b1bc77 adjust so downloader doesnt autostart when queue was paused (#2413)
adjust so downloader doesnt autostart when queue was paused
2020-01-03 15:33:17 -05:00
491d476cac auto attempt a login refresh once if MAL returns http 400 (#32) (#2403) 2019-12-29 17:45:58 -05:00
f0053a2f78 add width and height to listview for browseCatalogueController (#2406)
* add width and height to listview for browseCatalogueController

* readd recycler has fixed size
add width and height to list view
2019-12-28 14:57:44 -05:00
10e7a3b35b Update JSoup (#2400) 2019-12-28 14:11:18 -05:00
4147fd6b19 recycler is not fixed size (#2402) 2019-12-28 14:10:34 -05:00
2bb903088e Tweak FAB sizing method (fixes #2398)
Ref: https://stackoverflow.com/questions/56945314/floating-action-button-fab-icon-size-problems-after-migrating-to-sdk-28
2019-12-27 20:53:04 -05:00
c90f985fcc Add REQUEST_DELETE_PACKAGES permission for uninstalling extensions 2019-12-27 07:24:19 -05:00
2ebaacfc89 Replace dependency for case insensitive natural sorting (#2389)
Replace dependency for case insensitive natural sorting
2019-12-27 07:18:30 -05:00
c339bd49d0 Address minor Kotlin compiler warnings 2019-12-26 17:48:39 -05:00
c349fb0e37 Enable Java 8 language feature support 2019-12-26 16:47:33 -05:00
bc825bdefa Minor dependency updates 2019-12-26 16:47:01 -05:00
ed49ce8e1d Update project-level dependencies 2019-12-26 16:06:37 -05:00
ad2ecd538d Allow cleartext traffic
Certain catalogues (e.g. Mangakakalot) do not use HTTPS
2019-12-26 16:06:28 -05:00
ff8e3f0af4 Update to SDK 28 (#2394) 2019-12-26 16:01:16 -05:00
698e17178a Increase default text size of the transition chapter page (#2285) 2019-12-26 12:40:56 -05:00
ebeee70931 Allow back button to navigate to previous URL in WebView, add Forward, Refresh, and Close menu options (#2176) 2019-12-26 12:40:11 -05:00
b8b118bdeb Add .nomedia file in each chapter download folder (#2199)
* Move .nomedia creation to directory fetch

* Add .nomedia file to all chapter download directories
2019-12-26 12:39:20 -05:00
5ddd7d1b14 Remove minSdkVersion 21 for dev builds (no longer needed for multidex) 2019-12-23 22:23:05 -05:00
450b23436f Update DB architecture component
Needs to eventually be replaced by androidx.sqlite.db
2019-12-23 22:20:59 -05:00
89793ac338 Update to Kotlin 1.3.61 2019-12-23 22:18:11 -05:00
c456812a46 Update coroutines 2019-12-23 22:14:20 -05:00
6f8f6e9233 Update Gradle 2019-12-23 22:03:07 -05:00
5770d00f81 Upgrade Kotlin (to 1.3), Coroutines, Gradle, and Android Gradle… (#2239)
Upgrade Kotlin (to 1.3), Coroutines, Gradle, and Android Gradle plugin
2019-12-23 22:02:07 -05:00
a0fb1eff4a Merge branch 'master' into update-kotlin-coroutines-gradle 2019-12-23 09:49:53 -05:00
89dc240a22 Clean up Anilist GraphQL query formatting 2019-12-22 22:19:15 -05:00
ee4f069341 fix: Don't send newlines and whitespace in API calls (#2348) 2019-12-22 22:13:27 -05:00
011bb9f5b1 Update to build tools v29.0.2 (#2385) 2019-12-22 16:14:09 -05:00
08b06e1b4e Update to latest version of Android support libraries
Should make migration to AndroidX a bit smoother
2019-12-22 15:56:25 -05:00
0416a2ff15 Extract some hardcoded strings (closes #1989) 2019-12-22 15:48:36 -05:00
3a7cdfcaa4 Update README.md (#2372)
changed  Chat to support server
fixed grammar mistake
2019-12-21 10:45:43 -05:00
c36a47576d Update README.md (#2351)
Update README.md
2019-12-03 09:34:58 -05:00
6c9135c093 Improve Issue reporting experience (#2189)
* Improve issue reporting workflow.

* Add meta request template

* Remove old template

* Fix label for bug

* Add template text.

* Remove meta request

As per [review](https://github.com/inorichi/tachiyomi/pull/2189#discussion_r321668645)

* Remove Acceptance Criteria

As per [review](https://github.com/inorichi/tachiyomi/pull/2189#discussion_r321665449)

* Requested changes from arkon

All except the default template.

* Revert "Remove old template"

This reverts commit b9ef01f655e13e2582af68d3a454bd2db4723534.
2019-12-01 13:49:47 -05:00
80ea9001b3 Allow 'Default' category as the default for adding manga (#2292) 2019-11-03 13:52:33 +01:00
24bb94ceac Implemented extension search functionality. (#2211) 2019-10-14 11:15:00 +02:00
0f16351f5f Set glide to use the gif loop count (#2263) 2019-10-14 11:11:11 +02:00
b60b26bbd0 fix jitpack cause it's shitting out (#2274)
fix jitpack shitting out
2019-10-13 01:09:58 -04:00
86e53e08de Group available extensions by language (#2210) 2019-10-11 19:07:55 +02:00
d3cb10a74e Merge pull request #2271 from mezzode/patch-1
Update link in FAQ
2019-10-10 14:17:32 -04:00
7d6cfff719 Update link in FAQ
Wiki has been replaced by website.
2019-10-10 11:10:48 -07:00
cc0fe0a1a9 Change "Help" link from Github Wiki to Website (#2265)
Change "Help" link from Github Wiki to Website
2019-10-04 11:44:52 -04:00
25327342fb Changed README's app screenshot (#2229)
* Delete old screenshot

* Add new screenshots
2019-09-21 11:01:26 -04:00
e02cf67f85 Update translation files (#2238)
Updated by "Cleanup translation files" hook in Weblate.

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/
2019-09-21 11:00:45 -04:00
bf4bef6d62 Translations (#2011)
Translations
2019-09-21 09:50:38 -04:00
76645bce6e Remove redundant "publishNonDefault" setting
I think I've seen the following message from just about every single
gradle run throughout the upgrade process:

publishNonDefault is deprecated and has no effect anymore. All variants are now published.
2019-09-20 12:42:41 -04:00
9276c491bc Upgrade Kotlin (to 1.3), Coroutines, Gradle and Android gradle plugin.
Kotlin:                1.2.71 -> 1.3.50
Coroutines:            0.30.2 -> 1.3.1
Gradle:                4.6    -> 5.4.1
Android gradle plugin: 3.2.1  -> 3.5.0

This brings us down to *one* experimental coroutine API, and we've
opted in to using it in just *one* place.

(The fact that the API to opt-in to using an experimental API in a
specific place is *also* experimental surely will not come back to
bite us later.)
2019-09-18 22:45:54 -04:00
fa59b4f8a7 Fix coroutine deprecations again 2019-09-18 17:41:09 -04:00
934a37c36b Update to kotlinx.coroutines 0.30.2
Almost done, honest!
2019-09-18 13:37:57 -04:00
5362f62078 Update deprecated coroutines code 2019-09-18 13:32:42 -04:00
ccd360687e Update to kotlinx.coroutines 0.26.0 2019-09-18 13:20:39 -04:00
5a2e8a838c Update to kotlinx.coroutines 0.23.4 2019-09-18 01:31:15 -04:00
3abae1cc75 Add chinese track website "bangumi" (#2032)
* copy from shikimori and change parmater

* add login activity

* fix

* login sucess

* search

* add...

* auth fix

* save status

* revert shikimori

* fix oauth error

* add bangumi info

* update read chapter index

* refersh token

* remove outdate file

* drop comment

* change icon

* drop search result which type not comic

* fix bind logic

* set status

* add ep status

* format code

* disable cache for `collection` api
2019-07-23 12:35:38 +02:00
b68ef8c983 Update info about auto updates in README (#2132) 2019-07-23 12:29:01 +02:00
d5f5ba95bb Add automatic updates for dev builds (#2128) 2019-07-13 19:36:30 +02:00
e8638cb0b3 Hide Empty Search Results in Catalogues (#2066)
* test2

* remove nothing_found view and associated resources
2019-07-01 13:06:19 +02:00
62f9071adc Avoid infinite loading in global search if a single catalogue fails (#2097) 2019-06-29 22:27:58 +02:00
1d079dd9a4 use dist: trusty (#2085) 2019-06-18 13:53:47 +02:00
cccb56bda1 Change default update priorization 2019-06-09 14:35:24 +02:00
5d8dc241d8 Update ranking (#1772)
* Add LibraryUpdateRanker

This class provides various functions to generate Comparators that can 
be used to order the manga to update.

One such ordering is by relevance:
It prioritises manga that were updated more recently.

Another Ordering is by lexicographic order:
This is the default behaviour.

* Use relevanceRanking scheme

Instead of default(noRanking/lex ranking) now mangaList is sorted with 
relevanceRanking.

* Add UI and associated variables & strings for Update Ranking.

* Use user preferences to determine update ranking scheme.

* Refactor relevanceRanking to latestFirstranking.

This name seems to better reflect the ranking scheme and frees up the 
name relevanceRanking for future use.

* Set latestFirst scheme as default.

(Changing over from lexicographic scheme)

* Fix 1

[Convert LibraryUpdateRanker to a object.](./files/82f263749f0ae775385b23dd919f1865360db969#r287513539)

[Nitpick: Add lines](./files/82f263749f0ae775385b23dd919f1865360db969#r287540256)

[Replace Java comparator](./files/82f263749f0ae775385b23dd919f1865360db969#r287539976)

[Nitpick: Add local variable](./files/82f263749f0ae775385b23dd919f1865360db969#r287514805)

* Fix 2

[Weird import](./files/82f263749f0ae775385b23dd919f1865360db969#r287513709)

[Default value](./files/82f263749f0ae775385b23dd919f1865360db969#r287540064)

[Use existing Strings](./files/82f263749f0ae775385b23dd919f1865360db969#r287514476)

[Use Library update order](./files/82f263749f0ae775385b23dd919f1865360db969#r287540204)
2019-06-09 14:32:12 +02:00
9ba7312caf Make MAL Tracking Slightly Less Shitty (#2042)
* * fix cookieManager not clearing cookies properly
* manually clear tracking prefs when !isLogged (e.g. cookies were cleared)

* use full url for removing cookies

* add interceptor for all non-login network calls
* attempt auto login if cookies are missing
* move handling of csrf token to interceptor

* * move methods around to improve readability
* fix TrackSearchAdapter not updating other fields if cover_url is missing
* revert accidental removal of feature in https://github.com/inorichi/tachiyomi/issues/65
* avoid login if credentials are missing

* fix eol

* *separate login flow from rxjava for reuse in sync

* *use less expensive method of finding manga

* *move variable declaration

* formatting

* set total chapters in remote track
2019-06-09 14:31:19 +02:00
8ebda219c4 Fix the category selection bug (#2052)
Fixes #2051
2019-05-26 11:37:47 +02:00
47f14e8555 Long click to manage categories (#2045) 2019-05-25 13:47:53 +02:00
974a24d03b Add help link to nav drawer (#2049) 2019-05-25 13:46:42 +02:00
15f225537e Update tracking sites after finishing chapter (#2044)
* Added second updateTrackLastChapterRead() called whenever a chapter has been read in the reader

* Removed old updateTrackLastChapterRead() so that it's not called twice.
2019-05-25 13:46:20 +02:00
a32572fc96 Ignore case while sorting Library (#2048)
* Ignore case while sorting Library

* Simplify code

As suggested by @arkon
2019-05-24 09:57:05 +02:00
be3ed9b6af Create FUNDING.yml 2019-05-24 09:56:31 +02:00
a0939e1c48 Update Shikimori (#2038)
Domain name change due to blocking by local authorities.
2019-05-16 18:21:54 +02:00
003dca9d45 Bugfix. Sharing images with very long name (#1999)
* Fix sharing with very long images name

* Fix dropLast to take
2019-05-07 11:06:38 +02:00
5c1770247c Update README.md
mangafox has been broken for months
2019-05-03 15:51:27 -04:00
021dde66eb Add color filter blend modes (#2013)
* Add color filter blend modes

* Only show modes supported by currently used API level.

* Fix arrays.xml for API level <=27.
2019-04-29 19:32:49 +02:00
5840a3e1e2 Shikomori -> Shikimori. Fix update chapters (#1996)
* Shikomori -> Shikimori. Fix update chapters

* Removed logs and format code
2019-04-29 18:40:26 +02:00
7c6478fe6b Force Migration to display titles from source rather than from local DB, and update local titles when migrated (#1670) 2019-04-29 18:38:59 +02:00
68aca55e6f Add options to open catalogue in browser/webview (#1979) 2019-04-16 17:34:52 +02:00
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
294 changed files with 20042 additions and 13719 deletions

24
.gitattributes vendored Normal file
View File

@ -0,0 +1,24 @@
* text=auto
* text eol=lf
# Windows forced line-endings
/.idea/* text eol=crlf
# Gradle wrapper
*.jar binary
# Images
*.webp binary
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.gz binary
*.zip binary
*.7z binary
*.ttf binary
*.eot binary
*.woff binary
*.pyc binary
*.swp binary

2
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,2 @@
github: inorichi
ko_fi: inorichi

View File

@ -4,6 +4,8 @@
**App version:** **App version:**
**Android version:**
**Issue/Request:** **Issue/Request:**
**Steps to reproduce (if applicable)** **Steps to reproduce (if applicable)**

24
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,24 @@
---
name: "🐞 Bug report"
about: Report a bug
title: "[Bug] Write short description here"
labels: "bug"
---
### Device information
* Tachiyomi version: ?
* Android version: ?
## Steps to reproduce
1. First step
2. Second step
### Expected behavior
This should happen.
### Actual behavior
This happened instead.
### Other details
Additional details and attachments.

View File

@ -0,0 +1,12 @@
---
name: "🌟 Feature request"
about: Suggest a feature to improve Tachiyomi
title: "[Feature Request] Write short description here"
labels: "feature"
---
### Why/User Benefit/User Problem
(explain why this feature should be added)
### What/Requirements
(explain how this feature would behave)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 730 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

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

View File

@ -1,6 +1,6 @@
| Build | Stable | Dev | Contribute | Contact | | Build | Stable | Dev | Contribute | Support Server |
|-------|----------|---------|------------|---------| |-------|----------|---------|------------|---------|
| [![Travis](https://img.shields.io/travis/inorichi/tachiyomi.svg)](https://travis-ci.org/inorichi/tachiyomi) | [![stable release](https://img.shields.io/github/release/inorichi/tachiyomi.svg?maxAge=3600&label=download%20(autoupdate%20included))](https://github.com/inorichi/tachiyomi/releases) | [![latest dev build](https://img.shields.io/badge/download-latest%20build-blue.svg)](http://tachiyomi.kanade.eu/latest) [![fdroid dev](https://img.shields.io/badge/autoupdate-wiki-blue.svg)](//github.com/inorichi/tachiyomi/wiki/F-Droid-for-dev-versions) | [![Translation status](https://hosted.weblate.org/widgets/tachiyomi/-/svg-badge.svg)](https://hosted.weblate.org/engage/tachiyomi/?utm_source=widget) | [![Discord](https://img.shields.io/discord/349436576037732353.svg)](https://discord.gg/tachiyomi) | | [![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)](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 # ![app icon](./.github/readme-images/app-icon.png)Tachiyomi
@ -11,10 +11,10 @@ Tachiyomi is a free and open source manga reader for Android.
## Features ## Features
Features include: Features include:
* Online reading from sources such as KissManga, MangaFox, [and more](https://github.com/inorichi/tachiyomi-extensions) * Online reading from sources such as KissManga, MangaDex, [and more](https://github.com/inorichi/tachiyomi-extensions)
* Local reading of downloaded manga * Local reading of downloaded manga
* Configurable reader with multiple viewers, reading directions and other settings * A configurable reader with multiple viewers, reading directions and other settings.
* [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), and [Kitsu](https://kitsu.io/explore/anime) support * [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), [Kitsu](https://kitsu.io/explore/anime), and [Shikimori](https://shikimori.one) support
* Categories to organize your library * Categories to organize your library
* Light and dark themes * Light and dark themes
* Schedule updating your library for new chapters * Schedule updating your library for new chapters
@ -23,7 +23,7 @@ Features include:
## Download ## Download
Get the app from our [releases page](https://github.com/inorichi/tachiyomi/releases). 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).
## Issues, Feature Requests and Contributing ## Issues, Feature Requests and Contributing
@ -63,8 +63,8 @@ Catalogue requests should be created at https://github.com/inorichi/tachiyomi-ex
## FAQ ## FAQ
[See our wiki.](https://github.com/inorichi/tachiyomi/wiki/FAQ) [See our website.](https://tachiyomi.org/)
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 ## License

View File

@ -29,17 +29,17 @@ ext {
} }
android { android {
compileSdkVersion 27 compileSdkVersion 28
buildToolsVersion '28.0.3' buildToolsVersion '29.0.2'
publishNonDefault true publishNonDefault true
defaultConfig { defaultConfig {
applicationId "eu.kanade.tachiyomi" applicationId "eu.kanade.tachiyomi"
minSdkVersion 16 minSdkVersion 16
targetSdkVersion 27 targetSdkVersion 28
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
versionCode 38 versionCode 42
versionName "0.8.0" versionName "0.8.5"
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\"" buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\"" buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
@ -48,6 +48,8 @@ android {
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
multiDexEnabled true
ndk { ndk {
abiFilters "armeabi-v7a", "arm64-v8a", "x86" abiFilters "armeabi-v7a", "arm64-v8a", "x86"
} }
@ -57,13 +59,6 @@ android {
debug { debug {
versionNameSuffix "-${getCommitCount()}" versionNameSuffix "-${getCommitCount()}"
applicationIdSuffix ".debug" applicationIdSuffix ".debug"
multiDexEnabled true
}
release {
minifyEnabled true
shrinkResources true
multiDexEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
} }
} }
@ -78,7 +73,6 @@ android {
dimension "default" dimension "default"
} }
dev { dev {
minSdkVersion 21
resConfigs "en", "xxhdpi" resConfigs "en", "xxhdpi"
dimension "default" dimension "default"
} }
@ -97,6 +91,14 @@ android {
checkReleaseBuilds false checkReleaseBuilds false
} }
compileOptions {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
kotlinOptions {
jvmTarget = "1.8"
}
} }
dependencies { dependencies {
@ -106,7 +108,7 @@ dependencies {
implementation 'com.github.inorichi:junrar-android:634c1f5' implementation 'com.github.inorichi:junrar-android:634c1f5'
// Android support library // Android support library
final support_library_version = '27.0.2' final support_library_version = '28.0.0'
implementation "com.android.support:support-v4:$support_library_version" implementation "com.android.support:support-v4:$support_library_version"
implementation "com.android.support:appcompat-v7:$support_library_version" implementation "com.android.support:appcompat-v7:$support_library_version"
implementation "com.android.support:cardview-v7:$support_library_version" implementation "com.android.support:cardview-v7:$support_library_version"
@ -116,18 +118,18 @@ dependencies {
implementation "com.android.support:support-annotations:$support_library_version" implementation "com.android.support:support-annotations:$support_library_version"
implementation "com.android.support:customtabs:$support_library_version" implementation "com.android.support:customtabs:$support_library_version"
implementation 'com.android.support.constraint:constraint-layout:1.1.2' implementation 'com.android.support.constraint:constraint-layout:1.1.3'
implementation 'com.android.support:multidex:1.0.2' implementation 'com.android.support:multidex:1.0.3'
standardImplementation 'com.google.firebase:firebase-core:11.8.0' standardImplementation 'com.google.firebase:firebase-core:11.8.0'
// ReactiveX // ReactiveX
implementation 'io.reactivex:rxandroid:1.2.1' implementation 'io.reactivex:rxandroid:1.2.1'
implementation 'io.reactivex:rxjava:1.3.6' implementation 'io.reactivex:rxjava:1.3.8'
implementation 'com.jakewharton.rxrelay:rxrelay:1.2.0' implementation 'com.jakewharton.rxrelay:rxrelay:1.2.0'
implementation 'com.f2prateek.rx.preferences:rx-preferences:1.0.2' implementation 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
implementation 'com.github.pwittchen:reactivenetwork:0.7.0' implementation 'com.github.pwittchen:reactivenetwork:0.13.0'
// Network client // Network client
implementation "com.squareup.okhttp3:okhttp:3.10.0" implementation "com.squareup.okhttp3:okhttp:3.10.0"
@ -151,7 +153,7 @@ dependencies {
implementation 'com.github.inorichi:unifile:e9ee588' implementation 'com.github.inorichi:unifile:e9ee588'
// HTML parser // HTML parser
implementation 'org.jsoup:jsoup:1.10.2' implementation 'org.jsoup:jsoup:1.12.1'
// Job scheduling // Job scheduling
implementation 'com.evernote:android-job:1.2.5' implementation 'com.evernote:android-job:1.2.5'
@ -161,7 +163,10 @@ dependencies {
implementation 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0' implementation 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
// Database // Database
implementation "com.pushtorefresh.storio:sqlite:1.13.0" implementation 'android.arch.persistence:db:1.1.1'
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 // Model View Presenter
final nucleus_version = '3.0.0' final nucleus_version = '3.0.0'
@ -181,14 +186,11 @@ dependencies {
implementation 'jp.wasabeef:glide-transformations:3.1.1' implementation 'jp.wasabeef:glide-transformations:3.1.1'
// Logging // Logging
implementation 'com.jakewharton.timber:timber:4.6.1' implementation 'com.jakewharton.timber:timber:4.7.1'
// Crash reports // Crash reports
implementation 'ch.acra:acra:4.9.2' implementation 'ch.acra:acra:4.9.2'
// Sort
implementation 'com.github.gpanther:java-nat-sort:natural-comparator-1.1'
// UI // UI
implementation 'com.dmitrymalkovich.android:material-design-dimens:1.4' implementation 'com.dmitrymalkovich.android:material-design-dimens:1.4'
implementation 'com.github.dmytrodanylyk.android-process-button:library:1.0.4' implementation 'com.github.dmytrodanylyk.android-process-button:library:1.0.4'
@ -230,13 +232,12 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
final coroutines_version = '0.22.2' final coroutines_version = '1.3.3'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
} }
buildscript { buildscript {
ext.kotlin_version = '1.2.71' ext.kotlin_version = '1.3.61'
repositories { repositories {
mavenCentral() mavenCentral()
} }
@ -249,10 +250,9 @@ repositories {
mavenCentral() mavenCentral()
} }
kotlin { // See https://kotlinlang.org/docs/reference/experimental.html#experimental-status-of-experimental-api-markers
experimental { tasks.withType(org.jetbrains.kotlin.gradle.tasks.AbstractKotlinCompile).all {
coroutines 'enable' kotlinOptions.freeCompilerArgs += ["-Xuse-experimental=kotlin.Experimental"]
}
} }
androidExtensions { androidExtensions {

View File

@ -9,12 +9,16 @@
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" /> <uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<application <application
android:name=".App" android:name=".App"
android:allowBackup="true" android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name" android:label="@string/app_name"
@ -22,12 +26,21 @@
android:theme="@style/Theme.Tachiyomi"> android:theme="@style/Theme.Tachiyomi">
<activity <activity
android:name=".ui.main.MainActivity" android:name=".ui.main.MainActivity"
android:launchMode="singleTop"> android:launchMode="singleTask">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </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 --> <!--suppress AndroidDomInspection -->
<meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts"/> <meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts"/>
</activity> </activity>
@ -51,6 +64,35 @@
android:scheme="tachiyomi" /> android:scheme="tachiyomi" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".ui.setting.ShikimoriLoginActivity"
android:label="Shikimori">
<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=".ui.setting.BangumiLoginActivity"
android:label="Bangumi">
<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="bangumi-auth"
android:scheme="tachiyomi" />
</intent-filter>
</activity>
<activity <activity
android:name=".extension.util.ExtensionInstallActivity" android:name=".extension.util.ExtensionInstallActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar"/> android:theme="@android:style/Theme.Translucent.NoTitleBar"/>

View File

@ -42,9 +42,7 @@ open class App : Application() {
override fun attachBaseContext(base: Context) { override fun attachBaseContext(base: Context) {
super.attachBaseContext(base) super.attachBaseContext(base)
if (BuildConfig.DEBUG) { MultiDex.install(this)
MultiDex.install(this)
}
} }
override fun onConfigurationChanged(newConfig: Configuration) { override fun onConfigurationChanged(newConfig: Configuration) {
@ -57,13 +55,17 @@ open class App : Application() {
} }
protected open fun setupJobManager() { protected open fun setupJobManager() {
JobManager.create(this).addJobCreator { tag -> try {
when (tag) { JobManager.create(this).addJobCreator { tag ->
LibraryUpdateJob.TAG -> LibraryUpdateJob() when (tag) {
UpdaterJob.TAG -> UpdaterJob() LibraryUpdateJob.TAG -> LibraryUpdateJob()
BackupCreatorJob.TAG -> BackupCreatorJob() UpdaterJob.TAG -> UpdaterJob()
else -> null BackupCreatorJob.TAG -> BackupCreatorJob()
else -> null
}
} }
} catch (e: Exception) {
Timber.w("Can't initialize job manager")
} }
} }

View File

@ -1,23 +1,23 @@
package eu.kanade.tachiyomi.data.backup.models package eu.kanade.tachiyomi.data.backup.models
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
/** /**
* Json values * Json values
*/ */
object Backup { object Backup {
const val CURRENT_VERSION = 2 const val CURRENT_VERSION = 2
const val MANGA = "manga" const val MANGA = "manga"
const val MANGAS = "mangas" const val MANGAS = "mangas"
const val TRACK = "track" const val TRACK = "track"
const val CHAPTERS = "chapters" const val CHAPTERS = "chapters"
const val CATEGORIES = "categories" const val CATEGORIES = "categories"
const val HISTORY = "history" const val HISTORY = "history"
const val VERSION = "version" const val VERSION = "version"
fun getDefaultFilename(): String { fun getDefaultFilename(): String {
val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date()) val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date())
return "tachiyomi_$date.json" return "tachiyomi_$date.json"
} }
} }

View File

@ -60,7 +60,7 @@ class CoverCache(private val context: Context) {
return false return false
// Remove file. // Remove file.
val file = getCoverFile(thumbnailUrl!!) val file = getCoverFile(thumbnailUrl)
return file.exists() && file.delete() return file.exists() && file.delete()
} }

View File

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

View File

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

View File

@ -82,6 +82,11 @@ interface MangaQueries : DbProvider {
.withPutResolver(MangaViewerPutResolver()) .withPutResolver(MangaViewerPutResolver())
.prepare() .prepare()
fun updateMangaTitle(manga: Manga) = db.put()
.`object`(manga)
.withPutResolver(MangaTitlePutResolver())
.prepare()
fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare() fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare()
fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare() fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare()

View File

@ -1,34 +1,34 @@
package eu.kanade.tachiyomi.data.database.queries package eu.kanade.tachiyomi.data.database.queries
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
import com.pushtorefresh.storio.sqlite.queries.Query import com.pushtorefresh.storio.sqlite.queries.Query
import eu.kanade.tachiyomi.data.database.DbProvider import eu.kanade.tachiyomi.data.database.DbProvider
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.database.tables.TrackTable import eu.kanade.tachiyomi.data.database.tables.TrackTable
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
interface TrackQueries : DbProvider { interface TrackQueries : DbProvider {
fun getTracks(manga: Manga) = db.get() fun getTracks(manga: Manga) = db.get()
.listOfObjects(Track::class.java) .listOfObjects(Track::class.java)
.withQuery(Query.builder() .withQuery(Query.builder()
.table(TrackTable.TABLE) .table(TrackTable.TABLE)
.where("${TrackTable.COL_MANGA_ID} = ?") .where("${TrackTable.COL_MANGA_ID} = ?")
.whereArgs(manga.id) .whereArgs(manga.id)
.build()) .build())
.prepare() .prepare()
fun insertTrack(track: Track) = db.put().`object`(track).prepare() fun insertTrack(track: Track) = db.put().`object`(track).prepare()
fun insertTracks(tracks: List<Track>) = db.put().objects(tracks).prepare() fun insertTracks(tracks: List<Track>) = db.put().objects(tracks).prepare()
fun deleteTrackForManga(manga: Manga, sync: TrackService) = db.delete() fun deleteTrackForManga(manga: Manga, sync: TrackService) = db.delete()
.byQuery(DeleteQuery.builder() .byQuery(DeleteQuery.builder()
.table(TrackTable.TABLE) .table(TrackTable.TABLE)
.where("${TrackTable.COL_MANGA_ID} = ? AND ${TrackTable.COL_SYNC_ID} = ?") .where("${TrackTable.COL_MANGA_ID} = ? AND ${TrackTable.COL_SYNC_ID} = ?")
.whereArgs(manga.id, sync.id) .whereArgs(manga.id, sync.id)
.build()) .build())
.prepare() .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 MangaTitlePutResolver : 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_TITLE, manga.title)
}
}

View File

@ -20,10 +20,10 @@ class MangaViewerPutResolver : PutResolver<Manga>() {
} }
fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder() fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder()
.table(MangaTable.TABLE) .table(MangaTable.TABLE)
.where("${MangaTable.COL_ID} = ?") .where("${MangaTable.COL_ID} = ?")
.whereArgs(manga.id) .whereArgs(manga.id)
.build() .build()
fun mapToContentValues(manga: Manga) = ContentValues(1).apply { fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
put(MangaTable.COL_VIEWER, manga.viewer) put(MangaTable.COL_VIEWER, manga.viewer)

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.download
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@ -28,7 +29,9 @@ class DownloadProvider(private val context: Context) {
* The root directory for downloads. * The root directory for downloads.
*/ */
private var downloadsDir = preferences.downloadsDirectory().getOrDefault().let { private var downloadsDir = preferences.downloadsDirectory().getOrDefault().let {
UniFile.fromUri(context, Uri.parse(it)) val dir = UniFile.fromUri(context, Uri.parse(it))
DiskUtil.createNoMediaFile(dir, context)
dir
} }
init { init {
@ -44,9 +47,13 @@ class DownloadProvider(private val context: Context) {
* @param source the source of the manga. * @param source the source of the manga.
*/ */
internal fun getMangaDir(manga: Manga, source: Source): UniFile { internal fun getMangaDir(manga: Manga, source: Source): UniFile {
return downloadsDir try {
.createDirectory(getSourceDirName(source)) return downloadsDir
.createDirectory(getMangaDirName(manga)) .createDirectory(getSourceDirName(source))
.createDirectory(getMangaDirName(manga))
} catch (e: NullPointerException) {
throw Exception(context.getString(R.string.invalid_download_dir))
}
} }
/** /**

View File

@ -132,7 +132,7 @@ class DownloadService : Service() {
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe({ state -> onNetworkStateChanged(state) .subscribe({ state -> onNetworkStateChanged(state)
}, { _ -> }, {
toast(R.string.download_queue_error) toast(R.string.download_queue_error)
stopSelf() stopSelf()
}) })

View File

@ -14,7 +14,7 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.fetchAllImageUrlsFromPageList import eu.kanade.tachiyomi.source.online.fetchAllImageUrlsFromPageList
import eu.kanade.tachiyomi.util.* import eu.kanade.tachiyomi.util.*
import kotlinx.coroutines.experimental.async import kotlinx.coroutines.async
import okhttp3.Response import okhttp3.Response
import rx.Observable import rx.Observable
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
@ -102,7 +102,7 @@ class Downloader(
pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE } pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE }
downloadsRelay.call(pending) downloadsRelay.call(pending)
return !pending.isEmpty() return pending.isNotEmpty()
} }
/** /**
@ -199,7 +199,7 @@ class Downloader(
*/ */
fun queueChapters(manga: Manga, chapters: List<Chapter>, autoStart: Boolean) = launchUI { fun queueChapters(manga: Manga, chapters: List<Chapter>, autoStart: Boolean) = launchUI {
val source = sourceManager.get(manga.source) as? HttpSource ?: return@launchUI val source = sourceManager.get(manga.source) as? HttpSource ?: return@launchUI
val wasEmpty = queue.isEmpty()
// Called in background thread, the operation can be slow with SAF. // Called in background thread, the operation can be slow with SAF.
val chaptersWithoutDir = async { val chaptersWithoutDir = async {
val mangaDir = provider.findMangaDir(manga, source) val mangaDir = provider.findMangaDir(manga, source)
@ -232,7 +232,7 @@ class Downloader(
} }
// Start downloader if needed // Start downloader if needed
if (autoStart) { if (autoStart && wasEmpty) {
DownloadService.start(this@Downloader.context) DownloadService.start(this@Downloader.context)
} }
} }
@ -407,6 +407,8 @@ class Downloader(
if (download.status == Download.DOWNLOADED) { if (download.status == Download.DOWNLOADED) {
tmpDir.renameTo(dirname) tmpDir.renameTo(dirname)
cache.addChapter(dirname, mangaDir, download.manga) cache.addChapter(dirname, mangaDir, download.manga)
DiskUtil.createNoMediaFile(tmpDir, context)
} }
} }

View File

@ -0,0 +1,43 @@
package eu.kanade.tachiyomi.data.library
import eu.kanade.tachiyomi.data.database.models.Manga
/**
* This class will provide various functions to Rank mangas to efficiently schedule mangas to update.
*/
object LibraryUpdateRanker {
val rankingScheme = listOf(
(this::lexicographicRanking)(),
(this::latestFirstRanking)())
/**
* Provides a total ordering over all the Mangas.
*
* Assumption: An active [Manga] mActive is expected to have been last updated after an
* inactive [Manga] mInactive.
*
* Using this insight, function returns a Comparator for which mActive appears before mInactive.
* @return a Comparator that ranks manga based on relevance.
*/
fun latestFirstRanking(): Comparator<Manga> {
return Comparator { mangaFirst: Manga,
mangaSecond: Manga ->
compareValues(mangaSecond.last_update, mangaFirst.last_update)
}
}
/**
* Provides a total ordering over all the Mangas.
*
* Order the manga lexicographically.
* @return a Comparator that ranks manga lexicographically based on the title.
*/
fun lexicographicRanking(): Comparator<Manga> {
return Comparator { mangaFirst: Manga,
mangaSecond: Manga ->
compareValues(mangaFirst.title, mangaSecond.title)
}
}
}

View File

@ -18,6 +18,7 @@ import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.library.LibraryUpdateRanker.rankingScheme
import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start
import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
@ -204,7 +205,9 @@ class LibraryUpdateService(
// Update favorite manga. Destroy service when completed or in case of an error. // Update favorite manga. Destroy service when completed or in case of an error.
subscription = Observable subscription = Observable
.defer { .defer {
val selectedScheme = preferences.libraryUpdatePrioritization().getOrDefault()
val mangaList = getMangaToUpdate(intent, target) val mangaList = getMangaToUpdate(intent, target)
.sortedWith(rankingScheme[selectedScheme])
// Update either chapter list or manga details. // Update either chapter list or manga details.
when (target) { when (target) {
@ -246,7 +249,6 @@ class LibraryUpdateService(
else else
db.getLibraryMangas().executeAsBlocking().distinctBy { it.id } db.getLibraryMangas().executeAsBlocking().distinctBy { it.id }
} }
if (target == Target.CHAPTERS && preferences.updateOnlyNonCompleted()) { if (target == Target.CHAPTERS && preferences.updateOnlyNonCompleted()) {
listToUpdate = listToUpdate.filter { it.status != SManga.COMPLETED } listToUpdate = listToUpdate.filter { it.status != SManga.COMPLETED }
} }

View File

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

View File

@ -1,122 +1,132 @@
package eu.kanade.tachiyomi.data.preference package eu.kanade.tachiyomi.data.preference
/** /**
* This class stores the keys for the preferences in the application. * This class stores the keys for the preferences in the application.
*/ */
object PreferenceKeys { object PreferenceKeys {
const val theme = "pref_theme_key" const val theme = "pref_theme_key"
const val rotation = "pref_rotation_type_key" const val rotation = "pref_rotation_type_key"
const val enableTransitions = "pref_enable_transitions_key" const val enableTransitions = "pref_enable_transitions_key"
const val doubleTapAnimationSpeed = "pref_double_tap_anim_speed" const val doubleTapAnimationSpeed = "pref_double_tap_anim_speed"
const val showPageNumber = "pref_show_page_number_key" const val showPageNumber = "pref_show_page_number_key"
const val fullscreen = "fullscreen" const val trueColor = "pref_true_color_key"
const val keepScreenOn = "pref_keep_screen_on_key" const val fullscreen = "fullscreen"
const val customBrightness = "pref_custom_brightness_key" const val keepScreenOn = "pref_keep_screen_on_key"
const val customBrightnessValue = "custom_brightness_value" const val customBrightness = "pref_custom_brightness_key"
const val colorFilter = "pref_color_filter_key" const val customBrightnessValue = "custom_brightness_value"
const val colorFilterValue = "color_filter_value" const val colorFilter = "pref_color_filter_key"
const val defaultViewer = "pref_default_viewer_key" const val colorFilterValue = "color_filter_value"
const val imageScaleType = "pref_image_scale_type_key" const val colorFilterMode = "color_filter_mode"
const val zoomStart = "pref_zoom_start_key" const val defaultViewer = "pref_default_viewer_key"
const val readerTheme = "pref_reader_theme_key" const val imageScaleType = "pref_image_scale_type_key"
const val cropBorders = "crop_borders" const val zoomStart = "pref_zoom_start_key"
const val cropBordersWebtoon = "crop_borders_webtoon" const val readerTheme = "pref_reader_theme_key"
const val readWithTapping = "reader_tap" const val cropBorders = "crop_borders"
const val readWithVolumeKeys = "reader_volume_keys" const val cropBordersWebtoon = "crop_borders_webtoon"
const val readWithVolumeKeysInverted = "reader_volume_keys_inverted" const val readWithTapping = "reader_tap"
const val portraitColumns = "pref_library_columns_portrait_key" const val readWithLongTap = "reader_long_tap"
const val landscapeColumns = "pref_library_columns_landscape_key" const val readWithVolumeKeys = "reader_volume_keys"
const val updateOnlyNonCompleted = "pref_update_only_non_completed_key" const val readWithVolumeKeysInverted = "reader_volume_keys_inverted"
const val autoUpdateTrack = "pref_auto_update_manga_sync_key" const val portraitColumns = "pref_library_columns_portrait_key"
const val lastUsedCatalogueSource = "last_catalogue_source" const val landscapeColumns = "pref_library_columns_landscape_key"
const val lastUsedCategory = "last_used_category" const val updateOnlyNonCompleted = "pref_update_only_non_completed_key"
const val catalogueAsList = "pref_display_catalogue_as_list" const val autoUpdateTrack = "pref_auto_update_manga_sync_key"
const val enabledLanguages = "source_languages" const val lastUsedCatalogueSource = "last_catalogue_source"
const val backupDirectory = "backup_directory" const val lastUsedCategory = "last_used_category"
const val downloadsDirectory = "download_directory" const val catalogueAsList = "pref_display_catalogue_as_list"
const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key" const val enabledLanguages = "source_languages"
const val numberOfBackups = "backup_slots" const val backupDirectory = "backup_directory"
const val backupInterval = "backup_interval" const val downloadsDirectory = "download_directory"
const val removeAfterReadSlots = "remove_after_read_slots" const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key"
const val removeAfterMarkedAsRead = "pref_remove_after_marked_as_read_key" const val numberOfBackups = "backup_slots"
const val libraryUpdateInterval = "pref_library_update_interval_key" const val backupInterval = "backup_interval"
const val libraryUpdateRestriction = "library_update_restriction" const val removeAfterReadSlots = "remove_after_read_slots"
const val libraryUpdateCategories = "library_update_categories" const val removeAfterMarkedAsRead = "pref_remove_after_marked_as_read_key"
const val filterDownloaded = "pref_filter_downloaded_key" const val libraryUpdateInterval = "pref_library_update_interval_key"
const val filterUnread = "pref_filter_unread_key" const val libraryUpdateRestriction = "library_update_restriction"
const val filterCompleted = "pref_filter_completed_key" const val libraryUpdateCategories = "library_update_categories"
const val librarySortingMode = "library_sorting_mode" const val libraryUpdatePrioritization = "library_update_prioritization"
const val automaticUpdates = "automatic_updates" const val filterDownloaded = "pref_filter_downloaded_key"
const val startScreen = "start_screen" const val filterUnread = "pref_filter_unread_key"
const val downloadNew = "download_new" const val filterCompleted = "pref_filter_completed_key"
const val downloadNewCategories = "download_new_categories" const val librarySortingMode = "library_sorting_mode"
const val libraryAsList = "pref_display_library_as_list" const val automaticUpdates = "automatic_updates"
const val lang = "app_language" const val startScreen = "start_screen"
const val defaultCategory = "default_category" const val downloadNew = "download_new"
const val downloadBadge = "display_download_badge" const val downloadNewCategories = "download_new_categories"
@Deprecated("Use the preferences of the source") const val libraryAsList = "pref_display_library_as_list"
fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId"
const val lang = "app_language"
@Deprecated("Use the preferences of the source")
fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId" const val defaultCategory = "default_category"
fun sourceSharedPref(sourceId: Long) = "source_$sourceId" const val skipRead = "skip_read"
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" const val downloadBadge = "display_download_badge"
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId" @Deprecated("Use the preferences of the source")
fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId"
fun trackToken(syncId: Int) = "track_token_$syncId"
@Deprecated("Use the preferences of the source")
} fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId"
fun sourceSharedPref(sourceId: Long) = "source_$sourceId"
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"
fun trackToken(syncId: Int) = "track_token_$syncId"
}

View File

@ -43,6 +43,8 @@ class PreferencesHelper(val context: Context) {
fun showPageNumber() = rxPrefs.getBoolean(Keys.showPageNumber, true) fun showPageNumber() = rxPrefs.getBoolean(Keys.showPageNumber, true)
fun trueColor() = rxPrefs.getBoolean(Keys.trueColor, false)
fun fullscreen() = rxPrefs.getBoolean(Keys.fullscreen, true) fun fullscreen() = rxPrefs.getBoolean(Keys.fullscreen, true)
fun keepScreenOn() = rxPrefs.getBoolean(Keys.keepScreenOn, true) fun keepScreenOn() = rxPrefs.getBoolean(Keys.keepScreenOn, true)
@ -55,6 +57,8 @@ class PreferencesHelper(val context: Context) {
fun colorFilterValue() = rxPrefs.getInteger(Keys.colorFilterValue, 0) fun colorFilterValue() = rxPrefs.getInteger(Keys.colorFilterValue, 0)
fun colorFilterMode() = rxPrefs.getInteger(Keys.colorFilterMode, 0)
fun defaultViewer() = prefs.getInt(Keys.defaultViewer, 1) fun defaultViewer() = prefs.getInt(Keys.defaultViewer, 1)
fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1) fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1)
@ -69,6 +73,8 @@ class PreferencesHelper(val context: Context) {
fun readWithTapping() = rxPrefs.getBoolean(Keys.readWithTapping, true) fun readWithTapping() = rxPrefs.getBoolean(Keys.readWithTapping, true)
fun readWithLongTap() = rxPrefs.getBoolean(Keys.readWithLongTap, true)
fun readWithVolumeKeys() = rxPrefs.getBoolean(Keys.readWithVolumeKeys, false) fun readWithVolumeKeys() = rxPrefs.getBoolean(Keys.readWithVolumeKeys, false)
fun readWithVolumeKeysInverted() = rxPrefs.getBoolean(Keys.readWithVolumeKeysInverted, false) fun readWithVolumeKeysInverted() = rxPrefs.getBoolean(Keys.readWithVolumeKeysInverted, false)
@ -137,6 +143,8 @@ class PreferencesHelper(val context: Context) {
fun libraryUpdateCategories() = rxPrefs.getStringSet(Keys.libraryUpdateCategories, emptySet()) fun libraryUpdateCategories() = rxPrefs.getStringSet(Keys.libraryUpdateCategories, emptySet())
fun libraryUpdatePrioritization() = rxPrefs.getInteger(Keys.libraryUpdatePrioritization, 0)
fun libraryAsList() = rxPrefs.getBoolean(Keys.libraryAsList, false) fun libraryAsList() = rxPrefs.getBoolean(Keys.libraryAsList, false)
fun downloadBadge() = rxPrefs.getBoolean(Keys.downloadBadge, false) fun downloadBadge() = rxPrefs.getBoolean(Keys.downloadBadge, false)
@ -163,6 +171,8 @@ class PreferencesHelper(val context: Context) {
fun defaultCategory() = prefs.getInt(Keys.defaultCategory, -1) fun defaultCategory() = prefs.getInt(Keys.defaultCategory, -1)
fun skipRead() = prefs.getBoolean(Keys.skipRead, false)
fun migrateFlags() = rxPrefs.getInteger("migrate_flags", Int.MAX_VALUE) fun migrateFlags() = rxPrefs.getInteger("migrate_flags", Int.MAX_VALUE)
fun trustedSignatures() = rxPrefs.getStringSet("trusted_signatures", emptySet()) fun trustedSignatures() = rxPrefs.getStringSet("trusted_signatures", emptySet())

View File

@ -45,11 +45,11 @@ class SharedPreferencesDataStore(private val prefs: SharedPreferences) : Prefere
prefs.edit().putString(key, value).apply() prefs.edit().putString(key, value).apply()
} }
override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String> { override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String>? {
return prefs.getStringSet(key, defValues) return prefs.getStringSet(key, defValues)
} }
override fun putStringSet(key: String?, values: MutableSet<String>?) { override fun putStringSet(key: String?, values: MutableSet<String>?) {
prefs.edit().putStringSet(key, values).apply() prefs.edit().putStringSet(key, values).apply()
} }
} }

View File

@ -1,28 +1,36 @@
package eu.kanade.tachiyomi.data.track package eu.kanade.tachiyomi.data.track
import android.content.Context import android.content.Context
import eu.kanade.tachiyomi.data.track.anilist.Anilist import eu.kanade.tachiyomi.data.track.anilist.Anilist
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist
import eu.kanade.tachiyomi.data.track.shikimori.Shikimori
class TrackManager(private val context: Context) { import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
companion object { class TrackManager(private val context: Context) {
const val MYANIMELIST = 1
const val ANILIST = 2 companion object {
const val KITSU = 3 const val MYANIMELIST = 1
} const val ANILIST = 2
const val KITSU = 3
val myAnimeList = Myanimelist(context, MYANIMELIST) const val SHIKIMORI = 4
const val BANGUMI = 5
val aniList = Anilist(context, ANILIST) }
val kitsu = Kitsu(context, KITSU) val myAnimeList = Myanimelist(context, MYANIMELIST)
val services = listOf(myAnimeList, aniList, kitsu) val aniList = Anilist(context, ANILIST)
fun getService(id: Int) = services.find { it.id == id } val kitsu = Kitsu(context, KITSU)
fun hasLoggedServices() = services.any { it.isLogged } val shikimori = Shikimori(context, SHIKIMORI)
} val bangumi = Bangumi(context, BANGUMI)
val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi)
fun getService(id: Int) = services.find { it.id == id }
fun hasLoggedServices() = services.any { it.isLogged }
}

View File

@ -1,71 +1,70 @@
package eu.kanade.tachiyomi.data.track package eu.kanade.tachiyomi.data.track
import android.support.annotation.CallSuper import android.support.annotation.CallSuper
import android.support.annotation.DrawableRes import android.support.annotation.DrawableRes
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import rx.Completable import rx.Completable
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
abstract class TrackService(val id: Int) { abstract class TrackService(val id: Int) {
val preferences: PreferencesHelper by injectLazy() val preferences: PreferencesHelper by injectLazy()
val networkService: NetworkHelper by injectLazy() val networkService: NetworkHelper by injectLazy()
open val client: OkHttpClient open val client: OkHttpClient
get() = networkService.client get() = networkService.client
// Name of the manga sync service to display // Name of the manga sync service to display
abstract val name: String abstract val name: String
@DrawableRes @DrawableRes
abstract fun getLogo(): Int abstract fun getLogo(): Int
abstract fun getLogoColor(): Int abstract fun getLogoColor(): Int
abstract fun getStatusList(): List<Int> abstract fun getStatusList(): List<Int>
abstract fun getStatus(status: Int): String abstract fun getStatus(status: Int): String
abstract fun getScoreList(): List<String> abstract fun getScoreList(): List<String>
open fun indexToScore(index: Int): Float { open fun indexToScore(index: Int): Float {
return index.toFloat() return index.toFloat()
} }
abstract fun displayScore(track: Track): String abstract fun displayScore(track: Track): String
abstract fun add(track: Track): Observable<Track> abstract fun add(track: Track): Observable<Track>
abstract fun update(track: Track): Observable<Track> abstract fun update(track: Track): Observable<Track>
abstract fun bind(track: Track): Observable<Track> abstract fun bind(track: Track): Observable<Track>
abstract fun search(query: String): Observable<List<TrackSearch>> abstract fun search(query: String): Observable<List<TrackSearch>>
abstract fun refresh(track: Track): Observable<Track> abstract fun refresh(track: Track): Observable<Track>
abstract fun login(username: String, password: String): Completable abstract fun login(username: String, password: String): Completable
@CallSuper @CallSuper
open fun logout() { open fun logout() {
preferences.setTrackCredentials(this, "", "") preferences.setTrackCredentials(this, "", "")
} }
open val isLogged: Boolean open val isLogged: Boolean
get() = !getUsername().isEmpty() && get() = !getUsername().isEmpty() &&
!getPassword().isEmpty() !getPassword().isEmpty()
fun getUsername() = preferences.trackUsername(this) fun getUsername() = preferences.trackUsername(this)!!
fun getPassword() = preferences.trackPassword(this) fun getPassword() = preferences.trackPassword(this)!!
fun saveCredentials(username: String, password: String) { fun saveCredentials(username: String, password: String) {
preferences.setTrackCredentials(this, username, password) preferences.setTrackCredentials(this, username, password)
} }
}
}

View File

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

View File

@ -1,275 +1,286 @@
package eu.kanade.tachiyomi.data.track.anilist package eu.kanade.tachiyomi.data.track.anilist
import android.net.Uri import android.net.Uri
import com.github.salomonbrys.kotson.* import com.github.salomonbrys.kotson.*
import com.google.gson.JsonObject import com.google.gson.JsonObject
import com.google.gson.JsonParser import com.google.gson.JsonParser
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.asObservableSuccess
import okhttp3.MediaType import okhttp3.MediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import rx.Observable import rx.Observable
import java.util.Calendar
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
private val parser = JsonParser()
private val jsonMime = MediaType.parse("application/json; charset=utf-8") private val parser = JsonParser()
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val jsonMime = MediaType.parse("application/json; charset=utf-8")
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun addLibManga(track: Track): Observable<Track> { fun addLibManga(track: Track): Observable<Track> {
val query = """ val query = """
mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) { |mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) {
SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) |SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) {
{ id status } } | id
""" | status
val variables = jsonObject( |}
"mangaId" to track.media_id, |}
"progress" to track.last_chapter_read, |""".trimMargin()
"status" to track.toAnilistStatus() val variables = jsonObject(
) "mangaId" to track.media_id,
val payload = jsonObject( "progress" to track.last_chapter_read,
"query" to query, "status" to track.toAnilistStatus()
"variables" to variables )
) val payload = jsonObject(
val body = RequestBody.create(jsonMime, payload.toString()) "query" to query,
val request = Request.Builder() "variables" to variables
.url(apiUrl) )
.post(body) val body = RequestBody.create(jsonMime, payload.toString())
.build() val request = Request.Builder()
return authClient.newCall(request) .url(apiUrl)
.asObservableSuccess() .post(body)
.map { netResponse -> .build()
val responseBody = netResponse.body()?.string().orEmpty() return authClient.newCall(request)
netResponse.close() .asObservableSuccess()
if (responseBody.isEmpty()) { .map { netResponse ->
throw Exception("Null Response") val responseBody = netResponse.body()?.string().orEmpty()
} netResponse.close()
val response = parser.parse(responseBody).obj if (responseBody.isEmpty()) {
track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong throw Exception("Null Response")
track }
} val response = parser.parse(responseBody).obj
} track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong
track
fun updateLibManga(track: Track): Observable<Track> { }
val query = """ }
mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) {
SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) { fun updateLibManga(track: Track): Observable<Track> {
id val query = """
status |mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) {
progress |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, |""".trimMargin()
"status" to track.toAnilistStatus(), val variables = jsonObject(
"score" to track.score.toInt() "listId" to track.library_id,
) "progress" to track.last_chapter_read,
val payload = jsonObject( "status" to track.toAnilistStatus(),
"query" to query, "score" to track.score.toInt()
"variables" to variables )
) val payload = jsonObject(
val body = RequestBody.create(jsonMime, payload.toString()) "query" to query,
val request = Request.Builder() "variables" to variables
.url(apiUrl) )
.post(body) val body = RequestBody.create(jsonMime, payload.toString())
.build() val request = Request.Builder()
return authClient.newCall(request) .url(apiUrl)
.asObservableSuccess() .post(body)
.map { .build()
track return authClient.newCall(request)
} .asObservableSuccess()
} .map {
track
fun search(search: String): Observable<List<TrackSearch>> { }
val query = """ }
query Search(${'$'}query: String) {
Page (perPage: 25) { fun search(search: String): Observable<List<TrackSearch>> {
media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) { val query = """
id |query Search(${'$'}query: String) {
title { |Page (perPage: 50) {
romaji |media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
} |id
coverImage { |title {
large |romaji
} |}
type |coverImage {
status |large
chapters |}
startDate { |type
year |status
month |chapters
day |description
} |startDate {
} |year
} |month
} |day
""" |}
val variables = jsonObject( |}
"query" to search |}
) |}
val payload = jsonObject( |""".trimMargin()
"query" to query, val variables = jsonObject(
"variables" to variables "query" to search
) )
val body = RequestBody.create(jsonMime, payload.toString()) val payload = jsonObject(
val request = Request.Builder() "query" to query,
.url(apiUrl) "variables" to variables
.post(body) )
.build() val body = RequestBody.create(jsonMime, payload.toString())
return authClient.newCall(request) val request = Request.Builder()
.asObservableSuccess() .url(apiUrl)
.map { netResponse -> .post(body)
val responseBody = netResponse.body()?.string().orEmpty() .build()
if (responseBody.isEmpty()) { return authClient.newCall(request)
throw Exception("Null Response") .asObservableSuccess()
} .map { netResponse ->
val response = parser.parse(responseBody).obj val responseBody = netResponse.body()?.string().orEmpty()
val data = response["data"]!!.obj if (responseBody.isEmpty()) {
val page = data["Page"].obj throw Exception("Null Response")
val media = page["media"].array }
val entries = media.map { jsonToALManga(it.obj) } val response = parser.parse(responseBody).obj
entries.map { it.toTrack() } 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, userid: Int) : Observable<Track?> { }
val query = """ }
query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
Page {
mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) { fun findLibManga(track: Track, userid: Int): Observable<Track?> {
id val query = """
status |query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
scoreRaw: score(format: POINT_100) |Page {
progress |mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) {
media{ |id
id |status
title { |scoreRaw: score(format: POINT_100)
romaji |progress
} |media {
coverImage { |id
large |title {
} |romaji
type |}
status |coverImage {
chapters |large
startDate { |}
year |type
month |status
day |chapters
} |description
} |startDate {
} |year
} |month
} |day
""" |}
val variables = jsonObject( |}
"id" to userid, |}
"manga_id" to track.media_id |}
) |}
val payload = jsonObject( |""".trimMargin()
"query" to query, val variables = jsonObject(
"variables" to variables "id" to userid,
) "manga_id" to track.media_id
val body = RequestBody.create(jsonMime, payload.toString()) )
val request = Request.Builder() val payload = jsonObject(
.url(apiUrl) "query" to query,
.post(body) "variables" to variables
.build() )
return authClient.newCall(request) val body = RequestBody.create(jsonMime, payload.toString())
.asObservableSuccess() val request = Request.Builder()
.map { netResponse -> .url(apiUrl)
val responseBody = netResponse.body()?.string().orEmpty() .post(body)
if (responseBody.isEmpty()) { .build()
throw Exception("Null Response") return authClient.newCall(request)
} .asObservableSuccess()
val response = parser.parse(responseBody).obj .map { netResponse ->
val data = response["data"]!!.obj val responseBody = netResponse.body()?.string().orEmpty()
val page = data["Page"].obj if (responseBody.isEmpty()) {
val media = page["mediaList"].array throw Exception("Null Response")
val entries = media.map { jsonToALUserManga(it.obj) } }
entries.firstOrNull()?.toTrack() 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) }
fun getLibManga(track: Track, userid: Int): Observable<Track> { entries.firstOrNull()?.toTrack()
return findLibManga(track, userid)
.map { it ?: throw Exception("Could not find manga") } }
} }
fun createOAuth(token: String): OAuth { fun getLibManga(track: Track, userid: Int): Observable<Track> {
return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000) return findLibManga(track, userid)
} .map { it ?: throw Exception("Could not find manga") }
}
fun getCurrentUser(): Observable<Pair<Int, String>> {
val query = """ fun createOAuth(token: String): OAuth {
query User return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
{ }
Viewer {
id fun getCurrentUser(): Observable<Pair<Int, String>> {
mediaListOptions { val query = """
scoreFormat |query User {
} |Viewer {
} |id
} |mediaListOptions {
""" |scoreFormat
val payload = jsonObject( |}
"query" to query |}
) |}
val body = RequestBody.create(jsonMime, payload.toString()) |""".trimMargin()
val request = Request.Builder() val payload = jsonObject(
.url(apiUrl) "query" to query
.post(body) )
.build() val body = RequestBody.create(jsonMime, payload.toString())
return authClient.newCall(request) val request = Request.Builder()
.asObservableSuccess() .url(apiUrl)
.map { netResponse -> .post(body)
val responseBody = netResponse.body()?.string().orEmpty() .build()
if (responseBody.isEmpty()) { return authClient.newCall(request)
throw Exception("Null Response") .asObservableSuccess()
} .map { netResponse ->
val response = parser.parse(responseBody).obj val responseBody = netResponse.body()?.string().orEmpty()
val data = response["data"]!!.obj if (responseBody.isEmpty()) {
val viewer = data["Viewer"].obj throw Exception("Null Response")
Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString) }
} val response = parser.parse(responseBody).obj
} val data = response["data"]!!.obj
val viewer = data["Viewer"].obj
fun jsonToALManga(struct: JsonObject): ALManga{ Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString)
return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString, }
null, struct["type"].asString, struct["status"].asString, }
struct["startDate"]["year"].nullString.orEmpty() + struct["startDate"]["month"].nullString.orEmpty()
+ struct["startDate"]["day"].nullString.orEmpty(), struct["chapters"].nullInt ?: 0) private fun jsonToALManga(struct: JsonObject): ALManga {
} val date = try {
val date = Calendar.getInstance()
fun jsonToALUserManga(struct: JsonObject): ALUserManga{ date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1,
return ALUserManga(struct["id"].asLong, struct["status"].asString, struct["scoreRaw"].asInt, struct["progress"].asInt, jsonToALManga(struct["media"].obj) ) struct["startDate"]["day"].nullInt ?: 0)
} date.timeInMillis
} catch (_: Exception) {
0L
companion object { }
private const val clientId = "385"
private const val clientUrl = "tachiyomi://anilist-auth" return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString,
private const val apiUrl = "https://graphql.anilist.co/" struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString,
private const val baseUrl = "https://anilist.co/api/v2/" date, struct["chapters"].nullInt ?: 0)
private const val baseMangaUrl = "https://anilist.co/manga/" }
fun mangaUrl(mediaId: Int): String { private fun jsonToALUserManga(struct: JsonObject): ALUserManga {
return baseMangaUrl + mediaId return ALUserManga(struct["id"].asLong, struct["status"].asString, struct["scoreRaw"].asInt, struct["progress"].asInt, jsonToALManga(struct["media"].obj))
} }
fun authUrl() = Uri.parse("${baseUrl}oauth/authorize").buildUpon() companion object {
.appendQueryParameter("client_id", clientId) private const val clientId = "385"
.appendQueryParameter("response_type", "token") private const val clientUrl = "tachiyomi://anilist-auth"
.build() 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,58 +1,58 @@
package eu.kanade.tachiyomi.data.track.anilist package eu.kanade.tachiyomi.data.track.anilist
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Interceptor { class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Interceptor {
/** /**
* OAuth object used for authenticated requests. * OAuth object used for authenticated requests.
* *
* Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute * Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute
* before its original expiration date. * before its original expiration date.
*/ */
private var oauth: OAuth? = null private var oauth: OAuth? = null
set(value) { set(value) {
field = value?.copy(expires = value.expires * 1000 - 60 * 1000) field = value?.copy(expires = value.expires * 1000 - 60 * 1000)
} }
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request() val originalRequest = chain.request()
if (token.isNullOrEmpty()) { if (token.isNullOrEmpty()) {
throw Exception("Not authenticated with Anilist") throw Exception("Not authenticated with Anilist")
} }
if (oauth == null){ if (oauth == null){
oauth = anilist.loadOAuth() oauth = anilist.loadOAuth()
} }
// Refresh access token if null or expired. // Refresh access token if null or expired.
if (oauth!!.isExpired()) { if (oauth!!.isExpired()) {
anilist.logout() anilist.logout()
throw Exception("Token expired") throw Exception("Token expired")
} }
// Throw on null auth. // Throw on null auth.
if (oauth == null) { if (oauth == null) {
throw Exception("No authentication token") throw Exception("No authentication token")
} }
// Add the authorization header to the original request. // Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder() val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}") .addHeader("Authorization", "Bearer ${oauth!!.access_token}")
.build() .build()
return chain.proceed(authRequest) return chain.proceed(authRequest)
} }
/** /**
* Called when the user authenticates with Anilist for the first time. Sets the refresh token * Called when the user authenticates with Anilist for the first time. Sets the refresh token
* and the oauth object. * and the oauth object.
*/ */
fun setAuth(oauth: OAuth?) { fun setAuth(oauth: OAuth?) {
token = oauth?.access_token token = oauth?.access_token
this.oauth = oauth this.oauth = oauth
anilist.saveOAuth(oauth) anilist.saveOAuth(oauth)
} }
} }

View File

@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.track.anilist 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.database.models.Track
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
@ -17,7 +16,7 @@ data class ALManga(
val description: String?, val description: String?,
val type: String, val type: String,
val publishing_status: String, val publishing_status: String,
val start_date_fuzzy: String, val start_date_fuzzy: Long,
val total_chapters: Int) { val total_chapters: Int) {
fun toTrack() = TrackSearch.create(TrackManager.ANILIST).apply { fun toTrack() = TrackSearch.create(TrackManager.ANILIST).apply {
@ -29,14 +28,12 @@ data class ALManga(
tracking_url = AnilistApi.mangaUrl(media_id) tracking_url = AnilistApi.mangaUrl(media_id)
publishing_status = this@ALManga.publishing_status publishing_status = this@ALManga.publishing_status
publishing_type = type publishing_type = type
if (!start_date_fuzzy.isNullOrBlank()) { if (start_date_fuzzy != 0L) {
start_date = try { start_date = try {
val inputDf = SimpleDateFormat("yyyyMMdd", Locale.US)
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
val date = inputDf.parse(BuildConfig.BUILD_TIME) outputDf.format(start_date_fuzzy)
outputDf.format(date)
} catch (e: Exception) { } catch (e: Exception) {
start_date_fuzzy.orEmpty() ""
} }
} }
} }
@ -64,6 +61,7 @@ data class ALUserManga(
"PAUSED" -> Anilist.ON_HOLD "PAUSED" -> Anilist.ON_HOLD
"DROPPED" -> Anilist.DROPPED "DROPPED" -> Anilist.DROPPED
"PLANNING" -> Anilist.PLANNING "PLANNING" -> Anilist.PLANNING
"REPEATING" -> Anilist.REPEATING
else -> throw NotImplementedError("Unknown status") else -> throw NotImplementedError("Unknown status")
} }
} }

View File

@ -1,10 +1,10 @@
package eu.kanade.tachiyomi.data.track.anilist package eu.kanade.tachiyomi.data.track.anilist
data class OAuth( data class OAuth(
val access_token: String, val access_token: String,
val token_type: String, val token_type: String,
val expires: Long, val expires: Long,
val expires_in: Long) { val expires_in: Long) {
fun isExpired() = System.currentTimeMillis() > expires fun isExpired() = System.currentTimeMillis() > expires
} }

View File

@ -0,0 +1,7 @@
package eu.kanade.tachiyomi.data.track.bangumi
data class Avatar(
val large: String? = "",
val medium: String? = "",
val small: String? = ""
)

View File

@ -0,0 +1,144 @@
package eu.kanade.tachiyomi.data.track.bangumi
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 Bangumi(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)
}
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)
}
override fun bind(track: Track): Observable<Track> {
return api.statusLibManga(track)
.flatMap {
api.findLibManga(track).flatMap { remoteTrack ->
if (remoteTrack != null && it != null) {
track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id
track.status = remoteTrack.status
track.last_chapter_read = remoteTrack.last_chapter_read
refresh(track)
} else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
add(track)
update(track)
}
}
}
}
override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.statusLibManga(track)
.flatMap {
track.copyPersonalFrom(it!!)
api.findLibManga(track)
.map { remoteTrack ->
if (remoteTrack != null) {
track.total_chapters = remoteTrack.total_chapters
track.status = remoteTrack.status
}
track
}
}
}
companion object {
const val READING = 3
const val COMPLETED = 2
const val ON_HOLD = 4
const val DROPPED = 5
const val PLANNING = 1
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
}
override val name = "Bangumi"
private val gson: Gson by injectLazy()
private val interceptor by lazy { BangumiInterceptor(this, gson) }
private val api by lazy { BangumiApi(client, interceptor) }
override fun getLogo() = R.drawable.bangumi
override fun getLogoColor() = Color.rgb(0xF0, 0x91, 0x99)
override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING)
}
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)
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) {
saveCredentials(oauth.user_id.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,211 @@
package eu.kanade.tachiyomi.data.track.bangumi
import android.net.Uri
import com.github.salomonbrys.kotson.array
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.POST
import eu.kanade.tachiyomi.network.asObservableSuccess
import okhttp3.CacheControl
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder
class BangumiApi(private val client: OkHttpClient, interceptor: BangumiInterceptor) {
private val gson: Gson by injectLazy()
private val parser = JsonParser()
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun addLibManga(track: Track): Observable<Track> {
val body = FormBody.Builder()
.add("rating", track.score.toInt().toString())
.add("status", track.toBangumiStatus())
.build()
val request = Request.Builder()
.url("$apiUrl/collection/${track.media_id}/update")
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map {
track
}
}
fun updateLibManga(track: Track): Observable<Track> {
// chapter update
val body = FormBody.Builder()
.add("watched_eps", track.last_chapter_read.toString())
.build()
val request = Request.Builder()
.url("$apiUrl/subject/${track.media_id}/update/watched_eps")
.post(body)
.build()
// read status update
val sbody = FormBody.Builder()
.add("status", track.toBangumiStatus())
.build()
val srequest = Request.Builder()
.url("$apiUrl/collection/${track.media_id}/update")
.post(sbody)
.build()
return authClient.newCall(srequest)
.asObservableSuccess()
.map {
track
}.flatMap {
authClient.newCall(request)
.asObservableSuccess()
.map {
track
}
}
}
fun search(search: String): Observable<List<TrackSearch>> {
val url = Uri.parse(
"$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}").buildUpon()
.appendQueryParameter("max_results", "20")
.build()
val request = Request.Builder()
.url(url.toString())
.get()
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
var responseBody = netResponse.body()?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
if(responseBody.contains("\"code\":404")){
responseBody = "{\"results\":0,\"list\":[]}"
}
val response = parser.parse(responseBody).obj["list"]?.array
response?.filter { it.obj["type"].asInt == 1 }?.map { jsonToSearch(it.obj) }
}
}
private fun jsonToSearch(obj: JsonObject): TrackSearch {
return TrackSearch.create(TrackManager.BANGUMI).apply {
media_id = obj["id"].asInt
title = obj["name_cn"].asString
cover_url = obj["images"].obj["common"].asString
summary = obj["name"].asString
tracking_url = obj["url"].asString
}
}
private fun jsonToTrack(mangas: JsonObject): Track {
return Track.create(TrackManager.BANGUMI).apply {
title = mangas["name"].asString
media_id = mangas["id"].asInt
score = if (mangas["rating"] != null)
(if (mangas["rating"].isJsonObject) mangas["rating"].obj["score"].asFloat else 0f)
else 0f
status = Bangumi.DEFAULT_STATUS
tracking_url = mangas["url"].asString
}
}
fun findLibManga(track: Track): Observable<Track?> {
val urlMangas = "$apiUrl/subject/${track.media_id}"
val requestMangas = Request.Builder()
.url(urlMangas)
.get()
.build()
return authClient.newCall(requestMangas)
.asObservableSuccess()
.map { netResponse ->
// get comic info
val responseBody = netResponse.body()?.string().orEmpty()
jsonToTrack(parser.parse(responseBody).obj)
}
}
fun statusLibManga(track: Track): Observable<Track?> {
val urlUserRead = "$apiUrl/collection/${track.media_id}"
val requestUserRead = Request.Builder()
.url(urlUserRead)
.cacheControl(CacheControl.FORCE_NETWORK)
.get()
.build()
// todo get user readed chapter here
return authClient.newCall(requestUserRead)
.asObservableSuccess()
.map { netResponse ->
val resp = netResponse.body()?.string()
val coll = gson.fromJson(resp, Collection::class.java)
track.status = coll.status?.id!!
track.last_chapter_read = coll.ep_status!!
track
}
}
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 = "bgm10555cda0762e80ca"
private const val clientSecret = "8fff394a8627b4c388cbf349ec865775"
private const val baseUrl = "https://bangumi.org"
private const val apiUrl = "https://api.bgm.tv"
private const val oauthUrl = "https://bgm.tv/oauth/access_token"
private const val loginUrl = "https://bgm.tv/oauth/authorize"
private const val redirectUrl = "tachiyomi://bangumi-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("response_type", "code")
.appendQueryParameter("redirect_uri", redirectUrl)
.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)
.add("redirect_uri", redirectUrl)
.build())
}
}

View File

@ -0,0 +1,61 @@
package eu.kanade.tachiyomi.data.track.bangumi
import com.google.gson.Gson
import okhttp3.FormBody
import okhttp3.Interceptor
import okhttp3.Response
class BangumiInterceptor(val bangumi: Bangumi, val gson: Gson) : Interceptor {
/**
* OAuth object used for authenticated requests.
*/
private var oauth: OAuth? = bangumi.restoreToken()
fun addTocken(tocken: String, oidFormBody: FormBody): FormBody {
val newFormBody = FormBody.Builder()
for (i in 0 until oidFormBody.size()) {
newFormBody.add(oidFormBody.name(i), oidFormBody.value(i))
}
newFormBody.add("access_token", tocken)
return newFormBody.build()
}
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val currAuth = oauth ?: throw Exception("Not authenticated with Bangumi")
if (currAuth.isExpired()) {
val response = chain.proceed(BangumiApi.refreshTokenRequest(currAuth.refresh_token!!))
if (response.isSuccessful) {
newAuth(gson.fromJson(response.body()!!.string(), OAuth::class.java))
} else {
response.close()
}
}
var authRequest = if (originalRequest.method() == "GET") originalRequest.newBuilder()
.header("User-Agent", "Tachiyomi")
.url(originalRequest.url().newBuilder()
.addQueryParameter("access_token", currAuth.access_token).build())
.build() else originalRequest.newBuilder()
.post(addTocken(currAuth.access_token, originalRequest.body() as FormBody))
.header("User-Agent", "Tachiyomi")
.build()
return chain.proceed(authRequest)
}
fun newAuth(oauth: OAuth?) {
this.oauth = if (oauth == null) null else OAuth(
oauth.access_token,
oauth.token_type,
System.currentTimeMillis() / 1000,
oauth.expires_in,
oauth.refresh_token,
this.oauth?.user_id)
bangumi.saveToken(oauth)
}
}

View File

@ -0,0 +1,22 @@
package eu.kanade.tachiyomi.data.track.bangumi
import eu.kanade.tachiyomi.data.database.models.Track
fun Track.toBangumiStatus() = when (status) {
Bangumi.READING -> "do"
Bangumi.COMPLETED -> "collect"
Bangumi.ON_HOLD -> "on_hold"
Bangumi.DROPPED -> "dropped"
Bangumi.PLANNING -> "wish"
else -> throw NotImplementedError("Unknown status")
}
fun toTrackStatus(status: String) = when (status) {
"do" -> Bangumi.READING
"collect" -> Bangumi.COMPLETED
"on_hold" -> Bangumi.ON_HOLD
"dropped" -> Bangumi.DROPPED
"wish" -> Bangumi.PLANNING
else -> throw Exception("Unknown status")
}

View File

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.data.track.bangumi
data class Collection(
val `private`: Int? = 0,
val comment: String? = "",
val ep_status: Int? = 0,
val lasttouch: Int? = 0,
val rating: Int? = 0,
val status: Status? = Status(),
val tag: List<String?>? = listOf(),
val user: User? = User(),
val vol_status: Int? = 0
)

View File

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

View File

@ -0,0 +1,7 @@
package eu.kanade.tachiyomi.data.track.bangumi
data class Status(
val id: Int? = 0,
val name: String? = "",
val type: String? = ""
)

View File

@ -0,0 +1,11 @@
package eu.kanade.tachiyomi.data.track.bangumi
data class User(
val avatar: Avatar? = Avatar(),
val id: Int? = 0,
val nickname: String? = "",
val sign: String? = "",
val url: String? = "",
val usergroup: Int? = 0,
val username: String? = ""
)

View File

@ -1,144 +1,144 @@
package eu.kanade.tachiyomi.data.track.kitsu package eu.kanade.tachiyomi.data.track.kitsu
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import com.google.gson.Gson import com.google.gson.Gson
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable import rx.Completable
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.text.DecimalFormat import java.text.DecimalFormat
class Kitsu(private val context: Context, id: Int) : TrackService(id) { class Kitsu(private val context: Context, id: Int) : TrackService(id) {
companion object { companion object {
const val READING = 1 const val READING = 1
const val COMPLETED = 2 const val COMPLETED = 2
const val ON_HOLD = 3 const val ON_HOLD = 3
const val DROPPED = 4 const val DROPPED = 4
const val PLAN_TO_READ = 5 const val PLAN_TO_READ = 5
const val DEFAULT_STATUS = READING const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0f const val DEFAULT_SCORE = 0f
} }
override val name = "Kitsu" override val name = "Kitsu"
private val gson: Gson by injectLazy() private val gson: Gson by injectLazy()
private val interceptor by lazy { KitsuInterceptor(this, gson) } private val interceptor by lazy { KitsuInterceptor(this, gson) }
private val api by lazy { KitsuApi(client, interceptor) } private val api by lazy { KitsuApi(client, interceptor) }
override fun getLogo(): Int { override fun getLogo(): Int {
return R.drawable.kitsu return R.drawable.kitsu
} }
override fun getLogoColor(): Int { override fun getLogoColor(): Int {
return Color.rgb(51, 37, 50) return Color.rgb(51, 37, 50)
} }
override fun getStatusList(): List<Int> { override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
} }
override fun getStatus(status: Int): String = with(context) { override fun getStatus(status: Int): String = with(context) {
when (status) { when (status) {
READING -> getString(R.string.reading) READING -> getString(R.string.reading)
COMPLETED -> getString(R.string.completed) COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold) ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped) DROPPED -> getString(R.string.dropped)
PLAN_TO_READ -> getString(R.string.plan_to_read) PLAN_TO_READ -> getString(R.string.plan_to_read)
else -> "" else -> ""
} }
} }
override fun getScoreList(): List<String> { override fun getScoreList(): List<String> {
val df = DecimalFormat("0.#") val df = DecimalFormat("0.#")
return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) } return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) }
} }
override fun indexToScore(index: Int): Float { override fun indexToScore(index: Int): Float {
return if (index > 0) (index + 1) / 2f else 0f return if (index > 0) (index + 1) / 2f else 0f
} }
override fun displayScore(track: Track): String { override fun displayScore(track: Track): String {
val df = DecimalFormat("0.#") val df = DecimalFormat("0.#")
return df.format(track.score) return df.format(track.score)
} }
override fun add(track: Track): Observable<Track> { override fun add(track: Track): Observable<Track> {
return api.addLibManga(track, getUserId()) return api.addLibManga(track, getUserId())
} }
override fun update(track: Track): Observable<Track> { override fun update(track: Track): Observable<Track> {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED track.status = COMPLETED
} }
return api.updateLibManga(track) return api.updateLibManga(track)
} }
override fun bind(track: Track): Observable<Track> { override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUserId()) return api.findLibManga(track, getUserId())
.flatMap { remoteTrack -> .flatMap { remoteTrack ->
if (remoteTrack != null) { if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.media_id = remoteTrack.media_id track.media_id = remoteTrack.media_id
update(track) update(track)
} else { } else {
track.score = DEFAULT_SCORE track.score = DEFAULT_SCORE
track.status = DEFAULT_STATUS track.status = DEFAULT_STATUS
add(track) add(track)
} }
} }
} }
override fun search(query: String): Observable<List<TrackSearch>> { override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query) return api.search(query)
} }
override fun refresh(track: Track): Observable<Track> { override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track) return api.getLibManga(track)
.map { remoteTrack -> .map { remoteTrack ->
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters track.total_chapters = remoteTrack.total_chapters
track track
} }
} }
override fun login(username: String, password: String): Completable { override fun login(username: String, password: String): Completable {
return api.login(username, password) return api.login(username, password)
.doOnNext { interceptor.newAuth(it) } .doOnNext { interceptor.newAuth(it) }
.flatMap { api.getCurrentUser() } .flatMap { api.getCurrentUser() }
.doOnNext { userId -> saveCredentials(username, userId) } .doOnNext { userId -> saveCredentials(username, userId) }
.doOnError { logout() } .doOnError { logout() }
.toCompletable() .toCompletable()
} }
override fun logout() { override fun logout() {
super.logout() super.logout()
interceptor.newAuth(null) interceptor.newAuth(null)
} }
private fun getUserId(): String { private fun getUserId(): String {
return getPassword() return getPassword()
} }
fun saveToken(oauth: OAuth?) { fun saveToken(oauth: OAuth?) {
val json = gson.toJson(oauth) val json = gson.toJson(oauth)
preferences.trackToken(this).set(json) preferences.trackToken(this).set(json)
} }
fun restoreToken(): OAuth? { fun restoreToken(): OAuth? {
return try { return try {
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
} }
} }

View File

@ -14,21 +14,20 @@ class KitsuSearchManga(obj: JsonObject) {
private val canonicalTitle by obj.byString private val canonicalTitle by obj.byString
private val chapterCount = obj.get("chapterCount").nullInt private val chapterCount = obj.get("chapterCount").nullInt
val subType = obj.get("subtype").nullString val subType = obj.get("subtype").nullString
val original by obj["posterImage"].byString val original = obj.get("posterImage").nullObj?.get("original")?.asString
private val synopsis by obj.byString private val synopsis by obj.byString
private var startDate = obj.get("startDate").nullString?.let { private var startDate = obj.get("startDate").nullString?.let {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
outputDf.format(Date(it!!.toLong() * 1000)) outputDf.format(Date(it.toLong() * 1000))
} }
private val endDate = obj.get("endDate").nullString private val endDate = obj.get("endDate").nullString
@CallSuper @CallSuper
open fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply { fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
media_id = this@KitsuSearchManga.id media_id = this@KitsuSearchManga.id
title = canonicalTitle title = canonicalTitle
total_chapters = chapterCount ?: 0 total_chapters = chapterCount ?: 0
cover_url = original cover_url = original ?: ""
summary = synopsis summary = synopsis
tracking_url = KitsuApi.mangaUrl(media_id) tracking_url = KitsuApi.mangaUrl(media_id)
if (endDate == null) { if (endDate == null) {
@ -55,7 +54,7 @@ class KitsuLibManga(obj: JsonObject, manga: JsonObject) {
private val ratingTwenty = obj["attributes"].obj.get("ratingTwenty").nullString private val ratingTwenty = obj["attributes"].obj.get("ratingTwenty").nullString
val progress by obj["attributes"].byInt val progress by obj["attributes"].byInt
open fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply { fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
media_id = libraryId media_id = libraryId
title = canonicalTitle title = canonicalTitle
total_chapters = chapterCount ?: 0 total_chapters = chapterCount ?: 0

View File

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

View File

@ -1,105 +1,164 @@
package eu.kanade.tachiyomi.data.track.myanimelist package eu.kanade.tachiyomi.data.track.myanimelist
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import rx.Completable import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Observable import okhttp3.HttpUrl
import rx.Completable
class Myanimelist(private val context: Context, id: Int) : TrackService(id) { import rx.Observable
import java.lang.Exception
companion object {
class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
const val READING = 1
const val COMPLETED = 2 companion object {
const val ON_HOLD = 3 const val READING = 1
const val DROPPED = 4 const val COMPLETED = 2
const val PLAN_TO_READ = 6 const val ON_HOLD = 3
const val DROPPED = 4
const val DEFAULT_STATUS = READING const val PLAN_TO_READ = 6
const val DEFAULT_SCORE = 0
} const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
private val api by lazy { MyanimelistApi(client, getUsername(), getPassword()) }
const val BASE_URL = "https://myanimelist.net"
override val name: String const val USER_SESSION_COOKIE = "MALSESSIONID"
get() = "MyAnimeList" const val LOGGED_IN_COOKIE = "is_logged_in"
}
override fun getLogo() = R.drawable.mal
private val interceptor by lazy { MyAnimeListInterceptor(this) }
override fun getLogoColor() = Color.rgb(46, 81, 162) private val api by lazy { MyanimelistApi(client, interceptor) }
override fun getStatus(status: Int): String = with(context) { override val name: String
when (status) { get() = "MyAnimeList"
READING -> getString(R.string.reading)
COMPLETED -> getString(R.string.completed) override fun getLogo() = R.drawable.mal
ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped) override fun getLogoColor() = Color.rgb(46, 81, 162)
PLAN_TO_READ -> getString(R.string.plan_to_read)
else -> "" override fun getStatus(status: Int): String = with(context) {
} when (status) {
} READING -> getString(R.string.reading)
COMPLETED -> getString(R.string.completed)
override fun getStatusList(): List<Int> { ON_HOLD -> getString(R.string.on_hold)
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) DROPPED -> getString(R.string.dropped)
} PLAN_TO_READ -> getString(R.string.plan_to_read)
else -> ""
override fun getScoreList(): List<String> { }
return IntRange(0, 10).map(Int::toString) }
}
override fun getStatusList(): List<Int> {
override fun displayScore(track: Track): String { return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
return track.score.toInt().toString() }
}
override fun getScoreList(): List<String> {
override fun add(track: Track): Observable<Track> { return IntRange(0, 10).map(Int::toString)
return api.addLibManga(track) }
}
override fun displayScore(track: Track): String {
override fun update(track: Track): Observable<Track> { return track.score.toInt().toString()
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { }
track.status = COMPLETED
} override fun add(track: Track): Observable<Track> {
return api.addLibManga(track)
return api.updateLibManga(track) }
}
override fun update(track: Track): Observable<Track> {
override fun bind(track: Track): Observable<Track> { if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
return api.findLibManga(track, getUsername()) track.status = COMPLETED
.flatMap { remoteTrack -> }
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) return api.updateLibManga(track)
update(track) }
} else {
// Set default fields if it's not found in the list override fun bind(track: Track): Observable<Track> {
track.score = DEFAULT_SCORE.toFloat() return api.findLibManga(track)
track.status = DEFAULT_STATUS .flatMap { remoteTrack ->
add(track) if (remoteTrack != null) {
} track.copyPersonalFrom(remoteTrack)
} update(track)
} } else {
// Set default fields if it's not found in the list
override fun search(query: String): Observable<List<TrackSearch>> { track.score = DEFAULT_SCORE.toFloat()
return api.search(query, getUsername()) track.status = DEFAULT_STATUS
} add(track)
}
override fun refresh(track: Track): Observable<Track> { }
return api.getLibManga(track, getUsername()) }
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack) override fun search(query: String): Observable<List<TrackSearch>> {
track.total_chapters = remoteTrack.total_chapters return api.search(query)
track }
}
} override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track)
override fun login(username: String, password: String): Completable { .map { remoteTrack ->
return api.login(username, password) track.copyPersonalFrom(remoteTrack)
.doOnNext { saveCredentials(username, password) } track.total_chapters = remoteTrack.total_chapters
.doOnError { logout() } track
.toCompletable() }
} }
} override fun login(username: String, password: String): Completable {
logout()
return Observable.fromCallable { api.login(username, password) }
.doOnNext { csrf -> saveCSRF(csrf) }
.doOnNext { saveCredentials(username, password) }
.doOnError { logout() }
.toCompletable()
}
fun refreshLogin() {
val username = getUsername()
val password = getPassword()
logout()
try {
val csrf = api.login(username, password)
saveCSRF(csrf)
saveCredentials(username, password)
} catch (e: Exception) {
logout()
throw e
}
}
// Attempt to login again if cookies have been cleared but credentials are still filled
fun ensureLoggedIn() {
if (isAuthorized) return
if (!isLogged) throw Exception("MAL Login Credentials not found")
refreshLogin()
}
override fun logout() {
super.logout()
preferences.trackToken(this).delete()
networkService.cookieManager.remove(HttpUrl.parse(BASE_URL)!!)
}
val isAuthorized: Boolean
get() = super.isLogged &&
getCSRF().isNotEmpty() &&
checkCookies()
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

@ -0,0 +1,59 @@
package eu.kanade.tachiyomi.data.track.myanimelist
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.Response
import okio.Buffer
import org.json.JSONObject
class MyAnimeListInterceptor(private val myanimelist: Myanimelist): Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
myanimelist.ensureLoggedIn()
val request = chain.request()
var response = chain.proceed(updateRequest(request))
if (response.code() == 400){
myanimelist.refreshLogin()
response = chain.proceed(updateRequest(request))
}
return response
}
private fun updateRequest(request: Request): Request {
return request.body()?.let {
val contentType = it.contentType().toString()
val updatedBody = when {
contentType.contains("x-www-form-urlencoded") -> updateFormBody(it)
contentType.contains("json") -> updateJsonBody(it)
else -> it
}
request.newBuilder().post(updatedBody).build()
} ?: request
}
private fun bodyToString(requestBody: RequestBody): String {
Buffer().use {
requestBody.writeTo(it)
return it.readUtf8()
}
}
private fun updateFormBody(requestBody: RequestBody): RequestBody {
val formString = bodyToString(requestBody)
return RequestBody.create(requestBody.contentType(),
"$formString${if (formString.isNotEmpty()) "&" else ""}${MyanimelistApi.CSRF}=${myanimelist.getCSRF()}")
}
private fun updateJsonBody(requestBody: RequestBody): RequestBody {
val jsonString = bodyToString(requestBody)
val newBody = JSONObject(jsonString)
.put(MyanimelistApi.CSRF, myanimelist.getCSRF())
return RequestBody.create(requestBody.contentType(), newBody.toString())
}
}

View File

@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.data.track.myanimelist package eu.kanade.tachiyomi.data.track.myanimelist
import android.net.Uri import android.net.Uri
import android.util.Xml
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
@ -12,19 +11,61 @@ import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.util.selectInt import eu.kanade.tachiyomi.util.selectInt
import eu.kanade.tachiyomi.util.selectText import eu.kanade.tachiyomi.util.selectText
import okhttp3.* import okhttp3.*
import org.json.JSONObject
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.parser.Parser import org.jsoup.parser.Parser
import org.xmlpull.v1.XmlSerializer
import rx.Observable import rx.Observable
import java.io.StringWriter import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.zip.GZIPInputStream
class MyanimelistApi(private val client: OkHttpClient, username: String, password: String) {
private var headers = createHeaders(username, password) class MyanimelistApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) {
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun search(query: String): Observable<List<TrackSearch>> {
return if (query.startsWith(PREFIX_MY)) {
val realQuery = query.removePrefix(PREFIX_MY)
getList()
.flatMap { Observable.from(it) }
.filter { it.title.contains(realQuery, true) }
.toList()
}
else {
client.newCall(GET(searchUrl(query)))
.asObservable()
.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 = 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 addLibManga(track: Track): Observable<Track> { fun addLibManga(track: Track): Observable<Track> {
return Observable.defer { return Observable.defer {
client.newCall(POST(getAddUrl(track), headers, getMangaPostPayload(track))) authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track)))
.asObservableSuccess() .asObservableSuccess()
.map { track } .map { track }
} }
@ -32,171 +73,229 @@ class MyanimelistApi(private val client: OkHttpClient, username: String, passwor
fun updateLibManga(track: Track): Observable<Track> { fun updateLibManga(track: Track): Observable<Track> {
return Observable.defer { return Observable.defer {
client.newCall(POST(getUpdateUrl(track), headers, getMangaPostPayload(track))) authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track)))
.asObservableSuccess() .asObservableSuccess()
.map { track } .map { track }
} }
} }
fun search(query: String, username: String): Observable<List<TrackSearch>> { fun findLibManga(track: Track): Observable<Track?> {
return if (query.startsWith(PREFIX_MY)) { return authClient.newCall(GET(url = listEntryUrl(track.media_id)))
val realQuery = query.substring(PREFIX_MY.length).toLowerCase().trim() .asObservable()
getList(username) .map {response ->
.flatMap { Observable.from(it) } var libTrack: Track? = null
.filter { realQuery in it.title.toLowerCase() } response.use {
.toList() if (it.priorResponse()?.isRedirect != true) {
} else { val trackForm = Jsoup.parse(it.consumeBody())
client.newCall(GET(getSearchUrl(query), headers))
.asObservable() libTrack = Track.create(TrackManager.MYANIMELIST).apply {
.map { Jsoup.parse(Parser.unescapeEntities(it.body()!!.string(), false), "", Parser.xmlParser()) } last_chapter_read = trackForm.select("#add_manga_num_read_chapters").`val`().toInt()
.flatMap { Observable.from(it.select("entry")) } total_chapters = trackForm.select("#totalChap").text().toInt()
.filter { it.select("type").text() != "Novel" } status = trackForm.select("#add_manga_status > option[selected]").`val`().toInt()
.map { score = trackForm.select("#add_manga_score > option[selected]").`val`().toFloatOrNull() ?: 0f
TrackSearch.create(TrackManager.MYANIMELIST).apply { }
title = it.selectText("title")!!
media_id = it.selectInt("id")
total_chapters = it.selectInt("chapters")
summary = it.selectText("synopsis")!!
cover_url = it.selectText("image")!!
tracking_url = MyanimelistApi.mangaUrl(media_id)
publishing_status = it.selectText("status")!!
publishing_type = it.selectText("type")!!
start_date = it.selectText("start_date")!!
} }
} }
.toList() libTrack
}
}
fun getLibManga(track: Track): Observable<Track> {
return findLibManga(track)
.map { it ?: throw Exception("Could not find manga") }
}
fun login(username: String, password: String): String {
val csrf = getSessionInfo()
login(username, password, csrf)
return csrf
}
private fun getSessionInfo(): String {
val response = client.newCall(GET(loginUrl())).execute()
return Jsoup.parse(response.consumeBody())
.select("meta[name=csrf_token]")
.attr("content")
}
private fun login(username: String, password: String, csrf: String) {
val response = client.newCall(POST(url = loginUrl(), body = loginPostBody(username, password, csrf))).execute()
response.use {
if (response.priorResponse()?.code() != 302) throw Exception("Authentication error")
} }
} }
fun getList(username: String): Observable<List<TrackSearch>> { private fun getList(): Observable<List<TrackSearch>> {
return client return getListUrl()
.newCall(GET(getListUrl(username), headers)) .flatMap { url ->
.asObservable() getListXml(url)
.map { Jsoup.parse(Parser.unescapeEntities(it.body()!!.string(), false), "", Parser.xmlParser()) } }
.flatMap { Observable.from(it.select("manga")) } .flatMap { doc ->
Observable.from(doc.select("manga"))
}
.map { .map {
TrackSearch.create(TrackManager.MYANIMELIST).apply { TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("series_title")!! title = it.selectText("manga_title")!!
media_id = it.selectInt("series_mangadb_id") media_id = it.selectInt("manga_mangadb_id")
last_chapter_read = it.selectInt("my_read_chapters") last_chapter_read = it.selectInt("my_read_chapters")
status = it.selectInt("my_status") status = getStatus(it.selectText("my_status")!!)
score = it.selectInt("my_score").toFloat() score = it.selectInt("my_score").toFloat()
total_chapters = it.selectInt("series_chapters") total_chapters = it.selectInt("manga_chapters")
cover_url = it.selectText("series_image")!! tracking_url = mangaUrl(media_id)
tracking_url = MyanimelistApi.mangaUrl(media_id)
} }
} }
.toList() .toList()
} }
fun findLibManga(track: Track, username: String): Observable<Track?> { private fun getListUrl(): Observable<String> {
return getList(username) return authClient.newCall(POST(url = exportListUrl(), body = exportPostBody()))
.map { list -> list.find { it.media_id == track.media_id } }
}
fun getLibManga(track: Track, username: String): Observable<Track> {
return findLibManga(track, username)
.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() .asObservable()
.doOnNext { response -> .map {response ->
response.close() baseUrl + Jsoup.parse(response.consumeBody())
if (response.code() != 200) throw Exception("Login error") .select("div.goodresult")
.select("a")
.attr("href")
} }
} }
private fun getMangaPostPayload(track: Track): RequestBody { private fun getListXml(url: String): Observable<Document> {
val data = xml { return authClient.newCall(GET(url))
element(ENTRY_TAG) { .asObservable()
if (track.last_chapter_read != 0) { .map { response ->
text(CHAPTER_TAG, track.last_chapter_read.toString()) Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
} }
text(STATUS_TAG, track.status.toString()) }
text(SCORE_TAG, track.score.toString())
private fun Response.consumeBody(): String? {
use {
if (it.code() != 200) throw Exception("HTTP error ${it.code()}")
return it.body()?.string()
}
}
private fun Response.consumeXmlBody(): String? {
use { res ->
if (res.code() != 200) throw Exception("Export list error")
BufferedReader(InputStreamReader(GZIPInputStream(res.body()?.source()?.inputStream()))).use { reader ->
val sb = StringBuilder()
reader.forEachLine { line ->
sb.append(line)
}
return sb.toString()
} }
} }
return FormBody.Builder()
.add("data", data)
.build()
}
private inline fun xml(block: XmlSerializer.() -> Unit): String {
val x = Xml.newSerializer()
val writer = StringWriter()
with(x) {
setOutput(writer)
startDocument("UTF-8", false)
block()
endDocument()
}
return writer.toString()
}
private inline fun XmlSerializer.element(tag: String, block: XmlSerializer.() -> Unit) {
startTag("", tag)
block()
endTag("", tag)
}
private fun XmlSerializer.text(tag: String, body: String) {
startTag("", tag)
text(body)
endTag("", tag)
}
fun getLoginUrl() = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/account/verify_credentials.xml")
.toString()
fun getSearchUrl(query: String) = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/manga/search.xml")
.appendQueryParameter("q", query)
.toString()
fun getListUrl(username: String) = Uri.parse(baseUrl).buildUpon()
.appendPath("malappinfo.php")
.appendQueryParameter("u", username)
.appendQueryParameter("status", "all")
.appendQueryParameter("type", "manga")
.toString()
fun getUpdateUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/mangalist/update")
.appendPath("${track.media_id}.xml")
.toString()
fun getAddUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/mangalist/add")
.appendPath("${track.media_id}.xml")
.toString()
fun createHeaders(username: String, password: String): Headers {
return Headers.Builder()
.add("Authorization", Credentials.basic(username, password))
.add("User-Agent", "api-indiv-9F93C52A963974CF674325391990191C")
.build()
} }
companion object { companion object {
const val baseUrl = "https://myanimelist.net" const val CSRF = "csrf_token"
const val baseMangaUrl = baseUrl + "/manga/"
fun mangaUrl(remoteId: Int): String { private const val baseUrl = "https://myanimelist.net"
return baseMangaUrl + remoteId private const val baseMangaUrl = "$baseUrl/manga/"
private const val baseModifyListUrl = "$baseUrl/ownlist/manga"
private const val PREFIX_MY = "my:"
private const val TD = "td"
private fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId
private fun loginUrl() = Uri.parse(baseUrl).buildUpon()
.appendPath("login.php")
.toString()
private fun searchUrl(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 val ENTRY_TAG = "entry" private fun exportListUrl() = Uri.parse(baseUrl).buildUpon()
private val CHAPTER_TAG = "chapter" .appendPath("panel.php")
private val SCORE_TAG = "score" .appendQueryParameter("go", "export")
private val STATUS_TAG = "status" .toString()
const val PREFIX_MY = "my:" private fun updateUrl() = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath("edit.json")
.toString()
private fun addUrl() = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath( "add.json")
.toString()
private fun listEntryUrl(mediaId: Int) = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath(mediaId.toString())
.appendPath("edit")
.toString()
private fun loginPostBody(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 exportPostBody(): RequestBody {
return FormBody.Builder()
.add("type", "2")
.add("subexport", "Export My List")
.build()
}
private fun mangaPostPayload(track: Track): 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)
return RequestBody.create(MediaType.parse("application/json; charset=utf-8"), body.toString())
}
private fun Element.searchTitle() = select("strong").text()!!
private fun Element.searchTotalChapters() = if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt()
private fun Element.searchCoverUrl() = select("img")
.attr("data-src")
.split("\\?")[0]
.replace("/r/50x70/", "/")
private fun Element.searchMediaId() = select("div.picSurround")
.select("a").attr("id")
.replace("sarea", "")
.toInt()
private fun Element.searchSummary() = select("div.pt4")
.first()
.ownText()!!
private fun Element.searchPublishingStatus() = if (select(TD).last().text() == "-") "Publishing" else "Finished"
private fun Element.searchPublishingType() = select(TD)[2].text()!!
private fun Element.searchStartDate() = select(TD)[6].text()!!
private fun getStatus(status: String) = when (status) {
"Reading" -> 1
"Completed" -> 2
"On-Hold" -> 3
"Dropped" -> 4
"Plan to Read" -> 6
else -> 1
}
} }
} }

View File

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.data.track.shikimori
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,139 @@
package eu.kanade.tachiyomi.data.track.shikimori
import android.content.Context
import android.graphics.Color
import android.util.Log
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 Shikimori(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 = "Shikimori"
private val gson: Gson by injectLazy()
private val interceptor by lazy { ShikimoriInterceptor(this, gson) }
private val api by lazy { ShikimoriApi(client, interceptor) }
override fun getLogo() = R.drawable.shikimori
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,205 @@
package eu.kanade.tachiyomi.data.track.shikimori
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 ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInterceptor) {
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.toShikimoriStatus()
)
)
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.SHIKIMORI).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, mangas: JsonObject): Track {
return Track.create(TrackManager.SHIKIMORI).apply {
title = mangas["name"].asString
media_id = obj["id"].asInt
total_chapters = mangas["chapters"].asInt
last_chapter_read = obj["chapters"].asInt
score = (obj["score"].asInt).toFloat()
status = toTrackStatus(obj["status"].asString)
tracking_url = baseUrl + mangas["url"].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()
val urlMangas = Uri.parse("$apiUrl/mangas").buildUpon()
.appendPath(track.media_id.toString())
.build()
val requestMangas = Request.Builder()
.url(urlMangas.toString())
.get()
.build()
return authClient.newCall(requestMangas)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body()?.string().orEmpty()
parser.parse(responseBody).obj
}.flatMap { mangas ->
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, mangas)
}
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.one"
private const val apiUrl = "https://shikimori.one/api"
private const val oauthUrl = "https://shikimori.one/oauth/token"
private const val loginUrl = "https://shikimori.one/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.shikimori
import com.google.gson.Gson
import okhttp3.Interceptor
import okhttp3.Response
class ShikimoriInterceptor(val shikimori: Shikimori, val gson: Gson) : Interceptor {
/**
* OAuth object used for authenticated requests.
*/
private var oauth: OAuth? = shikimori.restoreToken()
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val currAuth = oauth ?: throw Exception("Not authenticated with Shikimori")
val refreshToken = currAuth.refresh_token!!
// Refresh access token if expired.
if (currAuth.isExpired()) {
val response = chain.proceed(ShikimoriApi.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
shikimori.saveToken(oauth)
}
}

View File

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

View File

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

View File

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.data.updater
interface Release {
val info: String
/**
* Get download link of latest release.
* @return download link of latest release.
*/
val downloadLink: String
}

View File

@ -0,0 +1,25 @@
package eu.kanade.tachiyomi.data.updater
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.updater.devrepo.DevRepoUpdateChecker
import eu.kanade.tachiyomi.data.updater.github.GithubUpdateChecker
import rx.Observable
abstract class UpdateChecker {
companion object {
fun getUpdateChecker(): UpdateChecker {
return if (BuildConfig.DEBUG) {
DevRepoUpdateChecker()
} else {
GithubUpdateChecker()
}
}
}
/**
* Returns observable containing release information
*/
abstract fun checkForUpdate(): Observable<UpdateResult>
}

View File

@ -0,0 +1,8 @@
package eu.kanade.tachiyomi.data.updater
abstract class UpdateResult {
open class NewUpdate<T : Release>(val release: T): UpdateResult()
open class NoNewUpdate: UpdateResult()
}

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.updater
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.os.Build
import android.support.v4.app.NotificationCompat import android.support.v4.app.NotificationCompat
import com.evernote.android.job.Job import com.evernote.android.job.Job
import com.evernote.android.job.JobManager import com.evernote.android.job.JobManager
@ -13,10 +14,15 @@ import eu.kanade.tachiyomi.util.notificationManager
class UpdaterJob : Job() { class UpdaterJob : Job() {
override fun onRunJob(params: Params): Result { override fun onRunJob(params: Params): Result {
return GithubUpdateChecker() // Android 4.x is no longer supported
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
return Result.SUCCESS
}
return UpdateChecker.getUpdateChecker()
.checkForUpdate() .checkForUpdate()
.map { result -> .map { result ->
if (result is GithubUpdateResult.NewUpdate) { if (result is UpdateResult.NewUpdate<*>) {
val url = result.release.downloadLink val url = result.release.downloadLink
val intent = Intent(context, UpdaterService::class.java).apply { val intent = Intent(context, UpdaterService::class.java).apply {
@ -33,9 +39,9 @@ class UpdaterJob : Job() {
PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)) PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
} }
} }
Job.Result.SUCCESS Result.SUCCESS
} }
.onErrorReturn { Job.Result.FAILURE } .onErrorReturn { Result.FAILURE }
// Sadly, the task needs to be synchronous. // Sadly, the task needs to be synchronous.
.toBlocking() .toBlocking()
.single() .single()
@ -64,4 +70,4 @@ class UpdaterJob : Job() {
} }
} }
} }

View File

@ -0,0 +1,14 @@
package eu.kanade.tachiyomi.data.updater.devrepo
import eu.kanade.tachiyomi.data.updater.Release
class DevRepoRelease(override val info: String) : Release {
override val downloadLink: String
get() = LATEST_URL
companion object {
const val LATEST_URL = "https://tachiyomi.kanade.eu/latest"
}
}

View File

@ -0,0 +1,40 @@
package eu.kanade.tachiyomi.data.updater.devrepo
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.updater.UpdateChecker
import eu.kanade.tachiyomi.data.updater.UpdateResult
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservable
import okhttp3.OkHttpClient
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class DevRepoUpdateChecker : UpdateChecker() {
private val client: OkHttpClient by lazy {
Injekt.get<NetworkHelper>().client.newBuilder()
.followRedirects(false)
.build()
}
private val versionRegex: Regex by lazy {
Regex("tachiyomi-r(\\d+).apk")
}
override fun checkForUpdate(): Observable<UpdateResult> {
return client.newCall(GET(DevRepoRelease.LATEST_URL)).asObservable()
.map { response ->
// Get latest repo version number from header in format "Location: tachiyomi-r1512.apk"
val latestVersionNumber: String = versionRegex.find(response.header("Location")!!)!!.groupValues[1]
if (latestVersionNumber.toInt() > BuildConfig.COMMIT_COUNT.toInt()) {
DevRepoUpdateResult.NewUpdate(DevRepoRelease("v$latestVersionNumber"))
} else {
DevRepoUpdateResult.NoNewUpdate()
}
}
}
}

View File

@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.data.updater.devrepo
import eu.kanade.tachiyomi.data.updater.UpdateResult
sealed class DevRepoUpdateResult : UpdateResult() {
class NewUpdate(release: DevRepoRelease): UpdateResult.NewUpdate<DevRepoRelease>(release)
class NoNewUpdate: UpdateResult.NoNewUpdate()
}

View File

@ -1,24 +1,25 @@
package eu.kanade.tachiyomi.data.updater package eu.kanade.tachiyomi.data.updater.github
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import eu.kanade.tachiyomi.data.updater.Release
/** /**
* Release object. * Release object.
* Contains information about the latest release from Github. * Contains information about the latest release from Github.
* *
* @param version version of latest release. * @param version version of latest release.
* @param changeLog log of latest release. * @param info log of latest release.
* @param assets assets of latest release. * @param assets assets of latest release.
*/ */
class GithubRelease(@SerializedName("tag_name") val version: String, class GithubRelease(@SerializedName("tag_name") val version: String,
@SerializedName("body") val changeLog: String, @SerializedName("body") override val info: String,
@SerializedName("assets") private val assets: List<Assets>) { @SerializedName("assets") private val assets: List<Assets>): Release {
/** /**
* Get download link of latest release from the assets. * Get download link of latest release from the assets.
* @return download link of latest release. * @return download link of latest release.
*/ */
val downloadLink: String override val downloadLink: String
get() = assets[0].downloadLink get() = assets[0].downloadLink
/** /**

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.data.updater package eu.kanade.tachiyomi.data.updater.github
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import retrofit2.Retrofit import retrofit2.Retrofit
@ -30,4 +30,4 @@ interface GithubService {
@GET("/repos/inorichi/tachiyomi/releases/latest") @GET("/repos/inorichi/tachiyomi/releases/latest")
fun getLatestVersion(): Observable<GithubRelease> fun getLatestVersion(): Observable<GithubRelease>
} }

View File

@ -1,16 +1,15 @@
package eu.kanade.tachiyomi.data.updater package eu.kanade.tachiyomi.data.updater.github
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.updater.UpdateChecker
import eu.kanade.tachiyomi.data.updater.UpdateResult
import rx.Observable import rx.Observable
class GithubUpdateChecker { class GithubUpdateChecker : UpdateChecker() {
private val service: GithubService = GithubService.create() private val service: GithubService = GithubService.create()
/** override fun checkForUpdate(): Observable<UpdateResult> {
* Returns observable containing release information
*/
fun checkForUpdate(): Observable<GithubUpdateResult> {
return service.getLatestVersion().map { release -> return service.getLatestVersion().map { release ->
val newVersion = release.version.replace("[^\\d.]".toRegex(), "") val newVersion = release.version.replace("[^\\d.]".toRegex(), "")
@ -22,4 +21,5 @@ class GithubUpdateChecker {
} }
} }
} }
}
}

View File

@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.data.updater.github
import eu.kanade.tachiyomi.data.updater.UpdateResult
sealed class GithubUpdateResult : UpdateResult() {
class NewUpdate(release: GithubRelease): UpdateResult.NewUpdate<GithubRelease>(release)
class NoNewUpdate : UpdateResult.NoNewUpdate()
}

View File

@ -13,7 +13,7 @@ import eu.kanade.tachiyomi.extension.util.ExtensionInstaller
import eu.kanade.tachiyomi.extension.util.ExtensionLoader import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.util.launchNow import eu.kanade.tachiyomi.util.launchNow
import kotlinx.coroutines.experimental.async import kotlinx.coroutines.async
import rx.Observable import rx.Observable
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers import rx.schedulers.Schedulers

View File

@ -7,6 +7,7 @@ import com.github.salomonbrys.kotson.string
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonArray import com.google.gson.JsonArray
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.asObservableSuccess
@ -36,17 +37,23 @@ internal class ExtensionGithubApi {
val json = gson.fromJson<JsonArray>(text) val json = gson.fromJson<JsonArray>(text)
return json.map { element -> return json
val name = element["name"].string.substringAfter("Tachiyomi: ") .filter { element ->
val pkgName = element["pkg"].string val versionName = element["version"].string
val apkName = element["apk"].string val libVersion = versionName.substringBeforeLast('.').toDouble()
val versionName = element["version"].string libVersion >= ExtensionLoader.LIB_VERSION_MIN && libVersion <= ExtensionLoader.LIB_VERSION_MAX
val versionCode = element["code"].int }
val lang = element["lang"].string .map { element ->
val icon = "$repoUrl/icon/${apkName.replace(".apk", ".png")}" val name = element["name"].string.substringAfter("Tachiyomi: ")
val pkgName = element["pkg"].string
val apkName = element["apk"].string
val versionName = element["version"].string
val versionCode = element["code"].int
val lang = element["lang"].string
val icon = "$repoUrl/icon/${apkName.replace(".apk", ".png")}"
Extension.Available(name, pkgName, versionName, versionCode, lang, apkName, icon) Extension.Available(name, pkgName, versionName, versionCode, lang, apkName, icon)
} }
} }
fun getApkUrl(extension: Extension.Available): String { fun getApkUrl(extension: Extension.Available): String {

View File

@ -39,7 +39,7 @@ class ExtensionInstallActivity : Activity() {
} }
private fun checkInstallationResult(resultCode: Int) { private fun checkInstallationResult(resultCode: Int) {
val downloadId = intent.extras.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID) val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID)
val success = resultCode == RESULT_OK val success = resultCode == RESULT_OK
val extensionManager = Injekt.get<ExtensionManager>() val extensionManager = Injekt.get<ExtensionManager>()

View File

@ -7,7 +7,10 @@ import android.content.IntentFilter
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.extension.model.LoadResult
import eu.kanade.tachiyomi.util.launchNow import eu.kanade.tachiyomi.util.launchNow
import kotlinx.coroutines.experimental.async import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
/** /**
* Broadcast receiver that listens for the system's packages installed, updated or removed, and only * Broadcast receiver that listens for the system's packages installed, updated or removed, and only
@ -91,7 +94,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): LoadResult { private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): LoadResult {
val pkgName = getPackageNameFromIntent(intent) ?: val pkgName = getPackageNameFromIntent(intent) ?:
return LoadResult.Error("Package name not found") return LoadResult.Error("Package name not found")
return async { ExtensionLoader.loadExtensionFromPkgName(context, pkgName) }.await() return GlobalScope.async(Dispatchers.Default, CoroutineStart.DEFAULT, { ExtensionLoader.loadExtensionFromPkgName(context, pkgName) }).await()
} }
/** /**

View File

@ -13,8 +13,8 @@ import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.util.Hash import eu.kanade.tachiyomi.util.Hash
import kotlinx.coroutines.experimental.async import kotlinx.coroutines.async
import kotlinx.coroutines.experimental.runBlocking import kotlinx.coroutines.runBlocking
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -27,8 +27,8 @@ internal object ExtensionLoader {
private const val EXTENSION_FEATURE = "tachiyomi.extension" private const val EXTENSION_FEATURE = "tachiyomi.extension"
private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class" private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
private const val LIB_VERSION_MIN = 1 const val LIB_VERSION_MIN = 1.0
private const val LIB_VERSION_MAX = 1 const val LIB_VERSION_MAX = 1.2
private const val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES private const val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES
@ -100,10 +100,16 @@ internal object ExtensionLoader {
val versionName = pkgInfo.versionName val versionName = pkgInfo.versionName
val versionCode = pkgInfo.versionCode val versionCode = pkgInfo.versionCode
if (versionName.isNullOrEmpty()) {
val exception = Exception("Missing versionName for extension $extName")
Timber.w(exception)
return LoadResult.Error(exception)
}
// Validate lib version // Validate lib version
val majorLibVersion = versionName.substringBefore('.').toInt() val libVersion = versionName.substringBeforeLast('.').toDouble()
if (majorLibVersion < LIB_VERSION_MIN || majorLibVersion > LIB_VERSION_MAX) { if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
val exception = Exception("Lib version is $majorLibVersion, while only versions " + val exception = Exception("Lib version is $libVersion, while only versions " +
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed") "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed")
Timber.w(exception) Timber.w(exception)
return LoadResult.Error(exception) return LoadResult.Error(exception)
@ -121,7 +127,7 @@ internal object ExtensionLoader {
val classLoader = PathClassLoader(appInfo.sourceDir, null, context.classLoader) val classLoader = PathClassLoader(appInfo.sourceDir, null, context.classLoader)
val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS) val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS)!!
.split(";") .split(";")
.map { .map {
val sourceClass = it.trim() val sourceClass = it.trim()

View File

@ -0,0 +1,79 @@
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, cookieNames: List<String>? = null, maxAge: Int = -1) {
val urlString = url.toString()
val cookies = manager.getCookie(urlString) ?: return
fun List<String>.filterNames(): List<String> {
return if (cookieNames != null) {
this.filter { it in cookieNames }
} else {
this
}
}
cookies.split(";")
.map { it.substringBefore("=") }
.filterNames()
.onEach { manager.setCookie(urlString, "$it=;Max-Age=$maxAge") }
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,140 @@
package eu.kanade.tachiyomi.network package eu.kanade.tachiyomi.network
import com.squareup.duktape.Duktape import android.annotation.SuppressLint
import okhttp3.CacheControl import android.content.Context
import okhttp3.HttpUrl import android.os.Build
import okhttp3.Interceptor import android.os.Handler
import okhttp3.Request import android.os.Looper
import okhttp3.Response import android.webkit.WebSettings
import android.webkit.WebView
class CloudflareInterceptor : Interceptor { import eu.kanade.tachiyomi.util.WebViewClientCompat
import okhttp3.*
private val operationPattern = Regex("""setTimeout\(function\(\)\{\s+(var (?:\w,)+f.+?\r?\n[\s\S]+?a\.value =.+?)\r?\n""") import uy.kohesive.injekt.injectLazy
import java.io.IOException
private val passPattern = Regex("""name="pass" value="(.+?)"""") import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
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 serverCheck = arrayOf("cloudflare-nginx", "cloudflare")
@Synchronized
override fun intercept(chain: Interceptor.Chain): Response { private val handler = Handler(Looper.getMainLooper())
val response = chain.proceed(chain.request())
private val networkHelper: NetworkHelper by injectLazy()
// Check if Cloudflare anti-bot is on
if (response.code() == 503 && response.header("Server") in serverCheck) { /**
return chain.proceed(resolveChallenge(response)) * 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.
return response */
} private val initWebView by lazy {
if (Build.VERSION.SDK_INT >= 17) {
private fun resolveChallenge(response: Response): Request { WebSettings.getDefaultUserAgent(context)
Duktape.create().use { duktape -> } else {
val originalRequest = response.request() null
val url = originalRequest.url() }
val domain = url.host() }
val content = response.body()!!.string()
@Synchronized
// CloudFlare requires waiting 4 seconds before resolving the challenge override fun intercept(chain: Interceptor.Chain): Response {
Thread.sleep(4000) initWebView
val operation = operationPattern.find(content)?.groups?.get(1)?.value val originalRequest = chain.request()
val challenge = challengePattern.find(content)?.groups?.get(1)?.value val response = chain.proceed(originalRequest)
val pass = passPattern.find(content)?.groups?.get(1)?.value
// Check if Cloudflare anti-bot is on
if (operation == null || challenge == null || pass == null) { if (response.code() == 503 && response.header("Server") in serverCheck) {
throw Exception("Failed resolving Cloudflare challenge") try {
} response.close()
networkHelper.cookieManager.remove(originalRequest.url(), listOf("__cfduid", "cf_clearance"), 0)
val js = operation val oldCookie = networkHelper.cookieManager.get(originalRequest.url())
.replace(Regex("""a\.value = (.+ \+ t\.length).+"""), "$1") .firstOrNull { it.name() == "cf_clearance" }
.replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "") return if (resolveWithWebView(originalRequest, oldCookie)) {
.replace("t.length", "${domain.length}") chain.proceed(originalRequest)
.replace("\n", "") } else {
throw IOException("Failed to bypass Cloudflare!")
val result = duktape.evaluate(js) as Double }
} catch (e: Exception) {
val cloudflareUrl = HttpUrl.parse("${url.scheme()}://$domain/cdn-cgi/l/chk_jschl")!! // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
.newBuilder() // we don't crash the entire app
.addQueryParameter("jschl_vc", challenge) throw IOException(e)
.addQueryParameter("pass", pass) }
.addQueryParameter("jschl_answer", "$result") }
.toString()
return response
val cloudflareHeaders = originalRequest.headers() }
.newBuilder()
.add("Referer", url.toString()) @SuppressLint("SetJavaScriptEnabled")
.add("Accept", "text/html,application/xhtml+xml,application/xml") private fun resolveWithWebView(request: Request, oldCookie: Cookie?): Boolean {
.add("Accept-Language", "en") // We need to lock this thread until the WebView finds the challenge solution url, because
.build() // OkHttp doesn't support asynchronous interceptors.
val latch = CountDownLatch(1)
return GET(cloudflareUrl, cloudflareHeaders, cache = CacheControl.Builder().build())
} var webView: WebView? = null
} var challengeFound = false
var cloudflareBypassed = false
}
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 onPageFinished(view: WebView, url: String) {
fun isCloudFlareBypassed(): Boolean {
return networkHelper.cookieManager.get(HttpUrl.parse(origRequestUrl)!!)
.firstOrNull { it.name() == "cf_clearance" }
.let { it != null && it != oldCookie }
}
if (isCloudFlareBypassed()) {
cloudflareBypassed = true
latch.countDown()
}
// 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()
}
}
}
}
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()
}
return cloudflareBypassed
}
}

View File

@ -1,128 +1,117 @@
package eu.kanade.tachiyomi.network package eu.kanade.tachiyomi.network
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import okhttp3.Cache import okhttp3.*
import okhttp3.CipherSuite import java.io.File
import okhttp3.ConnectionSpec import java.io.IOException
import okhttp3.OkHttpClient import java.net.InetAddress
import okhttp3.TlsVersion import java.net.Socket
import java.io.File import java.net.UnknownHostException
import java.io.IOException import java.security.KeyManagementException
import java.net.InetAddress import java.security.KeyStore
import java.net.Socket import java.security.NoSuchAlgorithmException
import java.net.UnknownHostException import javax.net.ssl.*
import java.security.KeyManagementException
import java.security.KeyStore class NetworkHelper(context: Context) {
import java.security.NoSuchAlgorithmException
import javax.net.ssl.SSLContext private val cacheDir = File(context.cacheDir, "network_cache")
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory private val cacheSize = 5L * 1024 * 1024 // 5 MiB
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager val cookieManager = AndroidCookieJar(context)
class NetworkHelper(context: Context) { val client = OkHttpClient.Builder()
.cookieJar(cookieManager)
private val cacheDir = File(context.cacheDir, "network_cache") .cache(Cache(cacheDir, cacheSize))
.enableTLS12()
private val cacheSize = 5L * 1024 * 1024 // 5 MiB .build()
private val cookieManager = PersistentCookieJar(context) val cloudflareClient = client.newBuilder()
.addInterceptor(CloudflareInterceptor(context))
val client = OkHttpClient.Builder() .build()
.cookieJar(cookieManager)
.cache(Cache(cacheDir, cacheSize)) private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder {
.enableTLS12() if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) {
.build() return this
}
val cloudflareClient = client.newBuilder()
.addInterceptor(CloudflareInterceptor()) val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
.build() trustManagerFactory.init(null as KeyStore?)
val trustManagers = trustManagerFactory.trustManagers
val cookies: PersistentCookieStore if (trustManagers.size == 1 && trustManagers[0] is X509TrustManager) {
get() = cookieManager.store class TLSSocketFactory @Throws(KeyManagementException::class, NoSuchAlgorithmException::class)
constructor() : SSLSocketFactory() {
private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) { private val internalSSLSocketFactory: SSLSocketFactory
return this
} init {
val context = SSLContext.getInstance("TLS")
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) context.init(null, null, null)
trustManagerFactory.init(null as KeyStore?) internalSSLSocketFactory = context.socketFactory
val trustManagers = trustManagerFactory.trustManagers }
if (trustManagers.size == 1 && trustManagers[0] is X509TrustManager) {
class TLSSocketFactory @Throws(KeyManagementException::class, NoSuchAlgorithmException::class) override fun getDefaultCipherSuites(): Array<String> {
constructor() : SSLSocketFactory() { return internalSSLSocketFactory.defaultCipherSuites
}
private val internalSSLSocketFactory: SSLSocketFactory
override fun getSupportedCipherSuites(): Array<String> {
init { return internalSSLSocketFactory.supportedCipherSuites
val context = SSLContext.getInstance("TLS") }
context.init(null, null, null)
internalSSLSocketFactory = context.socketFactory @Throws(IOException::class)
} override fun createSocket(): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket())
override fun getDefaultCipherSuites(): Array<String> { }
return internalSSLSocketFactory.defaultCipherSuites
} @Throws(IOException::class)
override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket? {
override fun getSupportedCipherSuites(): Array<String> { return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose))
return internalSSLSocketFactory.supportedCipherSuites }
}
@Throws(IOException::class, UnknownHostException::class)
@Throws(IOException::class) override fun createSocket(host: String, port: Int): Socket? {
override fun createSocket(): Socket? { return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port))
return enableTLSOnSocket(internalSSLSocketFactory.createSocket()) }
}
@Throws(IOException::class, UnknownHostException::class)
@Throws(IOException::class) override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket? {
override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket? { return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort))
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose)) }
}
@Throws(IOException::class)
@Throws(IOException::class, UnknownHostException::class) override fun createSocket(host: InetAddress, port: Int): Socket? {
override fun createSocket(host: String, port: Int): Socket? { return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port))
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)) }
}
@Throws(IOException::class)
@Throws(IOException::class, UnknownHostException::class) override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket? {
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket? { return enableTLSOnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort))
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort)) }
}
private fun enableTLSOnSocket(socket: Socket?): Socket? {
@Throws(IOException::class) if (socket != null && socket is SSLSocket) {
override fun createSocket(host: InetAddress, port: Int): Socket? { socket.enabledProtocols = socket.supportedProtocols
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)) }
} return socket
}
@Throws(IOException::class) }
override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort)) sslSocketFactory(TLSSocketFactory(), trustManagers[0] as X509TrustManager)
} }
private fun enableTLSOnSocket(socket: Socket?): Socket? { val specCompat = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
if (socket != null && socket is SSLSocket) { .tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0)
socket.enabledProtocols = socket.supportedProtocols .cipherSuites(
} *ConnectionSpec.MODERN_TLS.cipherSuites().orEmpty().toTypedArray(),
return socket CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
} CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
} )
.build()
sslSocketFactory(TLSSocketFactory(), trustManagers[0] as X509TrustManager)
} val specs = listOf(specCompat, ConnectionSpec.CLEARTEXT)
connectionSpecs(specs)
val specCompat = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
.tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0) return this
.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,70 +1,70 @@
package eu.kanade.tachiyomi.network package eu.kanade.tachiyomi.network
import okhttp3.Call import okhttp3.Call
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import rx.Observable import rx.Observable
import rx.Producer import rx.Producer
import rx.Subscription import rx.Subscription
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
fun Call.asObservable(): Observable<Response> { fun Call.asObservable(): Observable<Response> {
return Observable.unsafeCreate { subscriber -> return Observable.unsafeCreate { subscriber ->
// Since Call is a one-shot type, clone it for each new subscriber. // Since Call is a one-shot type, clone it for each new subscriber.
val call = clone() val call = clone()
// Wrap the call in a helper which handles both unsubscription and backpressure. // Wrap the call in a helper which handles both unsubscription and backpressure.
val requestArbiter = object : AtomicBoolean(), Producer, Subscription { val requestArbiter = object : AtomicBoolean(), Producer, Subscription {
override fun request(n: Long) { override fun request(n: Long) {
if (n == 0L || !compareAndSet(false, true)) return if (n == 0L || !compareAndSet(false, true)) return
try { try {
val response = call.execute() val response = call.execute()
if (!subscriber.isUnsubscribed) { if (!subscriber.isUnsubscribed) {
subscriber.onNext(response) subscriber.onNext(response)
subscriber.onCompleted() subscriber.onCompleted()
} }
} catch (error: Exception) { } catch (error: Exception) {
if (!subscriber.isUnsubscribed) { if (!subscriber.isUnsubscribed) {
subscriber.onError(error) subscriber.onError(error)
} }
} }
} }
override fun unsubscribe() { override fun unsubscribe() {
call.cancel() call.cancel()
} }
override fun isUnsubscribed(): Boolean { override fun isUnsubscribed(): Boolean {
return call.isCanceled return call.isCanceled
} }
} }
subscriber.add(requestArbiter) subscriber.add(requestArbiter)
subscriber.setProducer(requestArbiter) subscriber.setProducer(requestArbiter)
} }
} }
fun Call.asObservableSuccess(): Observable<Response> { fun Call.asObservableSuccess(): Observable<Response> {
return asObservable().doOnNext { response -> return asObservable().doOnNext { response ->
if (!response.isSuccessful) { if (!response.isSuccessful) {
response.close() response.close()
throw Exception("HTTP error ${response.code()}") throw Exception("HTTP error ${response.code()}")
} }
} }
} }
fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call { fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call {
val progressClient = newBuilder() val progressClient = newBuilder()
.cache(null) .cache(null)
.addNetworkInterceptor { chain -> .addNetworkInterceptor { chain ->
val originalResponse = chain.proceed(chain.request()) val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder() originalResponse.newBuilder()
.body(ProgressResponseBody(originalResponse.body()!!, listener)) .body(ProgressResponseBody(originalResponse.body()!!, listener))
.build() .build()
} }
.build() .build()
return progressClient.newCall(request) return progressClient.newCall(request)
} }

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,5 +1,5 @@
package eu.kanade.tachiyomi.network package eu.kanade.tachiyomi.network
interface ProgressListener { interface ProgressListener {
fun update(bytesRead: Long, contentLength: Long, done: Boolean) fun update(bytesRead: Long, contentLength: Long, done: Boolean)
} }

View File

@ -1,40 +1,40 @@
package eu.kanade.tachiyomi.network package eu.kanade.tachiyomi.network
import okhttp3.MediaType import okhttp3.MediaType
import okhttp3.ResponseBody import okhttp3.ResponseBody
import okio.* import okio.*
import java.io.IOException import java.io.IOException
class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() { class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() {
private val bufferedSource: BufferedSource by lazy { private val bufferedSource: BufferedSource by lazy {
Okio.buffer(source(responseBody.source())) Okio.buffer(source(responseBody.source()))
} }
override fun contentType(): MediaType { override fun contentType(): MediaType {
return responseBody.contentType()!! return responseBody.contentType()!!
} }
override fun contentLength(): Long { override fun contentLength(): Long {
return responseBody.contentLength() return responseBody.contentLength()
} }
override fun source(): BufferedSource { override fun source(): BufferedSource {
return bufferedSource return bufferedSource
} }
private fun source(source: Source): Source { private fun source(source: Source): Source {
return object : ForwardingSource(source) { return object : ForwardingSource(source) {
internal var totalBytesRead = 0L internal var totalBytesRead = 0L
@Throws(IOException::class) @Throws(IOException::class)
override fun read(sink: Buffer, byteCount: Long): Long { override fun read(sink: Buffer, byteCount: Long): Long {
val bytesRead = super.read(sink, byteCount) val bytesRead = super.read(sink, byteCount)
// read() returns the number of bytes read, or -1 if this source is exhausted. // read() returns the number of bytes read, or -1 if this source is exhausted.
totalBytesRead += if (bytesRead != -1L) bytesRead else 0 totalBytesRead += if (bytesRead != -1L) bytesRead else 0
progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L) progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
return bytesRead return bytesRead
} }
} }
} }
} }

View File

@ -1,32 +1,32 @@
package eu.kanade.tachiyomi.network package eu.kanade.tachiyomi.network
import okhttp3.* import okhttp3.*
import java.util.concurrent.TimeUnit.MINUTES import java.util.concurrent.TimeUnit.MINUTES
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build() private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build()
private val DEFAULT_HEADERS = Headers.Builder().build() private val DEFAULT_HEADERS = Headers.Builder().build()
private val DEFAULT_BODY: RequestBody = FormBody.Builder().build() private val DEFAULT_BODY: RequestBody = FormBody.Builder().build()
fun GET(url: String, fun GET(url: String,
headers: Headers = DEFAULT_HEADERS, headers: Headers = DEFAULT_HEADERS,
cache: CacheControl = DEFAULT_CACHE_CONTROL): Request { cache: CacheControl = DEFAULT_CACHE_CONTROL): Request {
return Request.Builder() return Request.Builder()
.url(url) .url(url)
.headers(headers) .headers(headers)
.cacheControl(cache) .cacheControl(cache)
.build() .build()
} }
fun POST(url: String, fun POST(url: String,
headers: Headers = DEFAULT_HEADERS, headers: Headers = DEFAULT_HEADERS,
body: RequestBody = DEFAULT_BODY, body: RequestBody = DEFAULT_BODY,
cache: CacheControl = DEFAULT_CACHE_CONTROL): Request { cache: CacheControl = DEFAULT_CACHE_CONTROL): Request {
return Request.Builder() return Request.Builder()
.url(url) .url(url)
.post(body) .post(body)
.headers(headers) .headers(headers)
.cacheControl(cache) .cacheControl(cache)
.build() .build()
} }

View File

@ -1,46 +1,46 @@
package eu.kanade.tachiyomi.source package eu.kanade.tachiyomi.source
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
import rx.Observable import rx.Observable
interface CatalogueSource : Source { interface CatalogueSource : Source {
/** /**
* An ISO 639-1 compliant language code (two letters in lower case). * An ISO 639-1 compliant language code (two letters in lower case).
*/ */
val lang: String val lang: String
/** /**
* Whether the source has support for latest updates. * Whether the source has support for latest updates.
*/ */
val supportsLatest: Boolean val supportsLatest: Boolean
/** /**
* Returns an observable containing a page with a list of manga. * Returns an observable containing a page with a list of manga.
* *
* @param page the page number to retrieve. * @param page the page number to retrieve.
*/ */
fun fetchPopularManga(page: Int): Observable<MangasPage> fun fetchPopularManga(page: Int): Observable<MangasPage>
/** /**
* Returns an observable containing a page with a list of manga. * Returns an observable containing a page with a list of manga.
* *
* @param page the page number to retrieve. * @param page the page number to retrieve.
* @param query the search query. * @param query the search query.
* @param filters the list of filters to apply. * @param filters the list of filters to apply.
*/ */
fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage>
/** /**
* Returns an observable containing a page with a list of latest manga updates. * Returns an observable containing a page with a list of latest manga updates.
* *
* @param page the page number to retrieve. * @param page the page number to retrieve.
*/ */
fun fetchLatestUpdates(page: Int): Observable<MangasPage> fun fetchLatestUpdates(page: Int): Observable<MangasPage>
/** /**
* Returns the list of filters for the source. * Returns the list of filters for the source.
*/ */
fun getFilterList(): FilterList fun getFilterList(): FilterList
} }

View File

@ -2,20 +2,24 @@ package eu.kanade.tachiyomi.source
import android.content.Context import android.content.Context
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.* import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
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.util.ChapterRecognition import eu.kanade.tachiyomi.util.ChapterRecognition
import eu.kanade.tachiyomi.util.ComparatorUtil.CaseInsensitiveNaturalComparator
import eu.kanade.tachiyomi.util.DiskUtil import eu.kanade.tachiyomi.util.DiskUtil
import eu.kanade.tachiyomi.util.EpubFile import eu.kanade.tachiyomi.util.EpubFile
import eu.kanade.tachiyomi.util.ImageUtil import eu.kanade.tachiyomi.util.ImageUtil
import junrar.Archive import junrar.Archive
import junrar.rarfile.FileHeader import junrar.rarfile.FileHeader
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import rx.Observable import rx.Observable
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.InputStream import java.io.InputStream
import java.util.Comparator
import java.util.Locale import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
@ -125,7 +129,6 @@ class LocalSource(private val context: Context) : CatalogueSource {
override fun fetchMangaDetails(manga: SManga) = Observable.just(manga) override fun fetchMangaDetails(manga: SManga) = Observable.just(manga)
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> { override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
val chapters = getBaseDirectories(context) val chapters = getBaseDirectories(context)
.mapNotNull { File(it, manga.url).listFiles()?.toList() } .mapNotNull { File(it, manga.url).listFiles()?.toList() }
.flatten() .flatten()
@ -146,7 +149,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
} }
.sortedWith(Comparator { c1, c2 -> .sortedWith(Comparator { c1, c2 ->
val c = c2.chapter_number.compareTo(c1.chapter_number) val c = c2.chapter_number.compareTo(c1.chapter_number)
if (c == 0) comparator.compare(c2.name, c1.name) else c if (c == 0) CaseInsensitiveNaturalComparator.compare(c2.name, c1.name) else c
}) })
return Observable.just(chapters) return Observable.just(chapters)
@ -189,20 +192,19 @@ class LocalSource(private val context: Context) : CatalogueSource {
private fun updateCover(chapter: SChapter, manga: SManga): File? { private fun updateCover(chapter: SChapter, manga: SManga): File? {
val format = getFormat(chapter) val format = getFormat(chapter)
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return when (format) { return when (format) {
is Format.Directory -> { is Format.Directory -> {
val entry = format.file.listFiles() val entry = format.file.listFiles()
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) }) .sortedWith(Comparator<File> { f1, f2 -> CaseInsensitiveNaturalComparator.compare(f1.name, f2.name) })
.find { !it.isDirectory && ImageUtil.isImage(it.name, { FileInputStream(it) }) } .find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
entry?.let { updateCover(context, manga, it.inputStream())} entry?.let { updateCover(context, manga, it.inputStream())}
} }
is Format.Zip -> { is Format.Zip -> {
ZipFile(format.file).use { zip -> ZipFile(format.file).use { zip ->
val entry = zip.entries().toList() val entry = zip.entries().toList()
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) }) .sortedWith(Comparator<ZipEntry> { f1, f2 -> CaseInsensitiveNaturalComparator.compare(f1.name, f2.name) })
.find { !it.isDirectory && ImageUtil.isImage(it.name, { zip.getInputStream(it) }) } .find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
entry?.let { updateCover(context, manga, zip.getInputStream(it) )} entry?.let { updateCover(context, manga, zip.getInputStream(it) )}
} }
@ -210,8 +212,8 @@ class LocalSource(private val context: Context) : CatalogueSource {
is Format.Rar -> { is Format.Rar -> {
Archive(format.file).use { archive -> Archive(format.file).use { archive ->
val entry = archive.fileHeaders val entry = archive.fileHeaders
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) }) .sortedWith(Comparator<FileHeader> { f1, f2 -> CaseInsensitiveNaturalComparator.compare(f1.fileNameString, f2.fileNameString) })
.find { !it.isDirectory && ImageUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) } .find { !it.isDirectory && ImageUtil.isImage(it.fileNameString) { archive.getInputStream(it) } }
entry?.let { updateCover(context, manga, archive.getInputStream(it) )} entry?.let { updateCover(context, manga, archive.getInputStream(it) )}
} }

View File

@ -1,44 +1,44 @@
package eu.kanade.tachiyomi.source package eu.kanade.tachiyomi.source
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import rx.Observable import rx.Observable
/** /**
* A basic interface for creating a source. It could be an online source, a local source, etc... * A basic interface for creating a source. It could be an online source, a local source, etc...
*/ */
interface Source { interface Source {
/** /**
* Id for the source. Must be unique. * Id for the source. Must be unique.
*/ */
val id: Long val id: Long
/** /**
* Name of the source. * Name of the source.
*/ */
val name: String val name: String
/** /**
* Returns an observable with the updated details for a manga. * Returns an observable with the updated details for a manga.
* *
* @param manga the manga to update. * @param manga the manga to update.
*/ */
fun fetchMangaDetails(manga: SManga): Observable<SManga> fun fetchMangaDetails(manga: SManga): Observable<SManga>
/** /**
* Returns an observable with all the available chapters for a manga. * Returns an observable with all the available chapters for a manga.
* *
* @param manga the manga to update. * @param manga the manga to update.
*/ */
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> fun fetchChapterList(manga: SManga): Observable<List<SChapter>>
/** /**
* Returns an observable with the list of pages a chapter has. * Returns an observable with the list of pages a chapter has.
* *
* @param chapter the chapter. * @param chapter the chapter.
*/ */
fun fetchPageList(chapter: SChapter): Observable<List<Page>> fun fetchPageList(chapter: SChapter): Observable<List<Page>>
} }

View File

@ -1,89 +1,74 @@
package eu.kanade.tachiyomi.source package eu.kanade.tachiyomi.source
import android.content.Context import android.content.Context
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.english.* import rx.Observable
import eu.kanade.tachiyomi.source.online.german.WieManga
import eu.kanade.tachiyomi.source.online.russian.Mangachan open class SourceManager(private val context: Context) {
import eu.kanade.tachiyomi.source.online.russian.Mintmanga
import eu.kanade.tachiyomi.source.online.russian.Readmanga private val sourcesMap = mutableMapOf<Long, Source>()
import rx.Observable
private val stubSourcesMap = mutableMapOf<Long, StubSource>()
open class SourceManager(private val context: Context) {
init {
private val sourcesMap = mutableMapOf<Long, Source>() createInternalSources().forEach { registerSource(it) }
}
private val stubSourcesMap = mutableMapOf<Long, StubSource>()
open fun get(sourceKey: Long): Source? {
init { return sourcesMap[sourceKey]
createInternalSources().forEach { registerSource(it) } }
}
fun getOrStub(sourceKey: Long): Source {
open fun get(sourceKey: Long): Source? { return sourcesMap[sourceKey] ?: stubSourcesMap.getOrPut(sourceKey) {
return sourcesMap[sourceKey] StubSource(sourceKey)
} }
}
fun getOrStub(sourceKey: Long): Source {
return sourcesMap[sourceKey] ?: stubSourcesMap.getOrPut(sourceKey) { fun getOnlineSources() = sourcesMap.values.filterIsInstance<HttpSource>()
StubSource(sourceKey)
} fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>()
}
internal fun registerSource(source: Source, overwrite: Boolean = false) {
fun getOnlineSources() = sourcesMap.values.filterIsInstance<HttpSource>() if (overwrite || !sourcesMap.containsKey(source.id)) {
sourcesMap[source.id] = source
fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>() }
}
internal fun registerSource(source: Source, overwrite: Boolean = false) {
if (overwrite || !sourcesMap.containsKey(source.id)) { internal fun unregisterSource(source: Source) {
sourcesMap[source.id] = source sourcesMap.remove(source.id)
} }
}
private fun createInternalSources(): List<Source> = listOf(
internal fun unregisterSource(source: Source) { LocalSource(context)
sourcesMap.remove(source.id) )
}
private inner class StubSource(override val id: Long) : Source {
private fun createInternalSources(): List<Source> = listOf(
LocalSource(context), override val name: String
Batoto(), get() = id.toString()
Mangahere(),
Mangafox(), override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
Kissmanga(), return Observable.error(getSourceNotInstalledException())
Readmanga(), }
Mintmanga(),
Mangachan(), override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
Readmangatoday(), return Observable.error(getSourceNotInstalledException())
Mangasee(), }
WieManga()
) override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return Observable.error(getSourceNotInstalledException())
private inner class StubSource(override val id: Long) : Source { }
override val name: String override fun toString(): String {
get() = id.toString() return name
}
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return Observable.error(getSourceNotInstalledException()) private fun getSourceNotInstalledException(): Exception {
} return Exception(context.getString(R.string.source_not_installed, id.toString()))
}
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

@ -1,40 +1,40 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.source.model
sealed class Filter<T>(val name: String, var state: T) { sealed class Filter<T>(val name: String, var state: T) {
open class Header(name: String) : Filter<Any>(name, 0) open class Header(name: String) : Filter<Any>(name, 0)
open class Separator(name: String = "") : Filter<Any>(name, 0) open class Separator(name: String = "") : Filter<Any>(name, 0)
abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(name, state) abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(name, state)
abstract class Text(name: String, state: String = "") : Filter<String>(name, state) abstract class Text(name: String, state: String = "") : Filter<String>(name, state)
abstract class CheckBox(name: String, state: Boolean = false) : Filter<Boolean>(name, state) abstract class CheckBox(name: String, state: Boolean = false) : Filter<Boolean>(name, state)
abstract class TriState(name: String, state: Int = STATE_IGNORE) : Filter<Int>(name, state) { abstract class TriState(name: String, state: Int = STATE_IGNORE) : Filter<Int>(name, state) {
fun isIgnored() = state == STATE_IGNORE fun isIgnored() = state == STATE_IGNORE
fun isIncluded() = state == STATE_INCLUDE fun isIncluded() = state == STATE_INCLUDE
fun isExcluded() = state == STATE_EXCLUDE fun isExcluded() = state == STATE_EXCLUDE
companion object { companion object {
const val STATE_IGNORE = 0 const val STATE_IGNORE = 0
const val STATE_INCLUDE = 1 const val STATE_INCLUDE = 1
const val STATE_EXCLUDE = 2 const val STATE_EXCLUDE = 2
} }
} }
abstract class Group<V>(name: String, state: List<V>): Filter<List<V>>(name, state) abstract class Group<V>(name: String, state: List<V>): Filter<List<V>>(name, state)
abstract class Sort(name: String, val values: Array<String>, state: Selection? = null) abstract class Sort(name: String, val values: Array<String>, state: Selection? = null)
: Filter<Sort.Selection?>(name, state) { : Filter<Sort.Selection?>(name, state) {
data class Selection(val index: Int, val ascending: Boolean) data class Selection(val index: Int, val ascending: Boolean)
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (other !is Filter<*>) return false if (other !is Filter<*>) return false
return name == other.name && state == other.state return name == other.name && state == other.state
} }
override fun hashCode(): Int { override fun hashCode(): Int {
var result = name.hashCode() var result = name.hashCode()
result = 31 * result + (state?.hashCode() ?: 0) result = 31 * result + (state?.hashCode() ?: 0)
return result return result
} }
} }

View File

@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.source.model
data class FilterList(val list: List<Filter<*>>) : List<Filter<*>> by list { data class FilterList(val list: List<Filter<*>>) : List<Filter<*>> by list {
constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList()) constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList())
} }

View File

@ -1,3 +1,3 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.source.model
data class MangasPage(val mangas: List<SManga>, val hasNextPage: Boolean) data class MangasPage(val mangas: List<SManga>, val hasNextPage: Boolean)

View File

@ -1,48 +1,48 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.source.model
import android.net.Uri import android.net.Uri
import eu.kanade.tachiyomi.network.ProgressListener import eu.kanade.tachiyomi.network.ProgressListener
import rx.subjects.Subject import rx.subjects.Subject
open class Page( open class Page(
val index: Int, val index: Int,
val url: String = "", val url: String = "",
var imageUrl: String? = null, var imageUrl: String? = null,
@Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions @Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions
) : ProgressListener { ) : ProgressListener {
val number: Int val number: Int
get() = index + 1 get() = index + 1
@Transient @Volatile var status: Int = 0 @Transient @Volatile var status: Int = 0
set(value) { set(value) {
field = value field = value
statusSubject?.onNext(value) statusSubject?.onNext(value)
} }
@Transient @Volatile var progress: Int = 0 @Transient @Volatile var progress: Int = 0
@Transient private var statusSubject: Subject<Int, Int>? = null @Transient private var statusSubject: Subject<Int, Int>? = null
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
progress = if (contentLength > 0) { progress = if (contentLength > 0) {
(100 * bytesRead / contentLength).toInt() (100 * bytesRead / contentLength).toInt()
} else { } else {
-1 -1
} }
} }
fun setStatusSubject(subject: Subject<Int, Int>?) { fun setStatusSubject(subject: Subject<Int, Int>?) {
this.statusSubject = subject this.statusSubject = subject
} }
companion object { companion object {
const val QUEUE = 0 const val QUEUE = 0
const val LOAD_PAGE = 1 const val LOAD_PAGE = 1
const val DOWNLOAD_IMAGE = 2 const val DOWNLOAD_IMAGE = 2
const val READY = 3 const val READY = 3
const val ERROR = 4 const val ERROR = 4
} }
} }

View File

@ -1,31 +1,31 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.source.model
import java.io.Serializable import java.io.Serializable
interface SChapter : Serializable { interface SChapter : Serializable {
var url: String var url: String
var name: String var name: String
var date_upload: Long var date_upload: Long
var chapter_number: Float var chapter_number: Float
var scanlator: String? var scanlator: String?
fun copyFrom(other: SChapter) { fun copyFrom(other: SChapter) {
name = other.name name = other.name
url = other.url url = other.url
date_upload = other.date_upload date_upload = other.date_upload
chapter_number = other.chapter_number chapter_number = other.chapter_number
scanlator = other.scanlator scanlator = other.scanlator
} }
companion object { companion object {
fun create(): SChapter { fun create(): SChapter {
return SChapterImpl() return SChapterImpl()
} }
} }
} }

View File

@ -1,15 +1,15 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.source.model
class SChapterImpl : SChapter { class SChapterImpl : SChapter {
override lateinit var url: String override lateinit var url: String
override lateinit var name: String override lateinit var name: String
override var date_upload: Long = 0 override var date_upload: Long = 0
override var chapter_number: Float = -1f override var chapter_number: Float = -1f
override var scanlator: String? = null override var scanlator: String? = null
} }

View File

@ -1,58 +1,58 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.source.model
import java.io.Serializable import java.io.Serializable
interface SManga : Serializable { interface SManga : Serializable {
var url: String var url: String
var title: String var title: String
var artist: String? var artist: String?
var author: String? var author: String?
var description: String? var description: String?
var genre: String? var genre: String?
var status: Int var status: Int
var thumbnail_url: String? var thumbnail_url: String?
var initialized: Boolean var initialized: Boolean
fun copyFrom(other: SManga) { fun copyFrom(other: SManga) {
if (other.author != null) if (other.author != null)
author = other.author author = other.author
if (other.artist != null) if (other.artist != null)
artist = other.artist artist = other.artist
if (other.description != null) if (other.description != null)
description = other.description description = other.description
if (other.genre != null) if (other.genre != null)
genre = other.genre genre = other.genre
if (other.thumbnail_url != null) if (other.thumbnail_url != null)
thumbnail_url = other.thumbnail_url thumbnail_url = other.thumbnail_url
status = other.status status = other.status
if (!initialized) if (!initialized)
initialized = other.initialized initialized = other.initialized
} }
companion object { companion object {
const val UNKNOWN = 0 const val UNKNOWN = 0
const val ONGOING = 1 const val ONGOING = 1
const val COMPLETED = 2 const val COMPLETED = 2
const val LICENSED = 3 const val LICENSED = 3
fun create(): SManga { fun create(): SManga {
return SMangaImpl() return SMangaImpl()
} }
} }
} }

View File

@ -1,23 +1,23 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.source.model
class SMangaImpl : SManga { class SMangaImpl : SManga {
override lateinit var url: String override lateinit var url: String
override lateinit var title: String override lateinit var title: String
override var artist: String? = null override var artist: String? = null
override var author: String? = null override var author: String? = null
override var description: String? = null override var description: String? = null
override var genre: String? = null override var genre: String? = null
override var status: Int = 0 override var status: Int = 0
override var thumbnail_url: String? = null override var thumbnail_url: String? = null
override var initialized: Boolean = false override var initialized: Boolean = false
} }

View File

@ -1,367 +1,367 @@
package eu.kanade.tachiyomi.source.online package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.newCallWithProgress import eu.kanade.tachiyomi.network.newCallWithProgress
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.* import eu.kanade.tachiyomi.source.model.*
import okhttp3.Headers import okhttp3.Headers
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.lang.Exception import java.lang.Exception
import java.net.URI import java.net.URI
import java.net.URISyntaxException import java.net.URISyntaxException
import java.security.MessageDigest import java.security.MessageDigest
/** /**
* A simple implementation for sources from a website. * A simple implementation for sources from a website.
*/ */
abstract class HttpSource : CatalogueSource { abstract class HttpSource : CatalogueSource {
/** /**
* Network service. * Network service.
*/ */
protected val network: NetworkHelper by injectLazy() protected val network: NetworkHelper by injectLazy()
// /** // /**
// * Preferences that a source may need. // * Preferences that a source may need.
// */ // */
// val preferences: SharedPreferences by lazy { // val preferences: SharedPreferences by lazy {
// Injekt.get<Application>().getSharedPreferences("source_$id", Context.MODE_PRIVATE) // Injekt.get<Application>().getSharedPreferences("source_$id", Context.MODE_PRIVATE)
// } // }
/** /**
* Base url of the website without the trailing slash, like: http://mysite.com * Base url of the website without the trailing slash, like: http://mysite.com
*/ */
abstract val baseUrl: String abstract val baseUrl: String
/** /**
* Version id used to generate the source id. If the site completely changes and urls are * Version id used to generate the source id. If the site completely changes and urls are
* incompatible, you may increase this value and it'll be considered as a new source. * incompatible, you may increase this value and it'll be considered as a new source.
*/ */
open val versionId = 1 open val versionId = 1
/** /**
* Id of the source. By default it uses a generated id using the first 16 characters (64 bits) * Id of the source. By default it uses a generated id using the first 16 characters (64 bits)
* of the MD5 of the string: sourcename/language/versionId * of the MD5 of the string: sourcename/language/versionId
* Note the generated id sets the sign bit to 0. * Note the generated id sets the sign bit to 0.
*/ */
override val id by lazy { override val id by lazy {
val key = "${name.toLowerCase()}/$lang/$versionId" val key = "${name.toLowerCase()}/$lang/$versionId"
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
} }
/** /**
* Headers used for requests. * Headers used for requests.
*/ */
val headers: Headers by lazy { headersBuilder().build() } val headers: Headers by lazy { headersBuilder().build() }
/** /**
* Default network client for doing requests. * Default network client for doing requests.
*/ */
open val client: OkHttpClient open val client: OkHttpClient
get() = network.client get() = network.client
/** /**
* Headers builder for requests. Implementations can override this method for custom headers. * Headers builder for requests. Implementations can override this method for custom headers.
*/ */
open protected fun headersBuilder() = Headers.Builder().apply { open protected fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)") add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)")
} }
/** /**
* Visible name of the source. * Visible name of the source.
*/ */
override fun toString() = "$name (${lang.toUpperCase()})" override fun toString() = "$name (${lang.toUpperCase()})"
/** /**
* Returns an observable containing a page with a list of manga. Normally it's not needed to * Returns an observable containing a page with a list of manga. Normally it's not needed to
* override this method. * override this method.
* *
* @param page the page number to retrieve. * @param page the page number to retrieve.
*/ */
override fun fetchPopularManga(page: Int): Observable<MangasPage> { override fun fetchPopularManga(page: Int): Observable<MangasPage> {
return client.newCall(popularMangaRequest(page)) return client.newCall(popularMangaRequest(page))
.asObservableSuccess() .asObservableSuccess()
.map { response -> .map { response ->
popularMangaParse(response) popularMangaParse(response)
} }
} }
/** /**
* Returns the request for the popular manga given the page. * Returns the request for the popular manga given the page.
* *
* @param page the page number to retrieve. * @param page the page number to retrieve.
*/ */
abstract protected fun popularMangaRequest(page: Int): Request abstract protected fun popularMangaRequest(page: Int): Request
/** /**
* Parses the response from the site and returns a [MangasPage] object. * Parses the response from the site and returns a [MangasPage] object.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
abstract protected fun popularMangaParse(response: Response): MangasPage abstract protected fun popularMangaParse(response: Response): MangasPage
/** /**
* Returns an observable containing a page with a list of manga. Normally it's not needed to * Returns an observable containing a page with a list of manga. Normally it's not needed to
* override this method. * override this method.
* *
* @param page the page number to retrieve. * @param page the page number to retrieve.
* @param query the search query. * @param query the search query.
* @param filters the list of filters to apply. * @param filters the list of filters to apply.
*/ */
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> { override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return client.newCall(searchMangaRequest(page, query, filters)) return client.newCall(searchMangaRequest(page, query, filters))
.asObservableSuccess() .asObservableSuccess()
.map { response -> .map { response ->
searchMangaParse(response) searchMangaParse(response)
} }
} }
/** /**
* Returns the request for the search manga given the page. * Returns the request for the search manga given the page.
* *
* @param page the page number to retrieve. * @param page the page number to retrieve.
* @param query the search query. * @param query the search query.
* @param filters the list of filters to apply. * @param filters the list of filters to apply.
*/ */
abstract protected fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request abstract protected fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request
/** /**
* Parses the response from the site and returns a [MangasPage] object. * Parses the response from the site and returns a [MangasPage] object.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
abstract protected fun searchMangaParse(response: Response): MangasPage abstract protected fun searchMangaParse(response: Response): MangasPage
/** /**
* Returns an observable containing a page with a list of latest manga updates. * Returns an observable containing a page with a list of latest manga updates.
* *
* @param page the page number to retrieve. * @param page the page number to retrieve.
*/ */
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> { override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
return client.newCall(latestUpdatesRequest(page)) return client.newCall(latestUpdatesRequest(page))
.asObservableSuccess() .asObservableSuccess()
.map { response -> .map { response ->
latestUpdatesParse(response) latestUpdatesParse(response)
} }
} }
/** /**
* Returns the request for latest manga given the page. * Returns the request for latest manga given the page.
* *
* @param page the page number to retrieve. * @param page the page number to retrieve.
*/ */
abstract protected fun latestUpdatesRequest(page: Int): Request abstract protected fun latestUpdatesRequest(page: Int): Request
/** /**
* Parses the response from the site and returns a [MangasPage] object. * Parses the response from the site and returns a [MangasPage] object.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
abstract protected fun latestUpdatesParse(response: Response): MangasPage abstract protected fun latestUpdatesParse(response: Response): MangasPage
/** /**
* Returns an observable with the updated details for a manga. Normally it's not needed to * Returns an observable with the updated details for a manga. Normally it's not needed to
* override this method. * override this method.
* *
* @param manga the manga to be updated. * @param manga the manga to be updated.
*/ */
override fun fetchMangaDetails(manga: SManga): Observable<SManga> { override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(mangaDetailsRequest(manga)) return client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess() .asObservableSuccess()
.map { response -> .map { response ->
mangaDetailsParse(response).apply { initialized = true } mangaDetailsParse(response).apply { initialized = true }
} }
} }
/** /**
* Returns the request for the details of a manga. Override only if it's needed to change the * Returns the request for the details of a manga. Override only if it's needed to change the
* url, send different headers or request method like POST. * url, send different headers or request method like POST.
* *
* @param manga the manga to be updated. * @param manga the manga to be updated.
*/ */
open fun mangaDetailsRequest(manga: SManga): Request { open fun mangaDetailsRequest(manga: SManga): Request {
return GET(baseUrl + manga.url, headers) return GET(baseUrl + manga.url, headers)
} }
/** /**
* Parses the response from the site and returns the details of a manga. * Parses the response from the site and returns the details of a manga.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
abstract protected fun mangaDetailsParse(response: Response): SManga abstract protected fun mangaDetailsParse(response: Response): SManga
/** /**
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to * Returns an observable with the updated chapter list for a manga. Normally it's not needed to
* override this method. If a manga is licensed an empty chapter list observable is returned * override this method. If a manga is licensed an empty chapter list observable is returned
* *
* @param manga the manga to look for chapters. * @param manga the manga to look for chapters.
*/ */
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> { override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
if (manga.status != SManga.LICENSED) { if (manga.status != SManga.LICENSED) {
return client.newCall(chapterListRequest(manga)) return client.newCall(chapterListRequest(manga))
.asObservableSuccess() .asObservableSuccess()
.map { response -> .map { response ->
chapterListParse(response) chapterListParse(response)
} }
} else { } else {
return Observable.error(Exception("Licensed - No chapters to show")) return Observable.error(Exception("Licensed - No chapters to show"))
} }
} }
/** /**
* Returns the request for updating the chapter list. Override only if it's needed to override * Returns the request for updating the chapter list. Override only if it's needed to override
* the url, send different headers or request method like POST. * the url, send different headers or request method like POST.
* *
* @param manga the manga to look for chapters. * @param manga the manga to look for chapters.
*/ */
open protected fun chapterListRequest(manga: SManga): Request { open protected fun chapterListRequest(manga: SManga): Request {
return GET(baseUrl + manga.url, headers) return GET(baseUrl + manga.url, headers)
} }
/** /**
* Parses the response from the site and returns a list of chapters. * Parses the response from the site and returns a list of chapters.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
abstract protected fun chapterListParse(response: Response): List<SChapter> abstract protected fun chapterListParse(response: Response): List<SChapter>
/** /**
* Returns an observable with the page list for a chapter. * Returns an observable with the page list for a chapter.
* *
* @param chapter the chapter whose page list has to be fetched. * @param chapter the chapter whose page list has to be fetched.
*/ */
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> { override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return client.newCall(pageListRequest(chapter)) return client.newCall(pageListRequest(chapter))
.asObservableSuccess() .asObservableSuccess()
.map { response -> .map { response ->
pageListParse(response) pageListParse(response)
} }
} }
/** /**
* Returns the request for getting the page list. Override only if it's needed to override the * Returns the request for getting the page list. Override only if it's needed to override the
* url, send different headers or request method like POST. * url, send different headers or request method like POST.
* *
* @param chapter the chapter whose page list has to be fetched. * @param chapter the chapter whose page list has to be fetched.
*/ */
open protected fun pageListRequest(chapter: SChapter): Request { open protected fun pageListRequest(chapter: SChapter): Request {
return GET(baseUrl + chapter.url, headers) return GET(baseUrl + chapter.url, headers)
} }
/** /**
* Parses the response from the site and returns a list of pages. * Parses the response from the site and returns a list of pages.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
abstract protected fun pageListParse(response: Response): List<Page> abstract protected fun pageListParse(response: Response): List<Page>
/** /**
* Returns an observable with the page containing the source url of the image. If there's any * Returns an observable with the page containing the source url of the image. If there's any
* error, it will return null instead of throwing an exception. * error, it will return null instead of throwing an exception.
* *
* @param page the page whose source image has to be fetched. * @param page the page whose source image has to be fetched.
*/ */
open fun fetchImageUrl(page: Page): Observable<String> { open fun fetchImageUrl(page: Page): Observable<String> {
return client.newCall(imageUrlRequest(page)) return client.newCall(imageUrlRequest(page))
.asObservableSuccess() .asObservableSuccess()
.map { imageUrlParse(it) } .map { imageUrlParse(it) }
} }
/** /**
* Returns the request for getting the url to the source image. Override only if it's needed to * Returns the request for getting the url to the source image. Override only if it's needed to
* override the url, send different headers or request method like POST. * override the url, send different headers or request method like POST.
* *
* @param page the chapter whose page list has to be fetched * @param page the chapter whose page list has to be fetched
*/ */
open protected fun imageUrlRequest(page: Page): Request { open protected fun imageUrlRequest(page: Page): Request {
return GET(page.url, headers) return GET(page.url, headers)
} }
/** /**
* Parses the response from the site and returns the absolute url to the source image. * Parses the response from the site and returns the absolute url to the source image.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
abstract protected fun imageUrlParse(response: Response): String abstract protected fun imageUrlParse(response: Response): String
/** /**
* Returns an observable with the response of the source image. * Returns an observable with the response of the source image.
* *
* @param page the page whose source image has to be downloaded. * @param page the page whose source image has to be downloaded.
*/ */
fun fetchImage(page: Page): Observable<Response> { fun fetchImage(page: Page): Observable<Response> {
return client.newCallWithProgress(imageRequest(page), page) return client.newCallWithProgress(imageRequest(page), page)
.asObservableSuccess() .asObservableSuccess()
} }
/** /**
* Returns the request for getting the source image. Override only if it's needed to override * Returns the request for getting the source image. Override only if it's needed to override
* the url, send different headers or request method like POST. * the url, send different headers or request method like POST.
* *
* @param page the chapter whose page list has to be fetched * @param page the chapter whose page list has to be fetched
*/ */
open protected fun imageRequest(page: Page): Request { open protected fun imageRequest(page: Page): Request {
return GET(page.imageUrl!!, headers) return GET(page.imageUrl!!, headers)
} }
/** /**
* Assigns the url of the chapter without the scheme and domain. It saves some redundancy from * Assigns the url of the chapter without the scheme and domain. It saves some redundancy from
* database and the urls could still work after a domain change. * database and the urls could still work after a domain change.
* *
* @param url the full url to the chapter. * @param url the full url to the chapter.
*/ */
fun SChapter.setUrlWithoutDomain(url: String) { fun SChapter.setUrlWithoutDomain(url: String) {
this.url = getUrlWithoutDomain(url) this.url = getUrlWithoutDomain(url)
} }
/** /**
* Assigns the url of the manga without the scheme and domain. It saves some redundancy from * Assigns the url of the manga without the scheme and domain. It saves some redundancy from
* database and the urls could still work after a domain change. * database and the urls could still work after a domain change.
* *
* @param url the full url to the manga. * @param url the full url to the manga.
*/ */
fun SManga.setUrlWithoutDomain(url: String) { fun SManga.setUrlWithoutDomain(url: String) {
this.url = getUrlWithoutDomain(url) this.url = getUrlWithoutDomain(url)
} }
/** /**
* Returns the url of the given string without the scheme and domain. * Returns the url of the given string without the scheme and domain.
* *
* @param orig the full url. * @param orig the full url.
*/ */
private fun getUrlWithoutDomain(orig: String): String { private fun getUrlWithoutDomain(orig: String): String {
try { try {
val uri = URI(orig) val uri = URI(orig)
var out = uri.path var out = uri.path
if (uri.query != null) if (uri.query != null)
out += "?" + uri.query out += "?" + uri.query
if (uri.fragment != null) if (uri.fragment != null)
out += "#" + uri.fragment out += "#" + uri.fragment
return out return out
} catch (e: URISyntaxException) { } catch (e: URISyntaxException) {
return orig return orig
} }
} }
/** /**
* Called before inserting a new chapter into database. Use it if you need to override chapter * Called before inserting a new chapter into database. Use it if you need to override chapter
* fields, like the title or the chapter number. Do not change anything to [manga]. * fields, like the title or the chapter number. Do not change anything to [manga].
* *
* @param chapter the chapter to be added. * @param chapter the chapter to be added.
* @param manga the manga of the chapter. * @param manga the manga of the chapter.
*/ */
open fun prepareNewChapter(chapter: SChapter, manga: SManga) { open fun prepareNewChapter(chapter: SChapter, manga: SManga) {
} }
/** /**
* Returns the list of filters for the source. * Returns the list of filters for the source.
*/ */
override fun getFilterList() = FilterList() override fun getFilterList() = FilterList()
} }

View File

@ -1,25 +1,25 @@
package eu.kanade.tachiyomi.source.online package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import rx.Observable import rx.Observable
fun HttpSource.getImageUrl(page: Page): Observable<Page> { fun HttpSource.getImageUrl(page: Page): Observable<Page> {
page.status = Page.LOAD_PAGE page.status = Page.LOAD_PAGE
return fetchImageUrl(page) return fetchImageUrl(page)
.doOnError { page.status = Page.ERROR } .doOnError { page.status = Page.ERROR }
.onErrorReturn { null } .onErrorReturn { null }
.doOnNext { page.imageUrl = it } .doOnNext { page.imageUrl = it }
.map { page } .map { page }
} }
fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> { fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
return Observable.from(pages) return Observable.from(pages)
.filter { !it.imageUrl.isNullOrEmpty() } .filter { !it.imageUrl.isNullOrEmpty() }
.mergeWith(fetchRemainingImageUrlsFromPageList(pages)) .mergeWith(fetchRemainingImageUrlsFromPageList(pages))
} }
fun HttpSource.fetchRemainingImageUrlsFromPageList(pages: List<Page>): Observable<Page> { fun HttpSource.fetchRemainingImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
return Observable.from(pages) return Observable.from(pages)
.filter { it.imageUrl.isNullOrEmpty() } .filter { it.imageUrl.isNullOrEmpty() }
.concatMap { getImageUrl(it) } .concatMap { getImageUrl(it) }
} }

View File

@ -1,15 +1,15 @@
package eu.kanade.tachiyomi.source.online package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import okhttp3.Response import okhttp3.Response
import rx.Observable import rx.Observable
interface LoginSource : Source { interface LoginSource : Source {
fun isLogged(): Boolean fun isLogged(): Boolean
fun login(username: String, password: String): Observable<Boolean> fun login(username: String, password: String): Observable<Boolean>
fun isAuthenticationSuccessful(response: Response): Boolean fun isAuthenticationSuccessful(response: Response): Boolean
} }

View File

@ -1,200 +1,200 @@
package eu.kanade.tachiyomi.source.online package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.asJsoup import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Response import okhttp3.Response
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
/** /**
* A simple implementation for sources from a website using Jsoup, an HTML parser. * A simple implementation for sources from a website using Jsoup, an HTML parser.
*/ */
abstract class ParsedHttpSource : HttpSource() { abstract class ParsedHttpSource : HttpSource() {
/** /**
* Parses the response from the site and returns a [MangasPage] object. * Parses the response from the site and returns a [MangasPage] object.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
override fun popularMangaParse(response: Response): MangasPage { override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup() val document = response.asJsoup()
val mangas = document.select(popularMangaSelector()).map { element -> val mangas = document.select(popularMangaSelector()).map { element ->
popularMangaFromElement(element) popularMangaFromElement(element)
} }
val hasNextPage = popularMangaNextPageSelector()?.let { selector -> val hasNextPage = popularMangaNextPageSelector()?.let { selector ->
document.select(selector).first() document.select(selector).first()
} != null } != null
return MangasPage(mangas, hasNextPage) return MangasPage(mangas, hasNextPage)
} }
/** /**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
*/ */
abstract protected fun popularMangaSelector(): String abstract protected fun popularMangaSelector(): String
/** /**
* Returns a manga from the given [element]. Most sites only show the title and the url, it's * Returns a manga from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values. * totally fine to fill only those two values.
* *
* @param element an element obtained from [popularMangaSelector]. * @param element an element obtained from [popularMangaSelector].
*/ */
abstract protected fun popularMangaFromElement(element: Element): SManga abstract protected fun popularMangaFromElement(element: Element): SManga
/** /**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if * Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page. * there's no next page.
*/ */
abstract protected fun popularMangaNextPageSelector(): String? abstract protected fun popularMangaNextPageSelector(): String?
/** /**
* Parses the response from the site and returns a [MangasPage] object. * Parses the response from the site and returns a [MangasPage] object.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
override fun searchMangaParse(response: Response): MangasPage { override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup() val document = response.asJsoup()
val mangas = document.select(searchMangaSelector()).map { element -> val mangas = document.select(searchMangaSelector()).map { element ->
searchMangaFromElement(element) searchMangaFromElement(element)
} }
val hasNextPage = searchMangaNextPageSelector()?.let { selector -> val hasNextPage = searchMangaNextPageSelector()?.let { selector ->
document.select(selector).first() document.select(selector).first()
} != null } != null
return MangasPage(mangas, hasNextPage) return MangasPage(mangas, hasNextPage)
} }
/** /**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
*/ */
abstract protected fun searchMangaSelector(): String abstract protected fun searchMangaSelector(): String
/** /**
* Returns a manga from the given [element]. Most sites only show the title and the url, it's * Returns a manga from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values. * totally fine to fill only those two values.
* *
* @param element an element obtained from [searchMangaSelector]. * @param element an element obtained from [searchMangaSelector].
*/ */
abstract protected fun searchMangaFromElement(element: Element): SManga abstract protected fun searchMangaFromElement(element: Element): SManga
/** /**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if * Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page. * there's no next page.
*/ */
abstract protected fun searchMangaNextPageSelector(): String? abstract protected fun searchMangaNextPageSelector(): String?
/** /**
* Parses the response from the site and returns a [MangasPage] object. * Parses the response from the site and returns a [MangasPage] object.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
override fun latestUpdatesParse(response: Response): MangasPage { override fun latestUpdatesParse(response: Response): MangasPage {
val document = response.asJsoup() val document = response.asJsoup()
val mangas = document.select(latestUpdatesSelector()).map { element -> val mangas = document.select(latestUpdatesSelector()).map { element ->
latestUpdatesFromElement(element) latestUpdatesFromElement(element)
} }
val hasNextPage = latestUpdatesNextPageSelector()?.let { selector -> val hasNextPage = latestUpdatesNextPageSelector()?.let { selector ->
document.select(selector).first() document.select(selector).first()
} != null } != null
return MangasPage(mangas, hasNextPage) return MangasPage(mangas, hasNextPage)
} }
/** /**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
*/ */
abstract protected fun latestUpdatesSelector(): String abstract protected fun latestUpdatesSelector(): String
/** /**
* Returns a manga from the given [element]. Most sites only show the title and the url, it's * Returns a manga from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values. * totally fine to fill only those two values.
* *
* @param element an element obtained from [latestUpdatesSelector]. * @param element an element obtained from [latestUpdatesSelector].
*/ */
abstract protected fun latestUpdatesFromElement(element: Element): SManga abstract protected fun latestUpdatesFromElement(element: Element): SManga
/** /**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if * Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page. * there's no next page.
*/ */
abstract protected fun latestUpdatesNextPageSelector(): String? abstract protected fun latestUpdatesNextPageSelector(): String?
/** /**
* Parses the response from the site and returns the details of a manga. * Parses the response from the site and returns the details of a manga.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
override fun mangaDetailsParse(response: Response): SManga { override fun mangaDetailsParse(response: Response): SManga {
return mangaDetailsParse(response.asJsoup()) return mangaDetailsParse(response.asJsoup())
} }
/** /**
* Returns the details of the manga from the given [document]. * Returns the details of the manga from the given [document].
* *
* @param document the parsed document. * @param document the parsed document.
*/ */
abstract protected fun mangaDetailsParse(document: Document): SManga abstract protected fun mangaDetailsParse(document: Document): SManga
/** /**
* Parses the response from the site and returns a list of chapters. * Parses the response from the site and returns a list of chapters.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
override fun chapterListParse(response: Response): List<SChapter> { override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup() val document = response.asJsoup()
return document.select(chapterListSelector()).map { chapterFromElement(it) } return document.select(chapterListSelector()).map { chapterFromElement(it) }
} }
/** /**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter. * Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter.
*/ */
abstract protected fun chapterListSelector(): String abstract protected fun chapterListSelector(): String
/** /**
* Returns a chapter from the given element. * Returns a chapter from the given element.
* *
* @param element an element obtained from [chapterListSelector]. * @param element an element obtained from [chapterListSelector].
*/ */
abstract protected fun chapterFromElement(element: Element): SChapter abstract protected fun chapterFromElement(element: Element): SChapter
/** /**
* Parses the response from the site and returns the page list. * Parses the response from the site and returns the page list.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
override fun pageListParse(response: Response): List<Page> { override fun pageListParse(response: Response): List<Page> {
return pageListParse(response.asJsoup()) return pageListParse(response.asJsoup())
} }
/** /**
* Returns a page list from the given document. * Returns a page list from the given document.
* *
* @param document the parsed document. * @param document the parsed document.
*/ */
abstract protected fun pageListParse(document: Document): List<Page> abstract protected fun pageListParse(document: Document): List<Page>
/** /**
* Parse the response from the site and returns the absolute url to the source image. * Parse the response from the site and returns the absolute url to the source image.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
override fun imageUrlParse(response: Response): String { override fun imageUrlParse(response: Response): String {
return imageUrlParse(response.asJsoup()) return imageUrlParse(response.asJsoup())
} }
/** /**
* Returns the absolute url to the source image from the document. * Returns the absolute url to the source image from the document.
* *
* @param document the parsed document. * @param document the parsed document.
*/ */
abstract protected fun imageUrlParse(document: Document): String abstract protected fun imageUrlParse(document: Document): String
} }

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