Compare commits

...

290 Commits

Author SHA1 Message Date
68bdd22634 Remove deprecated preferences 2023-08-14 10:19:25 +03:00
2e7867f60c Merge branch 'devel' 2023-08-14 10:09:56 +03:00
e4c2972cae Update parsers 2023-08-14 09:59:00 +03:00
af654b8c40 Remove WorkManager bug workaround 2023-08-14 09:44:56 +03:00
9eace89d4f Update error messages 2023-08-14 09:44:02 +03:00
7dbec8eb8f Update bottom nav item animations 2023-08-13 16:09:43 +03:00
111f816f18 Update themes 2023-08-13 15:59:05 +03:00
d4589716aa Improve tablet layout 2023-08-12 18:20:15 +03:00
1c4bd6da28 Retry tracker after errors 2023-08-12 13:55:13 +03:00
c83538f66d Merge pull request #450 from weblate/weblate-kotatsu-strings 2023-08-12 12:59:38 +03:00
gallegonovato
885cae7635 Translated using Weblate (Arabic)
Currently translated at 16.9% (80 of 471 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (471 of 471 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-08-12 06:39:19 +02:00
InfinityDouki56
c66d36abf9 Translated using Weblate (Filipino)
Currently translated at 91.0% (428 of 470 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-08-12 06:39:19 +02:00
Макар Разин
4bd9c6df81 Translated using Weblate (Chinese (Simplified))
Currently translated at 91.4% (430 of 470 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-08-12 06:39:18 +02:00
Nayuki
b835cb98b7 Translated using Weblate (Thai)
Currently translated at 25.5% (120 of 470 strings)

Translated using Weblate (Thai)

Currently translated at 20.8% (98 of 470 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2023-08-12 06:39:18 +02:00
Balog Ferenc József
7cb1f90155 Translated using Weblate (Hungarian)
Currently translated at 28.5% (2 of 7 strings)

Added translation using Weblate (Hungarian)

Co-authored-by: Balog Ferenc József <ferencb2412@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/hu/
Translation: Kotatsu/plurals
2023-08-12 06:39:17 +02:00
96bac81b84 Fix captcha detection 2023-08-11 15:13:25 +03:00
b59f933031 Fix strings 2023-08-11 14:51:56 +03:00
caebca36de Option to hide nsfw content 2023-08-11 14:50:56 +03:00
03cb458d92 Remove custom activity animation 2023-08-11 14:09:21 +03:00
0788f5f05e Adjust cells content to grid size 2023-08-11 13:58:26 +03:00
0271ed2ba9 Show updated manga on top of feed 2023-08-11 13:18:23 +03:00
788c7b862a Fallback to another favicon on error 2023-08-11 12:30:47 +03:00
f4f84099cc Update parsers 2023-08-11 12:23:08 +03:00
3bea94bf1f Handle 429 TooManyRequests error 2023-08-11 11:21:25 +03:00
746eed698f Refactor tracker 2023-08-11 10:36:58 +03:00
fa0289eb27 Show search results once received 2023-08-10 09:09:04 +03:00
c874d73c04 Fix warnings and enable locales-config auto-generating 2023-08-09 16:17:59 +03:00
edb91c46d4 Option to disable pages animation #406 2023-08-09 14:50:52 +03:00
4b9f4f9af2 GC manga updates 2023-08-09 13:17:13 +03:00
J. Lavoie
a07117087a Translated using Weblate (French)
Currently translated at 100.0% (470 of 470 strings)

Translated using Weblate (Italian)

Currently translated at 78.9% (371 of 470 strings)

Translated using Weblate (German)

Currently translated at 99.3% (467 of 470 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2023-08-09 12:27:20 +03:00
kuragehime
ce0ffca197 Translated using Weblate (Japanese)
Currently translated at 100.0% (470 of 470 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-08-09 12:27:20 +03:00
TzurS11
eece4d8f00 Added translation using Weblate (Hebrew)
Co-authored-by: TzurS11 <tzurshafriri11@gmail.com>
2023-08-09 12:27:20 +03:00
Nayuki
419e2e578b Translated using Weblate (Thai)
Currently translated at 11.0% (52 of 470 strings)

Translated using Weblate (Thai)

Currently translated at 71.4% (5 of 7 strings)

Added translation using Weblate (Thai)

Co-authored-by: Nayuki <notkungz.suphakorn@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/th/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-08-09 12:27:20 +03:00
Cookies
0a7387c22e Translated using Weblate (Vietnamese)
Currently translated at 85.2% (399 of 468 strings)

Co-authored-by: Cookies <Nekop1845@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-08-09 12:27:20 +03:00
InfinityDouki56
2a23c3b3b3 Translated using Weblate (Filipino)
Currently translated at 91.0% (428 of 470 strings)

Translated using Weblate (Filipino)

Currently translated at 91.4% (428 of 468 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-08-09 12:27:20 +03:00
Masowick
0566aa4e6a Translated using Weblate (German)
Currently translated at 98.9% (465 of 470 strings)

Translated using Weblate (German)

Currently translated at 93.8% (441 of 470 strings)

Translated using Weblate (German)

Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: Masowick <Demian@gmx.co.uk>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-08-09 12:27:20 +03:00
Макар Разин
3f27acf1aa Translated using Weblate (Ukrainian)
Currently translated at 100.0% (470 of 470 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (470 of 470 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (470 of 470 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (468 of 468 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (468 of 468 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (468 of 468 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-08-09 12:27:20 +03:00
JA Sunny
d48c3fbe1b Translated using Weblate (Bengali)
Currently translated at 18.4% (86 of 467 strings)

Translated using Weblate (Bengali)

Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: JA Sunny <ayasbinshams2003@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/bn/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/bn/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-08-09 12:27:20 +03:00
kuragehime
6720474667 Translated using Weblate (Japanese)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (467 of 467 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ja/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-08-09 12:27:20 +03:00
Eric
ad42ca5085 Translated using Weblate (Chinese (Simplified))
Currently translated at 92.0% (429 of 466 strings)

Co-authored-by: Eric <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-08-09 12:27:20 +03:00
gallegonovato
d5e40d79ec Translated using Weblate (Spanish)
Currently translated at 100.0% (470 of 470 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (468 of 468 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-08-09 12:27:20 +03:00
Макар Разин
7853b9a73e Translated using Weblate (Belarusian)
Currently translated at 100.0% (468 of 468 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (467 of 467 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (467 of 467 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (467 of 467 strings)

Translated using Weblate (Korean)

Currently translated at 76.3% (356 of 466 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (466 of 466 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (466 of 466 strings)

Translated using Weblate (Japanese)

Currently translated at 96.3% (449 of 466 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (466 of 466 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/uk/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ko/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-08-09 12:27:20 +03:00
return_null
cb71a24d81 Translated using Weblate (Chinese (Simplified))
Currently translated at 95.5% (427 of 447 strings)

Co-authored-by: return_null <demolang@dismail.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-08-09 12:27:20 +03:00
a5d99db105 Use nio for File.listFiles() #449 2023-08-09 12:22:52 +03:00
6cc13784e4 Remove invalid track logs 2023-08-07 17:59:26 +03:00
cf9aab9afe Update parsers 2023-08-06 09:22:47 +03:00
83a919f9f6 Add contribution guidelines 2023-08-06 09:12:51 +03:00
70bf80ad8f Update parsers 2023-08-06 09:11:30 +03:00
05a724eae0 Add contribution guidelines 2023-08-04 17:08:50 +03:00
496f3637c4 Update gradle and kotlin 2023-08-04 16:32:08 +03:00
c5c907c8dc UI improvements 2023-08-02 16:45:01 +03:00
af4845a770 Revert "Use ConnectivityManagerCompat.getRestrictBackgroundStatus()"
This reverts commit bfad632b8c.
2023-08-02 14:55:22 +03:00
511f9af991 Limit pages loading parallelism 2023-08-02 14:55:15 +03:00
baf6f6d2c9 Re-use ZipFile instances in PageLoader 2023-08-02 14:35:55 +03:00
8b6a0a8c87 Improve downloads list 2023-08-02 12:42:57 +03:00
e7ee261680 Grouping history by progress 2023-08-02 12:08:28 +03:00
2949fdd2c6 Recover history if chapterId id changed 2023-08-02 11:03:38 +03:00
829ea01b18 Migrate some classes to data classes 2023-08-02 10:26:18 +03:00
08b173b94a Update AppShortcutManager 2023-08-02 10:08:46 +03:00
Isira Seneviratne
7b090c4ccd Add default constructor values 2023-08-02 10:06:27 +03:00
Isira Seneviratne
bf1b8e8b75 Convert ListHeader to a data class 2023-08-02 10:06:27 +03:00
Isira Seneviratne
bfad632b8c Use ConnectivityManagerCompat.getRestrictBackgroundStatus() 2023-08-02 10:06:08 +03:00
Isira Seneviratne
73e6f730e1 Convert lazy value to Size 2023-07-31 15:35:39 +03:00
Isira Seneviratne
62d8b848b2 Add uses of ShortcutManagerCompat methods 2023-07-31 15:35:39 +03:00
2793f6ce52 Update sources manage screen 2023-07-31 12:09:24 +03:00
b107801188 Update bookmarks list screen 2023-07-31 11:28:46 +03:00
97de27dfb3 Recover manga after NotFoundException 2023-07-31 10:08:59 +03:00
cd4317dec5 Show captcha notfication for images 2023-07-30 17:44:24 +03:00
50554c6936 Fix tags suggestions 2023-07-28 16:42:01 +03:00
694297f49b Update bottom navigation 2023-07-28 16:15:34 +03:00
3e48ce85fd Show notification if captcha required in background 2023-07-28 16:09:46 +03:00
0f7bceb268 Fix new sources tip 2023-07-28 14:01:21 +03:00
00187c0d17 Fix description scrolling 2023-07-28 12:22:17 +03:00
2378d104c3 Option to open random manga from source 2023-07-28 12:15:03 +03:00
f105f4b496 Mark nsfw sources 2023-07-28 12:00:36 +03:00
Isira Seneviratne
01e27ba91f Add uses of NotificationManagerCompat and related classes 2023-07-28 10:51:13 +03:00
2342594885 Unify list spacing approach 2023-07-26 16:40:10 +03:00
61a7f1c830 Improve ui 2023-07-26 15:27:43 +03:00
01c23bc3b8 Manga preview in list on tablet 2023-07-26 12:11:42 +03:00
7c7106a63c History sorting #428 2023-07-25 15:36:50 +03:00
ac1a919476 Fix instrumented tests 2023-07-25 12:44:07 +03:00
234f74aa0d Fix database migrations 2023-07-25 12:28:50 +03:00
1711ebe616 Merge branch 'devel' into next 2023-07-25 10:11:44 +03:00
07af79a6bd Update parsers 2023-07-25 09:42:55 +03:00
e4942b0d93 Fix new manga sources enabling 2023-07-24 20:21:57 +03:00
5ca22f1419 Fix crash with empty EnumSet 2023-07-24 16:57:24 +03:00
345a878d83 Merge branch 'devel' into next 2023-07-24 16:38:51 +03:00
42bb5a65ab Fix crash in ScrobblingInfoSheet 2023-07-24 16:08:25 +03:00
0c37265a5b Update parsers 2023-07-24 16:03:08 +03:00
plum7x
7a65ae3ea7 Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: plum7x <plumgift@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2023-07-24 15:56:11 +03:00
376cee1859 Store manga sources in database #426 2023-07-24 15:47:52 +03:00
InfinityDouki56
ee027cd64f Translated using Weblate (Filipino)
Currently translated at 91.0% (407 of 447 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-07-22 13:59:48 +03:00
Макар Разин
7b2bb5ea8f Translated using Weblate (Polish)
Currently translated at 100.0% (447 of 447 strings)

Translated using Weblate (Korean)

Currently translated at 79.6% (356 of 447 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ko/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2023-07-22 13:59:48 +03:00
plum7x
eff2d6bcb6 Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: plum7x <plumgift@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2023-07-22 13:59:48 +03:00
03b92c4898 Improve wrokers parallelism 2023-07-21 15:46:28 +03:00
6dcb537a9a Update search ui 2023-07-21 15:26:26 +03:00
052cfe26b1 Show chapters count in suggestion notification 2023-07-21 10:32:10 +03:00
45b2f2337a Favourites manage activity 2023-07-21 10:23:02 +03:00
5785a2d5d1 Remove shelf 2023-07-20 16:11:03 +03:00
bc273bfb8f Storage usage preference 2023-07-20 15:50:32 +03:00
513aa1a285 Code cleanup 2023-07-20 14:09:07 +03:00
82a3b93214 Expandable manga description 2023-07-20 13:33:48 +03:00
80149b1ce7 Related manga activity 2023-07-20 10:56:15 +03:00
297029a659 Options to run background workers only using wifi 2023-07-20 09:29:26 +03:00
08acf2d882 Fix crashes 2023-07-19 15:18:30 +03:00
dafca9e1e1 Update suggestions 2023-07-19 15:10:11 +03:00
e174bc68af Merge branch 'devel' into next 2023-07-19 13:48:28 +03:00
1d78c64350 Move coroutines from UserDataSettingsFragment to ViewModel 2023-07-19 13:32:02 +03:00
321a9ecf62 Update parsers 2023-07-19 12:30:58 +03:00
83cf6aa997 Temporarily replace ViewPager2 within ViewPager in favourites 2023-07-18 16:22:02 +03:00
e7bd74429e Cache related manga lists 2023-07-18 15:43:28 +03:00
9ba87640c0 Bookmarks bottom sheet 2023-07-18 15:34:56 +03:00
fff77cf208 Backup and restore bookmarks 2023-07-18 14:27:14 +03:00
967e8df7c9 Fix backup restoring 2023-07-18 12:28:02 +03:00
f86d873361 Merge branch 'feature/backup-settings' of github.com:javlonrahimov/Kotatsu into javlonrahimov-feature/backup-settings 2023-07-18 12:05:23 +03:00
2d5332d8df Merge branch 'devel' into next 2023-07-18 12:04:41 +03:00
439a01c43f Fix bookmark has direct url detection #424 2023-07-18 11:43:31 +03:00
3a9d0def7d Update parsers 2023-07-18 10:13:46 +03:00
javlon
33a45ac5b3 remove clutter code 2023-07-17 14:49:28 +02:00
e4c80b4443 Remove rubbish file 2023-07-17 14:14:23 +03:00
940d448e00 Fix local manga update on shelf 2023-07-17 14:13:16 +03:00
5ab48a7545 Fix scrobbling rating 2023-07-17 13:30:50 +03:00
cb2bdbdd9a Update parsers 2023-07-17 13:08:39 +03:00
Cookies
8fdaf92cc4 Translated using Weblate (Vietnamese)
Currently translated at 89.2% (399 of 447 strings)

Co-authored-by: Cookies <Nekop1845@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-07-17 12:39:46 +03:00
Shubham Niraula
0416077964 Translated using Weblate (Nepali)
Currently translated at 51.9% (232 of 447 strings)

Co-authored-by: Shubham Niraula <niraulas018@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ne/
Translation: Kotatsu/Strings
2023-07-17 12:39:46 +03:00
javlon
a8176e6589 add settings backup 2023-07-15 21:22:16 +02:00
a2437dd27a Adjust tip view 2023-07-15 15:56:07 +03:00
9e56766e9e New sources tip 2023-07-15 14:59:54 +03:00
eec750789d Reader background option 2023-07-14 14:33:55 +03:00
44a2b6db11 Update explore fragment 2023-07-13 16:08:09 +03:00
55ca2b8d8d Description bottom sheet 2023-07-13 15:08:39 +03:00
2d670418c7 Application update indicator 2023-07-13 15:08:19 +03:00
4c201bf950 Merge branch 'devel' into next 2023-07-13 13:20:53 +03:00
7b60ed6bad Fix new sources dialog list 2023-07-13 13:12:21 +03:00
Cookies
619be69580 Translated using Weblate (Vietnamese)
Currently translated at 89.2% (399 of 447 strings)

Co-authored-by: Cookies <Nekop1845@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-07-12 11:00:13 +03:00
Shubham Niraula
9f3c3f8985 Translated using Weblate (Nepali)
Currently translated at 51.2% (229 of 447 strings)

Co-authored-by: Shubham Niraula <niraulas018@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ne/
Translation: Kotatsu/Strings
2023-07-12 11:00:13 +03:00
Vítor Fernandes Almado
f345977858 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: Vítor Fernandes Almado <vfalmado@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2023-07-12 11:00:13 +03:00
9610caf002 Downloads scheduler fixes 2023-07-12 10:58:02 +03:00
b75220a1b7 Fix cover loading in details 2023-07-11 11:45:57 +03:00
ab2a6f5a17 Fix loading state 2023-07-11 11:34:47 +03:00
2aeefc607b Udpate dependencies 2023-07-11 11:09:45 +03:00
Shubham Niraula
9af769bc69 Translated using Weblate (Nepali)
Currently translated at 50.3% (225 of 447 strings)

Co-authored-by: Shubham Niraula <niraulas018@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ne/
Translation: Kotatsu/Strings
2023-07-11 09:54:06 +03:00
Pluto
46b78cfcd7 Translated using Weblate (Czech)
Currently translated at 100.0% (6 of 6 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (447 of 447 strings)

Added translation using Weblate (Czech)

Added translation using Weblate (Czech)

Co-authored-by: Pluto <notemailprotected@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/cs/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-07-11 09:54:06 +03:00
Nguyễn Mạnh Hùng
c24324de9a Translated using Weblate (Vietnamese)
Currently translated at 81.8% (366 of 447 strings)

Co-authored-by: Nguyễn Mạnh Hùng <hungmn13@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-07-11 09:54:06 +03:00
Hosted Weblate
48b9c1236d Update translation files
Updated by "Remove blank strings" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/
Translation: Kotatsu/Strings
2023-07-11 09:54:06 +03:00
plum7x
c69d293caa Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (447 of 447 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 97.5% (436 of 447 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 97.3% (435 of 447 strings)

Co-authored-by: plum7x <plumgift@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2023-07-11 09:54:06 +03:00
Clxff H3r4ld0
0f4cca0e07 Translated using Weblate (Indonesian)
Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: Clxff H3r4ld0 <123844876+clxf12@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-07-11 09:54:06 +03:00
Luiz-bro
d6500b8fec Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.1% (443 of 447 strings)

Co-authored-by: Luiz-bro <luiznneto1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2023-07-11 09:54:06 +03:00
Detrimental God
86140cab1e Added translation using Weblate (Malayalam)
Co-authored-by: Detrimental God <judeliger1@gmail.com>
2023-07-11 09:54:06 +03:00
Isira Seneviratne
46ab5af905 Apply suggestions. 2023-07-11 09:53:42 +03:00
Isira Seneviratne
9a815f28fa Add Parceler implementations for Manga classes. 2023-07-11 09:53:42 +03:00
394479192b Refactor adapters 2023-07-06 06:53:23 +03:00
7908eb1441 Favourites container fixes 2023-07-04 13:44:01 +03:00
ed672feebe Show related manga 2023-07-04 09:09:56 +03:00
4739da2774 Reorganize navigation 2023-07-03 17:20:17 +03:00
fb674b6028 More M3 theming 2023-07-03 16:11:05 +03:00
df5f5ea737 Return rounded square for simple manga item 2023-07-03 14:57:57 +03:00
e6facd4e41 Material fade through preview 2023-07-03 14:39:37 +03:00
942d4fe5ab Refactor ListModel 2023-07-03 13:57:51 +03:00
80db817ff2 Single suggestion more button intent 2023-07-02 15:59:42 +03:00
b2817a2ce7 Recommendation item on explore screen (not finished) 2023-07-02 02:15:07 +03:00
e2835e3e95 Use FastOutSlowInInterpolator for segmented bar 2023-07-01 23:45:03 +03:00
91928d058b Pie chart test 2023-07-01 23:43:38 +03:00
425e8a49c4 Adjust two-line items to M3 guidelines 2023-07-01 22:54:45 +03:00
148dbfdf02 Set 16dp corners for tips 2023-07-01 22:54:00 +03:00
55fdd6b7b1 Update default favicon drawable 2023-07-01 17:44:36 +03:00
a726b4f499 Merge remote-tracking branch 'origin/next' into next 2023-07-01 17:07:26 +03:00
39cd199044 Tip view 2023-07-01 16:03:48 +03:00
98b8aa3f2d Update details fragment layout 2023-07-01 14:46:42 +03:00
f5b8d41a86 Merge branch 'devel' into next 2023-07-01 13:18:29 +03:00
90dfc84119 Update dependencies 2023-07-01 12:54:44 +03:00
f01fd18711 Main activity theming 2023-06-30 21:27:20 +03:00
0098bdd07e Initial starting of writing next major version 2023-06-30 17:01:29 +03:00
6a792f8ac3 Use CoroutineStart.ATOMIC in some cases 2023-06-30 14:04:22 +03:00
c81e8749b6 Update parsers and headers processing 2023-06-28 13:27:26 +03:00
5fa260a0c7 Update issue template 2023-06-28 03:14:30 +03:00
e0ba4e2686 Remove unused code 2023-06-27 12:52:41 +03:00
f188d1c0f3 Remove ongoing flag from background work notifications 2023-06-27 12:34:12 +03:00
CakesTwix
6de55afa27 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: CakesTwix <cakestwix1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-06-27 12:30:56 +03:00
J. Lavoie
21dcb5b754 Translated using Weblate (French)
Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-06-27 12:30:56 +03:00
gallegonovato
9b3ea57db1 Translated using Weblate (Spanish)
Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-06-27 12:30:56 +03:00
kuragehime
032a8607ba Translated using Weblate (Japanese)
Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-06-27 12:30:56 +03:00
Макар Разин
f7303c5957 Translated using Weblate (Serbian)
Currently translated at 29.3% (131 of 447 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.5% (445 of 447 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (447 of 447 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (447 of 447 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-06-27 12:30:56 +03:00
d696606ef9 Misc fixes 2023-06-27 10:28:47 +03:00
0a6e106a1d Filter local manga files 2023-06-24 13:18:09 +03:00
de1a7f0ca8 Fix IndexOutOfBoundsException in RemoteViewsFactory 2023-06-24 09:38:13 +03:00
9d31e76cc7 Translated using Weblate (Russian)
Currently translated at 100.0% (443 of 443 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
Cookies
20910ffb5d Translated using Weblate (Vietnamese)
Currently translated at 81.2% (360 of 443 strings)

Co-authored-by: Cookies <Nekop1845@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
Clxff H3r4ld0
7497ee6364 Translated using Weblate (Indonesian)
Currently translated at 100.0% (443 of 443 strings)

Co-authored-by: Clxff H3r4ld0 <123844876+clxf12@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
Abay Emes
0f2ed50e18 Translated using Weblate (Kazakh)
Currently translated at 48.8% (213 of 436 strings)

Co-authored-by: Abay Emes <abayemes@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/kk/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
BlackSpectrum
ba066b577b Translated using Weblate (Hindi)
Currently translated at 15.5% (68 of 436 strings)

Co-authored-by: BlackSpectrum <tittan5000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
CakesTwix
4496fe876f Translated using Weblate (Ukrainian)
Currently translated at 100.0% (436 of 436 strings)

Co-authored-by: CakesTwix <cakestwix1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
gallegonovato
a9f5abebf0 Translated using Weblate (Spanish)
Currently translated at 100.0% (443 of 443 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (436 of 436 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
qrynill
bebee2ef27 Translated using Weblate (Norwegian Nynorsk)
Currently translated at 91.7% (399 of 435 strings)

Co-authored-by: qrynill <tryvseu@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nn/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
Макар Разин
4ec2b0c8fe Translated using Weblate (Vietnamese)
Currently translated at 79.3% (345 of 435 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (Korean)

Currently translated at 75.1% (327 of 435 strings)

Translated using Weblate (Greek)

Currently translated at 19.3% (84 of 435 strings)

Translated using Weblate (Serbian)

Currently translated at 28.2% (123 of 435 strings)

Translated using Weblate (Arabic)

Currently translated at 18.1% (79 of 435 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (Italian)

Currently translated at 85.2% (371 of 435 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (435 of 435 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/el/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ko/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-06-23 16:23:37 +03:00
4a7be70898 Update queries in manifest 2023-06-23 16:14:41 +03:00
2bcba1eb21 Configure manga directories 2023-06-22 13:45:29 +03:00
feca7ba3fc Support for custom directories for manga 2023-06-22 10:11:11 +03:00
745b349e5e Ability to remove item from updates 2023-06-21 15:27:20 +03:00
13946783a5 Fix crashes 2023-06-21 15:06:01 +03:00
84e5400522 Download options dialog 2023-06-21 14:54:11 +03:00
02c9a933d2 Fix offline manga details 2023-06-20 17:06:18 +03:00
92af851d3b Option to clear single source cookies 2023-06-20 13:43:09 +03:00
009eb9fe44 Fix recursive sync 2023-06-17 18:34:08 +03:00
fc8a5ccd9f Fix Continue button in offline mode 2023-06-17 18:20:57 +03:00
91f46de547 Fix crashes 2023-06-17 18:11:09 +03:00
d548993e14 Move syncronization to main process 2023-06-17 17:36:12 +03:00
4f32664b33 Respect system PowerSave mode 2023-06-17 16:12:14 +03:00
71b14a3aa8 Refactor FilterOwner 2023-06-17 16:05:08 +03:00
Isira Seneviratne
183a61272e Use ParcelCompat methods. 2023-06-17 15:50:08 +03:00
f1f208ad15 Merge branch 'master' into devel 2023-06-16 10:45:12 +03:00
c6983d794c Fix tablet portrait layout 2023-06-16 10:42:19 +03:00
8228153c83 Fix tablet portrait layout 2023-06-16 10:41:32 +03:00
844bd13a07 Fix filter lifecycle 2023-06-16 10:23:40 +03:00
60a5620134 Schedule workers only on demand 2023-06-16 09:50:02 +03:00
qrynill
dd09a39077 Translated using Weblate (Norwegian Nynorsk)
Currently translated at 82.0% (357 of 435 strings)

Co-authored-by: qrynill <tryvseu@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nn/
Translation: Kotatsu/Strings
2023-06-15 09:45:35 +03:00
MaSHiNiK
1511bd3279 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (435 of 435 strings)

Co-authored-by: MaSHiNiK <infinitymashinik456@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-06-15 09:45:35 +03:00
Макар Разин
259c335607 Translated using Weblate (Turkish)
Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (French)

Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (Portuguese)

Currently translated at 86.2% (375 of 435 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (435 of 435 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-06-15 09:45:07 +03:00
Nick New
86367b6d3b Added translation using Weblate (Thai)
Co-authored-by: Nick New <newblackseries@gmail.com>
2023-06-15 09:45:07 +03:00
19b893738d Update parsers 2023-06-15 09:44:35 +03:00
d817ae0394 Fix Cloudflare bypass 2023-06-15 09:43:14 +03:00
d81c22b586 Fix crashes 2023-06-14 10:49:33 +03:00
cd23b044df Merge branch 'devel' 2023-06-13 19:08:45 +03:00
4922881343 Update parsers 2023-06-13 18:41:32 +03:00
ff0d04bea6 Add current screen info to crash reports 2023-06-13 18:32:11 +03:00
97de629c3b Fix BottomSheet duplication 2023-06-13 10:45:29 +03:00
7b482e5bcf Fix shortcuts icons size 2023-06-12 16:16:33 +03:00
fd575b8131 Merge branch 'devel' 2023-06-12 12:57:29 +03:00
c77e023bef Fix color filter preview 2023-06-12 12:54:24 +03:00
a3cf52859b Reader refactoring 2023-06-12 12:47:05 +03:00
5e55bce529 Use indirect image url in bookmarks 2023-06-12 10:54:12 +03:00
b1ba70bf77 Fix settings issues 2023-06-10 15:48:30 +03:00
b930272221 Fix settings background 2023-06-09 16:34:29 +03:00
75305c0b94 Fix fast scroll issues 2023-06-09 16:18:58 +03:00
24b16e2ce2 Fix debug/release sources packages 2023-06-08 21:15:32 +03:00
qrynill
0ccbba6787 Translated using Weblate (Norwegian Nynorsk)
Currently translated at 81.8% (356 of 435 strings)

Co-authored-by: qrynill <tryvseu@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nn/
Translation: Kotatsu/Strings
2023-06-08 18:53:35 +03:00
Reza Almanda
ca314867f2 Translated using Weblate (Indonesian)
Currently translated at 100.0% (435 of 435 strings)

Co-authored-by: Reza Almanda <rezaalmanda27@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-06-08 18:53:35 +03:00
gallegonovato
236e284360 Translated using Weblate (Spanish)
Currently translated at 100.0% (435 of 435 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-06-08 18:53:35 +03:00
Макар Разин
e9a09b6be4 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (435 of 435 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-06-08 18:53:35 +03:00
ViAnh
9e1be337ed Translated using Weblate (Vietnamese)
Currently translated at 78.3% (341 of 435 strings)

Added translation using Weblate (Vietnamese)

Co-authored-by: ViAnh <polaris140698@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2023-06-08 18:53:35 +03:00
kuragehime
104f2ebfae Translated using Weblate (Japanese)
Currently translated at 100.0% (435 of 435 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (434 of 434 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-06-08 18:53:35 +03:00
6a2e12dc29 Read button tip in DetailsActivity 2023-06-06 12:35:36 +03:00
9587cb439c Android 5 fixes 2023-06-06 10:10:24 +03:00
c42d0824b0 Add summaries to settings root 2023-06-05 13:54:49 +03:00
09f6dd9b4e Remove unused resources 2023-06-05 13:53:52 +03:00
b494c96e31 Translated using Weblate (Russian)
Currently translated at 99.7% (433 of 434 strings)

Co-authored-by: Zakhar Timoshenko <vp1984tanki@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-06-05 13:46:13 +03:00
0f6d56ee2d Merge remote-tracking branch 'weblate/devel' into devel 2023-06-05 13:23:14 +03:00
8d15691e17 Remove unused resources 2023-06-05 13:04:26 +03:00
GpixeL
bd8b251934 Translated using Weblate (Indonesian)
Currently translated at 100.0% (430 of 430 strings)

Co-authored-by: GpixeL <gamesfire313@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
frablock
2f1b74e45a Translated using Weblate (French)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (French)

Currently translated at 100.0% (430 of 430 strings)

Co-authored-by: frablock <franon1.frablock29@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-06-05 12:56:26 +03:00
FateXBlood
73217b8e11 Translated using Weblate (Nepali)
Currently translated at 34.1% (144 of 422 strings)

Co-authored-by: FateXBlood <zecrofelix@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ne/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
wr131
759df969c9 Translated using Weblate (Chinese (Traditional))
Currently translated at 23.4% (99 of 422 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.2% (419 of 422 strings)

Co-authored-by: wr131 <wr13173893699@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
Clxff H3r4ld0
466e35fffa Translated using Weblate (Indonesian)
Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (422 of 422 strings)

Co-authored-by: Clxff H3r4ld0 <123844876+clxf12@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
Reza Almanda
f44db3dbff Translated using Weblate (Indonesian)
Currently translated at 100.0% (421 of 421 strings)

Co-authored-by: Reza Almanda <rezaalmanda27@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
Макар Разин
315870abcb Translated using Weblate (Ukrainian)
Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (French)

Currently translated at 100.0% (421 of 421 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
Bai
3e46b3957c Translated using Weblate (Turkish)
Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Turkish)

Currently translated at 99.7% (416 of 417 strings)

Co-authored-by: Bai <batuhanakkurt000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
gallegonovato
6dc81468d2 Translated using Weblate (Spanish)
Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (422 of 422 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (417 of 417 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-06-05 12:56:26 +03:00
Subham Jena
56bc0dbf07 Translated using Weblate (Odia)
Currently translated at 7.3% (31 of 421 strings)

Translated using Weblate (Odia)

Currently translated at 5.5% (23 of 416 strings)

Co-authored-by: Subham Jena <subhamjena8465@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/or/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
tryvseu
7bc33adca8 Translated using Weblate (Norwegian Nynorsk)
Currently translated at 85.5% (356 of 416 strings)

Co-authored-by: tryvseu <tryvseu@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nn/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
InfinityDouki56
c8794d59f7 Translated using Weblate (Filipino)
Currently translated at 93.0% (387 of 416 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
Insopitus
9c2a57812e Translated using Weblate (Chinese (Simplified))
Currently translated at 99.2% (413 of 416 strings)

Co-authored-by: Insopitus <jessefor@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
kuragehime
6bd5033858 Translated using Weblate (Japanese)
Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (427 of 427 strings)

Translated using Weblate (Japanese)

Currently translated at 91.8% (382 of 416 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
J. Lavoie
e7c2a76219 Translated using Weblate (French)
Currently translated at 100.0% (416 of 416 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
Макар Разин
0934363298 Translated using Weblate (Belarusian)
Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (416 of 416 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
2023-06-05 12:56:26 +03:00
GpixeL
de29527805 Translated using Weblate (Indonesian)
Currently translated at 100.0% (430 of 430 strings)

Co-authored-by: GpixeL <gamesfire313@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-06-05 11:53:43 +02:00
frablock
f11e964f0b Translated using Weblate (French)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (French)

Currently translated at 100.0% (430 of 430 strings)

Co-authored-by: frablock <franon1.frablock29@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-06-05 11:53:42 +02:00
FateXBlood
61a98f54b9 Translated using Weblate (Nepali)
Currently translated at 34.1% (144 of 422 strings)

Co-authored-by: FateXBlood <zecrofelix@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ne/
Translation: Kotatsu/Strings
2023-06-05 11:53:42 +02:00
wr131
50e67daea4 Translated using Weblate (Chinese (Traditional))
Currently translated at 23.4% (99 of 422 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.2% (419 of 422 strings)

Co-authored-by: wr131 <wr13173893699@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2023-06-05 11:53:41 +02:00
Clxff H3r4ld0
0030706226 Translated using Weblate (Indonesian)
Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (422 of 422 strings)

Co-authored-by: Clxff H3r4ld0 <123844876+clxf12@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-06-05 11:53:41 +02:00
Reza Almanda
056ef5433d Translated using Weblate (Indonesian)
Currently translated at 100.0% (421 of 421 strings)

Co-authored-by: Reza Almanda <rezaalmanda27@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-06-05 11:53:40 +02:00
Макар Разин
c14b2ceeff Translated using Weblate (Ukrainian)
Currently translated at 100.0% (431 of 431 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (431 of 431 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (431 of 431 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (French)

Currently translated at 100.0% (421 of 421 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-06-05 11:53:40 +02:00
Bai
ff2cf9d18a Translated using Weblate (Turkish)
Currently translated at 100.0% (431 of 431 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Turkish)

Currently translated at 99.7% (416 of 417 strings)

Co-authored-by: Bai <batuhanakkurt000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-06-05 11:53:39 +02:00
gallegonovato
96b6900c70 Translated using Weblate (Spanish)
Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (423 of 423 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (422 of 422 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (417 of 417 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-06-05 11:53:39 +02:00
Subham Jena
c6228d3fe1 Translated using Weblate (Odia)
Currently translated at 7.3% (31 of 421 strings)

Translated using Weblate (Odia)

Currently translated at 5.5% (23 of 416 strings)

Co-authored-by: Subham Jena <subhamjena8465@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/or/
Translation: Kotatsu/Strings
2023-06-05 11:53:38 +02:00
tryvseu
8ac95e1608 Translated using Weblate (Norwegian Nynorsk)
Currently translated at 85.5% (356 of 416 strings)

Co-authored-by: tryvseu <tryvseu@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nn/
Translation: Kotatsu/Strings
2023-06-05 11:53:38 +02:00
InfinityDouki56
69a9ec354b Translated using Weblate (Filipino)
Currently translated at 93.0% (387 of 416 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-06-05 11:53:37 +02:00
Insopitus
0639d3e6c1 Translated using Weblate (Chinese (Simplified))
Currently translated at 99.2% (413 of 416 strings)

Co-authored-by: Insopitus <jessefor@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-06-05 11:53:37 +02:00
kuragehime
ae5cebd42d Translated using Weblate (Japanese)
Currently translated at 100.0% (431 of 431 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (427 of 427 strings)

Translated using Weblate (Japanese)

Currently translated at 91.8% (382 of 416 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-06-05 11:53:36 +02:00
J. Lavoie
cd8381cbfb Translated using Weblate (French)
Currently translated at 100.0% (416 of 416 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-06-05 11:53:36 +02:00
Макар Разин
3132049a63 Translated using Weblate (Belarusian)
Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (416 of 416 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
2023-06-05 11:53:35 +02:00
bc3a7fc211 Reorganize settings 2023-06-05 12:53:12 +03:00
e794f84c6f Get rid of SlidingPaneLayout in settings 2023-06-05 11:52:56 +03:00
76709dda21 Remove replacement characters from manga description 2023-06-05 10:57:35 +03:00
6dc460bc20 Replace CloudFlareDialog with activity 2023-06-05 10:45:23 +03:00
c2ee548f0a Show errors in global search results 2023-06-05 09:34:56 +03:00
1847759ec3 Refactor list extra provider 2023-06-04 17:37:54 +03:00
02d5dfb375 Check if page file valid before page display 2023-06-04 16:55:51 +03:00
ef9b16da0b Fix favourite categories covers loading 2023-06-02 17:07:26 +03:00
5d1ef983e9 Check available ram before pages prefetch 2023-06-02 17:06:37 +03:00
eb78a776cf Update parsers 2023-06-02 17:04:46 +03:00
685 changed files with 19465 additions and 13589 deletions

View File

@@ -13,6 +13,7 @@ disabled_rules = no-wildcard-imports, no-unused-imports
[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul}]
ij_continuation_indent_size = 4
ij_xml_attribute_wrap = on_every_item
[{*.kt,*.kts}]
ij_kotlin_allow_trailing_comma_on_call_site = true

View File

@@ -61,4 +61,6 @@ body:
label: Acknowledgements
options:
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true
required: true
- label: If this is an issue with a parser, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
required: true

1
.idea/.gitignore generated vendored
View File

@@ -1,3 +1,4 @@
# Default ignored files
/shelf/
/workspace.xml
/migrations.xml

2
.idea/gradle.xml generated
View File

@@ -5,7 +5,6 @@
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-17" />
<option name="modules">
@@ -14,6 +13,7 @@
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>

3
.weblate Normal file
View File

@@ -0,0 +1,3 @@
[weblate]
url = https://hosted.weblate.org/api/
translation = kotatsu/strings

11
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,11 @@
## Kotatsu contribution guidelines
- If you want to fix bug or implement a new feature, that already mention in the [issues](https://github.com/KotatsuApp/Kotatsu/issues), please, assign this issue to you and/or comment about it.
- Whether you have to implement new feature, please, open an issue or discussion regarding it to ensure it will be accepted.
- Translations have to be managed using the [Weblate](https://hosted.weblate.org/engage/kotatsu/) platform.
- In case you want to add a new manga source, refer to the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers).
Refactoring or some dev-faces improvements are also might be accepted, however please stick to the following principles:
- Performance matters. In the case of choosing between source code beauty and performance, performance should be a priority.
- Please, do not modify readme and other information files (except for typos).
- Avoid adding new dependencies unless required. APK size is important.

View File

@@ -39,6 +39,10 @@ Kotatsu is a free and open source manga reader for Android.
Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages,
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)
### Contributing
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the guidelines.
### License
[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html)

View File

@@ -2,28 +2,30 @@ plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
id 'com.google.devtools.ksp'
id 'kotlin-parcelize'
id 'dagger.hilt.android.plugin'
}
android {
compileSdk = 33
buildToolsVersion = '33.0.2'
// compileSdkExtension = 4
buildToolsVersion = '34.0.0'
namespace = 'org.koitharu.kotatsu'
defaultConfig {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 33
versionCode 547
versionName '5.1.3'
versionCode 570
versionName '6.0-a1'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
kapt {
arguments {
arg 'room.schemaLocation', "$projectDir/schemas".toString()
}
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
androidResources {
generateLocaleConfig true
}
}
buildTypes {
@@ -39,6 +41,7 @@ android {
}
buildFeatures {
viewBinding true
buildConfig true
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
@@ -79,80 +82,80 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:44e28b40d3') {
implementation('com.github.KotatsuApp:kotatsu-parsers:407ef5b655') {
exclude group: 'org.json', module: 'json'
}
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.21'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.activity:activity-ktx:1.7.2'
implementation 'androidx.fragment:fragment-ktx:1.5.7'
implementation 'androidx.fragment:fragment-ktx:1.6.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-service:2.6.1'
implementation 'androidx.lifecycle:lifecycle-process:2.6.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.0'
implementation 'androidx.recyclerview:recyclerview:1.3.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.9.0'
//noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.1'
// TODO https://issuetracker.google.com/issues/254846063
implementation 'androidx.work:work-runtime-ktx:2.8.1'
//noinspection GradleDependency
implementation('com.google.guava:guava:32.0.0-android') {
implementation('com.google.guava:guava:32.0.1-android') {
exclude group: 'com.google.guava', module: 'failureaccess'
exclude group: 'org.checkerframework', module: 'checker-qual'
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
}
implementation 'androidx.room:room-runtime:2.5.1'
implementation 'androidx.room:room-ktx:2.5.1'
kapt 'androidx.room:room-compiler:2.5.1'
implementation 'androidx.room:room-runtime:2.5.2'
implementation 'androidx.room:room-ktx:2.5.2'
ksp 'androidx.room:room-compiler:2.5.2'
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
implementation 'com.squareup.okio:okio:3.3.0'
implementation 'com.squareup.okio:okio:3.4.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'com.google.dagger:hilt-android:2.46.1'
kapt 'com.google.dagger:hilt-compiler:2.46.1'
implementation 'com.google.dagger:hilt-android:2.47'
kapt 'com.google.dagger:hilt-compiler:2.47'
implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.4.0'
implementation 'io.coil-kt:coil-svg:2.4.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:9b1d20be67'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'
implementation 'ch.acra:acra-http:5.9.7'
implementation 'ch.acra:acra-dialog:5.9.7'
implementation 'ch.acra:acra-http:5.11.0'
implementation 'ch.acra:acra-dialog:5.11.0'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20230227'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
testImplementation 'org.json:json:20230618'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
androidTestImplementation 'androidx.room:room-testing:2.5.1'
androidTestImplementation 'androidx.room:room-testing:2.5.2'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.46.1'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.46.1'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.47'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.47'
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}

View File

@@ -17,7 +17,7 @@ class MangaDatabaseTest {
MangaDatabase::class.java,
)
private val migrations = databaseMigrations
private val migrations = getDatabaseMigrations(InstrumentationRegistry.getInstrumentation().targetContext)
@Test
fun versions() {

View File

@@ -23,7 +23,7 @@ import javax.inject.Inject
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class ShortcutsUpdaterTest {
class AppShortcutManagerTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@@ -32,7 +32,7 @@ class ShortcutsUpdaterTest {
lateinit var historyRepository: HistoryRepository
@Inject
lateinit var shortcutsUpdater: ShortcutsUpdater
lateinit var appShortcutManager: AppShortcutManager
@Inject
lateinit var database: MangaDatabase
@@ -48,6 +48,7 @@ class ShortcutsUpdaterTest {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
return@runTest
}
database.invalidationTracker.addObserver(appShortcutManager)
awaitUpdate()
assertTrue(getShortcuts().isEmpty())
historyRepository.addOrUpdate(
@@ -72,6 +73,6 @@ class ShortcutsUpdaterTest {
private suspend fun awaitUpdate() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.awaitForIdle()
shortcutsUpdater.await()
appShortcutManager.await()
}
}

View File

@@ -17,7 +17,7 @@ import java.util.EnumSet
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain()
get() = ConfigKey.Domain("")
override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)

View File

@@ -1,3 +1,3 @@
package org.koitharu.kotatsu.util.ext
package org.koitharu.kotatsu.core.util.ext
fun Throwable.printStackTraceDebug() = printStackTrace()

View File

@@ -1,37 +0,0 @@
package org.koitharu.kotatsu.util
import android.util.Log
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
class LoggingAdapterDataObserver(
private val tag: String,
) : AdapterDataObserver() {
override fun onChanged() {
Log.d(tag, "onChanged()")
}
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
Log.d(tag, "onItemRangeChanged(positionStart=$positionStart, itemCount=$itemCount)")
}
override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) {
Log.d(tag, "onItemRangeChanged(positionStart=$positionStart, itemCount=$itemCount, payload=$payload)")
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
Log.d(tag, "onItemRangeInserted(positionStart=$positionStart, itemCount=$itemCount)")
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
Log.d(tag, "onItemRangeRemoved(positionStart=$positionStart, itemCount=$itemCount)")
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
Log.d(tag, "onItemRangeMoved(fromPosition=$fromPosition, toPosition=$toPosition, itemCount=$itemCount)")
}
override fun onStateRestorationPolicyChanged() {
Log.d(tag, "onStateRestorationPolicyChanged()")
}
}

View File

@@ -18,6 +18,22 @@
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
</intent>
</queries>
<application
android:name="org.koitharu.kotatsu.KotatsuApp"
@@ -30,8 +46,8 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="true"
android:localeConfig="@xml/locales"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Kotatsu"
@@ -83,6 +99,9 @@
<activity
android:name="org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity"
android:label="@string/suggestions" />
<activity
android:name="org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity"
android:label="@string/related_manga" />
<activity
android:name="org.koitharu.kotatsu.settings.SettingsActivity"
android:exported="true"
@@ -98,18 +117,24 @@
<data android:host="sync-settings" />
</intent-filter>
</activity>
<activity
android:name="org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity"
android:label="@string/local_manga_directories" />
<activity
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity"
android:label="@string/favourites"
android:windowSoftInputMode="stateAlwaysHidden" />
android:label="@string/manage_categories" />
<activity
android:name="org.koitharu.kotatsu.widget.shelf.ShelfConfigActivity"
android:exported="true"
@@ -140,9 +165,6 @@
<activity
android:name="org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity"
android:label="@string/color_correction" />
<activity
android:name="org.koitharu.kotatsu.shelf.ui.config.ShelfSettingsActivity"
android:label="@string/settings" />
<activity
android:name="org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity"
android:exported="true"
@@ -184,8 +206,7 @@
<service
android:name="org.koitharu.kotatsu.sync.ui.favourites.FavouritesSyncService"
android:exported="false"
android:label="@string/favourites"
android:process=":sync">
android:label="@string/favourites">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
@@ -196,8 +217,7 @@
<service
android:name="org.koitharu.kotatsu.sync.ui.history.HistorySyncService"
android:exported="false"
android:label="@string/history"
android:process=":sync">
android:label="@string/history">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>

View File

@@ -9,9 +9,11 @@ import androidx.fragment.app.strictmode.FragmentStrictMode
import androidx.hilt.work.HiltWorkerFactory
import androidx.room.InvalidationTracker
import androidx.work.Configuration
import androidx.work.WorkManager
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.acra.ACRA
import org.acra.ReportField
import org.acra.config.dialog
import org.acra.config.httpSender
@@ -19,14 +21,16 @@ import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.os.AppValidator
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
import javax.inject.Inject
import javax.inject.Provider
@HiltAndroidApp
class KotatsuApp : Application(), Configuration.Provider {
@@ -46,8 +50,18 @@ class KotatsuApp : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
@Inject
lateinit var appValidator: AppValidator
@Inject
lateinit var workScheduleManager: WorkScheduleManager
@Inject
lateinit var workManagerProvider: Provider<WorkManager>
override fun onCreate() {
super.onCreate()
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.toString())
if (BuildConfig.DEBUG) {
enableStrictMode()
}
@@ -57,7 +71,7 @@ class KotatsuApp : Application(), Configuration.Provider {
processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers()
}
WorkServiceStopHelper(applicationContext).setup()
workScheduleManager.init()
}
override fun attachBaseContext(base: Context?) {
@@ -67,6 +81,10 @@ class KotatsuApp : Application(), Configuration.Provider {
reportFormat = StringFormat.JSON
excludeMatchingSharedPreferencesKeys = listOf(
"sources_\\w+",
AppSettings.KEY_APP_PASSWORD,
AppSettings.KEY_PROXY_LOGIN,
AppSettings.KEY_PROXY_ADDRESS,
AppSettings.KEY_PROXY_PASSWORD,
)
httpSender {
uri = getString(R.string.url_error_report)
@@ -83,8 +101,10 @@ class KotatsuApp : Application(), Configuration.Provider {
ReportField.PHONE_MODEL,
ReportField.STACK_TRACE,
ReportField.CRASH_CONFIGURATION,
ReportField.CUSTOM_DATA,
ReportField.SHARED_PREFERENCES,
)
dialog {
text = getString(R.string.crash_text)
title = getString(R.string.error_occurred)
@@ -135,7 +155,7 @@ class KotatsuApp : Application(), Configuration.Provider {
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
.penaltyDeath()
.detectFragmentReuse()
.detectWrongFragmentContainer()
// .detectWrongFragmentContainer() FIXME: migrate to ViewPager2
.detectRetainInstanceUsage()
.detectSetUserVisibleHint()
.detectFragmentTagUsage()

View File

@@ -5,6 +5,7 @@ import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
@@ -14,15 +15,24 @@ abstract class BookmarksDao {
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
abstract suspend fun find(mangaId: Long, pageId: Long): BookmarkEntity?
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page")
@Query("SELECT * FROM bookmarks WHERE page_id = :pageId")
abstract suspend fun find(pageId: Long): BookmarkEntity?
@Transaction
@Query(
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
)
abstract suspend fun findAll(): Map<MangaWithTags, List<BookmarkEntity>>
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent")
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY created_at DESC")
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY percent")
abstract fun observe(mangaId: Long): Flow<List<BookmarkEntity>>
@Transaction
@Query(
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY bookmarks.created_at",
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
)
abstract fun observe(): Flow<Map<MangaWithTags, List<BookmarkEntity>>>
@@ -35,6 +45,12 @@ abstract class BookmarksDao {
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
abstract suspend fun delete(mangaId: Long, pageId: Long): Int
@Query("DELETE FROM bookmarks WHERE page_id = :pageId")
abstract suspend fun delete(pageId: Long): Int
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page")
abstract suspend fun delete(mangaId: Long, chapterId: Long, page: Int): Int
@Upsert
abstract suspend fun upsert(bookmarks: Collection<BookmarkEntity>)
}

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.bookmarks.data
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.*
import java.util.Date
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
manga = manga,
@@ -30,4 +30,5 @@ fun Collection<BookmarkEntity>.toBookmarks(manga: Manga) = map {
it.toBookmark(manga)
}
fun Collection<Bookmark>.ids() = map { it.pageId }
@JvmName("bookmarksIds")
fun Collection<Bookmark>.ids() = map { it.pageId }

View File

@@ -1,9 +1,12 @@
package org.koitharu.kotatsu.bookmarks.domain
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.local.data.ImageFileFilter
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import java.util.Date
class Bookmark(
data class Bookmark(
val manga: Manga,
val pageId: Long,
val chapterId: Long,
@@ -12,33 +15,30 @@ class Bookmark(
val imageUrl: String,
val createdAt: Date,
val percent: Float,
) {
) : ListModel {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
val directImageUrl: String?
get() = if (isImageUrlDirect()) imageUrl else null
other as Bookmark
val imageLoadData: Any
get() = if (isImageUrlDirect()) imageUrl else toMangaPage()
if (manga != other.manga) return false
if (pageId != other.pageId) return false
if (chapterId != other.chapterId) return false
if (page != other.page) return false
if (scroll != other.scroll) return false
if (imageUrl != other.imageUrl) return false
if (createdAt != other.createdAt) return false
return percent == other.percent
override fun areItemsTheSame(other: ListModel): Boolean {
return other is Bookmark &&
manga.id == other.manga.id &&
chapterId == other.chapterId &&
page == other.page
}
override fun hashCode(): Int {
var result = manga.hashCode()
result = 31 * result + pageId.hashCode()
result = 31 * result + chapterId.hashCode()
result = 31 * result + page
result = 31 * result + scroll
result = 31 * result + imageUrl.hashCode()
result = 31 * result + createdAt.hashCode()
result = 31 * result + percent.hashCode()
return result
fun toMangaPage() = MangaPage(
id = pageId,
url = imageUrl,
preview = null,
source = manga.source,
)
private fun isImageUrlDirect(): Boolean {
val extension = imageUrl.substringAfterLast('.')
return extension.isNotEmpty() && ImageFileFilter().isExtensionValid(extension)
}
}

View File

@@ -15,8 +15,8 @@ import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.mapItems
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import javax.inject.Inject
@Reusable
@@ -62,18 +62,16 @@ class BookmarksRepository @Inject constructor(
removeBookmark(bookmark.manga.id, bookmark.chapterId, bookmark.page)
}
suspend fun removeBookmarks(ids: Map<Manga, Set<Long>>): ReversibleHandle {
suspend fun removeBookmarks(ids: Set<Long>): ReversibleHandle {
val entities = ArrayList<BookmarkEntity>(ids.size)
db.withTransaction {
val dao = db.bookmarksDao
for ((manga, idSet) in ids) {
for (pageId in idSet) {
val e = dao.find(manga.id, pageId)
if (e != null) {
entities.add(e)
}
dao.delete(manga.id, pageId)
for (pageId in ids) {
val e = dao.find(pageId)
if (e != null) {
entities.add(e)
}
dao.delete(pageId)
}
}
return BookmarksRestorer(entities)

View File

@@ -12,31 +12,32 @@ import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.data.ids
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksGroupAdapter
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksAdapter
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.ui.util.reverseAsync
import org.koitharu.kotatsu.core.util.ext.invalidateNestedItemDecorations
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
import org.koitharu.kotatsu.parsers.model.Manga
@@ -48,15 +49,18 @@ class BookmarksFragment :
BaseFragment<FragmentListSimpleBinding>(),
ListStateHolderListener,
OnListItemClickListener<Bookmark>,
SectionedSelectionController.Callback<Manga>,
FastScroller.FastScrollListener {
ListSelectionController.Callback2,
FastScroller.FastScrollListener, ListHeaderClickListener {
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var settings: AppSettings
private val viewModel by viewModels<BookmarksViewModel>()
private var adapter: BookmarksGroupAdapter? = null
private var selectionController: SectionedSelectionController<Manga>? = null
private var bookmarksAdapter: BookmarksAdapter? = null
private var selectionController: ListSelectionController? = null
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentListSimpleBinding {
return FragmentListSimpleBinding.inflate(inflater, container, false)
@@ -64,45 +68,62 @@ class BookmarksFragment :
override fun onViewBindingCreated(binding: FragmentListSimpleBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
selectionController = SectionedSelectionController(
selectionController = ListSelectionController(
activity = requireActivity(),
owner = this,
decoration = BookmarksSelectionDecoration(binding.root.context),
registryOwner = this,
callback = this,
)
adapter = BookmarksGroupAdapter(
bookmarksAdapter = BookmarksAdapter(
lifecycleOwner = viewLifecycleOwner,
coil = coil,
listener = this,
selectionController = checkNotNull(selectionController),
bookmarkClickListener = this,
groupClickListener = OnGroupClickListener(),
clickListener = this,
headerClickListener = this,
)
binding.recyclerView.adapter = adapter
binding.recyclerView.setHasFixedSize(true)
val spacingDecoration = SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing))
binding.recyclerView.addItemDecoration(spacingDecoration)
viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
val spanSizeLookup = SpanSizeLookup()
with(binding.recyclerView) {
setHasFixedSize(true)
val spanResolver = MangaListSpanResolver(resources)
addItemDecoration(TypedListSpacingDecoration(context))
adapter = bookmarksAdapter
addOnLayoutChangeListener(spanResolver)
spanResolver.setGridSize(settings.gridSize / 100f, this)
val lm = GridLayoutManager(context, spanResolver.spanCount)
lm.spanSizeLookup = spanSizeLookup
layoutManager = lm
selectionController?.attachToRecyclerView(this)
}
viewModel.content.observe(viewLifecycleOwner) {
bookmarksAdapter?.setItems(it, spanSizeLookup)
}
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ::onActionDone)
}
override fun onDestroyView() {
super.onDestroyView()
adapter = null
bookmarksAdapter = null
selectionController = null
}
override fun onItemClick(item: Bookmark, view: View) {
if (selectionController?.onItemClick(item.manga, item.pageId) != true) {
val intent = ReaderActivity.newIntent(view.context, item)
startActivity(intent, scaleUpActivityOptionsOf(view).toBundle())
if (selectionController?.onItemClick(item.pageId) != true) {
val intent = ReaderActivity.IntentBuilder(view.context)
.bookmark(item)
.incognito(true)
.build()
startActivity(intent)
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
}
override fun onListHeaderClick(item: ListHeader, view: View) {
val manga = item.payload as? Manga ?: return
startActivity(DetailsActivity.newIntent(view.context, manga))
}
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
return selectionController?.onItemLongClick(item.manga, item.pageId) ?: false
return selectionController?.onItemLongClick(item.pageId) ?: false
}
override fun onRetryClick(error: Throwable) = Unit
@@ -115,24 +136,16 @@ class BookmarksFragment :
override fun onFastScrollStop(fastScroller: FastScroller) = Unit
override fun onSelectionChanged(controller: SectionedSelectionController<Manga>, count: Int) {
requireViewBinding().recyclerView.invalidateNestedItemDecorations()
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
requireViewBinding().recyclerView.invalidateItemDecorations()
}
override fun onCreateActionMode(
controller: SectionedSelectionController<Manga>,
mode: ActionMode,
menu: Menu,
): Boolean {
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
return true
}
override fun onActionItemClicked(
controller: SectionedSelectionController<Manga>,
mode: ActionMode,
item: MenuItem,
): Boolean {
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_remove -> {
val ids = selectionController?.snapshot() ?: return false
@@ -145,11 +158,6 @@ class BookmarksFragment :
}
}
override fun onCreateItemDecoration(
controller: SectionedSelectionController<Manga>,
section: Manga,
): AbstractSelectionItemDecoration = BookmarksSelectionDecoration(requireContext())
override fun onWindowInsetsChanged(insets: Insets) {
requireViewBinding().recyclerView.updatePadding(
bottom = insets.bottom,
@@ -159,10 +167,6 @@ class BookmarksFragment :
}
}
private fun onListChanged(list: List<ListModel>) {
adapter?.items = list
}
private fun onActionDone(action: ReversibleAction) {
val handle = action.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
@@ -173,24 +177,24 @@ class BookmarksFragment :
snackbar.show()
}
private inner class OnGroupClickListener : OnListItemClickListener<BookmarksGroup> {
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable {
override fun onItemClick(item: BookmarksGroup, view: View) {
val controller = selectionController
if (controller != null && controller.count > 0) {
if (controller.getSectionCount(item.manga) == item.bookmarks.size) {
controller.clearSelection(item.manga)
} else {
controller.addToSelection(item.manga, item.bookmarks.ids())
}
return
}
val intent = DetailsActivity.newIntent(view.context, item.manga)
startActivity(intent)
init {
isSpanIndexCacheEnabled = true
isSpanGroupIndexCacheEnabled = true
}
override fun onItemLongClick(item: BookmarksGroup, view: View): Boolean {
return selectionController?.addToSelection(item.manga, item.bookmarks.ids()) ?: false
override fun getSpanSize(position: Int): Int {
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1
return when (bookmarksAdapter?.getItemViewType(position)) {
ListItemType.PAGE_THUMB.ordinal -> 1
else -> total
}
}
override fun run() {
invalidateSpanGroupIndexCache()
invalidateSpanIndexCache()
}
}

View File

@@ -10,13 +10,14 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
@@ -41,17 +42,26 @@ class BookmarksViewModel @Inject constructor(
actionStringRes = 0,
),
)
} else list.map { (manga, bookmarks) ->
BookmarksGroup(manga, bookmarks)
} else {
mapList(list)
}
}
.catch { e -> emit(listOf(e.toErrorState(canRetry = false))) }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
fun removeBookmarks(ids: Map<Manga, Set<Long>>) {
fun removeBookmarks(ids: Set<Long>) {
launchJob(Dispatchers.Default) {
val handle = repository.removeBookmarks(ids)
onActionDone.call(ReversibleAction(R.string.bookmarks_removed, handle))
}
}
private fun mapList(data: Map<Manga, List<Bookmark>>): List<ListModel> {
val result = ArrayList<ListModel>(data.values.sumOf { it.size + 1 })
for ((manga, bookmarks) in data) {
result.add(ListHeader(manga.title, R.string.more, manga))
result.addAll(bookmarks)
}
return result
}
}

View File

@@ -28,7 +28,7 @@ fun bookmarkListAD(
binding.root.setOnLongClickListener(listener)
bind {
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageUrl)?.run {
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
size(CoverSizeResolver(binding.imageViewThumb))
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)

View File

@@ -1,32 +1,15 @@
package org.koitharu.kotatsu.bookmarks.ui.adapter
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
class BookmarksAdapter(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Bookmark>,
) : AsyncListDifferDelegationAdapter<Bookmark>(
DiffCallback(),
) : BaseListAdapter<Bookmark>(
bookmarkListAD(coil, lifecycleOwner, clickListener),
) {
private class DiffCallback : DiffUtil.ItemCallback<Bookmark>() {
override fun areItemsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
return oldItem.manga.id == newItem.manga.id &&
oldItem.chapterId == newItem.chapterId &&
oldItem.page == newItem.page
}
override fun areContentsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
return oldItem.imageUrl == newItem.imageUrl
}
}
}
)

View File

@@ -1,69 +0,0 @@
package org.koitharu.kotatsu.bookmarks.ui.adapter
import android.view.View
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.core.util.ext.clearItemDecorations
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemBookmarksGroupBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
fun bookmarksGroupAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
sharedPool: RecyclerView.RecycledViewPool,
selectionController: SectionedSelectionController<Manga>,
bookmarkClickListener: OnListItemClickListener<Bookmark>,
groupClickListener: OnListItemClickListener<BookmarksGroup>,
) = adapterDelegateViewBinding<BookmarksGroup, ListModel, ItemBookmarksGroupBinding>(
{ layoutInflater, parent -> ItemBookmarksGroupBinding.inflate(layoutInflater, parent, false) },
) {
val viewListenerAdapter = object : View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View) = groupClickListener.onItemClick(item, v)
override fun onLongClick(v: View) = groupClickListener.onItemLongClick(item, v)
}
val adapter = BookmarksAdapter(coil, lifecycleOwner, bookmarkClickListener)
binding.recyclerView.setRecycledViewPool(sharedPool)
binding.recyclerView.adapter = adapter
val spacingDecoration = SpacingItemDecoration(context.resources.getDimensionPixelOffset(R.dimen.grid_spacing))
binding.recyclerView.addItemDecoration(spacingDecoration)
binding.root.setOnClickListener(viewListenerAdapter)
binding.root.setOnLongClickListener(viewListenerAdapter)
bind { payloads ->
if (payloads.isEmpty()) {
binding.recyclerView.clearItemDecorations()
binding.recyclerView.addItemDecoration(spacingDecoration)
selectionController.attachToRecyclerView(item.manga, binding.recyclerView)
}
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
size(CoverSizeResolver(binding.imageViewCover))
source(item.manga.source)
enqueueWith(coil)
}
binding.textViewTitle.text = item.manga.title
adapter.items = item.bookmarks
}
onViewRecycled {
binding.imageViewCover.disposeImageRequest()
}
}

View File

@@ -1,77 +0,0 @@
package org.koitharu.kotatsu.bookmarks.ui.adapter
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.parsers.model.Manga
import kotlin.jvm.internal.Intrinsics
class BookmarksGroupAdapter(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
selectionController: SectionedSelectionController<Manga>,
listener: ListStateHolderListener,
bookmarkClickListener: OnListItemClickListener<Bookmark>,
groupClickListener: OnListItemClickListener<BookmarksGroup>,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init {
val pool = RecyclerView.RecycledViewPool()
delegatesManager
.addDelegate(
bookmarksGroupAD(
coil = coil,
lifecycleOwner = lifecycleOwner,
sharedPool = pool,
selectionController = selectionController,
bookmarkClickListener = bookmarkClickListener,
groupClickListener = groupClickListener,
),
)
.addDelegate(loadingStateAD())
.addDelegate(loadingFooterAD())
.addDelegate(emptyStateListAD(coil, lifecycleOwner, listener))
.addDelegate(errorStateListAD(listener))
}
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return when {
oldItem is BookmarksGroup && newItem is BookmarksGroup -> {
oldItem.manga.id == newItem.manga.id
}
oldItem is LoadingFooter && newItem is LoadingFooter -> {
oldItem.key == newItem.key
}
else -> oldItem.javaClass == newItem.javaClass
}
}
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return Intrinsics.areEqual(oldItem, newItem)
}
override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? {
return when {
oldItem is BookmarksGroup && newItem is BookmarksGroup -> Unit
else -> super.getChangePayload(oldItem, newItem)
}
}
}
}

View File

@@ -1,31 +0,0 @@
package org.koitharu.kotatsu.bookmarks.ui.model
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.areItemsEquals
class BookmarksGroup(
val manga: Manga,
val bookmarks: List<Bookmark>,
) : ListModel {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as BookmarksGroup
if (manga != other.manga) return false
return bookmarks.areItemsEquals(other.bookmarks) { a, b ->
a.imageUrl == b.imageUrl
}
}
override fun hashCode(): Int {
var result = manga.hashCode()
result = 31 * result + bookmarks.sumOf { it.imageUrl.hashCode() }
return result
}
}

View File

@@ -0,0 +1,48 @@
package org.koitharu.kotatsu.bookmarks.ui.sheet
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
fun bookmarkLargeAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Bookmark>,
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
) {
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
binding.root.setOnClickListener(listener)
binding.root.setOnLongClickListener(listener)
bind {
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
size(CoverSizeResolver(binding.imageViewThumb))
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
decodeRegion(item.scroll)
source(item.manga.source)
enqueueWith(coil)
}
binding.progressView.percent = item.percent
}
onViewRecycled {
binding.imageViewThumb.disposeImageRequest()
}
}

View File

@@ -0,0 +1,42 @@
package org.koitharu.kotatsu.bookmarks.ui.sheet
import android.content.Context
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
class BookmarksAdapter(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Bookmark>,
headerClickListener: ListHeaderClickListener?,
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
init {
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener))
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
}
override fun getSectionText(context: Context, position: Int): CharSequence? {
val list = items
for (i in (0..position).reversed()) {
val item = list.getOrNull(i) ?: continue
if (item is ListHeader) {
return item.getText(context)
}
}
return null
}
}

View File

@@ -0,0 +1,169 @@
package org.koitharu.kotatsu.bookmarks.ui.sheet
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.plus
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetPagesBinding
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
import javax.inject.Inject
import kotlin.math.roundToInt
@AndroidEntryPoint
class BookmarksSheet :
BaseAdaptiveSheet<SheetPagesBinding>(),
AdaptiveSheetCallback,
OnListItemClickListener<Bookmark> {
private val viewModel by viewModels<BookmarksSheetViewModel>()
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var settings: AppSettings
private var bookmarksAdapter: BookmarksAdapter? = null
private var spanResolver: MangaListSpanResolver? = null
private val spanSizeLookup = SpanSizeLookup()
private val listCommitCallback = Runnable {
spanSizeLookup.invalidateCache()
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetPagesBinding {
return SheetPagesBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetPagesBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
addSheetCallback(this)
spanResolver = MangaListSpanResolver(binding.root.resources)
bookmarksAdapter = BookmarksAdapter(
coil = coil,
lifecycleOwner = viewLifecycleOwner,
clickListener = this@BookmarksSheet,
headerClickListener = null,
)
viewBinding?.headerBar?.setTitle(R.string.bookmarks)
with(binding.recyclerView) {
addItemDecoration(TypedListSpacingDecoration(context))
adapter = bookmarksAdapter
addOnLayoutChangeListener(spanResolver)
spanResolver?.setGridSize(settings.gridSize / 100f, this)
(layoutManager as GridLayoutManager).spanSizeLookup = spanSizeLookup
}
viewModel.content.observe(viewLifecycleOwner, ::onThumbnailsChanged)
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
}
override fun onDestroyView() {
spanResolver = null
bookmarksAdapter = null
spanSizeLookup.invalidateCache()
super.onDestroyView()
}
override fun onItemClick(item: Bookmark, view: View) {
val listener = (parentFragment as? OnPageSelectListener) ?: (activity as? OnPageSelectListener)
if (listener != null) {
listener.onPageSelected(ReaderPage(item.toMangaPage(), item.page, item.chapterId))
} else {
val intent = IntentBuilder(view.context)
.manga(viewModel.manga)
.bookmark(item)
.incognito(true)
.build()
startActivity(intent)
}
dismiss()
}
override fun onStateChanged(sheet: View, newState: Int) {
viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED
}
private fun onThumbnailsChanged(list: List<ListModel>) {
val adapter = bookmarksAdapter ?: return
if (adapter.itemCount == 0) {
var position = list.indexOfFirst { it is PageThumbnail && it.isCurrent }
if (position > 0) {
val spanCount = spanResolver?.spanCount ?: 0
val offset = if (position > spanCount + 1) {
(resources.getDimensionPixelSize(R.dimen.manga_list_details_item_height) * 0.6).roundToInt()
} else {
position = 0
0
}
val scrollCallback = RecyclerViewScrollCallback(requireViewBinding().recyclerView, position, offset)
adapter.setItems(list, listCommitCallback + scrollCallback)
} else {
adapter.setItems(list, listCommitCallback)
}
} else {
adapter.setItems(list, listCommitCallback)
}
}
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {
init {
isSpanIndexCacheEnabled = true
isSpanGroupIndexCacheEnabled = true
}
override fun getSpanSize(position: Int): Int {
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1
return when (bookmarksAdapter?.getItemViewType(position)) {
ListItemType.PAGE_THUMB.ordinal -> 1
else -> total
}
}
fun invalidateCache() {
invalidateSpanGroupIndexCache()
invalidateSpanIndexCache()
}
}
companion object {
const val ARG_MANGA = "manga"
private const val TAG = "BookmarksSheet"
fun show(fm: FragmentManager, manga: Manga) {
BookmarksSheet().withArgs(1) {
putParcelable(ARG_MANGA, ParcelableManga(manga, withChapters = true))
}.showDistinct(fm, TAG)
}
}
}

View File

@@ -0,0 +1,54 @@
package org.koitharu.kotatsu.bookmarks.ui.sheet
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import javax.inject.Inject
@HiltViewModel
class BookmarksSheetViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory,
bookmarksRepository: BookmarksRepository,
) : BaseViewModel() {
val manga = savedStateHandle.require<ParcelableManga>(BookmarksSheet.ARG_MANGA).manga
private val chaptersLazy = SuspendLazy {
requireNotNull(manga.chapters ?: mangaRepositoryFactory.create(manga.source).getDetails(manga).chapters)
}
val content: StateFlow<List<ListModel>> = bookmarksRepository.observeBookmarks(manga)
.map { mapList(it) }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingFooter()))
private suspend fun mapList(bookmarks: List<Bookmark>): List<ListModel> {
val chapters = chaptersLazy.get()
val bookmarksMap = bookmarks.groupBy { it.chapterId }
val result = ArrayList<ListModel>(bookmarks.size + bookmarksMap.size)
for (chapter in chapters) {
val b = bookmarksMap[chapter.id]
if (b.isNullOrEmpty()) {
continue
}
result += ListHeader(chapter.name)
result.addAll(b)
}
return result
}
}

View File

@@ -8,14 +8,15 @@ import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.webkit.CookieManager
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.network.UserAgents
import com.google.android.material.R as materialR
@SuppressLint("SetJavaScriptEnabled")
@@ -34,8 +35,9 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
}
with(viewBinding.webView.settings) {
javaScriptEnabled = true
userAgentString = CommonHeadersInterceptor.userAgentChrome
userAgentString = UserAgents.CHROME_MOBILE
}
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
viewBinding.webView.webViewClient = BrowserClient(this)
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
@@ -103,6 +105,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
override fun onDestroy() {
super.onDestroy()
viewBinding.webView.stopLoading()
viewBinding.webView.destroy()
}

View File

@@ -0,0 +1,74 @@
package org.koitharu.kotatsu.browser.cloudflare
import android.annotation.SuppressLint
import android.content.Context
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.net.toUri
import coil.request.ErrorResult
import coil.request.ImageRequest
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.parsers.model.ContentType
class CaptchaNotifier(
private val context: Context,
) : ImageRequest.Listener {
@SuppressLint("MissingPermission")
fun notify(exception: CloudFlareProtectedException) {
val manager = NotificationManagerCompat.from(context)
if (!manager.areNotificationsEnabled()) {
return
}
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
.setName(context.getString(R.string.captcha_required))
.setShowBadge(true)
.setVibrationEnabled(false)
.setSound(null, null)
.setLightsEnabled(false)
.build()
manager.createNotificationChannel(channel)
val intent = CloudFlareActivity.newIntent(context, exception.url, exception.headers)
.setData(exception.url.toUri())
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(channel.name)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setDefaults(NotificationCompat.DEFAULT_SOUND)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setAutoCancel(true)
.setVisibility(
if (exception.source?.contentType == ContentType.HENTAI) {
NotificationCompat.VISIBILITY_SECRET
} else {
NotificationCompat.VISIBILITY_PUBLIC
},
)
.setContentText(
context.getString(
R.string.captcha_required_summary,
exception.source?.title ?: context.getString(R.string.app_name),
),
)
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))
.build()
manager.notify(TAG, exception.source.hashCode(), notification)
}
override fun onError(request: ImageRequest, result: ErrorResult) {
super.onError(request, result)
val e = result.throwable
if (e is CloudFlareProtectedException) {
notify(e)
}
}
private companion object {
private const val CHANNEL_ID = "captcha"
private const val TAG = CHANNEL_ID
}
}

View File

@@ -0,0 +1,173 @@
package org.koitharu.kotatsu.browser.cloudflare
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import android.webkit.CookieManager
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.graphics.Insets
import androidx.core.net.toUri
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.network.UserAgents
import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint
class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCallback {
private var pendingResult = RESULT_CANCELED
@Inject
lateinit var cookieJar: MutableCookieJar
private var onBackPressedCallback: WebViewBackPressedCallback? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
return
}
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
val url = intent?.dataString.orEmpty()
with(viewBinding.webView.settings) {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
userAgentString = intent?.getStringExtra(ARG_UA) ?: UserAgents.CHROME_MOBILE
}
viewBinding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
onBackPressedDispatcher.addCallback(it)
}
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
if (savedInstanceState != null) {
return
}
if (url.isEmpty()) {
finishAfterTransition()
} else {
onTitleChanged(getString(R.string.loading_), url)
viewBinding.webView.loadUrl(url)
}
}
override fun onDestroy() {
viewBinding.webView.run {
stopLoading()
destroy()
}
super.onDestroy()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
viewBinding.webView.saveState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
viewBinding.webView.restoreState(savedInstanceState)
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.appbar.updatePadding(
top = insets.top,
)
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom,
)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> {
viewBinding.webView.stopLoading()
finishAfterTransition()
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onResume() {
super.onResume()
viewBinding.webView.onResume()
}
override fun onPause() {
viewBinding.webView.onPause()
super.onPause()
}
override fun finish() {
setResult(pendingResult)
super.finish()
}
override fun onPageLoaded() {
viewBinding.progressBar.isInvisible = true
}
override fun onCheckPassed() {
pendingResult = RESULT_OK
finishAfterTransition()
}
override fun onLoadingStateChanged(isLoading: Boolean) {
viewBinding.progressBar.isVisible = isLoading
}
override fun onHistoryChanged() {
onBackPressedCallback?.onHistoryChanged()
}
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
setTitle(title)
supportActionBar?.subtitle = subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
}
class Contract : ActivityResultContract<Pair<String, Headers?>, TaggedActivityResult>() {
override fun createIntent(context: Context, input: Pair<String, Headers?>): Intent {
return newIntent(context, input.first, input.second)
}
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
return TaggedActivityResult(TAG, resultCode)
}
}
companion object {
const val TAG = "CloudFlareActivity"
private const val ARG_UA = "ua"
fun newIntent(
context: Context,
url: String,
headers: Headers?,
) = Intent(context, CloudFlareActivity::class.java).apply {
data = url.toUri()
headers?.get(CommonHeaders.USER_AGENT)?.let {
putExtra(ARG_UA, it)
}
}
}
}

View File

@@ -1,123 +0,0 @@
package org.koitharu.kotatsu.browser.cloudflare
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebSettings
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isInvisible
import androidx.fragment.app.setFragmentResult
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import okhttp3.Headers
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding
import javax.inject.Inject
@AndroidEntryPoint
class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), CloudFlareCallback {
private lateinit var url: String
private val pendingResult = Bundle(1)
@Inject
lateinit var cookieJar: MutableCookieJar
private var onBackPressedCallback: WebViewBackPressedCallback? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
url = requireArguments().getString(ARG_URL).orEmpty()
}
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
) = FragmentCloudflareBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: FragmentCloudflareBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
with(binding.webView.settings) {
javaScriptEnabled = true
cacheMode = WebSettings.LOAD_DEFAULT
domStorageEnabled = true
databaseEnabled = true
userAgentString = arguments?.getString(ARG_UA) ?: CommonHeadersInterceptor.userAgentChrome
}
binding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
CookieManager.getInstance().setAcceptThirdPartyCookies(binding.webView, true)
if (url.isEmpty()) {
dismissAllowingStateLoss()
} else {
binding.webView.loadUrl(url)
}
}
override fun onDestroyView() {
requireViewBinding().webView.stopLoading()
requireViewBinding().webView.destroy()
onBackPressedCallback = null
super.onDestroyView()
}
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder).setNegativeButton(android.R.string.cancel, null)
}
override fun onDialogCreated(dialog: AlertDialog) {
super.onDialogCreated(dialog)
onBackPressedCallback = WebViewBackPressedCallback(requireViewBinding().webView).also {
dialog.onBackPressedDispatcher.addCallback(it)
}
}
override fun onResume() {
super.onResume()
requireViewBinding().webView.onResume()
}
override fun onPause() {
requireViewBinding().webView.onPause()
super.onPause()
}
override fun onDismiss(dialog: DialogInterface) {
setFragmentResult(TAG, pendingResult)
super.onDismiss(dialog)
}
override fun onPageLoaded() {
viewBinding?.progressBar?.isInvisible = true
}
override fun onCheckPassed() {
pendingResult.putBoolean(EXTRA_RESULT, true)
dismissAllowingStateLoss()
}
override fun onHistoryChanged() {
onBackPressedCallback?.onHistoryChanged()
}
companion object {
const val TAG = "CloudFlareDialog"
const val EXTRA_RESULT = "result"
private const val ARG_URL = "url"
private const val ARG_UA = "ua"
fun newInstance(url: String, headers: Headers?) = CloudFlareDialog().withArgs(2) {
putString(ARG_URL, url)
headers?.get(CommonHeaders.USER_AGENT)?.let {
putString(ARG_UA, it)
}
}
}
}

View File

@@ -6,6 +6,7 @@ import android.provider.SearchRecentSuggestions
import android.text.Html
import androidx.collection.arraySetOf
import androidx.room.InvalidationTracker
import androidx.work.WorkManager
import coil.ComponentRegistry
import coil.ImageLoader
import coil.decode.SvgDecoder
@@ -29,19 +30,18 @@ import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.StubContentCache
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.network.*
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.util.AcraScreenLogger
import org.koitharu.kotatsu.core.util.IncognitoModeIndicator
import org.koitharu.kotatsu.core.util.ext.activityManager
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.domain.ListExtraProviderImpl
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher
import org.koitharu.kotatsu.local.data.LocalStorageChanges
@@ -65,9 +65,6 @@ interface AppModule {
@Binds
fun bindImageGetter(coilImageGetter: CoilImageGetter): Html.ImageGetter
@Binds
fun bindListExtraProvider(impl: ListExtraProviderImpl): ListExtraProvider
companion object {
@Provides
@@ -130,12 +127,12 @@ interface AppModule {
@ElementsIntoSet
fun provideDatabaseObservers(
widgetUpdater: WidgetUpdater,
shortcutsUpdater: ShortcutsUpdater,
appShortcutManager: AppShortcutManager,
backupObserver: BackupObserver,
syncController: SyncController,
): Set<@JvmSuppressWildcards InvalidationTracker.Observer> = arraySetOf(
widgetUpdater,
shortcutsUpdater,
appShortcutManager,
backupObserver,
syncController,
)
@@ -146,10 +143,12 @@ interface AppModule {
appProtectHelper: AppProtectHelper,
activityRecreationHandle: ActivityRecreationHandle,
incognitoModeIndicator: IncognitoModeIndicator,
acraScreenLogger: AcraScreenLogger,
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
appProtectHelper,
activityRecreationHandle,
incognitoModeIndicator,
acraScreenLogger,
)
@Provides
@@ -174,5 +173,10 @@ interface AppModule {
fun provideLocalStorageChangesFlow(
@LocalStorageChanges flow: MutableSharedFlow<LocalManga?>,
): SharedFlow<LocalManga?> = flow.asSharedFlow()
@Provides
fun provideWorkManager(
@ApplicationContext context: Context,
): WorkManager = WorkManager.getInstance(context)
}
}

View File

@@ -13,5 +13,7 @@ class BackupEntry(
const val HISTORY = "history"
const val CATEGORIES = "categories"
const val FAVOURITES = "favourites"
const val SETTINGS = "settings"
const val BOOKMARKS = "bookmarks"
}
}
}

View File

@@ -5,6 +5,7 @@ import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.json.JSONIterator
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -12,7 +13,10 @@ import javax.inject.Inject
private const val PAGE_SIZE = 10
class BackupRepository @Inject constructor(private val db: MangaDatabase) {
class BackupRepository @Inject constructor(
private val db: MangaDatabase,
private val settings: AppSettings,
) {
suspend fun dumpHistory(): BackupEntry {
var offset = 0
@@ -67,6 +71,36 @@ class BackupRepository @Inject constructor(private val db: MangaDatabase) {
return entry
}
suspend fun dumpBookmarks(): BackupEntry {
val entry = BackupEntry(BackupEntry.BOOKMARKS, JSONArray())
val all = db.bookmarksDao.findAll()
for ((m, b) in all) {
val json = JSONObject()
val manga = JsonSerializer(m.manga).toJson()
json.put("manga", manga)
val tags = JSONArray()
m.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
json.put("tags", tags)
val bookmarks = JSONArray()
b.forEach { bookmarks.put(JsonSerializer(it).toJson()) }
json.put("bookmarks", bookmarks)
entry.data.put(json)
}
return entry
}
fun dumpSettings(): BackupEntry {
val entry = BackupEntry(BackupEntry.SETTINGS, JSONArray())
val settingsDump = settings.getAllValues().toMutableMap()
settingsDump.remove(AppSettings.KEY_APP_PASSWORD)
settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD)
settingsDump.remove(AppSettings.KEY_PROXY_LOGIN)
settingsDump.remove(AppSettings.KEY_INCOGNITO_MODE)
val json = JsonSerializer(settingsDump).toJson()
entry.data.put(json)
return entry
}
fun createIndex(): BackupEntry {
val entry = BackupEntry(BackupEntry.INDEX, JSONArray())
val json = JSONObject()
@@ -127,4 +161,36 @@ class BackupRepository @Inject constructor(private val db: MangaDatabase) {
}
return result
}
suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
val mangaJson = item.getJSONObject("manga")
val manga = JsonDeserializer(mangaJson).toMangaEntity()
val tags = item.getJSONArray("tags").mapJSON {
JsonDeserializer(it).toTagEntity()
}
val bookmarks = item.getJSONArray("bookmarks").mapJSON {
JsonDeserializer(it).toBookmarkEntity()
}
result += runCatchingCancellable {
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga, tags)
db.bookmarksDao.upsert(bookmarks)
}
}
}
return result
}
fun restoreSettings(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
result += runCatchingCancellable {
settings.upsertAll(JsonDeserializer(item).toMap())
}
}
return result
}
}

View File

@@ -11,8 +11,8 @@ class BackupZipInput(val file: File) : Closeable {
private val zipFile = ZipFile(file)
suspend fun getEntry(name: String): BackupEntry = runInterruptible(Dispatchers.IO) {
val entry = zipFile.getEntry(name)
suspend fun getEntry(name: String): BackupEntry? = runInterruptible(Dispatchers.IO) {
val entry = zipFile.getEntry(name) ?: return@runInterruptible null
val json = zipFile.getInputStream(entry).use {
JSONArray(it.bufferedReader().readText())
}
@@ -22,4 +22,4 @@ class BackupZipInput(val file: File) : Closeable {
override fun close() {
zipFile.close()
}
}
}

View File

@@ -11,6 +11,9 @@ class CompositeResult {
val failures: List<Throwable>
get() = errors.filterNotNull()
val isEmpty: Boolean
get() = errors.isEmpty() && successCount == 0
val isAllSuccess: Boolean
get() = errors.none { it != null }
@@ -36,4 +39,4 @@ class CompositeResult {
result.errors.addAll(other.errors)
return result
}
}
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.backup
import org.json.JSONObject
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
@@ -34,14 +35,14 @@ class JsonDeserializer(private val json: JSONObject) {
largeCoverUrl = json.getStringOrNull("large_cover_url"),
state = json.getStringOrNull("state"),
author = json.getStringOrNull("author"),
source = json.getString("source")
source = json.getString("source"),
)
fun toTagEntity() = TagEntity(
id = json.getLong("id"),
title = json.getString("title"),
key = json.getString("key"),
source = json.getString("source")
source = json.getString("source"),
)
fun toHistoryEntity() = HistoryEntity(
@@ -65,4 +66,28 @@ class JsonDeserializer(private val json: JSONObject) {
isVisibleInLibrary = json.getBooleanOrDefault("show_in_lib", true),
deletedAt = 0L,
)
fun toBookmarkEntity() = BookmarkEntity(
mangaId = json.getLong("manga_id"),
pageId = json.getLong("page_id"),
chapterId = json.getLong("chapter_id"),
page = json.getInt("page"),
scroll = json.getInt("scroll"),
imageUrl = json.getString("image_url"),
createdAt = json.getLong("created_at"),
percent = json.getDouble("percent").toFloat(),
)
fun toMap(): Map<String, Any?> {
val map = mutableMapOf<String, Any?>()
val keys = json.keys()
while (keys.hasNext()) {
val key = keys.next()
val value = json.get(key)
map[key] = value
}
return map
}
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.backup
import org.json.JSONObject
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
@@ -15,7 +16,7 @@ class JsonSerializer private constructor(private val json: JSONObject) {
put("category_id", e.categoryId)
put("sort_key", e.sortKey)
put("created_at", e.createdAt)
}
},
)
constructor(e: FavouriteCategoryEntity) : this(
@@ -27,7 +28,7 @@ class JsonSerializer private constructor(private val json: JSONObject) {
put("order", e.order)
put("track", e.track)
put("show_in_lib", e.isVisibleInLibrary)
}
},
)
constructor(e: HistoryEntity) : this(
@@ -39,7 +40,7 @@ class JsonSerializer private constructor(private val json: JSONObject) {
put("page", e.page)
put("scroll", e.scroll)
put("percent", e.percent)
}
},
)
constructor(e: TagEntity) : this(
@@ -48,7 +49,7 @@ class JsonSerializer private constructor(private val json: JSONObject) {
put("title", e.title)
put("key", e.key)
put("source", e.source)
}
},
)
constructor(e: MangaEntity) : this(
@@ -65,8 +66,25 @@ class JsonSerializer private constructor(private val json: JSONObject) {
put("state", e.state)
put("author", e.author)
put("source", e.source)
}
},
)
constructor(e: BookmarkEntity) : this(
JSONObject().apply {
put("manga_id", e.mangaId)
put("page_id", e.pageId)
put("chapter_id", e.chapterId)
put("page", e.page)
put("scroll", e.scroll)
put("image_url", e.imageUrl)
put("created_at", e.createdAt)
put("percent", e.percent)
},
)
constructor(m: Map<String, *>) : this(
JSONObject(m),
)
fun toJson(): JSONObject = json
}
}

View File

@@ -16,8 +16,29 @@ interface ContentCache {
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>)
data class Key(
suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>?
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>)
class Key(
val source: MangaSource,
val url: String,
)
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Key
if (source != other.source) return false
return url == other.url
}
override fun hashCode(): Int {
var result = source.hashCode()
result = 31 * result + url.hashCode()
return result
}
}
}

View File

@@ -16,6 +16,7 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(4, 5, TimeUnit.MINUTES)
private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(4, 10, TimeUnit.MINUTES)
private val relatedMangaCache = ExpiringLruCache<SafeDeferred<List<Manga>>>(4, 10, TimeUnit.MINUTES)
override val isCachingEnabled: Boolean = true
@@ -35,6 +36,14 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
pagesCache[ContentCache.Key(source, url)] = pages
}
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? {
return relatedMangaCache[ContentCache.Key(source, url)]?.awaitOrNull()
}
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) {
relatedMangaCache[ContentCache.Key(source, url)] = related
}
override fun onConfigurationChanged(newConfig: Configuration) = Unit
override fun onLowMemory() = Unit
@@ -42,6 +51,7 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
override fun onTrimMemory(level: Int) {
trimCache(detailsCache, level)
trimCache(pagesCache, level)
trimCache(relatedMangaCache, level)
}
private fun trimCache(cache: ExpiringLruCache<*>, level: Int) {

View File

@@ -15,4 +15,8 @@ class StubContentCache : ContentCache {
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? = null
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) = Unit
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? = null
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) = Unit
}

View File

@@ -12,11 +12,13 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
import org.koitharu.kotatsu.core.db.dao.MangaDao
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
import org.koitharu.kotatsu.core.db.dao.PreferencesDao
import org.koitharu.kotatsu.core.db.dao.TagsDao
import org.koitharu.kotatsu.core.db.dao.TrackLogsDao
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.db.migrations.Migration10To11
@@ -25,6 +27,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration12To13
import org.koitharu.kotatsu.core.db.migrations.Migration13To14
import org.koitharu.kotatsu.core.db.migrations.Migration14To15
import org.koitharu.kotatsu.core.db.migrations.Migration15To16
import org.koitharu.kotatsu.core.db.migrations.Migration16To17
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
@@ -49,14 +52,14 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 16
const val DATABASE_VERSION = 17
@Database(
entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
ScrobblingEntity::class,
ScrobblingEntity::class, MangaSourceEntity::class,
],
version = DATABASE_VERSION,
)
@@ -83,30 +86,32 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val bookmarksDao: BookmarksDao
abstract val scrobblingDao: ScrobblingDao
abstract val sourcesDao: MangaSourcesDao
}
val databaseMigrations: Array<Migration>
get() = arrayOf(
Migration1To2(),
Migration2To3(),
Migration3To4(),
Migration4To5(),
Migration5To6(),
Migration6To7(),
Migration7To8(),
Migration8To9(),
Migration9To10(),
Migration10To11(),
Migration11To12(),
Migration12To13(),
Migration13To14(),
Migration14To15(),
Migration15To16(),
)
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration1To2(),
Migration2To3(),
Migration3To4(),
Migration4To5(),
Migration5To6(),
Migration6To7(),
Migration7To8(),
Migration8To9(),
Migration9To10(),
Migration10To11(),
Migration11To12(),
Migration12To13(),
Migration13To14(),
Migration14To15(),
Migration15To16(),
Migration16To17(context),
)
fun MangaDatabase(context: Context): MangaDatabase = Room
.databaseBuilder(context, MangaDatabase::class.java, "kotatsu-db")
.addMigrations(*databaseMigrations)
.addMigrations(*getDatabaseMigrations(context))
.addCallback(DatabasePrePopulateCallback(context.resources))
.build()

View File

@@ -1,6 +1,12 @@
package org.koitharu.kotatsu.core.db.dao
import androidx.room.*
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import androidx.room.Upsert
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
@@ -21,8 +27,8 @@ abstract class MangaDao {
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND source = :source AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List<MangaWithTags>
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(manga: MangaEntity): Long
@Upsert
abstract suspend fun upsert(manga: MangaEntity)
@Update(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun update(manga: MangaEntity): Int
@@ -35,15 +41,13 @@ abstract class MangaDao {
@Transaction
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
if (update(manga) <= 0) {
insert(manga)
if (tags != null) {
clearTagRelation(manga.id)
tags.map {
MangaTagsEntity(manga.id, it.id)
}.forEach {
insertTagRelation(it)
}
upsert(manga)
if (tags != null) {
clearTagRelation(manga.id)
tags.map {
MangaTagsEntity(manga.id, it.id)
}.forEach {
insertTagRelation(it)
}
}
}

View File

@@ -0,0 +1,57 @@
package org.koitharu.kotatsu.core.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
@Dao
abstract class MangaSourcesDao {
@Query("SELECT * FROM sources ORDER BY sort_key")
abstract suspend fun findAll(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key")
abstract suspend fun findAllEnabled(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key")
abstract fun observeEnabled(): Flow<List<MangaSourceEntity>>
@Query("SELECT * FROM sources ORDER BY sort_key")
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
@Query("SELECT IFNULL(MAX(sort_key),0) FROM sources")
abstract suspend fun getMaxSortKey(): Int
@Query("UPDATE sources SET enabled = 0")
abstract suspend fun disableAllSources()
@Query("UPDATE sources SET sort_key = :sortKey WHERE source = :source")
abstract suspend fun setSortKey(source: String, sortKey: Int)
@Insert(onConflict = OnConflictStrategy.IGNORE)
@Transaction
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
@Upsert
abstract suspend fun upsert(entry: MangaSourceEntity)
@Transaction
open suspend fun setEnabled(source: String, isEnabled: Boolean) {
if (updateIsEnabled(source, isEnabled) == 0) {
val entity = MangaSourceEntity(
source = source,
isEnabled = isEnabled,
sortKey = getMaxSortKey() + 1,
)
upsert(entity)
}
}
@Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source")
protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int
}

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.core.db.dao
import androidx.room.*
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import org.koitharu.kotatsu.core.db.entity.TagEntity
@Dao
@@ -12,6 +14,7 @@ abstract class TagsDao {
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE manga_tags.manga_id IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit""",
@@ -21,7 +24,7 @@ abstract class TagsDao {
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE tags.source = :source
WHERE tags.source = :source
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit""",
@@ -31,7 +34,7 @@ abstract class TagsDao {
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE tags.source = :source AND title LIKE :query
WHERE tags.source = :source AND title LIKE :query
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit""",
@@ -41,7 +44,7 @@ abstract class TagsDao {
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE title LIKE :query
WHERE title LIKE :query AND manga_tags.manga_id IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit""",

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(
tableName = "sources",
)
data class MangaSourceEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "source")
val source: String,
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
)

View File

@@ -21,9 +21,7 @@ class MangaWithTags(
other as MangaWithTags
if (manga != other.manga) return false
if (tags != other.tags) return false
return true
return tags == other.tags
}
override fun hashCode(): Int {
@@ -31,4 +29,4 @@ class MangaWithTags(
result = 31 * result + tags.hashCode()
return result
}
}
}

View File

@@ -9,5 +9,7 @@ class Migration13To14 : Migration(13, 14) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE favourites ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE history ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_brightness` REAL NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_contrast` REAL NOT NULL DEFAULT 0")
}
}

View File

@@ -5,8 +5,5 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration14To15 : Migration(14, 15) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_brightness` REAL NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_contrast` REAL NOT NULL DEFAULT 0")
}
override fun migrate(database: SupportSQLiteDatabase) = Unit
}

View File

@@ -0,0 +1,36 @@
package org.koitharu.kotatsu.core.db.migrations
import android.content.Context
import androidx.preference.PreferenceManager
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import org.koitharu.kotatsu.parsers.model.MangaSource
class Migration16To17(context: Context) : Migration(16, 17) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE `sources` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))")
database.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
val sources = MangaSource.entries
for (source in sources) {
if (source == MangaSource.LOCAL) {
continue
}
val name = source.name
var sortKey = order.indexOf(name)
if (sortKey == -1) {
sortKey = order.size + source.ordinal
}
database.execSQL(
"INSERT INTO `sources` (`source`, `enabled`, `sort_key`) VALUES (?, ?, ?)",
arrayOf(name, (name !in hiddenSources).toInt(), sortKey),
)
}
}
private fun Boolean.toInt() = if (this) 1 else 0
}

View File

@@ -2,8 +2,10 @@ package org.koitharu.kotatsu.core.exceptions
import okhttp3.Headers
import okio.IOException
import org.koitharu.kotatsu.parsers.model.MangaSource
class CloudFlareProtectedException(
val url: String,
val source: MangaSource?,
@Transient val headers: Headers,
) : IOException("Protected by CloudFlare")

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.core.exceptions
import okio.IOException
import java.util.Date
class TooManyRequestExceptions(
val url: String,
val retryAt: Date?,
) : IOException()

View File

@@ -33,11 +33,21 @@ abstract class ErrorObserver(
return resolver != null && ExceptionResolver.canResolve(error)
}
private fun isAlive(): Boolean {
return when {
fragment != null -> fragment.view != null
activity != null -> !activity.isDestroyed
else -> true
}
}
protected fun resolve(error: Throwable) {
lifecycleScope.launch {
val isResolved = resolver?.resolve(error) ?: false
if (isActive) {
onResolved?.accept(isResolved)
if (isAlive()) {
lifecycleScope.launch {
val isResolved = resolver?.resolve(error) ?: false
if (isActive) {
onResolved?.accept(isResolved)
}
}
}
}

View File

@@ -6,15 +6,13 @@ import androidx.annotation.StringRes
import androidx.collection.ArrayMap
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Headers
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.isSuccess
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -23,20 +21,26 @@ import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class ExceptionResolver private constructor(
private val activity: FragmentActivity?,
private val fragment: Fragment?,
) : ActivityResultCallback<TaggedActivityResult> {
class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
private val continuations = ArrayMap<String, Continuation<Boolean>>(1)
private lateinit var sourceAuthContract: ActivityResultLauncher<MangaSource>
private val activity: FragmentActivity?
private val fragment: Fragment?
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
private val cloudflareContract: ActivityResultLauncher<Pair<String, Headers?>>
constructor(activity: FragmentActivity) : this(activity = activity, fragment = null) {
constructor(activity: FragmentActivity) {
this.activity = activity
fragment = null
sourceAuthContract = activity.registerForActivityResult(SourceAuthActivity.Contract(), this)
cloudflareContract = activity.registerForActivityResult(CloudFlareActivity.Contract(), this)
}
constructor(fragment: Fragment) : this(activity = null, fragment = fragment) {
constructor(fragment: Fragment) {
this.fragment = fragment
activity = null
sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this)
cloudflareContract = fragment.registerForActivityResult(CloudFlareActivity.Contract(), this)
}
override fun onActivityResult(result: TaggedActivityResult) {
@@ -58,22 +62,9 @@ class ExceptionResolver private constructor(
else -> false
}
private suspend fun resolveCF(url: String, headers: Headers): Boolean {
val dialog = CloudFlareDialog.newInstance(url, headers)
val fm = getFragmentManager()
return suspendCancellableCoroutine { cont ->
fm.clearFragmentResult(CloudFlareDialog.TAG)
continuations[CloudFlareDialog.TAG] = cont
fm.setFragmentResultListener(CloudFlareDialog.TAG, checkNotNull(fragment ?: activity)) { key, result ->
continuations.remove(key)?.resume(result.getBoolean(CloudFlareDialog.EXTRA_RESULT))
}
dialog.show(fm, CloudFlareDialog.TAG)
cont.invokeOnCancellation {
continuations.remove(CloudFlareDialog.TAG, cont)
fm.clearFragmentResultListener(CloudFlareDialog.TAG)
dialog.dismissAllowingStateLoss()
}
}
private suspend fun resolveCF(url: String, headers: Headers): Boolean = suspendCoroutine { cont ->
continuations[CloudFlareActivity.TAG] = cont
cloudflareContract.launch(url to headers)
}
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->

View File

@@ -0,0 +1,17 @@
package org.koitharu.kotatsu.core.exceptions.resolve
import android.view.View
import android.widget.Toast
import androidx.fragment.app.Fragment
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
class ToastErrorObserver(
host: View,
fragment: Fragment?,
) : ErrorObserver(host, fragment, null, null) {
override suspend fun emit(value: Throwable) {
val toast = Toast.makeText(host.context, value.getDisplayMessage(host.context.resources), Toast.LENGTH_SHORT)
toast.show()
}
}

View File

@@ -0,0 +1,20 @@
package org.koitharu.kotatsu.core.fs
import android.os.Build
import org.koitharu.kotatsu.core.util.iterator.CloseableIterator
import org.koitharu.kotatsu.core.util.iterator.MappingIterator
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
class FileSequence(private val dir: File) : Sequence<File> {
override fun iterator(): Iterator<File> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val stream = Files.newDirectoryStream(dir.toPath())
CloseableIterator(MappingIterator(stream.iterator(), Path::toFile), stream)
} else {
dir.listFiles().orEmpty().iterator()
}
}
}

View File

@@ -1,9 +1,5 @@
package org.koitharu.kotatsu.core.github
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -14,28 +10,22 @@ import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.network.BaseHttpClient
import org.koitharu.kotatsu.core.os.AppValidator
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.asArrayList
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
import org.koitharu.kotatsu.parsers.util.parseJsonArray
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.security.MessageDigest
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.inject.Inject
import javax.inject.Singleton
private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE"
private const val CONTENT_TYPE_APK = "application/vnd.android.package-archive"
@Singleton
class AppUpdateRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val appValidator: AppValidator,
private val settings: AppSettings,
@BaseHttpClient private val okHttp: OkHttpClient,
) {
@@ -85,7 +75,7 @@ class AppUpdateRepository @Inject constructor(
}
fun isUpdateSupported(): Boolean {
return BuildConfig.DEBUG || getCertificateSHA1Fingerprint() == CERT_SHA1
return BuildConfig.DEBUG || appValidator.isOriginalApp
}
suspend fun getCurrentVersionChangelog(): String? {
@@ -94,22 +84,6 @@ class AppUpdateRepository @Inject constructor(
return available.find { x -> x.versionId == currentVersion }?.description
}
@Suppress("DEPRECATION")
@SuppressLint("PackageManagerGetSignatures")
private fun getCertificateSHA1Fingerprint(): String? = runCatching {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES)
val signatures = requireNotNull(packageInfo?.signatures)
val cert: ByteArray = signatures.first().toByteArray()
val input: InputStream = ByteArrayInputStream(cert)
val cf = CertificateFactory.getInstance("X509")
val c = cf.generateCertificate(input) as X509Certificate
val md: MessageDigest = MessageDigest.getInstance("SHA1")
val publicKey: ByteArray = md.digest(c.encoded)
return publicKey.byte2HexFormatted()
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrNull()
private inline fun JSONArray.find(predicate: (JSONObject) -> Boolean): JSONObject? {
val size = length()
for (i in 0 until size) {

View File

@@ -40,9 +40,7 @@ class VersionId(
if (minor != other.minor) return false
if (build != other.build) return false
if (variantType != other.variantType) return false
if (variantNumber != other.variantNumber) return false
return true
return variantNumber == other.variantNumber
}
override fun hashCode(): Int {

View File

@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.core.util.ext.subdir
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat

View File

@@ -2,8 +2,10 @@ package org.koitharu.kotatsu.core.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.*
import java.util.Date
@Parcelize
data class FavouriteCategory(
@@ -14,4 +16,20 @@ data class FavouriteCategory(
val createdAt: Date,
val isTrackingEnabled: Boolean,
val isVisibleInLibrary: Boolean,
) : Parcelable
) : Parcelable, ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is FavouriteCategory && id == other.id
}
override fun getChangePayload(previousState: ListModel): Any? {
if (previousState !is FavouriteCategory) {
return null
}
return if (isTrackingEnabled != previousState.isTrackingEnabled || isVisibleInLibrary != previousState.isVisibleInLibrary) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
null
}
}
}

View File

@@ -8,10 +8,14 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
@JvmName("mangaIds")
fun Collection<Manga>.ids() = mapToSet { it.id }
fun Collection<Manga>.distinctById() = distinctBy { it.id }
@JvmName("chaptersIds")
fun Collection<MangaChapter>.ids() = mapToSet { it.id }
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) {
return size

View File

@@ -10,7 +10,7 @@ fun MangaSource.getLocaleTitle(): String? {
}
fun MangaSource(name: String): MangaSource {
MangaSource.values().forEach {
MangaSource.entries.forEach {
if (it.name == name) return it
}
return MangaSource.DUMMY

View File

@@ -1,98 +0,0 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import androidx.core.os.ParcelCompat
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaTag
fun Manga.writeToParcel(out: Parcel, flags: Int, withChapters: Boolean) {
out.writeLong(id)
out.writeString(title)
out.writeString(altTitle)
out.writeString(url)
out.writeString(publicUrl)
out.writeFloat(rating)
ParcelCompat.writeBoolean(out, isNsfw)
out.writeString(coverUrl)
out.writeString(largeCoverUrl)
out.writeString(description)
out.writeParcelable(ParcelableMangaTags(tags), flags)
out.writeSerializable(state)
out.writeString(author)
if (withChapters) {
out.writeParcelable(chapters?.let(::ParcelableMangaChapters), flags)
} else {
out.writeString(null)
}
out.writeSerializable(source)
}
fun Parcel.readManga() = Manga(
id = readLong(),
title = requireNotNull(readString()),
altTitle = readString(),
url = requireNotNull(readString()),
publicUrl = requireNotNull(readString()),
rating = readFloat(),
isNsfw = ParcelCompat.readBoolean(this),
coverUrl = requireNotNull(readString()),
largeCoverUrl = readString(),
description = readString(),
tags = requireNotNull(readParcelableCompat<ParcelableMangaTags>()).tags,
state = readSerializableCompat(),
author = readString(),
chapters = readParcelableCompat<ParcelableMangaChapters>()?.chapters,
source = checkNotNull(readSerializableCompat()),
)
fun MangaPage.writeToParcel(out: Parcel) {
out.writeLong(id)
out.writeString(url)
out.writeString(preview)
out.writeSerializable(source)
}
fun Parcel.readMangaPage() = MangaPage(
id = readLong(),
url = requireNotNull(readString()),
preview = readString(),
source = checkNotNull(readSerializableCompat()),
)
fun MangaChapter.writeToParcel(out: Parcel) {
out.writeLong(id)
out.writeString(name)
out.writeInt(number)
out.writeString(url)
out.writeString(scanlator)
out.writeLong(uploadDate)
out.writeString(branch)
out.writeSerializable(source)
}
fun Parcel.readMangaChapter() = MangaChapter(
id = readLong(),
name = requireNotNull(readString()),
number = readInt(),
url = requireNotNull(readString()),
scanlator = readString(),
uploadDate = readLong(),
branch = readString(),
source = checkNotNull(readSerializableCompat()),
)
fun MangaTag.writeToParcel(out: Parcel) {
out.writeString(title)
out.writeString(key)
out.writeSerializable(source)
}
fun Parcel.readMangaTag() = MangaTag(
title = requireNotNull(readString()),
key = requireNotNull(readString()),
source = checkNotNull(readSerializableCompat()),
)

View File

@@ -2,52 +2,83 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import androidx.core.os.ParcelCompat
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.Manga
// Limits to avoid TransactionTooLargeException
private const val MAX_SAFE_SIZE = 1024 * 100 // Assume that 100 kb is safe parcel size
private const val MAX_SAFE_CHAPTERS_COUNT = 24 // this is 100% safe
class ParcelableManga(
@Parcelize
data class ParcelableManga(
val manga: Manga,
private val withChapters: Boolean,
) : Parcelable {
constructor(parcel: Parcel) : this(parcel.readManga(), true)
override fun writeToParcel(parcel: Parcel, flags: Int) {
val chapters = manga.chapters
if (!withChapters || chapters == null) {
manga.writeToParcel(parcel, flags, withChapters = false)
return
}
if (chapters.size <= MAX_SAFE_CHAPTERS_COUNT) {
// fast path
manga.writeToParcel(parcel, flags, withChapters = true)
return
}
val tempParcel = Parcel.obtain()
manga.writeToParcel(tempParcel, flags, withChapters = true)
val size = tempParcel.dataSize()
if (size < MAX_SAFE_SIZE) {
parcel.appendFrom(tempParcel, 0, size)
} else {
manga.writeToParcel(parcel, flags, withChapters = false)
}
tempParcel.recycle()
}
override fun describeContents(): Int {
return 0
}
companion object CREATOR : Parcelable.Creator<ParcelableManga> {
override fun createFromParcel(parcel: Parcel): ParcelableManga {
return ParcelableManga(parcel)
companion object : Parceler<ParcelableManga> {
private fun Manga.writeToParcel(out: Parcel, flags: Int, withChapters: Boolean) {
out.writeLong(id)
out.writeString(title)
out.writeString(altTitle)
out.writeString(url)
out.writeString(publicUrl)
out.writeFloat(rating)
ParcelCompat.writeBoolean(out, isNsfw)
out.writeString(coverUrl)
out.writeString(largeCoverUrl)
out.writeString(description)
out.writeParcelable(ParcelableMangaTags(tags), flags)
out.writeSerializable(state)
out.writeString(author)
val parcelableChapters = if (withChapters) null else chapters?.let(::ParcelableMangaChapters)
out.writeParcelable(parcelableChapters, flags)
out.writeSerializable(source)
}
override fun newArray(size: Int): Array<ParcelableManga?> {
return arrayOfNulls(size)
override fun ParcelableManga.write(parcel: Parcel, flags: Int) {
val chapters = manga.chapters
if (!withChapters || chapters == null) {
manga.writeToParcel(parcel, flags, withChapters = false)
return
}
if (chapters.size <= MAX_SAFE_CHAPTERS_COUNT) {
// fast path
manga.writeToParcel(parcel, flags, withChapters = true)
return
}
val tempParcel = Parcel.obtain()
manga.writeToParcel(tempParcel, flags, withChapters = true)
val size = tempParcel.dataSize()
if (size < MAX_SAFE_SIZE) {
parcel.appendFrom(tempParcel, 0, size)
} else {
manga.writeToParcel(parcel, flags, withChapters = false)
}
tempParcel.recycle()
}
override fun create(parcel: Parcel) = ParcelableManga(
Manga(
id = parcel.readLong(),
title = requireNotNull(parcel.readString()),
altTitle = parcel.readString(),
url = requireNotNull(parcel.readString()),
publicUrl = requireNotNull(parcel.readString()),
rating = parcel.readFloat(),
isNsfw = ParcelCompat.readBoolean(parcel),
coverUrl = requireNotNull(parcel.readString()),
largeCoverUrl = parcel.readString(),
description = parcel.readString(),
tags = requireNotNull(parcel.readParcelableCompat<ParcelableMangaTags>()).tags,
state = parcel.readSerializableCompat(),
author = parcel.readString(),
chapters = parcel.readParcelableCompat<ParcelableMangaChapters>()?.chapters,
source = requireNotNull(parcel.readSerializableCompat()),
),
withChapters = true
)
}
}

View File

@@ -2,34 +2,36 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.MangaChapter
class ParcelableMangaChapters(
val chapters: List<MangaChapter>,
) : Parcelable {
constructor(parcel: Parcel) : this(
List(parcel.readInt()) { parcel.readMangaChapter() }
object MangaChapterParceler : Parceler<MangaChapter> {
override fun create(parcel: Parcel) = MangaChapter(
id = parcel.readLong(),
name = requireNotNull(parcel.readString()),
number = parcel.readInt(),
url = requireNotNull(parcel.readString()),
scanlator = parcel.readString(),
uploadDate = parcel.readLong(),
branch = parcel.readString(),
source = requireNotNull(parcel.readSerializableCompat()),
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeInt(chapters.size)
for (chapter in chapters) {
chapter.writeToParcel(parcel)
}
override fun MangaChapter.write(parcel: Parcel, flags: Int) {
parcel.writeLong(id)
parcel.writeString(name)
parcel.writeInt(number)
parcel.writeString(url)
parcel.writeString(scanlator)
parcel.writeLong(uploadDate)
parcel.writeString(branch)
parcel.writeSerializable(source)
}
}
override fun describeContents(): Int {
return 0
}
companion object CREATOR : Parcelable.Creator<ParcelableMangaChapters> {
override fun createFromParcel(parcel: Parcel): ParcelableMangaChapters {
return ParcelableMangaChapters(parcel)
}
override fun newArray(size: Int): Array<ParcelableMangaChapters?> {
return arrayOfNulls(size)
}
}
}
@Parcelize
@TypeParceler<MangaChapter, MangaChapterParceler>
data class ParcelableMangaChapters(val chapters: List<MangaChapter>) : Parcelable

View File

@@ -0,0 +1,29 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.MangaPage
object MangaPageParceler : Parceler<MangaPage> {
override fun create(parcel: Parcel) = MangaPage(
id = parcel.readLong(),
url = requireNotNull(parcel.readString()),
preview = parcel.readString(),
source = requireNotNull(parcel.readSerializableCompat()),
)
override fun MangaPage.write(parcel: Parcel, flags: Int) {
parcel.writeLong(id)
parcel.writeString(url)
parcel.writeString(preview)
parcel.writeSerializable(source)
}
}
@Parcelize
@TypeParceler<MangaPage, MangaPageParceler>
class ParcelableMangaPage(val page: MangaPage) : Parcelable

View File

@@ -1,35 +0,0 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import org.koitharu.kotatsu.parsers.model.MangaPage
class ParcelableMangaPages(
val pages: List<MangaPage>,
) : Parcelable {
constructor(parcel: Parcel) : this(
List(parcel.readInt()) { parcel.readMangaPage() }
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeInt(pages.size)
for (page in pages) {
page.writeToParcel(parcel)
}
}
override fun describeContents(): Int {
return 0
}
companion object CREATOR : Parcelable.Creator<ParcelableMangaPages> {
override fun createFromParcel(parcel: Parcel): ParcelableMangaPages {
return ParcelableMangaPages(parcel)
}
override fun newArray(size: Int): Array<ParcelableMangaPages?> {
return arrayOfNulls(size)
}
}
}

View File

@@ -2,35 +2,26 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import org.koitharu.kotatsu.core.util.ext.Set
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.MangaTag
class ParcelableMangaTags(
val tags: Set<MangaTag>,
) : Parcelable {
constructor(parcel: Parcel) : this(
Set(parcel.readInt()) { parcel.readMangaTag() },
object MangaTagParceler : Parceler<MangaTag> {
override fun create(parcel: Parcel) = MangaTag(
title = requireNotNull(parcel.readString()),
key = requireNotNull(parcel.readString()),
source = requireNotNull(parcel.readSerializableCompat()),
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeInt(tags.size)
for (tag in tags) {
tag.writeToParcel(parcel)
}
}
override fun describeContents(): Int {
return 0
}
companion object CREATOR : Parcelable.Creator<ParcelableMangaTags> {
override fun createFromParcel(parcel: Parcel): ParcelableMangaTags {
return ParcelableMangaTags(parcel)
}
override fun newArray(size: Int): Array<ParcelableMangaTags?> {
return arrayOfNulls(size)
}
override fun MangaTag.write(parcel: Parcel, flags: Int) {
parcel.writeString(title)
parcel.writeString(key)
parcel.writeSerializable(source)
}
}
@Parcelize
@TypeParceler<MangaTag, MangaTagParceler>
data class ParcelableMangaTags(val tags: Set<MangaTag>) : Parcelable

View File

@@ -1,7 +1,7 @@
package org.koitharu.kotatsu.core.network
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Proxy

View File

@@ -3,23 +3,26 @@ package org.koitharu.kotatsu.core.network
import okhttp3.Interceptor
import okhttp3.Response
import okhttp3.internal.closeQuietly
import org.jsoup.Jsoup
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.net.HttpURLConnection.HTTP_FORBIDDEN
import java.net.HttpURLConnection.HTTP_UNAVAILABLE
private const val HEADER_SERVER = "Server"
private const val SERVER_CLOUDFLARE = "cloudflare"
class CloudFlareInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
val response = chain.proceed(chain.request())
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
if (response.header(HEADER_SERVER)?.startsWith(SERVER_CLOUDFLARE) == true) {
val content = response.body?.source()?.peek()?.use {
Jsoup.parse(it.inputStream(), Charsets.UTF_8.name(), response.request.url.toString())
} ?: return response
if (content.getElementById("challenge-error-title") != null) {
val request = response.request
response.closeQuietly()
throw CloudFlareProtectedException(
url = response.request.url.toString(),
url = request.url.toString(),
source = request.tag(MangaSource::class.java),
headers = request.headers,
)
}

View File

@@ -15,6 +15,7 @@ object CommonHeaders {
const val AUTHORIZATION = "Authorization"
const val CACHE_CONTROL = "Cache-Control"
const val PROXY_AUTHORIZATION = "Proxy-Authorization"
const val RETRY_AFTER = "Retry-After"
val CACHE_CONTROL_NO_STORE: CacheControl
get() = CacheControl.Builder().noStore().build()

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.network
import android.os.Build
import android.util.Log
import dagger.Lazy
import okhttp3.Headers
@@ -10,11 +9,11 @@ import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.UserAgents
import org.koitharu.kotatsu.parsers.util.mergeWith
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import java.net.IDN
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@@ -39,7 +38,7 @@ class CommonHeadersInterceptor @Inject constructor(
headersBuilder.mergeWith(it, replaceExisting = false)
}
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
headersBuilder[CommonHeaders.USER_AGENT] = userAgentFallback
headersBuilder[CommonHeaders.USER_AGENT] = UserAgents.CHROME_MOBILE
}
if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) {
val idn = IDN.toASCII(repository.domain)
@@ -62,26 +61,4 @@ class CommonHeadersInterceptor @Inject constructor(
override fun request(): Request = request
}
companion object {
val userAgentFallback
get() = "Kotatsu/%s (Android %s; %s; %s %s; %s)".format(
BuildConfig.VERSION_NAME,
Build.VERSION.RELEASE,
Build.MODEL,
Build.BRAND,
Build.DEVICE,
Locale.getDefault().language,
)
val userAgentChrome
get() = (
"Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/100.0.4896.127 Mobile Safari/537.36"
).format(
Build.VERSION.RELEASE,
Build.MODEL,
)
}
}

View File

@@ -6,7 +6,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.dnsoverhttps.DnsOverHttps
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.net.InetAddress
import java.net.UnknownHostException

View File

@@ -67,6 +67,7 @@ interface NetworkModule {
cache(cache)
addInterceptor(GZipInterceptor())
addInterceptor(CloudFlareInterceptor())
addInterceptor(RateLimitInterceptor())
if (BuildConfig.DEBUG) {
addInterceptor(CurlLoggingInterceptor())
}

View File

@@ -0,0 +1,36 @@
package org.koitharu.kotatsu.core.network
import okhttp3.Interceptor
import okhttp3.Response
import okhttp3.internal.closeQuietly
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
class RateLimitInterceptor : Interceptor {
private val dateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss ZZZ", Locale.ENGLISH)
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
if (response.code == 429) {
val retryDate = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryDate()
val request = response.request
response.closeQuietly()
throw TooManyRequestExceptions(
url = request.url.toString(),
retryAt = retryDate,
)
}
return response
}
private fun String.parseRetryDate(): Date? {
toIntOrNull()?.let {
return Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(it.toLong()))
}
return dateFormat.parse(this)
}
}

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.network
import android.annotation.SuppressLint
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext

View File

@@ -4,6 +4,7 @@ import android.webkit.CookieManager
import androidx.annotation.WorkerThread
import okhttp3.Cookie
import okhttp3.HttpUrl
import org.koitharu.kotatsu.core.util.ext.newBuilder
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@@ -30,6 +31,21 @@ class AndroidCookieJar : MutableCookieJar {
}
}
override fun removeCookies(url: HttpUrl) {
val cookies = loadForRequest(url)
if (cookies.isEmpty()) {
return
}
val urlString = url.toString()
for (c in cookies) {
val nc = c.newBuilder()
.expiresAt(System.currentTimeMillis() - 100000)
.build()
cookieManager.setCookie(urlString, nc.toString())
}
check(loadForRequest(url).isEmpty())
}
override suspend fun clear() = suspendCoroutine<Boolean> { continuation ->
cookieManager.removeAllCookies(continuation::resume)
}

View File

@@ -73,9 +73,7 @@ class CookieWrapper(
other as CookieWrapper
if (cookie != other.cookie) return false
return true
return cookie == other.cookie
}
override fun hashCode(): Int {

View File

@@ -13,5 +13,8 @@ interface MutableCookieJar : CookieJar {
@WorkerThread
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>)
@WorkerThread
fun removeCookies(url: HttpUrl)
suspend fun clear(): Boolean
}

View File

@@ -8,7 +8,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Cookie
import okhttp3.HttpUrl
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
private const val PREFS_NAME = "cookies"
@@ -21,6 +21,7 @@ class PreferencesCookieJar(
private var isLoaded = false
@WorkerThread
@Synchronized
override fun loadForRequest(url: HttpUrl): List<Cookie> {
loadPersistent()
val expired = HashSet<String>()
@@ -40,6 +41,7 @@ class PreferencesCookieJar(
}
@WorkerThread
@Synchronized
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val wrapped = cookies.map { CookieWrapper(it) }
prefs.edit(commit = true) {
@@ -53,6 +55,22 @@ class PreferencesCookieJar(
}
}
@Synchronized
@WorkerThread
override fun removeCookies(url: HttpUrl) {
loadPersistent()
val toRemove = HashSet<String>()
for ((key, cookie) in cache) {
if (cookie.isExpired() || cookie.cookie.matches(url)) {
toRemove += key
}
}
if (toRemove.isNotEmpty()) {
cache.removeAll(toRemove)
removePersistent(toRemove)
}
}
override suspend fun clear(): Boolean {
cache.clear()
withContext(Dispatchers.IO) {

View File

@@ -1,12 +1,9 @@
package org.koitharu.kotatsu.core.os
import android.app.ActivityManager
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.ShortcutManager
import android.os.Build
import android.util.Size
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
@@ -15,8 +12,8 @@ import androidx.core.graphics.drawable.toBitmap
import androidx.room.InvalidationTracker
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Precision
import coil.size.Scale
import coil.size.Size
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -25,18 +22,20 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ShortcutsUpdater @Inject constructor(
class AppShortcutManager @Inject constructor(
@ApplicationContext private val context: Context,
private val coil: ImageLoader,
private val historyRepository: HistoryRepository,
@@ -44,7 +43,9 @@ class ShortcutsUpdater @Inject constructor(
private val settings: AppSettings,
) : InvalidationTracker.Observer(TABLE_HISTORY), SharedPreferences.OnSharedPreferenceChangeListener {
private val iconSize by lazy { getIconSize(context) }
private val iconSize by lazy {
Size(ShortcutManagerCompat.getIconMaxWidth(context), ShortcutManagerCompat.getIconMaxHeight(context))
}
private var shortcutsUpdateJob: Job? = null
init {
@@ -52,7 +53,7 @@ class ShortcutsUpdater @Inject constructor(
}
override fun onInvalidated(tables: Set<String>) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1 || !settings.isDynamicShortcutsEnabled) {
if (!settings.isDynamicShortcutsEnabled) {
return
}
val prevJob = shortcutsUpdateJob
@@ -63,7 +64,7 @@ class ShortcutsUpdater @Inject constructor(
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 && key == AppSettings.KEY_SHORTCUTS) {
if (key == AppSettings.KEY_SHORTCUTS) {
if (settings.isDynamicShortcutsEnabled) {
onInvalidated(emptySet())
} else {
@@ -73,11 +74,7 @@ class ShortcutsUpdater @Inject constructor(
}
suspend fun requestPinShortcut(manga: Manga): Boolean {
return ShortcutManagerCompat.requestPinShortcut(
context,
buildShortcutInfo(manga).build(),
null,
)
return ShortcutManagerCompat.requestPinShortcut(context, buildShortcutInfo(manga), null)
}
@VisibleForTesting
@@ -85,51 +82,36 @@ class ShortcutsUpdater @Inject constructor(
return shortcutsUpdateJob?.join() != null
}
fun isDynamicShortcutsAvailable(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
return false
}
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
return manager.maxShortcutCountPerActivity > 0
}
fun notifyMangaOpened(mangaId: Long) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
return
}
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
manager.reportShortcutUsed(mangaId.toString())
ShortcutManagerCompat.reportShortcutUsed(context, mangaId.toString())
}
@RequiresApi(Build.VERSION_CODES.N_MR1)
private suspend fun updateShortcutsImpl() = runCatchingCancellable {
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity)
val maxShortcuts = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context).coerceAtLeast(5)
val shortcuts = historyRepository.getList(0, maxShortcuts)
.filter { x -> x.title.isNotEmpty() }
.map { buildShortcutInfo(it).build().toShortcutInfo() }
manager.dynamicShortcuts = shortcuts
.map { buildShortcutInfo(it) }
ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts)
}.onFailure {
it.printStackTraceDebug()
}
@RequiresApi(Build.VERSION_CODES.N_MR1)
private fun clearShortcuts() {
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
try {
manager.removeAllDynamicShortcuts()
ShortcutManagerCompat.removeAllDynamicShortcuts(context)
} catch (_: IllegalStateException) {
}
}
private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat.Builder {
private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat {
val icon = runCatchingCancellable {
coil.execute(
ImageRequest.Builder(context)
.data(manga.coverUrl)
.size(iconSize.width, iconSize.height)
.tag(manga.source)
.precision(Precision.EXACT)
.size(iconSize)
.source(manga.source)
.scale(Scale.FILL)
.transformations(ThumbnailTransformation())
.build(),
).getDrawableOrThrow().toBitmap()
}.fold(
@@ -141,21 +123,17 @@ class ShortcutsUpdater @Inject constructor(
.setShortLabel(manga.title)
.setLongLabel(manga.title)
.setIcon(icon)
.setLongLived(true)
.setIntent(
ReaderActivity.newIntent(context, manga.id)
.setAction(ReaderActivity.ACTION_MANGA_READ),
ReaderActivity.IntentBuilder(context)
.mangaId(manga.id)
.build(),
)
.build()
}
private fun getIconSize(context: Context): Size {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
(context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager).let {
Size(it.iconMaxWidth, it.iconMaxHeight)
}
} else {
(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).launcherLargeIconSize.let {
Size(it, it)
}
}
fun isDynamicShortcutsAvailable(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 &&
context.getSystemService(ShortcutManager::class.java).maxShortcutCountPerActivity > 0
}
}

View File

@@ -0,0 +1,46 @@
package org.koitharu.kotatsu.core.os
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.security.MessageDigest
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AppValidator @Inject constructor(
@ApplicationContext private val context: Context,
) {
val isOriginalApp by lazy {
getCertificateSHA1Fingerprint() == CERT_SHA1
}
@Suppress("DEPRECATION")
@SuppressLint("PackageManagerGetSignatures")
private fun getCertificateSHA1Fingerprint(): String? = runCatching {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES)
val signatures = requireNotNull(packageInfo?.signatures)
val cert: ByteArray = signatures.first().toByteArray()
val input: InputStream = ByteArrayInputStream(cert)
val cf = CertificateFactory.getInstance("X509")
val c = cf.generateCertificate(input) as X509Certificate
val md: MessageDigest = MessageDigest.getInstance("SHA1")
val publicKey: ByteArray = md.digest(c.encoded)
return publicKey.byte2HexFormatted()
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrNull()
private companion object {
private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE"
}
}

View File

@@ -3,12 +3,11 @@ package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.newParser
fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaParser {
return if (source == MangaSource.DUMMY) {
DummyParser(loaderContext)
} else {
source.newParser(loaderContext)
loaderContext.newParserInstance(source)
}
}
}

View File

@@ -36,6 +36,8 @@ interface MangaRepository {
suspend fun getTags(): Set<MangaTag>
suspend fun getRelated(seed: Manga): List<Manga>
@Singleton
class Factory @Inject constructor(
private val localMangaRepository: LocalMangaRepository,

View File

@@ -1,37 +0,0 @@
package org.koitharu.kotatsu.core.parser
import android.content.Context
import androidx.annotation.ColorRes
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.model.MangaTag
import javax.inject.Inject
@Reusable
class MangaTagHighlighter @Inject constructor(
@ApplicationContext context: Context,
) {
private val dict by lazy {
context.resources.openRawResource(R.raw.tags_redlist).use {
val set = HashSet<String>()
it.bufferedReader().forEachLine { x ->
val line = x.trim()
if (line.isNotEmpty()) {
set.add(line)
}
}
set
}
}
@ColorRes
fun getTint(tag: MangaTag): Int {
return if (tag.title.lowercase() in dict) {
R.color.warning
} else {
0
}
}
}

View File

@@ -51,7 +51,7 @@ class RemoteMangaRepository(
getConfig()[parser.configKeyDomain] = value
}
val headers: Headers?
val headers: Headers
get() = parser.headers
override fun intercept(chain: Interceptor.Chain): Response {
@@ -94,6 +94,15 @@ class RemoteMangaRepository(
suspend fun getFavicons(): Favicons = parser.getFavicons()
override suspend fun getRelated(seed: Manga): List<Manga> {
cache.getRelatedManga(source, seed.url)?.let { return it }
val related = asyncSafe {
parser.getRelatedManga(seed).filterNot { it.id == seed.id }
}
cache.putRelatedManga(source, seed.url, related)
return related.await()
}
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
fun getConfigKeys(): List<ConfigKey<*>> = ArrayList<ConfigKey<*>>().also {

View File

@@ -20,7 +20,9 @@ import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.internal.closeQuietly
import okio.Closeable
import okio.IOException
import okio.buffer
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
@@ -49,22 +51,32 @@ class FaviconFetcher(
override suspend fun fetch(): FetchResult {
getCached(options)?.let { return it }
val repo = mangaRepositoryFactory.create(mangaSource) as RemoteMangaRepository
val favicons = repo.getFavicons()
val sizePx = maxOf(
options.size.width.pxOrElse { FALLBACK_SIZE },
options.size.height.pxOrElse { FALLBACK_SIZE },
)
val icon = checkNotNull(favicons.find(sizePx)) { "No favicons found" }
val response = loadIcon(icon.url, mangaSource)
val responseBody = response.requireBody()
val source = writeToDiskCache(responseBody)?.toImageSource()?.also {
response.closeQuietly()
} ?: responseBody.toImageSource(response)
return SourceResult(
source = source,
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(icon.type),
dataSource = response.toDataSource(),
)
var favicons = repo.getFavicons()
while (favicons.isNotEmpty()) {
val icon = favicons.find(sizePx) ?: throwNSEE()
val response = try {
loadIcon(icon.url, mangaSource)
} catch (e: CloudFlareProtectedException) {
throw e
} catch (e: IOException) {
favicons -= icon
continue
}
val responseBody = response.requireBody()
val source = writeToDiskCache(responseBody)?.toImageSource()?.also {
response.closeQuietly()
} ?: responseBody.toImageSource(response)
return SourceResult(
source = source,
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(icon.type),
dataSource = response.toDataSource(),
)
}
throwNSEE()
}
private suspend fun loadIcon(url: String, source: MangaSource): Response {
@@ -143,6 +155,8 @@ class FaviconFetcher(
append(height.toString())
}
private fun throwNSEE(): Nothing = throw NoSuchElementException("No favicons found")
class Factory(
context: Context,
private val okHttpClient: OkHttpClient,

View File

@@ -8,28 +8,26 @@ import android.os.Build
import android.provider.Settings
import androidx.annotation.FloatRange
import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.arraySetOf
import androidx.collection.ArraySet
import androidx.core.content.edit
import androidx.core.os.LocaleListCompat
import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.BuildConfig
import org.json.JSONArray
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.filterToSet
import org.koitharu.kotatsu.core.util.ext.getEnumValue
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.history.domain.model.HistoryOrder
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.shelf.domain.model.ShelfSection
import java.io.File
import java.net.Proxy
import java.util.Collections
import java.util.EnumSet
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@@ -40,32 +38,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private val connectivityManager = context.connectivityManager
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
remove(MangaSource.LOCAL)
if (!BuildConfig.DEBUG) {
remove(MangaSource.DUMMY)
}
}
val remoteMangaSources: Set<MangaSource>
get() = Collections.unmodifiableSet(remoteSources)
var shelfSections: List<ShelfSection>
get() {
val raw = prefs.getString(KEY_SHELF_SECTIONS, null)
val values = enumValues<ShelfSection>()
if (raw.isNullOrEmpty()) {
return values.toList()
}
return raw.split('|')
.mapNotNull { values.getOrNull(it.toIntOrNull() ?: -1) }
.distinct()
}
set(value) {
val raw = value.joinToString("|") { it.ordinal.toString() }
prefs.edit { putString(KEY_SHELF_SECTIONS, raw) }
}
var listMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
@@ -83,6 +55,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getInt(KEY_GRID_SIZE, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
var isNsfwContentDisabled: Boolean
get() = prefs.getBoolean(KEY_DISABLE_NSFW, false)
set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) }
var appLocales: LocaleListCompat
get() {
val raw = prefs.getString(KEY_APP_LOCALE, null)
@@ -111,6 +87,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isTrackerEnabled: Boolean
get() = prefs.getBoolean(KEY_TRACKER_ENABLED, true)
val isTrackerWifiOnly: Boolean
get() = prefs.getBoolean(KEY_TRACKER_WIFI_ONLY, false)
val isTrackerNotificationsEnabled: Boolean
get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true)
@@ -125,8 +104,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val notificationLight: Boolean
get() = prefs.getBoolean(KEY_NOTIFICATIONS_LIGHT, true)
val readerAnimation: Boolean
get() = prefs.getBoolean(KEY_READER_ANIMATION, false)
val readerAnimation: ReaderAnimation
get() = prefs.getEnumValue(KEY_READER_ANIMATION, ReaderAnimation.DEFAULT)
val readerBackground: ReaderBackground
get() = prefs.getEnumValue(KEY_READER_BACKGROUND, ReaderBackground.DEFAULT)
val defaultReaderMode: ReaderMode
get() = prefs.getEnumValue(KEY_READER_MODE, ReaderMode.STANDARD)
@@ -156,7 +138,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getEnumValue(KEY_ZOOM_MODE, ZoomMode.FIT_CENTER)
val trackSources: Set<String>
get() = prefs.getStringSet(KEY_TRACK_SOURCES, null) ?: arraySetOf(TRACK_FAVOURITES, TRACK_HISTORY)
get() = prefs.getStringSet(KEY_TRACK_SOURCES, null) ?: setOf(TRACK_FAVOURITES)
var appPassword: String?
get() = prefs.getString(KEY_APP_PASSWORD, null)
@@ -190,37 +172,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return policy.isNetworkAllowed(connectivityManager)
}
var sourcesOrder: List<String>
get() = prefs.getString(KEY_SOURCES_ORDER, null)
?.split('|')
.orEmpty()
set(value) = prefs.edit {
putString(KEY_SOURCES_ORDER, value.joinToString("|"))
}
var hiddenSources: Set<String>
get() = prefs.getStringSet(KEY_SOURCES_HIDDEN, null)?.filterToSet { name ->
remoteSources.any { it.name == name }
}.orEmpty()
set(value) = prefs.edit { putStringSet(KEY_SOURCES_HIDDEN, value) }
val isSourcesSelected: Boolean
get() = KEY_SOURCES_HIDDEN in prefs
val newSources: Set<MangaSource>
get() {
val known = sourcesOrder.toSet()
val hidden = hiddenSources
return remoteMangaSources
.filterNotTo(EnumSet.noneOf(MangaSource::class.java)) { x ->
x.name in known || x.name in hidden
}
}
fun markKnownSources(sources: Collection<MangaSource>) {
sourcesOrder = (sourcesOrder + sources.map { it.name }).distinct()
}
var isSourcesGridMode: Boolean
get() = prefs.getBoolean(KEY_SOURCES_GRID, false)
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
@@ -234,14 +185,28 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
if (key == null) ScreenshotsPolicy.ALLOW else ScreenshotsPolicy.valueOf(key)
}.getOrDefault(ScreenshotsPolicy.ALLOW)
var userSpecifiedMangaDirectories: Set<File>
get() {
val set = prefs.getStringSet(KEY_LOCAL_MANGA_DIRS, emptySet()).orEmpty()
return set.mapNotNullToSet { File(it).takeIfReadable() }
}
set(value) {
val set = value.mapToSet { it.absolutePath }
prefs.edit { putStringSet(KEY_LOCAL_MANGA_DIRS, set) }
}
var mangaStorageDir: File?
get() = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
File(it)
}?.takeIf { it.exists() }
}?.takeIf { it.exists() && it in userSpecifiedMangaDirectories }
set(value) = prefs.edit {
if (value == null) {
remove(KEY_LOCAL_STORAGE)
} else {
val userDirs = userSpecifiedMangaDirectories
if (value !in userDirs) {
userSpecifiedMangaDirectories = userDirs + value
}
putString(KEY_LOCAL_STORAGE, value.path)
}
}
@@ -256,11 +221,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) }
val isSuggestionsWiFiOnly: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_WIFI_ONLY, false)
val isSuggestionsExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false)
val isSuggestionsNotificationAvailable: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_NOTIFICATIONS, true)
get() = prefs.getBoolean(KEY_SUGGESTIONS_NOTIFICATIONS, false)
val suggestionsTagsBlacklist: Set<String>
get() {
@@ -308,6 +276,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST)
set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) }
var historySortOrder: HistoryOrder
get() = prefs.getEnumValue(KEY_HISTORY_ORDER, HistoryOrder.UPDATED)
set(value) = prefs.edit { putEnumValue(KEY_HISTORY_ORDER, value) }
val isWebtoonZoomEnable: Boolean
get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true)
@@ -325,20 +297,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return policy.isNetworkAllowed(connectivityManager)
}
fun getMangaSources(includeHidden: Boolean): List<MangaSource> {
val list = remoteSources.toMutableList()
val order = sourcesOrder
list.sortBy { x ->
val e = order.indexOf(x.name)
if (e == -1) order.size + x.ordinal else e
}
if (!includeHidden) {
val hidden = hiddenSources
list.removeAll { x -> x.name in hidden }
}
return list
}
fun isTipEnabled(tip: String): Boolean {
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
}
@@ -361,6 +319,23 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
fun observe() = prefs.observe()
fun getAllValues(): Map<String, *> = prefs.all
fun upsertAll(m: Map<String, *>) {
prefs.edit {
m.forEach { e ->
when (val v = e.value) {
is Boolean -> putBoolean(e.key, v)
is Int -> putInt(e.key, v)
is Long -> putLong(e.key, v)
is Float -> putFloat(e.key, v)
is String -> putString(e.key, v)
is JSONArray -> putStringSet(e.key, v.toStringSet())
}
}
}
}
private fun isBackgroundNetworkRestricted(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED
@@ -369,6 +344,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
}
}
private fun JSONArray.toStringSet(): Set<String> {
val len = length()
val result = ArraySet<String>(len)
for (i in 0 until len) {
result.add(getString(i))
}
return result
}
companion object {
const val PAGE_SWITCH_TAPS = "taps"
@@ -381,8 +365,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_THEME = "theme"
const val KEY_COLOR_THEME = "color_theme"
const val KEY_THEME_AMOLED = "amoled_theme"
const val KEY_SOURCES_ORDER = "sources_order_2"
const val KEY_SOURCES_HIDDEN = "sources_hidden"
const val KEY_TRAFFIC_WARNING = "traffic_warning"
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear"
@@ -395,6 +377,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LOCAL_STORAGE = "local_storage"
const val KEY_READER_SWITCHERS = "reader_switchers"
const val KEY_TRACKER_ENABLED = "tracker_enabled"
const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi"
const val KEY_TRACK_SOURCES = "track_sources"
const val KEY_TRACK_CATEGORIES = "track_categories"
const val KEY_TRACK_WARNING = "track_warning"
@@ -404,7 +387,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
const val KEY_NOTIFICATIONS_LIGHT = "notifications_light"
const val KEY_NOTIFICATIONS_INFO = "tracker_notifications_info"
const val KEY_READER_ANIMATION = "reader_animation"
const val KEY_READER_ANIMATION = "reader_animation2"
const val KEY_READER_MODE = "reader_mode"
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
const val KEY_APP_PASSWORD = "app_password"
@@ -422,6 +405,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SCREENSHOTS_POLICY = "screenshots_policy"
const val KEY_PAGES_PRELOAD = "pages_preload"
const val KEY_SUGGESTIONS = "suggestions"
const val KEY_SUGGESTIONS_WIFI_ONLY = "suggestions_wifi"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
const val KEY_SUGGESTIONS_NOTIFICATIONS = "suggestions_notifications"
@@ -438,11 +422,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SYNC_SETTINGS = "sync_settings"
const val KEY_READER_BAR = "reader_bar"
const val KEY_READER_SLIDER = "reader_slider"
const val KEY_READER_BACKGROUND = "reader_background"
const val KEY_SHORTCUTS = "dynamic_shortcuts"
const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
const val KEY_LOCAL_LIST_ORDER = "local_order"
const val KEY_HISTORY_ORDER = "history_order"
const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
const val KEY_SHELF_SECTIONS = "shelf_sections_2"
const val KEY_PREFETCH_CONTENT = "prefetch_content"
const val KEY_APP_LOCALE = "app_locale"
const val KEY_LOGGING_ENABLED = "logging"
@@ -461,6 +446,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PROXY_LOGIN = "proxy_login"
const val KEY_PROXY_PASSWORD = "proxy_password"
const val KEY_IMAGES_PROXY = "images_proxy"
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
const val KEY_DISABLE_NSFW = "no_nsfw"
// About
const val KEY_APP_UPDATE = "app_update"

View File

@@ -4,6 +4,7 @@ import androidx.annotation.StringRes
import androidx.annotation.StyleRes
import com.google.android.material.color.DynamicColors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.find
enum class ColorScheme(
@StyleRes val styleResId: Int,
@@ -31,7 +32,7 @@ enum class ColorScheme(
}
fun getAvailableList(): List<ColorScheme> {
val list = enumValues<ColorScheme>().toMutableList()
val list = ColorScheme.entries.toMutableList()
if (!DynamicColors.isDynamicColorAvailable()) {
list.remove(MONET)
}
@@ -39,7 +40,7 @@ enum class ColorScheme(
}
fun safeValueOf(name: String): ColorScheme? {
return enumValues<ColorScheme>().find { it.name == name }
return ColorScheme.entries.find(name)
}
}
}

View File

@@ -20,7 +20,7 @@ enum class NetworkPolicy(
fun from(key: String?, default: NetworkPolicy): NetworkPolicy {
val intKey = key?.toIntOrNull() ?: return default
return enumValues<NetworkPolicy>().find { it.key == intKey } ?: default
return NetworkPolicy.entries.find { it.key == intKey } ?: default
}
}
}

View File

@@ -0,0 +1,7 @@
package org.koitharu.kotatsu.core.prefs
enum class ReaderAnimation {
// Do not rename this
NONE, DEFAULT, ADVANCED;
}

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.core.prefs
import android.content.Context
import android.view.ContextThemeWrapper
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toDrawable
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
import com.google.android.material.R as materialR
enum class ReaderBackground {
DEFAULT, LIGHT, DARK, WHITE, BLACK;
fun resolve(context: Context) = when (this) {
DEFAULT -> context.getThemeDrawable(android.R.attr.windowBackground)
LIGHT -> ContextThemeWrapper(context, materialR.style.ThemeOverlay_Material3_Light)
.getThemeDrawable(android.R.attr.windowBackground)
DARK -> ContextThemeWrapper(context, materialR.style.ThemeOverlay_Material3_Dark)
.getThemeDrawable(android.R.attr.windowBackground)
WHITE -> ContextCompat.getColor(context, android.R.color.white).toDrawable()
BLACK -> ContextCompat.getColor(context, android.R.color.black).toDrawable()
}
}

View File

@@ -8,6 +8,6 @@ enum class ReaderMode(val id: Int) {
companion object {
fun valueOf(id: Int) = values().firstOrNull { it.id == id }
fun valueOf(id: Int) = entries.firstOrNull { it.id == id }
}
}
}

View File

@@ -27,6 +27,7 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
.setView(binding.root)
.run(::onBuildDialog)
.create()
.also(::onDialogCreated)
}
final override fun onCreateView(

View File

@@ -45,7 +45,7 @@ abstract class BaseActivity<B : ViewBinding> :
protected val exceptionResolver = ExceptionResolver(this)
@JvmField
protected val insetsDelegate = WindowInsetsDelegate(this)
protected val insetsDelegate = WindowInsetsDelegate()
@JvmField
val actionModeDelegate = ActionModeDelegate()
@@ -62,6 +62,7 @@ abstract class BaseActivity<B : ViewBinding> :
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
insetsDelegate.handleImeInsets = true
insetsDelegate.addInsetsListener(this)
putDataToExtras(intent)
}
@@ -96,7 +97,6 @@ abstract class BaseActivity<B : ViewBinding> :
}
override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) {
@Suppress("DEPRECATION")
onBackPressed()
true
} else super.onOptionsItemSelected(item)

View File

@@ -1,106 +0,0 @@
package org.koitharu.kotatsu.core.ui
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import androidx.activity.OnBackPressedDispatcher
import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.dialog.AppBottomSheetDialog
import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.core.util.ext.getDisplaySize
import com.google.android.material.R as materialR
@Deprecated(
"Use BaseAdaptiveSheet",
replaceWith = ReplaceWith("BaseAdaptiveSheet<B>", "org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet"),
)
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
var viewBinding: B? = null
private set
@Deprecated("", ReplaceWith("requireViewBinding()"))
protected val binding: B
get() = requireViewBinding()
protected val behavior: BottomSheetBehavior<*>?
get() = (dialog as? BottomSheetDialog)?.behavior
val isExpanded: Boolean
get() = behavior?.state == BottomSheetBehavior.STATE_EXPANDED
val onBackPressedDispatcher: OnBackPressedDispatcher
get() = (requireDialog() as AppBottomSheetDialog).onBackPressedDispatcher
final override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val binding = onCreateViewBinding(inflater, container)
viewBinding = binding
return binding.root
}
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val binding = requireViewBinding()
// Enforce max width for tablets
val width = resources.getDimensionPixelSize(R.dimen.bottom_sheet_width)
if (width > 0) {
behavior?.maxWidth = width
}
// Set peek height to 40% display height
binding.root.context.findActivity()?.getDisplaySize()?.let {
behavior?.peekHeight = (it.height() * 0.4).toInt()
}
onViewBindingCreated(binding, savedInstanceState)
}
override fun onDestroyView() {
viewBinding = null
super.onDestroyView()
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return AppBottomSheetDialog(requireContext(), theme)
}
fun addBottomSheetCallback(callback: BottomSheetBehavior.BottomSheetCallback) {
val b = behavior ?: return
b.addBottomSheetCallback(callback)
val rootView = dialog?.findViewById<View>(materialR.id.design_bottom_sheet)
if (rootView != null) {
callback.onStateChanged(rootView, b.state)
}
}
protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B
protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit
protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) {
val b = behavior ?: return
if (isExpanded) {
b.state = BottomSheetBehavior.STATE_EXPANDED
}
b.isFitToContents = !isExpanded
val rootView = dialog?.findViewById<View>(materialR.id.design_bottom_sheet)
rootView?.updateLayoutParams {
height = if (isExpanded) LayoutParams.MATCH_PARENT else LayoutParams.WRAP_CONTENT
}
b.isDraggable = !isLocked
}
fun requireViewBinding(): B = checkNotNull(viewBinding) {
"Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()."
}
}

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