Compare commits

...

357 Commits

Author SHA1 Message Date
0dc4862d79 Revert case insensitive source folder check 2021-04-11 18:19:41 -04:00
a3f1b72126 Lint fixes/ignore some errors 2021-04-11 18:16:15 -04:00
5ff10799e4 Release 0.10.10 2021-04-11 17:59:07 -04:00
a82e5f5452 Make library update/backup error log action clearer for non-technical users 2021-04-11 16:19:56 -04:00
e10cb0e632 Add locales: jv, lt, ne 2021-04-11 16:03:03 -04:00
c7e07a6df0 Update DoH translations 2021-04-11 16:02:02 -04:00
2e0c778090 Weblate translations (#4647)
Co-authored-by: Ajeje Brazorf <lmelonimamo@yahoo.it>
Co-authored-by: Alessandro Jean <alessandrojean@gmail.com>
Co-authored-by: Arlangue <virgilemp@outlook.fr>
Co-authored-by: August Wale <bleachlithium@gmail.com>
Co-authored-by: Ava <Sasu.ruotsalainen@live.fi>
Co-authored-by: C201 <derasetad@gmail.com>
Co-authored-by: DarKCroX <darkcrox.2020@outlook.com>
Co-authored-by: Edgar Mejía <edgar13155@gmail.com>
Co-authored-by: Eduard Ereza Martínez <eduard@ereza.cat>
Co-authored-by: Eric <spice2wolf@gmail.com>
Co-authored-by: FateXBlood <zecrofelix@gmail.com>
Co-authored-by: Flamm <robindevaux25@gmail.com>
Co-authored-by: Hajba Károly <karoly.hajba98@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Huang Zhiyi <hzy980512@126.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Jakub Fabijan <animatorzPolski@gmail.com>
Co-authored-by: Kiroki Benjamin <heptahex999@gmail.com>
Co-authored-by: Kurocon <weblate@kurocon.nl>
Co-authored-by: LOKE__01 <luckylakshman378@gmail.com>
Co-authored-by: Luis Andrés Bajaña F <labfernandez2014@gmail.com>
Co-authored-by: Lusuho <jevpsychox@gmail.com>
Co-authored-by: Lyfja <yassinelaoud@gmail.com>
Co-authored-by: Marco Santos <enum.scima@gmail.com>
Co-authored-by: Matteo Gaeta <matteo.gaeta.1998@gmail.com>
Co-authored-by: Matyáš Caras <contact@hernikplays.cz>
Co-authored-by: Matyáš Caras <hernik27@gmail.com>
Co-authored-by: Michalis <michalisntovas@yahoo.gr>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Nikola Perović <nikolaperovicccc@gmail.com>
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
Co-authored-by: P6N7L <nichitapospai@gmail.com>
Co-authored-by: Paulo Pinho <kebrus@gmail.com>
Co-authored-by: Pijus Bend <pijus.bend@gmail.com>
Co-authored-by: Pitpe11 <giorgos2550@gmail.com>
Co-authored-by: Q farfayoux <aym.belrhiti@gmail.com>
Co-authored-by: Riztard Lanthorn <riyanluqman@gmail.com>
Co-authored-by: Rostyslav <info@ubilling.net.ua>
Co-authored-by: Schrödinger's cat <schrodingers-kate@protonmail.com>
Co-authored-by: Shashank Pujari <shashankppujari@gmail.com>
Co-authored-by: Shjosan <shjosan@kakmix.co>
Co-authored-by: Tantia <ilovechocobi@yahoo.com>
Co-authored-by: TheLastMelody <swordofthefallen@hotmail.com>
Co-authored-by: Tooster <max@polarczyk.pl>
Co-authored-by: Yardi van Nimwegen <yardivn@live.nl>
Co-authored-by: antocs <roxasthethund@yahoo.it>
Co-authored-by: Роман <Rozhenkov69@gmail.com>
Co-authored-by: 殺Mustafa <mustafasheref8@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ar/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ca/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/cs/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/cv/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/de/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/el/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/eo/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/es/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/es_419/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fil/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hi/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hu/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/id/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/it/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ja/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/jv/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/kn/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/lt/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/lv/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ms/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/my/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ne/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/nl/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ro/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sc/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sv/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/tr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/uk/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/zh_Hans/
Translation: Tachiyomi/Tachiyomi 0.x

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Ajeje Brazorf <lmelonimamo@yahoo.it>
Co-authored-by: Alessandro Jean <alessandrojean@gmail.com>
Co-authored-by: Arlangue <virgilemp@outlook.fr>
Co-authored-by: August Wale <bleachlithium@gmail.com>
Co-authored-by: Ava <Sasu.ruotsalainen@live.fi>
Co-authored-by: C201 <derasetad@gmail.com>
Co-authored-by: DarKCroX <darkcrox.2020@outlook.com>
Co-authored-by: Edgar Mejía <edgar13155@gmail.com>
Co-authored-by: Eduard Ereza Martínez <eduard@ereza.cat>
Co-authored-by: Eric <spice2wolf@gmail.com>
Co-authored-by: FateXBlood <zecrofelix@gmail.com>
Co-authored-by: Flamm <robindevaux25@gmail.com>
Co-authored-by: Hajba Károly <karoly.hajba98@gmail.com>
Co-authored-by: Huang Zhiyi <hzy980512@126.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Jakub Fabijan <animatorzPolski@gmail.com>
Co-authored-by: Kiroki Benjamin <heptahex999@gmail.com>
Co-authored-by: Kurocon <weblate@kurocon.nl>
Co-authored-by: LOKE__01 <luckylakshman378@gmail.com>
Co-authored-by: Luis Andrés Bajaña F <labfernandez2014@gmail.com>
Co-authored-by: Lusuho <jevpsychox@gmail.com>
Co-authored-by: Lyfja <yassinelaoud@gmail.com>
Co-authored-by: Marco Santos <enum.scima@gmail.com>
Co-authored-by: Matteo Gaeta <matteo.gaeta.1998@gmail.com>
Co-authored-by: Matyáš Caras <contact@hernikplays.cz>
Co-authored-by: Matyáš Caras <hernik27@gmail.com>
Co-authored-by: Michalis <michalisntovas@yahoo.gr>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Nikola Perović <nikolaperovicccc@gmail.com>
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
Co-authored-by: P6N7L <nichitapospai@gmail.com>
Co-authored-by: Paulo Pinho <kebrus@gmail.com>
Co-authored-by: Pijus Bend <pijus.bend@gmail.com>
Co-authored-by: Pitpe11 <giorgos2550@gmail.com>
Co-authored-by: Q farfayoux <aym.belrhiti@gmail.com>
Co-authored-by: Riztard Lanthorn <riyanluqman@gmail.com>
Co-authored-by: Rostyslav <info@ubilling.net.ua>
Co-authored-by: Schrödinger's cat <schrodingers-kate@protonmail.com>
Co-authored-by: Shashank Pujari <shashankppujari@gmail.com>
Co-authored-by: Shjosan <shjosan@kakmix.co>
Co-authored-by: Tantia <ilovechocobi@yahoo.com>
Co-authored-by: TheLastMelody <swordofthefallen@hotmail.com>
Co-authored-by: Tooster <max@polarczyk.pl>
Co-authored-by: Yardi van Nimwegen <yardivn@live.nl>
Co-authored-by: antocs <roxasthethund@yahoo.it>
Co-authored-by: Роман <Rozhenkov69@gmail.com>
Co-authored-by: 殺Mustafa <mustafasheref8@gmail.com>
2021-04-11 15:56:32 -04:00
592050c668 Actually ignore the case... 2021-04-11 14:23:24 -04:00
02c9191525 Make source download folder name case-insensitive
Fixes issues from things like "Mangasee" being renamed to "MangaSee"
2021-04-11 14:03:23 -04:00
d421401626 Log "Invalid download location" issues to error log 2021-04-11 14:00:45 -04:00
b2d4e5ab84 Add Google DoH provider 2021-04-11 13:10:03 -04:00
84e023607c BrowseSourceController: Fix navigation bar insets not properly applied (#4810) 2021-04-11 10:49:23 -04:00
f145fd0dec Move deletion actions to the IO thread (#4808) 2021-04-11 10:49:13 -04:00
42a9f911d8 Update some dependencies; downgrade core-ktx
Fixes ActionMode being underneath statusbar
2021-04-10 22:29:52 -04:00
9567d55312 Revert manga title folder for saved pages (closes #4803)
People also didn't like it making their galleries more complicate to navigate.
2021-04-10 14:33:14 -04:00
531cd99247 Update to Gradle 7 2021-04-10 09:48:30 -04:00
f3660d88dd Draw edge-to-edge (#4802) 2021-04-10 09:38:55 -04:00
3accb9a08b [SKIP CI] Add lock workflow 2021-04-10 09:36:01 -04:00
63ce7371bb Update some internal dependencies
They no longer rely on jcenter
2021-04-09 22:44:59 -04:00
01c3498dbf Search in library include manga description (#4787)
Co-Authored-By: jobobby04 <jobobby04@gmail.com>

Co-authored-by: jobobby04 <jobobby04@gmail.com>
2021-04-08 17:56:33 -04:00
b3471234ad Update NDK, more KTX usage (#4792)
* Update NDK

* Utilize more KTX extensions
2021-04-08 17:56:01 -04:00
b2d697131c Add clarification for category exclusion (closes #4777) 2021-04-06 23:29:46 -04:00
ef49fc91d8 Minor cleanup 2021-04-06 23:21:21 -04:00
6222b47a4f Flip crop borders and orientation toggles 2021-04-06 22:31:36 -04:00
f58e3c390a Update Kotlin and Kotlinter 2021-04-06 22:29:56 -04:00
7504621a24 Make reader spinner colors a bit more consistent 2021-04-06 22:29:39 -04:00
88e49a9b8b Align filter spinners (closes #2995) 2021-04-06 11:21:39 -04:00
5b23f29d06 Revert using fetch date for updates list
Spamming the list post-migration is currently a more common usecase than sources without chapter dates. We'll need to figure out a better way of handling both scenarios.
2021-04-04 18:11:11 -04:00
c1bdebee78 Fix global update category exclusion 2021-04-04 18:09:07 -04:00
ddd4cc10ff add sort by date fetched in library (#4773)
* add sort by date fetched in library

* chapter fetch date to 8
2021-04-04 17:18:28 -04:00
0ca62a4acc Allow excluding categories from auto-download
Closes #1412

Supersedes #4121
2021-04-04 17:15:06 -04:00
4f1275ac01 Allow excluding categories from library update
Closes #3467, #4661, #1839

Supersedes #4474
2021-04-04 16:48:39 -04:00
b2fee7035f Use Material Dialogs for auto-download categories preference
To allow for negative selections in the future.
2021-04-03 16:13:12 -04:00
e15d7cb548 Use Material Dialogs for global update categories preference
To allow for negative selections in the future.
2021-04-03 16:07:42 -04:00
3257cbe21f Fix label overflow for reader spinner preferences 2021-04-03 15:46:07 -04:00
1237af1ff3 Move BiometricUtil to correct package 2021-04-03 11:38:01 -04:00
68600b337e Allow weaker unlock methods (closes #4265) 2021-04-03 11:35:33 -04:00
dac2072eaa Use app name for page download folder and use manga title subfolders (closes #4684) 2021-04-03 10:40:35 -04:00
1b921f9845 Make extension load error logs less verbose 2021-04-03 10:27:40 -04:00
a3992d9fbe Minor cleanup 2021-04-03 10:12:31 -04:00
efd2a0cb7b Replace reading mode snackbar with toast (#4752) 2021-04-03 10:07:49 -04:00
fba428257b Remove weird cropping from icon when showing missing chapter warning (#4769) 2021-04-03 10:00:23 -04:00
ff36901007 Don't repeatedly vibrate/make sounds on download progress 2021-04-01 12:18:54 -04:00
940d8389b5 Add QuadStateCheckBox view 2021-03-31 23:03:42 -04:00
f7a6cbe5e2 Revert "Drop support for Android 5.x"
This reverts commit 443024cebb. Guess I'll do this a bit later so scb can get another major update first.

April Fools or whatever.
2021-03-31 22:20:59 -04:00
7aa379a857 Better handle webtoon SSIV crop border change 2021-03-31 22:20:17 -04:00
443024cebb Drop support for Android 5.x
It's 5-6 years old, and only accounts for 2% of users in the Firebase analytics.
2021-03-30 23:15:17 -04:00
1657f04d55 Add tooltips for previous/next chapter buttons
Based on d0738f5b00
2021-03-30 23:11:36 -04:00
407e798fdb Recreate webtoon SSIV when crop borders setting changes (fixes #4734) 2021-03-30 18:47:44 -04:00
4054f2a6a0 Add icon for crop border shortcut off state 2021-03-30 18:27:09 -04:00
468cdf603c Allow translating DNS over HTTPS (closes #4747) 2021-03-30 17:54:31 -04:00
988ec6a224 Fix nav overlay always showing on start (fixes #4736) 2021-03-29 16:54:32 -04:00
bdbdf211e2 Remove insert page when dual page split get turned off (#4739) 2021-03-29 16:54:20 -04:00
0437703cbf Fix binding of intarray preferences (maybe fixes #4728) 2021-03-28 17:06:56 -04:00
71aa592111 Use regular crop icon 2021-03-28 17:06:07 -04:00
d501c02f8b Add crop borders shortcut 2021-03-28 16:25:53 -04:00
9daf0e78b8 Remove ALPHA from dual page split label 2021-03-28 16:15:11 -04:00
dfa07a5f35 Clean up SpinnerPreference a bit 2021-03-28 16:13:59 -04:00
437c995d12 Show nav overlay on invert tap change
Based on db4eca90e9
2021-03-28 16:13:34 -04:00
cc6ae9d1a8 Fix Some Bangumi Track Bug (#4726) 2021-03-28 11:36:29 -04:00
c58e4f4dee Prevent manga title from jumping (fixes #4709) 2021-03-28 11:20:19 -04:00
c87b0e77de Show number of manga per source in migrate menu (#4703) 2021-03-28 11:11:19 -04:00
355d5af8ae Dismiss action toolbar after download action in updates (closes #4729) 2021-03-28 10:59:35 -04:00
3d99a8ebdb Fix fullscreen not applying on opening reader (fixes #4723) 2021-03-28 10:48:41 -04:00
c4b975b777 Cleanup reader spinner layouts 2021-03-27 17:59:52 -04:00
2911fe7a1a Add onPause\onResume persistence to searchView. Fixes issue #3627 (#4494)
* Add onPause\onResume persistence to searchView. Fixes issue #3627

* New controller subclass with built-in SearchView support

* Implement new SearchableNucleusController in SourceController

* Add query to BasePresenter (for one field it is not worth create a subclass in my opinion), convert BrowseSourceController to inherit from SearchableNucleusController

* move to flows to fix an issue in GlobalSearch where it would trigger the search multiple times

* Continue conversion to SearchableNucleusController

* Convert LibraryController, convert to flows, Known ISSUE with empty string being posted after setting the query upon creation of UI

* Fix issues with the post being tide to the SearchView queue which is not processed until shown. Add COLLAPSING state capture which should wrap this up.

* refactoring & enforce @StringRes for queryHint
2021-03-27 16:38:41 -04:00
14c114756d Clean up reader sheet spinner preferences
Based on fe2543b9d5

Co-Authored-By: Jays2Kings
2021-03-27 16:28:49 -04:00
e7a8107279 Reduce height of sheet when on color filter tab 2021-03-27 15:15:31 -04:00
bff73b1b40 Add tooltips to bottom reader menu items 2021-03-27 10:53:31 -04:00
c255f57d95 Reorganize reader sheet contents a bit 2021-03-27 10:53:19 -04:00
64c47bbaed Split general and reading mode sheet settings 2021-03-26 22:31:21 -04:00
e0b7698d40 Merge reader settings and color filter sheets
Heavily influenced by fe2543b9d5 (diff-8f47d7b7b53769ac18c28fe9978140c6bef44709879567acab2c6ef3270cd3a8)
2021-03-25 23:10:22 -04:00
a01792ac9a Maybe make opening file picker for choosing backup file more reliable 2021-03-25 13:56:39 -04:00
3ba078f64c Use more common MIME type for protobuf 2021-03-25 13:46:53 -04:00
a16240f123 Show unread entries first when sorting by unread (closes #4711)
Based on b212f8233e
2021-03-24 09:27:00 -04:00
e5a120e778 Update plugins 2021-03-24 09:26:27 -04:00
2ba60e9114 Added Start/Finished Date Support to AniList
Based on 1e3de8a67f

Co-Authored-By: Jays2Kings
2021-03-22 22:38:14 -04:00
472ce5a5e4 Fix migration due to variable shadowing (#4689) 2021-03-21 19:47:17 -04:00
99ba84c810 Handle null Anilist start dates (fixes #4685) 2021-03-20 16:36:31 -04:00
78285bdf37 Minor code cleanup 2021-03-20 15:58:54 -04:00
5a7f2684b3 Add navigation layout overlay (#4683)
* Add navigation layout overlay

* Minor clean up 

Destroy animator when done not on start
Move and change pref title 
Add summary
2021-03-20 15:36:01 -04:00
d912a42249 Fix chapters list getting updated from wrong thread (fixes #4505) 2021-03-20 15:35:02 -04:00
6d8c4fb8b1 Fix Bangumi search null image errors 2021-03-20 10:22:11 -04:00
a63cecbfcb Make tapping available extension row prompt install 2021-03-20 10:10:58 -04:00
4a5bceb4e4 Fix offline restore ignoring manga from not installed sources (fixes #4679) 2021-03-20 10:03:13 -04:00
86541445b7 Update AGP 2021-03-20 09:54:29 -04:00
4e826aa8e7 Use newer action for build workflow 2021-03-20 09:53:17 -04:00
b6e6f490e9 Implement migration for source search (#4657) 2021-03-19 23:40:09 -04:00
2145e878a4 Limit query for recent chapters to 500 (#4678) 2021-03-19 23:39:36 -04:00
355f6db255 [SKIP CI] Update README.md (#4667)
Fix link to Code of Conduct.
2021-03-18 16:52:48 -04:00
bc7632bf02 [SKIP CI] Add Code of Conduct (#4665)
* Add Code of Conduct

* Update badge section

* Add Code of Conduct link to README

* Change to relative links
2021-03-18 09:28:08 -04:00
609d8c9685 Add icons for reading mode toggle 2021-03-14 17:13:20 -04:00
2f08515455 Less janky enum iteration 2021-03-14 17:03:43 -04:00
7f450e185d Use fetch date instead of upload date when querying recent chapters (#4645) 2021-03-14 16:38:21 -04:00
747879b4ec Remove __cfduid cookie check
As per email:

Cloudflare is deprecating the __cfduid cookie and the cf-request-id headers. The __cfduid cookie will be removed on 10 May 2021 and the cf-request-id headers will be removed on 1 July. We expect that most customers will not have to take action as a result of this removal. [...] Starting on 10 May 2021, we will stop adding a “Set-Cookie” header on all HTTP responses. The last __cfduid cookies will expire 30 days after that.
2021-03-14 16:24:14 -04:00
4193870fa6 Library update freq: add 4 & 8 hours (#4557) 2021-03-14 16:22:10 -04:00
cdc5de3f1b Flip order of previous chapter reader transition text (closes #4608) 2021-03-14 16:18:52 -04:00
bc34d4fa88 Round snackbar corners 2021-03-14 16:15:25 -04:00
6fd4af8736 Adjust reader navigation button ripples 2021-03-14 16:13:18 -04:00
b5c2934270 Refactor LibraryUpdateService a bit for future changes 2021-03-14 16:08:00 -04:00
94f5117941 Remove online protobuf backup restore option 2021-03-13 18:45:22 -05:00
112e233498 Use Material dialogs for preferences
Partially addresses #2907
2021-03-13 18:00:24 -05:00
18b1326f3a Tweak dialog corner radius 2021-03-13 17:18:22 -05:00
1e58b05ead Add reading mode toggle 2021-03-13 16:47:16 -05:00
938919bd9b Move reader setting related classes 2021-03-13 16:24:44 -05:00
b6b78994d8 Move clear history from advanced settings to history screen menu (closes #4613) 2021-03-13 16:09:12 -05:00
fddd8ce305 Add "my" locale 2021-03-13 16:00:13 -05:00
ccff337975 Weblate translations (#4461)
Co-authored-by: Adaś <adam.prosniak@gmail.com>
Co-authored-by: Ajeje Brazorf <lmelonimamo@yahoo.it>
Co-authored-by: Alessandro Jean <alessandrojean@gmail.com>
Co-authored-by: Alex <linuxrf@gmail.com>
Co-authored-by: Andreas E <andreas.everos@gmail.com>
Co-authored-by: Aung Myint Myat Oo <solidifyarmor@gmail.com>
Co-authored-by: Ava <Sasu.ruotsalainen@live.fi>
Co-authored-by: Bail Adnan Farid <fks7dev@gmail.com>
Co-authored-by: C201 <derasetad@gmail.com>
Co-authored-by: Crazyom <naxom@laposte.net>
Co-authored-by: Cream π <f.t.nayeem014@gmail.com>
Co-authored-by: DarKCroX <darkcrox.2020@outlook.com>
Co-authored-by: Eduard Ereza Martínez <eduard@ereza.cat>
Co-authored-by: Eric <spice2wolf@gmail.com>
Co-authored-by: Eugene <e.shlyapkin99@gmail.com>
Co-authored-by: Eugene <eugcheung94@gmail.com>
Co-authored-by: Flamm <robindevaux25@gmail.com>
Co-authored-by: Habibur Rahman <habiburr016@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Huang Zhiyi <hzy980512@126.com>
Co-authored-by: Iuri Jikidze <ijiki16@freeuni.edu.ge>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Jimly Asshiddiqy <j_mly@ymail.com>
Co-authored-by: Kurocon <weblate@kurocon.nl>
Co-authored-by: Lyfja <yassinelaoud@gmail.com>
Co-authored-by: Lzmxya <lzmxya@gmail.com>
Co-authored-by: Marco Santos <enum.scima@gmail.com>
Co-authored-by: Matteo Gaeta <matteo.gaeta.1998@gmail.com>
Co-authored-by: Michalis <michalisntovas@yahoo.gr>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Murat Topuz <mrt_tpz@outlook.com>
Co-authored-by: Murilo Simionato Arnemann <murilo2110@hotmail.com>
Co-authored-by: Nick Koroghlishvili <n.koroglishvili5@gmail.com>
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
Co-authored-by: Paulo Pinho <kebrus@gmail.com>
Co-authored-by: Pitpe11 <giorgos2550@gmail.com>
Co-authored-by: Rocco Casadei <roccobot@gmail.com>
Co-authored-by: Rostyslav <info@ubilling.net.ua>
Co-authored-by: Ryota Hasegawa <unkchn123456@gmail.com>
Co-authored-by: Samuel Carvalho de Araújo <samuelnegro12345@gmail.com>
Co-authored-by: Soitora <simon.mattila@protonmail.com>
Co-authored-by: Tooster <max@polarczyk.pl>
Co-authored-by: Yasin Chamsoy <tristeroni@gmail.com>
Co-authored-by: darkbeast13 <nikhil15mps@gmail.com>
Co-authored-by: dmswd <Bmswad1@gmail.com>
Co-authored-by: Роман <Rozhenkov69@gmail.com>
Co-authored-by: 赤城 悠 <hapipon815@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ar/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/bg/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/bn/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ca/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/cv/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/de/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/el/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fa/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fil/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hi/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/id/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/it/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ja/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ka/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ko/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ms/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/my/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/nl/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sc/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sv/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/tr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/uk/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/uz/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/zh_Hant/
Translation: Tachiyomi/Tachiyomi 0.x

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Adaś <adam.prosniak@gmail.com>
Co-authored-by: Ajeje Brazorf <lmelonimamo@yahoo.it>
Co-authored-by: Alessandro Jean <alessandrojean@gmail.com>
Co-authored-by: Alex <linuxrf@gmail.com>
Co-authored-by: Andreas E <andreas.everos@gmail.com>
Co-authored-by: Aung Myint Myat Oo <solidifyarmor@gmail.com>
Co-authored-by: Ava <Sasu.ruotsalainen@live.fi>
Co-authored-by: Bail Adnan Farid <fks7dev@gmail.com>
Co-authored-by: C201 <derasetad@gmail.com>
Co-authored-by: Crazyom <naxom@laposte.net>
Co-authored-by: Cream π <f.t.nayeem014@gmail.com>
Co-authored-by: DarKCroX <darkcrox.2020@outlook.com>
Co-authored-by: Eduard Ereza Martínez <eduard@ereza.cat>
Co-authored-by: Eric <spice2wolf@gmail.com>
Co-authored-by: Eugene <e.shlyapkin99@gmail.com>
Co-authored-by: Eugene <eugcheung94@gmail.com>
Co-authored-by: Flamm <robindevaux25@gmail.com>
Co-authored-by: Habibur Rahman <habiburr016@gmail.com>
Co-authored-by: Huang Zhiyi <hzy980512@126.com>
Co-authored-by: Iuri Jikidze <ijiki16@freeuni.edu.ge>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Jimly Asshiddiqy <j_mly@ymail.com>
Co-authored-by: Kurocon <weblate@kurocon.nl>
Co-authored-by: Lyfja <yassinelaoud@gmail.com>
Co-authored-by: Lzmxya <lzmxya@gmail.com>
Co-authored-by: Marco Santos <enum.scima@gmail.com>
Co-authored-by: Matteo Gaeta <matteo.gaeta.1998@gmail.com>
Co-authored-by: Michalis <michalisntovas@yahoo.gr>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Murat Topuz <mrt_tpz@outlook.com>
Co-authored-by: Murilo Simionato Arnemann <murilo2110@hotmail.com>
Co-authored-by: Nick Koroghlishvili <n.koroglishvili5@gmail.com>
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
Co-authored-by: Paulo Pinho <kebrus@gmail.com>
Co-authored-by: Pitpe11 <giorgos2550@gmail.com>
Co-authored-by: Rocco Casadei <roccobot@gmail.com>
Co-authored-by: Rostyslav <info@ubilling.net.ua>
Co-authored-by: Ryota Hasegawa <unkchn123456@gmail.com>
Co-authored-by: Samuel Carvalho de Araújo <samuelnegro12345@gmail.com>
Co-authored-by: Soitora <simon.mattila@protonmail.com>
Co-authored-by: Tooster <max@polarczyk.pl>
Co-authored-by: Yasin Chamsoy <tristeroni@gmail.com>
Co-authored-by: darkbeast13 <nikhil15mps@gmail.com>
Co-authored-by: dmswd <Bmswad1@gmail.com>
Co-authored-by: Роман <Rozhenkov69@gmail.com>
Co-authored-by: 赤城 悠 <hapipon815@gmail.com>
2021-03-13 15:58:55 -05:00
fde6b7af4f Disable sensor when using force orientation (closes #4618) 2021-03-13 15:47:43 -05:00
0657db7dcb Allow scrolling within reader color filter sheet (fixes #4612) 2021-03-13 15:20:07 -05:00
d1c2eaf6d5 Update URL for Local Manga guide (#4641) 2021-03-13 11:38:06 -05:00
91bb6b9016 Dependency updates 2021-03-12 09:06:13 -05:00
90351c6e9e Revert to core-ktx:1.5.0-beta01
Fixes bottom reader menu from being hidden behind navbar on Android 5.0.
2021-03-07 23:04:29 -05:00
dd4740e54f [SKIP CI] Automatically reopen issues when valid 2021-03-07 10:43:35 -05:00
48e7cbd76c Fix a decoder crash with RAR files 2021-03-05 18:52:58 +01:00
f51e32f39b Avoid crash during migration 2021-02-28 16:26:05 -05:00
ae42f59102 Hide subtitle in migration list of sources if no language set (i.e. uninstalled source) 2021-02-28 16:26:05 -05:00
5c8006f9b7 Use correct background for left chapter button in reader 2021-02-28 16:26:05 -05:00
aa5861d3ca AndroidX dependency updates 2021-02-28 16:26:05 -05:00
7a64bf55cb Dual page split allow to have different setting for Paged and Webtoon (#4527) 2021-02-28 16:17:37 -05:00
d4c9ab793f Fix a decoder crash 2021-02-23 16:53:57 +01:00
48d2849d97 Support CMYK and YCCK JPEGs and fix bad PNG cropping 2021-02-22 20:43:15 +01:00
776610d0e6 Let users invert dual page split (#4470)
* Let users invert dual page split

* Use Activity lifecycleScope and cleanup invert logic
2021-02-20 09:26:57 -05:00
3a790f3d66 Add Right and Left to reader settings (#4489)
* Add Right and Left to settings

* Fix whoopsie and minor tweak to how the array is fetched
2021-02-15 12:06:03 -05:00
7382042288 Add Twitter link to About section 2021-02-15 11:58:25 -05:00
33992d80bf Add orientation toggle to bottom reader menu 2021-02-13 18:50:50 -05:00
a92b0e567b Reword bookmark strings to clarify it's for a chapter, not a page 2021-02-13 17:27:40 -05:00
829a65e515 Adjust reader seekbar design
- Revert back to old prev/next chapter icons
- Make views taller for easier actions
- Use more consistent spacing
- Add ripples to prev/next chapter buttons
2021-02-13 17:00:00 -05:00
03ad48c055 [SKIP CI] Add instructions on how to get crash logs in issue templates 2021-02-13 16:07:30 -05:00
89837e4ced Initial adoption of bottom reader menus from TachiyomiSY
Co-authored-by: Jobobby04 <jobobby04@users.noreply.github.com>
Co-authored-by: CrepeTF <CrepeTF@users.noreply.github.com>
2021-02-13 10:47:17 -05:00
ace1db21d1 Rename drawable with more consistent naming 2021-02-13 10:44:35 -05:00
8bb69c455b Allow clicking the toolbar to go to the manga
Co-authored-by: Jobobby04 <jobobby04@users.noreply.github.com>
2021-02-13 10:26:59 -05:00
2dae706198 Avoid crash when source list is animating 2021-02-12 17:31:17 -05:00
3eda2a220a Avoid rare crashes in settings search for ListPreferences 2021-02-12 17:22:01 -05:00
61e5440b7c Avoid crash when device fails to handle opening a URL 2021-02-12 17:02:37 -05:00
2e2663bad9 Avoid crash if activity is already dead 2021-02-12 16:55:14 -05:00
f4dd150b70 [SKIP CI] Update to issue-closer-action@v2.0 2021-02-12 16:26:48 -05:00
2b35d22e25 Switch back to new image decoder for preview builds 2021-02-12 16:07:48 -05:00
f590378761 Release 0.10.9 2021-02-12 16:01:23 -05:00
f5f592be91 Require minimum WebView v88, try to catch fatal errors too 2021-02-12 12:42:33 -05:00
7a373fb43a Minor download icon optimizations 2021-02-12 12:27:40 -05:00
aded11e599 Make backup restoring logic more sequential 2021-02-12 12:27:40 -05:00
41d7cee020 Remove ExperimentalSerializationApi opt-in annotations 2021-02-12 12:27:40 -05:00
f2ef6a20e6 Weblate translations (#4378)
Co-authored-by: Adaś <adam.prosniak@gmail.com>
Co-authored-by: Ajeje Brazorf <lmelonimamo@yahoo.it>
Co-authored-by: Alex <linuxrf@gmail.com>
Co-authored-by: Ava <Sasu.ruotsalainen@live.fi>
Co-authored-by: Crazyom <naxom@laposte.net>
Co-authored-by: Cream π <f.t.nayeem014@gmail.com>
Co-authored-by: DarKCroX <darkcrox.2020@outlook.com>
Co-authored-by: Eduard Ereza Martínez <eduard@ereza.cat>
Co-authored-by: Eric <spice2wolf@gmail.com>
Co-authored-by: Eugene <eugcheung94@gmail.com>
Co-authored-by: Flamm <robindevaux25@gmail.com>
Co-authored-by: Habibur Rahman <habiburr016@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Huang Zhiyi <hzy980512@126.com>
Co-authored-by: Iuri Jikidze <ijiki16@freeuni.edu.ge>
Co-authored-by: Jimly Asshiddiqy <j_mly@ymail.com>
Co-authored-by: Kurocon <weblate@kurocon.nl>
Co-authored-by: Lyfja <yassinelaoud@gmail.com>
Co-authored-by: Lzmxya <lzmxya@gmail.com>
Co-authored-by: Matteo Gaeta <matteo.gaeta.1998@gmail.com>
Co-authored-by: Michalis <michalisntovas@yahoo.gr>
Co-authored-by: Murilo Simionato Arnemann <murilo2110@hotmail.com>
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
Co-authored-by: Paulo Pinho <kebrus@gmail.com>
Co-authored-by: Pitpe11 <giorgos2550@gmail.com>
Co-authored-by: Rocco Casadei <roccobot@gmail.com>
Co-authored-by: Rostyslav <info@ubilling.net.ua>
Co-authored-by: Samuel Carvalho de Araújo <samuelnegro12345@gmail.com>
Co-authored-by: Soitora <simon.mattila@protonmail.com>
Co-authored-by: darkbeast13 <nikhil15mps@gmail.com>
Co-authored-by: Роман <Rozhenkov69@gmail.com>
Co-authored-by: 赤城 悠 <hapipon815@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/bn/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ca/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/de/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/el/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hi/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/id/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/it/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ja/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ka/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ms/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/nl/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sc/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sv/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/tr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/uk/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/zh_Hant/
Translation: Tachiyomi/Tachiyomi 0.x

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Adaś <adam.prosniak@gmail.com>
Co-authored-by: Ajeje Brazorf <lmelonimamo@yahoo.it>
Co-authored-by: Alex <linuxrf@gmail.com>
Co-authored-by: Ava <Sasu.ruotsalainen@live.fi>
Co-authored-by: Crazyom <naxom@laposte.net>
Co-authored-by: Cream π <f.t.nayeem014@gmail.com>
Co-authored-by: DarKCroX <darkcrox.2020@outlook.com>
Co-authored-by: Eduard Ereza Martínez <eduard@ereza.cat>
Co-authored-by: Eric <spice2wolf@gmail.com>
Co-authored-by: Eugene <eugcheung94@gmail.com>
Co-authored-by: Flamm <robindevaux25@gmail.com>
Co-authored-by: Habibur Rahman <habiburr016@gmail.com>
Co-authored-by: Huang Zhiyi <hzy980512@126.com>
Co-authored-by: Iuri Jikidze <ijiki16@freeuni.edu.ge>
Co-authored-by: Jimly Asshiddiqy <j_mly@ymail.com>
Co-authored-by: Kurocon <weblate@kurocon.nl>
Co-authored-by: Lyfja <yassinelaoud@gmail.com>
Co-authored-by: Lzmxya <lzmxya@gmail.com>
Co-authored-by: Matteo Gaeta <matteo.gaeta.1998@gmail.com>
Co-authored-by: Michalis <michalisntovas@yahoo.gr>
Co-authored-by: Murilo Simionato Arnemann <murilo2110@hotmail.com>
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
Co-authored-by: Paulo Pinho <kebrus@gmail.com>
Co-authored-by: Pitpe11 <giorgos2550@gmail.com>
Co-authored-by: Rocco Casadei <roccobot@gmail.com>
Co-authored-by: Rostyslav <info@ubilling.net.ua>
Co-authored-by: Samuel Carvalho de Araújo <samuelnegro12345@gmail.com>
Co-authored-by: Soitora <simon.mattila@protonmail.com>
Co-authored-by: darkbeast13 <nikhil15mps@gmail.com>
Co-authored-by: Роман <Rozhenkov69@gmail.com>
Co-authored-by: 赤城 悠 <hapipon815@gmail.com>
2021-02-12 12:27:32 -05:00
a398c3fb81 Handle link for multisource extension commits (closes #4432) 2021-02-11 17:35:15 -05:00
2a454b44cc Adjust some scopes 2021-02-09 19:14:38 -05:00
7b66ece895 Fix invisible overflow icon in chapter filter sheet in light blue theme 2021-02-09 19:12:44 -05:00
b5017eebbf Added dual page split setting (#4252)
* Add DualPageSplit option

* remove extra line

* Split double-page into two pages

* Remove !isAnimated check and add (ALPHA) to the label

* Fix missing insert pages

* Pager cleanup

* Add dual split to Webtoon and fix Vertical

* Fix L2R/R2L

* Add comments and refactor code in ImageUtil

* Use a simpler split solution in webtoon mode

Co-authored-by: weng <>
Co-authored-by: Andreas E <andreas.everos@gmail.com>
2021-02-09 17:54:44 -05:00
aa67229daf Add weekly to library update frequency options (closes #4422) 2021-02-09 17:49:02 -05:00
5af68186d6 Clean up LibraryUpdateService a bit 2021-02-09 17:44:22 -05:00
545bc0e605 Open manga when clicking thumbnail in migration list (closes #4152) 2021-02-08 17:47:44 -05:00
291168f4de Remove unnecessary LayoutContainer implementations 2021-02-08 17:45:42 -05:00
9facb51f22 Add action to directly share crash log file from notification 2021-02-07 23:05:13 -05:00
5b7d8c5e37 Show locales in list of sources to migrate 2021-02-07 22:54:13 -05:00
5945937e4b Update AboutLibraries 2021-02-07 22:51:23 -05:00
9f9f9872eb Fix legacy backups
(cherry picked from commit ded58541f5903c109b70799683829e26018d2af6)
2021-02-07 22:33:07 -05:00
3566072f4a Revert attempt to programmatically determine user agent string; fallback to Edge 2021-02-07 17:54:28 -05:00
b85cd86b24 Add Esperanto locale 2021-02-07 16:55:44 -05:00
79c3767fff Chapter backup optimization
From fc6d9aaf51 (diff-9872ccc3c9af14d2872ec99199409e60a11cb754ab23e733b1d45843778f7c95R24)
2021-02-07 16:20:07 -05:00
cf1609a429 Massage user agent string from WebView a bit more 2021-02-07 16:19:13 -05:00
3aeac7e7b5 Fix selected tab in sheets not being the accent color 2021-02-07 10:54:35 -05:00
1557f713f4 Don't restrict filter sheet height anymore 2021-02-07 10:49:08 -05:00
b63d24ac1a Add Right and Left navigation (#4392)
and remove default navigation classes in favor of the navigation classes
2021-02-06 23:26:56 -05:00
348c1ff29d Avoid some unnecessary re-renderings of download icons 2021-02-06 23:25:39 -05:00
717e55497f Fix downloads getting deleted when marked as unread 2021-02-06 22:48:06 -05:00
d84b5e8b46 Show help action when source fails to load 2021-02-06 13:09:56 -05:00
5f9ddf9ff5 Use AndroidX version of ContextThemeWrapper 2021-02-06 12:51:40 -05:00
bbee093c63 Remove some logic around old legacy backup versions + minor optimizations 2021-02-06 12:15:34 -05:00
e8c35ae4e1 Do a regular return to cancel update jobs instead of throwing an exception 2021-02-06 12:14:55 -05:00
1607658c30 Set clip data when sharing content URIs (closes #4198) 2021-02-06 09:43:33 -05:00
2e9ef373f3 Minor optimizations for restoring full backups
Based on fc6d9aaf51
2021-02-06 09:32:00 -05:00
ec6eef6d37 Switch back to new image decoder for preview builds 2021-02-06 09:31:18 -05:00
45a19d15ec Release 0.10.8 2021-02-06 09:09:59 -05:00
7191552126 Avoid crash when changing spinner indeterminate state 2021-02-05 23:13:57 -05:00
cfa07490e5 Update dependencies 2021-02-05 22:59:29 -05:00
ae40990eb9 Update to Gradle 6.8.2 2021-02-05 22:59:06 -05:00
9f2fe33ce0 Fix unreadable sheet tab text in light blue theme 2021-02-04 09:16:37 -05:00
33660de6b1 Remove buildSrc module's dependency on JCenter 2021-02-04 09:01:56 -05:00
13d25e0849 Don't automatically set MAL start date (closes #4349) 2021-02-01 22:39:41 -05:00
6662e2002f Rounded bottom sheets 2021-02-01 22:23:29 -05:00
d4081dc899 Remove strings that shouldn't have been translated 2021-01-31 14:59:15 -05:00
62dffb8226 Pad trackers list a bit 2021-01-31 14:52:40 -05:00
cb6aa18480 Weblate translations (#4346)
Co-authored-by: Ajeje Brazorf <lmelonimamo@yahoo.it>
Co-authored-by: Alessandro Jean <alessandrojean@gmail.com>
Co-authored-by: Andreas E <andreas.everos@gmail.com>
Co-authored-by: Ava <Sasu.ruotsalainen@live.fi>
Co-authored-by: Byron Vanstien <byronvanstien@rocketmail.com>
Co-authored-by: C201 <derasetad@gmail.com>
Co-authored-by: DarKCroX <darkcrox.2020@outlook.com>
Co-authored-by: Eduard Ereza Martínez <eduard@ereza.cat>
Co-authored-by: Eric <spice2wolf@gmail.com>
Co-authored-by: Flamm <robindevaux25@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Lyfja <yassinelaoud@gmail.com>
Co-authored-by: Lzmxya <lzmxya@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Nestor A. Sanchez <help.toastcode@gmail.com>
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
Co-authored-by: Paulo Pinho <kebrus@gmail.com>
Co-authored-by: Pitpe11 <giorgos2550@gmail.com>
Co-authored-by: Rostyslav <info@ubilling.net.ua>
Co-authored-by: Soitora <simon.mattila@protonmail.com>
Co-authored-by: Tantia <ilovechocobi@yahoo.com>
Co-authored-by: Tooster <max@polarczyk.pl>
Co-authored-by: Роман <Rozhenkov69@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ca/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/cs/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/cv/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/de/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/el/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/eo/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/es/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/es_419/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hu/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/id/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ko/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ms/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ro/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sc/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sk/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sv/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/tr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/uk/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/zh_Hant/
Translation: Tachiyomi/Tachiyomi 0.x

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Ajeje Brazorf <lmelonimamo@yahoo.it>
Co-authored-by: Alessandro Jean <alessandrojean@gmail.com>
Co-authored-by: Andreas E <andreas.everos@gmail.com>
Co-authored-by: Ava <Sasu.ruotsalainen@live.fi>
Co-authored-by: Byron Vanstien <byronvanstien@rocketmail.com>
Co-authored-by: C201 <derasetad@gmail.com>
Co-authored-by: DarKCroX <darkcrox.2020@outlook.com>
Co-authored-by: Eduard Ereza Martínez <eduard@ereza.cat>
Co-authored-by: Eric <spice2wolf@gmail.com>
Co-authored-by: Flamm <robindevaux25@gmail.com>
Co-authored-by: Lyfja <yassinelaoud@gmail.com>
Co-authored-by: Lzmxya <lzmxya@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Nestor A. Sanchez <help.toastcode@gmail.com>
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
Co-authored-by: Paulo Pinho <kebrus@gmail.com>
Co-authored-by: Pitpe11 <giorgos2550@gmail.com>
Co-authored-by: Rostyslav <info@ubilling.net.ua>
Co-authored-by: Soitora <simon.mattila@protonmail.com>
Co-authored-by: Tantia <ilovechocobi@yahoo.com>
Co-authored-by: Tooster <max@polarczyk.pl>
Co-authored-by: Роман <Rozhenkov69@gmail.com>
2021-01-31 14:52:32 -05:00
d5cfbef42b Minor cleanup 2021-01-31 14:46:35 -05:00
535abcbb8b Move tracking to a bottom sheet (#4364)
* Move tracking to a bottom sheet

* Give methods better names and remove unnecessary annotation
2021-01-31 14:43:43 -05:00
c34b548a3e Add manga count to the library header (#3884)
* Add manga count to the library header

* Make showing the number of manga configurable

Co-authored-by: arkon <arkon@users.noreply.github.com>
2021-01-31 10:15:37 -05:00
9bf452856c Regenerate drawables (#4352) 2021-01-31 09:55:28 -05:00
17109ab760 Handle failures when updating metadata in library updater 2021-01-29 22:14:17 -05:00
6bc6e1a1d1 Remove unused dimen values 2021-01-28 09:13:05 -05:00
7eef4f7fbf Apply bottom sheet dialog with restriction consistently 2021-01-28 09:10:15 -05:00
75bec6a8e3 Update AndroidX dependencies 2021-01-28 09:03:55 -05:00
0a10f66053 Unify history and update item (#4361)
* Unify history and update item

* Use card_radius
2021-01-27 18:28:02 -05:00
58860b51a2 Ignore failures when updating metadata as part of library update 2021-01-27 17:51:02 -05:00
3ee652b61a Disable Acra crash logs in dev builds 2021-01-27 09:17:40 -05:00
426ed7308b Add comments to not translate the word "WebView" 2021-01-26 17:58:36 -05:00
0ecfef3f70 Address unit test compilation errors
They don't actually run since they broke a long time ago (AndroidX + Roboelectric issues?), but it addresses the annoying red squigglies in Android Studio at least.
2021-01-26 09:05:15 -05:00
5f7e34b6a1 Update Okio, use more KTX stuff (#4353)
* Update Okio to 2.10.0

* Use some more KTX extensions
2021-01-26 09:02:53 -05:00
34cb24fe34 Update total number of chapters when refreshing MAL entries (fixes #4348) 2021-01-24 16:58:23 -05:00
1490112135 Parse correct object when finding existing MAL list item (fixes #4347) 2021-01-24 16:33:47 -05:00
c4716a3f4c Fix cancelling library updates not working 2021-01-24 16:11:45 -05:00
0a54901eb0 Refactor tracker name strings 2021-01-24 16:06:28 -05:00
fea2e0a265 Extend track filter (#4344)
* Allow to filter for each tracker logged in

* Simplify filter logic

* Use variable names instead of it

and rename variables

* Change how trackFilters and items are setup

* Use variable name instead of it and try cleanup filterFnTracking

* Changes from feedback
2021-01-24 15:24:00 -05:00
d3c087375b Weblate translations (#4204)
Co-authored-by: ARiyou Jahan <AR.Jahan2000@gmail.com>
Co-authored-by: Ajeje Brazorf <lmelonimamo@yahoo.it>
Co-authored-by: Alessandro Ciavola Pennelli <loxli91@gmail.com>
Co-authored-by: Alessandro Jean <alessandrojean@gmail.com>
Co-authored-by: Alessandro Zangrandi <alessandro@mzit.it>
Co-authored-by: Alex <linuxrf@gmail.com>
Co-authored-by: Ava <Sasu.ruotsalainen@live.fi>
Co-authored-by: DarKCroX <darkcrox.2020@outlook.com>
Co-authored-by: Eric <spice2wolf@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Jozef Hollý <j2.00ghz@gmail.com>
Co-authored-by: Lyaiya <hipsnafoha@outlook.com>
Co-authored-by: Lzmxya <lzmxya@gmail.com>
Co-authored-by: Marco Santos <enum.scima@gmail.com>
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
Co-authored-by: Pitpe11 <giorgos2550@gmail.com>
Co-authored-by: Redya Rachmandanu <redyarachmandanu@gmail.com>
Co-authored-by: Samuel Carvalho de Araújo <samuelnegro12345@gmail.com>
Co-authored-by: Sl3iN <fddf.ddrf2015@ya.ru>
Co-authored-by: Tooster <max@polarczyk.pl>
Co-authored-by: Woodyx <shiposhouyou@gmail.com>
Co-authored-by: Yassin <yassinelaoud@gmail.com>
Co-authored-by: Zulkifli <zulhaha1@gmail.com>
Co-authored-by: arkon <eugcheung94@gmail.com>
Co-authored-by: waquack <idragonus@gmail.com>
Co-authored-by: Николаев Дмитрий <nikolaevddv@gmail.com>
Co-authored-by: Роман <Rozhenkov69@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ar/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/cs/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/de/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/el/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fa/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fil/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hu/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/id/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/it/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ms/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sah/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sc/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/tr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/zh_Hant/
Translation: Tachiyomi/Tachiyomi 0.x

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: ARiyou Jahan <AR.Jahan2000@gmail.com>
Co-authored-by: Ajeje Brazorf <lmelonimamo@yahoo.it>
Co-authored-by: Alessandro Ciavola Pennelli <loxli91@gmail.com>
Co-authored-by: Alessandro Jean <alessandrojean@gmail.com>
Co-authored-by: Alessandro Zangrandi <alessandro@mzit.it>
Co-authored-by: Alex <linuxrf@gmail.com>
Co-authored-by: Ava <Sasu.ruotsalainen@live.fi>
Co-authored-by: DarKCroX <darkcrox.2020@outlook.com>
Co-authored-by: Eric <spice2wolf@gmail.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Lyaiya <hipsnafoha@outlook.com>
Co-authored-by: Lzmxya <lzmxya@gmail.com>
Co-authored-by: Marco Santos <enum.scima@gmail.com>
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
Co-authored-by: Pitpe11 <giorgos2550@gmail.com>
Co-authored-by: Redya Rachmandanu <redyarachmandanu@gmail.com>
Co-authored-by: Samuel Carvalho de Araújo <samuelnegro12345@gmail.com>
Co-authored-by: Sl3iN <fddf.ddrf2015@ya.ru>
Co-authored-by: Tooster <max@polarczyk.pl>
Co-authored-by: Woodyx <shiposhouyou@gmail.com>
Co-authored-by: Yassin <yassinelaoud@gmail.com>
Co-authored-by: Zulkifli <zulhaha1@gmail.com>
Co-authored-by: arkon <eugcheung94@gmail.com>
Co-authored-by: waquack <idragonus@gmail.com>
Co-authored-by: Николаев Дмитрий <nikolaevddv@gmail.com>
Co-authored-by: Роман <Rozhenkov69@gmail.com>
2021-01-24 12:08:01 -05:00
a93c0577ac Minor cleanup 2021-01-24 11:04:25 -05:00
e4dc35674d Perform metadata update in global scope 2021-01-24 10:33:29 -05:00
8a668ba7b9 Revert async metadata updates to fix lag when updating (fixes #4341) 2021-01-23 19:26:21 -05:00
ee9a68b040 Point to preview release for what's new 2021-01-23 17:43:25 -05:00
78e8d40649 Update AboutLibraries plugin 2021-01-23 17:21:39 -05:00
660e13b701 Remove some unused rx/coroutine bridge code 2021-01-23 17:18:43 -05:00
0685382083 Extract user agent string from WebView 2021-01-23 15:57:59 -05:00
04a993c997 Update trackers in parallel, update manga metadata asynchronously 2021-01-23 14:55:00 -05:00
7cae3095c4 Minor dependency updates 2021-01-23 13:23:49 -05:00
e288bf902b Reword download chapter number sorting options 2021-01-23 13:19:57 -05:00
a083e1f71a add sort by chapter number in download queue (#4337)
* add sort by chapter number in download queue

* Bigest, smallest chapter download

* grouped
2021-01-23 13:10:57 -05:00
86b9d7e843 Remove usage of RxJava from LibraryUpdateService 2021-01-23 11:20:16 -05:00
628bd5d6b4 add source name in download queue (#4338) 2021-01-23 10:15:30 -05:00
00285a782c Ignore error when cover is missing in Kitsu search results (fixes #4334) 2021-01-23 10:14:43 -05:00
16be469ecb Surface tracking search errors properly 2021-01-22 22:47:01 -05:00
fdcbc4cffa Add QUERY_ALL_PACKAGES permission (maybe fixes #4313) 2021-01-22 18:03:58 -05:00
fc548304cf Remove unused RECEIVE_BOOT_COMPLETED permission
Was originally added for the library check mechanism: fcb5bf4dd4
2021-01-22 18:03:36 -05:00
7c7ff8165e Don't stop downloader after deleting downloads if it wasn't running (fixes #4309) 2021-01-22 17:52:10 -05:00
496a476c13 Fix multi-select phantom anchor bug in manga chapters and library (#4201)
* Fix phantom anchor bug in manga chapters list when multi-selecting

* Fix phantom bug when long pressing selected items not at top of stack

* Fix phantom anchor bug in library page
2021-01-22 17:35:51 -05:00
441fc6e45b Match color for download, header, and pin buttons (#4331)
* Match download button color to pin color

* Match manga header buttons color to pin color
2021-01-22 09:10:58 -05:00
cf7ec6aa76 Use proper method to clear notification actions 2021-01-21 18:21:21 -05:00
db2dd4b6c6 Add method for users to save error logs to a file 2021-01-21 18:21:03 -05:00
a68417a0b0 Update AGP for Android Studio 4.1.2 2021-01-21 18:05:36 -05:00
2a5102a457 Fix Kitsu toasting "Logged in" when there is an error (#4329) 2021-01-21 18:05:22 -05:00
837d8f5f30 Delay restoring AppBar elevation on Android 5.0 (fixes #4311) 2021-01-18 17:24:23 -05:00
1a5858e99b Hide tracking when no tracker is logged in and change filter logic (#4310)
* Hide tracking when not logged in

* Change string name and value
2021-01-18 17:08:11 -05:00
4044427d93 Add shortcut to see commit history for official extensions 2021-01-17 15:57:19 -05:00
f667f85fa5 More consistent injectLazy style 2021-01-17 15:40:26 -05:00
5cddc0c387 Add filter for tracking (#4276)
* Add filter for tracking or not

* Use .any

* Access database only when needed
2021-01-17 15:40:17 -05:00
cbc01dd6f1 [SKIP CI] Update fork change checklist 2021-01-17 14:39:21 -05:00
b820c7debf Remove duplicated info from CONTRIBUTING.md 2021-01-17 14:15:36 -05:00
2bee072cba Add list of things to change in forks to CONTRIBUTING.md 2021-01-17 14:09:42 -05:00
80710b0b94 Move CONTRIBUTING.md to top level 2021-01-17 14:05:40 -05:00
3319ccfd41 Move ACRA endpoint config to build.gradle.kts 2021-01-17 11:09:29 -05:00
878008e93b Reimplement MAL start/end date support 2021-01-16 16:03:19 -05:00
0cd551d4fd Revert removal of tracker start/end date 2021-01-16 15:48:20 -05:00
f85194ec46 Handle download cancelation from icon properly (fixes #4241) 2021-01-16 15:48:02 -05:00
271489bdfd Fix display mode not updating in source view 2021-01-16 11:30:27 -05:00
bd5f22a049 Update Material Components (fixes #4251) 2021-01-16 10:51:35 -05:00
189f18b112 Simplify lookup for existing MAL list item 2021-01-16 10:51:17 -05:00
df166184ea Update AndroidX dependencies 2021-01-16 10:51:17 -05:00
ce42cba096 Fix jpeg decoder when used with high samples 2021-01-15 16:15:56 +01:00
9670863a41 Show error when trying to download chapters from not installed source (closes #4283) 2021-01-10 11:11:53 -05:00
1ae52bd33f Update SubsamplingScaleImageView
Merged with upstream master and disabled Jetifier.
2021-01-10 11:02:21 -05:00
c9cf9cfff0 More coroutine tweaks 2021-01-10 11:01:10 -05:00
2ffbee3db2 Avoid using global scope where appropriate
Also fixes the crash in tracking when an exception is thrown during a refresh.
2021-01-08 18:05:51 -05:00
96b8beb9cd Fix png artifacts & banding when ratio > 1 2021-01-08 15:13:58 +01:00
365b849046 Fix bad cropping of PNGs with alpha channel 2021-01-08 12:12:59 +01:00
8e613d03e3 Address coroutine scope leaks in custom views 2021-01-07 19:16:26 -05:00
b18a794eca Use lifecycleScope directly 2021-01-07 19:12:30 -05:00
c620c924f9 Revert "Use flows instead of relays for extensions loading"
This reverts commit 07e76f35fa.
2021-01-07 19:06:34 -05:00
9db81a5a49 Fix navigation from feedback (#4238)
* Fix navigation from feedback (fixes #4237)

* Add additional enum values to NavigationRegion mainly for PagerViewer

Co-authored-by: arkon <arkon@users.noreply.github.com>
2021-01-07 19:05:38 -05:00
6fb7a85e8a Address more coroutine scope leaks 2021-01-07 18:15:57 -05:00
36f81b4a62 Minor fixes regarding leaks 2021-01-07 15:19:00 +01:00
2caecc01b2 Fix crash when no chapters can't be loaded 2021-01-06 14:21:55 -05:00
dedb8d2d68 Fix a crash with the new decoder when cropping borders of an entirely white or black image 2021-01-06 20:08:31 +01:00
7192b26402 Fixes on the new decoder library 2021-01-06 13:35:40 +01:00
762f5bdc33 Switch to version of SubsamplingScaleImageView with new image decoder
Courtesy of @inorichi.
Branch: https://github.com/tachiyomiorg/subsampling-scale-image-view/commits/tachiyomi-new-decoder

Decoding speed will be a bit slower now due to two passes if crop borders is enabled, but should be more
reliable on colored images and work for black borders. Memory usage (re: leaks) should also be better.
2021-01-05 22:13:48 -05:00
bebb52b4e8 Allow typing in tracker score similar to chapters (closes #2498) 2021-01-04 16:25:37 -05:00
2c9f8bb9ce Revert bad RxJava conversion that prevented tracking data to be updated in UI 2021-01-04 16:25:19 -05:00
efbefabb01 Reword unmetered network restriction for library updates 2021-01-04 16:12:34 -05:00
990fb22d3e Remove usage of RxJava from backup/restore 2021-01-04 15:30:05 -05:00
9b2c22b2d9 Use JDK 11 for build workflow (#4250) 2021-01-04 15:29:53 -05:00
df7e0d2f2f Surface "NSFW" (includes things like ecchi) results in MAL search (closes #4249) 2021-01-04 15:16:08 -05:00
5cfda1b1bf Refactor bridged RxJava/coroutine calls in SearchPresenters 2021-01-04 15:08:36 -05:00
ac9bf1f3ff Refactor bridged RxJava/coroutine calls in MangaPresenter 2021-01-04 14:55:28 -05:00
7eb0868791 Remove use of RxJava from TrackPresenter 2021-01-04 14:47:23 -05:00
8a792e6d76 MAL: add way to search by list items' titles 2021-01-04 14:30:04 -05:00
d8a3692d92 Fix content focusiblity issues when using remote/controllers (closes #3766) 2021-01-04 14:02:23 -05:00
95ce0e39ef Fix malformed extension URLs 2021-01-04 12:14:02 -05:00
17b70ab38c Refactor Kitsu API to remove Retrofit usage 2021-01-04 12:13:14 -05:00
07e76f35fa Use flows instead of relays for extensions loading 2021-01-04 11:09:31 -05:00
a4cab9876a More consistent wording for NSFW warning strings 2021-01-04 10:27:31 -05:00
c06a932c95 Remove some OptIn annotations 2021-01-04 10:22:26 -05:00
7d713b87b1 Fully remove usages of RxJava from tracker classes
TODO: refactor usages to coroutines as well
2021-01-03 23:41:36 -05:00
b1167146c5 Adjust download icon alignment more 2021-01-03 23:37:56 -05:00
2d0a5eb02c Convert more TrackService methods to coroutines 2021-01-03 23:33:21 -05:00
8d68859c2a Change MAL ID search prefix to "id:" 2021-01-03 23:21:45 -05:00
444cefc9a2 Use updated Tachiyomi version of DirectionalViewPager 2021-01-03 23:04:39 -05:00
d0deceabbd Tint downloaded icons with primary color
Makes them more easily distinguishable from other states.
2021-01-03 22:47:09 -05:00
175c1df0b8 Hide scrollbar from compact tags group 2021-01-03 22:46:37 -05:00
9cc6491c2a Adjust padding of text before download icons 2021-01-03 22:45:41 -05:00
710179f4b4 Note valid backup file extensions in error toast 2021-01-03 22:39:09 -05:00
d11c72fd48 Replace global search context menu option with share target 2021-01-03 11:08:23 -05:00
0af505828e Add more padding to download buttons 2021-01-03 11:01:23 -05:00
135cf9960f Minor cleanup 2021-01-03 10:54:09 -05:00
3bf7c74f93 Navigation settings and split invert tapping for webtoon and pager (#4233) 2021-01-03 10:50:38 -05:00
cea4911c4d add date Format (#4236) 2021-01-03 10:47:23 -05:00
54dc01253d Replace usages of fetchChapterList with 1.x getChapterList API 2021-01-01 19:19:11 -05:00
4db9a90da2 Replace usages of fetchMangaDetails with 1.x getMangaDetails API 2021-01-01 19:08:12 -05:00
d69e9034ab Viewer navigation (#3869)
* Viewer navigation

Co-authored-by: Harsh Parekh <h.x.dev@outlook.com>

* Match current reader behavior and add ability to invert it

* A bit of clean up

* Clean up inversion

* Only create navigator when changed

and change tap zone when invertTapping is changed

* Clean up PagerConfig

* Change how Viewer navigation works

* Add Edge Navigation

Co-authored-by: Harsh Parekh <h.x.dev@outlook.com>
2021-01-01 18:41:20 -05:00
71ece73d99 Direct user back to global search when coming from source search results (#4196) 2021-01-01 16:46:44 -05:00
3bb2102eb4 Include source name in backup restore error logs (closes #4230) 2021-01-01 16:43:32 -05:00
b7914909d0 Remove some unnecessary coroutine dispatcher switching 2020-12-31 18:37:43 -05:00
63398fe491 Implement click events for chapter download icons 2020-12-31 18:14:51 -05:00
bf32bf28da Use coroutines instead of rx for some MangaPresenter operations 2020-12-31 17:45:59 -05:00
dcb6bfb18d Remove some unused variables 2020-12-31 16:41:09 -05:00
8f605dc0f6 Adjust chapters filter icon to line up with download icons 2020-12-31 16:22:11 -05:00
47e770948b Fix download progress bar sizing to match icons 2020-12-31 16:19:25 -05:00
9ab29f5b7f Move some build.gradle.kts content around 2020-12-31 11:32:18 -05:00
10bf430ce6 Only include Firebase dependency in standard flavor 2020-12-31 11:32:06 -05:00
67eb4e8180 Convert app build.gradle to Kotlin DSL (#4222) 2020-12-31 11:28:34 -05:00
141f9b7730 Handle last read page being above total page count (fixes #4010) 2020-12-31 11:21:39 -05:00
139a589ad6 Increase per-page limit when searching MAL manga list 2020-12-31 10:58:40 -05:00
591873a185 Update preview build links (fixes #4225) 2020-12-31 10:52:20 -05:00
97a308b114 Minor cleanup of some tracker observables 2020-12-31 10:51:23 -05:00
430714e67f Find existing entry in MAL list when binding 2020-12-31 10:50:50 -05:00
a49adbd09c Add ID search workaround for MAL tracking 2020-12-30 15:08:10 -05:00
3df98d576e Fix crash on updating trackers after reading with no network (closes #4207) 2020-12-29 09:21:18 -05:00
8135136c86 Adjust download icon sizing 2020-12-29 09:15:32 -05:00
cef1c4b8a1 Tweak manga info header backdrop 2020-12-27 17:58:03 -05:00
2e8791a101 Refactor tracker response parsing 2020-12-27 17:46:14 -05:00
0e2b8b10d1 Show download progress in download icons 2020-12-27 16:50:25 -05:00
3cb64669e4 Animate download icon 2020-12-27 16:41:28 -05:00
bc0d32f330 Add Telugu to language setting 2020-12-27 15:50:37 -05:00
0db17beacc Weblate translations (#4173)
Co-authored-by: Ajeje Brazorf <lmelonimamo@yahoo.it>
Co-authored-by: Alessandro Jean <alessandrojean@gmail.com>
Co-authored-by: Alessandro Zangrandi <alessandro@mzit.it>
Co-authored-by: Alex <linuxrf@gmail.com>
Co-authored-by: Alperen Arslan <slyvioborin@gmail.com>
Co-authored-by: Andreas E <andreas.everos@gmail.com>
Co-authored-by: Ava <Sasu.ruotsalainen@live.fi>
Co-authored-by: C201 <derasetad@gmail.com>
Co-authored-by: Crazyom <naxom@laposte.net>
Co-authored-by: Damiano Mason <damicricio99@gmail.com>
Co-authored-by: Dams <qashdzn@gmail.com>
Co-authored-by: DarKCroX <darkcrox.2020@outlook.com>
Co-authored-by: Edgar Mejía <edgar13155@gmail.com>
Co-authored-by: Eduard Ereza Martínez <eduard@ereza.cat>
Co-authored-by: Eric <spice2wolf@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Huang Zhiyi <hzy980512@126.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Kurocon <weblate@kurocon.nl>
Co-authored-by: Luis Andrés Bajaña F <labfernandez2014@gmail.com>
Co-authored-by: Lyaiya <hipsnafoha@outlook.com>
Co-authored-by: Lzmxya <lzmxya@gmail.com>
Co-authored-by: MINEJHAZZ <minecrafterngt@gmail.com>
Co-authored-by: Matteo Gaeta <matteo.gaeta.1998@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Nicolas Meunier <minecraft.arsenof@gmail.com>
Co-authored-by: Nikola Perović <nikolaperovicccc@gmail.com>
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
Co-authored-by: Paulo Pinho <kebrus@gmail.com>
Co-authored-by: Pitpe11 <giorgos2550@gmail.com>
Co-authored-by: Redya Rachmandanu <redyarachmandanu@gmail.com>
Co-authored-by: Rostyslav <info@ubilling.net.ua>
Co-authored-by: Samiul Azam <yeasin.sanny99@gmail.com>
Co-authored-by: Shashank Pujari <shashankppujari@gmail.com>
Co-authored-by: SmolderingGummy <bairamsaieesh@gmail.com>
Co-authored-by: Soitora <simon.mattila@protonmail.com>
Co-authored-by: Tooster <max@polarczyk.pl>
Co-authored-by: Woodyx <shiposhouyou@gmail.com>
Co-authored-by: Xoko14 <xoquinperezb@gmail.com>
Co-authored-by: Yassin <yassinelaoud@gmail.com>
Co-authored-by: Zulkifli <zulhaha1@gmail.com>
Co-authored-by: darkbeast13 <nikhil15mps@gmail.com>
Co-authored-by: vlad hateg <vhateg@gmail.com>
Co-authored-by: Đỗ Vũ Minh Quang <quacmichael@gmail.com>
Co-authored-by: Роман <Rozhenkov69@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ar/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/bn/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ca/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/cv/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/de/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/el/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/es/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/es_419/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fil/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/gl/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hi/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/hr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/id/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/it/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ja/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/kn/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ms/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/nl/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ro/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sc/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/sv/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/te/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/tr/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/uk/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/vi/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/zh_Hant/
Translation: Tachiyomi/Strings
Translation: Tachiyomi/Tachiyomi 0.x

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Ajeje Brazorf <lmelonimamo@yahoo.it>
Co-authored-by: Alessandro Jean <alessandrojean@gmail.com>
Co-authored-by: Alessandro Zangrandi <alessandro@mzit.it>
Co-authored-by: Alex <linuxrf@gmail.com>
Co-authored-by: Alperen Arslan <slyvioborin@gmail.com>
Co-authored-by: Andreas E <andreas.everos@gmail.com>
Co-authored-by: Ava <Sasu.ruotsalainen@live.fi>
Co-authored-by: C201 <derasetad@gmail.com>
Co-authored-by: Crazyom <naxom@laposte.net>
Co-authored-by: Damiano Mason <damicricio99@gmail.com>
Co-authored-by: Dams <qashdzn@gmail.com>
Co-authored-by: DarKCroX <darkcrox.2020@outlook.com>
Co-authored-by: Edgar Mejía <edgar13155@gmail.com>
Co-authored-by: Eduard Ereza Martínez <eduard@ereza.cat>
Co-authored-by: Eric <spice2wolf@gmail.com>
Co-authored-by: Huang Zhiyi <hzy980512@126.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Kurocon <weblate@kurocon.nl>
Co-authored-by: Luis Andrés Bajaña F <labfernandez2014@gmail.com>
Co-authored-by: Lyaiya <hipsnafoha@outlook.com>
Co-authored-by: Lzmxya <lzmxya@gmail.com>
Co-authored-by: MINEJHAZZ <minecrafterngt@gmail.com>
Co-authored-by: Matteo Gaeta <matteo.gaeta.1998@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Nicolas Meunier <minecraft.arsenof@gmail.com>
Co-authored-by: Nikola Perović <nikolaperovicccc@gmail.com>
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
Co-authored-by: Paulo Pinho <kebrus@gmail.com>
Co-authored-by: Pitpe11 <giorgos2550@gmail.com>
Co-authored-by: Redya Rachmandanu <redyarachmandanu@gmail.com>
Co-authored-by: Rostyslav <info@ubilling.net.ua>
Co-authored-by: Samiul Azam <yeasin.sanny99@gmail.com>
Co-authored-by: Shashank Pujari <shashankppujari@gmail.com>
Co-authored-by: SmolderingGummy <bairamsaieesh@gmail.com>
Co-authored-by: Soitora <simon.mattila@protonmail.com>
Co-authored-by: Tooster <max@polarczyk.pl>
Co-authored-by: Woodyx <shiposhouyou@gmail.com>
Co-authored-by: Xoko14 <xoquinperezb@gmail.com>
Co-authored-by: Yassin <yassinelaoud@gmail.com>
Co-authored-by: Zulkifli <zulhaha1@gmail.com>
Co-authored-by: darkbeast13 <nikhil15mps@gmail.com>
Co-authored-by: vlad hateg <vhateg@gmail.com>
Co-authored-by: Đỗ Vũ Minh Quang <quacmichael@gmail.com>
Co-authored-by: Роман <Rozhenkov69@gmail.com>
2020-12-27 15:49:27 -05:00
931efed784 Use master branch for build workflow 2020-12-27 15:38:32 -05:00
6378a41b6d Minor cleanup of UpdatesHolder 2020-12-27 15:34:14 -05:00
23bf7faf9f Hide chapter download icons for local manga 2020-12-27 15:25:40 -05:00
01ff3af63f Add error state to chapter download icons 2020-12-27 10:44:08 -05:00
8f98055e9e Refactor response parsing helper function 2020-12-27 10:22:24 -05:00
84ae61f72c Refactor download states into enum 2020-12-27 10:20:14 -05:00
6dd280205b Initial chapter download icon implementation 2020-12-27 10:13:13 -05:00
1365d553a4 Log exception on invalid download directory issue 2020-12-26 18:05:39 -05:00
61a594493c Remove usage of Retrofit for update check and extensions list 2020-12-26 16:59:24 -05:00
62ab70f889 Remove unnecessary BaseWebViewActivity class 2020-12-26 11:44:21 -05:00
eaccfdde59 Replace circular ProgressBars with Material component 2020-12-26 11:24:10 -05:00
a8e536478c Replace ProgressBars with Material component 2020-12-26 10:40:45 -05:00
e94d5626dd Update dependencies 2020-12-26 10:40:45 -05:00
be3e31ddc4 Try to avoid crashing when unable to get backup filename for some reason 2020-12-26 10:40:45 -05:00
b92b6520cb Reader snackbar dismissible (#4190)
* Make Reading Mode Snackbar dismissible

* Change reader FrameLayout to CoordinatorLayout
2020-12-26 10:40:31 -05:00
ea33179a95 Convert tracker add/update/login methods to coroutines 2020-12-24 17:50:28 -05:00
6fcf6ae1f5 Use coroutines for Bangumi and Shikimori APIs 2020-12-24 17:23:10 -05:00
f2a9247b68 Remove usage of Gson in Kitsu tracker 2020-12-24 17:02:33 -05:00
dc3ed7fffc Use coroutines for Anilist API 2020-12-24 16:55:04 -05:00
271de31d51 Migrate Kitsu API to coroutines and kotlinx.serialization 2020-12-24 16:39:28 -05:00
1268caf3e0 Make OkHttp coroutine calls always throw exceptions on errors 2020-12-24 15:36:57 -05:00
c0cef58e39 Surface MAL HTTP errors properly 2020-12-24 15:09:10 -05:00
d363d205c3 Fix trackers after MAL not updating 2020-12-21 17:31:31 -05:00
2fd5a9e883 Filter out novels from MAL search results 2020-12-21 17:24:35 -05:00
e7ef974a39 Format MAL search result status/type better 2020-12-21 17:24:25 -05:00
0b62fa8b76 Bump versionCode so next build will force MAL logout 2020-12-21 17:10:08 -05:00
2d28750782 Remove License Appendix That Should Have Been Removed Initially (#4158)
http://www.apache.org/licenses/LICENSE-2.0 makes it pretty clear the appendix is supposed to be separate from the rest of the license. I guess inorichi just forget to get rid of it at the end of LICENSES.txt
2020-12-19 18:41:02 -05:00
e2054a0ab7 Update NDK & buildTools, fix deprecated Parcelize (#4157)
* Update buildTools and NDK

* Fix deprecated Parcelize
2020-12-19 18:40:44 -05:00
7ae5c3b2e7 [SKIP CI] Add preview note for MAL rewrite 2020-12-18 23:37:58 -05:00
6e7fefb8b2 Strip out no longer used tracker start/end date code
Unfortunately MAL was the only one that supported it in the app, but the official API doesn't support it, so now no trackers have it.
2020-12-18 23:31:28 -05:00
450bef278b Add forced MAL logout for next release migration 2020-12-18 23:20:42 -05:00
0affc0d58b Migrate to official MyAnimeList API (closes #4140) 2020-12-18 23:18:31 -05:00
3d153b6c8e Don't surface hidden settings when searching 2020-12-15 23:17:20 -05:00
04fff91e23 Update app repo URL 2020-12-15 15:56:16 -05:00
28a23452f2 Update extensions repo URL 2020-12-15 15:48:06 -05:00
6d403851cf Apply theme to OAuth login redirect activities 2020-12-14 23:00:01 -05:00
395a749bce Misc tracker code cleanup 2020-12-14 22:56:33 -05:00
2cc2a90941 Refactor CustomTabsIntent creation 2020-12-13 22:30:27 -05:00
481 changed files with 12388 additions and 7773 deletions

View File

@ -1,33 +0,0 @@
1. **Before reporting a new issue, take a look at the [FAQ](https://tachiyomi.org/help/faq/), the [changelog](https://github.com/inorichi/tachiyomi/releases) and the already opened [issues](https://github.com/inorichi/tachiyomi/issues).**
2. If you are unsure, ask here: [![Discord](https://img.shields.io/discord/349436576037732353.svg)](https://discord.gg/tachiyomi)
3. What is your type of issue?
* [Catalogue request](#catalogue-requests)
* [Bugs](#bugs)
* [Feature requests](#feature-requests)
* [Translations](https://tachiyomi.org/help/contribution/#translation)
4. After following 1. and 3. you can [open your issue](https://github.com/inorichi/tachiyomi/issues/new)
***
# Catalogue requests
* Catalogue requests should be created at https://github.com/inorichi/tachiyomi-extensions#readme, not here
# Bugs
* Include version (More > About > Version)
* If not latest, try updating, it may have already been solved
* Preview version is equal to the number of commits as seen in the main page
* Include steps to reproduce (if not obvious from description)
* Include screenshot (if needed)
* If it could be device-dependent, try reproducing on another device (if possible)
* For large logs use http://pastebin.com/ (or similar)
* Don't group unrelated requests into one issue
DO: https://github.com/inorichi/tachiyomi/issues/24 https://github.com/inorichi/tachiyomi/issues/71
DON'T: https://github.com/inorichi/tachiyomi/issues/75
# Feature requests
* Write a detailed issue, explaining what it should do or how. Avoid writing just "like X app does"
* Include screenshot (if needed)

View File

@ -2,9 +2,9 @@
I acknowledge that:
- I have updated to the latest version of the app (stable is v0.10.7)
- I have updated to the latest version of the app (stable is v0.10.10)
- I have updated all extensions
- If this is an issue with an extension, that I should be opening an issue in https://github.com/inorichi/tachiyomi-extensions
- If this is an issue with an extension, that I should be opening an issue in https://github.com/tachiyomiorg/tachiyomi-extensions
**DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT**
@ -24,3 +24,5 @@ I acknowledge that:
## Other details
Additional details and attachments.
If you're experiencing crashes, share the crash logs from More → Settings → Advanced → Dump crash logs.

View File

@ -9,9 +9,9 @@ labels: "bug"
I acknowledge that:
- I have updated to the latest version of the app (stable is v0.10.7)
- I have updated to the latest version of the app (stable is v0.10.10)
- I have updated all extensions
- If this is an issue with an extension, that I should be opening an issue in https://github.com/inorichi/tachiyomi-extensions
- If this is an issue with an extension, that I should be opening an issue in https://github.com/tachiyomiorg/tachiyomi-extensions
**DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT**
@ -34,3 +34,5 @@ This happened instead.
## Other details
Additional details and attachments.
If you're experiencing crashes, share the crash logs from More → Settings → Advanced → Dump crash logs.

View File

@ -4,5 +4,5 @@ contact_links:
url: https://tachiyomi.org/help/
about: Common questions are answered here.
- name: Tachiyomi extensions GitHub repository
url: https://github.com/inorichi/tachiyomi-extensions
url: https://github.com/tachiyomiorg/tachiyomi-extensions
about: Issues about an extension/source/catalogue should be opened here instead.

View File

@ -9,9 +9,9 @@ labels: "feature"
I acknowledge that:
- I have updated to the latest version of the app (stable is v0.10.7)
- I have updated to the latest version of the app (stable is v0.10.10)
- I have updated all extensions
- If this is an issue with an extension, that I should be opening an issue in https://github.com/inorichi/tachiyomi-extensions
- If this is an issue with an extension, that I should be opening an issue in https://github.com/tachiyomiorg/tachiyomi-extensions
**DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT**

View File

@ -1,8 +1,8 @@
---
name: "Extension/source/catalogue issue"
about: "Do not open an issue here. See https://github.com/inorichi/tachiyomi-extensions"
title: "THIS ISSUE IS IN THE WRONG REPO; SEE https://github.com/inorichi/tachiyomi-extensions"
about: "Do not open an issue here. See https://github.com/tachiyomiorg/tachiyomi-extensions"
title: "THIS ISSUE IS IN THE WRONG REPO; SEE https://github.com/tachiyomiorg/tachiyomi-extensions"
labels: "catalog, invalid"
---
DO NOT OPEN AN ISSUE IN THIS REPO. SEE https://github.com/inorichi/tachiyomi-extensions
DO NOT OPEN AN ISSUE IN THIS REPO. SEE https://github.com/tachiyomiorg/tachiyomi-extensions

View File

@ -2,7 +2,7 @@ name: CI
on:
push:
branches:
- dev
- master
tags:
- v*
pull_request:
@ -34,10 +34,10 @@ jobs:
- name: Clone repo
uses: actions/checkout@v2
- name: Set up JDK 1.8
- name: Set up JDK 11
uses: actions/setup-java@v1
with:
java-version: 1.8
java-version: 11
- name: Copy CI gradle.properties
run: |
@ -55,14 +55,14 @@ jobs:
# Sign APK and create release for tags
- name: Get tag name
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'inorichi/tachiyomi'
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'tachiyomiorg/tachiyomi'
id: get_tag_name
run: |
set -x
echo "VERSION_TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
- name: Sign APK
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'inorichi/tachiyomi'
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'tachiyomiorg/tachiyomi'
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/apk/standard/release
@ -71,25 +71,24 @@ jobs:
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Create release
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'inorichi/tachiyomi'
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Clean up build artifacts
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'tachiyomiorg/tachiyomi'
run: |
cp ${{ env.SIGNED_RELEASE_FILE }} tachiyomi-${{ env.VERSION_TAG }}.apk
md5=`md5sum tachiyomi-${{ env.VERSION_TAG }}.apk | awk '{ print $1 }'`
echo "APK_MD5=$md5" >> $GITHUB_ENV
- name: Create Release
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'tachiyomiorg/tachiyomi'
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ env.VERSION_TAG }}
release_name: Tachiyomi ${{ env.VERSION_TAG }}
draft: true
name: Tachiyomi ${{ env.VERSION_TAG }}
body: |
MD5: ${{ env.APK_MD5 }}
files: |
tachiyomi-${{ env.VERSION_TAG }}.apk
draft: ${{ github.event.inputs.dry-run != '' }}
prerelease: false
- name: Upload APK to release
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'inorichi/tachiyomi'
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ${{ env.SIGNED_RELEASE_FILE }}
asset_name: tachiyomi-${{ env.VERSION_TAG }}.apk
asset_content_type: application/vnd.android.package-archive

View File

@ -7,31 +7,30 @@ jobs:
autoclose:
runs-on: ubuntu-latest
steps:
- name: Autoclose when created in wrong repo
uses: arkon/issue-closer-action@v1.1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
type: title
regex: ".*THIS ISSUE IS IN THE WRONG REPO.*"
message: "@${issue.user.login} this issue was automatically closed because it was not opened in the correct repo, as the template mentioned."
- name: Autoclose when no short description provided
uses: arkon/issue-closer-action@v1.1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
type: title
regex: ".*<Write short description here>*"
message: "@${issue.user.login} this issue was automatically closed because you did not fill out the description in the title."
- name: Autoclose when body acknowledgement section not removed
uses: arkon/issue-closer-action@v1.1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
type: body
regex: ".*DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT.*"
message: "@${issue.user.login} this issue was automatically closed because the acknowledgment section was not removed."
- name: Autoclose when body requested information not filled out
uses: arkon/issue-closer-action@v1.1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
type: body
regex: ".*\\* (Tachiyomi version|Android version|Device): \\?.*"
message: "@${issue.user.login} this issue was automatically closed because the requested information was not filled out."
- name: Autoclose issues
uses: arkon/issue-closer-action@v3.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
rules: |
[
{
"type": "title",
"regex": ".*THIS ISSUE IS IN THE WRONG REPO.*",
"message": "It was not opened in the correct repo, as the template mentioned."
},
{
"type": "title",
"regex": ".*<Write short description here>*",
"message": "The description in the title was not filled out."
},
{
"type": "body",
"regex": ".*DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT.*",
"message": "The acknowledgment section was not removed."
},
{
"type": "body",
"regex": ".*\\* (Tachiyomi version|Android version|Device): \\?.*",
"message": "Requested information in the template was not filled out."
}
]

19
.github/workflows/lock.yml vendored Normal file
View File

@ -0,0 +1,19 @@
name: Lock threads
on:
# Daily
schedule:
- cron: '0 * * * *'
# Manual trigger
workflow_dispatch:
inputs:
jobs:
lock:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v2
with:
github-token: ${{ github.token }}
issue-lock-inactive-days: '2'
pr-lock-inactive-days: '2'

76
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,76 @@
# Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at the Tachiyomi [Discord server](https://discord.gg/tachiyomi). All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq

34
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,34 @@
Looking to report an issue/bug or make a feature request? Please refer to the [README file](https://github.com/tachiyomiorg/tachiyomi#issues-feature-requests-and-contributing).
---
Thanks for your interest in contributing to Tachiyomi!
# Code contributions
Pull requests are welcome!
If you're interested in taking on [an open issue](https://github.com/tachiyomiorg/tachiyomi/issues), please comment on it so others are aware.
# Translations
Translations are done externally via Weblate. See [our website](https://tachiyomi.org/help/contribution/#translation) for more details.
# Forks
Forks are allowed so long as they abide by [the project's LICENSE](https://github.com/tachiyomiorg/tachiyomi/blob/master/LICENSE).
When creating a fork, remember to:
- To avoid confusion with the main app:
- Change the app name
- Change the app icon
- Change or disable the [app update checker](https://github.com/tachiyomiorg/tachiyomi/blob/master/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubUpdateChecker.kt)
- To avoid installation conflicts:
- Change the `applicationId` in [`build.gradle.kts`](https://github.com/tachiyomiorg/tachiyomi/blob/master/app/build.gradle.kts)
- To avoid having your data polluting the main app's analytics and crash report services:
- If you want to use Firebase analytics, replace [`google-services.json`](https://github.com/tachiyomiorg/tachiyomi/blob/master/app/src/standard/google-services.json) with your own
- If you want to use ACRA crash reporting, replace the `ACRA_URI` endpoint in [`build.gradle.kts`](https://github.com/tachiyomiorg/tachiyomi/blob/master/app/build.gradle.kts) with your own

26
LICENSE
View File

@ -174,29 +174,3 @@
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -1,3 +1,6 @@
### r2903
- The MyAnimeList tracker was rewritten. You will need to log out and log in again.
### r1810
- Background jobs were migrated to a new system. You may need to toggle the settings to ensure they
run properly. This includes app updates, library updates, and automatic backups.

View File

@ -1,6 +1,6 @@
| Build | Stable | Weekly Preview | Contribute | Support Server |
|-------|----------|---------|------------|---------|
| ![CI](https://github.com/inorichi/tachiyomi/workflows/CI/badge.svg?branch=dev&event=push) | [![stable release](https://img.shields.io/github/release/inorichi/tachiyomi.svg?maxAge=3600&label=download)](https://github.com/inorichi/tachiyomi/releases) | [![latest weekly build](https://img.shields.io/github/v/release/tachiyomiorg/android-app-preview.svg?maxAge=3600&label=download)](https://github.com/tachiyomiorg/android-app-preview/releases) | [![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) |
| ![CI](https://github.com/tachiyomiorg/tachiyomi/workflows/CI/badge.svg?branch=dev&event=push) | [![stable release](https://img.shields.io/github/release/tachiyomiorg/tachiyomi.svg?maxAge=3600&label=download)](https://github.com/tachiyomiorg/tachiyomi/releases) | [![latest weekly build](https://img.shields.io/github/v/release/tachiyomiorg/tachiyomi-preview.svg?maxAge=3600&label=download)](https://github.com/tachiyomiorg/tachiyomi-preview/releases) | [![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?label=discord&labelColor=7289da&color=2c2f33&style=flat)](https://discord.gg/tachiyomi) |
# ![app icon](./.github/readme-images/app-icon.png)Tachiyomi
@ -11,7 +11,7 @@ Tachiyomi is a free and open source manga reader for Android 5.0 and above.
## Features
Features include:
* Online reading from sources such as MangaDex, MangaSee, Mangakakalot, [and more](https://github.com/inorichi/tachiyomi-extensions)
* Online reading from sources such as MangaDex, MangaSee, Mangakakalot, [and more](https://github.com/tachiyomiorg/tachiyomi-extensions)
* Local reading of downloaded manga
* A configurable reader with multiple viewers, reading directions and other settings.
* [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), [Kitsu](https://kitsu.io/), [Shikimori](https://shikimori.one), and [Bangumi](https://bgm.tv/) support
@ -21,7 +21,7 @@ Features include:
* Create backups locally to read offline or to your desired cloud service
## Download
Get the app from our [releases page](https://github.com/inorichi/tachiyomi/releases).
Get the app from our [releases page](https://github.com/tachiyomiorg/tachiyomi/releases).
If you want to try new features before they get to the stable release, you can download the preview version [here](https://github.com/tachiyomiorg/android-app-preview/releases).
@ -31,7 +31,7 @@ Please make sure to read the full guidelines. Your issue may be closed without w
<details><summary>Issues</summary>
1. **Before reporting a new issue, take a look at the [FAQ](https://tachiyomi.org/help/faq/), the [changelog](https://github.com/inorichi/tachiyomi/releases) and the already opened [issues](https://github.com/inorichi/tachiyomi/issues).**
1. **Before reporting a new issue, take a look at the [FAQ](https://tachiyomi.org/help/faq/), the [changelog](https://github.com/tachiyomiorg/tachiyomi/releases) and the already opened [issues](https://github.com/tachiyomiorg/tachiyomi/issues).**
2. If you are unsure, ask here: [![Discord](https://img.shields.io/discord/349436576037732353.svg)](https://discord.gg/tachiyomi)
</details>
@ -47,9 +47,9 @@ Please make sure to read the full guidelines. Your issue may be closed without w
* For large logs use http://pastebin.com/ (or similar)
* Don't group unrelated requests into one issue
DO: https://github.com/inorichi/tachiyomi/issues/24 https://github.com/inorichi/tachiyomi/issues/71
DO: https://github.com/tachiyomiorg/tachiyomi/issues/24 https://github.com/tachiyomiorg/tachiyomi/issues/71
DON'T: https://github.com/inorichi/tachiyomi/issues/75
DON'T: https://github.com/tachiyomiorg/tachiyomi/issues/75
</details>
@ -58,7 +58,17 @@ DON'T: https://github.com/inorichi/tachiyomi/issues/75
* Write a detailed issue, explaining what it should do or how. Avoid writing just "like X app does"
* Include screenshot (if needed)
Source requests should be created at https://github.com/inorichi/tachiyomi-extensions, they do not belong in this repository.
Source requests should be created at https://github.com/tachiyomiorg/tachiyomi-extensions, they do not belong in this repository.
</details>
<details><summary>Contributing</summary>
See [CONTRIBUTING.md](./CONTRIBUTING.md).
</details>
<details><summary>Code of Conduct</summary>
See [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md).
</details>
## FAQ

View File

@ -1,323 +0,0 @@
import org.jetbrains.kotlin.gradle.tasks.AbstractKotlinCompile
import java.text.SimpleDateFormat
apply plugin: 'com.android.application'
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-parcelize'
apply plugin: 'kotlinx-serialization'
apply plugin: 'com.github.zellius.shortcut-helper'
shortcutHelper.filePath = './shortcuts.xml'
ext {
// Git is needed in your system PATH for these commands to work.
// If it's not installed, you can return a random value as a workaround
getCommitCount = {
return 'git rev-list --count HEAD'.execute().text.trim()
// return "1"
}
getGitSha = {
return 'git rev-parse --short HEAD'.execute().text.trim()
// return "1"
}
getBuildTime = {
def df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'")
df.setTimeZone(TimeZone.getTimeZone("UTC"))
return df.format(new Date())
}
}
android {
compileSdkVersion AndroidConfig.compileSdk
buildToolsVersion AndroidConfig.buildTools
ndkVersion AndroidConfig.ndk
defaultConfig {
applicationId "eu.kanade.tachiyomi"
minSdkVersion AndroidConfig.minSdk
targetSdkVersion AndroidConfig.targetSdk
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
versionCode 53
versionName "0.10.7"
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
buildConfigField "String", "BUILD_TIME", "\"${getBuildTime()}\""
buildConfigField "boolean", "INCLUDE_UPDATER", "false"
multiDexEnabled true
ndk {
abiFilters "armeabi-v7a", "arm64-v8a", "x86"
}
}
buildFeatures {
viewBinding = true
}
buildTypes {
debug {
versionNameSuffix "-${getCommitCount()}"
applicationIdSuffix ".debug"
}
release {
// postprocessing {
// obfuscate false
// optimizeCode true
// removeUnusedCode false
// removeUnusedResources true
// proguardFiles 'proguard-rules.pro'
// }
}
}
flavorDimensions "default"
productFlavors {
standard {
buildConfigField "boolean", "INCLUDE_UPDATER", "true"
dimension "default"
}
fdroid {
dimension "default"
}
dev {
resConfigs "en", "xxhdpi"
dimension "default"
}
}
packagingOptions {
exclude 'META-INF/DEPENDENCIES'
exclude 'LICENSE.txt'
exclude 'META-INF/LICENSE'
exclude 'META-INF/LICENSE.txt'
exclude 'META-INF/NOTICE'
}
dependenciesInfo {
includeInApk = false
}
lintOptions {
disable 'MissingTranslation'
disable 'ExtraTranslation'
abortOnError false
checkReleaseBuilds false
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
}
dependencies {
// Source models and interfaces from Tachiyomi 1.x
implementation 'tachiyomi.sourceapi:source-api:1.1'
// AndroidX libraries
implementation 'androidx.annotation:annotation:1.2.0-alpha01'
implementation 'androidx.appcompat:appcompat:1.3.0-alpha02'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha01'
implementation 'androidx.browser:browser:1.3.0'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.0-alpha1'
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.1.0'
implementation 'androidx.core:core-ktx:1.5.0-alpha05'
implementation 'androidx.multidex:multidex:2.0.1'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'androidx.recyclerview:recyclerview:1.2.0-beta01'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01'
final lifecycle_version = '2.3.0-beta01'
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
// Job scheduling
implementation "androidx.work:work-runtime-ktx:2.5.0-beta02"
// UI library
implementation 'com.google.android.material:material:1.3.0-alpha04'
standardImplementation 'com.google.firebase:firebase-core:18.0.0'
// ReactiveX
implementation 'io.reactivex:rxandroid:1.2.1'
implementation 'io.reactivex:rxjava:1.3.8'
implementation 'com.jakewharton.rxrelay:rxrelay:1.2.0'
implementation 'com.github.pwittchen:reactivenetwork:0.13.0'
// Network client
final okhttp_version = '4.10.0-RC1'
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version"
implementation "com.squareup.okhttp3:okhttp-dnsoverhttps:$okhttp_version"
implementation 'com.squareup.okio:okio:2.9.0'
// TLS 1.3 support for Android < 10
implementation 'org.conscrypt:conscrypt-android:2.5.1'
// REST
final retrofit_version = '2.9.0'
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
implementation "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0"
implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"
implementation "com.squareup.retrofit2:adapter-rxjava:$retrofit_version"
// JSON
final kotlin_serialization_version = '1.0.1'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlin_serialization_version"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-protobuf:$kotlin_serialization_version"
implementation 'com.google.code.gson:gson:2.8.6'
implementation 'com.github.salomonbrys.kotson:kotson:2.5.0'
// JavaScript engine
implementation 'com.squareup.duktape:duktape-android:1.3.0'
// Disk
implementation 'com.jakewharton:disklrucache:2.0.2'
implementation 'com.github.inorichi:unifile:e9ee588'
implementation 'com.github.junrar:junrar:7.4.0'
// HTML parser
implementation 'org.jsoup:jsoup:1.13.1'
// Database
implementation 'androidx.sqlite:sqlite-ktx:2.1.0'
implementation 'com.github.inorichi.storio:storio-common:8be19de@aar'
implementation 'com.github.inorichi.storio:storio-sqlite:8be19de@aar'
implementation 'io.requery:sqlite-android:3.33.0'
// Preferences
implementation 'com.github.tfcporciuncula.flow-preferences:flow-preferences:1.3.3'
// Model View Presenter
final nucleus_version = '3.0.0'
implementation "info.android15.nucleus:nucleus:$nucleus_version"
implementation "info.android15.nucleus:nucleus-support-v7:$nucleus_version"
// Dependency injection
implementation "com.github.inorichi.injekt:injekt-core:65b0440"
// Image library
final glide_version = '4.11.0'
implementation "com.github.bumptech.glide:glide:$glide_version"
implementation "com.github.bumptech.glide:okhttp3-integration:$glide_version"
kapt "com.github.bumptech.glide:compiler:$glide_version"
implementation 'com.github.tachiyomiorg:subsampling-scale-image-view:6caf219'
// Logging
implementation 'com.jakewharton.timber:timber:4.7.1'
// Crash reports
implementation 'ch.acra:acra-http:5.7.0'
// Sort
implementation 'com.github.gpanther:java-nat-sort:natural-comparator-1.1'
// UI
implementation 'com.dmitrymalkovich.android:material-design-dimens:1.4'
implementation 'com.github.dmytrodanylyk.android-process-button:library:1.0.4'
implementation 'eu.davidea:flexible-adapter:5.1.0'
implementation 'eu.davidea:flexible-adapter-ui:1.0.0'
implementation 'com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.1.0'
implementation 'com.github.chrisbanes:PhotoView:2.3.0'
implementation 'com.github.carlosesco:DirectionalViewPager:a844dbca0a'
// 3.2.0+ introduces weird UI blinking or cut off issues on some devices
final material_dialogs_version = '3.1.1'
implementation "com.afollestad.material-dialogs:core:$material_dialogs_version"
implementation "com.afollestad.material-dialogs:input:$material_dialogs_version"
implementation "com.afollestad.material-dialogs:datetime:$material_dialogs_version"
// Conductor
implementation 'com.bluelinelabs:conductor:2.1.5'
implementation("com.bluelinelabs:conductor-support:2.1.5") {
exclude group: "com.android.support"
}
implementation 'com.github.tachiyomiorg:conductor-support-preference:1.1.1'
// FlowBinding
final flowbinding_version = '0.12.0'
implementation "io.github.reactivecircus.flowbinding:flowbinding-android:$flowbinding_version"
implementation "io.github.reactivecircus.flowbinding:flowbinding-appcompat:$flowbinding_version"
implementation "io.github.reactivecircus.flowbinding:flowbinding-recyclerview:$flowbinding_version"
implementation "io.github.reactivecircus.flowbinding:flowbinding-swiperefreshlayout:$flowbinding_version"
implementation "io.github.reactivecircus.flowbinding:flowbinding-viewpager:$flowbinding_version"
// Licenses
implementation "com.mikepenz:aboutlibraries:$BuildPluginsVersion.ABOUTLIB_PLUGIN"
// Tests
testImplementation 'junit:junit:4.13'
testImplementation 'org.assertj:assertj-core:3.16.1'
testImplementation 'org.mockito:mockito-core:1.10.19'
final robolectric_version = '3.1.4'
testImplementation "org.robolectric:robolectric:$robolectric_version"
testImplementation "org.robolectric:shadows-multidex:$robolectric_version"
testImplementation "org.robolectric:shadows-play-services:$robolectric_version"
implementation "org.jetbrains.kotlin:kotlin-reflect:$BuildPluginsVersion.KOTLIN"
final coroutines_version = '1.4.2'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
// For detecting memory leaks; see https://square.github.io/leakcanary/
// debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.4'
}
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$BuildPluginsVersion.KOTLIN"
}
}
repositories {
mavenCentral()
jcenter()
}
// See https://kotlinlang.org/docs/reference/experimental.html#experimental-status-of-experimental-api-markers
tasks.withType(AbstractKotlinCompile).all {
kotlinOptions.freeCompilerArgs += [
"-Xopt-in=kotlin.Experimental",
"-Xopt-in=kotlin.RequiresOptIn",
"-Xuse-experimental=kotlin.ExperimentalStdlibApi",
"-Xuse-experimental=kotlinx.coroutines.FlowPreview",
"-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-Xuse-experimental=kotlinx.serialization.ExperimentalSerializationApi",
]
}
// Duplicating Hebrew string assets due to some locale code issues on different devices
task copyHebrewStrings(type: Copy) {
from './src/main/res/values-he'
into './src/main/res/values-iw'
include '**/*'
}
preBuild.dependsOn(formatKotlin, copyHebrewStrings)
if (getGradle().getStartParameter().getTaskRequests().toString().contains("Standard")) {
apply plugin: 'com.google.gms.google-services'
}

333
app/build.gradle.kts Normal file
View File

@ -0,0 +1,333 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.io.ByteArrayOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.TimeZone
plugins {
id("com.android.application")
id("com.mikepenz.aboutlibraries.plugin")
kotlin("android")
kotlin("kapt")
kotlin("plugin.serialization")
id("com.github.zellius.shortcut-helper")
}
if (gradle.startParameter.taskRequests.toString().contains("Standard")) {
apply(plugin = "com.google.gms.google-services")
}
shortcutHelper.setFilePath("./shortcuts.xml")
android {
compileSdkVersion(AndroidConfig.compileSdk)
buildToolsVersion(AndroidConfig.buildTools)
ndkVersion = AndroidConfig.ndk
defaultConfig {
applicationId = "eu.kanade.tachiyomi"
minSdkVersion(AndroidConfig.minSdk)
targetSdkVersion(AndroidConfig.targetSdk)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
versionCode = 57
versionName = "0.10.10"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
buildConfigField("String", "BUILD_TIME", "\"${getBuildTime()}\"")
buildConfigField("boolean", "INCLUDE_UPDATER", "false")
// Please disable ACRA or use your own instance in forked versions of the project
buildConfigField("String", "ACRA_URI", "\"https://tachiyomi.kanade.eu/crash_report\"")
multiDexEnabled = true
ndk {
abiFilters += setOf("armeabi-v7a", "arm64-v8a", "x86")
}
}
buildFeatures {
viewBinding = true
}
buildTypes {
named("debug") {
versionNameSuffix = "-${getCommitCount()}"
applicationIdSuffix = ".debug"
}
named("release") {
/*named("postprocessing") {
postprocessing {
isObfuscate = false
isOptimizeCode = true
isRemoveUnusedCode = false
isRemoveUnusedResources = true
}
setProguardFiles(listOf(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"))
}*/
}
}
flavorDimensions("default")
productFlavors {
create("standard") {
buildConfigField("boolean", "INCLUDE_UPDATER", "true")
dimension = "default"
}
create("fdroid") {
dimension = "default"
}
create("dev") {
resConfigs("en", "xxhdpi")
dimension = "default"
}
}
packagingOptions {
exclude("META-INF/DEPENDENCIES")
exclude("LICENSE.txt")
exclude("META-INF/LICENSE")
exclude("META-INF/LICENSE.txt")
exclude("META-INF/NOTICE")
exclude("META-INF/*.kotlin_module")
}
dependenciesInfo {
includeInApk = false
}
lintOptions {
disable("MissingTranslation", "ExtraTranslation")
isAbortOnError = false
isCheckReleaseBuilds = false
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
}
dependencies {
// Source models and interfaces from Tachiyomi 1.x
implementation("tachiyomi.sourceapi:source-api:1.1")
// AndroidX libraries
implementation("androidx.annotation:annotation:1.3.0-alpha01")
implementation("androidx.appcompat:appcompat:1.3.0-rc01")
implementation("androidx.biometric:biometric-ktx:1.2.0-alpha03")
implementation("androidx.browser:browser:1.3.0")
implementation("androidx.cardview:cardview:1.0.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.0-beta01")
implementation("androidx.coordinatorlayout:coordinatorlayout:1.1.0")
implementation("androidx.core:core-ktx:1.3.2")
implementation("androidx.multidex:multidex:2.0.1")
implementation("androidx.preference:preference-ktx:1.1.1")
implementation("androidx.recyclerview:recyclerview:1.2.0")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01")
val lifecycleVersion = "2.3.0"
implementation("androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-process:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion")
// Job scheduling
implementation("androidx.work:work-runtime-ktx:2.5.0")
// UI library
implementation("com.google.android.material:material:1.3.0")
"standardImplementation"("com.google.firebase:firebase-core:18.0.3")
// ReactiveX
implementation("io.reactivex:rxandroid:1.2.1")
implementation("io.reactivex:rxjava:1.3.8")
implementation("com.jakewharton.rxrelay:rxrelay:1.2.0")
implementation("com.github.pwittchen:reactivenetwork:0.13.0")
// Network client
val okhttpVersion = "5.0.0-alpha.2"
implementation("com.squareup.okhttp3:okhttp:$okhttpVersion")
implementation("com.squareup.okhttp3:logging-interceptor:$okhttpVersion")
implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:$okhttpVersion")
implementation("com.squareup.okio:okio:2.10.0")
// TLS 1.3 support for Android < 10
implementation("org.conscrypt:conscrypt-android:2.5.1")
// JSON
val kotlinSerializationVersion = "1.0.1"
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinSerializationVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:$kotlinSerializationVersion")
implementation("com.google.code.gson:gson:2.8.6")
implementation("com.github.salomonbrys.kotson:kotson:2.5.0")
// JavaScript engine
implementation("com.squareup.duktape:duktape-android:1.3.0")
// Disk
implementation("com.jakewharton:disklrucache:2.0.2")
implementation("com.github.tachiyomiorg:unifile:e9e3a40")
implementation("com.github.junrar:junrar:7.4.0")
// HTML parser
implementation("org.jsoup:jsoup:1.13.1")
// Database
implementation("androidx.sqlite:sqlite-ktx:2.1.0")
implementation("com.github.inorichi.storio:storio-common:8be19de@aar")
implementation("com.github.inorichi.storio:storio-sqlite:8be19de@aar")
implementation("io.requery:sqlite-android:3.33.0")
// Preferences
implementation("com.github.tfcporciuncula.flow-preferences:flow-preferences:1.3.4")
// Model View Presenter
val nucleusVersion = "3.0.0"
implementation("info.android15.nucleus:nucleus:$nucleusVersion")
implementation("info.android15.nucleus:nucleus-support-v7:$nucleusVersion")
// Dependency injection
implementation("com.github.inorichi.injekt:injekt-core:65b0440")
// Image library
val glideVersion = "4.12.0"
implementation("com.github.bumptech.glide:glide:$glideVersion")
implementation("com.github.bumptech.glide:okhttp3-integration:$glideVersion")
kapt("com.github.bumptech.glide:compiler:$glideVersion")
implementation("com.github.tachiyomiorg:subsampling-scale-image-view:547d9c0")
// Logging
implementation("com.jakewharton.timber:timber:4.7.1")
// Crash reports
implementation("ch.acra:acra-http:5.7.0")
// Sort
implementation("com.github.gpanther:java-nat-sort:natural-comparator-1.1")
// UI
implementation("com.dmitrymalkovich.android:material-design-dimens:1.4")
implementation("com.github.dmytrodanylyk.android-process-button:library:1.0.4")
implementation("eu.davidea:flexible-adapter:5.1.0")
implementation("eu.davidea:flexible-adapter-ui:1.0.0")
implementation("com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.1.0")
implementation("com.github.chrisbanes:PhotoView:2.3.0")
implementation("com.github.tachiyomiorg:DirectionalViewPager:1.0.0")
implementation("dev.chrisbanes.insetter:insetter:0.5.0")
// 3.2.0+ introduces weird UI blinking or cut off issues on some devices
val materialDialogsVersion = "3.1.1"
implementation("com.afollestad.material-dialogs:core:$materialDialogsVersion")
implementation("com.afollestad.material-dialogs:input:$materialDialogsVersion")
implementation("com.afollestad.material-dialogs:datetime:$materialDialogsVersion")
// Conductor
implementation("com.bluelinelabs:conductor:2.1.5")
implementation("com.bluelinelabs:conductor-support:2.1.5") {
exclude(group = "com.android.support")
}
implementation("com.github.tachiyomiorg:conductor-support-preference:2.0.1")
// FlowBinding
val flowbindingVersion = "0.12.0"
implementation("io.github.reactivecircus.flowbinding:flowbinding-android:$flowbindingVersion")
implementation("io.github.reactivecircus.flowbinding:flowbinding-appcompat:$flowbindingVersion")
implementation("io.github.reactivecircus.flowbinding:flowbinding-recyclerview:$flowbindingVersion")
implementation("io.github.reactivecircus.flowbinding:flowbinding-swiperefreshlayout:$flowbindingVersion")
implementation("io.github.reactivecircus.flowbinding:flowbinding-viewpager:$flowbindingVersion")
// Licenses
implementation("com.mikepenz:aboutlibraries:${BuildPluginsVersion.ABOUTLIB_PLUGIN}")
// Tests
testImplementation("junit:junit:4.13.2")
testImplementation("org.assertj:assertj-core:3.16.1")
testImplementation("org.mockito:mockito-core:1.10.19")
val robolectricVersion = "3.1.4"
testImplementation("org.robolectric:robolectric:$robolectricVersion")
testImplementation("org.robolectric:shadows-multidex:$robolectricVersion")
testImplementation("org.robolectric:shadows-play-services:$robolectricVersion")
implementation(kotlin("reflect", version = BuildPluginsVersion.KOTLIN))
val coroutinesVersion = "1.4.2"
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion")
// For detecting memory leaks; see https://square.github.io/leakcanary/
// debugImplementation("com.squareup.leakcanary:leakcanary-android:2.6")
}
tasks {
// See https://kotlinlang.org/docs/reference/experimental.html#experimental-status-of-experimental-api(-markers)
withType<KotlinCompile> {
kotlinOptions.freeCompilerArgs += listOf(
"-Xopt-in=kotlin.Experimental",
"-Xopt-in=kotlin.RequiresOptIn",
"-Xuse-experimental=kotlin.ExperimentalStdlibApi",
"-Xuse-experimental=kotlinx.coroutines.FlowPreview",
"-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-Xuse-experimental=kotlinx.coroutines.InternalCoroutinesApi",
"-Xuse-experimental=kotlinx.serialization.ExperimentalSerializationApi"
)
}
// Duplicating Hebrew string assets due to some locale code issues on different devices
val copyHebrewStrings = task("copyHebrewStrings", type = Copy::class) {
from("./src/main/res/values-he")
into("./src/main/res/values-iw")
include("**/*")
}
preBuild {
dependsOn(formatKotlin, copyHebrewStrings)
}
}
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath(kotlin("gradle-plugin", version = BuildPluginsVersion.KOTLIN))
}
}
// Git is needed in your system PATH for these commands to work.
// If it's not installed, you can return a random value as a workaround
fun getCommitCount(): String {
return runCommand("git rev-list --count HEAD")
// return "1"
}
fun getGitSha(): String {
return runCommand("git rev-parse --short HEAD")
// return "1"
}
fun getBuildTime(): String {
val df = SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'")
df.timeZone = TimeZone.getTimeZone("UTC")
return df.format(Date())
}
fun runCommand(command: String): String {
val byteOut = ByteArrayOutputStream()
project.exec {
commandLine = command.split(" ")
standardOutput = byteOut
}
return String(byteOut.toByteArray()).trim()
}

View File

@ -2,16 +2,24 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="eu.kanade.tachiyomi">
<!-- Internet -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Storage -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<!-- For background jobs -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- For managing extensions -->
<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" />
<!-- To view extension packages in API 30+ -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<application
android:name=".App"
@ -43,7 +51,7 @@
android:name=".ui.main.DeepLinkActivity"
android:launchMode="singleTask"
android:theme="@android:style/Theme.NoDisplay"
android:label="@string/process_text_action_name">
android:label="@string/action_global_search">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
<action android:name="com.google.android.gms.actions.SEARCH_ACTION" />
@ -55,7 +63,7 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.PROCESS_TEXT" />
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
@ -96,7 +104,18 @@
</activity>
<activity
android:name=".ui.setting.track.MyAnimeListLoginActivity"
android:configChanges="uiMode|orientation|screenSize" />
android:label="MyAnimeList">
<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="myanimelist-auth"
android:scheme="tachiyomi" />
</intent-filter>
</activity>
<activity
android:name=".ui.setting.track.ShikimoriLoginActivity"
android:label="Shikimori">

View File

@ -20,9 +20,7 @@ import org.acra.sender.HttpSender
import org.conscrypt.Conscrypt
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.InjektScope
import uy.kohesive.injekt.injectLazy
import uy.kohesive.injekt.registry.default.DefaultRegistrar
import java.security.Security
@AcraCore(
@ -30,30 +28,22 @@ import java.security.Security
excludeMatchingSharedPreferencesKeys = [".*username.*", ".*password.*", ".*token.*"]
)
@AcraHttpSender(
uri = "https://tachiyomi.kanade.eu/crash_report",
uri = BuildConfig.ACRA_URI,
httpMethod = HttpSender.Method.PUT
)
open class App : Application(), LifecycleObserver {
private val preferences: PreferencesHelper by injectLazy()
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
// Debug tool; see https://fbflipper.com/
// SoLoader.init(this, false)
// if (BuildConfig.DEBUG && FlipperUtils.shouldEnableFlipper(this)) {
// val client = AndroidFlipperClient.getInstance(this)
// client.addPlugin(InspectorFlipperPlugin(this, DescriptorMapping.withDefaults()))
// client.addPlugin(DatabasesFlipperPlugin(this))
// client.start()
// }
// TLS 1.3 support for Android < 10
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
Security.insertProviderAt(Conscrypt.newProvider(), 1)
}
Injekt = InjektScope(DefaultRegistrar())
Injekt.importModule(AppModule(this))
setupAcra()
@ -77,14 +67,15 @@ open class App : Application(), LifecycleObserver {
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
@Suppress("unused")
fun onAppBackgrounded() {
val preferences: PreferencesHelper by injectLazy()
if (preferences.lockAppAfter().get() >= 0) {
SecureActivityDelegate.locked = true
}
}
protected open fun setupAcra() {
ACRA.init(this)
if (BuildConfig.FLAVOR != "dev") {
ACRA.init(this)
}
}
protected open fun setupNotificationChannels() {

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi
import android.app.Application
import android.os.Handler
import com.google.gson.Gson
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.cache.CoverCache
@ -11,8 +12,6 @@ import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.SourceManager
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar
@ -48,15 +47,16 @@ class AppModule(val app: Application) : InjektModule {
addSingletonFactory { Json { ignoreUnknownKeys = true } }
// Asynchronously init expensive components for a faster cold start
Handler().post {
get<PreferencesHelper>()
GlobalScope.launch { get<PreferencesHelper>() }
get<NetworkHelper>()
GlobalScope.launch { get<NetworkHelper>() }
get<SourceManager>()
GlobalScope.launch { get<SourceManager>() }
get<DatabaseHelper>()
GlobalScope.launch { get<DatabaseHelper>() }
GlobalScope.launch { get<DownloadManager>() }
get<DownloadManager>()
}
}
}

View File

@ -9,6 +9,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.updater.UpdaterJob
import eu.kanade.tachiyomi.extension.ExtensionUpdateJob
import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE
import eu.kanade.tachiyomi.ui.library.LibrarySort
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.widget.ExtendedNavigationView
@ -116,14 +117,28 @@ object Migrations {
putInt(PreferenceKeys.filterCompleted, convertBooleanPrefToTriState("pref_filter_completed_key"))
remove("pref_filter_completed_key")
}
}
if (oldVersion < 54) {
// Force MAL log out due to login flow change
// v52: switched from scraping to WebView
// v53: switched from WebView to OAuth
val trackManager = Injekt.get<TrackManager>()
if (trackManager.myAnimeList.isLogged) {
trackManager.myAnimeList.logout()
context.toast(R.string.myanimelist_relogin)
}
}
if (oldVersion < 57) {
// Migrate DNS over HTTPS setting
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val wasDohEnabled = prefs.getBoolean("enable_doh", false)
if (wasDohEnabled) {
prefs.edit {
putInt(PreferenceKeys.dohProvider, PREF_DOH_CLOUDFLARE)
remove("enable_doh")
}
}
}
return true
}

View File

@ -5,12 +5,13 @@ import android.net.Uri
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.toMangaInfo
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.toSChapter
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import rx.Observable
import uy.kohesive.injekt.injectLazy
abstract class AbstractBackupManager(protected val context: Context) {
@ -31,22 +32,22 @@ abstract class AbstractBackupManager(protected val context: Context) {
databaseHelper.getManga(manga.url, manga.source).executeAsBlocking()
/**
* [Observable] that fetches chapter information
* Fetches chapter information.
*
* @param source source of manga
* @param manga manga that needs updating
* @param chapters list of chapters in the backup
* @return [Observable] that contains manga
* @return Updated manga chapters.
*/
internal fun restoreChapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
return source.fetchChapterList(manga)
.map { syncChaptersWithSource(databaseHelper, it, manga, source) }
.doOnNext { (first) ->
if (first.isNotEmpty()) {
chapters.forEach { it.manga_id = manga.id }
updateChapters(chapters)
}
}
internal suspend fun restoreChapters(source: Source, manga: Manga, chapters: List<Chapter>): Pair<List<Chapter>, List<Chapter>> {
val fetchedChapters = source.getChapterList(manga.toMangaInfo())
.map { it.toSChapter() }
val syncedChapters = syncChaptersWithSource(databaseHelper, fetchedChapters, manga, source)
if (syncedChapters.first.isNotEmpty()) {
chapters.forEach { it.manga_id = manga.id }
updateChapters(chapters)
}
return syncedChapters
}
/**
@ -79,6 +80,13 @@ abstract class AbstractBackupManager(protected val context: Context) {
databaseHelper.updateChaptersBackup(chapters).executeAsBlocking()
}
/**
* Updates a list of chapters with known database ids
*/
protected fun updateKnownChapters(chapters: List<Chapter>) {
databaseHelper.updateKnownChaptersBackup(chapters).executeAsBlocking()
}
/**
* Return number of backups.
*

View File

@ -10,8 +10,8 @@ import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.util.chapter.NoChaptersException
import eu.kanade.tachiyomi.util.system.createFileInCacheDir
import kotlinx.coroutines.Job
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.text.SimpleDateFormat
@ -37,9 +37,9 @@ abstract class AbstractBackupRestore<T : AbstractBackupManager>(protected val co
protected val errors = mutableListOf<Pair<Date, String>>()
abstract fun performRestore(uri: Uri): Boolean
abstract suspend fun performRestore(uri: Uri): Boolean
fun restoreBackup(uri: Uri): Boolean {
suspend fun restoreBackup(uri: Uri): Boolean {
val startTime = System.currentTimeMillis()
restoreProgress = 0
errors.clear()
@ -58,48 +58,48 @@ abstract class AbstractBackupRestore<T : AbstractBackupManager>(protected val co
}
/**
* [Observable] that fetches chapter information
* Fetches chapter information.
*
* @param source source of manga
* @param manga manga that needs updating
* @return [Observable] that contains manga
* @return Updated manga chapters.
*/
internal fun chapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
return backupManager.restoreChapterFetchObservable(source, manga, chapters)
internal suspend fun updateChapters(source: Source, manga: Manga, chapters: List<Chapter>): Pair<List<Chapter>, List<Chapter>> {
return try {
backupManager.restoreChapters(source, manga, chapters)
} catch (e: Exception) {
// If there's any error, return empty update and continue.
.onErrorReturn {
val errorMessage = if (it is NoChaptersException) {
context.getString(R.string.no_chapters_error)
} else {
it.message
}
errors.add(Date() to "${manga.title} - $errorMessage")
Pair(emptyList(), emptyList())
val errorMessage = if (e is NoChaptersException) {
context.getString(R.string.no_chapters_error)
} else {
e.message
}
errors.add(Date() to "${manga.title} - $errorMessage")
Pair(emptyList(), emptyList())
}
}
/**
* [Observable] that refreshes tracking information
* Refreshes tracking information.
*
* @param manga manga that needs updating.
* @param tracks list containing tracks from restore file.
* @return [Observable] that contains updated track item
*/
internal fun trackingFetchObservable(manga: Manga, tracks: List<Track>): Observable<Track> {
return Observable.from(tracks)
.flatMap { track ->
val service = trackManager.getService(track.sync_id)
if (service != null && service.isLogged) {
service.refresh(track)
.doOnNext { db.insertTrack(it).executeAsBlocking() }
.onErrorReturn {
errors.add(Date() to "${manga.title} - ${it.message}")
track
}
} else {
errors.add(Date() to "${manga.title} - ${context.getString(R.string.tracker_not_logged_in, service?.name)}")
Observable.empty()
internal suspend fun updateTracking(manga: Manga, tracks: List<Track>) {
tracks.forEach { track ->
val service = trackManager.getService(track.sync_id)
if (service != null && service.isLogged) {
try {
val updatedTrack = service.refresh(track)
db.insertTrack(updatedTrack).executeAsBlocking()
} catch (e: Exception) {
errors.add(Date() to "${manga.title} - ${e.message}")
}
} else {
val serviceName = service?.nameRes()?.let { context.getString(it) }
errors.add(Date() to "${manga.title} - ${context.getString(R.string.tracker_not_logged_in, serviceName)}")
}
}
}
/**
@ -120,15 +120,15 @@ abstract class AbstractBackupRestore<T : AbstractBackupManager>(protected val co
internal fun writeErrorLog(): File {
try {
if (errors.isNotEmpty()) {
val destFile = File(context.externalCacheDir, "tachiyomi_restore.txt")
val file = context.createFileInCacheDir("tachiyomi_restore.txt")
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
destFile.bufferedWriter().use { out ->
file.bufferedWriter().use { out ->
errors.forEach { (date, message) ->
out.write("[${sdf.format(date)}] $message\n")
}
}
return destFile
return file
}
} catch (e: Exception) {
// Empty

View File

@ -111,7 +111,7 @@ class BackupCreateService : Service() {
val backupFileUri = backupManager.createBackup(uri, backupFlags, false)?.toUri()
val unifile = UniFile.fromUri(this, backupFileUri)
notifier.showBackupComplete(unifile)
notifier.showBackupComplete(unifile, backupType == BackupConst.BACKUP_TYPE_LEGACY)
} catch (e: Exception) {
notifier.showBackupError(e.message)
}

View File

@ -24,6 +24,7 @@ class BackupNotifier(private val context: Context) {
setSmallIcon(R.drawable.ic_tachi)
setAutoCancel(false)
setOngoing(true)
setOnlyAlertOnce(true)
}
private val completeNotificationBuilder = context.notificationBuilder(Notifications.CHANNEL_BACKUP_RESTORE_COMPLETE) {
@ -41,7 +42,6 @@ class BackupNotifier(private val context: Context) {
setContentTitle(context.getString(R.string.creating_backup))
setProgress(0, 0, true)
setOnlyAlertOnce(true)
}
builder.show(Notifications.ID_BACKUP_PROGRESS)
@ -60,7 +60,7 @@ class BackupNotifier(private val context: Context) {
}
}
fun showBackupComplete(unifile: UniFile) {
fun showBackupComplete(unifile: UniFile, isLegacyFormat: Boolean) {
context.notificationManager.cancel(Notifications.ID_BACKUP_PROGRESS)
with(completeNotificationBuilder) {
@ -68,14 +68,12 @@ class BackupNotifier(private val context: Context) {
setContentText(unifile.filePath ?: unifile.name)
// Clear old actions if they exist
if (mActions.isNotEmpty()) {
mActions.clear()
}
clearActions()
addAction(
R.drawable.ic_share_24dp,
context.getString(R.string.action_share),
NotificationReceiver.shareBackupPendingBroadcast(context, unifile.uri, Notifications.ID_BACKUP_COMPLETE)
NotificationReceiver.shareBackupPendingBroadcast(context, unifile.uri, isLegacyFormat, Notifications.ID_BACKUP_COMPLETE)
)
show(Notifications.ID_BACKUP_COMPLETE)
@ -94,9 +92,7 @@ class BackupNotifier(private val context: Context) {
setOnlyAlertOnce(true)
// Clear old actions if they exist
if (mActions.isNotEmpty()) {
mActions.clear()
}
clearActions()
addAction(
R.drawable.ic_close_24dp,
@ -137,9 +133,7 @@ class BackupNotifier(private val context: Context) {
setContentText(context.resources.getQuantityString(R.plurals.restore_completed_message, errorCount, timeString, errorCount))
// Clear old actions if they exist
if (mActions.isNotEmpty()) {
mActions.clear()
}
clearActions()
if (errorCount > 0 && !path.isNullOrEmpty() && !file.isNullOrEmpty()) {
val destFile = File(path, file)
@ -147,7 +141,7 @@ class BackupNotifier(private val context: Context) {
addAction(
R.drawable.ic_folder_24dp,
context.getString(R.string.action_open_log),
context.getString(R.string.action_show_errors),
NotificationReceiver.openErrorLogPendingActivity(context, uri)
)
}

View File

@ -14,7 +14,10 @@ import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.util.system.acquireWakeLock
import eu.kanade.tachiyomi.util.system.isServiceRunning
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import timber.log.Timber
@ -40,12 +43,11 @@ class BackupRestoreService : Service() {
* @param context context of application
* @param uri path of Uri
*/
fun start(context: Context, uri: Uri, mode: Int, online: Boolean?) {
fun start(context: Context, uri: Uri, mode: Int) {
if (!isRunning(context)) {
val intent = Intent(context, BackupRestoreService::class.java).apply {
putExtra(BackupConst.EXTRA_URI, uri)
putExtra(BackupConst.EXTRA_MODE, mode)
online?.let { putExtra(BackupConst.EXTRA_TYPE, it) }
}
ContextCompat.startForegroundService(context, intent)
}
@ -68,12 +70,14 @@ class BackupRestoreService : Service() {
*/
private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var ioScope: CoroutineScope
private var backupRestore: AbstractBackupRestore<*>? = null
private lateinit var notifier: BackupNotifier
override fun onCreate() {
super.onCreate()
ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
notifier = BackupNotifier(this)
wakeLock = acquireWakeLock(javaClass.name)
@ -92,6 +96,7 @@ class BackupRestoreService : Service() {
private fun destroyJob() {
backupRestore?.job?.cancel()
ioScope?.cancel()
if (wakeLock.isHeld) {
wakeLock.release()
}
@ -113,15 +118,15 @@ class BackupRestoreService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val uri = intent?.getParcelableExtra<Uri>(BackupConst.EXTRA_URI) ?: return START_NOT_STICKY
val mode = intent.getIntExtra(BackupConst.EXTRA_MODE, BackupConst.BACKUP_TYPE_FULL)
val online = intent.getBooleanExtra(BackupConst.EXTRA_TYPE, true)
// Cancel any previous job if needed.
backupRestore?.job?.cancel()
backupRestore = when (mode) {
BackupConst.BACKUP_TYPE_FULL -> FullBackupRestore(this, notifier, online)
BackupConst.BACKUP_TYPE_FULL -> FullBackupRestore(this, notifier)
else -> LegacyBackupRestore(this, notifier)
}
val handler = CoroutineExceptionHandler { _, exception ->
Timber.e(exception)
backupRestore?.writeErrorLog()
@ -129,14 +134,15 @@ class BackupRestoreService : Service() {
notifier.showRestoreError(exception.message)
stopSelf(startId)
}
backupRestore?.job = GlobalScope.launch(handler) {
val job = ioScope.launch(handler) {
if (backupRestore?.restoreBackup(uri) == false) {
notifier.showRestoreError(getString(R.string.restoring_backup_canceled))
}
}
backupRestore?.job?.invokeOnCompletion {
job.invokeOnCompletion {
stopSelf(startId)
}
backupRestore?.job = job
return START_NOT_STICKY
}

View File

@ -26,17 +26,13 @@ import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.source.Source
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.protobuf.ProtoBuf
import okio.buffer
import okio.gzip
import okio.sink
import rx.Observable
import timber.log.Timber
import kotlin.math.max
@OptIn(ExperimentalSerializationApi::class)
class FullBackupManager(context: Context) : AbstractBackupManager(context) {
val parser = ProtoBuf
@ -182,29 +178,15 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) {
}
/**
* [Observable] that fetches manga information
* Fetches manga information
*
* @param source source of manga
* @param manga manga that needs updating
* @return [Observable] that contains manga
* @return Updated manga info.
*/
fun restoreMangaFetchObservable(source: Source?, manga: Manga, online: Boolean): Observable<Manga> {
return if (online && source != null) {
source.fetchMangaDetails(manga)
.map { networkManga ->
manga.copyFrom(networkManga)
manga.favorite = manga.favorite
manga.initialized = true
manga.id = insertManga(manga)
manga
}
} else {
Observable.just(manga)
.map {
it.initialized = it.description != null
it.id = insertManga(it)
it
}
fun restoreManga(manga: Manga): Manga {
return manga.also {
it.initialized = it.description != null
it.id = insertManga(it)
}
}
@ -249,7 +231,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) {
*/
internal fun restoreCategoriesForManga(manga: Manga, categories: List<Int>, backupCategories: List<BackupCategory>) {
val dbCategories = databaseHelper.getCategories().executeAsBlocking()
val mangaCategoriesToUpdate = mutableListOf<MangaCategory>()
val mangaCategoriesToUpdate = ArrayList<MangaCategory>(categories.size)
categories.forEach { backupCategoryOrder ->
backupCategories.firstOrNull {
it.order == backupCategoryOrder
@ -276,7 +258,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) {
*/
internal fun restoreHistoryForManga(history: List<BackupHistory>) {
// List containing history to be updated
val historyToBeUpdated = mutableListOf<History>()
val historyToBeUpdated = ArrayList<History>(history.size)
for ((url, lastRead) in history) {
val dbHistory = databaseHelper.getHistoryByChapterUrl(url).executeAsBlocking()
// Check if history already in database and update
@ -313,29 +295,26 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) {
val trackToUpdate = mutableListOf<Track>()
tracks.forEach { track ->
val service = trackManager.getService(track.sync_id)
if (service != null && service.isLogged) {
var isInDatabase = false
for (dbTrack in dbTracks) {
if (track.sync_id == dbTrack.sync_id) {
// The sync is already in the db, only update its fields
if (track.media_id != dbTrack.media_id) {
dbTrack.media_id = track.media_id
}
if (track.library_id != dbTrack.library_id) {
dbTrack.library_id = track.library_id
}
dbTrack.last_chapter_read = max(dbTrack.last_chapter_read, track.last_chapter_read)
isInDatabase = true
trackToUpdate.add(dbTrack)
break
var isInDatabase = false
for (dbTrack in dbTracks) {
if (track.sync_id == dbTrack.sync_id) {
// The sync is already in the db, only update its fields
if (track.media_id != dbTrack.media_id) {
dbTrack.media_id = track.media_id
}
if (track.library_id != dbTrack.library_id) {
dbTrack.library_id = track.library_id
}
dbTrack.last_chapter_read = max(dbTrack.last_chapter_read, track.last_chapter_read)
isInDatabase = true
trackToUpdate.add(dbTrack)
break
}
if (!isInDatabase) {
// Insert new sync. Let the db assign the id
track.id = null
trackToUpdate.add(track)
}
}
if (!isInDatabase) {
// Insert new sync. Let the db assign the id
track.id = null
trackToUpdate.add(track)
}
}
// Update database
@ -344,25 +323,12 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) {
}
}
/**
* Restore the chapters for manga if chapters already in database
*
* @param manga manga of chapters
* @param chapters list containing chapters that get restored
* @return boolean answering if chapter fetch is not needed
*/
internal fun restoreChaptersForManga(manga: Manga, chapters: List<Chapter>): Boolean {
internal fun restoreChaptersForManga(manga: Manga, chapters: List<Chapter>) {
val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking()
// Return if fetch is needed
if (dbChapters.isEmpty() || dbChapters.size < chapters.size) {
return false
}
chapters.forEach { chapter ->
val pos = dbChapters.indexOfFirst { it.url == chapter.url }
if (pos != -1) {
val dbChapter = dbChapters[pos]
val dbChapter = dbChapters.find { it.url == chapter.url }
if (dbChapter != null) {
chapter.id = dbChapter.id
chapter.copyFrom(dbChapter)
if (dbChapter.read && !chapter.read) {
@ -375,38 +341,12 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) {
chapter.bookmark = dbChapter.bookmark
}
}
chapter.manga_id = manga.id
}
// Filter the chapters that couldn't be found.
chapters.filter { it.id != null }
chapters.map { it.manga_id = manga.id }
updateChapters(chapters)
return true
}
internal fun restoreChaptersForMangaOffline(manga: Manga, chapters: List<Chapter>) {
val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking()
chapters.forEach { chapter ->
val pos = dbChapters.indexOfFirst { it.url == chapter.url }
if (pos != -1) {
val dbChapter = dbChapters[pos]
chapter.id = dbChapter.id
chapter.copyFrom(dbChapter)
if (dbChapter.read && !chapter.read) {
chapter.read = dbChapter.read
chapter.last_page_read = dbChapter.last_page_read
} else if (chapter.last_page_read == 0 && dbChapter.last_page_read != 0) {
chapter.last_page_read = dbChapter.last_page_read
}
if (!chapter.bookmark && dbChapter.bookmark) {
chapter.bookmark = dbChapter.bookmark
}
}
}
chapters.map { it.manga_id = manga.id }
updateChapters(chapters.filter { it.id != null })
insertChapters(chapters.filter { it.id == null })
val newChapters = chapters.groupBy { it.id != null }
newChapters[true]?.let { updateKnownChapters(it) }
newChapters[false]?.let { insertChapters(it) }
}
}

View File

@ -12,18 +12,14 @@ import eu.kanade.tachiyomi.data.backup.full.models.BackupSerializer
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.source.Source
import kotlinx.serialization.ExperimentalSerializationApi
import okio.buffer
import okio.gzip
import okio.source
import rx.Observable
import java.util.Date
@OptIn(ExperimentalSerializationApi::class)
class FullBackupRestore(context: Context, notifier: BackupNotifier, private val online: Boolean) : AbstractBackupRestore<FullBackupManager>(context, notifier) {
class FullBackupRestore(context: Context, notifier: BackupNotifier) : AbstractBackupRestore<FullBackupManager>(context, notifier) {
override fun performRestore(uri: Uri): Boolean {
override suspend fun performRestore(uri: Uri): Boolean {
backupManager = FullBackupManager(context)
val backupString = context.contentResolver.openInputStream(uri)!!.source().gzip().buffer().use { it.readByteArray() }
@ -45,9 +41,11 @@ class FullBackupRestore(context: Context, notifier: BackupNotifier, private val
return false
}
restoreManga(it, backup.backupCategories, online)
restoreManga(it, backup.backupCategories)
}
// TODO: optionally trigger online library + tracker update
return true
}
@ -60,7 +58,7 @@ class FullBackupRestore(context: Context, notifier: BackupNotifier, private val
showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories))
}
private fun restoreManga(backupManga: BackupManga, backupCategories: List<BackupCategory>, online: Boolean) {
private fun restoreManga(backupManga: BackupManga, backupCategories: List<BackupCategory>) {
val manga = backupManga.getMangaImpl()
val chapters = backupManga.getChaptersImpl()
val categories = backupManga.categories
@ -68,15 +66,10 @@ class FullBackupRestore(context: Context, notifier: BackupNotifier, private val
val tracks = backupManga.getTrackingImpl()
try {
val source = backupManager.sourceManager.get(manga.source)
if (source != null || !online) {
restoreMangaData(manga, source, chapters, categories, history, tracks, backupCategories, online)
} else {
val sourceName = sourceMapping[manga.source] ?: manga.source.toString()
errors.add(Date() to "${manga.title} - ${context.getString(R.string.source_not_found_name, sourceName)}")
}
restoreMangaData(manga, chapters, categories, history, tracks, backupCategories)
} catch (e: Exception) {
errors.add(Date() to "${manga.title} - ${e.message}")
val sourceName = sourceMapping[manga.source] ?: manga.source.toString()
errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}")
}
restoreProgress += 1
@ -87,7 +80,6 @@ class FullBackupRestore(context: Context, notifier: BackupNotifier, private val
* Returns a manga restore observable
*
* @param manga manga data from json
* @param source source to get manga data from
* @param chapters chapters data from json
* @param categories categories data from json
* @param history history data from json
@ -95,101 +87,65 @@ class FullBackupRestore(context: Context, notifier: BackupNotifier, private val
*/
private fun restoreMangaData(
manga: Manga,
source: Source?,
chapters: List<Chapter>,
categories: List<Int>,
history: List<BackupHistory>,
tracks: List<Track>,
backupCategories: List<BackupCategory>,
online: Boolean
backupCategories: List<BackupCategory>
) {
val dbManga = backupManager.getMangaFromDatabase(manga)
db.inTransaction {
val dbManga = backupManager.getMangaFromDatabase(manga)
if (dbManga == null) {
// Manga not in database
restoreMangaFetch(source, manga, chapters, categories, history, tracks, backupCategories, online)
} else { // Manga in database
restoreMangaFetch(manga, chapters, categories, history, tracks, backupCategories)
} else {
// Manga in database
// Copy information from manga already in database
backupManager.restoreMangaNoFetch(manga, dbManga)
// Fetch rest of manga information
restoreMangaNoFetch(source, manga, chapters, categories, history, tracks, backupCategories, online)
restoreMangaNoFetch(manga, chapters, categories, history, tracks, backupCategories)
}
}
}
/**
* [Observable] that fetches manga information
* Fetches manga information
*
* @param manga manga that needs updating
* @param chapters chapters of manga that needs updating
* @param categories categories that need updating
*/
private fun restoreMangaFetch(
source: Source?,
manga: Manga,
chapters: List<Chapter>,
categories: List<Int>,
history: List<BackupHistory>,
tracks: List<Track>,
backupCategories: List<BackupCategory>,
online: Boolean
backupCategories: List<BackupCategory>
) {
backupManager.restoreMangaFetchObservable(source, manga, online)
.doOnError {
errors.add(Date() to "${manga.title} - ${it.message}")
}
.filter { it.id != null }
.flatMap {
if (online && source != null) {
chapterFetchObservable(source, it, chapters)
// Convert to the manga that contains new chapters.
.map { manga }
} else {
backupManager.restoreChaptersForMangaOffline(it, chapters)
Observable.just(manga)
}
}
.doOnNext {
restoreExtraForManga(it, categories, history, tracks, backupCategories)
}
.flatMap {
trackingFetchObservable(it, tracks)
}
.subscribe()
try {
val fetchedManga = backupManager.restoreManga(manga)
fetchedManga.id ?: return
backupManager.restoreChaptersForManga(fetchedManga, chapters)
restoreExtraForManga(fetchedManga, categories, history, tracks, backupCategories)
} catch (e: Exception) {
errors.add(Date() to "${manga.title} - ${e.message}")
}
}
private fun restoreMangaNoFetch(
source: Source?,
backupManga: Manga,
chapters: List<Chapter>,
categories: List<Int>,
history: List<BackupHistory>,
tracks: List<Track>,
backupCategories: List<BackupCategory>,
online: Boolean
backupCategories: List<BackupCategory>
) {
Observable.just(backupManga)
.flatMap { manga ->
if (online && source != null) {
if (!backupManager.restoreChaptersForManga(manga, chapters)) {
chapterFetchObservable(source, manga, chapters)
.map { manga }
} else {
Observable.just(manga)
}
} else {
backupManager.restoreChaptersForMangaOffline(manga, chapters)
Observable.just(manga)
}
}
.doOnNext {
restoreExtraForManga(it, categories, history, tracks, backupCategories)
}
.flatMap { manga ->
trackingFetchObservable(manga, tracks)
}
.subscribe()
backupManager.restoreChaptersForManga(backupManga, chapters)
restoreExtraForManga(backupManga, categories, history, tracks, backupCategories)
}
private fun restoreExtraForManga(manga: Manga, categories: List<Int>, history: List<BackupHistory>, tracks: List<Track>, backupCategories: List<BackupCategory>) {

View File

@ -5,12 +5,10 @@ import android.net.Uri
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.AbstractBackupRestoreValidator
import eu.kanade.tachiyomi.data.backup.full.models.BackupSerializer
import kotlinx.serialization.ExperimentalSerializationApi
import okio.buffer
import okio.gzip
import okio.source
@OptIn(ExperimentalSerializationApi::class)
class FullBackupRestoreValidator : AbstractBackupRestoreValidator() {
/**
* Checks for critical backup file data.
@ -41,7 +39,7 @@ class FullBackupRestoreValidator : AbstractBackupRestoreValidator() {
val missingTrackers = trackers
.mapNotNull { trackManager.getService(it) }
.filter { !it.isLogged }
.map { it.name }
.map { context.getString(it.nameRes()) }
.sorted()
return Results(missingSources, missingTrackers)

View File

@ -44,38 +44,23 @@ import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.database.models.MangaImpl
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.database.models.TrackImpl
import eu.kanade.tachiyomi.data.database.models.toMangaInfo
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import rx.Observable
import eu.kanade.tachiyomi.source.model.toSManga
import timber.log.Timber
import kotlin.math.max
class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : AbstractBackupManager(context) {
var parserVersion: Int = version
private set
var parser: Gson = initParser()
/**
* Set version of parser
*
* @param version version of parser
*/
internal fun setVersion(version: Int) {
this.parserVersion = version
parser = initParser()
}
private fun initParser(): Gson = when (parserVersion) {
2 ->
GsonBuilder()
.registerTypeAdapter<MangaImpl>(MangaTypeAdapter.build())
.registerTypeHierarchyAdapter<ChapterImpl>(ChapterTypeAdapter.build())
.registerTypeAdapter<CategoryImpl>(CategoryTypeAdapter.build())
.registerTypeAdapter<DHistory>(HistoryTypeAdapter.build())
.registerTypeHierarchyAdapter<TrackImpl>(TrackTypeAdapter.build())
.create()
val parser: Gson = when (version) {
2 -> GsonBuilder()
.registerTypeAdapter<MangaImpl>(MangaTypeAdapter.build())
.registerTypeHierarchyAdapter<ChapterImpl>(ChapterTypeAdapter.build())
.registerTypeAdapter<CategoryImpl>(CategoryTypeAdapter.build())
.registerTypeAdapter<DHistory>(HistoryTypeAdapter.build())
.registerTypeHierarchyAdapter<TrackImpl>(TrackTypeAdapter.build())
.create()
else -> throw Exception("Unknown backup version")
}
@ -249,21 +234,20 @@ class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : Ab
}
/**
* [Observable] that fetches manga information
* Fetches manga information
*
* @param source source of manga
* @param manga manga that needs updating
* @return [Observable] that contains manga
* @return Updated manga.
*/
fun restoreMangaFetchObservable(source: Source, manga: Manga): Observable<Manga> {
return source.fetchMangaDetails(manga)
.map { networkManga ->
manga.copyFrom(networkManga)
manga.favorite = true
manga.initialized = true
manga.id = insertManga(manga)
manga
}
suspend fun fetchManga(source: Source, manga: Manga): Manga {
val networkManga = source.getMangaDetails(manga.toMangaInfo())
return manga.also {
it.copyFrom(networkManga.toSManga())
it.favorite = true
it.initialized = true
it.id = insertManga(manga)
}
}
/**
@ -308,7 +292,7 @@ class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : Ab
*/
internal fun restoreCategoriesForManga(manga: Manga, categories: List<String>) {
val dbCategories = databaseHelper.getCategories().executeAsBlocking()
val mangaCategoriesToUpdate = mutableListOf<MangaCategory>()
val mangaCategoriesToUpdate = ArrayList<MangaCategory>(categories.size)
for (backupCategoryStr in categories) {
for (dbCategory in dbCategories) {
if (backupCategoryStr == dbCategory.name) {
@ -332,7 +316,7 @@ class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : Ab
*/
internal fun restoreHistoryForManga(history: List<DHistory>) {
// List containing history to be updated
val historyToBeUpdated = mutableListOf<History>()
val historyToBeUpdated = ArrayList<History>(history.size)
for ((url, lastRead) in history) {
val dbHistory = databaseHelper.getHistoryByChapterUrl(url).executeAsBlocking()
// Check if history already in database and update
@ -361,14 +345,14 @@ class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : Ab
* @param tracks the track list to restore.
*/
internal fun restoreTrackForManga(manga: Manga, tracks: List<Track>) {
// Fix foreign keys with the current manga id
tracks.map { it.manga_id = manga.id!! }
// Get tracks from database
val dbTracks = databaseHelper.getTracks(manga).executeAsBlocking()
val trackToUpdate = mutableListOf<Track>()
val trackToUpdate = ArrayList<Track>(tracks.size)
tracks.forEach { track ->
// Fix foreign keys with the current manga id
track.manga_id = manga.id!!
val service = trackManager.getService(track.sync_id)
if (service != null && service.isLogged) {
var isInDatabase = false
@ -423,12 +407,13 @@ class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : Ab
chapter.copyFrom(dbChapter)
break
}
}
// Filter the chapters that couldn't be found.
chapters.filter { it.id != null }
chapters.map { it.manga_id = manga.id }
updateChapters(chapters)
chapter.manga_id = manga.id
}
// Filter the chapters that couldn't be found.
updateChapters(chapters.filter { it.id != null })
return true
}
}

View File

@ -21,12 +21,11 @@ import eu.kanade.tachiyomi.data.database.models.MangaImpl
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.database.models.TrackImpl
import eu.kanade.tachiyomi.source.Source
import rx.Observable
import java.util.Date
class LegacyBackupRestore(context: Context, notifier: BackupNotifier) : AbstractBackupRestore<LegacyBackupManager>(context, notifier) {
override fun performRestore(uri: Uri): Boolean {
override suspend fun performRestore(uri: Uri): Boolean {
val reader = JsonReader(context.contentResolver.openInputStream(uri)!!.bufferedReader())
val json = JsonParser.parseReader(reader).asJsonObject
@ -63,7 +62,7 @@ class LegacyBackupRestore(context: Context, notifier: BackupNotifier) : Abstract
showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories))
}
private fun restoreManga(mangaJson: JsonObject) {
private suspend fun restoreManga(mangaJson: JsonObject) {
val manga = backupManager.parser.fromJson<MangaImpl>(
mangaJson.get(
Backup.MANGA
@ -86,16 +85,17 @@ class LegacyBackupRestore(context: Context, notifier: BackupNotifier) : Abstract
?: JsonArray()
)
val source = backupManager.sourceManager.get(manga.source)
val sourceName = sourceMapping[manga.source] ?: manga.source.toString()
try {
val source = backupManager.sourceManager.get(manga.source)
if (source != null) {
restoreMangaData(manga, source, chapters, categories, history, tracks)
} else {
val sourceName = sourceMapping[manga.source] ?: manga.source.toString()
errors.add(Date() to "${manga.title} - ${context.getString(R.string.source_not_found_name, sourceName)}")
errors.add(Date() to "${manga.title} [$sourceName]: ${context.getString(R.string.source_not_found_name, sourceName)}")
}
} catch (e: Exception) {
errors.add(Date() to "${manga.title} - ${e.message}")
errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}")
}
restoreProgress += 1
@ -112,7 +112,7 @@ class LegacyBackupRestore(context: Context, notifier: BackupNotifier) : Abstract
* @param history history data from json
* @param tracks tracking data from json
*/
private fun restoreMangaData(
private suspend fun restoreMangaData(
manga: Manga,
source: Source,
chapters: List<Chapter>,
@ -136,13 +136,13 @@ class LegacyBackupRestore(context: Context, notifier: BackupNotifier) : Abstract
}
/**
* [Observable] that fetches manga information
* Fetches manga information.
*
* @param manga manga that needs updating
* @param chapters chapters of manga that needs updating
* @param categories categories that need updating
*/
private fun restoreMangaFetch(
private suspend fun restoreMangaFetch(
source: Source,
manga: Manga,
chapters: List<Chapter>,
@ -150,27 +150,21 @@ class LegacyBackupRestore(context: Context, notifier: BackupNotifier) : Abstract
history: List<DHistory>,
tracks: List<Track>
) {
backupManager.restoreMangaFetchObservable(source, manga)
.onErrorReturn {
errors.add(Date() to "${manga.title} - ${it.message}")
manga
}
.filter { it.id != null }
.flatMap {
chapterFetchObservable(source, it, chapters)
// Convert to the manga that contains new chapters.
.map { manga }
}
.doOnNext {
restoreExtraForManga(it, categories, history, tracks)
}
.flatMap {
trackingFetchObservable(it, tracks)
}
.subscribe()
try {
val fetchedManga = backupManager.fetchManga(source, manga)
fetchedManga.id ?: return
updateChapters(source, fetchedManga, chapters)
restoreExtraForManga(fetchedManga, categories, history, tracks)
updateTracking(fetchedManga, tracks)
} catch (e: Exception) {
errors.add(Date() to "${manga.title} - ${e.message}")
}
}
private fun restoreMangaNoFetch(
private suspend fun restoreMangaNoFetch(
source: Source,
backupManga: Manga,
chapters: List<Chapter>,
@ -178,22 +172,13 @@ class LegacyBackupRestore(context: Context, notifier: BackupNotifier) : Abstract
history: List<DHistory>,
tracks: List<Track>
) {
Observable.just(backupManga)
.flatMap { manga ->
if (!backupManager.restoreChaptersForManga(manga, chapters)) {
chapterFetchObservable(source, manga, chapters)
.map { manga }
} else {
Observable.just(manga)
}
}
.doOnNext {
restoreExtraForManga(it, categories, history, tracks)
}
.flatMap { manga ->
trackingFetchObservable(manga, tracks)
}
.subscribe()
if (!backupManager.restoreChaptersForManga(backupManga, chapters)) {
updateChapters(source, backupManga, chapters)
}
restoreExtraForManga(backupManga, categories, history, tracks)
updateTracking(backupManga, tracks)
}
private fun restoreExtraForManga(manga: Manga, categories: List<String>, history: List<DHistory>, tracks: List<Track>) {

View File

@ -45,7 +45,7 @@ class LegacyBackupRestoreValidator : AbstractBackupRestoreValidator() {
val missingTrackers = trackers
.mapNotNull { trackManager.getService(it) }
.filter { !it.isLogged }
.map { it.name }
.map { context.getString(it.nameRes()) }
.sorted()
return Results(missingSources, missingTrackers)

View File

@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.data.database.mappers
import android.content.ContentValues
import android.database.Cursor
import androidx.core.content.contentValuesOf
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
@ -35,12 +35,13 @@ class CategoryPutResolver : DefaultPutResolver<Category>() {
.whereArgs(obj.id)
.build()
override fun mapToContentValues(obj: Category) = ContentValues(4).apply {
put(COL_ID, obj.id)
put(COL_NAME, obj.name)
put(COL_ORDER, obj.order)
put(COL_FLAGS, obj.flags)
}
override fun mapToContentValues(obj: Category) =
contentValuesOf(
COL_ID to obj.id,
COL_NAME to obj.name,
COL_ORDER to obj.order,
COL_FLAGS to obj.flags
)
}
class CategoryGetResolver : DefaultGetResolver<Category>() {

View File

@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.data.database.mappers
import android.content.ContentValues
import android.database.Cursor
import androidx.core.content.contentValuesOf
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
@ -43,20 +43,21 @@ class ChapterPutResolver : DefaultPutResolver<Chapter>() {
.whereArgs(obj.id)
.build()
override fun mapToContentValues(obj: Chapter) = ContentValues(11).apply {
put(COL_ID, obj.id)
put(COL_MANGA_ID, obj.manga_id)
put(COL_URL, obj.url)
put(COL_NAME, obj.name)
put(COL_READ, obj.read)
put(COL_SCANLATOR, obj.scanlator)
put(COL_BOOKMARK, obj.bookmark)
put(COL_DATE_FETCH, obj.date_fetch)
put(COL_DATE_UPLOAD, obj.date_upload)
put(COL_LAST_PAGE_READ, obj.last_page_read)
put(COL_CHAPTER_NUMBER, obj.chapter_number)
put(COL_SOURCE_ORDER, obj.source_order)
}
override fun mapToContentValues(obj: Chapter) =
contentValuesOf(
COL_ID to obj.id,
COL_MANGA_ID to obj.manga_id,
COL_URL to obj.url,
COL_NAME to obj.name,
COL_READ to obj.read,
COL_SCANLATOR to obj.scanlator,
COL_BOOKMARK to obj.bookmark,
COL_DATE_FETCH to obj.date_fetch,
COL_DATE_UPLOAD to obj.date_upload,
COL_LAST_PAGE_READ to obj.last_page_read,
COL_CHAPTER_NUMBER to obj.chapter_number,
COL_SOURCE_ORDER to obj.source_order
)
}
class ChapterGetResolver : DefaultGetResolver<Chapter>() {

View File

@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.data.database.mappers
import android.content.ContentValues
import android.database.Cursor
import androidx.core.content.contentValuesOf
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
@ -35,12 +35,13 @@ open class HistoryPutResolver : DefaultPutResolver<History>() {
.whereArgs(obj.id)
.build()
override fun mapToContentValues(obj: History) = ContentValues(4).apply {
put(COL_ID, obj.id)
put(COL_CHAPTER_ID, obj.chapter_id)
put(COL_LAST_READ, obj.last_read)
put(COL_TIME_READ, obj.time_read)
}
override fun mapToContentValues(obj: History) =
contentValuesOf(
COL_ID to obj.id,
COL_CHAPTER_ID to obj.chapter_id,
COL_LAST_READ to obj.last_read,
COL_TIME_READ to obj.time_read
)
}
class HistoryGetResolver : DefaultGetResolver<History>() {

View File

@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.data.database.mappers
import android.content.ContentValues
import android.database.Cursor
import androidx.core.content.contentValuesOf
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
@ -33,11 +33,12 @@ class MangaCategoryPutResolver : DefaultPutResolver<MangaCategory>() {
.whereArgs(obj.id)
.build()
override fun mapToContentValues(obj: MangaCategory) = ContentValues(3).apply {
put(COL_ID, obj.id)
put(COL_MANGA_ID, obj.manga_id)
put(COL_CATEGORY_ID, obj.category_id)
}
override fun mapToContentValues(obj: MangaCategory) =
contentValuesOf(
COL_ID to obj.id,
COL_MANGA_ID to obj.manga_id,
COL_CATEGORY_ID to obj.category_id
)
}
class MangaCategoryGetResolver : DefaultGetResolver<MangaCategory>() {

View File

@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.data.database.mappers
import android.content.ContentValues
import android.database.Cursor
import androidx.core.content.contentValuesOf
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
@ -48,25 +48,26 @@ class MangaPutResolver : DefaultPutResolver<Manga>() {
.whereArgs(obj.id)
.build()
override fun mapToContentValues(obj: Manga) = ContentValues(17).apply {
put(COL_ID, obj.id)
put(COL_SOURCE, obj.source)
put(COL_URL, obj.url)
put(COL_ARTIST, obj.artist)
put(COL_AUTHOR, obj.author)
put(COL_DESCRIPTION, obj.description)
put(COL_GENRE, obj.genre)
put(COL_TITLE, obj.title)
put(COL_STATUS, obj.status)
put(COL_THUMBNAIL_URL, obj.thumbnail_url)
put(COL_FAVORITE, obj.favorite)
put(COL_LAST_UPDATE, obj.last_update)
put(COL_INITIALIZED, obj.initialized)
put(COL_VIEWER, obj.viewer)
put(COL_CHAPTER_FLAGS, obj.chapter_flags)
put(COL_COVER_LAST_MODIFIED, obj.cover_last_modified)
put(COL_DATE_ADDED, obj.date_added)
}
override fun mapToContentValues(obj: Manga) =
contentValuesOf(
COL_ID to obj.id,
COL_SOURCE to obj.source,
COL_URL to obj.url,
COL_ARTIST to obj.artist,
COL_AUTHOR to obj.author,
COL_DESCRIPTION to obj.description,
COL_GENRE to obj.genre,
COL_TITLE to obj.title,
COL_STATUS to obj.status,
COL_THUMBNAIL_URL to obj.thumbnail_url,
COL_FAVORITE to obj.favorite,
COL_LAST_UPDATE to obj.last_update,
COL_INITIALIZED to obj.initialized,
COL_VIEWER to obj.viewer,
COL_CHAPTER_FLAGS to obj.chapter_flags,
COL_COVER_LAST_MODIFIED to obj.cover_last_modified,
COL_DATE_ADDED to obj.date_added
)
}
interface BaseMangaGetResolver {

View File

@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.data.database.mappers
import android.content.ContentValues
import android.database.Cursor
import androidx.core.content.contentValuesOf
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
@ -44,21 +44,22 @@ class TrackPutResolver : DefaultPutResolver<Track>() {
.whereArgs(obj.id)
.build()
override fun mapToContentValues(obj: Track) = ContentValues(10).apply {
put(COL_ID, obj.id)
put(COL_MANGA_ID, obj.manga_id)
put(COL_SYNC_ID, obj.sync_id)
put(COL_MEDIA_ID, obj.media_id)
put(COL_LIBRARY_ID, obj.library_id)
put(COL_TITLE, obj.title)
put(COL_LAST_CHAPTER_READ, obj.last_chapter_read)
put(COL_TOTAL_CHAPTERS, obj.total_chapters)
put(COL_STATUS, obj.status)
put(COL_TRACKING_URL, obj.tracking_url)
put(COL_SCORE, obj.score)
put(COL_START_DATE, obj.started_reading_date)
put(COL_FINISH_DATE, obj.finished_reading_date)
}
override fun mapToContentValues(obj: Track) =
contentValuesOf(
COL_ID to obj.id,
COL_MANGA_ID to obj.manga_id,
COL_SYNC_ID to obj.sync_id,
COL_MEDIA_ID to obj.media_id,
COL_LIBRARY_ID to obj.library_id,
COL_TITLE to obj.title,
COL_LAST_CHAPTER_READ to obj.last_chapter_read,
COL_TOTAL_CHAPTERS to obj.total_chapters,
COL_STATUS to obj.status,
COL_TRACKING_URL to obj.tracking_url,
COL_SCORE to obj.score,
COL_START_DATE to obj.started_reading_date,
COL_FINISH_DATE to obj.finished_reading_date
)
}
class TrackGetResolver : DefaultGetResolver<Track>() {

View File

@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaChapter
import eu.kanade.tachiyomi.data.database.resolvers.ChapterBackupPutResolver
import eu.kanade.tachiyomi.data.database.resolvers.ChapterKnownBackupPutResolver
import eu.kanade.tachiyomi.data.database.resolvers.ChapterProgressPutResolver
import eu.kanade.tachiyomi.data.database.resolvers.ChapterSourceOrderPutResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterGetResolver
@ -84,6 +85,11 @@ interface ChapterQueries : DbProvider {
.withPutResolver(ChapterBackupPutResolver())
.prepare()
fun updateKnownChaptersBackup(chapters: List<Chapter>) = db.put()
.objects(chapters)
.withPutResolver(ChapterKnownBackupPutResolver())
.prepare()
fun updateChapterProgress(chapter: Chapter) = db.put()
.`object`(chapter)
.withPutResolver(ChapterProgressPutResolver())

View File

@ -164,4 +164,14 @@ interface MangaQueries : DbProvider {
.build()
)
.prepare()
fun getChapterFetchDateManga() = db.get()
.listOfObjects(Manga::class.java)
.withQuery(
RawQuery.builder()
.query(getChapterFetchDateMangaQuery())
.observesTables(MangaTable.TABLE)
.build()
)
.prepare()
}

View File

@ -122,6 +122,16 @@ fun getLatestChapterMangaQuery() =
ORDER by max DESC
"""
fun getChapterFetchDateMangaQuery() =
"""
SELECT ${Manga.TABLE}.*, MAX(${Chapter.TABLE}.${Chapter.COL_DATE_FETCH}) AS max
FROM ${Manga.TABLE}
JOIN ${Chapter.TABLE}
ON ${Manga.TABLE}.${Manga.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_MANGA_ID}
GROUP BY ${Manga.TABLE}.${Manga.COL_ID}
ORDER by max DESC
"""
/**
* Query to get the categories for a manga.
*/

View File

@ -10,6 +10,15 @@ import eu.kanade.tachiyomi.data.track.TrackService
interface TrackQueries : DbProvider {
fun getTracks() = db.get()
.listOfObjects(Track::class.java)
.withQuery(
Query.builder()
.table(TrackTable.TABLE)
.build()
)
.prepare()
fun getTracks(manga: Manga) = db.get()
.listOfObjects(Track::class.java)
.withQuery(

View File

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.data.database.resolvers
import android.content.ContentValues
import androidx.core.content.contentValuesOf
import com.pushtorefresh.storio.sqlite.StorIOSQLite
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
@ -25,9 +25,10 @@ class ChapterBackupPutResolver : PutResolver<Chapter>() {
.whereArgs(chapter.url)
.build()
fun mapToContentValues(chapter: Chapter) = ContentValues(3).apply {
put(ChapterTable.COL_READ, chapter.read)
put(ChapterTable.COL_BOOKMARK, chapter.bookmark)
put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read)
}
fun mapToContentValues(chapter: Chapter) =
contentValuesOf(
ChapterTable.COL_READ to chapter.read,
ChapterTable.COL_BOOKMARK to chapter.bookmark,
ChapterTable.COL_LAST_PAGE_READ to chapter.last_page_read
)
}

View File

@ -0,0 +1,34 @@
package eu.kanade.tachiyomi.data.database.resolvers
import androidx.core.content.contentValuesOf
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.Chapter
import eu.kanade.tachiyomi.data.database.tables.ChapterTable
class ChapterKnownBackupPutResolver : PutResolver<Chapter>() {
override fun performPut(db: StorIOSQLite, chapter: Chapter) = db.inTransactionReturn {
val updateQuery = mapToUpdateQuery(chapter)
val contentValues = mapToContentValues(chapter)
val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
}
fun mapToUpdateQuery(chapter: Chapter) = UpdateQuery.builder()
.table(ChapterTable.TABLE)
.where("${ChapterTable.COL_ID} = ?")
.whereArgs(chapter.id)
.build()
fun mapToContentValues(chapter: Chapter) =
contentValuesOf(
ChapterTable.COL_READ to chapter.read,
ChapterTable.COL_BOOKMARK to chapter.bookmark,
ChapterTable.COL_LAST_PAGE_READ to chapter.last_page_read
)
}

View File

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.data.database.resolvers
import android.content.ContentValues
import androidx.core.content.contentValuesOf
import com.pushtorefresh.storio.sqlite.StorIOSQLite
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
@ -25,9 +25,10 @@ class ChapterProgressPutResolver : PutResolver<Chapter>() {
.whereArgs(chapter.id)
.build()
fun mapToContentValues(chapter: Chapter) = ContentValues(3).apply {
put(ChapterTable.COL_READ, chapter.read)
put(ChapterTable.COL_BOOKMARK, chapter.bookmark)
put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read)
}
fun mapToContentValues(chapter: Chapter) =
contentValuesOf(
ChapterTable.COL_READ to chapter.read,
ChapterTable.COL_BOOKMARK to chapter.bookmark,
ChapterTable.COL_LAST_PAGE_READ to chapter.last_page_read
)
}

View File

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.data.database.resolvers
import android.content.ContentValues
import androidx.core.content.contentValuesOf
import com.pushtorefresh.storio.sqlite.StorIOSQLite
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
@ -25,7 +25,8 @@ class ChapterSourceOrderPutResolver : PutResolver<Chapter>() {
.whereArgs(chapter.url, chapter.manga_id)
.build()
fun mapToContentValues(chapter: Chapter) = ContentValues(1).apply {
put(ChapterTable.COL_SOURCE_ORDER, chapter.source_order)
}
fun mapToContentValues(chapter: Chapter) =
contentValuesOf(
ChapterTable.COL_SOURCE_ORDER to chapter.source_order
)
}

View File

@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.data.database.resolvers
import android.content.ContentValues
import androidx.annotation.NonNull
import androidx.core.content.contentValuesOf
import com.pushtorefresh.storio.sqlite.StorIOSQLite
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
import com.pushtorefresh.storio.sqlite.queries.Query
@ -57,7 +57,8 @@ class HistoryLastReadPutResolver : HistoryPutResolver() {
* Create content query
* @param history object
*/
fun mapToUpdateContentValues(history: History) = ContentValues(1).apply {
put(HistoryTable.COL_LAST_READ, history.last_read)
}
fun mapToUpdateContentValues(history: History) =
contentValuesOf(
HistoryTable.COL_LAST_READ to history.last_read
)
}

View File

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.data.database.resolvers
import android.content.ContentValues
import androidx.core.content.contentValuesOf
import com.pushtorefresh.storio.sqlite.StorIOSQLite
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
@ -25,7 +25,8 @@ class MangaCoverLastModifiedPutResolver : PutResolver<Manga>() {
.whereArgs(manga.id)
.build()
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
put(MangaTable.COL_COVER_LAST_MODIFIED, manga.cover_last_modified)
}
fun mapToContentValues(manga: Manga) =
contentValuesOf(
MangaTable.COL_COVER_LAST_MODIFIED to manga.cover_last_modified
)
}

View File

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.data.database.resolvers
import android.content.ContentValues
import androidx.core.content.contentValuesOf
import com.pushtorefresh.storio.sqlite.StorIOSQLite
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
@ -25,7 +25,8 @@ class MangaFavoritePutResolver : PutResolver<Manga>() {
.whereArgs(manga.id)
.build()
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
put(MangaTable.COL_FAVORITE, manga.favorite)
}
fun mapToContentValues(manga: Manga) =
contentValuesOf(
MangaTable.COL_FAVORITE to manga.favorite
)
}

View File

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.data.database.resolvers
import android.content.ContentValues
import androidx.core.content.contentValuesOf
import com.pushtorefresh.storio.sqlite.StorIOSQLite
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
@ -35,7 +35,8 @@ class MangaFlagsPutResolver(private val updateAll: Boolean = false) : PutResolve
}
}
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
put(MangaTable.COL_CHAPTER_FLAGS, manga.chapter_flags)
}
fun mapToContentValues(manga: Manga) =
contentValuesOf(
MangaTable.COL_CHAPTER_FLAGS to manga.chapter_flags
)
}

View File

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.data.database.resolvers
import android.content.ContentValues
import androidx.core.content.contentValuesOf
import com.pushtorefresh.storio.sqlite.StorIOSQLite
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
@ -25,7 +25,8 @@ class MangaLastUpdatedPutResolver : PutResolver<Manga>() {
.whereArgs(manga.id)
.build()
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
put(MangaTable.COL_LAST_UPDATE, manga.last_update)
}
fun mapToContentValues(manga: Manga) =
contentValuesOf(
MangaTable.COL_LAST_UPDATE to manga.last_update
)
}

View File

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.data.database.resolvers
import android.content.ContentValues
import androidx.core.content.contentValuesOf
import com.pushtorefresh.storio.sqlite.StorIOSQLite
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
@ -25,7 +25,8 @@ class MangaTitlePutResolver : PutResolver<Manga>() {
.whereArgs(manga.id)
.build()
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
put(MangaTable.COL_TITLE, manga.title)
}
fun mapToContentValues(manga: Manga) =
contentValuesOf(
MangaTable.COL_TITLE to manga.title
)
}

View File

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.data.database.resolvers
import android.content.ContentValues
import androidx.core.content.contentValuesOf
import com.pushtorefresh.storio.sqlite.StorIOSQLite
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
@ -25,7 +25,8 @@ class MangaViewerPutResolver : PutResolver<Manga>() {
.whereArgs(manga.id)
.build()
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
put(MangaTable.COL_VIEWER, manga.viewer)
}
fun mapToContentValues(manga: Manga) =
contentValuesOf(
MangaTable.COL_VIEWER to manga.viewer
)
}

View File

@ -12,6 +12,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.lang.launchIO
import rx.Observable
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
@ -211,17 +212,35 @@ class DownloadManager(private val context: Context) {
*/
fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source): List<Chapter> {
val filteredChapters = getChaptersToDelete(chapters)
launchIO {
removeFromDownloadQueue(filteredChapters)
queue.remove(filteredChapters)
val chapterDirs = provider.findChapterDirs(filteredChapters, manga, source)
chapterDirs.forEach { it.delete() }
cache.removeChapters(filteredChapters, manga)
if (cache.getDownloadCount(manga) == 0) { // Delete manga directory if empty
chapterDirs.firstOrNull()?.parentFile?.delete()
}
}
return filteredChapters
}
val chapterDirs = provider.findChapterDirs(filteredChapters, manga, source)
chapterDirs.forEach { it.delete() }
cache.removeChapters(filteredChapters, manga)
if (cache.getDownloadCount(manga) == 0) { // Delete manga directory if empty
chapterDirs.firstOrNull()?.parentFile?.delete()
private fun removeFromDownloadQueue(chapters: List<Chapter>) {
val wasRunning = downloader.isRunning
if (wasRunning) {
downloader.pause()
}
return filteredChapters
downloader.queue.remove(chapters)
if (wasRunning) {
if (downloader.queue.isEmpty()) {
DownloadService.stop(context)
downloader.stop()
} else if (downloader.queue.isNotEmpty()) {
downloader.start()
}
}
}
/**
@ -231,9 +250,11 @@ class DownloadManager(private val context: Context) {
* @param source the source of the manga.
*/
fun deleteManga(manga: Manga, source: Source) {
queue.remove(manga)
provider.findMangaDir(manga, source)?.delete()
cache.removeManga(manga)
launchIO {
downloader.queue.remove(manga)
provider.findMangaDir(manga, source)?.delete()
cache.removeManga(manga)
}
}
/**

View File

@ -27,6 +27,9 @@ internal class DownloadNotifier(private val context: Context) {
private val progressNotificationBuilder by lazy {
context.notificationBuilder(Notifications.CHANNEL_DOWNLOADER_PROGRESS) {
setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher))
setAutoCancel(false)
setOngoing(true)
setOnlyAlertOnce(true)
}
}
@ -66,15 +69,6 @@ internal class DownloadNotifier(private val context: Context) {
context.notificationManager.notify(id, build())
}
/**
* Clear old actions if they exist.
*/
private fun NotificationCompat.Builder.clearActions() {
if (mActions.isNotEmpty()) {
mActions.clear()
}
}
/**
* Dismiss the downloader's notification. Downloader error notifications use a different id, so
* those can only be dismissed by the user.
@ -93,7 +87,6 @@ internal class DownloadNotifier(private val context: Context) {
// Check if first call.
if (!isDownloading) {
setSmallIcon(android.R.drawable.stat_sys_download)
setAutoCancel(false)
clearActions()
// Open download manager when clicked
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
@ -136,7 +129,6 @@ internal class DownloadNotifier(private val context: Context) {
setContentTitle(context.getString(R.string.chapter_paused))
setContentText(context.getString(R.string.download_notifier_download_paused))
setSmallIcon(R.drawable.ic_pause_24dp)
setAutoCancel(false)
setProgress(0, 0, false)
clearActions()
// Open download manager when clicked
@ -165,6 +157,8 @@ internal class DownloadNotifier(private val context: Context) {
* This function shows a notification to inform download tasks are done.
*/
fun onComplete() {
dismissProgress()
if (!errorThrown) {
// Create notification
with(completeNotificationBuilder) {
@ -224,7 +218,6 @@ internal class DownloadNotifier(private val context: Context) {
setContentText(error ?: context.getString(R.string.download_notifier_unknown_error))
setSmallIcon(android.R.drawable.stat_sys_warning)
clearActions()
setAutoCancel(false)
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
setProgress(0, 0, false)

View File

@ -9,11 +9,10 @@ import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.util.storage.DiskUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
/**
@ -26,7 +25,7 @@ class DownloadProvider(private val context: Context) {
private val preferences: PreferencesHelper by injectLazy()
private val scope = CoroutineScope(Job() + Dispatchers.Main)
private val scope = MainScope()
/**
* The root directory for downloads.
@ -54,7 +53,8 @@ class DownloadProvider(private val context: Context) {
return downloadsDir
.createDirectory(getSourceDirName(source))
.createDirectory(getMangaDirName(manga))
} catch (e: NullPointerException) {
} catch (e: Throwable) {
Timber.e(e, "Invalid download directory")
throw Exception(context.getString(R.string.invalid_download_dir))
}
}

View File

@ -16,8 +16,8 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.fetchAllImageUrlsFromPageList
import eu.kanade.tachiyomi.util.lang.RetryWithDelay
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchNow
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.plusAssign
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.saveTo
@ -114,8 +114,8 @@ class Downloader(
initializeSubscriptions()
}
val pending = queue.filter { it.status != Download.DOWNLOADED }
pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE }
val pending = queue.filter { it.status != Download.State.DOWNLOADED }
pending.forEach { if (it.status != Download.State.QUEUE) it.status = Download.State.QUEUE }
notifier.paused = false
@ -129,20 +129,21 @@ class Downloader(
fun stop(reason: String? = null) {
destroySubscriptions()
queue
.filter { it.status == Download.DOWNLOADING }
.forEach { it.status = Download.ERROR }
.filter { it.status == Download.State.DOWNLOADING }
.forEach { it.status = Download.State.ERROR }
if (reason != null) {
notifier.onWarning(reason)
} else {
if (notifier.paused) {
notifier.paused = false
notifier.onPaused()
} else {
notifier.dismissProgress()
notifier.onComplete()
}
return
}
if (notifier.paused && !queue.isEmpty()) {
notifier.onPaused()
} else {
notifier.onComplete()
}
notifier.paused = false
}
/**
@ -151,8 +152,8 @@ class Downloader(
fun pause() {
destroySubscriptions()
queue
.filter { it.status == Download.DOWNLOADING }
.forEach { it.status = Download.QUEUE }
.filter { it.status == Download.State.DOWNLOADING }
.forEach { it.status = Download.State.QUEUE }
notifier.paused = true
}
@ -167,8 +168,8 @@ class Downloader(
// Needed to update the chapter view
if (isNotification) {
queue
.filter { it.status == Download.QUEUE }
.forEach { it.status = Download.NOT_DOWNLOADED }
.filter { it.status == Download.State.QUEUE }
.forEach { it.status = Download.State.NOT_DOWNLOADED }
}
queue.clear()
notifier.dismissProgress()
@ -227,8 +228,8 @@ class Downloader(
* @param chapters the list of chapters to download.
* @param autoStart whether to start the downloader after enqueing the chapters.
*/
fun queueChapters(manga: Manga, chapters: List<Chapter>, autoStart: Boolean) = launchUI {
val source = sourceManager.get(manga.source) as? HttpSource ?: return@launchUI
fun queueChapters(manga: Manga, chapters: List<Chapter>, autoStart: Boolean) = launchIO {
val source = sourceManager.get(manga.source) as? HttpSource ?: return@launchIO
val wasEmpty = queue.isEmpty()
// Called in background thread, the operation can be slow with SAF.
val chaptersWithoutDir = async {
@ -271,7 +272,7 @@ class Downloader(
val availSpace = DiskUtil.getAvailableStorageSpace(mangaDir)
if (availSpace != -1L && availSpace < MIN_DISK_SPACE) {
download.status = Download.ERROR
download.status = Download.State.ERROR
notifier.onError(context.getString(R.string.download_insufficient_space), download.chapter.name)
return@defer Observable.just(download)
}
@ -301,7 +302,7 @@ class Downloader(
?.forEach { it.delete() }
download.downloadedImages = 0
download.status = Download.DOWNLOADING
download.status = Download.State.DOWNLOADING
}
// Get all the URLs to the source images, fetch pages if necessary
.flatMap { download.source.fetchAllImageUrlsFromPageList(it) }
@ -317,7 +318,7 @@ class Downloader(
.doOnNext { ensureSuccessfulDownload(download, mangaDir, tmpDir, chapterDirname) }
// If the page list threw, it will resume here
.onErrorReturn { error ->
download.status = Download.ERROR
download.status = Download.State.ERROR
notifier.onError(error.message, download.chapter.name)
download
}
@ -457,13 +458,13 @@ class Downloader(
val downloadedImages = tmpDir.listFiles().orEmpty().filterNot { it.name!!.endsWith(".tmp") }
download.status = if (downloadedImages.size == download.pages!!.size) {
Download.DOWNLOADED
Download.State.DOWNLOADED
} else {
Download.ERROR
Download.State.ERROR
}
// Only rename the directory if it's downloaded.
if (download.status == Download.DOWNLOADED) {
if (download.status == Download.State.DOWNLOADED) {
tmpDir.renameTo(dirname)
cache.addChapter(dirname, mangaDir, download.manga)
@ -476,7 +477,7 @@ class Downloader(
*/
private fun completeDownload(download: Download) {
// Delete successful downloads from queue
if (download.status == Download.DOWNLOADED) {
if (download.status == Download.State.DOWNLOADED) {
// remove downloaded chapter from queue
queue.remove(download)
}
@ -489,7 +490,7 @@ class Downloader(
* Returns true if all the queued downloads are in DOWNLOADED or ERROR state.
*/
private fun areAllDownloadsFinished(): Boolean {
return queue.none { it.status <= Download.DOWNLOADING }
return queue.none { it.status.value <= Download.State.DOWNLOADING.value }
}
companion object {

View File

@ -20,7 +20,7 @@ class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) {
@Volatile
@Transient
var status: Int = 0
var status: State = State.NOT_DOWNLOADED
set(status) {
field = status
statusSubject?.onNext(this)
@ -47,11 +47,11 @@ class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) {
statusCallback = f
}
companion object {
const val NOT_DOWNLOADED = 0
const val QUEUE = 1
const val DOWNLOADING = 2
const val DOWNLOADED = 3
const val ERROR = 4
enum class State(val value: Int) {
NOT_DOWNLOADED(0),
QUEUE(1),
DOWNLOADING(2),
DOWNLOADED(3),
ERROR(4),
}
}

View File

@ -22,7 +22,7 @@ class DownloadQueue(
downloads.forEach { download ->
download.setStatusSubject(statusSubject)
download.setStatusCallback(::setPagesFor)
download.status = Download.QUEUE
download.status = Download.State.QUEUE
}
queue.addAll(downloads)
store.addAll(downloads)
@ -34,8 +34,8 @@ class DownloadQueue(
store.remove(download)
download.setStatusSubject(null)
download.setStatusCallback(null)
if (download.status == Download.DOWNLOADING || download.status == Download.QUEUE) {
download.status = Download.NOT_DOWNLOADED
if (download.status == Download.State.DOWNLOADING || download.status == Download.State.QUEUE) {
download.status = Download.State.NOT_DOWNLOADED
}
if (removed) {
updatedRelay.call(Unit)
@ -60,8 +60,8 @@ class DownloadQueue(
queue.forEach { download ->
download.setStatusSubject(null)
download.setStatusCallback(null)
if (download.status == Download.DOWNLOADING || download.status == Download.QUEUE) {
download.status = Download.NOT_DOWNLOADED
if (download.status == Download.State.DOWNLOADING || download.status == Download.State.QUEUE) {
download.status = Download.State.NOT_DOWNLOADED
}
}
queue.clear()
@ -70,7 +70,7 @@ class DownloadQueue(
}
fun getActiveDownloads(): Observable<Download> =
Observable.from(this).filter { download -> download.status == Download.DOWNLOADING }
Observable.from(this).filter { download -> download.status == Download.State.DOWNLOADING }
fun getStatusObservable(): Observable<Download> = statusSubject.onBackpressureBuffer()
@ -79,7 +79,7 @@ class DownloadQueue(
.map { this }
private fun setPagesFor(download: Download) {
if (download.status == Download.DOWNLOADED || download.status == Download.ERROR) {
if (download.status == Download.State.DOWNLOADED || download.status == Download.State.ERROR) {
setPagesSubject(download.pages, null)
}
}
@ -88,19 +88,19 @@ class DownloadQueue(
return statusSubject.onBackpressureBuffer()
.startWith(getActiveDownloads())
.flatMap { download ->
if (download.status == Download.DOWNLOADING) {
if (download.status == Download.State.DOWNLOADING) {
val pageStatusSubject = PublishSubject.create<Int>()
setPagesSubject(download.pages, pageStatusSubject)
return@flatMap pageStatusSubject
.onBackpressureBuffer()
.filter { it == Page.READY }
.map { download }
} else if (download.status == Download.DOWNLOADED || download.status == Download.ERROR) {
} else if (download.status == Download.State.DOWNLOADED || download.status == Download.State.ERROR) {
setPagesSubject(download.pages, null)
}
Observable.just(download)
}
.filter { it.status == Download.DOWNLOADING }
.filter { it.status == Download.State.DOWNLOADING }
}
private fun setPagesSubject(pages: List<Page>?, subject: PublishSubject<Int>?) {

View File

@ -110,7 +110,7 @@ class LibraryUpdateNotifier(private val context: Context) {
setContentIntent(errorLogIntent)
addAction(
R.drawable.ic_folder_24dp,
context.getString(R.string.action_open_log),
context.getString(R.string.action_show_errors),
errorLogIntent
)
}

View File

@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.toMangaInfo
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.library.LibraryUpdateRanker.rankingScheme
@ -22,16 +23,27 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.toSChapter
import eu.kanade.tachiyomi.source.model.toSManga
import eu.kanade.tachiyomi.util.chapter.NoChaptersException
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import eu.kanade.tachiyomi.util.prepUpdateCover
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.system.acquireWakeLock
import eu.kanade.tachiyomi.util.system.createFileInCacheDir
import eu.kanade.tachiyomi.util.system.isServiceRunning
import rx.Observable
import rx.Subscription
import rx.schedulers.Schedulers
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -55,17 +67,12 @@ class LibraryUpdateService(
val coverCache: CoverCache = Injekt.get()
) : Service() {
/**
* Wake lock that will be held until the service is destroyed.
*/
private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var notifier: LibraryUpdateNotifier
private lateinit var ioScope: CoroutineScope
/**
* Subscription where the update is done.
*/
private var subscription: Subscription? = null
private var mangaToUpdate: List<LibraryManga> = mutableListOf()
private var updateJob: Job? = null
/**
* Defines what should be updated within a service execution.
@ -80,6 +87,8 @@ class LibraryUpdateService(
companion object {
private var instance: LibraryUpdateService? = null
/**
* Key for category to update.
*/
@ -110,17 +119,18 @@ class LibraryUpdateService(
* @return true if service newly started, false otherwise
*/
fun start(context: Context, category: Category? = null, target: Target = Target.CHAPTERS): Boolean {
if (!isRunning(context)) {
return if (!isRunning(context)) {
val intent = Intent(context, LibraryUpdateService::class.java).apply {
putExtra(KEY_TARGET, target)
category?.let { putExtra(KEY_CATEGORY, it.id) }
}
ContextCompat.startForegroundService(context, intent)
return true
true
} else {
instance?.addMangaToQueue(category?.id ?: -1, target)
false
}
return false
}
/**
@ -140,6 +150,7 @@ class LibraryUpdateService(
override fun onCreate() {
super.onCreate()
ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
notifier = LibraryUpdateNotifier(this)
wakeLock = acquireWakeLock(javaClass.name)
@ -151,10 +162,14 @@ class LibraryUpdateService(
* lock.
*/
override fun onDestroy() {
subscription?.unsubscribe()
updateJob?.cancel()
ioScope?.cancel()
if (wakeLock.isHeld) {
wakeLock.release()
}
if (instance == this) {
instance = null
}
super.onDestroy()
}
@ -178,66 +193,68 @@ class LibraryUpdateService(
val target = intent.getSerializableExtra(KEY_TARGET) as? Target
?: return START_NOT_STICKY
// Unsubscribe from any previous subscription if needed.
subscription?.unsubscribe()
instance = this
// Update favorite manga. Destroy service when completed or in case of an error.
subscription = Observable
.defer {
val selectedScheme = preferences.libraryUpdatePrioritization().get()
val mangaList = getMangaToUpdate(intent, target)
.sortedWith(rankingScheme[selectedScheme])
// Unsubscribe from any previous subscription if needed
updateJob?.cancel()
// Update either chapter list or manga details.
when (target) {
Target.CHAPTERS -> updateChapterList(mangaList)
Target.COVERS -> updateCovers(mangaList)
Target.TRACKING -> updateTrackings(mangaList)
}
// Update favorite manga
val categoryId = intent.getIntExtra(KEY_CATEGORY, -1)
addMangaToQueue(categoryId, target)
// Destroy service when completed or in case of an error.
val handler = CoroutineExceptionHandler { _, exception ->
Timber.e(exception)
stopSelf(startId)
}
updateJob = ioScope.launch(handler) {
when (target) {
Target.CHAPTERS -> updateChapterList()
Target.COVERS -> updateCovers()
Target.TRACKING -> updateTrackings()
}
.subscribeOn(Schedulers.io())
.subscribe(
{
},
{
Timber.e(it)
stopSelf(startId)
},
{
stopSelf(startId)
}
)
}
updateJob?.invokeOnCompletion { stopSelf(startId) }
return START_REDELIVER_INTENT
}
/**
* Returns the list of manga to be updated.
* Adds list of manga to be updated.
*
* @param intent the update intent.
* @param category the ID of the category to update, or -1 if no category specified.
* @param target the target to update.
* @return a list of manga to update
*/
fun getMangaToUpdate(intent: Intent, target: Target): List<LibraryManga> {
val categoryId = intent.getIntExtra(KEY_CATEGORY, -1)
fun addMangaToQueue(categoryId: Int, target: Target) {
val libraryManga = db.getLibraryMangas().executeAsBlocking()
var listToUpdate = if (categoryId != -1) {
db.getLibraryMangas().executeAsBlocking().filter { it.category == categoryId }
libraryManga.filter { it.category == categoryId }
} else {
val categoriesToUpdate = preferences.libraryUpdateCategories().get().map(String::toInt)
if (categoriesToUpdate.isNotEmpty()) {
db.getLibraryMangas().executeAsBlocking()
.filter { it.category in categoriesToUpdate }
.distinctBy { it.id }
val listToInclude = if (categoriesToUpdate.isNotEmpty()) {
libraryManga.filter { it.category in categoriesToUpdate }
} else {
db.getLibraryMangas().executeAsBlocking().distinctBy { it.id }
libraryManga
}
val categoriesToExclude = preferences.libraryUpdateCategoriesExclude().get().map(String::toInt)
val listToExclude = if (categoriesToExclude.isNotEmpty()) {
libraryManga.filter { it.category in categoriesToExclude }
} else {
emptyList()
}
listToInclude.minus(listToExclude)
}
if (target == Target.CHAPTERS && preferences.updateOnlyNonCompleted()) {
listToUpdate = listToUpdate.filter { it.status != SManga.COMPLETED }
listToUpdate = listToUpdate.filterNot { it.status == SManga.COMPLETED }
}
return listToUpdate
val selectedScheme = preferences.libraryUpdatePrioritization().get()
mangaToUpdate = listToUpdate
.distinctBy { it.id }
.sortedWith(rankingScheme[selectedScheme])
}
/**
@ -249,77 +266,57 @@ class LibraryUpdateService(
* @param mangaToUpdate the list to update
* @return an observable delivering the progress of each update.
*/
fun updateChapterList(mangaToUpdate: List<LibraryManga>): Observable<LibraryManga> {
// Initialize the variables holding the progress of the updates.
val count = AtomicInteger(0)
// List containing new updates
suspend fun updateChapterList() {
val progressCount = AtomicInteger(0)
val newUpdates = mutableListOf<Pair<LibraryManga, Array<Chapter>>>()
// List containing failed updates
val failedUpdates = mutableListOf<Pair<Manga, String?>>()
// Boolean to determine if DownloadManager has downloads
var hasDownloads = false
// Emit each manga and update it sequentially.
return Observable.from(mangaToUpdate)
// Notify manga that will update.
.doOnNext { notifier.showProgressNotification(it, count.andIncrement, mangaToUpdate.size) }
// Update the chapters of the manga
.concatMap { manga ->
updateManga(manga)
// If there's any error, return empty update and continue.
.onErrorReturn {
val errorMessage = if (it is NoChaptersException) {
getString(R.string.no_chapters_error)
} else {
it.message
}
failedUpdates.add(Pair(manga, errorMessage))
Pair(emptyList(), emptyList())
}
// Filter out mangas without new chapters (or failed).
.filter { (first) -> first.isNotEmpty() }
.doOnNext {
if (manga.shouldDownloadNewChapters(db, preferences)) {
downloadChapters(manga, it.first)
hasDownloads = true
}
}
// Convert to the manga that contains new chapters.
.map {
Pair(
manga,
(
it.first.sortedByDescending { ch -> ch.source_order }
.toTypedArray()
)
)
}
mangaToUpdate.forEach { manga ->
if (updateJob?.isActive != true) {
return
}
// Add manga with new chapters to the list.
.doOnNext { manga ->
// Add to the list
newUpdates.add(manga)
}
// Notify result of the overall update.
.doOnCompleted {
notifier.cancelProgressNotification()
if (newUpdates.isNotEmpty()) {
notifier.showUpdateNotifications(newUpdates)
if (hasDownloads) {
DownloadService.start(this)
}
}
notifier.showProgressNotification(manga, progressCount.andIncrement, mangaToUpdate.size)
if (preferences.showLibraryUpdateErrors() && failedUpdates.isNotEmpty()) {
val errorFile = writeErrorFile(failedUpdates)
notifier.showUpdateErrorNotification(
failedUpdates.map { it.first.title },
errorFile.getUriCompat(this)
)
try {
val (newChapters, _) = updateManga(manga)
if (newChapters.isNotEmpty()) {
if (manga.shouldDownloadNewChapters(db, preferences)) {
downloadChapters(manga, newChapters)
hasDownloads = true
}
// Convert to the manga that contains new chapters
newUpdates.add(manga to newChapters.sortedByDescending { ch -> ch.source_order }.toTypedArray())
}
} catch (e: Throwable) {
val errorMessage = if (e is NoChaptersException) {
getString(R.string.no_chapters_error)
} else {
e.message
}
failedUpdates.add(manga to errorMessage)
}
.map { (first) -> first }
}
notifier.cancelProgressNotification()
if (newUpdates.isNotEmpty()) {
notifier.showUpdateNotifications(newUpdates)
if (hasDownloads) {
DownloadService.start(this)
}
}
if (preferences.showLibraryUpdateErrors() && failedUpdates.isNotEmpty()) {
val errorFile = writeErrorFile(failedUpdates)
notifier.showUpdateErrorNotification(
failedUpdates.map { it.first.title },
errorFile.getUriCompat(this)
)
}
}
private fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
@ -334,94 +331,102 @@ class LibraryUpdateService(
* @param manga the manga to update.
* @return a pair of the inserted and removed chapters.
*/
fun updateManga(manga: Manga): Observable<Pair<List<Chapter>, List<Chapter>>> {
suspend fun updateManga(manga: Manga): Pair<List<Chapter>, List<Chapter>> {
val source = sourceManager.getOrStub(manga.source)
// Update manga details metadata in the background
if (preferences.autoUpdateMetadata()) {
source.fetchMangaDetails(manga)
.map { updatedManga ->
// Avoid "losing" existing cover
if (!updatedManga.thumbnail_url.isNullOrEmpty()) {
manga.prepUpdateCover(coverCache, updatedManga, false)
} else {
updatedManga.thumbnail_url = manga.thumbnail_url
}
manga.copyFrom(updatedManga)
db.insertManga(manga).executeAsBlocking()
manga
val handler = CoroutineExceptionHandler { _, exception ->
Timber.e(exception)
}
GlobalScope.launch(Dispatchers.IO + handler) {
val updatedManga = source.getMangaDetails(manga.toMangaInfo())
val sManga = updatedManga.toSManga()
// Avoid "losing" existing cover
if (!sManga.thumbnail_url.isNullOrEmpty()) {
manga.prepUpdateCover(coverCache, sManga, false)
} else {
sManga.thumbnail_url = manga.thumbnail_url
}
.onErrorResumeNext { Observable.just(manga) }
.subscribeOn(Schedulers.io())
.subscribe()
manga.copyFrom(sManga)
db.insertManga(manga).executeAsBlocking()
}
}
return source.fetchChapterList(manga)
.map { syncChaptersWithSource(db, it, manga, source) }
val chapters = source.getChapterList(manga.toMangaInfo())
.map { it.toSChapter() }
return syncChaptersWithSource(db, chapters, manga, source)
}
private fun updateCovers(mangaToUpdate: List<LibraryManga>): Observable<LibraryManga> {
var count = 0
private suspend fun updateCovers() {
var progressCount = 0
return Observable.from(mangaToUpdate)
.doOnNext {
notifier.showProgressNotification(it, count++, mangaToUpdate.size)
mangaToUpdate.forEach { manga ->
if (updateJob?.isActive != true) {
return
}
.flatMap { manga ->
val source = sourceManager.get(manga.source)
?: return@flatMap Observable.empty<LibraryManga>()
source.fetchMangaDetails(manga)
.map { networkManga ->
manga.prepUpdateCover(coverCache, networkManga, true)
networkManga.thumbnail_url?.let {
manga.thumbnail_url = it
db.insertManga(manga).executeAsBlocking()
}
manga
notifier.showProgressNotification(manga, progressCount++, mangaToUpdate.size)
sourceManager.get(manga.source)?.let { source ->
try {
val networkManga = source.getMangaDetails(manga.toMangaInfo())
val sManga = networkManga.toSManga()
manga.prepUpdateCover(coverCache, sManga, true)
sManga.thumbnail_url?.let {
manga.thumbnail_url = it
db.insertManga(manga).executeAsBlocking()
}
.onErrorReturn { manga }
}
.doOnCompleted {
notifier.cancelProgressNotification()
} catch (e: Throwable) {
// Ignore errors and continue
Timber.e(e)
}
}
}
notifier.cancelProgressNotification()
}
/**
* Method that updates the metadata of the connected tracking services. It's called in a
* background thread, so it's safe to do heavy operations or network calls here.
*/
private fun updateTrackings(mangaToUpdate: List<LibraryManga>): Observable<LibraryManga> {
// Initialize the variables holding the progress of the updates.
var count = 0
private suspend fun updateTrackings() {
var progressCount = 0
val loggedServices = trackManager.services.filter { it.isLogged }
// Emit each manga and update it sequentially.
return Observable.from(mangaToUpdate)
// Notify manga that will update.
.doOnNext { notifier.showProgressNotification(it, count++, mangaToUpdate.size) }
// Update the tracking details.
.concatMap { manga ->
val tracks = db.getTracks(manga).executeAsBlocking()
mangaToUpdate.forEach { manga ->
if (updateJob?.isActive != true) {
return
}
Observable.from(tracks)
.concatMap { track ->
val service = trackManager.getService(track.sync_id)
if (service != null && service in loggedServices) {
service.refresh(track)
.doOnNext { db.insertTrack(it).executeAsBlocking() }
.onErrorReturn { track }
} else {
Observable.empty()
// Notify manga that will update.
notifier.showProgressNotification(manga, progressCount++, mangaToUpdate.size)
// Update the tracking details.
db.getTracks(manga).executeAsBlocking()
.map { track ->
supervisorScope {
async {
val service = trackManager.getService(track.sync_id)
if (service != null && service in loggedServices) {
try {
val updatedTrack = service.refresh(track)
db.insertTrack(updatedTrack).executeAsBlocking()
} catch (e: Throwable) {
// Ignore errors and continue
Timber.e(e)
}
}
}
}
.map { manga }
}
.doOnCompleted {
notifier.cancelProgressNotification()
}
}
.awaitAll()
}
notifier.cancelProgressNotification()
}
/**
@ -430,15 +435,14 @@ class LibraryUpdateService(
private fun writeErrorFile(errors: List<Pair<Manga, String?>>): File {
try {
if (errors.isNotEmpty()) {
val destFile = File(externalCacheDir, "tachiyomi_update_errors.txt")
destFile.bufferedWriter().use { out ->
val file = createFileInCacheDir("tachiyomi_update_errors.txt")
file.bufferedWriter().use { out ->
errors.forEach { (manga, error) ->
val source = sourceManager.getOrStub(manga.source)
out.write("${manga.title} ($source): $error\n")
}
}
return destFile
return file
}
} catch (e: Exception) {
// Empty

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.notification
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.net.Uri
@ -69,9 +70,10 @@ class NotificationReceiver : BroadcastReceiver() {
)
// Share backup file
ACTION_SHARE_BACKUP ->
shareBackup(
shareFile(
context,
intent.getParcelableExtra(EXTRA_URI),
if (intent.getBooleanExtra(EXTRA_IS_LEGACY_BACKUP, false)) "application/json" else "application/x-protobuf+gzip",
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)
)
ACTION_CANCEL_RESTORE -> cancelRestore(
@ -100,6 +102,14 @@ class NotificationReceiver : BroadcastReceiver() {
markAsRead(urls, mangaId)
}
}
// Share crash dump file
ACTION_SHARE_CRASH_LOG ->
shareFile(
context,
intent.getParcelableExtra(EXTRA_URI),
"text/plain",
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)
)
}
}
@ -120,14 +130,13 @@ class NotificationReceiver : BroadcastReceiver() {
* @param notificationId id of notification
*/
private fun shareImage(context: Context, path: String, notificationId: Int) {
// Create intent
val intent = Intent(Intent.ACTION_SEND).apply {
val uri = File(path).getUriCompat(context)
putExtra(Intent.EXTRA_STREAM, uri)
clipData = ClipData.newRawUri(null, uri)
type = "image/*"
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
}
// Dismiss notification
dismissNotification(context, notificationId)
// Launch share activity
context.startActivity(intent)
@ -140,10 +149,11 @@ class NotificationReceiver : BroadcastReceiver() {
* @param path path of file
* @param notificationId id of notification
*/
private fun shareBackup(context: Context, uri: Uri, notificationId: Int) {
private fun shareFile(context: Context, uri: Uri, fileMimeType: String, notificationId: Int) {
val sendIntent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
type = "application/json"
clipData = ClipData.newRawUri(null, uri)
type = fileMimeType
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
}
// Dismiss notification
@ -244,59 +254,34 @@ class NotificationReceiver : BroadcastReceiver() {
companion object {
private const val NAME = "NotificationReceiver"
// Called to launch share intent.
private const val ACTION_SHARE_IMAGE = "$ID.$NAME.SHARE_IMAGE"
// Called to delete image.
private const val ACTION_DELETE_IMAGE = "$ID.$NAME.DELETE_IMAGE"
// Called to launch send intent.
private const val ACTION_SHARE_BACKUP = "$ID.$NAME.SEND_BACKUP"
// Called to cancel backup restore job.
private const val ACTION_SHARE_CRASH_LOG = "$ID.$NAME.SEND_CRASH_LOG"
private const val ACTION_CANCEL_RESTORE = "$ID.$NAME.CANCEL_RESTORE"
// Called to cancel library update.
private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE"
// Called to mark manga chapters as read.
private const val ACTION_MARK_AS_READ = "$ID.$NAME.MARK_AS_READ"
// Called to open chapter.
private const val ACTION_OPEN_CHAPTER = "$ID.$NAME.ACTION_OPEN_CHAPTER"
// Value containing file location.
private const val EXTRA_FILE_LOCATION = "$ID.$NAME.FILE_LOCATION"
// Called to resume downloads.
private const val ACTION_RESUME_DOWNLOADS = "$ID.$NAME.ACTION_RESUME_DOWNLOADS"
// Called to pause downloads.
private const val ACTION_PAUSE_DOWNLOADS = "$ID.$NAME.ACTION_PAUSE_DOWNLOADS"
// Called to clear downloads.
private const val ACTION_CLEAR_DOWNLOADS = "$ID.$NAME.ACTION_CLEAR_DOWNLOADS"
// Called to dismiss notification.
private const val ACTION_DISMISS_NOTIFICATION = "$ID.$NAME.ACTION_DISMISS_NOTIFICATION"
// Value containing uri.
private const val EXTRA_FILE_LOCATION = "$ID.$NAME.FILE_LOCATION"
private const val EXTRA_URI = "$ID.$NAME.URI"
// Value containing notification id.
private const val EXTRA_NOTIFICATION_ID = "$ID.$NAME.NOTIFICATION_ID"
// Value containing group id.
private const val EXTRA_GROUP_ID = "$ID.$NAME.EXTRA_GROUP_ID"
// Value containing manga id.
private const val EXTRA_MANGA_ID = "$ID.$NAME.EXTRA_MANGA_ID"
// Value containing chapter id.
private const val EXTRA_CHAPTER_ID = "$ID.$NAME.EXTRA_CHAPTER_ID"
// Value containing chapter url.
private const val EXTRA_CHAPTER_URL = "$ID.$NAME.EXTRA_CHAPTER_URL"
private const val EXTRA_IS_LEGACY_BACKUP = "$ID.$NAME.EXTRA_IS_LEGACY_BACKUP"
/**
* Returns a [PendingIntent] that resumes the download of a chapter
@ -509,10 +494,11 @@ class NotificationReceiver : BroadcastReceiver() {
* @param notificationId id of notification
* @return [PendingIntent]
*/
internal fun shareBackupPendingBroadcast(context: Context, uri: Uri, notificationId: Int): PendingIntent {
internal fun shareBackupPendingBroadcast(context: Context, uri: Uri, isLegacyFormat: Boolean, notificationId: Int): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_SHARE_BACKUP
putExtra(EXTRA_URI, uri)
putExtra(EXTRA_IS_LEGACY_BACKUP, isLegacyFormat)
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
@ -534,6 +520,23 @@ class NotificationReceiver : BroadcastReceiver() {
return PendingIntent.getActivity(context, 0, intent, 0)
}
/**
* Returns [PendingIntent] that starts a share activity for a crash log dump file.
*
* @param context context of application
* @param uri uri of file
* @param notificationId id of notification
* @return [PendingIntent]
*/
internal fun shareCrashLogPendingBroadcast(context: Context, uri: Uri, notificationId: Int): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_SHARE_CRASH_LOG
putExtra(EXTRA_URI, uri)
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
/**
* Returns [PendingIntent] that cancels a backup restore job.
*

View File

@ -62,6 +62,12 @@ object Notifications {
const val ID_BACKUP_COMPLETE = -502
const val ID_RESTORE_COMPLETE = -504
/**
* Notification channel used for crash log file sharing.
*/
const val CHANNEL_CRASH_LOGS = "crash_logs_channel"
const val ID_CRASH_LOGS = -601
private val deprecatedChannels = listOf(
"downloader_channel",
"backup_restore_complete_channel"
@ -143,7 +149,12 @@ object Notifications {
group = GROUP_BACKUP_RESTORE
setShowBadge(false)
setSound(null, null)
}
},
NotificationChannel(
CHANNEL_CRASH_LOGS,
context.getString(R.string.channel_crash_logs),
NotificationManager.IMPORTANCE_HIGH
)
).forEach(context.notificationManager::createNotificationChannel)
// Delete old notification channels

View File

@ -23,6 +23,14 @@ object PreferenceKeys {
const val showPageNumber = "pref_show_page_number_key"
const val dualPageSplitPaged = "pref_dual_page_split"
const val dualPageSplitWebtoon = "pref_dual_page_split_webtoon"
const val dualPageInvertPaged = "pref_dual_page_invert"
const val dualPageInvertWebtoon = "pref_dual_page_invert_webtoon"
const val showReadingMode = "pref_show_reading_mode"
const val trueColor = "pref_true_color_key"
@ -57,7 +65,9 @@ object PreferenceKeys {
const val readWithTapping = "reader_tap"
const val readWithTappingInverted = "reader_tapping_inverted"
const val pagerNavInverted = "reader_tapping_inverted"
const val webtoonNavInverted = "reader_tapping_inverted_webtoon"
const val readWithLongTap = "reader_long_tap"
@ -65,6 +75,14 @@ object PreferenceKeys {
const val readWithVolumeKeysInverted = "reader_volume_keys_inverted"
const val navigationModePager = "reader_navigation_mode_pager"
const val navigationModeWebtoon = "reader_navigation_mode_webtoon"
const val showNavigationOverlayNewUser = "reader_navigation_overlay_new_user"
const val showNavigationOverlayOnStart = "reader_navigation_overlay_on_start"
const val webtoonSidePadding = "webtoon_side_padding"
const val portraitColumns = "pref_library_columns_portrait_key"
@ -106,6 +124,7 @@ object PreferenceKeys {
const val libraryUpdateRestriction = "library_update_restriction"
const val libraryUpdateCategories = "library_update_categories"
const val libraryUpdateCategoriesExclude = "library_update_categories_exclude"
const val libraryUpdatePrioritization = "library_update_prioritization"
@ -117,6 +136,8 @@ object PreferenceKeys {
const val filterCompleted = "pref_filter_library_completed"
const val filterTracked = "pref_filter_library_tracked"
const val librarySortingMode = "library_sorting_mode"
const val automaticExtUpdates = "automatic_ext_updates"
@ -144,6 +165,7 @@ object PreferenceKeys {
const val downloadNew = "download_new"
const val downloadNewCategories = "download_new_categories"
const val downloadNewCategoriesExclude = "download_new_categories_exclude"
const val libraryDisplayMode = "pref_display_mode_library"
@ -163,11 +185,13 @@ object PreferenceKeys {
const val categoryTabs = "display_category_tabs"
const val categoryNumberOfItems = "display_number_of_items"
const val alwaysShowChapterTransition = "always_show_chapter_transition"
const val searchPinnedSourcesOnly = "search_pinned_sources_only"
const val enableDoh = "enable_doh"
const val dohProvider = "doh_provider"
const val defaultChapterFilterByRead = "default_chapter_filter_by_read"

View File

@ -5,6 +5,8 @@ package eu.kanade.tachiyomi.data.preference
*/
object PreferenceValues {
/* ktlint-disable experimental:enum-entry-name-case */
// Keys are lowercase to match legacy string values
enum class ThemeMode {
light,
@ -25,16 +27,18 @@ object PreferenceValues {
amoled,
}
/* ktlint-enable experimental:enum-entry-name-case */
enum class DisplayMode {
COMPACT_GRID,
COMFORTABLE_GRID,
LIST,
}
enum class TappingInvertMode {
enum class TappingInvertMode(val shouldInvertHorizontal: Boolean = false, val shouldInvertVertical: Boolean = false) {
NONE,
HORIZONTAL,
VERTICAL,
BOTH
HORIZONTAL(shouldInvertHorizontal = true),
VERTICAL(shouldInvertVertical = true),
BOTH(shouldInvertHorizontal = true, shouldInvertVertical = true)
}
}

View File

@ -13,7 +13,6 @@ import eu.kanade.tachiyomi.data.preference.PreferenceValues.DisplayMode
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.anilist.Anilist
import eu.kanade.tachiyomi.widget.ExtendedNavigationView
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onEach
import java.io.File
@ -23,8 +22,7 @@ import java.util.Locale
import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys
import eu.kanade.tachiyomi.data.preference.PreferenceValues as Values
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> Preference<T>.asImmediateFlow(block: (value: T) -> Unit): Flow<T> {
fun <T> Preference<T>.asImmediateFlow(block: (T) -> Unit): Flow<T> {
block(get())
return asFlow()
.onEach { block(it) }
@ -38,7 +36,10 @@ operator fun <T> Preference<Set<T>>.minusAssign(item: T) {
set(get() - item)
}
@OptIn(ExperimentalCoroutinesApi::class)
fun Preference<Boolean>.toggle() {
set(!get())
}
class PreferencesHelper(val context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
@ -92,6 +93,14 @@ class PreferencesHelper(val context: Context) {
fun showPageNumber() = flowPrefs.getBoolean(Keys.showPageNumber, true)
fun dualPageSplitPaged() = flowPrefs.getBoolean(Keys.dualPageSplitPaged, false)
fun dualPageSplitWebtoon() = flowPrefs.getBoolean(Keys.dualPageSplitWebtoon, false)
fun dualPageInvertPaged() = flowPrefs.getBoolean(Keys.dualPageInvertPaged, false)
fun dualPageInvertWebtoon() = flowPrefs.getBoolean(Keys.dualPageInvertWebtoon, false)
fun showReadingMode() = prefs.getBoolean(Keys.showReadingMode, true)
fun trueColor() = flowPrefs.getBoolean(Keys.trueColor, false)
@ -130,7 +139,9 @@ class PreferencesHelper(val context: Context) {
fun readWithTapping() = flowPrefs.getBoolean(Keys.readWithTapping, true)
fun readWithTappingInverted() = flowPrefs.getEnum(Keys.readWithTappingInverted, Values.TappingInvertMode.NONE)
fun pagerNavInverted() = flowPrefs.getEnum(Keys.pagerNavInverted, Values.TappingInvertMode.NONE)
fun webtoonNavInverted() = flowPrefs.getEnum(Keys.webtoonNavInverted, Values.TappingInvertMode.NONE)
fun readWithLongTap() = flowPrefs.getBoolean(Keys.readWithLongTap, true)
@ -138,6 +149,14 @@ class PreferencesHelper(val context: Context) {
fun readWithVolumeKeysInverted() = flowPrefs.getBoolean(Keys.readWithVolumeKeysInverted, false)
fun navigationModePager() = flowPrefs.getInt(Keys.navigationModePager, 0)
fun navigationModeWebtoon() = flowPrefs.getInt(Keys.navigationModeWebtoon, 0)
fun showNavigationOverlayNewUser() = flowPrefs.getBoolean(Keys.showNavigationOverlayNewUser, true)
fun showNavigationOverlayOnStart() = flowPrefs.getBoolean(Keys.showNavigationOverlayOnStart, false)
fun portraitColumns() = flowPrefs.getInt(Keys.portraitColumns, 0)
fun landscapeColumns() = flowPrefs.getInt(Keys.landscapeColumns, 0)
@ -199,6 +218,7 @@ class PreferencesHelper(val context: Context) {
fun libraryUpdateRestriction() = prefs.getStringSet(Keys.libraryUpdateRestriction, setOf("wifi"))
fun libraryUpdateCategories() = flowPrefs.getStringSet(Keys.libraryUpdateCategories, emptySet())
fun libraryUpdateCategoriesExclude() = flowPrefs.getStringSet(Keys.libraryUpdateCategoriesExclude, emptySet())
fun libraryUpdatePrioritization() = flowPrefs.getInt(Keys.libraryUpdatePrioritization, 0)
@ -212,12 +232,16 @@ class PreferencesHelper(val context: Context) {
fun categoryTabs() = flowPrefs.getBoolean(Keys.categoryTabs, true)
fun categoryNumberOfItems() = flowPrefs.getBoolean(Keys.categoryNumberOfItems, false)
fun filterDownloaded() = flowPrefs.getInt(Keys.filterDownloaded, ExtendedNavigationView.Item.TriStateGroup.State.IGNORE.value)
fun filterUnread() = flowPrefs.getInt(Keys.filterUnread, ExtendedNavigationView.Item.TriStateGroup.State.IGNORE.value)
fun filterCompleted() = flowPrefs.getInt(Keys.filterCompleted, ExtendedNavigationView.Item.TriStateGroup.State.IGNORE.value)
fun filterTracking(name: Int) = flowPrefs.getInt("${Keys.filterTracked}_$name", ExtendedNavigationView.Item.TriStateGroup.State.IGNORE.value)
fun librarySortingMode() = flowPrefs.getInt(Keys.librarySortingMode, 0)
fun librarySortingAscending() = flowPrefs.getBoolean("library_sorting_ascending", true)
@ -241,6 +265,7 @@ class PreferencesHelper(val context: Context) {
fun downloadNew() = flowPrefs.getBoolean(Keys.downloadNew, false)
fun downloadNewCategories() = flowPrefs.getStringSet(Keys.downloadNewCategories, emptySet())
fun downloadNewCategoriesExclude() = flowPrefs.getStringSet(Keys.downloadNewCategoriesExclude, emptySet())
fun lang() = prefs.getString(Keys.lang, "")
@ -254,7 +279,7 @@ class PreferencesHelper(val context: Context) {
fun trustedSignatures() = flowPrefs.getStringSet("trusted_signatures", emptySet())
fun enableDoh() = prefs.getBoolean(Keys.enableDoh, false)
fun dohProvider() = prefs.getInt(Keys.dohProvider, -1)
fun lastSearchQuerySearchSettings() = flowPrefs.getString("last_search_query", "")

View File

@ -1,14 +1,14 @@
package eu.kanade.tachiyomi.data.track
import androidx.annotation.CallSuper
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.NetworkHelper
import okhttp3.OkHttpClient
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
abstract class TrackService(val id: Int) {
@ -20,7 +20,8 @@ abstract class TrackService(val id: Int) {
get() = networkService.client
// Name of the manga sync service to display
abstract val name: String
@StringRes
abstract fun nameRes(): Int
// Application and remote support for reading dates
open val supportsReadingDates: Boolean = false
@ -28,6 +29,7 @@ abstract class TrackService(val id: Int) {
@DrawableRes
abstract fun getLogo(): Int
@ColorInt
abstract fun getLogoColor(): Int
abstract fun getStatusList(): List<Int>
@ -44,17 +46,17 @@ abstract class TrackService(val id: Int) {
abstract fun displayScore(track: Track): String
abstract fun add(track: Track): Observable<Track>
abstract suspend fun add(track: Track): Track
abstract fun update(track: Track): Observable<Track>
abstract suspend fun update(track: Track): Track
abstract fun bind(track: Track): Observable<Track>
abstract suspend fun bind(track: Track): Track
abstract fun search(query: String): Observable<List<TrackSearch>>
abstract suspend fun search(query: String): List<TrackSearch>
abstract fun refresh(track: Track): Observable<Track>
abstract suspend fun refresh(track: Track): Track
abstract fun login(username: String, password: String): Completable
abstract suspend fun login(username: String, password: String)
@CallSuper
open fun logout() {

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.track.anilist
import android.content.Context
import android.graphics.Color
import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
@ -9,8 +10,6 @@ import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
class Anilist(private val context: Context, id: Int) : TrackService(id) {
@ -23,9 +22,6 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
const val PLANNING = 5
const val REPEATING = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
const val POINT_100 = "POINT_100"
const val POINT_10 = "POINT_10"
const val POINT_10_DECIMAL = "POINT_10_DECIMAL"
@ -33,14 +29,14 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
const val POINT_3 = "POINT_3"
}
override val name = "AniList"
private val json: Json by injectLazy()
private val interceptor by lazy { AnilistInterceptor(this, getPassword()) }
private val api by lazy { AnilistApi(client, interceptor) }
override val supportsReadingDates: Boolean = true
private val scorePreference = preferences.anilistScoreType()
init {
@ -53,6 +49,9 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
}
}
@StringRes
override fun nameRes() = R.string.tracker_anilist
override fun getLogo() = R.drawable.ic_tracker_anilist
override fun getLogoColor() = Color.rgb(18, 25, 35)
@ -131,65 +130,58 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
}
}
override fun add(track: Track): Observable<Track> {
override suspend fun add(track: Track): Track {
return api.addLibManga(track)
}
override fun update(track: Track): Observable<Track> {
override suspend fun update(track: Track): Track {
// If user was using API v1 fetch library_id
if (track.library_id == null || track.library_id!! == 0L) {
return api.findLibManga(track, getUsername().toInt()).flatMap {
if (it == null) {
throw Exception("$track not found on user library")
}
track.library_id = it.library_id
api.updateLibManga(track)
}
val libManga = api.findLibManga(track, getUsername().toInt())
?: throw Exception("$track not found on user library")
track.library_id = libManga.library_id
}
return api.updateLibManga(track)
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername().toInt())
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id
update(track)
} else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
add(track)
}
}
override suspend fun bind(track: Track): Track {
val remoteTrack = api.findLibManga(track, getUsername().toInt())
return 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.status = READING
track.score = 0F
add(track)
}
}
override fun search(query: String): Observable<List<TrackSearch>> {
override suspend fun search(query: String): List<TrackSearch> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track, getUsername().toInt())
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
track
}
override suspend fun refresh(track: Track): Track {
val remoteTrack = api.getLibManga(track, getUsername().toInt())
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
return track
}
override fun login(username: String, password: String) = login(password)
override suspend fun login(username: String, password: String) = login(password)
fun login(token: String): Completable {
val oauth = api.createOAuth(token)
interceptor.setAuth(oauth)
return api.getCurrentUser().map { (username, scoreType) ->
suspend fun login(token: String) {
try {
val oauth = api.createOAuth(token)
interceptor.setAuth(oauth)
val (username, scoreType) = api.getCurrentUser()
scorePreference.set(scoreType)
saveCredentials(username.toString(), oauth.access_token)
}.doOnError {
} catch (e: Throwable) {
logout()
}.toCompletable()
}
}
override fun logout() {

View File

@ -2,11 +2,17 @@ package eu.kanade.tachiyomi.data.track.anilist
import android.net.Uri
import androidx.core.net.toUri
import com.afollestad.date.dayOfMonth
import com.afollestad.date.month
import com.afollestad.date.year
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.asObservableSuccess
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.lang.withIOContext
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
@ -18,24 +24,17 @@ import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.util.Calendar
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
private val json: Json by injectLazy()
private val jsonMime = "application/json; charset=utf-8".toMediaTypeOrNull()
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun addLibManga(track: Track): Observable<Track> {
val query =
"""
suspend fun addLibManga(track: Track): Track {
return withIOContext {
val query = """
|mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) {
|SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) {
| id
@ -43,68 +42,67 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|}
|}
|""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("mangaId", track.media_id)
put("progress", track.last_chapter_read)
put("status", track.toAnilistStatus())
}
}
val body = payload.toString().toRequestBody(jsonMime)
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body?.string().orEmpty()
netResponse.close()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("mangaId", track.media_id)
put("progress", track.last_chapter_read)
put("status", track.toAnilistStatus())
}
val response = json.decodeFromString<JsonObject>(responseBody)
track.library_id = response["data"]!!.jsonObject["SaveMediaListEntry"]!!.jsonObject["id"]!!.jsonPrimitive.long
track
}
authClient.newCall(
POST(
apiUrl,
body = payload.toString().toRequestBody(jsonMime)
)
)
.await()
.parseAs<JsonObject>()
.let {
track.library_id =
it["data"]!!.jsonObject["SaveMediaListEntry"]!!.jsonObject["id"]!!.jsonPrimitive.long
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) {
suspend fun updateLibManga(track: Track): Track {
return withIOContext {
val query = """
|mutation UpdateManga(
|${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus,
|${'$'}score: Int, ${'$'}startedAt: FuzzyDateInput, ${'$'}completedAt: FuzzyDateInput
|) {
|SaveMediaListEntry(
|id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status,
|scoreRaw: ${'$'}score, startedAt: ${'$'}startedAt, completedAt: ${'$'}completedAt
|) {
|id
|status
|progress
|}
|}
|""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("listId", track.library_id)
put("progress", track.last_chapter_read)
put("status", track.toAnilistStatus())
put("score", track.score.toInt())
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("listId", track.library_id)
put("progress", track.last_chapter_read)
put("status", track.toAnilistStatus())
put("score", track.score.toInt())
put("startedAt", createDate(track.started_reading_date))
put("completedAt", createDate(track.finished_reading_date))
}
}
authClient.newCall(POST(apiUrl, body = payload.toString().toRequestBody(jsonMime)))
.await()
track
}
val body = payload.toString().toRequestBody(jsonMime)
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map {
track
}
}
fun search(search: String): Observable<List<TrackSearch>> {
val query =
"""
suspend fun search(search: String): List<TrackSearch> {
return withIOContext {
val query = """
|query Search(${'$'}query: String) {
|Page (perPage: 50) {
|media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
@ -128,36 +126,33 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|}
|}
|""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("query", search)
}
}
val body = payload.toString().toRequestBody(jsonMime)
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("query", search)
}
val response = json.decodeFromString<JsonObject>(responseBody)
val data = response["data"]!!.jsonObject
val page = data["Page"]!!.jsonObject
val media = page["media"]!!.jsonArray
val entries = media.map { jsonToALManga(it.jsonObject) }
entries.map { it.toTrack() }
}
authClient.newCall(
POST(
apiUrl,
body = payload.toString().toRequestBody(jsonMime)
)
)
.await()
.parseAs<JsonObject>()
.let { response ->
val data = response["data"]!!.jsonObject
val page = data["Page"]!!.jsonObject
val media = page["media"]!!.jsonArray
val entries = media.map { jsonToALManga(it.jsonObject) }
entries.map { it.toTrack() }
}
}
}
fun findLibManga(track: Track, userid: Int): Observable<Track?> {
val query =
"""
suspend fun findLibManga(track: Track, userid: Int): Track? {
return withIOContext {
val query = """
|query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
|Page {
|mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) {
@ -165,6 +160,16 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|status
|scoreRaw: score(format: POINT_100)
|progress
|startedAt {
|year
|month
|day
|}
|completedAt {
|year
|month
|day
|}
|media {
|id
|title {
@ -187,46 +192,42 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|}
|}
|""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("id", userid)
put("manga_id", track.media_id)
}
}
val body = payload.toString().toRequestBody(jsonMime)
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("id", userid)
put("manga_id", track.media_id)
}
val response = json.decodeFromString<JsonObject>(responseBody)
val data = response["data"]!!.jsonObject
val page = data["Page"]!!.jsonObject
val media = page["mediaList"]!!.jsonArray
val entries = media.map { jsonToALUserManga(it.jsonObject) }
entries.firstOrNull()?.toTrack()
}
authClient.newCall(
POST(
apiUrl,
body = payload.toString().toRequestBody(jsonMime)
)
)
.await()
.parseAs<JsonObject>()
.let { response ->
val data = response["data"]!!.jsonObject
val page = data["Page"]!!.jsonObject
val media = page["mediaList"]!!.jsonArray
val entries = media.map { jsonToALUserManga(it.jsonObject) }
entries.firstOrNull()?.toTrack()
}
}
}
fun getLibManga(track: Track, userid: Int): Observable<Track> {
return findLibManga(track, userid)
.map { it ?: throw Exception("Could not find manga") }
suspend fun getLibManga(track: Track, userid: Int): Track {
return findLibManga(track, userid) ?: throw Exception("Could not find manga")
}
fun createOAuth(token: String): OAuth {
return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
}
fun getCurrentUser(): Observable<Pair<Int, String>> {
val query =
"""
suspend fun getCurrentUser(): Pair<Int, String> {
return withIOContext {
val query = """
|query User {
|Viewer {
|id
@ -236,44 +237,29 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|}
|}
|""".trimMargin()
val payload = buildJsonObject {
put("query", query)
}
val body = payload.toString().toRequestBody(jsonMime)
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = json.decodeFromString<JsonObject>(responseBody)
val data = response["data"]!!.jsonObject
val viewer = data["Viewer"]!!.jsonObject
Pair(viewer["id"]!!.jsonPrimitive.int, viewer["mediaListOptions"]!!.jsonObject["scoreFormat"]!!.jsonPrimitive.content)
val payload = buildJsonObject {
put("query", query)
}
authClient.newCall(
POST(
apiUrl,
body = payload.toString().toRequestBody(jsonMime)
)
)
.await()
.parseAs<JsonObject>()
.let {
val data = it["data"]!!.jsonObject
val viewer = data["Viewer"]!!.jsonObject
Pair(
viewer["id"]!!.jsonPrimitive.int,
viewer["mediaListOptions"]!!.jsonObject["scoreFormat"]!!.jsonPrimitive.content
)
}
}
}
private fun jsonToALManga(struct: JsonObject): ALManga {
val date = try {
val date = Calendar.getInstance()
date.set(
struct["startDate"]!!.jsonObject["year"]!!.jsonPrimitive.intOrNull ?: 0,
(
struct["startDate"]!!.jsonObject["month"]!!.jsonPrimitive.intOrNull
?: 0
) - 1,
struct["startDate"]!!.jsonObject["day"]!!.jsonPrimitive.intOrNull ?: 0
)
date.timeInMillis
} catch (_: Exception) {
0L
}
return ALManga(
struct["id"]!!.jsonPrimitive.int,
struct["title"]!!.jsonObject["romaji"]!!.jsonPrimitive.content,
@ -281,7 +267,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
struct["description"]!!.jsonPrimitive.contentOrNull,
struct["type"]!!.jsonPrimitive.content,
struct["status"]!!.jsonPrimitive.contentOrNull ?: "",
date,
parseDate(struct, "startDate"),
struct["chapters"]!!.jsonPrimitive.intOrNull ?: 0
)
}
@ -292,13 +278,46 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
struct["status"]!!.jsonPrimitive.content,
struct["scoreRaw"]!!.jsonPrimitive.int,
struct["progress"]!!.jsonPrimitive.int,
parseDate(struct, "startedAt"),
parseDate(struct, "completedAt"),
jsonToALManga(struct["media"]!!.jsonObject)
)
}
private fun parseDate(struct: JsonObject, dateKey: String): Long {
return try {
val date = Calendar.getInstance()
date.set(
struct[dateKey]!!.jsonObject["year"]!!.jsonPrimitive.int,
struct[dateKey]!!.jsonObject["month"]!!.jsonPrimitive.int - 1,
struct[dateKey]!!.jsonObject["day"]!!.jsonPrimitive.int
)
date.timeInMillis
} catch (_: Exception) {
0L
}
}
private fun createDate(dateValue: Long): JsonObject {
if (dateValue == 0L) {
return buildJsonObject {
put("year", JsonNull)
put("month", JsonNull)
put("day", JsonNull)
}
}
val calendar = Calendar.getInstance()
calendar.timeInMillis = dateValue
return buildJsonObject {
put("year", calendar.year)
put("month", calendar.month + 1)
put("day", calendar.dayOfMonth)
}
}
companion object {
private const val clientId = "385"
private const val clientUrl = "tachiyomi://anilist-auth"
private const val apiUrl = "https://graphql.anilist.co/"
private const val baseUrl = "https://anilist.co/api/v2/"
private const val baseMangaUrl = "https://anilist.co/manga/"

View File

@ -44,6 +44,8 @@ data class ALUserManga(
val list_status: String,
val score_raw: Int,
val chapters_read: Int,
val start_date_fuzzy: Long,
val completed_date_fuzzy: Long,
val manga: ALManga
) {
@ -51,6 +53,8 @@ data class ALUserManga(
media_id = manga.media_id
status = toTrackStatus()
score = score_raw.toFloat()
started_reading_date = start_date_fuzzy
finished_reading_date = completed_date_fuzzy
last_chapter_read = chapters_read
library_id = this@ALUserManga.library_id
total_chapters = manga.total_chapters

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.track.bangumi
import android.content.Context
import android.graphics.Color
import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
@ -9,20 +10,19 @@ import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
class Bangumi(private val context: Context, id: Int) : TrackService(id) {
override val name = "Bangumi"
private val json: Json by injectLazy()
private val interceptor by lazy { BangumiInterceptor(this) }
private val api by lazy { BangumiApi(client, interceptor) }
@StringRes
override fun nameRes() = R.string.tracker_bangumi
override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString)
}
@ -31,52 +31,45 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
return track.score.toInt().toString()
}
override fun add(track: Track): Observable<Track> {
override suspend fun add(track: Track): Track {
return api.addLibManga(track)
}
override fun update(track: Track): Observable<Track> {
override suspend fun update(track: Track): Track {
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 suspend fun bind(track: Track): Track {
val statusTrack = api.statusLibManga(track)
val remoteTrack = api.findLibManga(track)
return if (remoteTrack != null && statusTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id
track.status = statusTrack.status
track.score = statusTrack.score
track.last_chapter_read = statusTrack.last_chapter_read
track.total_chapters = remoteTrack.total_chapters
refresh(track)
} else {
// Set default fields if it's not found in the list
track.status = READING
track.score = 0F
add(track)
update(track)
}
}
override fun search(query: String): Observable<List<TrackSearch>> {
override suspend fun search(query: String): 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
}
}
override suspend fun refresh(track: Track): Track {
val remoteStatusTrack = api.statusLibManga(track)
track.copyPersonalFrom(remoteStatusTrack!!)
api.findLibManga(track)?.let { remoteTrack ->
track.total_chapters = remoteTrack.total_chapters
}
return track
}
override fun getLogo() = R.drawable.ic_tracker_bangumi
@ -100,17 +93,16 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
override fun getCompletionStatus(): Int = COMPLETED
override fun login(username: String, password: String) = login(password)
override suspend fun login(username: String, password: String) = login(password)
fun login(code: String): Completable {
return api.accessToken(code).map { oauth: OAuth? ->
suspend fun login(code: String) {
try {
val oauth = api.accessToken(code)
interceptor.newAuth(oauth)
if (oauth != null) {
saveCredentials(oauth.user_id.toString(), oauth.access_token)
}
}.doOnError {
saveCredentials(oauth.user_id.toString(), oauth.access_token)
} catch (e: Throwable) {
logout()
}.toCompletable()
}
}
fun saveToken(oauth: OAuth?) {
@ -137,8 +129,5 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
const val ON_HOLD = 4
const val DROPPED = 5
const val PLANNING = 1
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
}
}

View File

@ -5,12 +5,15 @@ import androidx.core.net.toUri
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 eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.lang.withIOContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.float
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
@ -19,7 +22,6 @@ import okhttp3.CacheControl
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder
@ -29,145 +31,131 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
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
}
suspend fun addLibManga(track: Track): Track {
return withIOContext {
val body = FormBody.Builder()
.add("rating", track.score.toInt().toString())
.add("status", track.toBangumiStatus())
.build()
authClient.newCall(POST("$apiUrl/collection/${track.media_id}/update", body = body))
.await()
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()
suspend fun updateLibManga(track: Track): Track {
return withIOContext {
// read status update
val sbody = FormBody.Builder()
.add("rating", track.score.toInt().toString())
.add("status", track.toBangumiStatus())
.build()
authClient.newCall(POST("$apiUrl/collection/${track.media_id}/update", body = sbody))
.await()
// 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
// chapter update
val body = FormBody.Builder()
.add("watched_eps", track.last_chapter_read.toString())
.build()
authClient.newCall(
POST(
"$apiUrl/subject/${track.media_id}/update/watched_eps",
body = body
)
).await()
track
}
}
suspend fun search(search: String): List<TrackSearch> {
return withIOContext {
val url = "$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}"
.toUri()
.buildUpon()
.appendQueryParameter("max_results", "20")
.build()
authClient.newCall(GET(url.toString()))
.await()
.use {
var responseBody = it.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
}
}
fun search(search: String): Observable<List<TrackSearch>> {
val url = "$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}"
.toUri()
.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 = json.decodeFromString<JsonObject>(responseBody)["list"]?.jsonArray
response?.filter { it.jsonObject["type"]?.jsonPrimitive?.int == 1 }
?.map { jsonToSearch(it.jsonObject) }.orEmpty()
}
if (responseBody.contains("\"code\":404")) {
responseBody = "{\"results\":0,\"list\":[]}"
}
val response = json.decodeFromString<JsonObject>(responseBody)["list"]?.jsonArray
response?.filter { it.jsonObject["type"]?.jsonPrimitive?.int == 1 }?.map { jsonToSearch(it.jsonObject) }
}
}
}
private fun jsonToSearch(obj: JsonObject): TrackSearch {
val coverUrl = if (obj["images"] is JsonObject) {
obj["images"]?.jsonObject?.get("common")?.jsonPrimitive?.contentOrNull ?: ""
} else {
// Sometimes JsonNull
""
}
val totalChapters = if (obj["eps_count"] != null) {
obj["eps_count"]!!.jsonPrimitive.int
} else {
0
}
return TrackSearch.create(TrackManager.BANGUMI).apply {
media_id = obj["id"]!!.jsonPrimitive.int
title = obj["name_cn"]!!.jsonPrimitive.content
cover_url = obj["images"]!!.jsonObject["common"]!!.jsonPrimitive.content
cover_url = coverUrl
summary = obj["name"]!!.jsonPrimitive.content
tracking_url = obj["url"]!!.jsonPrimitive.content
total_chapters = totalChapters
}
}
private fun jsonToTrack(mangas: JsonObject): Track {
return Track.create(TrackManager.BANGUMI).apply {
title = mangas["name"]!!.jsonPrimitive.content
media_id = mangas["id"]!!.jsonPrimitive.int
score = try {
mangas["rating"]!!.jsonObject["score"]!!.jsonPrimitive.float
} catch (_: Exception) {
0f
}
status = Bangumi.DEFAULT_STATUS
tracking_url = mangas["url"]!!.jsonPrimitive.content
suspend fun findLibManga(track: Track): Track? {
return withIOContext {
authClient.newCall(GET("$apiUrl/subject/${track.media_id}"))
.await()
.parseAs<JsonObject>()
.let { jsonToSearch(it) }
}
}
fun findLibManga(track: Track): Observable<Track?> {
val urlMangas = "$apiUrl/subject/${track.media_id}"
val requestMangas = Request.Builder()
.url(urlMangas)
.get()
.build()
suspend fun statusLibManga(track: Track): Track? {
return withIOContext {
val urlUserRead = "$apiUrl/collection/${track.media_id}"
val requestUserRead = Request.Builder()
.url(urlUserRead)
.cacheControl(CacheControl.FORCE_NETWORK)
.get()
.build()
return authClient.newCall(requestMangas)
.asObservableSuccess()
.map { netResponse ->
// get comic info
val responseBody = netResponse.body?.string().orEmpty()
jsonToTrack(json.decodeFromString(responseBody))
}
}
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 = json.decodeFromString<Collection>(resp!!)
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()
// TODO: get user readed chapter here
var response = authClient.newCall(requestUserRead).await()
var responseBody = response.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
json.decodeFromString<OAuth>(responseBody)
if (responseBody.contains("\"code\":400")) {
null
} else {
json.decodeFromString<Collection>(responseBody).let {
track.status = it.status?.id!!
track.last_chapter_read = it.ep_status!!
track.score = it.rating!!
track
}
}
}
}
suspend fun accessToken(code: String): OAuth {
return withIOContext {
client.newCall(accessTokenRequest(code))
.await()
.parseAs()
}
}

View File

@ -8,7 +8,7 @@ data class Collection(
val comment: String? = "",
val ep_status: Int? = 0,
val lasttouch: Int? = 0,
val rating: Int? = 0,
val rating: Float? = 0f,
val status: Status? = Status(),
val tag: List<String?>? = listOf(),
val user: User? = User(),

View File

@ -2,13 +2,14 @@ package eu.kanade.tachiyomi.data.track.kitsu
import android.content.Context
import android.graphics.Color
import com.google.gson.Gson
import androidx.annotation.StringRes
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 kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import uy.kohesive.injekt.injectLazy
import java.text.DecimalFormat
@ -20,16 +21,14 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
const val ON_HOLD = 3
const val DROPPED = 4
const val PLAN_TO_READ = 5
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0f
}
override val name = "Kitsu"
@StringRes
override fun nameRes() = R.string.tracker_kitsu
private val gson: Gson by injectLazy()
private val json: Json by injectLazy()
private val interceptor by lazy { KitsuInterceptor(this, gson) }
private val interceptor by lazy { KitsuInterceptor(this) }
private val api by lazy { KitsuApi(client, interceptor) }
@ -68,49 +67,43 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
return df.format(track.score)
}
override fun add(track: Track): Observable<Track> {
override suspend fun add(track: Track): Track {
return api.addLibManga(track, getUserId())
}
override fun update(track: Track): Observable<Track> {
override suspend fun update(track: Track): Track {
return api.updateLibManga(track)
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUserId())
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.media_id = remoteTrack.media_id
update(track)
} else {
track.score = DEFAULT_SCORE
track.status = DEFAULT_STATUS
add(track)
}
}
override suspend fun bind(track: Track): Track {
val remoteTrack = api.findLibManga(track, getUserId())
return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.media_id = remoteTrack.media_id
update(track)
} else {
track.status = READING
track.score = 0F
add(track)
}
}
override fun search(query: String): Observable<List<TrackSearch>> {
override suspend fun search(query: String): List<TrackSearch> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track)
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
track
}
override suspend fun refresh(track: Track): Track {
val remoteTrack = api.getLibManga(track)
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
return track
}
override fun login(username: String, password: String): Completable {
return api.login(username, password)
.doOnNext { interceptor.newAuth(it) }
.flatMap { api.getCurrentUser() }
.doOnNext { userId -> saveCredentials(username, userId) }
.doOnError { logout() }
.toCompletable()
override suspend fun login(username: String, password: String) {
val token = api.login(username, password)
interceptor.newAuth(token)
val userId = api.getCurrentUser()
saveCredentials(username, userId)
}
override fun logout() {
@ -123,13 +116,12 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
}
fun saveToken(oauth: OAuth?) {
val json = gson.toJson(oauth)
preferences.trackToken(this).set(json)
preferences.trackToken(this).set(json.encodeToString(oauth))
}
fun restoreToken(): OAuth? {
return try {
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
json.decodeFromString<OAuth>(preferences.trackToken(this).get())
} catch (e: Exception) {
null
}

View File

@ -1,254 +1,253 @@
package eu.kanade.tachiyomi.data.track.kitsu
import com.github.salomonbrys.kotson.array
import com.github.salomonbrys.kotson.get
import com.github.salomonbrys.kotson.int
import com.github.salomonbrys.kotson.jsonObject
import com.github.salomonbrys.kotson.obj
import com.github.salomonbrys.kotson.string
import com.google.gson.GsonBuilder
import com.google.gson.JsonObject
import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track
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.await
import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.lang.withIOContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import okhttp3.FormBody
import okhttp3.Headers.Companion.headersOf
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Body
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Headers
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
import rx.Observable
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
private val rest = Retrofit.Builder()
.baseUrl(baseUrl)
.client(authClient)
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create()))
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build()
.create(Rest::class.java)
suspend fun addLibManga(track: Track, userId: String): Track {
return withIOContext {
val data = buildJsonObject {
putJsonObject("data") {
put("type", "libraryEntries")
putJsonObject("attributes") {
put("status", track.toKitsuStatus())
put("progress", track.last_chapter_read)
}
putJsonObject("relationships") {
putJsonObject("user") {
putJsonObject("data") {
put("id", userId)
put("type", "users")
}
}
putJsonObject("media") {
putJsonObject("data") {
put("id", track.media_id)
put("type", "manga")
}
}
}
}
}
private val searchRest = Retrofit.Builder()
.baseUrl(algoliaKeyUrl)
.client(authClient)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build()
.create(SearchKeyRest::class.java)
private val algoliaRest = Retrofit.Builder()
.baseUrl(algoliaUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build()
.create(AgoliaSearchRest::class.java)
fun addLibManga(track: Track, userId: String): Observable<Track> {
return Observable.defer {
// @formatter:off
val data = jsonObject(
"type" to "libraryEntries",
"attributes" to jsonObject(
"status" to track.toKitsuStatus(),
"progress" to track.last_chapter_read
),
"relationships" to jsonObject(
"user" to jsonObject(
"data" to jsonObject(
"id" to userId,
"type" to "users"
)
authClient.newCall(
POST(
"${baseUrl}library-entries",
headers = headersOf(
"Content-Type",
"application/vnd.api+json"
),
"media" to jsonObject(
"data" to jsonObject(
"id" to track.media_id,
"type" to "manga"
)
)
body = data.toString().toRequestBody("application/vnd.api+json".toMediaType())
)
)
rest.addLibManga(jsonObject("data" to data))
.map { json ->
track.media_id = json["data"]["id"].int
.await()
.parseAs<JsonObject>()
.let {
track.media_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.int
track
}
}
}
fun updateLibManga(track: Track): Observable<Track> {
return Observable.defer {
// @formatter:off
val data = jsonObject(
"type" to "libraryEntries",
"id" to track.media_id,
"attributes" to jsonObject(
"status" to track.toKitsuStatus(),
"progress" to track.last_chapter_read,
"ratingTwenty" to track.toKitsuScore()
)
)
// @formatter:on
suspend fun updateLibManga(track: Track): Track {
return withIOContext {
val data = buildJsonObject {
putJsonObject("data") {
put("type", "libraryEntries")
put("id", track.media_id)
putJsonObject("attributes") {
put("status", track.toKitsuStatus())
put("progress", track.last_chapter_read)
put("ratingTwenty", track.toKitsuScore())
}
}
}
rest.updateLibManga(track.media_id, jsonObject("data" to data))
.map { track }
authClient.newCall(
Request.Builder()
.url("${baseUrl}library-entries/${track.media_id}")
.headers(
headersOf(
"Content-Type",
"application/vnd.api+json"
)
)
.patch(data.toString().toRequestBody("application/vnd.api+json".toMediaType()))
.build()
)
.await()
.parseAs<JsonObject>()
.let {
track
}
}
}
fun search(query: String): Observable<List<TrackSearch>> {
return searchRest
.getKey().map { json ->
json["media"].asJsonObject["key"].string
}.flatMap { key ->
algoliaSearch(key, query)
}
}
private fun algoliaSearch(key: String, query: String): Observable<List<TrackSearch>> {
val jsonObject = jsonObject("params" to "query=$query$algoliaFilter")
return algoliaRest
.getSearchQuery(algoliaAppId, key, jsonObject)
.map { json ->
val data = json["hits"].array
data.map { KitsuSearchManga(it.obj) }
.filter { it.subType != "novel" }
.map { it.toTrack() }
}
}
fun findLibManga(track: Track, userId: String): Observable<Track?> {
return rest.findLibManga(track.media_id, userId)
.map { json ->
val data = json["data"].array
if (data.size() > 0) {
val manga = json["included"].array[0].obj
KitsuLibManga(data[0].obj, manga).toTrack()
} else {
null
suspend fun search(query: String): List<TrackSearch> {
return withIOContext {
authClient.newCall(GET(algoliaKeyUrl))
.await()
.parseAs<JsonObject>()
.let {
val key = it["media"]!!.jsonObject["key"]!!.jsonPrimitive.content
algoliaSearch(key, query)
}
}
}
}
fun getLibManga(track: Track): Observable<Track> {
return rest.getLibManga(track.media_id)
.map { json ->
val data = json["data"].array
if (data.size() > 0) {
val manga = json["included"].array[0].obj
KitsuLibManga(data[0].obj, manga).toTrack()
} else {
throw Exception("Could not find manga")
private suspend fun algoliaSearch(key: String, query: String): List<TrackSearch> {
return withIOContext {
val jsonObject = buildJsonObject {
put("params", "query=$query$algoliaFilter")
}
client.newCall(
POST(
algoliaUrl,
headers = headersOf(
"X-Algolia-Application-Id",
algoliaAppId,
"X-Algolia-API-Key",
key,
),
body = jsonObject.toString().toRequestBody(jsonMime)
)
)
.await()
.parseAs<JsonObject>()
.let {
it["hits"]!!.jsonArray
.map { KitsuSearchManga(it.jsonObject) }
.filter { it.subType != "novel" }
.map { it.toTrack() }
}
}
}
}
fun login(username: String, password: String): Observable<OAuth> {
return Retrofit.Builder()
.baseUrl(loginUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build()
.create(LoginRest::class.java)
.requestAccessToken(username, password)
suspend fun findLibManga(track: Track, userId: String): Track? {
return withIOContext {
val url = "${baseUrl}library-entries".toUri().buildUpon()
.encodedQuery("filter[manga_id]=${track.media_id}&filter[user_id]=$userId")
.appendQueryParameter("include", "manga")
.build()
authClient.newCall(GET(url.toString()))
.await()
.parseAs<JsonObject>()
.let {
val data = it["data"]!!.jsonArray
if (data.size > 0) {
val manga = it["included"]!!.jsonArray[0].jsonObject
KitsuLibManga(data[0].jsonObject, manga).toTrack()
} else {
null
}
}
}
}
fun getCurrentUser(): Observable<String> {
return rest.getCurrentUser().map { it["data"].array[0]["id"].string }
suspend fun getLibManga(track: Track): Track {
return withIOContext {
val url = "${baseUrl}library-entries".toUri().buildUpon()
.encodedQuery("filter[id]=${track.media_id}")
.appendQueryParameter("include", "manga")
.build()
authClient.newCall(GET(url.toString()))
.await()
.parseAs<JsonObject>()
.let {
val data = it["data"]!!.jsonArray
if (data.size > 0) {
val manga = it["included"]!!.jsonArray[0].jsonObject
KitsuLibManga(data[0].jsonObject, manga).toTrack()
} else {
throw Exception("Could not find manga")
}
}
}
}
private interface Rest {
@Headers("Content-Type: application/vnd.api+json")
@POST("library-entries")
fun addLibManga(
@Body data: JsonObject
): Observable<JsonObject>
@Headers("Content-Type: application/vnd.api+json")
@PATCH("library-entries/{id}")
fun updateLibManga(
@Path("id") remoteId: Int,
@Body data: JsonObject
): Observable<JsonObject>
@GET("library-entries")
fun findLibManga(
@Query("filter[manga_id]", encoded = true) remoteId: Int,
@Query("filter[user_id]", encoded = true) userId: String,
@Query("include") includes: String = "manga"
): Observable<JsonObject>
@GET("library-entries")
fun getLibManga(
@Query("filter[id]", encoded = true) remoteId: Int,
@Query("include") includes: String = "manga"
): Observable<JsonObject>
@GET("users")
fun getCurrentUser(
@Query("filter[self]", encoded = true) self: Boolean = true
): Observable<JsonObject>
suspend fun login(username: String, password: String): OAuth {
return withIOContext {
val formBody: RequestBody = FormBody.Builder()
.add("username", username)
.add("password", password)
.add("grant_type", "password")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.build()
client.newCall(POST(loginUrl, body = formBody))
.await()
.parseAs()
}
}
private interface SearchKeyRest {
@GET("media/")
fun getKey(): Observable<JsonObject>
}
private interface AgoliaSearchRest {
@POST("query/")
fun getSearchQuery(@Header("X-Algolia-Application-Id") appid: String, @Header("X-Algolia-API-Key") key: String, @Body json: JsonObject): Observable<JsonObject>
}
private interface LoginRest {
@FormUrlEncoded
@POST("oauth/token")
fun requestAccessToken(
@Field("username") username: String,
@Field("password") password: String,
@Field("grant_type") grantType: String = "password",
@Field("client_id") client_id: String = clientId,
@Field("client_secret") client_secret: String = clientSecret
): Observable<OAuth>
suspend fun getCurrentUser(): String {
return withIOContext {
val url = "${baseUrl}users".toUri().buildUpon()
.encodedQuery("filter[self]=true")
.build()
authClient.newCall(GET(url.toString()))
.await()
.parseAs<JsonObject>()
.let {
it["data"]!!.jsonArray[0].jsonObject["id"]!!.jsonPrimitive.content
}
}
}
companion object {
private const val clientId = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
private const val clientSecret = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
private const val clientId =
"dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
private const val clientSecret =
"54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
private const val baseUrl = "https://kitsu.io/api/edge/"
private const val loginUrl = "https://kitsu.io/api/"
private const val loginUrl = "https://kitsu.io/api/oauth/token"
private const val baseMangaUrl = "https://kitsu.io/manga/"
private const val algoliaKeyUrl = "https://kitsu.io/api/edge/algolia-keys/"
private const val algoliaUrl = "https://AWQO5J657S-dsn.algolia.net/1/indexes/production_media/"
private const val algoliaKeyUrl = "https://kitsu.io/api/edge/algolia-keys/media/"
private const val algoliaUrl =
"https://AWQO5J657S-dsn.algolia.net/1/indexes/production_media/query/"
private const val algoliaAppId = "AWQO5J657S"
private const val algoliaFilter = "&facetFilters=%5B%22kind%3Amanga%22%5D&attributesToRetrieve=%5B%22synopsis%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D"
private const val algoliaFilter =
"&facetFilters=%5B%22kind%3Amanga%22%5D&attributesToRetrieve=%5B%22synopsis%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D"
fun mangaUrl(remoteId: Int): String {
return baseMangaUrl + remoteId
}
fun refreshTokenRequest(token: String) = POST(
"${loginUrl}oauth/token",
loginUrl,
body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("refresh_token", token)
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("refresh_token", token)
.build()
)
}

View File

@ -1,10 +1,14 @@
package eu.kanade.tachiyomi.data.track.kitsu
import com.google.gson.Gson
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
class KitsuInterceptor(val kitsu: Kitsu, val gson: Gson) : Interceptor {
class KitsuInterceptor(val kitsu: Kitsu) : Interceptor {
private val json: Json by injectLazy()
/**
* OAuth object used for authenticated requests.
@ -22,7 +26,7 @@ class KitsuInterceptor(val kitsu: Kitsu, val gson: Gson) : Interceptor {
if (currAuth.isExpired()) {
val response = chain.proceed(KitsuApi.refreshTokenRequest(refreshToken))
if (response.isSuccessful) {
newAuth(gson.fromJson(response.body!!.string(), OAuth::class.java))
newAuth(json.decodeFromString(response.body!!.string()))
} else {
response.close()
}

View File

@ -1,32 +1,36 @@
package eu.kanade.tachiyomi.data.track.kitsu
import androidx.annotation.CallSuper
import com.github.salomonbrys.kotson.byInt
import com.github.salomonbrys.kotson.byString
import com.github.salomonbrys.kotson.nullInt
import com.github.salomonbrys.kotson.nullObj
import com.github.salomonbrys.kotson.nullString
import com.github.salomonbrys.kotson.obj
import com.google.gson.JsonObject
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.int
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class KitsuSearchManga(obj: JsonObject) {
val id by obj.byInt
private val canonicalTitle by obj.byString
private val chapterCount = obj.get("chapterCount").nullInt
val subType = obj.get("subtype").nullString
val original = obj.get("posterImage").nullObj?.get("original")?.asString
private val synopsis by obj.byString
private var startDate = obj.get("startDate").nullString?.let {
val id = obj["id"]!!.jsonPrimitive.int
private val canonicalTitle = obj["canonicalTitle"]!!.jsonPrimitive.content
private val chapterCount = obj["chapterCount"]?.jsonPrimitive?.intOrNull
val subType = obj["subtype"]?.jsonPrimitive?.contentOrNull
val original = try {
obj["posterImage"]?.jsonObject?.get("original")?.jsonPrimitive?.content
} catch (e: IllegalArgumentException) {
// posterImage is sometimes a jsonNull object instead
null
}
private val synopsis = obj["synopsis"]!!.jsonPrimitive.content
private var startDate = obj["startDate"]?.jsonPrimitive?.contentOrNull?.let {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
outputDf.format(Date(it.toLong() * 1000))
}
private val endDate = obj.get("endDate").nullString
private val endDate = obj["endDate"]?.jsonPrimitive?.contentOrNull
@CallSuper
fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
@ -47,17 +51,17 @@ class KitsuSearchManga(obj: JsonObject) {
}
class KitsuLibManga(obj: JsonObject, manga: JsonObject) {
val id by manga.byInt
private val canonicalTitle by manga["attributes"].byString
private val chapterCount = manga["attributes"].obj.get("chapterCount").nullInt
val type = manga["attributes"].obj.get("mangaType").nullString.orEmpty()
val original by manga["attributes"].obj["posterImage"].byString
private val synopsis by manga["attributes"].byString
private val startDate = manga["attributes"].obj.get("startDate").nullString.orEmpty()
private val libraryId by obj.byInt("id")
val status by obj["attributes"].byString
private val ratingTwenty = obj["attributes"].obj.get("ratingTwenty").nullString
val progress by obj["attributes"].byInt
val id = manga["id"]!!.jsonPrimitive.int
private val canonicalTitle = manga["attributes"]!!.jsonObject["canonicalTitle"]!!.jsonPrimitive.content
private val chapterCount = manga["attributes"]!!.jsonObject["chapterCount"]?.jsonPrimitive?.intOrNull
val type = manga["attributes"]!!.jsonObject["mangaType"]?.jsonPrimitive?.contentOrNull.orEmpty()
val original = manga["attributes"]!!.jsonObject["posterImage"]!!.jsonObject["original"]!!.jsonPrimitive.content
private val synopsis = manga["attributes"]!!.jsonObject["synopsis"]!!.jsonPrimitive.content
private val startDate = manga["attributes"]!!.jsonObject["startDate"]?.jsonPrimitive?.contentOrNull.orEmpty()
private val libraryId = obj["id"]!!.jsonPrimitive.int
val status = obj["attributes"]!!.jsonObject["status"]!!.jsonPrimitive.content
private val ratingTwenty = obj["attributes"]!!.jsonObject["ratingTwenty"]?.jsonPrimitive?.contentOrNull
val progress = obj["attributes"]!!.jsonObject["progress"]!!.jsonPrimitive.int
fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
media_id = libraryId

View File

@ -1,5 +1,8 @@
package eu.kanade.tachiyomi.data.track.kitsu
import kotlinx.serialization.Serializable
@Serializable
data class OAuth(
val access_token: String,
val token_type: String,

View File

@ -2,13 +2,15 @@ package eu.kanade.tachiyomi.data.track.myanimelist
import android.content.Context
import android.graphics.Color
import androidx.annotation.StringRes
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 okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import rx.Completable
import rx.Observable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import uy.kohesive.injekt.injectLazy
class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
@ -18,20 +20,19 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
const val ON_HOLD = 3
const val DROPPED = 4
const val PLAN_TO_READ = 6
const val REREADING = 7
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
const val BASE_URL = "https://myanimelist.net"
const val USER_SESSION_COOKIE = "MALSESSIONID"
const val LOGGED_IN_COOKIE = "is_logged_in"
private const val SEARCH_ID_PREFIX = "id:"
private const val SEARCH_LIST_PREFIX = "my:"
}
private val interceptor by lazy { MyAnimeListInterceptor(this) }
private val json: Json by injectLazy()
private val interceptor by lazy { MyAnimeListInterceptor(this, getPassword()) }
private val api by lazy { MyAnimeListApi(client, interceptor) }
override val name: String
get() = "MyAnimeList"
@StringRes
override fun nameRes() = R.string.tracker_myanimelist
override val supportsReadingDates: Boolean = true
@ -40,7 +41,7 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
override fun getLogoColor() = Color.rgb(46, 81, 162)
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, REREADING)
}
override fun getStatus(status: Int): String = with(context) {
@ -50,6 +51,7 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped)
PLAN_TO_READ -> getString(R.string.plan_to_read)
REREADING -> getString(R.string.repeating)
else -> ""
}
}
@ -64,77 +66,75 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
return track.score.toInt().toString()
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track)
override suspend fun add(track: Track): Track {
track.status = READING
track.score = 0F
return api.updateItem(track)
}
override fun update(track: Track): Observable<Track> {
return api.updateLibManga(track)
override suspend fun update(track: Track): Track {
return api.updateItem(track)
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track)
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
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 suspend fun bind(track: Track): Track {
val remoteTrack = api.findListItem(track)
return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.media_id = remoteTrack.media_id
update(track)
} else {
add(track)
}
}
override suspend fun search(query: String): List<TrackSearch> {
if (query.startsWith(SEARCH_ID_PREFIX)) {
query.substringAfter(SEARCH_ID_PREFIX).toIntOrNull()?.let { id ->
return listOf(api.getMangaDetails(id))
}
}
}
if (query.startsWith(SEARCH_LIST_PREFIX)) {
query.substringAfter(SEARCH_LIST_PREFIX).let { title ->
return api.findListItems(title)
}
}
override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track)
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
track
}
override suspend fun refresh(track: Track): Track {
return api.findListItem(track) ?: add(track)
}
fun login(csrfToken: String): Completable = login("myanimelist", csrfToken)
override suspend fun login(username: String, password: String) = login(password)
override fun login(username: String, password: String): Completable {
return Observable.fromCallable { saveCSRF(password) }
.doOnNext { saveCredentials(username, password) }
.doOnError { logout() }
.toCompletable()
}
fun ensureLoggedIn() {
if (isAuthorized) return
if (!isLogged) throw Exception(context.getString(R.string.myanimelist_creds_missing))
suspend fun login(authCode: String) {
try {
val oauth = api.getAccessToken(authCode)
interceptor.setAuth(oauth)
val username = api.getCurrentUser()
saveCredentials(username, oauth.access_token)
} catch (e: Throwable) {
logout()
}
}
override fun logout() {
super.logout()
preferences.trackToken(this).delete()
networkService.cookieManager.remove(BASE_URL.toHttpUrlOrNull()!!)
interceptor.setAuth(null)
}
private val isAuthorized: Boolean
get() = super.isLogged &&
getCSRF().isNotEmpty() &&
checkCookies()
fun saveOAuth(oAuth: OAuth?) {
preferences.trackToken(this).set(json.encodeToString(oAuth))
}
fun getCSRF(): String = preferences.trackToken(this).get()
private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf)
private fun checkCookies(): Boolean {
val url = BASE_URL.toHttpUrlOrNull()!!
val ckCount = networkService.cookieManager.get(url).count {
it.name == USER_SESSION_COOKIE || it.name == LOGGED_IN_COOKIE
fun loadOAuth(): OAuth? {
return try {
json.decodeFromString<OAuth>(preferences.trackToken(this).get())
} catch (e: Exception) {
null
}
return ckCount == 2
}
}

View File

@ -1,472 +1,271 @@
package eu.kanade.tachiyomi.data.track.myanimelist
import android.net.Uri
import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservable
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.util.lang.toCalendar
import eu.kanade.tachiyomi.util.selectInt
import eu.kanade.tachiyomi.util.selectText
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.PkceUtil
import eu.kanade.tachiyomi.util.lang.withIOContext
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.json.JSONObject
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.parser.Parser
import rx.Observable
import java.io.BufferedReader
import java.io.InputStreamReader
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.GregorianCalendar
import java.util.Locale
import java.util.zip.GZIPInputStream
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)
)
suspend fun getAccessToken(authCode: String): OAuth {
return withIOContext {
val formBody: RequestBody = FormBody.Builder()
.add("client_id", clientId)
.add("code", authCode)
.add("code_verifier", codeVerifier)
.add("grant_type", "authorization_code")
.build()
client.newCall(POST("$baseOAuthUrl/token", body = formBody))
.await()
.parseAs()
}
}
suspend fun getCurrentUser(): String {
return withIOContext {
val request = Request.Builder()
.url("$baseApiUrl/users/@me")
.get()
.build()
authClient.newCall(request)
.await()
.parseAs<JsonObject>()
.let { it["name"]!!.jsonPrimitive.content }
}
}
suspend fun search(query: String): List<TrackSearch> {
return withIOContext {
val url = "$baseApiUrl/manga".toUri().buildUpon()
.appendQueryParameter("q", query)
.appendQueryParameter("nsfw", "true")
.build()
authClient.newCall(GET(url.toString()))
.await()
.parseAs<JsonObject>()
.let {
it["data"]!!.jsonArray
.map { data -> data.jsonObject["node"]!!.jsonObject }
.map { node ->
val id = node["id"]!!.jsonPrimitive.int
async { getMangaDetails(id) }
}
.awaitAll()
.filter { trackSearch -> trackSearch.publishing_type != "novel" }
}
.filter { row ->
row.select(TD)[2].text() != "Novel"
}
.map { row ->
}
}
suspend fun getMangaDetails(id: Int): TrackSearch {
return withIOContext {
val url = "$baseApiUrl/manga".toUri().buildUpon()
.appendPath(id.toString())
.appendQueryParameter("fields", "id,title,synopsis,num_chapters,main_picture,status,media_type,start_date")
.build()
authClient.newCall(GET(url.toString()))
.await()
.parseAs<JsonObject>()
.let {
val obj = it.jsonObject
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> {
return Observable.defer {
authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track)))
.asObservableSuccess()
.map { track }
}
}
fun updateLibManga(track: Track): Observable<Track> {
return Observable.defer {
// Get track data
val response = authClient.newCall(GET(url = editPageUrl(track.media_id))).execute()
val editData = response.use {
val page = Jsoup.parse(it.consumeBody())
// Extract track data from MAL page
extractDataFromEditPage(page).apply {
// Apply changes to the just fetched data
copyPersonalFrom(track)
}
}
// Update remote
authClient.newCall(POST(url = editPageUrl(track.media_id), body = mangaEditPostBody(editData)))
.asObservableSuccess()
.map {
track
}
}
}
fun findLibManga(track: Track): Observable<Track?> {
return authClient.newCall(GET(url = editPageUrl(track.media_id)))
.asObservable()
.map { response ->
var libTrack: Track? = null
response.use {
if (it.priorResponse?.isRedirect != true) {
val trackForm = Jsoup.parse(it.consumeBody())
libTrack = Track.create(TrackManager.MYANIMELIST).apply {
last_chapter_read = trackForm.select("#add_manga_num_read_chapters").`val`().toInt()
total_chapters = trackForm.select("#totalChap").text().toInt()
status = trackForm.select("#add_manga_status > option[selected]").`val`().toInt()
score = trackForm.select("#add_manga_score > option[selected]").`val`().toFloatOrNull()
?: 0f
started_reading_date = trackForm.searchDatePicker("#add_manga_start_date")
finished_reading_date = trackForm.searchDatePicker("#add_manga_finish_date")
media_id = obj["id"]!!.jsonPrimitive.int
title = obj["title"]!!.jsonPrimitive.content
summary = obj["synopsis"]?.jsonPrimitive?.content ?: ""
total_chapters = obj["num_chapters"]!!.jsonPrimitive.int
cover_url = obj["main_picture"]?.jsonObject?.get("large")?.jsonPrimitive?.content ?: ""
tracking_url = "https://myanimelist.net/manga/$media_id"
publishing_status = obj["status"]!!.jsonPrimitive.content.replace("_", " ")
publishing_type = obj["media_type"]!!.jsonPrimitive.content.replace("_", " ")
start_date = try {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
outputDf.format(obj["start_date"]!!)
} catch (e: Exception) {
""
}
}
}
libTrack
}
}
fun getLibManga(track: Track): Observable<Track> {
return findLibManga(track)
.map { it ?: throw Exception("Could not find manga") }
}
private fun getList(): Observable<List<TrackSearch>> {
return getListUrl()
.flatMap { url ->
getListXml(url)
}
.flatMap { doc ->
Observable.from(doc.select("manga"))
}
.map {
TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("manga_title")!!
media_id = it.selectInt("manga_mangadb_id")
last_chapter_read = it.selectInt("my_read_chapters")
status = getStatus(it.selectText("my_status")!!)
score = it.selectInt("my_score").toFloat()
total_chapters = it.selectInt("manga_chapters")
tracking_url = mangaUrl(media_id)
started_reading_date = it.searchDateXml("my_start_date")
finished_reading_date = it.searchDateXml("my_finish_date")
}
}
.toList()
}
private fun getListUrl(): Observable<String> {
return authClient.newCall(POST(url = exportListUrl(), body = exportPostBody()))
.asObservable()
.map { response ->
baseUrl + Jsoup.parse(response.consumeBody())
.select("div.goodresult")
.select("a")
.attr("href")
}
}
private fun getListXml(url: String): Observable<Document> {
return authClient.newCall(GET(url))
.asObservable()
.map { response ->
Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
}
}
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)
suspend fun updateItem(track: Track): Track {
return withIOContext {
val formBodyBuilder = FormBody.Builder()
.add("status", track.toMyAnimeListStatus() ?: "reading")
.add("is_rereading", (track.status == MyAnimeList.REREADING).toString())
.add("score", track.score.toString())
.add("num_chapters_read", track.last_chapter_read.toString())
convertToIsoDate(track.started_reading_date)?.let {
formBodyBuilder.add("start_date", it)
}
convertToIsoDate(track.finished_reading_date)?.let {
formBodyBuilder.add("finish_date", it)
}
val request = Request.Builder()
.url(mangaUrl(track.media_id).toString())
.put(formBodyBuilder.build())
.build()
authClient.newCall(request)
.await()
.parseAs<JsonObject>()
.let { parseMangaItem(it, track) }
}
}
suspend fun findListItem(track: Track): Track? {
return withIOContext {
val uri = "$baseApiUrl/manga".toUri().buildUpon()
.appendPath(track.media_id.toString())
.appendQueryParameter("fields", "num_chapters,my_list_status{start_date,finish_date}")
.build()
authClient.newCall(GET(uri.toString()))
.await()
.parseAs<JsonObject>()
.let { obj ->
track.total_chapters = obj["num_chapters"]!!.jsonPrimitive.int
obj.jsonObject["my_list_status"]?.jsonObject?.let {
parseMangaItem(it, track)
}
}
return sb.toString()
}
}
suspend fun findListItems(query: String, offset: Int = 0): List<TrackSearch> {
return withIOContext {
val json = getListPage(offset)
val obj = json.jsonObject
val matches = obj["data"]!!.jsonArray
.filter {
it.jsonObject["node"]!!.jsonObject["title"]!!.jsonPrimitive.content.contains(
query,
ignoreCase = true
)
}
.map {
val id = it.jsonObject["node"]!!.jsonObject["id"]!!.jsonPrimitive.int
async { getMangaDetails(id) }
}
.awaitAll()
// Check next page if there's more
if (!obj["paging"]!!.jsonObject["next"]?.jsonPrimitive?.contentOrNull.isNullOrBlank()) {
matches + findListItems(query, offset + listPaginationAmount)
} else {
matches
}
}
}
private fun extractDataFromEditPage(page: Document): MyAnimeListEditData {
val tables = page.select("form#main-form table")
private suspend fun getListPage(offset: Int): JsonObject {
return withIOContext {
val urlBuilder = "$baseApiUrl/users/@me/mangalist".toUri().buildUpon()
.appendQueryParameter("fields", "list_status{start_date,finish_date}")
.appendQueryParameter("limit", listPaginationAmount.toString())
if (offset > 0) {
urlBuilder.appendQueryParameter("offset", offset.toString())
}
return MyAnimeListEditData(
entry_id = tables[0].select("input[name=entry_id]").`val`(), // Always 0
manga_id = tables[0].select("#manga_id").`val`(),
status = tables[0].select("#add_manga_status > option[selected]").`val`(),
num_read_volumes = tables[0].select("#add_manga_num_read_volumes").`val`(),
last_completed_vol = tables[0].select("input[name=last_completed_vol]").`val`(), // Always empty
num_read_chapters = tables[0].select("#add_manga_num_read_chapters").`val`(),
score = tables[0].select("#add_manga_score > option[selected]").`val`(),
start_date_month = tables[0].select("#add_manga_start_date_month > option[selected]").`val`(),
start_date_day = tables[0].select("#add_manga_start_date_day > option[selected]").`val`(),
start_date_year = tables[0].select("#add_manga_start_date_year > option[selected]").`val`(),
finish_date_month = tables[0].select("#add_manga_finish_date_month > option[selected]").`val`(),
finish_date_day = tables[0].select("#add_manga_finish_date_day > option[selected]").`val`(),
finish_date_year = tables[0].select("#add_manga_finish_date_year > option[selected]").`val`(),
tags = tables[1].select("#add_manga_tags").`val`(),
priority = tables[1].select("#add_manga_priority > option[selected]").`val`(),
storage_type = tables[1].select("#add_manga_storage_type > option[selected]").`val`(),
num_retail_volumes = tables[1].select("#add_manga_num_retail_volumes").`val`(),
num_read_times = tables[1].select("#add_manga_num_read_times").`val`(),
reread_value = tables[1].select("#add_manga_reread_value > option[selected]").`val`(),
comments = tables[1].select("#add_manga_comments").`val`(),
is_asked_to_discuss = tables[1].select("#add_manga_is_asked_to_discuss > option[selected]").`val`(),
sns_post_type = tables[1].select("#add_manga_sns_post_type > option[selected]").`val`()
)
val request = Request.Builder()
.url(urlBuilder.build().toString())
.get()
.build()
authClient.newCall(request)
.await()
.parseAs()
}
}
private fun parseMangaItem(response: JsonObject, track: Track): Track {
val obj = response.jsonObject
return track.apply {
val isRereading = obj["is_rereading"]!!.jsonPrimitive.boolean
status = if (isRereading) MyAnimeList.REREADING else getStatus(obj["status"]!!.jsonPrimitive.content)
last_chapter_read = obj["num_chapters_read"]!!.jsonPrimitive.int
score = obj["score"]!!.jsonPrimitive.int.toFloat()
obj["start_date"]?.let {
started_reading_date = parseDate(it.jsonPrimitive.content)
}
obj["finish_date"]?.let {
finished_reading_date = parseDate(it.jsonPrimitive.content)
}
}
}
private fun parseDate(isoDate: String): Long {
return SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(isoDate)?.time ?: 0L
}
private fun convertToIsoDate(epochTime: Long): String? {
if (epochTime == 0L) {
return ""
}
return try {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
outputDf.format(epochTime)
} catch (e: Exception) {
null
}
}
companion object {
const val CSRF = "csrf_token"
// Registered under arkon's MAL account
private const val clientId = "8fd3313bc138e8b890551aa1de1a2589"
private const val baseUrl = "https://myanimelist.net"
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 const val baseOAuthUrl = "https://myanimelist.net/v1/oauth2"
private const val baseApiUrl = "https://api.myanimelist.net/v2"
fun loginUrl() = baseUrl.toUri().buildUpon()
.appendPath("login.php")
.toString()
private const val listPaginationAmount = 250
private fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId
private var codeVerifier: String = ""
private fun searchUrl(query: String): String {
val col = "c[]"
return baseUrl.toUri().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()
}
fun authUrl(): Uri = "$baseOAuthUrl/authorize".toUri().buildUpon()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("code_challenge", getPkceChallengeCode())
.appendQueryParameter("response_type", "code")
.build()
private fun exportListUrl() = baseUrl.toUri().buildUpon()
.appendPath("panel.php")
.appendQueryParameter("go", "export")
.toString()
fun mangaUrl(id: Int): Uri = "$baseApiUrl/manga".toUri().buildUpon()
.appendPath(id.toString())
.appendPath("my_list_status")
.build()
private fun editPageUrl(mediaId: Int) = baseModifyListUrl.toUri().buildUpon()
.appendPath(mediaId.toString())
.appendPath("edit")
.toString()
private fun addUrl() = baseModifyListUrl.toUri().buildUpon()
.appendPath("add.json")
.toString()
private fun exportPostBody(): RequestBody {
return FormBody.Builder()
.add("type", "2")
.add("subexport", "Export My List")
fun refreshTokenRequest(refreshToken: String): Request {
val formBody: RequestBody = FormBody.Builder()
.add("client_id", clientId)
.add("refresh_token", refreshToken)
.add("grant_type", "refresh_token")
.build()
return POST("$baseOAuthUrl/token", body = formBody)
}
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 body.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
}
private fun mangaEditPostBody(track: MyAnimeListEditData): RequestBody {
return FormBody.Builder()
.add("entry_id", track.entry_id)
.add("manga_id", track.manga_id)
.add("add_manga[status]", track.status)
.add("add_manga[num_read_volumes]", track.num_read_volumes)
.add("last_completed_vol", track.last_completed_vol)
.add("add_manga[num_read_chapters]", track.num_read_chapters)
.add("add_manga[score]", track.score)
.add("add_manga[start_date][month]", track.start_date_month)
.add("add_manga[start_date][day]", track.start_date_day)
.add("add_manga[start_date][year]", track.start_date_year)
.add("add_manga[finish_date][month]", track.finish_date_month)
.add("add_manga[finish_date][day]", track.finish_date_day)
.add("add_manga[finish_date][year]", track.finish_date_year)
.add("add_manga[tags]", track.tags)
.add("add_manga[priority]", track.priority)
.add("add_manga[storage_type]", track.storage_type)
.add("add_manga[num_retail_volumes]", track.num_retail_volumes)
.add("add_manga[num_read_times]", track.num_read_times)
.add("add_manga[reread_value]", track.reread_value)
.add("add_manga[comments]", track.comments)
.add("add_manga[is_asked_to_discuss]", track.is_asked_to_discuss)
.add("add_manga[sns_post_type]", track.sns_post_type)
.add("submitIt", track.submitIt)
.build()
}
private fun Element.searchDateXml(field: String): Long {
val text = selectText(field, "0000-00-00")!!
// MAL sets the data to 0000-00-00 when date is invalid or missing
if (text == "0000-00-00") {
return 0L
}
return SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(text)?.time ?: 0L
}
private fun Element.searchDatePicker(id: String): Long {
val month = select(id + "_month > option[selected]").`val`().toIntOrNull()
val day = select(id + "_day > option[selected]").`val`().toIntOrNull()
val year = select(id + "_year > option[selected]").`val`().toIntOrNull()
if (year == null || month == null || day == null) {
return 0L
}
return GregorianCalendar(year, month - 1, day).timeInMillis
}
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
}
}
private class MyAnimeListEditData(
// entry_id
var entry_id: String,
// manga_id
var manga_id: String,
// add_manga[status]
var status: String,
// add_manga[num_read_volumes]
var num_read_volumes: String,
// last_completed_vol
var last_completed_vol: String,
// add_manga[num_read_chapters]
var num_read_chapters: String,
// add_manga[score]
var score: String,
// add_manga[start_date][month]
var start_date_month: String, // [1-12]
// add_manga[start_date][day]
var start_date_day: String,
// add_manga[start_date][year]
var start_date_year: String,
// add_manga[finish_date][month]
var finish_date_month: String, // [1-12]
// add_manga[finish_date][day]
var finish_date_day: String,
// add_manga[finish_date][year]
var finish_date_year: String,
// add_manga[tags]
var tags: String,
// add_manga[priority]
var priority: String,
// add_manga[storage_type]
var storage_type: String,
// add_manga[num_retail_volumes]
var num_retail_volumes: String,
// add_manga[num_read_times]
var num_read_times: String,
// add_manga[reread_value]
var reread_value: String,
// add_manga[comments]
var comments: String,
// add_manga[is_asked_to_discuss]
var is_asked_to_discuss: String,
// add_manga[sns_post_type]
var sns_post_type: String,
// submitIt
val submitIt: String = "0"
) {
fun copyPersonalFrom(track: Track) {
num_read_chapters = track.last_chapter_read.toString()
val numScore = track.score.toInt()
if (numScore == 0) {
score = ""
} else if (numScore in 1..10) {
score = numScore.toString()
}
status = track.status.toString()
if (track.started_reading_date == 0L) {
start_date_month = ""
start_date_day = ""
start_date_year = ""
}
if (track.finished_reading_date == 0L) {
finish_date_month = ""
finish_date_day = ""
finish_date_year = ""
}
track.started_reading_date.toCalendar()?.let { cal ->
start_date_month = (cal[Calendar.MONTH] + 1).toString()
start_date_day = cal[Calendar.DAY_OF_MONTH].toString()
start_date_year = cal[Calendar.YEAR].toString()
}
track.finished_reading_date.toCalendar()?.let { cal ->
finish_date_month = (cal[Calendar.MONTH] + 1).toString()
finish_date_day = cal[Calendar.DAY_OF_MONTH].toString()
finish_date_year = cal[Calendar.YEAR].toString()
}
private fun getPkceChallengeCode(): String {
codeVerifier = PkceUtil.generateCodeVerifier()
return codeVerifier
}
}
}

View File

@ -1,52 +1,58 @@
package eu.kanade.tachiyomi.data.track.myanimelist
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okio.Buffer
import org.json.JSONObject
import uy.kohesive.injekt.injectLazy
class MyAnimeListInterceptor(private val myanimelist: MyAnimeList) : Interceptor {
class MyAnimeListInterceptor(private val myanimelist: MyAnimeList, private var token: String?) : Interceptor {
private val json: Json by injectLazy()
private var oauth: OAuth? = null
set(value) {
field = value?.copy(expires_in = System.currentTimeMillis() + (value.expires_in * 1000))
}
override fun intercept(chain: Interceptor.Chain): Response {
myanimelist.ensureLoggedIn()
val originalRequest = chain.request()
val request = chain.request()
return chain.proceed(updateRequest(request))
}
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()
if (token.isNullOrEmpty()) {
throw Exception("Not authenticated with MyAnimeList")
}
if (oauth == null) {
oauth = myanimelist.loadOAuth()
}
// Refresh access token if null or expired.
if (oauth!!.isExpired()) {
chain.proceed(MyAnimeListApi.refreshTokenRequest(oauth!!.refresh_token)).use {
if (it.isSuccessful) {
setAuth(json.decodeFromString(it.body!!.string()))
}
}
}
// Throw on null auth.
if (oauth == null) {
throw Exception("No authentication token")
}
// Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
.build()
return chain.proceed(authRequest)
}
private fun updateFormBody(requestBody: RequestBody): RequestBody {
val formString = bodyToString(requestBody)
return "$formString${if (formString.isNotEmpty()) "&" else ""}${MyAnimeListApi.CSRF}=${myanimelist.getCSRF()}".toRequestBody(requestBody.contentType())
}
private fun updateJsonBody(requestBody: RequestBody): RequestBody {
val jsonString = bodyToString(requestBody)
val newBody = JSONObject(jsonString)
.put(MyAnimeListApi.CSRF, myanimelist.getCSRF())
return newBody.toString().toRequestBody(requestBody.contentType())
/**
* Called when the user authenticates with MyAnimeList for the first time. Sets the refresh token
* and the oauth object.
*/
fun setAuth(oauth: OAuth?) {
token = oauth?.access_token
this.oauth = oauth
myanimelist.saveOAuth(oauth)
}
}

View File

@ -0,0 +1,22 @@
package eu.kanade.tachiyomi.data.track.myanimelist
import eu.kanade.tachiyomi.data.database.models.Track
fun Track.toMyAnimeListStatus() = when (status) {
MyAnimeList.READING -> "reading"
MyAnimeList.COMPLETED -> "completed"
MyAnimeList.ON_HOLD -> "on_hold"
MyAnimeList.DROPPED -> "dropped"
MyAnimeList.PLAN_TO_READ -> "plan_to_read"
MyAnimeList.REREADING -> "reading"
else -> null
}
fun getStatus(status: String) = when (status) {
"reading" -> MyAnimeList.READING
"completed" -> MyAnimeList.COMPLETED
"on_hold" -> MyAnimeList.ON_HOLD
"dropped" -> MyAnimeList.DROPPED
"plan_to_read" -> MyAnimeList.PLAN_TO_READ
else -> MyAnimeList.READING
}

View File

@ -0,0 +1,14 @@
package eu.kanade.tachiyomi.data.track.myanimelist
import kotlinx.serialization.Serializable
@Serializable
data class OAuth(
val refresh_token: String,
val access_token: String,
val token_type: String,
val expires_in: Long
) {
fun isExpired() = System.currentTimeMillis() > expires_in
}

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.track.shikimori
import android.content.Context
import android.graphics.Color
import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
@ -9,8 +10,6 @@ import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
class Shikimori(private val context: Context, id: Int) : TrackService(id) {
@ -22,19 +21,17 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) {
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 json: Json by injectLazy()
private val interceptor by lazy { ShikimoriInterceptor(this) }
private val api by lazy { ShikimoriApi(client, interceptor) }
@StringRes
override fun nameRes() = R.string.tracker_shikimori
override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString)
}
@ -43,43 +40,38 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) {
return track.score.toInt().toString()
}
override fun add(track: Track): Observable<Track> {
override suspend fun add(track: Track): Track {
return api.addLibManga(track, getUsername())
}
override fun update(track: Track): Observable<Track> {
override suspend fun update(track: Track): Track {
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 suspend fun bind(track: Track): Track {
val remoteTrack = api.findLibManga(track, getUsername())
return 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.status = READING
track.score = 0F
add(track)
}
}
override fun search(query: String): Observable<List<TrackSearch>> {
override suspend fun search(query: String): 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
}
override suspend fun refresh(track: Track): Track {
api.findLibManga(track, getUsername())?.let { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
}
return track
}
override fun getLogo() = R.drawable.ic_tracker_shikimori
@ -104,18 +96,17 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) {
override fun getCompletionStatus(): Int = COMPLETED
override fun login(username: String, password: String) = login(password)
override suspend fun login(username: String, password: String) = login(password)
fun login(code: String): Completable {
return api.accessToken(code).map { oauth: OAuth? ->
suspend fun login(code: String) {
try {
val oauth = api.accessToken(code)
interceptor.newAuth(oauth)
if (oauth != null) {
val user = api.getCurrentUser()
saveCredentials(user.toString(), oauth.access_token)
}
}.doOnError {
val user = api.getCurrentUser()
saveCredentials(user.toString(), oauth.access_token)
} catch (e: Throwable) {
logout()
}.toCompletable()
}
}
fun saveToken(oauth: OAuth?) {

View File

@ -6,9 +6,11 @@ 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 kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.lang.withIOContext
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
@ -19,65 +21,53 @@ import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import rx.Observable
import uy.kohesive.injekt.injectLazy
class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInterceptor) {
private val json: Json by injectLazy()
private val jsonime = "application/json; charset=utf-8".toMediaTypeOrNull()
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun addLibManga(track: Track, user_id: String): Observable<Track> {
val payload = buildJsonObject {
putJsonObject("user_rate") {
put("user_id", user_id)
put("target_id", track.media_id)
put("target_type", "Manga")
put("chapters", track.last_chapter_read)
put("score", track.score.toInt())
put("status", track.toShikimoriStatus())
suspend fun addLibManga(track: Track, user_id: String): Track {
return withIOContext {
val payload = buildJsonObject {
putJsonObject("user_rate") {
put("user_id", user_id)
put("target_id", track.media_id)
put("target_type", "Manga")
put("chapters", track.last_chapter_read)
put("score", track.score.toInt())
put("status", track.toShikimoriStatus())
}
}
authClient.newCall(
POST(
"$apiUrl/v2/user_rates",
body = payload.toString().toRequestBody(jsonMime)
)
).await()
track
}
val body = payload.toString().toRequestBody(jsonime)
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)
suspend fun updateLibManga(track: Track, user_id: String): Track = addLibManga(track, user_id)
fun search(search: String): Observable<List<TrackSearch>> {
val url = "$apiUrl/mangas".toUri().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")
suspend fun search(search: String): List<TrackSearch> {
return withIOContext {
val url = "$apiUrl/mangas".toUri().buildUpon()
.appendQueryParameter("order", "popularity")
.appendQueryParameter("search", search)
.appendQueryParameter("limit", "20")
.build()
authClient.newCall(GET(url.toString()))
.await()
.parseAs<JsonArray>()
.let { response ->
response.map {
jsonToSearch(it.jsonObject)
}
}
val response = json.decodeFromString<JsonArray>(responseBody)
response.map { jsonToSearch(it.jsonObject) }
}
}
}
private fun jsonToSearch(obj: JsonObject): TrackSearch {
@ -106,61 +96,51 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
}
}
fun findLibManga(track: Track, user_id: String): Observable<Track?> {
val url = "$apiUrl/v2/user_rates".toUri().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()
suspend fun findLibManga(track: Track, user_id: String): Track? {
return withIOContext {
val urlMangas = "$apiUrl/mangas".toUri().buildUpon()
.appendPath(track.media_id.toString())
.build()
val mangas = authClient.newCall(GET(urlMangas.toString()))
.await()
.parseAs<JsonObject>()
val urlMangas = "$apiUrl/mangas".toUri().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()
json.decodeFromString<JsonObject>(responseBody)
}.flatMap { mangas ->
authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = json.decodeFromString<JsonArray>(responseBody)
if (response.size > 1) {
throw Exception("Too much mangas in response")
}
val entry = response.map {
jsonToTrack(it.jsonObject, mangas)
}
entry.firstOrNull()
val url = "$apiUrl/v2/user_rates".toUri().buildUpon()
.appendQueryParameter("user_id", user_id)
.appendQueryParameter("target_id", track.media_id.toString())
.appendQueryParameter("target_type", "Manga")
.build()
authClient.newCall(GET(url.toString()))
.await()
.parseAs<JsonArray>()
.let { response ->
if (response.size > 1) {
throw Exception("Too much mangas in response")
}
}
val entry = response.map {
jsonToTrack(it.jsonObject, mangas)
}
entry.firstOrNull()
}
}
}
fun getCurrentUser(): Int {
val user = authClient.newCall(GET("$apiUrl/users/whoami")).execute().body?.string()!!
return json.decodeFromString<JsonObject>(user)["id"]!!.jsonPrimitive.int
return runBlocking {
authClient.newCall(GET("$apiUrl/users/whoami"))
.await()
.parseAs<JsonObject>()
.let {
it["id"]!!.jsonPrimitive.int
}
}
}
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")
}
json.decodeFromString<OAuth>(responseBody)
suspend fun accessToken(code: String): OAuth {
return withIOContext {
client.newCall(accessTokenRequest(code))
.await()
.parseAs()
}
}

View File

@ -1,32 +0,0 @@
package eu.kanade.tachiyomi.data.updater.github
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import eu.kanade.tachiyomi.network.NetworkHelper
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.http.GET
import retrofit2.http.Path
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* Used to connect with the GitHub API to get the latest release version from a repo.
*/
interface GithubService {
companion object {
fun create(): GithubService {
val restAdapter = Retrofit.Builder()
.baseUrl("https://api.github.com")
.addConverterFactory(Json { ignoreUnknownKeys = true }.asConverterFactory("application/json".toMediaType()))
.client(Injekt.get<NetworkHelper>().client)
.build()
return restAdapter.create(GithubService::class.java)
}
}
@GET("/repos/{repo}/releases/latest")
suspend fun getLatestVersion(@Path("repo", encoded = true) repo: String): GithubRelease
}

View File

@ -2,27 +2,39 @@ package eu.kanade.tachiyomi.data.updater.github
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.updater.UpdateResult
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy
class GithubUpdateChecker {
private val service: GithubService = GithubService.create()
private val networkService: NetworkHelper by injectLazy()
private val repo: String by lazy {
if (BuildConfig.DEBUG) {
"tachiyomiorg/android-app-preview"
"tachiyomiorg/tachiyomi-preview"
} else {
"inorichi/tachiyomi"
"tachiyomiorg/tachiyomi"
}
}
suspend fun checkForUpdate(): UpdateResult {
val release = service.getLatestVersion(repo)
// Check if latest version is different from current version
return if (isNewVersion(release.version)) {
GithubUpdateResult.NewUpdate(release)
} else {
GithubUpdateResult.NoNewUpdate()
return withIOContext {
networkService.client
.newCall(GET("https://api.github.com/repos/$repo/releases/latest"))
.await()
.parseAs<GithubRelease>()
.let {
// Check if latest version is different from current version
if (isNewVersion(it.version)) {
GithubUpdateResult.NewUpdate(it)
} else {
GithubUpdateResult.NoNewUpdate()
}
}
}
}
@ -31,11 +43,11 @@ class GithubUpdateChecker {
val newVersion = versionTag.replace("[^\\d.]".toRegex(), "")
return if (BuildConfig.DEBUG) {
// Preview builds: based on releases in "tachiyomiorg/android-app-preview" repo
// Preview builds: based on releases in "tachiyomiorg/tachiyomi-preview" repo
// tagged as something like "r1234"
newVersion.toInt() > BuildConfig.COMMIT_COUNT.toInt()
} else {
// Release builds: based on releases in "inorichi/tachiyomi" repo
// Release builds: based on releases in "tachiyomiorg/tachiyomi" repo
// tagged as something like "v0.1.2"
newVersion != BuildConfig.VERSION_NAME
}

View File

@ -5,8 +5,11 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.lang.withIOContext
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
@ -16,14 +19,16 @@ import java.util.Date
internal class ExtensionGithubApi {
private val networkService: NetworkHelper by injectLazy()
private val preferences: PreferencesHelper by injectLazy()
suspend fun findExtensions(): List<Extension.Available> {
val service: ExtensionGithubService = ExtensionGithubService.create()
return withContext(Dispatchers.IO) {
val response = service.getRepo()
parseResponse(response)
return withIOContext {
networkService.client
.newCall(GET("${REPO_URL_PREFIX}index.min.json"))
.await()
.parseAs<JsonArray>()
.let { parseResponse(it) }
}
}
@ -65,18 +70,18 @@ internal class ExtensionGithubApi {
val versionCode = element.jsonObject["code"]!!.jsonPrimitive.int
val lang = element.jsonObject["lang"]!!.jsonPrimitive.content
val nsfw = element.jsonObject["nsfw"]!!.jsonPrimitive.int == 1
val icon = "$REPO_URL_PREFIX/icon/${apkName.replace(".apk", ".png")}"
val icon = "${REPO_URL_PREFIX}icon/${apkName.replace(".apk", ".png")}"
Extension.Available(name, pkgName, versionName, versionCode, lang, nsfw, apkName, icon)
}
}
fun getApkUrl(extension: Extension.Available): String {
return "$REPO_URL_PREFIX/apk/${extension.apkName}"
return "${REPO_URL_PREFIX}apk/${extension.apkName}"
}
companion object {
const val BASE_URL = "https://raw.githubusercontent.com/"
const val REPO_URL_PREFIX = "${BASE_URL}inorichi/tachiyomi-extensions/repo/"
const val REPO_URL_PREFIX = "${BASE_URL}tachiyomiorg/tachiyomi-extensions/repo/"
}
}

View File

@ -1,32 +0,0 @@
package eu.kanade.tachiyomi.extension.api
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import eu.kanade.tachiyomi.network.NetworkHelper
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.http.GET
import uy.kohesive.injekt.injectLazy
/**
* Used to get the extension repo listing from GitHub.
*/
interface ExtensionGithubService {
companion object {
fun create(): ExtensionGithubService {
val network: NetworkHelper by injectLazy()
val adapter = Retrofit.Builder()
.baseUrl(ExtensionGithubApi.BASE_URL)
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.client(network.client)
.build()
return adapter.create(ExtensionGithubService::class.java)
}
}
@GET("${ExtensionGithubApi.REPO_URL_PREFIX}index.min.json")
suspend fun getRepo(): JsonArray
}

View File

@ -18,6 +18,7 @@ sealed class Extension {
override val versionCode: Int,
override val lang: String,
override val isNsfw: Boolean,
val pkgFactory: String?,
val sources: List<Source>,
val hasUpdate: Boolean = false,
val isObsolete: Boolean = false,

View File

@ -31,6 +31,7 @@ internal object ExtensionLoader {
private const val EXTENSION_FEATURE = "tachiyomi.extension"
private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
private const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory"
private const val METADATA_NSFW = "tachiyomi.extension.nsfw"
const val LIB_VERSION_MIN = 1.2
const val LIB_VERSION_MAX = 1.2
@ -162,7 +163,7 @@ internal object ExtensionLoader {
else -> throw Exception("Unknown source class type! ${obj.javaClass}")
}
} catch (e: Throwable) {
Timber.e(e, "Extension load error: $extName.")
Timber.w(e, "Extension load error: $extName ($it)")
return LoadResult.Error(e)
}
}
@ -184,7 +185,8 @@ internal object ExtensionLoader {
versionCode,
lang,
isNsfw,
sources,
sources = sources,
pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY),
isUnofficial = signatureHash != officialSignature
)
return LoadResult.Success(extension)

View File

@ -99,7 +99,7 @@ class CloudflareInterceptor(private val context: Context) : Interceptor {
// Avoid sending empty User-Agent, Chromium WebView will reset to default if empty
webview.settings.userAgentString = request.header("User-Agent")
?: HttpSource.DEFAULT_USERAGENT
?: HttpSource.DEFAULT_USER_AGENT
webview.webViewClient = object : WebViewClientCompat() {
override fun onPageFinished(view: WebView, url: String) {
@ -171,6 +171,6 @@ class CloudflareInterceptor(private val context: Context) : Interceptor {
companion object {
private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
private val COOKIE_NAMES = listOf("__cfduid", "cf_clearance")
private val COOKIE_NAMES = listOf("cf_clearance")
}
}

View File

@ -0,0 +1,40 @@
package eu.kanade.tachiyomi.network
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.dnsoverhttps.DnsOverHttps
import java.net.InetAddress
/**
* Based on https://github.com/square/okhttp/blob/ef5d0c83f7bbd3a0c0534e7ca23cbc4ee7550f3b/okhttp-dnsoverhttps/src/test/java/okhttp3/dnsoverhttps/DohProviders.java
*/
const val PREF_DOH_CLOUDFLARE = 1
const val PREF_DOH_GOOGLE = 2
fun OkHttpClient.Builder.dohCloudflare() = dns(
DnsOverHttps.Builder().client(build())
.url("https://cloudflare-dns.com/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("162.159.36.1"),
InetAddress.getByName("162.159.46.1"),
InetAddress.getByName("1.1.1.1"),
InetAddress.getByName("1.0.0.1"),
InetAddress.getByName("162.159.132.53"),
InetAddress.getByName("2606:4700:4700::1111"),
InetAddress.getByName("2606:4700:4700::1001"),
InetAddress.getByName("2606:4700:4700::0064"),
InetAddress.getByName("2606:4700:4700::6400")
)
.build()
)
fun OkHttpClient.Builder.dohGoogle() = dns(
DnsOverHttps.Builder().client(build())
.url("https://dns.google/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("8.8.4.4"),
InetAddress.getByName("8.8.8.8")
)
.build()
)

View File

@ -4,13 +4,10 @@ import android.content.Context
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import okhttp3.Cache
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.dnsoverhttps.DnsOverHttps
import okhttp3.logging.HttpLoggingInterceptor
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.net.InetAddress
import java.util.concurrent.TimeUnit
class NetworkHelper(context: Context) {
@ -38,25 +35,9 @@ class NetworkHelper(context: Context) {
builder.addInterceptor(httpLoggingInterceptor)
}
if (preferences.enableDoh()) {
builder.dns(
DnsOverHttps.Builder().client(builder.build())
.url("https://cloudflare-dns.com/dns-query".toHttpUrl())
.bootstrapDnsHosts(
listOf(
InetAddress.getByName("162.159.36.1"),
InetAddress.getByName("162.159.46.1"),
InetAddress.getByName("1.1.1.1"),
InetAddress.getByName("1.0.0.1"),
InetAddress.getByName("162.159.132.53"),
InetAddress.getByName("2606:4700:4700::1111"),
InetAddress.getByName("2606:4700:4700::1001"),
InetAddress.getByName("2606:4700:4700::0064"),
InetAddress.getByName("2606:4700:4700::6400")
)
)
.build()
)
when (preferences.dohProvider()) {
PREF_DOH_CLOUDFLARE -> builder.dohCloudflare()
PREF_DOH_GOOGLE -> builder.dohGoogle()
}
builder.build()

View File

@ -1,19 +1,27 @@
package eu.kanade.tachiyomi.network
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import rx.Producer
import rx.Subscription
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.fullType
import uy.kohesive.injekt.api.get
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
val jsonMime = "application/json; charset=utf-8".toMediaType()
fun Call.asObservable(): Observable<Response> {
return Observable.unsafeCreate { subscriber ->
// Since Call is a one-shot type, clone it for each new subscriber.
@ -52,12 +60,12 @@ fun Call.asObservable(): Observable<Response> {
}
// Based on https://github.com/gildor/kotlin-coroutines-okhttp
suspend fun Call.await(assertSuccess: Boolean = false): Response {
suspend fun Call.await(): Response {
return suspendCancellableCoroutine { continuation ->
enqueue(
object : Callback {
override fun onResponse(call: Call, response: Response) {
if (assertSuccess && !response.isSuccessful) {
if (!response.isSuccessful) {
continuation.resumeWithException(Exception("HTTP error ${response.code}"))
return
}
@ -105,3 +113,12 @@ fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListene
return progressClient.newCall(request)
}
inline fun <reified T> Response.parseAs(): T {
// Avoiding Injekt.get<Json>() due to compiler issues
val json = Injekt.getInstance<Json>(fullType<Json>().type)
this.use {
val responseBody = it.body?.string().orEmpty()
return json.decodeFromString(responseBody)
}
}

View File

@ -12,7 +12,7 @@ class UserAgentInterceptor : Interceptor {
val newRequest = originalRequest
.newBuilder()
.removeHeader("User-Agent")
.addHeader("User-Agent", HttpSource.DEFAULT_USERAGENT)
.addHeader("User-Agent", HttpSource.DEFAULT_USER_AGENT)
.build()
chain.proceed(newRequest)
} else {

View File

@ -27,7 +27,7 @@ import java.util.zip.ZipFile
class LocalSource(private val context: Context) : CatalogueSource {
companion object {
const val ID = 0L
const val HELP_URL = "https://tachiyomi.org/help/guides/reading-local-manga/"
const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/"
private const val COVER_NAME = "cover.jpg"
private val SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub")

View File

@ -49,7 +49,7 @@ open class SourceManager(private val context: Context) {
LocalSource(context)
)
private inner class StubSource(override val id: Long) : Source {
inner class StubSource(override val id: Long) : Source {
override val name: String
get() = id.toString()

View File

@ -74,7 +74,7 @@ abstract class HttpSource : CatalogueSource {
* Headers builder for requests. Implementations can override this method for custom headers.
*/
protected open fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", DEFAULT_USERAGENT)
add("User-Agent", DEFAULT_USER_AGENT)
}
/**
@ -371,6 +371,6 @@ abstract class HttpSource : CatalogueSource {
override fun getFilterList() = FilterList()
companion object {
const val DEFAULT_USERAGENT = "Mozilla/5.0 (Windows NT 6.3; WOW64)"
const val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36 Edg/88.0.705.63"
}
}

View File

@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.ui.base.activity
import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import androidx.viewbinding.ViewBinding
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate
@ -13,7 +12,6 @@ abstract class BaseRxActivity<VB : ViewBinding, P : BasePresenter<*>> : NucleusA
@Suppress("LeakingThis")
private val secureActivityDelegate = SecureActivityDelegate(this)
val scope = lifecycleScope
lateinit var binding: VB
init {

View File

@ -4,26 +4,16 @@ import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.viewbinding.ViewBinding
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate
import eu.kanade.tachiyomi.util.system.LocaleHelper
import uy.kohesive.injekt.injectLazy
import eu.kanade.tachiyomi.data.preference.PreferenceValues as Values
abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
abstract class BaseThemedActivity : AppCompatActivity() {
val preferences: PreferencesHelper by injectLazy()
val scope = lifecycleScope
lateinit var binding: VB
@Suppress("LeakingThis")
private val secureActivityDelegate = SecureActivityDelegate(this)
private val isDarkMode: Boolean by lazy {
val isDarkMode: Boolean by lazy {
val themeMode = preferences.themeMode().get()
(themeMode == Values.ThemeMode.dark) ||
(
@ -62,11 +52,6 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
}
}
init {
@Suppress("LeakingThis")
LocaleHelper.updateConfiguration(this)
}
override fun onCreate(savedInstanceState: Bundle?) {
setTheme(
when {
@ -76,13 +61,5 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
)
super.onCreate(savedInstanceState)
secureActivityDelegate.onCreate()
}
override fun onResume() {
super.onResume()
secureActivityDelegate.onResume()
}
}

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