Compare commits

..

188 Commits
v7.3 ... v7.5.2

Author SHA1 Message Date
Koitharu
f09e28e782 Fix applying global color filter (close #1088) 2024-09-06 12:19:59 +03:00
Koitharu
b601b07586 Optimize the Downloaded quick filter 2024-09-06 11:35:23 +03:00
Koitharu
73cea59691 Change pref key for ProxyType 2024-09-06 07:45:48 +03:00
Koitharu
e2993d47b6 Fix closing ZipFile 2024-09-06 07:44:29 +03:00
Koitharu
2cd67e7cf8 Fix crashes 2024-09-06 07:28:17 +03:00
Koitharu
c51da5a9d5 Update parsers 2024-09-05 12:52:03 +03:00
Justine Kyle Cobar
bcfce29610 Translated using Weblate (Filipino)
Currently translated at 100.0% (689 of 689 strings)

Co-authored-by: Justine Kyle Cobar <cobarjustinekyle583@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-09-05 11:03:14 +03:00
maryush
a87d18fae3 Translated using Weblate (Polish)
Currently translated at 99.8% (688 of 689 strings)

Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-09-05 11:03:14 +03:00
gekka
bbd421445c Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (689 of 689 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-09-05 11:03:14 +03:00
Oğuz Ersen
f4e3d797dc Translated using Weblate (Turkish)
Currently translated at 100.0% (689 of 689 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-09-05 11:03:14 +03:00
Denis Bolba
bd65cbb8b8 Translated using Weblate (Romanian)
Currently translated at 12.9% (89 of 689 strings)

Added translation using Weblate (Romanian)

Added translation using Weblate (Romanian)

Co-authored-by: Denis Bolba <bolbadenis4@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ro/
Translation: Kotatsu/Strings
2024-09-05 11:03:14 +03:00
Draken
7d1f81607a Translated using Weblate (Vietnamese)
Currently translated at 100.0% (689 of 689 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-09-05 11:03:14 +03:00
gallegonovato
3b6cd0ea7f Translated using Weblate (Spanish)
Currently translated at 100.0% (689 of 689 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-09-05 11:03:14 +03:00
Koitharu
aff70d8519 Update dependencies 2024-09-05 10:30:52 +03:00
Koitharu
8a74faa4f0 Fix Downloaded quick filter (close #1076, close #1079) 2024-09-05 10:16:09 +03:00
Koitharu
c1ac207809 Fix downloading (close #1072) 2024-09-04 14:33:08 +03:00
Koitharu
e34e745c84 Fix reading saved manga offline (close #1081, close #1071) 2024-09-04 13:41:57 +03:00
Koitharu
50dd119ab5 Fixes 2024-09-03 17:47:36 +03:00
Koitharu
d0ef177d56 Fix sort order direction in filter 2024-09-03 14:52:04 +03:00
Infy's Tagalog Translations
9b9c2e49b9 Translated using Weblate (Filipino)
Currently translated at 100.0% (687 of 687 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-09-03 13:44:57 +03:00
Igor
afeb307453 Translated using Weblate (Ukrainian)
Currently translated at 97.3% (667 of 685 strings)

Co-authored-by: Igor <zerrxs@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2024-09-03 13:44:57 +03:00
Koitharu
7568b1aedc Translated using Weblate (Russian)
Currently translated at 100.0% (685 of 685 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-09-03 13:44:57 +03:00
yoval keshet
742d8cee00 Translated using Weblate (Hebrew)
Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: yoval keshet <keshetyoval@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/he/
Translation: Kotatsu/plurals
2024-09-03 13:44:57 +03:00
gallegonovato
d52bef28ff Translated using Weblate (Spanish)
Currently translated at 100.0% (687 of 687 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (685 of 685 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-09-03 13:44:57 +03:00
Koitharu
b2f48421c7 Sync unstable warning 2024-09-03 13:37:56 +03:00
Koitharu
22643bf9cc Handle scrobbler authorization errors 2024-09-03 11:09:58 +03:00
Koitharu
861ca63ea9 Manual-only new chapters check option 2024-09-02 13:02:25 +03:00
Koitharu
6e34356b6f Fix grayscale color filter (closes ##1065) 2024-09-02 12:43:26 +03:00
Koitharu
a9b3025724 Fix external sources tags filtering 2024-08-31 16:44:28 +03:00
Koitharu
0cc019ef19 Refactor details and reader ViewModels 2024-08-31 14:13:11 +03:00
Koitharu
eb49b31aeb Inverted filter presets 2024-08-31 09:53:32 +03:00
Koitharu
5ebbfd1c00 Refactor alert dialogs 2024-08-31 09:44:42 +03:00
Koitharu
0df67b86f8 Increase suggestions limit 2024-08-31 08:36:17 +03:00
Koitharu
2df567372e Fix empty lists state with Downloaded filter 2024-08-31 08:35:38 +03:00
Koitharu
0fb3c69e10 Fix release build 2024-08-30 11:17:09 +03:00
Koitharu
44900dbcbe Update AGP 2024-08-30 09:19:54 +03:00
Milo Ivir
89cd295f28 Translated using Weblate (Croatian)
Currently translated at 100.0% (685 of 685 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
2024-08-30 08:05:06 +03:00
Draken
d06811d94d Translated using Weblate (Vietnamese)
Currently translated at 100.0% (685 of 685 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-08-30 08:05:06 +03:00
gekka
ced22ebb0a Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (685 of 685 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-08-30 08:05:06 +03:00
Oğuz Ersen
3c2ad26f1d Translated using Weblate (Turkish)
Currently translated at 100.0% (685 of 685 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-08-30 08:05:06 +03:00
Макар Разин
9535e35ba7 Translated using Weblate (Polish)
Currently translated at 99.5% (682 of 685 strings)

Translated using Weblate (Serbian)

Currently translated at 99.5% (682 of 685 strings)

Translated using Weblate (Russian)

Currently translated at 99.8% (684 of 685 strings)

Translated using Weblate (Belarusian)

Currently translated at 99.8% (684 of 685 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/pl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-08-30 08:05:06 +03:00
Infy's Tagalog Translations
298e87dce2 Translated using Weblate (Filipino)
Currently translated at 100.0% (685 of 685 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (678 of 678 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-08-30 08:05:06 +03:00
Kacper Małecki
165ce61ded Translated using Weblate (Polish)
Currently translated at 99.7% (676 of 678 strings)

Co-authored-by: Kacper Małecki <kacperito887@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-08-30 08:05:06 +03:00
Koitharu
c70e3547d1 Support ascending/descending sort orders 2024-08-29 08:10:16 +03:00
Koitharu
05b5953f35 Fix excluding NSFW 2024-08-28 09:24:49 +03:00
Justine Kyle Cobar
9cba6e694a Translated using Weblate (Filipino)
Currently translated at 100.0% (678 of 678 strings)

Co-authored-by: Justine Kyle Cobar <cobarjustinekyle583@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
Lee Khemrinn Phang
b2c73ec9d8 Translated using Weblate (Khmer (Central))
Currently translated at 21.1% (143 of 677 strings)

Translated using Weblate (Khmer (Central))

Currently translated at 13.7% (92 of 671 strings)

Translated using Weblate (Khmer (Central))

Currently translated at 13.5% (91 of 671 strings)

Added translation using Weblate (Khmer (Central))

Added translation using Weblate (Khmer (Central))

Co-authored-by: Lee Khemrinn Phang <Nihil@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/km/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
ALN
cd7620673b Translated using Weblate (Kazakh)
Currently translated at 81.7% (547 of 669 strings)

Translated using Weblate (Kazakh)

Currently translated at 77.7% (7 of 9 strings)

Co-authored-by: ALN <alzhanalan1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/kk/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/kk/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-08-28 09:21:36 +03:00
maryush
09bfb2b0f4 Translated using Weblate (Polish)
Currently translated at 100.0% (669 of 669 strings)

Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
Anon
6dde7e9535 Translated using Weblate (Serbian)
Currently translated at 100.0% (678 of 678 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (667 of 667 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
Laura
98e24072ce Translated using Weblate (Arabic)
Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Laura <hankmaroua@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ar/
Translation: Kotatsu/plurals
2024-08-28 09:21:36 +03:00
Felipe Nascimento
fdc67f8f0e Translated using Weblate (Portuguese)
Currently translated at 100.0% (667 of 667 strings)

Co-authored-by: Felipe Nascimento <f.kgb@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
gallegonovato
f8acabcc86 Translated using Weblate (Spanish)
Currently translated at 100.0% (678 of 678 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (677 of 677 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (671 of 671 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (667 of 667 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
desu sude
5a0771b751 Translated using Weblate (Latvian)
Currently translated at 6.3% (42 of 666 strings)

Added translation using Weblate (Latvian)

Added translation using Weblate (Latvian)

Co-authored-by: desu sude <cobsonslittlecocksleeve@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/lv/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
user _425
6a231f76e1 Translated using Weblate (Indonesian)
Currently translated at 98.4% (656 of 666 strings)

Co-authored-by: user _425 <lelcraft96@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
LinCie
d203edbdae Translated using Weblate (Indonesian)
Currently translated at 98.4% (656 of 666 strings)

Translated using Weblate (Indonesian)

Currently translated at 97.2% (648 of 666 strings)

Co-authored-by: LinCie <aldiofernanda@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
user _425
91a5aa8d4c Translated using Weblate (Indonesian)
Currently translated at 97.2% (648 of 666 strings)

Co-authored-by: user _425 <lelcraft96@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
Ahmed seif al-nasr
40076dea36 Translated using Weblate (Arabic)
Currently translated at 100.0% (666 of 666 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Ahmed seif al-nasr <ahmdsyfalnsr2@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-08-28 09:21:36 +03:00
Eduardo Malaspina
2ab7228727 Translated using Weblate (Spanish)
Currently translated at 100.0% (666 of 666 strings)

Co-authored-by: Eduardo Malaspina <vaio0@swismail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
Eduardo
52e500a5fb Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (665 of 665 strings)

Co-authored-by: Eduardo <edu200399lim@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
Kis Miklós
44734867cc Translated using Weblate (Hungarian)
Currently translated at 93.8% (624 of 665 strings)

Co-authored-by: Kis Miklós <kisbivaly@duck.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hu/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
Макар Разин
6565d05274 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (667 of 667 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (667 of 667 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (667 of 667 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (665 of 665 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (665 of 665 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (665 of 665 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
2024-08-28 09:21:36 +03:00
Henrique
204758cbbb Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.8% (664 of 665 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (665 of 665 strings)

Co-authored-by: Henrique <heluis110@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
Milo Ivir
a60e7e13ca Translated using Weblate (Croatian)
Currently translated at 100.0% (671 of 671 strings)

Translated using Weblate (Croatian)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Croatian)

Currently translated at 100.0% (665 of 665 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
Infy's Tagalog Translations
3596109249 Translated using Weblate (Filipino)
Currently translated at 99.4% (673 of 677 strings)

Translated using Weblate (Filipino)

Currently translated at 99.7% (669 of 671 strings)

Translated using Weblate (Filipino)

Currently translated at 99.8% (668 of 669 strings)

Translated using Weblate (Filipino)

Currently translated at 99.6% (664 of 666 strings)

Translated using Weblate (Filipino)

Currently translated at 99.8% (664 of 665 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
Draken
3c703d9771 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (678 of 678 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (677 of 677 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (671 of 671 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (667 of 667 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (666 of 666 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (666 of 666 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (665 of 665 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (665 of 665 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
Scrambled777
1e87dc4c52 Translated using Weblate (Hindi)
Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (666 of 666 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (665 of 665 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
gekka
ae61d50a6c Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (678 of 678 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (677 of 677 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (671 of 671 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (667 of 667 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (666 of 666 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (665 of 665 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
Oğuz Ersen
63fb40dd65 Translated using Weblate (Turkish)
Currently translated at 100.0% (678 of 678 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (677 of 677 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (671 of 671 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (669 of 669 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (667 of 667 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (666 of 666 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (665 of 665 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
gallegonovato
afe2248bb8 Translated using Weblate (Spanish)
Currently translated at 100.0% (665 of 665 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (664 of 664 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
Anonymous
b3b82ace3f Translated using Weblate (Chinese (Simplified))
Currently translated at 99.8% (663 of 664 strings)

Translated using Weblate (Swedish)

Currently translated at 40.5% (269 of 664 strings)

Translated using Weblate (Finnish)

Currently translated at 38.8% (258 of 664 strings)

Translated using Weblate (Portuguese)

Currently translated at 95.6% (635 of 664 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 43.0% (286 of 664 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sv/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-08-28 09:21:36 +03:00
Koitharu
903fef6791 Apply the Disable NSFW option to all lists (closes #1057) 2024-08-26 17:34:28 +03:00
Koitharu
542ad29cd9 Webtoon reader fixes 2024-08-26 17:00:27 +03:00
Koitharu
d588e8d941 Fix chapters selections 2024-08-26 14:31:39 +03:00
Koitharu
6b786084cf Update parsers 2024-08-24 20:04:05 +03:00
Koitharu
85da41be9a Downloading improvements 2024-08-24 10:10:43 +03:00
Koitharu
6e8a1cd6af Skip nsfw sources for recommendations if disabled 2024-08-20 16:28:39 +03:00
Koitharu
0f28d5de11 Fix switching double-page mode 2024-08-20 15:41:55 +03:00
Koitharu
0d39909d89 Update parsers 2024-08-20 15:32:49 +03:00
Koitharu
e4282a8e9d Improve favorite categories screen (fix #1047) 2024-08-20 15:23:33 +03:00
Koitharu
05a64308ac Dont show favorites from hidden categories in the "All favorites" tab 2024-08-20 13:43:32 +03:00
Koitharu
7b01bafd53 Fix pages downsampling 2024-08-20 13:07:59 +03:00
Koitharu
b521460335 Split url and domain validations #1043 2024-08-20 12:08:51 +03:00
Nicolai Dagestad
249c8377bd Default to http for pre-configured syncs
When a user had configured a sync server before, it had no protocol specified.
This commit "restores" the previous behaviour of using http by default.
2024-08-20 12:03:44 +03:00
Nicolai Dagestad
58cdc9f29a Do not rename the string ressource (+ syntax fix) 2024-08-20 12:03:44 +03:00
Nicolai Dagestad
065beb72e1 First attempt to add https syncing 2024-08-20 12:03:44 +03:00
Koitharu
c00614f17d Improve quick filter behavior 2024-08-20 12:03:20 +03:00
Koitharu
d99bc08e49 Fix release build 2024-08-18 18:13:46 +03:00
Koitharu
9e49b28ac3 Quick filter request refactor 2024-08-18 17:02:15 +03:00
Koitharu
d06b396aec Quick filter for suggestions 2024-08-18 16:03:00 +03:00
Koitharu
65abef1282 Quick filter in feed and updates lists 2024-08-18 14:01:29 +03:00
Koitharu
b66d3ee8d4 Merge branch 'master' into devel 2024-08-17 12:19:55 +03:00
Koitharu
597abdb20c Update parsers and libraries 2024-08-17 11:47:56 +03:00
Koitharu
174fa800be Refactor and cleanup 2024-08-17 09:38:57 +03:00
Koitharu
28670bc7fb Improve favorites dialog 2024-08-15 16:14:24 +03:00
Koitharu
a61e406c91 Fix reading progress indication 2024-08-15 14:46:06 +03:00
Koitharu
20f357cb12 Handle invalid proxy settings 2024-08-15 12:18:41 +03:00
Koitharu
5ba6b81fac Update dependencies 2024-08-15 09:56:18 +03:00
Koitharu
e34bcd47d5 Refactor tracker 2024-08-15 09:34:59 +03:00
Koitharu
62ed8705e8 UI fixes 2024-08-13 08:20:06 +03:00
vianh
de18324798 Hide toolbar for favorites screen only 2024-08-12 19:36:40 +03:00
vianh
a7a943c8dc Hide toolbar when scrolling up 2024-08-12 19:36:40 +03:00
Coding Otaku
6e975b9d66 ix: migrate from kitsu.io to kitsu.app
The [kitsu.io](https://kitsu.io) domain has expired, and the main developers
do not have access to that domain. So they
[migrated](hummingbird-me/kitsu-server#1556)
to the new [kitsu.app](https://kitsu.app) domain.

This commit updates all references of `kitsu.io` to `kitsu.app`.
2024-08-12 18:39:01 +03:00
Koitharu
9e53dc3d5f Merge branch 'master' into devel 2024-08-12 18:35:22 +03:00
Koitharu
809e7d8701 Ability to check proxy connection 2024-08-12 18:22:05 +03:00
Koitharu
0015c5704a Fix ignoring external sources in global search 2024-08-12 17:30:21 +03:00
Koitharu
a7ff1610eb Fix crashes 2024-08-12 17:17:02 +03:00
Koitharu
22c402fc5e Update parers 2024-08-12 16:51:03 +03:00
Koitharu
f3c19f9c02 Add more quick filters 2024-08-12 16:46:07 +03:00
Koitharu
33b4b9fbcb Update incognito mode hint view 2024-08-12 16:07:23 +03:00
Koitharu
00396f2e1b Quick filter for favorites 2024-08-12 15:28:14 +03:00
Koitharu
8b71f99666 Refactor quick filter implementation 2024-08-08 12:10:26 +03:00
Koitharu
d00822a6c3 Quick filter in history draft implementation 2024-08-03 16:22:46 +03:00
Koitharu
6e92d46a63 Update parsers 2024-08-03 15:02:31 +03:00
Koitharu
66ed926ea8 Merge remote-tracking branch 'weblate/devel' into devel 2024-08-03 13:36:12 +03:00
Koitharu
b7741ce2af Allow to use biometric unlock manually (closes #999) 2024-08-03 13:35:28 +03:00
vianh
1a17324d26 Fix reader state not being restored 2024-08-03 12:47:57 +03:00
gekka
4044936481 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (664 of 664 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
Oğuz Ersen
1efe86421a Translated using Weblate (Turkish)
Currently translated at 100.0% (664 of 664 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
gallegonovato
34dd080f6c Translated using Weblate (Spanish)
Currently translated at 100.0% (664 of 664 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
Scrambled777
f4838afab0 Translated using Weblate (Hindi)
Currently translated at 100.0% (664 of 664 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
Anon
b207eebe56 Translated using Weblate (Serbian)
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
Draken
4f454ab438 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
weedyy
1ecf416113 Translated using Weblate (Arabic)
Currently translated at 99.8% (662 of 663 strings)

Co-authored-by: weedyy <huzskywalker@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
TheOneWhoCares
94670a03ff Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: TheOneWhoCares <266nre4gw@mozmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
Макар Разин
e92f165677 Translated using Weblate (Russian)
Currently translated at 100.0% (663 of 663 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (663 of 663 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/
Translation: Kotatsu/Strings
2024-08-03 12:47:46 +03:00
gekka
4a03137a25 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (664 of 664 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (664 of 664 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
Oğuz Ersen
7e6e1fb6de Translated using Weblate (Turkish)
Currently translated at 100.0% (664 of 664 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
gallegonovato
f477797823 Translated using Weblate (Spanish)
Currently translated at 100.0% (664 of 664 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
Scrambled777
125b6740a6 Translated using Weblate (Hindi)
Currently translated at 100.0% (664 of 664 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
Anon
1618a11955 Translated using Weblate (Serbian)
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
Draken
966d6e2383 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
weedyy
2f33a135fc Translated using Weblate (Arabic)
Currently translated at 99.8% (662 of 663 strings)

Co-authored-by: weedyy <huzskywalker@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
TheOneWhoCares
207ea492d5 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: TheOneWhoCares <266nre4gw@mozmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
Макар Разин
250d5432a0 Translated using Weblate (Russian)
Currently translated at 100.0% (663 of 663 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (663 of 663 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/
Translation: Kotatsu/Strings
2024-08-03 11:47:41 +02:00
Koitharu
9768758ecc Optimize external plugin cursor 2024-08-02 12:27:42 +03:00
Koitharu
20852dbd12 Fix query plugin source capabilities 2024-08-01 21:01:40 +03:00
Koitharu
2bc632474d Update parsers 2024-07-30 09:34:36 +03:00
gekka
78fd754d91 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-07-30 09:28:04 +03:00
Oğuz Ersen
bfa0045f1d Translated using Weblate (Turkish)
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-07-30 09:28:04 +03:00
gallegonovato
97e2d58750 Translated using Weblate (Spanish)
Currently translated at 100.0% (663 of 663 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-07-30 09:28:04 +03:00
maryush
ff668931ba Translated using Weblate (Polish)
Currently translated at 100.0% (662 of 662 strings)

Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-07-30 09:28:04 +03:00
weedyy
1c0149afc9 Translated using Weblate (Arabic)
Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Arabic)

Currently translated at 99.3% (658 of 662 strings)

Co-authored-by: weedyy <huzskywalker@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-07-30 09:28:04 +03:00
Draken
12ee3ef497 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/vi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-07-30 09:28:04 +03:00
Koitharu
ae2e38acac Trim description 2024-07-28 14:55:25 +03:00
Koitharu
f25050bce8 Support for manga sources from external APKs 2024-07-28 14:50:41 +03:00
Koitharu
830d500a68 Update dependencies 2024-07-28 07:31:45 +03:00
Anon
960e5d9d29 Translated using Weblate (Serbian)
Currently translated at 100.0% (662 of 662 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-07-27 12:20:41 +03:00
Yoshi Nizar
75b9f27761 Translated using Weblate (Italian)
Currently translated at 99.5% (659 of 662 strings)

Co-authored-by: Yoshi Nizar <canalefinto@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2024-07-27 12:20:41 +03:00
Draken
67af210f07 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/vi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-07-27 12:20:41 +03:00
Koitharu
06cdcac4df Update parsers 2024-07-27 12:13:45 +03:00
Koitharu
10dc1d10ed Dynamic source settings 2024-07-24 15:12:08 +03:00
Koitharu
43c65bf95b Fix global search 2024-07-24 11:40:55 +03:00
Koitharu
cb4ee2dcca Fix manga repository instantiation 2024-07-24 09:43:14 +03:00
Koitharu
bc64a96cc0 Translated using Weblate (Russian)
Currently translated at 100.0% (662 of 662 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
Joe
23dab16afc Translated using Weblate (Belarusian)
Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Joe <happenstance@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/be/
Translation: Kotatsu/plurals
2024-07-23 11:59:13 +03:00
Hosted Weblate
8755106fd2 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
2024-07-23 11:59:13 +03:00
maryush
b2c6c95dbd Translated using Weblate (Polish)
Currently translated at 100.0% (658 of 658 strings)

Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
Lorenzo Stella
20d5fcd54d Translated using Weblate (Italian)
Currently translated at 98.0% (645 of 658 strings)

Co-authored-by: Lorenzo Stella <lorenzo.stella.1408@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
Draken
0d09233b28 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (658 of 658 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (658 of 658 strings)

Translated using Weblate (Vietnamese)

Currently translated at 99.8% (657 of 658 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
weedyy
1f2700de38 Translated using Weblate (Arabic)
Currently translated at 100.0% (658 of 658 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (658 of 658 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (658 of 658 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Arabic)

Currently translated at 88.7% (584 of 658 strings)

Co-authored-by: Hushhush <huzskywalker@gmail.com>
Co-authored-by: weedyy <huzskywalker@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-07-23 11:59:13 +03:00
Ahmed seif al-nasr
d7ebdfbf5a Translated using Weblate (Arabic)
Currently translated at 80.5% (530 of 658 strings)

Co-authored-by: Ahmed seif al-nasr <ahmdsyfalnsr2@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
weedyy
14b70a78ab Translated using Weblate (Arabic)
Currently translated at 78.5% (517 of 658 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Hushhush <huzskywalker@gmail.com>
Co-authored-by: weedyy <huzskywalker@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-07-23 11:59:13 +03:00
LinCie
dd41af8b8e Translated using Weblate (Indonesian)
Currently translated at 98.1% (646 of 658 strings)

Co-authored-by: LinCie <aldiofernanda@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
Eduardo
5b19d61069 Translated using Weblate (Portuguese (Brazil))
Currently translated at 98.6% (649 of 658 strings)

Co-authored-by: Eduardo <edu200399lim@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
Ahmed seif al-nasr
be3e028f5c Translated using Weblate (Arabic)
Currently translated at 70.2% (462 of 658 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Ahmed seif al-nasr <ahmdsyfalnsr2@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-07-23 11:59:13 +03:00
gekka
d231436eb0 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (658 of 658 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (658 of 658 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (658 of 658 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
Infy's Tagalog Translations
4c6276d3f6 Translated using Weblate (Filipino)
Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (658 of 658 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
Scrambled777
583c00d2b7 Translated using Weblate (Hindi)
Currently translated at 100.0% (658 of 658 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
Oğuz Ersen
060ded3915 Translated using Weblate (Turkish)
Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (658 of 658 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
gallegonovato
8482a8746f Translated using Weblate (Spanish)
Currently translated at 100.0% (662 of 662 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (658 of 658 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
Макар Разин
dc12c0e770 Translated using Weblate (Belarusian)
Currently translated at 100.0% (658 of 658 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
2024-07-23 11:59:13 +03:00
Koitharu
6338e89507 Improve covers caching 2024-07-23 11:37:26 +03:00
Koitharu
0f97d29f6a Fix CloudFlare activity crash (close #982) 2024-07-23 10:53:38 +03:00
Koitharu
686f746070 Update parsers 2024-07-23 10:32:38 +03:00
Koitharu
5363719643 Show reverse progress and chapters in lists #904 2024-07-22 18:31:04 +03:00
Koitharu
607785dcd4 Refactor manga list model mapping 2024-07-22 15:02:01 +03:00
Koitharu
c14d39c456 Fix rtl paddings 2024-07-21 07:26:28 +03:00
Koitharu
2c9220090a Fix pinned sources order 2024-07-21 06:31:47 +03:00
Koitharu
b17ef8b6ff Fix sources catalog 2024-07-20 12:27:20 +03:00
Koitharu
6ac96747cf Update dependencies and targetSdk 2024-07-20 12:15:57 +03:00
Koitharu
92c8a13f96 Migrate to LongSet in selection controller 2024-07-15 19:36:11 +03:00
Koitharu
6d07c335de Show sources pinned icons 2024-07-15 17:10:31 +03:00
Koitharu
eba1679761 Animated source placeholder 2024-07-15 16:16:46 +03:00
Koitharu
05b05be0bd Fix dynamic sources 2024-07-15 15:46:48 +03:00
Koitharu
287861f5d7 Merge branch 'devel' into feature/dynamic_sources 2024-07-15 15:15:06 +03:00
Koitharu
4102c4a0ae Show last error in tracker log 2024-07-13 06:16:26 +03:00
Koitharu
0d8820bcab Dynamic sources support 2024-06-29 07:56:18 +03:00
397 changed files with 8603 additions and 4497 deletions

1
.gitignore vendored
View File

@@ -25,3 +25,4 @@
.externalNativeBuild
.cxx
/.idea/deviceManager.xml
/.kotlin/

1
.idea/.gitignore generated vendored
View File

@@ -2,3 +2,4 @@
/shelf/
/workspace.xml
/migrations.xml
/runConfigurations.xml

1
.idea/gradle.xml generated
View File

@@ -4,6 +4,7 @@
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">

View File

@@ -8,16 +8,16 @@ plugins {
}
android {
compileSdk = 34
buildToolsVersion = '34.0.0'
compileSdk = 35
buildToolsVersion = '35.0.0'
namespace = 'org.koitharu.kotatsu'
defaultConfig {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 34
versionCode = 651
versionName = '7.3'
targetSdk = 35
versionCode = 668
versionName = '7.5.2'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
@@ -56,6 +56,7 @@ android {
freeCompilerArgs += [
'-opt-in=kotlin.ExperimentalStdlibApi',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
'-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=coil.annotation.ExperimentalCoilApi',
@@ -82,23 +83,23 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:74b8aaa94e') {
implementation('com.github.KotatsuApp:kotatsu-parsers:ad726a3fd7') {
exclude group: 'org.json', module: 'json'
}
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.10'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0-RC.2'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.activity:activity-ktx:1.9.0'
implementation 'androidx.fragment:fragment-ktx:1.8.1'
implementation 'androidx.transition:transition-ktx:1.5.0'
implementation 'androidx.collection:collection-ktx:1.4.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3'
implementation 'androidx.lifecycle:lifecycle-service:2.8.3'
implementation 'androidx.lifecycle:lifecycle-process:2.8.3'
implementation 'androidx.activity:activity-ktx:1.9.2'
implementation 'androidx.fragment:fragment-ktx:1.8.3'
implementation 'androidx.transition:transition-ktx:1.5.1'
implementation 'androidx.collection:collection-ktx:1.4.3'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.5'
implementation 'androidx.lifecycle:lifecycle-service:2.8.5'
implementation 'androidx.lifecycle:lifecycle-process:2.8.5'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
@@ -106,12 +107,12 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.3'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.5'
implementation 'androidx.webkit:webkit:1.11.0'
implementation 'androidx.work:work-runtime:2.9.0'
implementation 'androidx.work:work-runtime:2.9.1'
//noinspection GradleDependency
implementation('com.google.guava:guava:32.0.1-android') {
implementation('com.google.guava:guava:33.2.1-android') {
exclude group: 'com.google.guava', module: 'failureaccess'
exclude group: 'org.checkerframework', module: 'checker-qual'
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
@@ -129,25 +130,23 @@ dependencies {
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.51.1'
kapt 'com.google.dagger:hilt-compiler:2.51.1'
implementation 'com.google.dagger:hilt-android:2.52'
kapt 'com.google.dagger:hilt-compiler:2.52'
implementation 'androidx.hilt:hilt-work:1.2.0'
kapt 'androidx.hilt:hilt-compiler:1.2.0'
implementation 'io.coil-kt:coil-base:2.6.0'
implementation 'io.coil-kt:coil-svg:2.6.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:882bc0620c'
implementation 'io.coil-kt:coil-base:2.7.0'
implementation 'io.coil-kt:coil-svg:2.7.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:4ec7176962'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'
implementation 'ch.acra:acra-http:5.11.3'
implementation 'ch.acra:acra-dialog:5.11.3'
compileOnly 'com.google.auto.service:auto-service-annotations:1.1.1'
ksp 'dev.zacsweers.autoservice:auto-service-ksp:1.1.0'
implementation 'org.conscrypt:conscrypt-android:2.5.2'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:3.0-alpha-8'
debugImplementation 'com.github.Koitharu:WorkInspector:5778dd1747'
testImplementation 'junit:junit:4.13.2'
@@ -164,6 +163,6 @@ dependencies {
androidTestImplementation 'androidx.room:room-testing:2.6.1'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.51.1'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.51.1'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.52'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.52'
}

View File

@@ -14,6 +14,7 @@
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
-dontwarn com.google.j2objc.annotations.**
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
@@ -21,3 +22,7 @@
-keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }
-keep class org.jsoup.parser.Tag
-keep class org.jsoup.internal.StringUtil
-keep class org.acra.security.NoKeyStoreFactory { *; }
-keep class org.acra.config.DefaultRetryPolicy { *; }
-keep class org.acra.attachment.DefaultAttachmentProvider { *; }

View File

@@ -1,198 +0,0 @@
package org.koitharu.kotatsu.tracker.domain
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import junit.framework.TestCase.*
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.koitharu.kotatsu.SampleData
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class TrackerTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var repository: TrackingRepository
@Inject
lateinit var dataRepository: MangaDataRepository
@Inject
lateinit var tracker: Tracker
@Before
fun setUp() {
hiltRule.inject()
}
@Test
fun noUpdates() = runTest {
val manga = loadManga("full.json")
tracker.deleteTrack(manga.id)
tracker.checkUpdates(manga, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(manga.id))
tracker.checkUpdates(manga, commit = true).apply {
assertTrue(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(manga.id))
}
@Test
fun hasUpdates() = runTest {
val mangaFirst = loadManga("first_chapters.json")
val mangaFull = loadManga("full.json")
tracker.deleteTrack(mangaFirst.id)
tracker.checkUpdates(mangaFirst, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertTrue(isValid)
assertEquals(3, newChapters.size)
}
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertTrue(isValid)
assert(newChapters.isEmpty())
}
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
}
@Test
fun badIds() = runTest {
val mangaFirst = loadManga("first_chapters.json")
val mangaBad = loadManga("bad_ids.json")
tracker.deleteTrack(mangaFirst.id)
tracker.checkUpdates(mangaFirst, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaBad, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaFirst, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
}
@Test
fun badIds2() = runTest {
val mangaFirst = loadManga("first_chapters.json")
val mangaBad = loadManga("bad_ids.json")
val mangaFull = loadManga("full.json")
tracker.deleteTrack(mangaFirst.id)
tracker.checkUpdates(mangaFirst, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertTrue(isValid)
assertEquals(3, newChapters.size)
}
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
tracker.checkUpdates(mangaBad, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
}
@Test
fun fullReset() = runTest {
val mangaFull = loadManga("full.json")
val mangaFirst = loadManga("first_chapters.json")
val mangaEmpty = loadManga("empty.json")
tracker.deleteTrack(mangaFull.id)
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
tracker.checkUpdates(mangaEmpty, commit = true).apply {
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
tracker.checkUpdates(mangaFirst, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertTrue(isValid)
assertEquals(3, newChapters.size)
}
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
tracker.checkUpdates(mangaEmpty, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
}
@Test
fun syncWithHistory() = runTest {
val mangaFull = loadManga("full.json")
val mangaFirst = loadManga("first_chapters.json")
tracker.deleteTrack(mangaFull.id)
tracker.checkUpdates(mangaFirst, commit = true).apply {
assertFalse(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertTrue(isValid)
assertEquals(3, newChapters.size)
}
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
var chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) }
tracker.syncWithHistory(mangaFull, chapter.id)
assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex) }
tracker.syncWithHistory(mangaFull, chapter.id)
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaFull, commit = true).apply {
assertTrue(isValid)
assert(newChapters.isEmpty())
}
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
}
private suspend fun loadManga(name: String): Manga {
val manga = SampleData.loadAsset("manga/$name", Manga::class)
dataRepository.storeManga(manga)
return manga
}
}

View File

@@ -8,6 +8,7 @@ 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.reader.ui.ReaderViewModel
class KotatsuApp : BaseApp() {
@@ -30,6 +31,7 @@ class KotatsuApp : BaseApp() {
.setClassInstanceLimit(PagesCache::class.java, 1)
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
.setClassInstanceLimit(PageLoader::class.java, 1)
.setClassInstanceLimit(ReaderViewModel::class.java, 1)
.penaltyLog()
.build(),
)

View File

@@ -20,6 +20,10 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES"/>
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />

View File

@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.core.util.ext.almostEquals
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
@@ -57,7 +58,7 @@ class AlternativesUseCase @Inject constructor(
}
private suspend fun getSources(ref: MangaSource): List<MangaSource> {
val result = ArrayList<MangaSource>(MangaSource.entries.size - 2)
val result = ArrayList<MangaSource>(MangaParserSource.entries.size - 2)
result.addAll(sourcesRepository.getEnabledSources())
result.sortByDescending { it.priority(ref) }
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
@@ -78,8 +79,10 @@ class AlternativesUseCase @Inject constructor(
private fun MangaSource.priority(ref: MangaSource): Int {
var res = 0
if (locale == ref.locale) res += 2
if (contentType == ref.contentType) res++
if (this is MangaParserSource && ref is MangaParserSource) {
if (locale == ref.locale) res += 2
if (contentType == ref.contentType) res++
}
return res
}
}

View File

@@ -7,8 +7,8 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.history.data.toMangaHistory
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -18,178 +18,178 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import javax.inject.Inject
class MigrateUseCase
@Inject
constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val mangaDataRepository: MangaDataRepository,
private val database: MangaDatabase,
private val progressUpdateUseCase: ProgressUpdateUseCase,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
@Inject
constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val mangaDataRepository: MangaDataRepository,
private val database: MangaDatabase,
private val progressUpdateUseCase: ProgressUpdateUseCase,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
) {
suspend operator fun invoke(
oldManga: Manga,
newManga: Manga,
) {
suspend operator fun invoke(
oldManga: Manga,
newManga: Manga,
) {
val oldDetails =
if (oldManga.chapters.isNullOrEmpty()) {
runCatchingCancellable {
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
}.getOrDefault(oldManga)
} else {
oldManga
}
val newDetails =
if (newManga.chapters.isNullOrEmpty()) {
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
} else {
newManga
}
mangaDataRepository.storeManga(newDetails)
database.withTransaction {
// replace favorites
val favoritesDao = database.getFavouritesDao()
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
if (oldFavourites.isNotEmpty()) {
favoritesDao.delete(oldManga.id)
for (f in oldFavourites) {
val e =
f.copy(
mangaId = newManga.id,
)
favoritesDao.upsert(e)
}
}
// replace history
val historyDao = database.getHistoryDao()
val oldHistory = historyDao.find(oldDetails.id)
val newHistory =
if (oldHistory != null) {
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
historyDao.delete(oldDetails.id)
historyDao.upsert(newHistory)
newHistory
} else {
null
}
// track
val tracksDao = database.getTracksDao()
val oldTrack = tracksDao.find(oldDetails.id)
if (oldTrack != null) {
val lastChapter = newDetails.chapters?.lastOrNull()
val newTrack =
TrackEntity(
mangaId = newDetails.id,
lastChapterId = lastChapter?.id ?: 0L,
newChapters = 0,
lastCheckTime = System.currentTimeMillis(),
lastChapterDate = lastChapter?.uploadDate ?: 0L,
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
lastError = null,
val oldDetails =
if (oldManga.chapters.isNullOrEmpty()) {
runCatchingCancellable {
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
}.getOrDefault(oldManga)
} else {
oldManga
}
val newDetails =
if (newManga.chapters.isNullOrEmpty()) {
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
} else {
newManga
}
mangaDataRepository.storeManga(newDetails)
database.withTransaction {
// replace favorites
val favoritesDao = database.getFavouritesDao()
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
if (oldFavourites.isNotEmpty()) {
favoritesDao.delete(oldManga.id)
for (f in oldFavourites) {
val e =
f.copy(
mangaId = newManga.id,
)
tracksDao.delete(oldDetails.id)
tracksDao.upsert(newTrack)
favoritesDao.upsert(e)
}
// scrobbling
for (scrobbler in scrobblers) {
if (!scrobbler.isEnabled) {
continue
}
val prevInfo = scrobbler.getScrobblingInfoOrNull(oldDetails.id) ?: continue
scrobbler.unregisterScrobbling(oldDetails.id)
scrobbler.linkManga(newDetails.id, prevInfo.targetId)
scrobbler.updateScrobblingInfo(
}
// replace history
val historyDao = database.getHistoryDao()
val oldHistory = historyDao.find(oldDetails.id)
val newHistory =
if (oldHistory != null) {
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
historyDao.delete(oldDetails.id)
historyDao.upsert(newHistory)
newHistory
} else {
null
}
// track
val tracksDao = database.getTracksDao()
val oldTrack = tracksDao.find(oldDetails.id)
if (oldTrack != null) {
val lastChapter = newDetails.chapters?.lastOrNull()
val newTrack =
TrackEntity(
mangaId = newDetails.id,
rating = prevInfo.rating,
status =
prevInfo.status ?: when {
newHistory == null -> ScrobblingStatus.PLANNED
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
else -> ScrobblingStatus.READING
},
comment = prevInfo.comment,
lastChapterId = lastChapter?.id ?: 0L,
newChapters = 0,
lastCheckTime = System.currentTimeMillis(),
lastChapterDate = lastChapter?.uploadDate ?: 0L,
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
lastError = null,
)
if (newHistory != null) {
scrobbler.scrobble(
manga = newDetails,
chapterId = newHistory.chapterId,
)
}
}
tracksDao.delete(oldDetails.id)
tracksDao.upsert(newTrack)
}
progressUpdateUseCase(newManga)
}
private fun makeNewHistory(
oldManga: Manga,
newManga: Manga,
history: HistoryEntity,
): HistoryEntity {
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
val branch = newManga.getPreferredBranch(null)
val chapters = checkNotNull(newManga.getChapters(branch))
val currentChapter =
if (history.percent in 0f..1f) {
chapters[(chapters.lastIndex * history.percent).toInt()]
} else {
chapters.first()
}
return HistoryEntity(
mangaId = newManga.id,
createdAt = history.createdAt,
updatedAt = System.currentTimeMillis(),
chapterId = currentChapter.id,
page = history.page,
scroll = history.scroll,
percent = history.percent,
deletedAt = 0,
chaptersCount = chapters.size,
// scrobbling
for (scrobbler in scrobblers) {
if (!scrobbler.isEnabled) {
continue
}
val prevInfo = scrobbler.getScrobblingInfoOrNull(oldDetails.id) ?: continue
scrobbler.unregisterScrobbling(oldDetails.id)
scrobbler.linkManga(newDetails.id, prevInfo.targetId)
scrobbler.updateScrobblingInfo(
mangaId = newDetails.id,
rating = prevInfo.rating,
status =
prevInfo.status ?: when {
newHistory == null -> ScrobblingStatus.PLANNED
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
else -> ScrobblingStatus.READING
},
comment = prevInfo.comment,
)
}
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
val oldChapters = checkNotNull(oldManga.getChapters(branch))
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
if (index < 0) {
index =
if (history.percent in 0f..1f) {
(oldChapters.lastIndex * history.percent).toInt()
} else {
0
}
}
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
val newBranch =
if (newChapters.containsKey(branch)) {
branch
} else {
newManga.getPreferredBranch(null)
if (newHistory != null) {
scrobbler.scrobble(
manga = newDetails,
chapterId = newHistory.chapterId,
)
}
val newChapterId =
checkNotNull(newChapters[newBranch])
.let {
val oldChapter = oldChapters[index]
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
}.id
}
}
progressUpdateUseCase(newManga)
}
private fun makeNewHistory(
oldManga: Manga,
newManga: Manga,
history: HistoryEntity,
): HistoryEntity {
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
val branch = newManga.getPreferredBranch(null)
val chapters = checkNotNull(newManga.getChapters(branch))
val currentChapter =
if (history.percent in 0f..1f) {
chapters[(chapters.lastIndex * history.percent).toInt()]
} else {
chapters.first()
}
return HistoryEntity(
mangaId = newManga.id,
createdAt = history.createdAt,
updatedAt = System.currentTimeMillis(),
chapterId = newChapterId,
chapterId = currentChapter.id,
page = history.page,
scroll = history.scroll,
percent = PROGRESS_NONE,
percent = history.percent,
deletedAt = 0,
chaptersCount = checkNotNull(newChapters[newBranch]).size,
chaptersCount = chapters.count { it.branch == currentChapter.branch },
)
}
private fun List<MangaChapter>.findByNumber(
volume: Int,
number: Float,
): MangaChapter? =
if (number <= 0f) {
null
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
val oldChapters = checkNotNull(oldManga.getChapters(branch))
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
if (index < 0) {
index =
if (history.percent in 0f..1f) {
(oldChapters.lastIndex * history.percent).toInt()
} else {
0
}
}
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
val newBranch =
if (newChapters.containsKey(branch)) {
branch
} else {
firstOrNull { it.volume == volume && it.number == number }
newManga.getPreferredBranch(null)
}
val newChapterId =
checkNotNull(newChapters[newBranch])
.let {
val oldChapter = oldChapters[index]
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
}.id
return HistoryEntity(
mangaId = newManga.id,
createdAt = history.createdAt,
updatedAt = System.currentTimeMillis(),
chapterId = newChapterId,
page = history.page,
scroll = history.scroll,
percent = PROGRESS_NONE,
deletedAt = 0,
chaptersCount = checkNotNull(newChapters[newBranch]).size,
)
}
private fun List<MangaChapter>.findByNumber(
volume: Int,
number: Float,
): MangaChapter? =
if (number <= 0f) {
null
} else {
firstOrNull { it.volume == volume && it.number == number }
}
}

View File

@@ -7,9 +7,10 @@ import androidx.core.text.inSpans
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import coil.request.ImageRequest
import coil.transform.CircleCropTransformation
import coil.transform.RoundedCornersTransformation
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
@@ -61,9 +62,9 @@ fun alternativeAD(
}
}
}
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
binding.progressView.setProgress(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
binding.chipSource.also { chip ->
chip.text = item.manga.source.title
chip.text = item.manga.source.getTitle(chip.context)
ImageRequest.Builder(context)
.data(item.manga.source.faviconUri())
.lifecycle(lifecycleOwner)
@@ -74,7 +75,7 @@ fun alternativeAD(
.fallback(R.drawable.ic_web)
.error(R.drawable.ic_web)
.source(item.manga.source)
.transformations(CircleCropTransformation())
.transformations(RoundedCornersTransformation(context.resources.getDimension(R.dimen.chip_icon_corner)))
.allowRgb565(true)
.enqueueWith(coil)
}

View File

@@ -9,16 +9,16 @@ import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import coil.ImageLoader
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
@@ -88,22 +88,23 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
}
private fun confirmMigration(target: Manga) {
MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED)
.setIcon(R.drawable.ic_replace)
.setTitle(R.string.manga_migration)
.setMessage(
buildAlertDialog(this, isCentered = true) {
setIcon(R.drawable.ic_replace)
setTitle(R.string.manga_migration)
setMessage(
getString(
R.string.migrate_confirmation,
viewModel.manga.title,
viewModel.manga.source.title,
viewModel.manga.source.getTitle(context),
target.title,
target.source.title,
target.source.getTitle(context),
),
)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.migrate) { _, _ ->
setNegativeButton(android.R.string.cancel, null)
setPositiveButton(R.string.migrate) { _, _ ->
viewModel.migrate(target)
}.show()
}
}.show()
}
companion object {

View File

@@ -15,11 +15,13 @@ import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
@@ -34,7 +36,8 @@ class AlternativesViewModel @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val alternativesUseCase: AlternativesUseCase,
private val migrateUseCase: MigrateUseCase,
private val extraProvider: ListExtraProvider,
private val historyRepository: HistoryRepository,
private val settings: AppSettings,
) : BaseViewModel() {
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
@@ -53,7 +56,7 @@ class AlternativesViewModel @Inject constructor(
.map {
MangaAlternativeModel(
manga = it,
progress = extraProvider.getProgress(it.id),
progress = getProgress(it.id),
referenceChapters = refCount,
)
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
@@ -86,13 +89,7 @@ class AlternativesViewModel @Inject constructor(
}
}
private suspend fun mapList(list: List<Manga>, refCount: Int): List<MangaAlternativeModel> {
return list.map {
MangaAlternativeModel(
manga = it,
progress = extraProvider.getProgress(it.id),
referenceChapters = refCount,
)
}
private suspend fun getProgress(mangaId: Long): ReadingProgress? {
return historyRepository.getProgress(mangaId, settings.progressIndicatorMode)
}
}

View File

@@ -1,12 +1,13 @@
package org.koitharu.kotatsu.alternatives.ui
import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
data class MangaAlternativeModel(
val manga: Manga,
val progress: Float,
val progress: ReadingProgress?,
private val referenceChapters: Int,
) : ListModel {

View File

@@ -46,7 +46,7 @@ class AllBookmarksFragment :
BaseFragment<FragmentListSimpleBinding>(),
ListStateHolderListener,
OnListItemClickListener<Bookmark>,
ListSelectionController.Callback2,
ListSelectionController.Callback,
FastScroller.FastScrollListener, ListHeaderClickListener {
@Inject

View File

@@ -37,6 +37,6 @@ fun bookmarkLargeAD(
source(item.manga.source)
enqueueWith(coil)
}
binding.progressView.percent = item.percent
binding.progressView.setProgress(item.percent, false)
}
}

View File

@@ -12,13 +12,14 @@ import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint
import okhttp3.internal.userAgent
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -42,10 +43,9 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
val userAgent = intent?.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)?.let { source ->
val repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository
repository?.headers?.get(CommonHeaders.USER_AGENT)
}
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
viewBinding.webView.configureForParser(userAgent)
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
viewBinding.webView.webViewClient = BrowserClient(this)
@@ -147,7 +147,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
return Intent(context, BrowserActivity::class.java)
.setData(Uri.parse(url))
.putExtra(EXTRA_TITLE, title)
.putExtra(EXTRA_SOURCE, source)
.putExtra(EXTRA_SOURCE, source?.name)
}
}
}

View File

@@ -14,8 +14,9 @@ import coil.request.ErrorResult
import coil.request.ImageRequest
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
class CaptchaNotifier(
@@ -46,7 +47,7 @@ class CaptchaNotifier(
.setGroup(GROUP_CAPTCHA)
.setAutoCancel(true)
.setVisibility(
if (exception.source?.contentType == ContentType.HENTAI) {
if (exception.source?.isNsfw() == true) {
NotificationCompat.VISIBILITY_SECRET
} else {
NotificationCompat.VISIBILITY_PUBLIC
@@ -55,7 +56,7 @@ class CaptchaNotifier(
.setContentText(
context.getString(
R.string.captcha_required_summary,
exception.source?.title ?: context.getString(R.string.app_name),
exception.source?.getTitle(context) ?: context.getString(R.string.app_name),
),
)
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.browser.cloudflare
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
@@ -28,7 +29,6 @@ import org.koitharu.kotatsu.core.model.MangaSource
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.configureForParser
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -55,7 +55,11 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
val url = intent?.dataString.orEmpty()
val url = intent?.dataString
if (url.isNullOrEmpty()) {
finishAfterTransition()
return
}
cfClient = CloudFlareClient(cookieJar, this, url)
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
viewBinding.webView.webViewClient = cfClient
@@ -63,12 +67,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
onBackPressedDispatcher.addCallback(it)
}
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
if (savedInstanceState != null) {
return
}
if (url.isEmpty()) {
finishAfterTransition()
} else {
if (savedInstanceState == null) {
onTitleChanged(getString(R.string.loading_), url)
viewBinding.webView.loadUrl(url)
}
@@ -181,13 +180,13 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
}
}
class Contract : ActivityResultContract<CloudFlareProtectedException, TaggedActivityResult>() {
class Contract : ActivityResultContract<CloudFlareProtectedException, Boolean>() {
override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent {
return newIntent(context, input)
}
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
return TaggedActivityResult(TAG, resultCode)
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
return resultCode == Activity.RESULT_OK
}
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core
import android.app.Application
import android.content.ContentResolver
import android.content.Context
import android.provider.SearchRecentSuggestions
import android.text.Html
@@ -110,6 +111,8 @@ interface AppModule {
.decoderDispatcher(Dispatchers.IO)
.transformationDispatcher(Dispatchers.Default)
.diskCache(diskCacheFactory)
.respectCacheHeaders(false)
.networkObserverEnabled(false)
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
.allowRgb565(context.isLowRamDevice())
.eventListener(CaptchaNotifier(context))

View File

@@ -1,24 +0,0 @@
package org.koitharu.kotatsu.core
import android.content.Context
import com.google.auto.service.AutoService
import org.acra.builder.ReportBuilder
import org.acra.config.CoreConfiguration
import org.acra.config.ReportingAdministrator
@AutoService(ReportingAdministrator::class)
class ErrorReportingAdmin : ReportingAdministrator {
override fun shouldStartCollecting(
context: Context,
config: CoreConfiguration,
reportBuilder: ReportBuilder
): Boolean {
return reportBuilder.exception?.isDeadOs() != true
}
private fun Throwable.isDeadOs(): Boolean {
val className = javaClass.simpleName
return className == "DeadSystemException" || className == "DeadSystemRuntimeException" || cause?.isDeadOs() == true
}
}

View File

@@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import okhttp3.internal.closeQuietly
import okio.Closeable
import org.json.JSONArray
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
@@ -38,7 +39,7 @@ class BackupZipInput private constructor(val file: File) : Closeable {
fun cleanupAsync() {
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
runCatching {
close()
closeQuietly()
file.delete()
}
}
@@ -46,14 +47,22 @@ class BackupZipInput private constructor(val file: File) : Closeable {
companion object {
fun from(file: File): BackupZipInput = try {
val res = BackupZipInput(file)
if (res.zipFile.getEntry("index") == null) {
throw BadBackupFormatException(null)
fun from(file: File): BackupZipInput {
var res: BackupZipInput? = null
return try {
res = BackupZipInput(file)
if (res.zipFile.getEntry("index") == null) {
throw BadBackupFormatException(null)
}
res
} catch (exception: Exception) {
res?.closeQuietly()
throw if (exception is ZipException) {
BadBackupFormatException(exception)
} else {
exception
}
}
res
} catch (e: ZipException) {
throw BadBackupFormatException(e)
}
}
}

View File

@@ -0,0 +1,113 @@
package org.koitharu.kotatsu.core.db
import androidx.sqlite.db.SimpleSQLiteQuery
import org.koitharu.kotatsu.list.domain.ListFilterOption
import java.util.LinkedList
class MangaQueryBuilder(
private val table: String,
private val conditionCallback: ConditionCallback
) {
private var filterOptions: Collection<ListFilterOption> = emptyList()
private var whereConditions = LinkedList<String>()
private var orderBy: String? = null
private var groupBy: String? = null
private var extraJoins: String? = null
private var limit: Int = 0
fun filters(options: Collection<ListFilterOption>) = apply {
filterOptions = options
}
fun where(condition: String) = apply {
whereConditions.add(condition)
}
fun orderBy(orderBy: String?) = apply {
this@MangaQueryBuilder.orderBy = orderBy
}
fun groupBy(groupBy: String?) = apply {
this@MangaQueryBuilder.groupBy = groupBy
}
fun limit(limit: Int) = apply {
this@MangaQueryBuilder.limit = limit
}
fun join(join: String?) = apply {
extraJoins = join
}
fun build() = buildString {
append("SELECT * FROM ")
append(table)
extraJoins?.let {
append(' ')
append(it)
}
if (whereConditions.isNotEmpty()) {
whereConditions.joinTo(
buffer = this,
prefix = " WHERE ",
separator = " AND ",
)
}
if (filterOptions.isNotEmpty()) {
if (whereConditions.isEmpty()) {
append(" WHERE")
} else {
append(" AND")
}
var isFirst = true
val groupedOptions = filterOptions.groupBy { it.groupKey }
for ((_, group) in groupedOptions) {
if (group.isEmpty()) {
continue
}
if (isFirst) {
isFirst = false
append(' ')
} else {
append(" AND ")
}
if (group.size > 1) {
group.joinTo(
buffer = this,
separator = " OR ",
prefix = "(",
postfix = ")",
transform = ::getConditionOrThrow,
)
} else {
append(getConditionOrThrow(group.single()))
}
}
}
groupBy?.let {
append(" GROUP BY ")
append(it)
}
orderBy?.let {
append(" ORDER BY ")
append(it)
}
if (limit > 0) {
append(" LIMIT ")
append(limit)
}
}.let { SimpleSQLiteQuery(it) }
private fun getConditionOrThrow(option: ListFilterOption): String = when (option) {
is ListFilterOption.Inverted -> "NOT(${getConditionOrThrow(option.option)})"
else -> requireNotNull(conditionCallback.getCondition(option)) {
"Unsupported filter option $option"
}
}
fun interface ConditionCallback {
fun getCondition(option: ListFilterOption): String?
}
}

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 kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
@@ -13,6 +15,9 @@ abstract class PreferencesDao {
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?>
@Query("UPDATE preferences SET cf_brightness = 0, cf_contrast = 0, cf_invert = 0, cf_grayscale = 0")
abstract suspend fun resetColorFilters()
@Upsert
abstract suspend fun upsert(pref: MangaPrefsEntity)
}

View File

@@ -4,36 +4,59 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.MangaQueryBuilder
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
@Dao
interface TrackLogsDao {
abstract class TrackLogsDao : MangaQueryBuilder.ConditionCallback {
@Transaction
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0")
fun observeAll(limit: Int): Flow<List<TrackLogWithManga>>
fun observeAll(
limit: Int,
filterOptions: Set<ListFilterOption>,
): Flow<List<TrackLogWithManga>> = observeAllImpl(
MangaQueryBuilder("track_logs", this)
.filters(filterOptions)
.limit(limit)
.orderBy("created_at DESC")
.build(),
)
@Query("SELECT COUNT(*) FROM track_logs WHERE unread = 1")
fun observeUnreadCount(): Flow<Int>
abstract fun observeUnreadCount(): Flow<Int>
@Query("DELETE FROM track_logs")
suspend fun clear()
abstract suspend fun clear()
@Query("UPDATE track_logs SET unread = 0 WHERE id = :id")
suspend fun markAsRead(id: Long)
abstract suspend fun markAsRead(id: Long)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entity: TrackLogEntity): Long
abstract suspend fun insert(entity: TrackLogEntity): Long
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
suspend fun gc()
abstract suspend fun gc()
@Query("DELETE FROM track_logs WHERE id IN (SELECT id FROM track_logs ORDER BY created_at DESC LIMIT 0 OFFSET :size)")
suspend fun trim(size: Int)
abstract suspend fun trim(size: Int)
@Query("SELECT COUNT(*) FROM track_logs")
suspend fun count(): Int
abstract suspend fun count(): Int
@Transaction
@RawQuery(observedEntities = [TrackLogEntity::class])
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<TrackLogWithManga>>
override fun getCondition(option: ListFilterOption): String? = when (option) {
ListFilterOption.Macro.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE favourites.manga_id = track_logs.manga_id)"
is ListFilterOption.Favorite -> "EXISTS(SELECT * FROM favourites WHERE favourites.manga_id = track_logs.manga_id AND favourites.category_id = ${option.category.id})"
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE manga_tags.manga_id = track_logs.manga_id AND tag_id = ${option.tagId})"
ListFilterOption.Macro.NSFW -> "(SELECT nsfw FROM manga WHERE manga.manga_id = track_logs.manga_id) = 1"
else -> null
}
}

View File

@@ -4,7 +4,7 @@ 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
import org.koitharu.kotatsu.parsers.model.MangaParserSource
class Migration16To17(context: Context) : Migration(16, 17) {
@@ -15,11 +15,8 @@ class Migration16To17(context: Context) : Migration(16, 17) {
db.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
val sources = MangaParserSource.entries
for (source in sources) {
if (source == MangaSource.LOCAL) {
continue
}
val name = source.name
val isHidden = name in hiddenSources
var sortKey = order.indexOf(name)

View File

@@ -0,0 +1,6 @@
package org.koitharu.kotatsu.core.exceptions
class IncompatiblePluginException(
val name: String?,
cause: Throwable?,
) : RuntimeException(cause)

View File

@@ -0,0 +1,5 @@
package org.koitharu.kotatsu.core.exceptions
import java.net.ProtocolException
class ProxyConfigException : ProtocolException("Wrong proxy configuration")

View File

@@ -1,13 +0,0 @@
package org.koitharu.kotatsu.core.exceptions
import okio.IOException
import java.time.Instant
import java.time.temporal.ChronoUnit
class TooManyRequestExceptions(
val url: String,
val retryAt: Instant?,
) : IOException() {
val retryAfter: Long
get() = retryAt?.until(Instant.now(), ChronoUnit.MILLIS) ?: 0
}

View File

@@ -2,67 +2,55 @@ package org.koitharu.kotatsu.core.exceptions.resolve
import android.content.Context
import android.widget.Toast
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.ActivityResultCaller
import androidx.annotation.StringRes
import androidx.collection.MutableScatterMap
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.EntryPointAccessors
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseActivity.BaseActivityEntryPoint
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import java.security.cert.CertPathValidatorException
import javax.inject.Provider
import javax.net.ssl.SSLException
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
class ExceptionResolver @AssistedInject constructor(
@Assisted private val host: Host,
private val settings: AppSettings,
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
) {
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
private val activity: FragmentActivity?
private val fragment: Fragment?
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
private val cloudflareContract: ActivityResultLauncher<CloudFlareProtectedException>
val context: Context?
get() = activity ?: fragment?.context
constructor(activity: FragmentActivity) {
this.activity = activity
fragment = null
sourceAuthContract = activity.registerForActivityResult(SourceAuthActivity.Contract(), this)
cloudflareContract = activity.registerForActivityResult(CloudFlareActivity.Contract(), this)
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
handleActivityResult(SourceAuthActivity.TAG, it)
}
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) {
continuations.remove(result.tag)?.resume(result.isSuccess)
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
handleActivityResult(CloudFlareActivity.TAG, it)
}
fun showDetails(e: Throwable, url: String?) {
ErrorDetailsDialog.show(getFragmentManager(), e, url)
ErrorDetailsDialog.show(host.getChildFragmentManager(), e, url)
}
suspend fun resolve(e: Throwable): Boolean = when (e) {
@@ -74,6 +62,13 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
false
}
is ProxyConfigException -> {
host.withContext {
startActivity(SettingsActivity.newProxySettingsIntent(this))
}
false
}
is NotFoundException -> {
openInBrowser(e.url)
false
@@ -84,6 +79,20 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
false
}
is ScrobblerAuthRequiredException -> {
val authHelper = scrobblerAuthHelperProvider.get()
if (authHelper.isAuthorized(e.scrobbler)) {
true
} else {
host.withContext {
authHelper.startAuth(this, e.scrobbler).onFailure {
showDetails(it, null)
}
}
false
}
}
else -> false
}
@@ -97,21 +106,20 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
sourceAuthContract.launch(source)
}
private fun openInBrowser(url: String) {
context?.run {
startActivity(BrowserActivity.newIntent(this, url, null, null))
}
private fun openInBrowser(url: String) = host.withContext {
startActivity(BrowserActivity.newIntent(this, url, null, null))
}
private fun openAlternatives(manga: Manga) {
context?.run {
startActivity(AlternativesActivity.newIntent(this, manga))
}
private fun openAlternatives(manga: Manga) = host.withContext {
startActivity(AlternativesActivity.newIntent(this, manga))
}
private fun handleActivityResult(tag: String, result: Boolean) {
continuations.remove(tag)?.resume(result)
}
private fun showSslErrorDialog() {
val ctx = context ?: return
val settings = getAppSettings(ctx)
val ctx = host.getContext() ?: return
if (settings.isSSLBypassEnabled) {
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
return
@@ -127,23 +135,38 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
.show()
}
private fun getAppSettings(context: Context): AppSettings {
return EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(context).settings
private inline fun Host.withContext(block: Context.() -> Unit) {
getContext()?.apply(block)
}
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
interface Host : ActivityResultCaller {
fun getChildFragmentManager(): FragmentManager
fun getContext(): Context?
}
@AssistedFactory
interface Factory {
fun create(host: Host): ExceptionResolver
}
companion object {
@StringRes
fun getResolveStringId(e: Throwable) = when (e) {
is CloudFlareProtectedException -> R.string.captcha_solve
is ScrobblerAuthRequiredException,
is AuthRequiredException -> R.string.sign_in
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
is SSLException,
is CertPathValidatorException -> R.string.fix
is ProxyConfigException -> R.string.settings
else -> 0
}

View File

@@ -0,0 +1,31 @@
package org.koitharu.kotatsu.core.model
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.model.SortOrder
enum class GenericSortOrder(
@StringRes val titleResId: Int,
val ascending: SortOrder,
val descending: SortOrder,
) {
UPDATED(R.string.updated, SortOrder.UPDATED_ASC, SortOrder.UPDATED),
RATING(R.string.by_rating, SortOrder.RATING_ASC, SortOrder.RATING),
POPULARITY(R.string.popularity, SortOrder.POPULARITY_ASC, SortOrder.POPULARITY),
DATE(R.string.by_date, SortOrder.NEWEST_ASC, SortOrder.NEWEST),
NAME(R.string.by_name, SortOrder.ALPHABETICAL, SortOrder.ALPHABETICAL_DESC),
;
operator fun get(direction: SortDirection): SortOrder = when (direction) {
SortDirection.ASC -> ascending
SortDirection.DESC -> descending
}
companion object {
fun of(order: SortOrder): GenericSortOrder = entries.first { e ->
e.ascending == order || e.descending == order
}
}
}

View File

@@ -11,7 +11,6 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.formatSimple
import org.koitharu.kotatsu.parsers.util.mapToSet
@@ -109,7 +108,7 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
}
val Manga.isLocal: Boolean
get() = source == MangaSource.LOCAL
get() = source == LocalMangaSource
val Manga.appUrl: Uri
get() = Uri.parse("https://kotatsu.app/manga").buildUpon()

View File

@@ -7,24 +7,47 @@ import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import android.text.style.SuperscriptSpan
import androidx.annotation.StringRes
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.core.util.ext.getDisplayName
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.toLocale
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.splitTwoParts
import com.google.android.material.R as materialR
fun MangaSource(name: String): MangaSource {
MangaSource.entries.forEach {
if (it.name == name) return it
}
return MangaSource.UNKNOWN
data object LocalMangaSource : MangaSource {
override val name = "LOCAL"
}
fun MangaSource.isNsfw() = contentType == ContentType.HENTAI
data object UnknownMangaSource : MangaSource {
override val name = "UNKNOWN"
}
fun MangaSource(name: String?): MangaSource {
when (name ?: return UnknownMangaSource) {
UnknownMangaSource.name -> return UnknownMangaSource
LocalMangaSource.name -> return LocalMangaSource
}
if (name.startsWith("content:")) {
val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownMangaSource
return ExternalMangaSource(packageName = parts.first, authority = parts.second)
}
MangaParserSource.entries.forEach {
if (it.name == name) return it
}
return UnknownMangaSource
}
fun MangaSource.isNsfw(): Boolean = when (this) {
is MangaSourceInfo -> mangaSource.isNsfw()
is MangaParserSource -> contentType == ContentType.HENTAI
else -> false
}
@get:StringRes
val ContentType.titleResId
@@ -35,23 +58,28 @@ val ContentType.titleResId
ContentType.OTHER -> R.string.content_type_other
}
fun MangaSource.getSummary(context: Context): String {
val type = context.getString(contentType.titleResId)
val locale = locale.toLocale().getDisplayName(context)
return context.getString(R.string.source_summary_pattern, type, locale)
}
fun MangaSource.getTitle(context: Context): CharSequence = if (isNsfw()) {
buildSpannedString {
append(title)
append(' ')
appendNsfwLabel(context)
fun MangaSource.getSummary(context: Context): String? = when (this) {
is MangaSourceInfo -> mangaSource.getSummary(context)
is MangaParserSource -> {
val type = context.getString(contentType.titleResId)
val locale = locale.toLocale().getDisplayName(context)
context.getString(R.string.source_summary_pattern, type, locale)
}
} else {
title
is ExternalMangaSource -> context.getString(R.string.external_source)
else -> null
}
private fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
fun MangaSource.getTitle(context: Context): String = when (this) {
is MangaSourceInfo -> mangaSource.getTitle(context)
is MangaParserSource -> title
LocalMangaSource -> context.getString(R.string.local_storage)
is ExternalMangaSource -> resolveName(context)
else -> context.getString(R.string.unknown)
}
fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)),
RelativeSizeSpan(0.74f),
SuperscriptSpan(),

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.core.model
import org.koitharu.kotatsu.parsers.model.MangaSource
data class MangaSourceInfo(
val mangaSource: MangaSource,
val isEnabled: Boolean,
val isPinned: Boolean,
) : MangaSource by mangaSource

View File

@@ -0,0 +1,6 @@
package org.koitharu.kotatsu.core.model
enum class SortDirection {
ASC, DESC;
}

View File

@@ -0,0 +1,15 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import kotlinx.parcelize.Parceler
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaSource
class MangaSourceParceler : Parceler<MangaSource> {
override fun create(parcel: Parcel): MangaSource = MangaSource(parcel.readString())
override fun MangaSource.write(parcel: Parcel, flags: Int) {
parcel.writeString(name)
}
}

View File

@@ -4,9 +4,8 @@ import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
@Parcelize
data class ParcelableChapter(
@@ -25,8 +24,8 @@ data class ParcelableChapter(
scanlator = parcel.readString(),
uploadDate = parcel.readLong(),
branch = parcel.readString(),
source = parcel.readSerializableCompat() ?: MangaSource.UNKNOWN,
)
source = MangaSource(parcel.readString()),
),
)
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
@@ -38,7 +37,7 @@ data class ParcelableChapter(
parcel.writeString(scanlator)
parcel.writeLong(uploadDate)
parcel.writeString(branch)
parcel.writeSerializable(source)
parcel.writeString(source.name)
}
}
}

View File

@@ -5,6 +5,7 @@ import android.os.Parcelable
import androidx.core.os.ParcelCompat
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.Manga
@@ -30,7 +31,7 @@ data class ParcelableManga(
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
parcel.writeSerializable(state)
parcel.writeString(author)
parcel.writeSerializable(source)
parcel.writeString(source.name)
}
override fun create(parcel: Parcel) = ParcelableManga(
@@ -49,8 +50,8 @@ data class ParcelableManga(
state = parcel.readSerializableCompat(),
author = parcel.readString(),
chapters = null,
source = requireNotNull(parcel.readSerializableCompat()),
)
source = MangaSource(parcel.readString()),
),
)
}
}

View File

@@ -5,7 +5,7 @@ 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.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaPage
object MangaPageParceler : Parceler<MangaPage> {
@@ -13,14 +13,14 @@ object MangaPageParceler : Parceler<MangaPage> {
id = parcel.readLong(),
url = requireNotNull(parcel.readString()),
preview = parcel.readString(),
source = requireNotNull(parcel.readSerializableCompat()),
source = MangaSource(parcel.readString()),
)
override fun MangaPage.write(parcel: Parcel, flags: Int) {
parcel.writeLong(id)
parcel.writeString(url)
parcel.writeString(preview)
parcel.writeSerializable(source)
parcel.writeString(source.name)
}
}

View File

@@ -5,20 +5,20 @@ 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.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
object MangaTagParceler : Parceler<MangaTag> {
override fun create(parcel: Parcel) = MangaTag(
title = requireNotNull(parcel.readString()),
key = requireNotNull(parcel.readString()),
source = requireNotNull(parcel.readSerializableCompat()),
source = MangaSource(parcel.readString()),
)
override fun MangaTag.write(parcel: Parcel, flags: Int) {
parcel.writeString(title)
parcel.writeString(key)
parcel.writeSerializable(source)
parcel.writeString(source.name)
}
}

View File

@@ -1,8 +1,9 @@
package org.koitharu.kotatsu.core.network
import okio.IOException
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.ProxySelector
@@ -31,9 +32,12 @@ class AppProxySelector(
val type = settings.proxyType
val address = settings.proxyAddress
val port = settings.proxyPort
if (type == Proxy.Type.DIRECT || address.isNullOrEmpty() || port == 0) {
if (type == Proxy.Type.DIRECT) {
return Proxy.NO_PROXY
}
if (address.isNullOrEmpty() || port == 0) {
throw ProxyConfigException()
}
cachedProxy?.let {
val addr = it.address() as? InetSocketAddress
if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) {

View File

@@ -11,7 +11,7 @@ import okio.IOException
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mergeWith
@@ -30,7 +30,7 @@ class CommonHeadersInterceptor @Inject constructor(
val request = chain.request()
val source = request.tag(MangaSource::class.java)
val repository = if (source != null) {
mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
} else {
if (BuildConfig.DEBUG) {
Log.w("Http", "Request without source tag: ${request.url}")
@@ -38,7 +38,7 @@ class CommonHeadersInterceptor @Inject constructor(
null
}
val headersBuilder = request.headers.newBuilder()
repository?.headers?.let {
repository?.getRequestHeaders()?.let {
headersBuilder.mergeWith(it, replaceExisting = false)
}
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {

View File

@@ -13,8 +13,9 @@ import okhttp3.internal.canParseAsIpAddress
import okhttp3.internal.closeQuietly
import okhttp3.internal.publicsuffix.PublicSuffixDatabase
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.EnumMap
import javax.inject.Inject
@@ -26,8 +27,8 @@ class MirrorSwitchInterceptor @Inject constructor(
private val settings: AppSettings,
) : Interceptor {
private val locks = EnumMap<MangaSource, Any>(MangaSource::class.java)
private val blacklist = EnumMap<MangaSource, MutableSet<String>>(MangaSource::class.java)
private val locks = EnumMap<MangaParserSource, Any>(MangaParserSource::class.java)
private val blacklist = EnumMap<MangaParserSource, MutableSet<String>>(MangaParserSource::class.java)
val isEnabled: Boolean
get() = settings.isMirrorSwitchingAvailable
@@ -53,7 +54,7 @@ class MirrorSwitchInterceptor @Inject constructor(
}
}
suspend fun trySwitchMirror(repository: RemoteMangaRepository): Boolean = runInterruptible(Dispatchers.Default) {
suspend fun trySwitchMirror(repository: ParserMangaRepository): Boolean = runInterruptible(Dispatchers.Default) {
if (!isEnabled) {
return@runInterruptible false
}
@@ -75,14 +76,14 @@ class MirrorSwitchInterceptor @Inject constructor(
}
}
fun rollback(repository: RemoteMangaRepository, oldMirror: String) = synchronized(obtainLock(repository.source)) {
fun rollback(repository: ParserMangaRepository, oldMirror: String) = synchronized(obtainLock(repository.source)) {
blacklist[repository.source]?.remove(oldMirror)
repository.domain = oldMirror
}
private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? {
val source = request.tag(MangaSource::class.java) ?: return null
val repository = mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository ?: return null
val repository = mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository ?: return null
val mirrors = repository.getAvailableMirrors()
if (mirrors.isEmpty()) {
return null
@@ -93,7 +94,7 @@ class MirrorSwitchInterceptor @Inject constructor(
}
private fun tryMirrors(
repository: RemoteMangaRepository,
repository: ParserMangaRepository,
mirrors: List<String>,
chain: Interceptor.Chain,
request: Request,
@@ -145,15 +146,15 @@ class MirrorSwitchInterceptor @Inject constructor(
return source().readByteArray().toResponseBody(contentType())
}
private fun obtainLock(source: MangaSource): Any = locks.getOrPut(source) {
private fun obtainLock(source: MangaParserSource): Any = locks.getOrPut(source) {
Any()
}
private fun isBlacklisted(source: MangaSource, domain: String): Boolean {
private fun isBlacklisted(source: MangaParserSource, domain: String): Boolean {
return blacklist[source]?.contains(domain) == true
}
private fun addToBlacklist(source: MangaSource, domain: String) {
private fun addToBlacklist(source: MangaParserSource, domain: String) {
blacklist.getOrPut(source) {
ArraySet(2)
}.add(domain)

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core.network
import android.content.Context
import android.util.AndroidRuntimeException
import dagger.Binds
import dagger.Module
import dagger.Provides
@@ -19,6 +18,7 @@ import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.imageproxy.RealImageProxyInterceptor
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.local.data.LocalStorageManager
import java.util.concurrent.TimeUnit
import javax.inject.Provider
@@ -40,9 +40,10 @@ interface NetworkModule {
@Singleton
fun provideCookieJar(
@ApplicationContext context: Context
): MutableCookieJar = try {
): MutableCookieJar = runCatching {
AndroidCookieJar()
} catch (e: AndroidRuntimeException) {
}.getOrElse { e ->
e.printStackTraceDebug()
// WebView is not available
PreferencesCookieJar(context)
}

View File

@@ -3,28 +3,27 @@ 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.time.Instant
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.concurrent.TimeUnit
class RateLimitInterceptor : Interceptor {
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,
retryAfter = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryAfter() ?: 0L,
)
}
return response
}
private fun String.parseRetryDate(): Instant? {
return toLongOrNull()?.let { Instant.now().plusSeconds(it) }
?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant()
private fun String.parseRetryAfter(): Long {
return toLongOrNull()?.let { TimeUnit.SECONDS.toMillis(it) }
?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant().toEpochMilli()
}
}

View File

@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -173,9 +174,10 @@ class AppShortcutManager @Inject constructor(
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
)
val title = source.getTitle(context)
ShortcutInfoCompat.Builder(context, source.name)
.setShortLabel(source.title)
.setLongLabel(source.title)
.setShortLabel(title)
.setLongLabel(title)
.setIcon(icon)
.setLongLived(true)
.setIntent(MangaListActivity.newIntent(context, source))

View File

@@ -17,6 +17,9 @@ class NetworkState(
private val callback = NetworkCallbackImpl()
override val value: Boolean
get() = connectivityManager.isOnline(settings)
@Synchronized
override fun onActive() {
invalidate()

View File

@@ -0,0 +1,104 @@
package org.koitharu.kotatsu.core.parser
import android.util.Log
import androidx.collection.MutableLongSet
import coil.request.CachePolicy
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.SafeDeferred
import org.koitharu.kotatsu.core.util.MultiMutex
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
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.util.runCatchingCancellable
abstract class CachingMangaRepository(
private val cache: MemoryContentCache,
) : MangaRepository {
private val detailsMutex = MultiMutex<Long>()
private val relatedMangaMutex = MultiMutex<Long>()
private val pagesMutex = MultiMutex<Long>()
final override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
final override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = pagesMutex.withLock(chapter.id) {
cache.getPages(source, chapter.url)?.let { return it }
val pages = asyncSafe {
getPagesImpl(chapter).distinctById()
}
cache.putPages(source, chapter.url, pages)
pages
}.await()
final override suspend fun getRelated(seed: Manga): List<Manga> = relatedMangaMutex.withLock(seed.id) {
cache.getRelatedManga(source, seed.url)?.let { return it }
val related = asyncSafe {
getRelatedMangaImpl(seed).filterNot { it.id == seed.id }
}
cache.putRelatedManga(source, seed.url, related)
related
}.await()
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga = detailsMutex.withLock(manga.id) {
if (cachePolicy.readEnabled) {
cache.getDetails(source, manga.url)?.let { return it }
}
val details = asyncSafe {
getDetailsImpl(manga)
}
if (cachePolicy.writeEnabled) {
cache.putDetails(source, manga.url, details)
}
details
}.await()
suspend fun peekDetails(manga: Manga): Manga? {
return cache.getDetails(source, manga.url)
}
fun invalidateCache() {
cache.clear(source)
}
protected abstract suspend fun getDetailsImpl(manga: Manga): Manga
protected abstract suspend fun getRelatedMangaImpl(seed: Manga): List<Manga>
protected abstract suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage>
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
dispatcher = Dispatchers.Default
}
return SafeDeferred(
processLifecycleScope.async(dispatcher) {
runCatchingCancellable { block() }
},
)
}
private fun List<MangaPage>.distinctById(): List<MangaPage> {
if (isEmpty()) {
return emptyList()
}
val result = ArrayList<MangaPage>(size)
val set = MutableLongSet(size)
for (page in this) {
if (set.add(page.id)) {
result.add(page)
} else if (BuildConfig.DEBUG) {
Log.w(null, "Duplicate page: $page")
}
}
return result
}
}

View File

@@ -8,7 +8,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet
@@ -16,7 +16,7 @@ import java.util.EnumSet
/**
* This parser is just for parser development, it should not be used in releases
*/
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParserSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("localhost")

View File

@@ -1,38 +1,49 @@
package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet
import java.util.Locale
/**
* This parser is just for parser development, it should not be used in releases
*/
class EmptyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("localhost")
override val availableSortOrders: Set<SortOrder>
override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
override val states: Set<MangaState>
get() = emptySet()
override val contentRatings: Set<ContentRating>
get() = emptySet()
override var defaultSortOrder: SortOrder
get() = SortOrder.NEWEST
set(value) = Unit
override val isMultipleTagsSupported: Boolean
get() = false
override val isTagsExclusionSupported: Boolean
get() = false
override val isSearchSupported: Boolean
get() = false
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
override suspend fun getAvailableTags(): Set<MangaTag> = stub(null)
override suspend fun getPageUrl(page: MangaPage): String = stub(null)
override suspend fun getRelatedManga(seed: Manga): List<Manga> = stub(seed)
override suspend fun getTags(): Set<MangaTag> = stub(null)
override suspend fun getLocales(): Set<Locale> = stub(null)
override suspend fun getRelated(seed: Manga): List<Manga> = stub(seed)
private fun stub(manga: Manga?): Nothing {
throw UnsupportedSourceException("This manga source is not supported", manga)

View File

@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
@@ -45,11 +46,16 @@ class MangaDataRepository @Inject constructor(
cfBrightness = colorFilter?.brightness ?: 0f,
cfContrast = colorFilter?.contrast ?: 0f,
cfInvert = colorFilter?.isInverted ?: false,
cfGrayscale = colorFilter?.isGrayscale ?: false,
),
)
}
}
suspend fun resetColorFilters() {
db.getPreferencesDao().resetColorFilters()
}
suspend fun getReaderMode(mangaId: Long): ReaderMode? {
return db.getPreferencesDao().find(mangaId)?.let { ReaderMode.valueOf(it.mode) }
}
@@ -101,7 +107,7 @@ class MangaDataRepository @Inject constructor(
suspend fun cleanupLocalManga() {
val dao = db.getMangaDao()
val broken = dao.findAllBySource(MangaSource.LOCAL.name)
val broken = dao.findAllBySource(LocalMangaSource.name)
.filter { x -> x.manga.url.toUri().toFileOrNull()?.exists() == false }
if (broken.isNotEmpty()) {
dao.delete(broken.map { it.manga })

View File

@@ -4,10 +4,11 @@ import android.net.Uri
import coil.request.CachePolicy
import dagger.Reusable
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -36,7 +37,7 @@ class MangaLinkResolver @Inject constructor(
require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" }
val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" }
val source = MangaSource(sourceName)
require(source != MangaSource.UNKNOWN) { "Manga source $sourceName is not supported" }
require(source != UnknownMangaSource) { "Manga source $sourceName is not supported" }
val repo = repositoryFactory.create(source)
return repo.findExact(
url = uri.getQueryParameter("url"),
@@ -51,7 +52,7 @@ class MangaLinkResolver @Inject constructor(
val host = uri.host ?: return null
val repo = sourcesRepository.allMangaSources.asSequence()
.map { source ->
repositoryFactory.create(source) as RemoteMangaRepository
repositoryFactory.create(source) as ParserMangaRepository
}.find { repo ->
host in repo.domains
} ?: return null
@@ -85,7 +86,7 @@ class MangaLinkResolver @Inject constructor(
}
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
return if (this is RemoteMangaRepository) {
return if (this is ParserMangaRepository) {
getDetails(manga, CachePolicy.READ_ONLY)
} else {
getDetails(manga)
@@ -108,7 +109,7 @@ class MangaLinkResolver @Inject constructor(
url = url,
publicUrl = "",
rating = 0.0f,
isNsfw = source.contentType == ContentType.HENTAI,
isNsfw = source.isNsfw(),
coverUrl = "",
tags = emptySet(),
state = null,

View File

@@ -2,12 +2,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.model.MangaParserSource
fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaParser {
fun MangaParser(source: MangaParserSource, loaderContext: MangaLoaderContext): MangaParser {
return when (source) {
MangaSource.UNKNOWN -> EmptyParser(loaderContext)
MangaSource.DUMMY -> DummyParser(loaderContext)
MangaParserSource.DUMMY -> DummyParser(loaderContext)
else -> loaderContext.newParserInstance(source)
}
}

View File

@@ -1,8 +1,16 @@
package org.koitharu.kotatsu.core.parser
import android.content.Context
import androidx.annotation.AnyThread
import androidx.collection.ArrayMap
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.ContentRating
@@ -10,12 +18,12 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.lang.ref.WeakReference
import java.util.EnumMap
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@@ -53,32 +61,60 @@ interface MangaRepository {
suspend fun getRelated(seed: Manga): List<Manga>
suspend fun find(manga: Manga): Manga? {
val list = getList(0, MangaListFilter.Search(manga.title))
return list.find { x -> x.id == manga.id }
}
@Singleton
class Factory @Inject constructor(
@ApplicationContext private val context: Context,
private val localMangaRepository: LocalMangaRepository,
private val loaderContext: MangaLoaderContext,
private val contentCache: MemoryContentCache,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
) {
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
private val cache = ArrayMap<MangaSource, WeakReference<MangaRepository>>()
@AnyThread
fun create(source: MangaSource): MangaRepository {
if (source == MangaSource.LOCAL) {
return localMangaRepository
when (source) {
is MangaSourceInfo -> return create(source.mangaSource)
LocalMangaSource -> return localMangaRepository
UnknownMangaSource -> return EmptyMangaRepository(source)
}
cache[source]?.get()?.let { return it }
return synchronized(cache) {
cache[source]?.get()?.let { return it }
val repository = RemoteMangaRepository(
parser = MangaParser(source, loaderContext),
cache = contentCache,
mirrorSwitchInterceptor = mirrorSwitchInterceptor,
)
cache[source] = WeakReference(repository)
repository
val repository = createRepository(source)
if (repository != null) {
cache[source] = WeakReference(repository)
repository
} else {
EmptyMangaRepository(source)
}
}
}
private fun createRepository(source: MangaSource): MangaRepository? = when (source) {
is MangaParserSource -> ParserMangaRepository(
parser = MangaParser(source, loaderContext),
cache = contentCache,
mirrorSwitchInterceptor = mirrorSwitchInterceptor,
)
is ExternalMangaSource -> if (source.isAvailable(context)) {
ExternalMangaRepository(
contentResolver = context.contentResolver,
source = source,
cache = contentCache,
)
} else {
EmptyMangaRepository(source)
}
else -> null
}
}
}

View File

@@ -1,24 +1,10 @@
package org.koitharu.kotatsu.core.parser
import android.util.Log
import androidx.collection.MutableLongSet
import coil.request.CachePolicy
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.SafeDeferred
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.MultiMutex
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey
@@ -28,7 +14,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -36,17 +22,13 @@ import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Locale
class RemoteMangaRepository(
class ParserMangaRepository(
private val parser: MangaParser,
private val cache: MemoryContentCache,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
) : MangaRepository, Interceptor {
cache: MemoryContentCache,
) : CachingMangaRepository(cache), Interceptor {
private val detailsMutex = MultiMutex<Long>()
private val relatedMangaMutex = MultiMutex<Long>()
private val pagesMutex = MultiMutex<Long>()
override val source: MangaSource
override val source: MangaParserSource
get() = parser.source
override val sortOrders: Set<SortOrder>
@@ -82,9 +64,6 @@ class RemoteMangaRepository(
val domains: Array<out String>
get() = parser.configKeyDomain.presetValues
val headers: Headers
get() = parser.headers
override fun intercept(chain: Interceptor.Chain): Response {
return if (parser is Interceptor) {
parser.intercept(chain)
@@ -99,18 +78,11 @@ class RemoteMangaRepository(
}
}
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = pagesMutex.withLock(chapter.id) {
cache.getPages(source, chapter.url)?.let { return it }
val pages = asyncSafe {
mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPages(chapter).distinctById()
}
}
cache.putPages(source, chapter.url, pages)
pages
}.await()
override suspend fun getPagesImpl(
chapter: MangaChapter
): List<MangaPage> = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPages(chapter)
}
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPageUrl(page)
@@ -128,41 +100,16 @@ class RemoteMangaRepository(
parser.getFavicons()
}
override suspend fun getRelated(seed: Manga): List<Manga> = relatedMangaMutex.withLock(seed.id) {
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)
related
}.await()
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = parser.getRelatedManga(seed)
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga = detailsMutex.withLock(manga.id) {
if (cachePolicy.readEnabled) {
cache.getDetails(source, manga.url)?.let { return it }
}
val details = asyncSafe {
mirrorSwitchInterceptor.withMirrorSwitching {
parser.getDetails(manga)
}
}
if (cachePolicy.writeEnabled) {
cache.putDetails(source, manga.url, details)
}
details
}.await()
suspend fun peekDetails(manga: Manga): Manga? {
return cache.getDetails(source, manga.url)
}
suspend fun find(manga: Manga): Manga? {
val list = getList(0, MangaListFilter.Search(manga.title))
return list.find { x -> x.id == manga.id }
override suspend fun getDetailsImpl(manga: Manga): Manga = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getDetails(manga)
}
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
fun getRequestHeaders() = parser.getRequestHeaders()
fun getConfigKeys(): List<ConfigKey<*>> = ArrayList<ConfigKey<*>>().also {
parser.onCreateConfig(it)
}
@@ -175,40 +122,8 @@ class RemoteMangaRepository(
return getConfig().isSlowdownEnabled
}
fun invalidateCache() {
cache.clear(source)
}
fun getConfig() = parser.config as SourceSettings
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
dispatcher = Dispatchers.Default
}
return SafeDeferred(
processLifecycleScope.async(dispatcher) {
runCatchingCancellable { block() }
},
)
}
private fun List<MangaPage>.distinctById(): List<MangaPage> {
if (isEmpty()) {
return emptyList()
}
val result = ArrayList<MangaPage>(size)
val set = MutableLongSet(size)
for (page in this) {
if (set.add(page.id)) {
result.add(page)
} else if (BuildConfig.DEBUG) {
Log.w(null, "Duplicate page: $page")
}
}
return result
}
private suspend fun <R> MirrorSwitchInterceptor.withMirrorSwitching(block: suspend () -> R): R {
if (!isEnabled) {
return block()
@@ -220,14 +135,14 @@ class RemoteMangaRepository(
if (result.isValidResult()) {
return result.getOrThrow()
}
return if (trySwitchMirror(this@RemoteMangaRepository)) {
return if (trySwitchMirror(this@ParserMangaRepository)) {
val newResult = runCatchingCancellable {
block()
}
if (newResult.isValidResult()) {
return newResult.getOrThrow()
} else {
rollback(this@RemoteMangaRepository, initialMirror)
rollback(this@ParserMangaRepository, initialMirror)
return result.getOrThrow()
}
} else {

View File

@@ -0,0 +1,80 @@
package org.koitharu.kotatsu.core.parser.external
import android.content.ContentResolver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet
import java.util.Locale
class ExternalMangaRepository(
private val contentResolver: ContentResolver,
override val source: ExternalMangaSource,
cache: MemoryContentCache,
) : CachingMangaRepository(cache) {
private val contentSource = ExternalPluginContentSource(contentResolver, source)
private val capabilities by lazy {
runCatching {
contentSource.getCapabilities()
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
}
override val sortOrders: Set<SortOrder>
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL)
override val states: Set<MangaState>
get() = capabilities?.availableStates.orEmpty()
override val contentRatings: Set<ContentRating>
get() = capabilities?.availableContentRating.orEmpty()
override var defaultSortOrder: SortOrder
get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL
set(value) = Unit
override val isMultipleTagsSupported: Boolean
get() = capabilities?.isMultipleTagsSupported ?: true
override val isTagsExclusionSupported: Boolean
get() = capabilities?.isTagsExclusionSupported ?: false
override val isSearchSupported: Boolean
get() = capabilities?.isSearchSupported ?: true
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> =
runInterruptible(Dispatchers.IO) {
contentSource.getList(offset, filter)
}
override suspend fun getDetailsImpl(manga: Manga): Manga = runInterruptible(Dispatchers.IO) {
contentSource.getDetails(manga)
}
override suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
contentSource.getPages(chapter)
}
override suspend fun getPageUrl(page: MangaPage): String = page.url // TODO
override suspend fun getTags(): Set<MangaTag> = runInterruptible(Dispatchers.IO) {
contentSource.getTags()
}
override suspend fun getLocales(): Set<Locale> = emptySet() // TODO
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO
}

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.core.parser.external
import android.content.Context
import org.koitharu.kotatsu.parsers.model.MangaSource
data class ExternalMangaSource(
val packageName: String,
val authority: String,
) : MangaSource {
override val name: String
get() = "content:$packageName/$authority"
private var cachedName: String? = null
fun isAvailable(context: Context): Boolean {
return context.packageManager.resolveContentProvider(authority, 0)?.isEnabled == true
}
fun resolveName(context: Context): String {
cachedName?.let {
return it
}
val pm = context.packageManager
val info = pm.resolveContentProvider(authority, 0)
return info?.loadLabel(pm)?.toString()?.also {
cachedName = it
} ?: authority
}
}

View File

@@ -0,0 +1,286 @@
package org.koitharu.kotatsu.core.parser.external
import android.content.ContentResolver
import android.database.Cursor
import androidx.annotation.WorkerThread
import androidx.collection.ArraySet
import androidx.core.net.toUri
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.toLocale
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.splitTwoParts
import java.util.EnumSet
import java.util.Locale
class ExternalPluginContentSource(
private val contentResolver: ContentResolver,
private val source: ExternalMangaSource,
) {
@Blocking
@WorkerThread
fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
val uri = "content://${source.authority}/manga".toUri().buildUpon()
uri.appendQueryParameter("offset", offset.toString())
when (filter) {
is MangaListFilter.Advanced -> {
filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") }
filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") }
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
}
is MangaListFilter.Search -> {
uri.appendQueryParameter("query", filter.query)
}
null -> Unit
}
return contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name)
.safe()
.use { cursor ->
val result = ArrayList<Manga>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += cursor.getManga()
} while (cursor.moveToNext())
}
result
}
}
@Blocking
@WorkerThread
fun getDetails(manga: Manga): Manga {
val chapters = queryChapters(manga.url)
val details = queryDetails(manga.url)
return Manga(
id = manga.id,
title = details.title.ifBlank { manga.title },
altTitle = details.altTitle.ifNullOrEmpty { manga.altTitle },
url = details.url.ifEmpty { manga.url },
publicUrl = details.publicUrl.ifEmpty { manga.publicUrl },
rating = maxOf(details.rating, manga.rating),
isNsfw = details.isNsfw,
coverUrl = details.coverUrl.ifEmpty { manga.coverUrl },
tags = details.tags + manga.tags,
state = details.state ?: manga.state,
author = details.author.ifNullOrEmpty { manga.author },
largeCoverUrl = details.largeCoverUrl.ifNullOrEmpty { manga.largeCoverUrl },
description = details.description.ifNullOrEmpty { manga.description },
chapters = chapters,
source = source,
)
}
@Blocking
@WorkerThread
fun getPages(chapter: MangaChapter): List<MangaPage> {
val uri = "content://${source.authority}/chapters".toUri()
.buildUpon()
.appendPath(chapter.url)
.build()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
val result = ArrayList<MangaPage>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaPage(
id = cursor.getLong(COLUMN_ID),
url = cursor.getString(COLUMN_URL),
preview = cursor.getStringOrNull(COLUMN_PREVIEW),
source = source,
)
} while (cursor.moveToNext())
}
result
}
}
@Blocking
@WorkerThread
fun getTags(): Set<MangaTag> {
val uri = "content://${source.authority}/tags".toUri()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
val result = ArraySet<MangaTag>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaTag(
key = cursor.getString(COLUMN_KEY),
title = cursor.getString(COLUMN_TITLE),
source = source,
)
} while (cursor.moveToNext())
}
result
}
}
fun getCapabilities(): MangaSourceCapabilities? {
val uri = "content://${source.authority}/capabilities".toUri()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
if (cursor.moveToFirst()) {
MangaSourceCapabilities(
availableSortOrders = cursor.getStringOrNull(COLUMN_SORT_ORDERS)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) {
SortOrder.entries.find(it)
}.orEmpty(),
availableStates = cursor.getStringOrNull(COLUMN_STATES)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(MangaState::class.java)) {
MangaState.entries.find(it)
}.orEmpty(),
availableContentRating = cursor.getStringOrNull(COLUMN_CONTENT_RATING)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(ContentRating::class.java)) {
ContentRating.entries.find(it)
}.orEmpty(),
isMultipleTagsSupported = cursor.getBooleanOrDefault(COLUMN_MULTIPLE_TAGS_SUPPORTED, true),
isTagsExclusionSupported = cursor.getBooleanOrDefault(COLUMN_TAGS_EXCLUSION_SUPPORTED, false),
isSearchSupported = cursor.getBooleanOrDefault(COLUMN_SEARCH_SUPPORTED, true),
contentType = cursor.getStringOrNull(COLUMN_CONTENT_TYPE)?.let {
ContentType.entries.find(it)
} ?: ContentType.OTHER,
defaultSortOrder = cursor.getStringOrNull(COLUMN_DEFAULT_SORT_ORDER)?.let {
SortOrder.entries.find(it)
} ?: SortOrder.ALPHABETICAL,
sourceLocale = cursor.getStringOrNull(COLUMN_LOCALE)?.toLocale() ?: Locale.ROOT,
)
} else {
null
}
}
}
private fun queryDetails(url: String): Manga {
val uri = "content://${source.authority}/manga".toUri()
.buildUpon()
.appendPath(url)
.build()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
cursor.moveToFirst()
cursor.getManga()
}
}
private fun queryChapters(url: String): List<MangaChapter> {
val uri = "content://${source.authority}/manga/chapters".toUri()
.buildUpon()
.appendPath(url)
.build()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
val result = ArrayList<MangaChapter>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaChapter(
id = cursor.getLong(COLUMN_ID),
name = cursor.getString(COLUMN_NAME),
number = cursor.getFloatOrDefault(COLUMN_NUMBER, 0f),
volume = cursor.getIntOrDefault(COLUMN_VOLUME, 0),
url = cursor.getString(COLUMN_URL),
scanlator = cursor.getStringOrNull(COLUMN_SCANLATOR),
uploadDate = cursor.getLongOrDefault(COLUMN_UPLOAD_DATE, 0L),
branch = cursor.getStringOrNull(COLUMN_BRANCH),
source = source,
)
} while (cursor.moveToNext())
}
result
}
}
private fun ExternalPluginCursor.getManga() = Manga(
id = getLong(COLUMN_ID),
title = getString(COLUMN_TITLE),
altTitle = getStringOrNull(COLUMN_ALT_TITLE),
url = getString(COLUMN_URL),
publicUrl = getString(COLUMN_PUBLIC_URL),
rating = getFloat(COLUMN_RATING),
isNsfw = getBooleanOrDefault(COLUMN_IS_NSFW, false),
coverUrl = getString(COLUMN_COVER_URL),
tags = getStringOrNull(COLUMN_TAGS)?.split(':')?.mapNotNullToSet {
val parts = it.splitTwoParts('=') ?: return@mapNotNullToSet null
MangaTag(key = parts.first, title = parts.second, source = source)
}.orEmpty(),
state = getStringOrNull(COLUMN_STATE)?.let { MangaState.entries.find(it) },
author = getStringOrNull(COLUMN_AUTHOR),
largeCoverUrl = getStringOrNull(COLUMN_LARGE_COVER_URL),
description = getStringOrNull(COLUMN_DESCRIPTION),
chapters = emptyList(),
source = source,
)
private fun Cursor?.safe() = ExternalPluginCursor(
source = source,
cursor = this ?: throw IncompatiblePluginException(source.name, null),
)
class MangaSourceCapabilities(
val availableSortOrders: Set<SortOrder>,
val availableStates: Set<MangaState>,
val availableContentRating: Set<ContentRating>,
val isMultipleTagsSupported: Boolean,
val isTagsExclusionSupported: Boolean,
val isSearchSupported: Boolean,
val contentType: ContentType,
val defaultSortOrder: SortOrder,
val sourceLocale: Locale,
)
private companion object {
const val COLUMN_SORT_ORDERS = "sort_orders"
const val COLUMN_STATES = "states"
const val COLUMN_CONTENT_RATING = "content_rating"
const val COLUMN_MULTIPLE_TAGS_SUPPORTED = "multiple_tags_supported"
const val COLUMN_TAGS_EXCLUSION_SUPPORTED = "tags_exclusion_supported"
const val COLUMN_SEARCH_SUPPORTED = "search_supported"
const val COLUMN_CONTENT_TYPE = "content_type"
const val COLUMN_DEFAULT_SORT_ORDER = "default_sort_order"
const val COLUMN_LOCALE = "locale"
const val COLUMN_ID = "id"
const val COLUMN_NAME = "name"
const val COLUMN_NUMBER = "number"
const val COLUMN_VOLUME = "volume"
const val COLUMN_URL = "url"
const val COLUMN_SCANLATOR = "scanlator"
const val COLUMN_UPLOAD_DATE = "upload_date"
const val COLUMN_BRANCH = "branch"
const val COLUMN_TITLE = "title"
const val COLUMN_ALT_TITLE = "alt_title"
const val COLUMN_PUBLIC_URL = "public_url"
const val COLUMN_RATING = "rating"
const val COLUMN_IS_NSFW = "is_nsfw"
const val COLUMN_COVER_URL = "cover_url"
const val COLUMN_TAGS = "tags"
const val COLUMN_STATE = "state"
const val COLUMN_AUTHOR = "author"
const val COLUMN_LARGE_COVER_URL = "large_cover_url"
const val COLUMN_DESCRIPTION = "description"
const val COLUMN_PREVIEW = "preview"
const val COLUMN_KEY = "key"
}
}

View File

@@ -0,0 +1,70 @@
package org.koitharu.kotatsu.core.parser.external
import android.database.Cursor
import android.database.CursorWrapper
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
import org.koitharu.kotatsu.core.util.ext.getBoolean
class ExternalPluginCursor(private val source: ExternalMangaSource, cursor: Cursor) : CursorWrapper(cursor) {
override fun getColumnIndexOrThrow(columnName: String?): Int = try {
super.getColumnIndexOrThrow(columnName)
} catch (e: Exception) {
throw IncompatiblePluginException(source.name, e)
}
fun getString(columnName: String): String = getString(getColumnIndexOrThrow(columnName))
fun getStringOrNull(columnName: String): String? {
val columnIndex = getColumnIndex(columnName)
return when {
columnIndex < 0 -> null
isNull(columnIndex) -> null
else -> getString(columnIndex)
}
}
fun getBoolean(columnName: String): Boolean = getBoolean(getColumnIndexOrThrow(columnName))
fun getBooleanOrDefault(columnName: String, defaultValue: Boolean): Boolean {
val columnIndex = getColumnIndex(columnName)
return when {
columnIndex < 0 -> defaultValue
isNull(columnIndex) -> defaultValue
else -> getBoolean(columnIndex)
}
}
fun getInt(columnName: String): Int = getInt(getColumnIndexOrThrow(columnName))
fun getIntOrDefault(columnName: String, defaultValue: Int): Int {
val columnIndex = getColumnIndex(columnName)
return when {
columnIndex < 0 -> defaultValue
isNull(columnIndex) -> defaultValue
else -> getInt(columnIndex)
}
}
fun getLong(columnName: String): Long = getLong(getColumnIndexOrThrow(columnName))
fun getLongOrDefault(columnName: String, defaultValue: Long): Long {
val columnIndex = getColumnIndex(columnName)
return when {
columnIndex < 0 -> defaultValue
isNull(columnIndex) -> defaultValue
else -> getLong(columnIndex)
}
}
fun getFloat(columnName: String): Float = getFloat(getColumnIndexOrThrow(columnName))
fun getFloatOrDefault(columnName: String, defaultValue: Float): Float {
val columnIndex = getColumnIndex(columnName)
return when {
columnIndex < 0 -> defaultValue
isNull(columnIndex) -> defaultValue
else -> getFloat(columnIndex)
}
}
}

View File

@@ -1,12 +1,19 @@
package org.koitharu.kotatsu.core.parser.favicon
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.AdaptiveIconDrawable
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.net.Uri
import android.os.Build
import android.webkit.MimeTypeMap
import coil.ImageLoader
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.disk.DiskCache
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
@@ -14,7 +21,9 @@ import coil.network.HttpException
import coil.request.Options
import coil.size.Size
import coil.size.pxOrElse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -24,8 +33,10 @@ import okio.Closeable
import okio.buffer
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.util.ext.requireBody
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.local.data.CacheDir
@@ -46,14 +57,27 @@ class FaviconFetcher(
) : Fetcher {
private val diskCacheKey
get() = options.diskCacheKey ?: "${mangaSource.name}[${mangaSource.ordinal}]x${options.size.toCacheKey()}"
get() = options.diskCacheKey ?: "${mangaSource.name}x${options.size.toCacheKey()}"
private val fileSystem
get() = checkNotNull(diskCache.value).fileSystem
override suspend fun fetch(): FetchResult {
getCached(options)?.let { return it }
val repo = mangaRepositoryFactory.create(mangaSource) as RemoteMangaRepository
return when (val repo = mangaRepositoryFactory.create(mangaSource)) {
is ParserMangaRepository -> fetchParserFavicon(repo)
is ExternalMangaRepository -> fetchPluginIcon(repo)
is EmptyMangaRepository -> DrawableResult(
drawable = ColorDrawable(Color.WHITE),
isSampled = false,
dataSource = DataSource.MEMORY,
)
else -> throw IllegalArgumentException("")
}
}
private suspend fun fetchParserFavicon(repo: ParserMangaRepository): FetchResult {
val sizePx = maxOf(
options.size.width.pxOrElse { FALLBACK_SIZE },
options.size.height.pxOrElse { FALLBACK_SIZE },
@@ -100,6 +124,20 @@ class FaviconFetcher(
return response
}
private suspend fun fetchPluginIcon(repository: ExternalMangaRepository): FetchResult {
val source = repository.source
val pm = options.context.packageManager
val icon = runInterruptible(Dispatchers.IO) {
val provider = pm.resolveContentProvider(source.authority, 0)
provider?.loadIcon(pm) ?: pm.getApplicationIcon(source.packageName)
}
return DrawableResult(
drawable = icon.nonAdaptive(),
isSampled = false,
dataSource = DataSource.DISK,
)
}
private fun getCached(options: Options): SourceResult? {
if (!options.diskCachePolicy.readEnabled) {
return null
@@ -165,6 +203,13 @@ class FaviconFetcher(
}
}
private fun Drawable.nonAdaptive() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this is AdaptiveIconDrawable) {
LayerDrawable(arrayOf(background, foreground))
} else {
this
}
class Factory(
context: Context,
okHttpClientLazy: Lazy<OkHttpClient>,

View File

@@ -85,6 +85,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getInt(KEY_GRID_SIZE_PAGES, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE_PAGES, value) }
val isQuickFilterEnabled: Boolean
get() = prefs.getBoolean(KEY_QUICK_FILTER, true)
var historyListMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE_HISTORY, listMode)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_HISTORY, value) }
@@ -192,8 +195,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_FEED_HEADER, true)
set(value) = prefs.edit { putBoolean(KEY_FEED_HEADER, value) }
val isReadingIndicatorsEnabled: Boolean
get() = prefs.getBoolean(KEY_READING_INDICATORS, true)
val progressIndicatorMode: ProgressIndicatorMode
get() = prefs.getEnumValue(KEY_PROGRESS_INDICATORS, ProgressIndicatorMode.PERCENT_READ)
val isHistoryExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_HISTORY_EXCLUDE_NSFW, false)
@@ -619,7 +622,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_UPDATED_GROUPING = "updated_grouping"
const val KEY_READING_INDICATORS = "reading_indicators"
const val KEY_PROGRESS_INDICATORS = "progress_indicators"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
const val KEY_GRID_VIEW_CHAPTERS = "grid_view_chapters"
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
@@ -666,7 +669,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_AUTOSCROLL_SPEED = "as_speed"
const val KEY_MIRROR_SWITCHING = "mirror_switching"
const val KEY_PROXY = "proxy"
const val KEY_PROXY_TYPE = "proxy_type"
const val KEY_PROXY_TYPE = "proxy_type_2"
const val KEY_PROXY_ADDRESS = "proxy_address"
const val KEY_PROXY_PORT = "proxy_port"
const val KEY_PROXY_AUTH = "proxy_auth"
@@ -696,6 +699,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_FEED_HEADER = "feed_header"
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
const val KEY_SOURCES_VERSION = "sources_version"
const val KEY_QUICK_FILTER = "quick_filter"
// keys for non-persistent preferences
const val KEY_APP_VERSION = "app_version"
@@ -704,6 +708,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LOGS_SHARE = "logs_share"
const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_TRANSLATION = "about_app_translation"
const val PROXY_TEST = "proxy_test"
// old keys are for migration only
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"

View File

@@ -0,0 +1,6 @@
package org.koitharu.kotatsu.core.prefs
enum class ProgressIndicatorMode {
NONE, PERCENT_READ, PERCENT_LEFT, CHAPTERS_READ, CHAPTERS_LEFT;
}

View File

@@ -16,10 +16,6 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
var viewBinding: B? = null
private set
@Deprecated("", ReplaceWith("requireViewBinding()"))
protected val binding: B
get() = requireViewBinding()
final override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val binding = onCreateViewBinding(layoutInflater, null)
viewBinding = binding
@@ -51,9 +47,6 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
open fun onDialogCreated(dialog: AlertDialog) = Unit
@Deprecated("", ReplaceWith("viewBinding"))
protected fun bindingOrNull() = viewBinding
fun requireViewBinding(): B = checkNotNull(viewBinding) {
"Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()."
}

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.ui
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.view.View
@@ -14,25 +13,22 @@ import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.view.WindowCompat
import androidx.fragment.app.FragmentManager
import androidx.viewbinding.ViewBinding
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
@Suppress("LeakingThis")
abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(),
ExceptionResolver.Host,
ScreenshotPolicyHelper.ContentContainer,
WindowInsetsDelegate.WindowInsetsListener {
@@ -41,8 +37,8 @@ abstract class BaseActivity<B : ViewBinding> :
lateinit var viewBinding: B
private set
@JvmField
protected val exceptionResolver = ExceptionResolver(this)
protected lateinit var exceptionResolver: ExceptionResolver
private set
@JvmField
protected val insetsDelegate = WindowInsetsDelegate()
@@ -53,13 +49,15 @@ abstract class BaseActivity<B : ViewBinding> :
private var defaultStatusBarColor = Color.TRANSPARENT
override fun onCreate(savedInstanceState: Bundle?) {
val settings = EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).settings
val entryPoint = EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(this)
val settings = entryPoint.settings
isAmoledTheme = settings.isAmoledTheme
setTheme(settings.colorScheme.styleResId)
if (isAmoledTheme) {
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
}
putDataToExtras(intent)
exceptionResolver = entryPoint.exceptionResolverFactory.create(this)
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
insetsDelegate.handleImeInsets = true
@@ -88,6 +86,10 @@ abstract class BaseActivity<B : ViewBinding> :
setupToolbar()
}
override fun getContext() = this
override fun getChildFragmentManager(): FragmentManager = supportFragmentManager
protected fun setContentView(binding: B) {
this.viewBinding = binding
super.setContentView(binding.root)
@@ -97,11 +99,6 @@ abstract class BaseActivity<B : ViewBinding> :
}
override fun onSupportNavigateUp(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// TODO fix behavior on Android 14
dispatchNavigateUp()
return true
}
val fm = supportFragmentManager
if (fm.isStateSaved) {
return false
@@ -178,12 +175,6 @@ abstract class BaseActivity<B : ViewBinding> :
protected fun hasViewBinding() = ::viewBinding.isInitialized
@EntryPoint
@InstallIn(SingletonComponent::class)
interface BaseActivityEntryPoint {
val settings: AppSettings
}
companion object {
const val EXTRA_DATA = "data"

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.core.ui
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
@EntryPoint
@InstallIn(SingletonComponent::class)
interface BaseActivityEntryPoint {
val settings: AppSettings
val exceptionResolverFactory: ExceptionResolver.Factory
}

View File

@@ -1,29 +1,27 @@
package org.koitharu.kotatsu.core.ui
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
@Suppress("LeakingThis")
abstract class BaseFragment<B : ViewBinding> :
Fragment(),
ExceptionResolver.Host,
WindowInsetsDelegate.WindowInsetsListener {
var viewBinding: B? = null
private set
@Deprecated("", ReplaceWith("requireViewBinding()"))
protected val binding: B
get() = requireViewBinding()
@JvmField
protected val exceptionResolver = ExceptionResolver(this)
protected lateinit var exceptionResolver: ExceptionResolver
private set
@JvmField
protected val insetsDelegate = WindowInsetsDelegate()
@@ -31,6 +29,12 @@ abstract class BaseFragment<B : ViewBinding> :
protected val actionModeDelegate: ActionModeDelegate
get() = (requireActivity() as BaseActivity<*>).actionModeDelegate
override fun onAttach(context: Context) {
super.onAttach(context)
val entryPoint = EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(context)
exceptionResolver = entryPoint.exceptionResolverFactory.create(this)
}
final override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -59,9 +63,6 @@ abstract class BaseFragment<B : ViewBinding> :
"Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()."
}
@Deprecated("", ReplaceWith("viewBinding"))
protected fun bindingOrNull() = viewBinding
protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B
protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.ui
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
@@ -12,7 +13,9 @@ import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
@@ -25,7 +28,11 @@ import javax.inject.Inject
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(),
WindowInsetsDelegate.WindowInsetsListener,
RecyclerViewOwner {
RecyclerViewOwner,
ExceptionResolver.Host {
protected lateinit var exceptionResolver: ExceptionResolver
private set
@Inject
lateinit var settings: AppSettings
@@ -36,6 +43,12 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
override val recyclerView: RecyclerView
get() = listView
override fun onAttach(context: Context) {
super.onAttach(context)
val entryPoint = EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(context)
exceptionResolver = entryPoint.exceptionResolverFactory.create(this)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val themedContext = (view.parentView ?: view).context

View File

@@ -52,8 +52,8 @@ open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>
}
protected class DiffCallback<T : ListModel>(
val oldList: List<T>,
val newList: List<T>,
private val oldList: List<T>,
private val newList: List<T>,
) : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldList.size
@@ -71,5 +71,11 @@ open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>
val newItem = newList[newItemPosition]
return newItem == oldItem
}
override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return newItem.getChangePayload(oldItem)
}
}
}

View File

@@ -0,0 +1,67 @@
package org.koitharu.kotatsu.core.ui.dialog
import android.content.Context
import android.view.LayoutInflater
import android.widget.CompoundButton.OnCheckedChangeListener
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
import com.hannesdorfmann.adapterdelegates4.AdapterDelegatesManager
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
import com.google.android.material.R as materialR
inline fun buildAlertDialog(
context: Context,
isCentered: Boolean = false,
block: MaterialAlertDialogBuilder.() -> Unit,
): AlertDialog = MaterialAlertDialogBuilder(
context,
if (isCentered) materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered else 0,
).apply(block).create()
fun <B : AlertDialog.Builder> B.setCheckbox(
@StringRes textResId: Int,
isChecked: Boolean,
onCheckedChangeListener: OnCheckedChangeListener
) = apply {
val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
binding.checkbox.setText(textResId)
binding.checkbox.isChecked = isChecked
binding.checkbox.setOnCheckedChangeListener(onCheckedChangeListener)
setView(binding.root)
}
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
list: List<T>,
delegate: AdapterDelegate<List<T>>,
) = apply {
val delegatesManager = AdapterDelegatesManager<List<T>>()
delegatesManager.addDelegate(delegate)
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
}
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
list: List<T>,
vararg delegates: AdapterDelegate<List<T>>,
) = apply {
val delegatesManager = AdapterDelegatesManager<List<T>>()
delegates.forEach { delegatesManager.addDelegate(it) }
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
}
fun <B : AlertDialog.Builder> B.setRecyclerViewList(adapter: RecyclerView.Adapter<*>) = apply {
val recyclerView = RecyclerView(context)
recyclerView.layoutManager = LinearLayoutManager(context)
recyclerView.updatePadding(
top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing),
)
recyclerView.clipToPadding = false
recyclerView.adapter = adapter
setView(recyclerView)
}

View File

@@ -1,80 +0,0 @@
package org.koitharu.kotatsu.core.ui.dialog
import android.content.Context
import android.content.DialogInterface
import android.view.LayoutInflater
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog) :
DialogInterface by delegate {
fun show() = delegate.show()
class Builder(context: Context) {
private val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
private val delegate = MaterialAlertDialogBuilder(context)
.setView(binding.root)
fun setTitle(@StringRes titleResId: Int): Builder {
delegate.setTitle(titleResId)
return this
}
fun setTitle(title: CharSequence): Builder {
delegate.setTitle(title)
return this
}
fun setMessage(@StringRes messageId: Int): Builder {
delegate.setMessage(messageId)
return this
}
fun setMessage(message: CharSequence): Builder {
delegate.setMessage(message)
return this
}
fun setCheckBoxText(@StringRes textId: Int): Builder {
binding.checkbox.setText(textId)
return this
}
fun setCheckBoxChecked(isChecked: Boolean): Builder {
binding.checkbox.isChecked = isChecked
return this
}
fun setIcon(@DrawableRes iconId: Int): Builder {
delegate.setIcon(iconId)
return this
}
fun setPositiveButton(
@StringRes textId: Int,
listener: (DialogInterface, Boolean) -> Unit
): Builder {
delegate.setPositiveButton(textId) { dialog, _ ->
listener(dialog, binding.checkbox.isChecked)
}
return this
}
fun setNegativeButton(
@StringRes textId: Int,
listener: DialogInterface.OnClickListener? = null
): Builder {
delegate.setNegativeButton(textId, listener)
return this
}
fun create() = CheckBoxAlertDialog(delegate.create())
}
}

View File

@@ -1,101 +0,0 @@
package org.koitharu.kotatsu.core.ui.dialog
import android.content.Context
import android.content.DialogInterface
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
import com.hannesdorfmann.adapterdelegates4.AdapterDelegatesManager
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import org.koitharu.kotatsu.R
class RecyclerViewAlertDialog private constructor(
private val delegate: AlertDialog
) : DialogInterface by delegate {
fun show() = delegate.show()
class Builder<T>(context: Context) {
private val recyclerView = RecyclerView(context)
private val delegatesManager = AdapterDelegatesManager<List<T>>()
private var items: List<T>? = null
private val delegate = MaterialAlertDialogBuilder(context)
.setView(recyclerView)
init {
recyclerView.layoutManager = LinearLayoutManager(context)
recyclerView.updatePadding(
top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing),
)
recyclerView.clipToPadding = false
}
fun setTitle(@StringRes titleResId: Int): Builder<T> {
delegate.setTitle(titleResId)
return this
}
fun setTitle(title: CharSequence): Builder<T> {
delegate.setTitle(title)
return this
}
fun setIcon(@DrawableRes iconId: Int): Builder<T> {
delegate.setIcon(iconId)
return this
}
fun setPositiveButton(
@StringRes textId: Int,
listener: DialogInterface.OnClickListener,
): Builder<T> {
delegate.setPositiveButton(textId, listener)
return this
}
fun setNegativeButton(
@StringRes textId: Int,
listener: DialogInterface.OnClickListener? = null
): Builder<T> {
delegate.setNegativeButton(textId, listener)
return this
}
fun setNeutralButton(
@StringRes textId: Int,
listener: DialogInterface.OnClickListener,
): Builder<T> {
delegate.setNeutralButton(textId, listener)
return this
}
fun setCancelable(isCancelable: Boolean): Builder<T> {
delegate.setCancelable(isCancelable)
return this
}
fun addAdapterDelegate(subject: AdapterDelegate<List<T>>): Builder<T> {
delegatesManager.addDelegate(subject)
return this
}
fun setItems(list: List<T>): Builder<T> {
items = list
return this
}
fun create(): RecyclerViewAlertDialog {
recyclerView.adapter = ListDelegationAdapter(delegatesManager).also {
it.items = items
}
return RecyclerViewAlertDialog(delegate.create())
}
}
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.core.ui.dialog
import android.widget.CompoundButton
import android.widget.CompoundButton.OnCheckedChangeListener
class RememberCheckListener(
initialValue: Boolean,
) : OnCheckedChangeListener {
var isChecked: Boolean = initialValue
private set
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
this.isChecked = isChecked
}
}

View File

@@ -0,0 +1,72 @@
package org.koitharu.kotatsu.core.ui.image
import android.animation.TimeAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.drawable.Animatable
import androidx.annotation.StyleRes
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import com.google.android.material.animation.ArgbEvaluatorCompat
import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import kotlin.math.abs
class AnimatedFaviconDrawable(
context: Context,
@StyleRes styleResId: Int,
name: String,
) : FaviconDrawable(context, styleResId, name), Animatable, TimeAnimator.TimeListener {
private val interpolator = FastOutSlowInInterpolator()
private val period = context.getAnimationDuration(R.integer.config_longAnimTime) * 2
private val timeAnimator = TimeAnimator()
private val colorHigh = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground)
private val colorLow = ArgbEvaluatorCompat.getInstance().evaluate(0.3f, colorHigh, colorBackground)
init {
timeAnimator.setTimeListener(this)
updateColor()
}
override fun draw(canvas: Canvas) {
if (!isRunning && period > 0) {
updateColor()
start()
}
super.draw(canvas)
}
override fun setAlpha(alpha: Int) = Unit
override fun getAlpha(): Int = 255
override fun onTimeUpdate(animation: TimeAnimator?, totalTime: Long, deltaTime: Long) {
callback?.also {
updateColor()
it.invalidateDrawable(this)
} ?: stop()
}
override fun start() {
timeAnimator.start()
}
override fun stop() {
timeAnimator.end()
}
override fun isRunning(): Boolean = timeAnimator.isStarted
private fun updateColor() {
if (period <= 0f) {
return
}
val ph = period / 2
val fraction = abs((System.currentTimeMillis() % period) - ph) / ph.toFloat()
colorForeground = ArgbEvaluatorCompat.getInstance()
.evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh)
}
}

View File

@@ -17,18 +17,18 @@ import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.KotatsuColors
class FaviconDrawable(
open class FaviconDrawable(
context: Context,
@StyleRes styleResId: Int,
name: String,
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private var colorBackground = Color.WHITE
protected var colorBackground = Color.WHITE
protected var colorForeground = Color.DKGRAY
private var colorStroke = Color.LTGRAY
private val letter = name.take(1).uppercase()
private var cornerSize = 0f
private var colorForeground = Color.DKGRAY
private val textBounds = Rect()
private val tempRect = Rect()
private val boundsF = RectF()

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.ui.list
import androidx.recyclerview.widget.RecyclerView
abstract class BaseListSelectionCallback(
protected val recyclerView: RecyclerView,
) : ListSelectionController.Callback {
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
recyclerView.invalidateItemDecorations()
}
}

View File

@@ -1,11 +1,11 @@
package org.koitharu.kotatsu.core.ui.list
import android.app.Notification.Action
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.view.ActionMode
import androidx.collection.LongSet
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
@@ -14,6 +14,8 @@ import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryOwner
import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.core.util.ext.toLongArray
import org.koitharu.kotatsu.core.util.ext.toSet
import kotlin.coroutines.EmptyCoroutineContext
private const val KEY_SELECTION = "selection"
@@ -23,7 +25,7 @@ class ListSelectionController(
private val appCompatDelegate: AppCompatDelegate,
private val decoration: AbstractSelectionItemDecoration,
private val registryOwner: SavedStateRegistryOwner,
private val callback: Callback2,
private val callback: Callback,
) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
private var actionMode: ActionMode? = null
@@ -35,11 +37,9 @@ class ListSelectionController(
registryOwner.lifecycle.addObserver(StateEventObserver())
}
fun snapshot(): Set<Long> {
return peekCheckedIds().toSet()
}
fun snapshot(): Set<Long> = peekCheckedIds().toSet()
fun peekCheckedIds(): Set<Long> {
fun peekCheckedIds(): LongSet {
return decoration.checkedItemsIds
}
@@ -130,43 +130,7 @@ class ListSelectionController(
notifySelectionChanged()
}
@Deprecated("")
interface Callback : Callback2 {
fun onSelectionChanged(count: Int)
fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean
fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean
fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean
fun onDestroyActionMode(mode: ActionMode) = Unit
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
onSelectionChanged(count)
}
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
return onCreateActionMode(mode, menu)
}
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
return onPrepareActionMode(mode, menu)
}
override fun onActionItemClicked(
controller: ListSelectionController,
mode: ActionMode,
item: MenuItem,
): Boolean = onActionItemClicked(mode, item)
override fun onDestroyActionMode(controller: ListSelectionController, mode: ActionMode) {
onDestroyActionMode(mode)
}
}
interface Callback2 {
interface Callback {
fun onSelectionChanged(controller: ListSelectionController, count: Int)

View File

@@ -4,6 +4,8 @@ import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.RectF
import android.view.View
import androidx.collection.LongSet
import androidx.collection.MutableLongSet
import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID
@@ -12,7 +14,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
private val bounds = Rect()
private val boundsF = RectF()
protected val selection = HashSet<Long>()
protected val selection = MutableLongSet()
protected var hasBackground: Boolean = true
protected var hasForeground: Boolean = false
@@ -21,7 +23,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
val checkedItemsCount: Int
get() = selection.size
val checkedItemsIds: Set<Long>
val checkedItemsIds: LongSet
get() = selection
fun toggleItemChecked(id: Long) {
@@ -39,7 +41,9 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
}
fun checkAll(ids: Collection<Long>) {
selection.addAll(ids)
for (id in ids) {
selection.add(id)
}
}
fun clearSelection() {

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.ui.list.lifecycle
import android.view.View
import androidx.core.view.children
import androidx.viewpager2.widget.ViewPager2
import org.koitharu.kotatsu.core.util.ext.recyclerView
@@ -8,16 +9,63 @@ class PagerLifecycleDispatcher(
private val pager: ViewPager2,
) : ViewPager2.OnPageChangeCallback() {
private var pendingUpdate: OneShotLayoutListener? = null
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
val rv = pager.recyclerView ?: return
for (child in rv.children) {
val wh = rv.getChildViewHolder(child) ?: continue
(wh as? LifecycleAwareViewHolder)?.setIsCurrent(wh.absoluteAdapterPosition == position)
}
setResumedPage(position)
}
fun invalidate() {
onPageSelected(pager.currentItem)
setResumedPage(pager.currentItem)
}
fun postInvalidate() = pager.post {
invalidate()
}
private fun setResumedPage(position: Int) {
pendingUpdate?.cancel()
pendingUpdate = null
var hasResumedItem = false
val rv = pager.recyclerView ?: return
if (rv.childCount == 0) {
return
}
for (child in rv.children) {
val wh = rv.getChildViewHolder(child) ?: continue
val isCurrent = wh.absoluteAdapterPosition == position
(wh as? LifecycleAwareViewHolder)?.setIsCurrent(isCurrent)
if (isCurrent) {
hasResumedItem = true
}
}
if (!hasResumedItem) {
rv.addOnLayoutChangeListener(OneShotLayoutListener(rv, position).also { pendingUpdate = it })
}
}
private inner class OneShotLayoutListener(
private val view: View,
private val targetPosition: Int,
) : View.OnLayoutChangeListener {
override fun onLayoutChange(
v: View?,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int
) {
view.removeOnLayoutChangeListener(this)
setResumedPage(targetPosition)
}
fun cancel() {
view.removeOnLayoutChangeListener(this)
}
}
}

View File

@@ -2,15 +2,45 @@ package org.koitharu.kotatsu.core.ui.model
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.SortDirection
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.model.SortOrder.ALPHABETICAL
import org.koitharu.kotatsu.parsers.model.SortOrder.ALPHABETICAL_DESC
import org.koitharu.kotatsu.parsers.model.SortOrder.NEWEST
import org.koitharu.kotatsu.parsers.model.SortOrder.NEWEST_ASC
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_ASC
import org.koitharu.kotatsu.parsers.model.SortOrder.RATING
import org.koitharu.kotatsu.parsers.model.SortOrder.RATING_ASC
import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED
import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED_ASC
@get:StringRes
val SortOrder.titleRes: Int
get() = when (this) {
SortOrder.UPDATED -> R.string.updated
SortOrder.POPULARITY -> R.string.popular
SortOrder.RATING -> R.string.by_rating
SortOrder.NEWEST -> R.string.newest
SortOrder.ALPHABETICAL -> R.string.by_name
SortOrder.ALPHABETICAL_DESC -> R.string.by_name_reverse
UPDATED -> R.string.updated
POPULARITY -> R.string.popular
RATING -> R.string.by_rating
NEWEST -> R.string.newest
ALPHABETICAL -> R.string.by_name
ALPHABETICAL_DESC -> R.string.by_name_reverse
UPDATED_ASC -> R.string.updated_long_ago
POPULARITY_ASC -> R.string.unpopular
RATING_ASC -> R.string.low_rating
NEWEST_ASC -> R.string.order_oldest
}
val SortOrder.direction: SortDirection
get() = when (this) {
UPDATED_ASC,
POPULARITY_ASC,
RATING_ASC,
NEWEST_ASC,
ALPHABETICAL -> SortDirection.ASC
UPDATED,
POPULARITY,
RATING,
NEWEST,
ALPHABETICAL_DESC -> SortDirection.DESC
}

View File

@@ -21,22 +21,24 @@ import androidx.viewbinding.ViewBinding
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.sidesheet.SideSheetDialog
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseActivityEntryPoint
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import com.google.android.material.R as materialR
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(), ExceptionResolver.Host {
private var waitingForDismissAllowingStateLoss = false
private var isFitToContentsDisabled = false
var viewBinding: B? = null
protected lateinit var exceptionResolver: ExceptionResolver
private set
@Deprecated("", ReplaceWith("requireViewBinding()"))
protected val binding: B
get() = requireViewBinding()
var viewBinding: B? = null
private set
protected val behavior: AdaptiveSheetBehavior?
get() = AdaptiveSheetBehavior.from(this)
@@ -54,6 +56,12 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
private set
private var lockCounter = 0
override fun onAttach(context: Context) {
super.onAttach(context)
val entryPoint = EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(context)
exceptionResolver = entryPoint.exceptionResolverFactory.create(this)
}
final override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,

View File

@@ -0,0 +1,36 @@
package org.koitharu.kotatsu.core.ui.util
import android.view.View
import com.google.android.material.appbar.AppBarLayout
class FadingAppbarMediator(
private val appBarLayout: AppBarLayout,
private val target: View
) : AppBarLayout.OnOffsetChangedListener {
private var isBound: Boolean = false
fun bind() {
if (!isBound) {
appBarLayout.addOnOffsetChangedListener(this)
isBound = true
}
}
fun unbind() {
if (isBound) {
appBarLayout.removeOnOffsetChangedListener(this)
isBound = false
}
target.alpha = 1f
}
override fun onOffsetChanged(appBarLayout: AppBarLayout?, verticalOffset: Int) {
val scrollRange = (appBarLayout ?: return).totalScrollRange
if (scrollRange <= 0) {
return
}
target.alpha = 1f + verticalOffset / (scrollRange / 2f)
}
}

View File

@@ -7,7 +7,5 @@ class MenuInvalidator(
private val host: MenuHost,
) : FlowCollector<Any?> {
override suspend fun emit(value: Any?) {
host.invalidateMenu()
}
override suspend fun emit(value: Any?) = host.invalidateMenu()
}

View File

@@ -5,9 +5,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
fun interface ReversibleHandle {
@@ -23,8 +23,3 @@ fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.D
it.printStackTraceDebug()
}
}
operator fun ReversibleHandle.plus(other: ReversibleHandle) = ReversibleHandle {
this.reverse()
other.reverse()
}

View File

@@ -2,16 +2,16 @@ package org.koitharu.kotatsu.core.ui.widgets
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.View.OnClickListener
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.view.children
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable
import com.google.android.material.chip.ChipGroup
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.castOrNull
import com.google.android.material.R as materialR
class ChipsView @JvmOverloads constructor(
@@ -22,9 +22,7 @@ class ChipsView @JvmOverloads constructor(
private var isLayoutSuppressedCompat = false
private var isLayoutCalledOnSuppressed = false
private val chipOnClickListener = OnClickListener {
onChipClickListener?.onChipClick(it as Chip, it.tag)
}
private val chipOnClickListener = InternalChipClickListener()
private val chipOnCloseListener = OnClickListener {
val chip = it as Chip
val data = it.tag
@@ -70,8 +68,8 @@ class ChipsView @JvmOverloads constructor(
suppressLayoutCompat(true)
try {
for ((i, model) in items.withIndex()) {
val chip = getChildAt(i) as Chip? ?: addChip()
bindChip(chip, model)
val chip = getChildAt(i) as DataChip? ?: addChip()
chip.bind(model)
}
if (childCount > items.size) {
removeViews(items.size, childCount - items.size)
@@ -81,52 +79,7 @@ class ChipsView @JvmOverloads constructor(
}
}
fun <T> getCheckedData(cls: Class<T>): Set<T> {
val result = LinkedHashSet<T>(childCount)
for (child in children) {
if (child is Chip && child.isChecked) {
result += cls.castOrNull(child.tag) ?: continue
}
}
return result
}
private fun bindChip(chip: Chip, model: ChipModel) {
chip.text = model.title
chip.isClickable = onChipClickListener != null || model.isCheckable
chip.isCheckable = model.isCheckable
if (model.icon == 0) {
chip.chipIcon = null
chip.isChipIconVisible = false
} else {
chip.setChipIconResource(model.icon)
chip.isChipIconVisible = true
}
chip.isChecked = model.isChecked
chip.isCheckedIconVisible = chip.isCheckable && model.icon == 0
chip.isCloseIconVisible = if (onChipCloseClickListener != null || model.isDropdown) {
chip.setCloseIconResource(
if (model.isDropdown) R.drawable.ic_expand_more else materialR.drawable.ic_m3_chip_close,
)
true
} else {
false
}
chip.tag = model.data
}
private fun addChip(): Chip {
val chip = Chip(context)
val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
chip.setChipDrawable(drawable)
chip.isChipIconVisible = false
chip.setOnCloseIconClickListener(chipOnCloseListener)
chip.setEnsureMinTouchTargetSize(false)
chip.setOnClickListener(chipOnClickListener)
chip.isElegantTextHeight = false
addView(chip)
return chip
}
private fun addChip() = DataChip(context).also { addView(it) }
private fun suppressLayoutCompat(suppress: Boolean) {
isLayoutSuppressedCompat = suppress
@@ -139,15 +92,74 @@ class ChipsView @JvmOverloads constructor(
}
data class ChipModel(
val title: CharSequence,
val title: CharSequence? = null,
@StringRes val titleResId: Int = 0,
@DrawableRes val icon: Int = 0,
val isCheckable: Boolean = false,
@ColorRes val tint: Int = 0,
val isChecked: Boolean = false,
val isDropdown: Boolean = false,
val data: Any? = null,
)
private inner class DataChip(context: Context) : Chip(context) {
private var model: ChipModel? = null
init {
val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
setChipDrawable(drawable)
isChipIconVisible = false
setOnCloseIconClickListener(chipOnCloseListener)
setEnsureMinTouchTargetSize(false)
setOnClickListener(chipOnClickListener)
isElegantTextHeight = false
}
fun bind(model: ChipModel) {
this.model = model
if (model.titleResId == 0) {
text = model.title
} else {
setText(model.titleResId)
}
isClickable = onChipClickListener != null
if (model.isChecked) {
isCheckable = true
isChecked = true
} else {
isChecked = false
isCheckable = false
}
if (model.icon == 0 || model.isChecked) {
chipIcon = null
isChipIconVisible = false
} else {
setChipIconResource(model.icon)
isChipIconVisible = true
}
isCheckedIconVisible = model.isChecked
isCloseIconVisible = if (onChipCloseClickListener != null || model.isDropdown) {
setCloseIconResource(
if (model.isDropdown) R.drawable.ic_expand_more else materialR.drawable.ic_m3_chip_close,
)
true
} else {
false
}
tag = model.data
}
override fun toggle() = Unit
}
private inner class InternalChipClickListener : OnClickListener {
override fun onClick(v: View?) {
val chip = v as? DataChip ?: return
onChipClickListener?.onChipClick(chip, chip.tag)
}
}
fun interface OnChipClickListener {
fun onChipClick(chip: Chip, data: Any?)

View File

@@ -13,7 +13,7 @@ abstract class MediatorStateFlow<T>(initialValue: T) : StateFlow<T> {
final override val replayCache: List<T>
get() = delegate.replayCache
final override val value: T
override val value: T
get() = delegate.value
final override suspend fun collect(collector: FlowCollector<T>): Nothing {

View File

@@ -5,7 +5,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
class MultiMutex<T : Any> : Set<T> {
open class MultiMutex<T : Any> : Set<T> {
private val delegates = ArrayMap<T, Mutex>()
@@ -20,19 +20,26 @@ class MultiMutex<T : Any> : Set<T> {
elements.all { x -> delegates.containsKey(x) }
}
override fun isEmpty(): Boolean {
return delegates.isEmpty()
override fun isEmpty(): Boolean = delegates.isEmpty()
override fun iterator(): Iterator<T> = synchronized(delegates) {
delegates.keys.toList()
}.iterator()
fun isLocked(element: T): Boolean = synchronized(delegates) {
delegates[element]?.isLocked == true
}
override fun iterator(): Iterator<T> {
return delegates.keys.iterator()
fun tryLock(element: T): Boolean {
val mutex = synchronized(delegates) {
delegates.getOrPut(element, ::Mutex)
}
return mutex.tryLock()
}
suspend fun lock(element: T) {
val mutex = synchronized(delegates) {
delegates.getOrPut(element) {
Mutex()
}
delegates.getOrPut(element, ::Mutex)
}
mutex.lock()
}

View File

@@ -1,12 +0,0 @@
package org.koitharu.kotatsu.core.util
import android.app.Activity
class TaggedActivityResult(
val tag: String,
val result: Int,
) {
val isSuccess: Boolean
get() = result == Activity.RESULT_OK
}

View File

@@ -20,7 +20,6 @@ import android.database.SQLException
import android.graphics.Bitmap
import android.graphics.Color
import android.net.ConnectivityManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
@@ -31,7 +30,6 @@ import android.view.Window
import android.webkit.WebView
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.IntegerRes
import androidx.annotation.WorkerThread
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDialog
@@ -79,8 +77,6 @@ val Context.powerManager: PowerManager?
val Context.connectivityManager: ConnectivityManager
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable {
val info = getForegroundInfo()
setForeground(info)
@@ -131,8 +127,7 @@ fun SyncResult.onError(error: Throwable) {
when (error) {
is IOException -> stats.numIoExceptions++
is OperationApplicationException,
is SQLException,
-> databaseError = true
is SQLException -> databaseError = true
is JSONException -> stats.numParseExceptions++
else -> if (BuildConfig.DEBUG) throw error
@@ -253,7 +248,6 @@ fun Context.checkNotificationPermission(channelId: String?): Boolean {
return hasPermission
}
@WorkerThread
suspend fun Bitmap.compressToPNG(output: File) = runInterruptible(Dispatchers.IO) {
output.outputStream().use { os ->
if (!compress(Bitmap.CompressFormat.PNG, 100, os)) {

View File

@@ -1,23 +0,0 @@
package org.koitharu.kotatsu.core.util.ext
import android.view.View
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
fun BottomSheetBehavior<*>.doOnExpansionsChanged(callback: (isExpanded: Boolean) -> Unit) {
var isExpended = state == BottomSheetBehavior.STATE_EXPANDED
callback(isExpended)
addBottomSheetCallback(
object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
val expanded = newState == BottomSheetBehavior.STATE_EXPANDED
if (expanded != isExpended) {
isExpended = expanded
callback(expanded)
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
},
)
}

View File

@@ -57,10 +57,6 @@ fun ImageResult.toBitmapOrNull() = when (this) {
is ErrorResult -> null
}
fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRequest.Builder {
return addListener(ImageRequestIndicatorListener(listOf(indicator)))
}
fun ImageRequest.Builder.indicator(indicators: List<BaseProgressIndicator<*>>): ImageRequest.Builder {
return addListener(ImageRequestIndicatorListener(indicators))
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.util.ext
import androidx.collection.ArrayMap
import androidx.collection.ArraySet
import androidx.collection.LongSet
import org.koitharu.kotatsu.BuildConfig
import java.util.Collections
import java.util.EnumSet
@@ -77,3 +78,16 @@ inline fun <T, reified R> Collection<T>.mapToArray(transform: (T) -> R): Array<R
forEachIndexed { index, t -> result[index] = transform(t) }
return result as Array<R>
}
fun LongSet.toLongArray(): LongArray {
val result = LongArray(size)
var i = 0
forEach { result[i++] = it }
return result
}
fun LongSet.toSet(): Set<Long> = toCollection(ArraySet<Long>(size))
fun <R : MutableCollection<Long>> LongSet.toCollection(out: R): R = out.also { result ->
forEach(result::add)
}

View File

@@ -37,3 +37,5 @@ fun JSONObject.toContentValues(): ContentValues {
}
private fun String.escapeName() = "`$this`"
fun Cursor.getBoolean(columnIndex: Int) = getInt(columnIndex) > 0

View File

@@ -1,11 +1,14 @@
package org.koitharu.kotatsu.core.util.ext
import android.content.res.Resources
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import java.util.concurrent.TimeUnit
fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo {
// TODO: Use Java 9's LocalDate.ofInstant().
@@ -33,3 +36,17 @@ fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo
}
fun Long.toInstantOrNull() = if (this == 0L) null else Instant.ofEpochMilli(this)
fun Resources.formatDurationShort(millis: Long): String? {
val hours = TimeUnit.MILLISECONDS.toHours(millis).toInt()
val minutes = (TimeUnit.MILLISECONDS.toMinutes(millis) % 60).toInt()
val seconds = (TimeUnit.MILLISECONDS.toSeconds(millis) % 60).toInt()
return when {
hours == 0 && minutes == 0 && seconds == 0 -> null
hours != 0 && minutes != 0 -> getString(R.string.hours_minutes_short, hours, minutes)
hours != 0 -> getString(R.string.hours_short, hours)
minutes != 0 && seconds != 0 -> getString(R.string.minutes_seconds_short, minutes, seconds)
minutes != 0 -> getString(R.string.minutes_short, minutes)
else -> getString(R.string.seconds_short, seconds)
}
}

View File

@@ -10,10 +10,13 @@ import android.provider.OpenableColumns
import androidx.core.database.getStringOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okhttp3.internal.closeQuietly
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.fs.FileSequence
import java.io.File
import java.io.FileFilter
import java.io.InputStream
import java.nio.file.attribute.BasicFileAttributes
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
@@ -32,10 +35,19 @@ fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() }
fun File.isNotEmpty() = length() != 0L
@Blocking
fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().use {
it.readText()
}
@Blocking
fun ZipFile.getInputStreamOrClose(entry: ZipEntry): InputStream = try {
getInputStream(entry)
} catch (e: Throwable) {
closeQuietly()
throw e
}
fun File.getStorageName(context: Context): String = runCatching {
val manager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {

View File

@@ -4,16 +4,21 @@ import android.os.SystemClock
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.transformWhile
import org.koitharu.kotatsu.R
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
@@ -87,6 +92,21 @@ fun <T> Flow<T>.zipWithPrevious(): Flow<Pair<T?, T>> = flow {
}
}
fun tickerFlow(interval: Long, timeUnit: TimeUnit): Flow<Long> = flow {
while (true) {
emit(SystemClock.elapsedRealtime())
delay(timeUnit.toMillis(interval))
}
}
fun <T> Flow<T>.withTicker(interval: Long, timeUnit: TimeUnit) = channelFlow<T> {
onCompletion { cause ->
close(cause)
}.combine(tickerFlow(interval, timeUnit)) { x, _ -> x }
.transformWhile<T, Unit> { trySend(it).isSuccess }
.collect()
}
@Suppress("UNCHECKED_CAST")
fun <T1, T2, T3, T4, T5, T6, R> combine(
flow: Flow<T1>,
@@ -110,3 +130,5 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
suspend fun <T : Any> Flow<T?>.firstNotNull(): T = checkNotNull(first { x -> x != null })
suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x != null }
fun <T> Flow<Flow<T>>.flattenLatest() = flatMapLatest { it }

View File

@@ -1,6 +1,9 @@
package org.koitharu.kotatsu.core.util.ext
import android.content.Context
import androidx.annotation.FloatRange
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import java.util.UUID
@@ -40,3 +43,24 @@ fun CharSequence.sanitize(): CharSequence {
}
fun Char.isReplacement() = this in '\uFFF0'..'\uFFFF'
fun <T> Collection<T>.joinToStringWithLimit(context: Context, limit: Int, transform: ((T) -> String)): String {
if (size == 1) {
return transform(first()).ellipsize(limit)
}
return buildString(limit + 6) {
for ((i, item) in this@joinToStringWithLimit.withIndex()) {
val str = transform(item)
when {
i == 0 -> append(str.ellipsize(limit - 4))
length + str.length > limit -> {
append(", ")
append(context.getString(R.string.list_ellipsize_pattern, this@joinToStringWithLimit.size - i))
break
}
else -> append(", ").append(str)
}
}
}
}

View File

@@ -11,7 +11,6 @@ import androidx.core.content.res.use
import androidx.core.view.isGone
import androidx.core.widget.TextViewCompat
var TextView.textAndVisible: CharSequence?
get() = text?.takeIf { visibility == View.VISIBLE }
set(value) {

View File

@@ -8,11 +8,9 @@ import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.FloatRange
import androidx.annotation.Px
import androidx.annotation.StyleRes
import androidx.core.content.ContextCompat
import androidx.core.content.res.use
import androidx.core.graphics.ColorUtils
import com.google.android.material.R as materialR
fun Context.getThemeDrawable(
@AttrRes resId: Int,
@@ -77,7 +75,3 @@ fun TypedArray.getDrawableCompat(context: Context, index: Int): Drawable? {
val resId = getResourceId(index, 0)
return if (resId != 0) ContextCompat.getDrawable(context, resId) else null
}
@get:StyleRes
val DIALOG_THEME_CENTERED: Int
inline get() = materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered

View File

@@ -3,10 +3,10 @@ package org.koitharu.kotatsu.core.util.ext
import android.content.ActivityNotFoundException
import android.content.res.Resources
import androidx.annotation.DrawableRes
import androidx.collection.arraySetOf
import coil.network.HttpException
import okio.FileNotFoundException
import okio.IOException
import okio.ProtocolException
import org.acra.ktx.sendWithAcra
import org.jsoup.HttpStatusException
import org.koitharu.kotatsu.R
@@ -15,12 +15,14 @@ import org.koitharu.kotatsu.core.exceptions.CaughtException
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.exceptions.SyncApiException
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED
@@ -30,6 +32,8 @@ import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
@@ -37,30 +41,47 @@ private const val MSG_NO_SPACE_LEFT = "No space left on device"
private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is ScrobblerAuthRequiredException -> resources.getString(
R.string.scrobbler_auth_required,
resources.getString(scrobbler.titleResId),
)
is AuthRequiredException -> resources.getString(R.string.auth_required)
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required)
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
is ActivityNotFoundException,
is UnsupportedOperationException,
-> resources.getString(R.string.operation_not_supported)
-> resources.getString(R.string.operation_not_supported)
is TooManyRequestExceptions -> {
val delay = getRetryDelay()
val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) {
resources.formatDurationShort(delay)
} else {
null
}
if (formattedTime != null) {
resources.getString(R.string.too_many_requests_message_retry, formattedTime)
} else {
resources.getString(R.string.too_many_requests_message)
}
}
is TooManyRequestExceptions -> resources.getString(R.string.too_many_requests_message)
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
is FileNotFoundException -> resources.getString(R.string.file_not_found)
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
is SyncApiException,
is ContentUnavailableException,
-> message
is ContentUnavailableException -> message
is ParseException -> shortMessage
is UnknownHostException,
is SocketTimeoutException,
-> resources.getString(R.string.network_error)
is SocketTimeoutException -> resources.getString(R.string.network_error)
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
is IncompatiblePluginException -> resources.getString(R.string.plugin_incompatible)
is WrongPasswordException -> resources.getString(R.string.wrong_password)
is NotFoundException -> resources.getString(R.string.not_found_404)
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
@@ -79,7 +100,7 @@ fun Throwable.getDisplayIcon() = when (this) {
is CloudFlareProtectedException -> R.drawable.ic_bot_large
is UnknownHostException,
is SocketTimeoutException,
-> R.drawable.ic_plug_large
is ProtocolException -> R.drawable.ic_plug_large
is CloudFlareBlockedException -> R.drawable.ic_denied_large
@@ -105,7 +126,25 @@ private fun getDisplayMessage(msg: String?, resources: Resources): String? = whe
}
fun Throwable.isReportable(): Boolean {
return this is Error || this.javaClass in reportableExceptions
if (this is Error) {
return true
}
if (this is CaughtException) {
return cause?.isReportable() == true
}
if (ExceptionResolver.canResolve(this)) {
return false
}
if (this is ParseException
|| this.isNetworkError()
|| this is CloudFlareBlockedException
|| this is CloudFlareProtectedException
|| this is BadBackupFormatException
|| this is WrongPasswordException
) {
return false
}
return true
}
fun Throwable.isNetworkError(): Boolean {
@@ -117,15 +156,6 @@ fun Throwable.report() {
exception.sendWithAcra()
}
private val reportableExceptions = arraySetOf<Class<*>>(
RuntimeException::class.java,
IllegalStateException::class.java,
IllegalArgumentException::class.java,
ConcurrentModificationException::class.java,
UnsupportedOperationException::class.java,
NoDataReceivedException::class.java,
)
fun Throwable.isWebViewUnavailable(): Boolean {
val trace = stackTraceToString()
return trace.contains("android.webkit.WebView.<init>")

View File

@@ -5,6 +5,7 @@ import androidx.core.net.toFile
import okio.Source
import okio.source
import okio.use
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import java.io.File
import java.util.zip.ZipFile
@@ -12,6 +13,7 @@ import java.util.zip.ZipFile
const val URI_SCHEME_FILE = "file"
const val URI_SCHEME_ZIP = "file+zip"
@Blocking
fun Uri.exists(): Boolean = when (scheme) {
URI_SCHEME_FILE -> toFile().exists()
URI_SCHEME_ZIP -> {
@@ -22,6 +24,7 @@ fun Uri.exists(): Boolean = when (scheme) {
else -> unsupportedUri(this)
}
@Blocking
fun Uri.isTargetNotEmpty(): Boolean = when (scheme) {
URI_SCHEME_FILE -> toFile().isNotEmpty()
URI_SCHEME_ZIP -> {
@@ -32,12 +35,13 @@ fun Uri.isTargetNotEmpty(): Boolean = when (scheme) {
else -> unsupportedUri(this)
}
@Blocking
fun Uri.source(): Source = when (scheme) {
URI_SCHEME_FILE -> toFile().source()
URI_SCHEME_ZIP -> {
val zip = ZipFile(schemeSpecificPart)
val entry = zip.getEntry(fragment)
zip.getInputStream(entry).source().withExtraCloseable(zip)
zip.getInputStreamOrClose(entry).source().withExtraCloseable(zip)
}
else -> unsupportedUri(this)
@@ -45,6 +49,8 @@ fun Uri.source(): Source = when (scheme) {
fun File.toZipUri(entryName: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryName")
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
private fun unsupportedUri(uri: Uri): Nothing {
throw IllegalArgumentException("Bad uri $uri: only schemes $URI_SCHEME_FILE and $URI_SCHEME_ZIP are supported")
}

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