Compare commits

...

132 Commits
v7.4 ... v7.5.1

Author SHA1 Message Date
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
281 changed files with 6307 additions and 3545 deletions

1
.idea/gradle.xml generated
View File

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

View File

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

View File

@@ -14,6 +14,7 @@
-dontwarn org.conscrypt.** -dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.** -dontwarn org.bouncycastle.**
-dontwarn org.openjsse.** -dontwarn org.openjsse.**
-dontwarn com.google.j2objc.annotations.**
-keep class org.koitharu.kotatsu.core.exceptions.* { *; } -keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment -keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment

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.local.data.PagesCache
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
class KotatsuApp : BaseApp() { class KotatsuApp : BaseApp() {
@@ -30,6 +31,7 @@ class KotatsuApp : BaseApp() {
.setClassInstanceLimit(PagesCache::class.java, 1) .setClassInstanceLimit(PagesCache::class.java, 1)
.setClassInstanceLimit(MangaLoaderContext::class.java, 1) .setClassInstanceLimit(MangaLoaderContext::class.java, 1)
.setClassInstanceLimit(PageLoader::class.java, 1) .setClassInstanceLimit(PageLoader::class.java, 1)
.setClassInstanceLimit(ReaderViewModel::class.java, 1)
.penaltyLog() .penaltyLog()
.build(), .build(),
) )

View File

@@ -7,8 +7,8 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
import org.koitharu.kotatsu.history.data.HistoryEntity 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.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.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable

View File

@@ -7,7 +7,7 @@ import androidx.core.text.inSpans
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.transform.CircleCropTransformation import coil.transform.RoundedCornersTransformation
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.getTitle
@@ -75,7 +75,7 @@ fun alternativeAD(
.fallback(R.drawable.ic_web) .fallback(R.drawable.ic_web)
.error(R.drawable.ic_web) .error(R.drawable.ic_web)
.source(item.manga.source) .source(item.manga.source)
.transformations(CircleCropTransformation()) .transformations(RoundedCornersTransformation(context.resources.getDimension(R.dimen.chip_icon_corner)))
.allowRgb565(true) .allowRgb565(true)
.enqueueWith(coil) .enqueueWith(coil)
} }

View File

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

View File

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

View File

@@ -45,7 +45,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
} }
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE)) val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
repository?.headers?.get(CommonHeaders.USER_AGENT) repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
viewBinding.webView.configureForParser(userAgent) viewBinding.webView.configureForParser(userAgent)
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true) CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
viewBinding.webView.webViewClient = BrowserClient(this) viewBinding.webView.webViewClient = BrowserClient(this)

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.browser.cloudflare package org.koitharu.kotatsu.browser.cloudflare
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle 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.CommonHeaders
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.ui.BaseActivity 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.core.util.ext.configureForParser
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -180,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 { override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent {
return newIntent(context, input) return newIntent(context, input)
} }
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult { override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
return TaggedActivityResult(TAG, resultCode) return resultCode == Activity.RESULT_OK
} }
} }

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

@@ -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

@@ -4,36 +4,59 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction import androidx.room.Transaction
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow 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.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
@Dao @Dao
interface TrackLogsDao { abstract class TrackLogsDao : MangaQueryBuilder.ConditionCallback {
@Transaction fun observeAll(
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0") limit: Int,
fun observeAll(limit: Int): Flow<List<TrackLogWithManga>> 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") @Query("SELECT COUNT(*) FROM track_logs WHERE unread = 1")
fun observeUnreadCount(): Flow<Int> abstract fun observeUnreadCount(): Flow<Int>
@Query("DELETE FROM track_logs") @Query("DELETE FROM track_logs")
suspend fun clear() abstract suspend fun clear()
@Query("UPDATE track_logs SET unread = 0 WHERE id = :id") @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) @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)") @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)") @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") @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

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

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

View File

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

View File

@@ -38,7 +38,7 @@ class CommonHeadersInterceptor @Inject constructor(
null null
} }
val headersBuilder = request.headers.newBuilder() val headersBuilder = request.headers.newBuilder()
repository?.headers?.let { repository?.getRequestHeaders()?.let {
headersBuilder.mergeWith(it, replaceExisting = false) headersBuilder.mergeWith(it, replaceExisting = false)
} }
if (headersBuilder[CommonHeaders.USER_AGENT] == null) { if (headersBuilder[CommonHeaders.USER_AGENT] == null) {

View File

@@ -3,28 +3,27 @@ package org.koitharu.kotatsu.core.network
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
import java.time.Instant
import java.time.ZonedDateTime import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.concurrent.TimeUnit
class RateLimitInterceptor : Interceptor { class RateLimitInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request()) val response = chain.proceed(chain.request())
if (response.code == 429) { if (response.code == 429) {
val retryDate = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryDate()
val request = response.request val request = response.request
response.closeQuietly() response.closeQuietly()
throw TooManyRequestExceptions( throw TooManyRequestExceptions(
url = request.url.toString(), url = request.url.toString(),
retryAt = retryDate, retryAfter = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryAfter() ?: 0L,
) )
} }
return response return response
} }
private fun String.parseRetryDate(): Instant? { private fun String.parseRetryAfter(): Long {
return toLongOrNull()?.let { Instant.now().plusSeconds(it) } return toLongOrNull()?.let { TimeUnit.SECONDS.toMillis(it) }
?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant() ?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant().toEpochMilli()
} }
} }

View File

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

View File

@@ -46,6 +46,7 @@ class MangaDataRepository @Inject constructor(
cfBrightness = colorFilter?.brightness ?: 0f, cfBrightness = colorFilter?.brightness ?: 0f,
cfContrast = colorFilter?.contrast ?: 0f, cfContrast = colorFilter?.contrast ?: 0f,
cfInvert = colorFilter?.isInverted ?: false, cfInvert = colorFilter?.isInverted ?: false,
cfGrayscale = colorFilter?.isGrayscale ?: false,
), ),
) )
} }

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import okhttp3.Headers
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
@@ -65,9 +64,6 @@ class ParserMangaRepository(
val domains: Array<out String> val domains: Array<out String>
get() = parser.configKeyDomain.presetValues get() = parser.configKeyDomain.presetValues
val headers: Headers
get() = parser.headers
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
return if (parser is Interceptor) { return if (parser is Interceptor) {
parser.intercept(chain) parser.intercept(chain)
@@ -112,6 +108,8 @@ class ParserMangaRepository(
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
fun getRequestHeaders() = parser.getRequestHeaders()
fun getConfigKeys(): List<ConfigKey<*>> = ArrayList<ConfigKey<*>>().also { fun getConfigKeys(): List<ConfigKey<*>> = ArrayList<ConfigKey<*>>().also {
parser.onCreateConfig(it) parser.onCreateConfig(it)
} }

View File

@@ -1,19 +1,12 @@
package org.koitharu.kotatsu.core.parser.external package org.koitharu.kotatsu.core.parser.external
import android.content.ContentResolver import android.content.ContentResolver
import android.database.Cursor
import androidx.collection.ArraySet
import androidx.core.database.getStringOrNull
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.parser.CachingMangaRepository import org.koitharu.kotatsu.core.parser.CachingMangaRepository
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.ContentRating 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.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
@@ -21,9 +14,6 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder 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.EnumSet
import java.util.Locale import java.util.Locale
@@ -33,232 +23,58 @@ class ExternalMangaRepository(
cache: MemoryContentCache, cache: MemoryContentCache,
) : CachingMangaRepository(cache) { ) : CachingMangaRepository(cache) {
private val capabilities by lazy { queryCapabilities() } private val contentSource = ExternalPluginContentSource(contentResolver, source)
private val capabilities by lazy {
runCatching {
contentSource.getCapabilities()
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
}
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL) get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL)
override val states: Set<MangaState> override val states: Set<MangaState>
get() = capabilities?.availableStates.orEmpty() get() = capabilities?.availableStates.orEmpty()
override val contentRatings: Set<ContentRating> override val contentRatings: Set<ContentRating>
get() = capabilities?.availableContentRating.orEmpty() get() = capabilities?.availableContentRating.orEmpty()
override var defaultSortOrder: SortOrder override var defaultSortOrder: SortOrder
get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL
set(value) = Unit set(value) = Unit
override val isMultipleTagsSupported: Boolean override val isMultipleTagsSupported: Boolean
get() = capabilities?.isMultipleTagsSupported ?: true get() = capabilities?.isMultipleTagsSupported ?: true
override val isTagsExclusionSupported: Boolean override val isTagsExclusionSupported: Boolean
get() = capabilities?.isTagsExclusionSupported ?: false get() = capabilities?.isTagsExclusionSupported ?: false
override val isSearchSupported: Boolean override val isSearchSupported: Boolean
get() = capabilities?.isSearchSupported ?: true get() = capabilities?.isSearchSupported ?: true
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> =
runInterruptible(Dispatchers.Default) { runInterruptible(Dispatchers.IO) {
val uri = "content://${source.authority}/manga".toUri().buildUpon() contentSource.getList(offset, filter)
uri.appendQueryParameter("offset", offset.toString())
when (filter) {
is MangaListFilter.Advanced -> {
filter.tags.forEach { uri.appendQueryParameter("tag_include", it.key) }
filter.tagsExclude.forEach { uri.appendQueryParameter("tag_exclude", it.key) }
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
}
contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name)?.use { cursor ->
val result = ArrayList<Manga>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += cursor.getManga()
} while (cursor.moveToNext())
}
result
}.orEmpty()
} }
override suspend fun getDetailsImpl(manga: Manga): Manga = coroutineScope { override suspend fun getDetailsImpl(manga: Manga): Manga = runInterruptible(Dispatchers.IO) {
val chapters = async { queryChapters(manga.url) } contentSource.getDetails(manga)
val details = queryDetails(manga.url)
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.await(),
source = source,
)
} }
override suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.Default) { override suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
val uri = "content://${source.authority}/chapters".toUri() contentSource.getPages(chapter)
.buildUpon()
.appendPath(chapter.url)
.build()
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val result = ArrayList<MangaPage>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaPage(
id = cursor.getLong(0),
url = cursor.getString(1),
preview = cursor.getStringOrNull(2),
source = source,
)
} while (cursor.moveToNext())
}
result
}.orEmpty()
} }
override suspend fun getPageUrl(page: MangaPage): String = page.url override suspend fun getPageUrl(page: MangaPage): String = page.url // TODO
override suspend fun getTags(): Set<MangaTag> = runInterruptible(Dispatchers.Default) { override suspend fun getTags(): Set<MangaTag> = runInterruptible(Dispatchers.IO) {
val uri = "content://${source.authority}/tags".toUri() contentSource.getTags()
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val result = ArraySet<MangaTag>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaTag(
key = cursor.getString(0),
title = cursor.getString(1),
source = source,
)
} while (cursor.moveToNext())
}
result
}.orEmpty()
} }
override suspend fun getLocales(): Set<Locale> = emptySet() override suspend fun getLocales(): Set<Locale> = emptySet() // TODO
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO
private suspend fun queryDetails(url: String): Manga = runInterruptible(Dispatchers.Default) {
val uri = "content://${source.authority}/manga".toUri()
.buildUpon()
.appendPath(url)
.build()
checkNotNull(
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
cursor.moveToFirst()
cursor.getManga()
},
)
}
private suspend fun queryChapters(url: String): List<MangaChapter>? = runInterruptible(Dispatchers.Default) {
val uri = "content://${source.authority}/manga/chapters".toUri()
.buildUpon()
.appendPath(url)
.build()
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val result = ArrayList<MangaChapter>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += MangaChapter(
id = cursor.getLong(0),
name = cursor.getString(1),
number = cursor.getFloat(2),
volume = cursor.getInt(3),
url = cursor.getString(4),
scanlator = cursor.getStringOrNull(5),
uploadDate = cursor.getLong(6),
branch = cursor.getStringOrNull(7),
source = source,
)
} while (cursor.moveToNext())
}
result
}
}
private fun Cursor.getManga() = Manga(
id = getLong(0),
title = getString(1),
altTitle = getStringOrNull(2),
url = getString(3),
publicUrl = getString(4),
rating = getFloat(5),
isNsfw = getInt(6) > 1,
coverUrl = getString(7),
tags = getStringOrNull(8)?.split(':')?.mapNotNullToSet {
val parts = it.splitTwoParts('=') ?: return@mapNotNullToSet null
MangaTag(key = parts.first, title = parts.second, source = source)
}.orEmpty(),
state = getStringOrNull(9)?.let { MangaState.entries.find(it) },
author = optString(10),
largeCoverUrl = optString(11),
description = optString(12),
chapters = emptyList(),
source = source,
)
private fun Cursor.optString(columnIndex: Int): String? {
return if (isNull(columnIndex)) {
null
} else {
getString(columnIndex)
}
}
private fun queryCapabilities(): MangaSourceCapabilities? {
val uri = "content://${source.authority}/capabilities".toUri()
return contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
MangaSourceCapabilities(
availableSortOrders = cursor.getStringOrNull(0)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) {
SortOrder.entries.find(it)
}.orEmpty(),
availableStates = cursor.getStringOrNull(1)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(MangaState::class.java)) {
MangaState.entries.find(it)
}.orEmpty(),
availableContentRating = cursor.getStringOrNull(2)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(ContentRating::class.java)) {
ContentRating.entries.find(it)
}.orEmpty(),
isMultipleTagsSupported = cursor.getInt(3) > 1,
isTagsExclusionSupported = cursor.getInt(4) > 1,
isSearchSupported = cursor.getInt(5) > 1,
contentType = ContentType.entries.find(cursor.getString(6)) ?: ContentType.OTHER,
defaultSortOrder = cursor.getStringOrNull(7)?.let {
SortOrder.entries.find(it)
} ?: SortOrder.ALPHABETICAL,
sourceLocale = cursor.getStringOrNull(8)?.let { Locale(it) } ?: Locale.ROOT,
)
} else {
null
}
}
}
private 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,
)
} }

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

@@ -85,6 +85,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getInt(KEY_GRID_SIZE_PAGES, 100) get() = prefs.getInt(KEY_GRID_SIZE_PAGES, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE_PAGES, value) } set(value) = prefs.edit { putInt(KEY_GRID_SIZE_PAGES, value) }
val isQuickFilterEnabled: Boolean
get() = prefs.getBoolean(KEY_QUICK_FILTER, true)
var historyListMode: ListMode var historyListMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE_HISTORY, listMode) get() = prefs.getEnumValue(KEY_LIST_MODE_HISTORY, listMode)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_HISTORY, value) } set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_HISTORY, value) }
@@ -696,6 +699,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_FEED_HEADER = "feed_header" const val KEY_FEED_HEADER = "feed_header"
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types" const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
const val KEY_SOURCES_VERSION = "sources_version" const val KEY_SOURCES_VERSION = "sources_version"
const val KEY_QUICK_FILTER = "quick_filter"
// keys for non-persistent preferences // keys for non-persistent preferences
const val KEY_APP_VERSION = "app_version" 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_LOGS_SHARE = "logs_share"
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_TRANSLATION = "about_app_translation" const val KEY_APP_TRANSLATION = "about_app_translation"
const val PROXY_TEST = "proxy_test"
// old keys are for migration only // old keys are for migration only
private const val KEY_IMAGES_PROXY_OLD = "images_proxy" private const val KEY_IMAGES_PROXY_OLD = "images_proxy"

View File

@@ -16,10 +16,6 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
var viewBinding: B? = null var viewBinding: B? = null
private set private set
@Deprecated("", ReplaceWith("requireViewBinding()"))
protected val binding: B
get() = requireViewBinding()
final override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { final override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val binding = onCreateViewBinding(layoutInflater, null) val binding = onCreateViewBinding(layoutInflater, null)
viewBinding = binding viewBinding = binding
@@ -51,9 +47,6 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
open fun onDialogCreated(dialog: AlertDialog) = Unit open fun onDialogCreated(dialog: AlertDialog) = Unit
@Deprecated("", ReplaceWith("viewBinding"))
protected fun bindingOrNull() = viewBinding
fun requireViewBinding(): B = checkNotNull(viewBinding) { fun requireViewBinding(): B = checkNotNull(viewBinding) {
"Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()." "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.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Color import android.graphics.Color
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
import android.view.View import android.view.View
@@ -14,25 +13,22 @@ import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.fragment.app.FragmentManager
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver 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.ActionModeDelegate
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
@Suppress("LeakingThis")
abstract class BaseActivity<B : ViewBinding> : abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(), AppCompatActivity(),
ExceptionResolver.Host,
ScreenshotPolicyHelper.ContentContainer, ScreenshotPolicyHelper.ContentContainer,
WindowInsetsDelegate.WindowInsetsListener { WindowInsetsDelegate.WindowInsetsListener {
@@ -41,8 +37,8 @@ abstract class BaseActivity<B : ViewBinding> :
lateinit var viewBinding: B lateinit var viewBinding: B
private set private set
@JvmField protected lateinit var exceptionResolver: ExceptionResolver
protected val exceptionResolver = ExceptionResolver(this) private set
@JvmField @JvmField
protected val insetsDelegate = WindowInsetsDelegate() protected val insetsDelegate = WindowInsetsDelegate()
@@ -53,13 +49,15 @@ abstract class BaseActivity<B : ViewBinding> :
private var defaultStatusBarColor = Color.TRANSPARENT private var defaultStatusBarColor = Color.TRANSPARENT
override fun onCreate(savedInstanceState: Bundle?) { 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 isAmoledTheme = settings.isAmoledTheme
setTheme(settings.colorScheme.styleResId) setTheme(settings.colorScheme.styleResId)
if (isAmoledTheme) { if (isAmoledTheme) {
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled) setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
} }
putDataToExtras(intent) putDataToExtras(intent)
exceptionResolver = entryPoint.exceptionResolverFactory.create(this)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
insetsDelegate.handleImeInsets = true insetsDelegate.handleImeInsets = true
@@ -88,6 +86,10 @@ abstract class BaseActivity<B : ViewBinding> :
setupToolbar() setupToolbar()
} }
override fun getContext() = this
override fun getChildFragmentManager(): FragmentManager = supportFragmentManager
protected fun setContentView(binding: B) { protected fun setContentView(binding: B) {
this.viewBinding = binding this.viewBinding = binding
super.setContentView(binding.root) super.setContentView(binding.root)
@@ -97,11 +99,6 @@ abstract class BaseActivity<B : ViewBinding> :
} }
override fun onSupportNavigateUp(): Boolean { 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 val fm = supportFragmentManager
if (fm.isStateSaved) { if (fm.isStateSaved) {
return false return false
@@ -178,12 +175,6 @@ abstract class BaseActivity<B : ViewBinding> :
protected fun hasViewBinding() = ::viewBinding.isInitialized protected fun hasViewBinding() = ::viewBinding.isInitialized
@EntryPoint
@InstallIn(SingletonComponent::class)
interface BaseActivityEntryPoint {
val settings: AppSettings
}
companion object { companion object {
const val EXTRA_DATA = "data" 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 package org.koitharu.kotatsu.core.ui
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
@Suppress("LeakingThis")
abstract class BaseFragment<B : ViewBinding> : abstract class BaseFragment<B : ViewBinding> :
Fragment(), Fragment(),
ExceptionResolver.Host,
WindowInsetsDelegate.WindowInsetsListener { WindowInsetsDelegate.WindowInsetsListener {
var viewBinding: B? = null var viewBinding: B? = null
private set private set
@Deprecated("", ReplaceWith("requireViewBinding()")) protected lateinit var exceptionResolver: ExceptionResolver
protected val binding: B private set
get() = requireViewBinding()
@JvmField
protected val exceptionResolver = ExceptionResolver(this)
@JvmField @JvmField
protected val insetsDelegate = WindowInsetsDelegate() protected val insetsDelegate = WindowInsetsDelegate()
@@ -31,6 +29,12 @@ abstract class BaseFragment<B : ViewBinding> :
protected val actionModeDelegate: ActionModeDelegate protected val actionModeDelegate: ActionModeDelegate
get() = (requireActivity() as BaseActivity<*>).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( final override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, 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()." "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 abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B
protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.ui package org.koitharu.kotatsu.core.ui
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
@@ -12,7 +13,9 @@ import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.R 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.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
@@ -25,7 +28,11 @@ import javax.inject.Inject
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(), PreferenceFragmentCompat(),
WindowInsetsDelegate.WindowInsetsListener, WindowInsetsDelegate.WindowInsetsListener,
RecyclerViewOwner { RecyclerViewOwner,
ExceptionResolver.Host {
protected lateinit var exceptionResolver: ExceptionResolver
private set
@Inject @Inject
lateinit var settings: AppSettings lateinit var settings: AppSettings
@@ -36,6 +43,12 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
override val recyclerView: RecyclerView override val recyclerView: RecyclerView
get() = listView 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val themedContext = (view.parentView ?: view).context 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>( protected class DiffCallback<T : ListModel>(
val oldList: List<T>, private val oldList: List<T>,
val newList: List<T>, private val newList: List<T>,
) : DiffUtil.Callback() { ) : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldList.size override fun getOldListSize(): Int = oldList.size
@@ -71,5 +71,11 @@ open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>
val newItem = newList[newItemPosition] val newItem = newList[newItemPosition]
return newItem == oldItem 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,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

@@ -25,7 +25,7 @@ class ListSelectionController(
private val appCompatDelegate: AppCompatDelegate, private val appCompatDelegate: AppCompatDelegate,
private val decoration: AbstractSelectionItemDecoration, private val decoration: AbstractSelectionItemDecoration,
private val registryOwner: SavedStateRegistryOwner, private val registryOwner: SavedStateRegistryOwner,
private val callback: Callback2, private val callback: Callback,
) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider { ) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
private var actionMode: ActionMode? = null private var actionMode: ActionMode? = null
@@ -130,43 +130,7 @@ class ListSelectionController(
notifySelectionChanged() notifySelectionChanged()
} }
@Deprecated("") interface Callback {
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 {
fun onSelectionChanged(controller: ListSelectionController, count: Int) fun onSelectionChanged(controller: ListSelectionController, count: Int)

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.ui.list.lifecycle package org.koitharu.kotatsu.core.ui.list.lifecycle
import android.view.View
import androidx.core.view.children import androidx.core.view.children
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import org.koitharu.kotatsu.core.util.ext.recyclerView import org.koitharu.kotatsu.core.util.ext.recyclerView
@@ -8,16 +9,63 @@ class PagerLifecycleDispatcher(
private val pager: ViewPager2, private val pager: ViewPager2,
) : ViewPager2.OnPageChangeCallback() { ) : ViewPager2.OnPageChangeCallback() {
private var pendingUpdate: OneShotLayoutListener? = null
override fun onPageSelected(position: Int) { override fun onPageSelected(position: Int) {
super.onPageSelected(position) setResumedPage(position)
val rv = pager.recyclerView ?: return
for (child in rv.children) {
val wh = rv.getChildViewHolder(child) ?: continue
(wh as? LifecycleAwareViewHolder)?.setIsCurrent(wh.absoluteAdapterPosition == position)
}
} }
fun invalidate() { 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 androidx.annotation.StringRes
import org.koitharu.kotatsu.R 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
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 @get:StringRes
val SortOrder.titleRes: Int val SortOrder.titleRes: Int
get() = when (this) { get() = when (this) {
SortOrder.UPDATED -> R.string.updated UPDATED -> R.string.updated
SortOrder.POPULARITY -> R.string.popular POPULARITY -> R.string.popular
SortOrder.RATING -> R.string.by_rating RATING -> R.string.by_rating
SortOrder.NEWEST -> R.string.newest NEWEST -> R.string.newest
SortOrder.ALPHABETICAL -> R.string.by_name ALPHABETICAL -> R.string.by_name
SortOrder.ALPHABETICAL_DESC -> R.string.by_name_reverse 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.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.sidesheet.SideSheetDialog import com.google.android.material.sidesheet.SideSheetDialog
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.R 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.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseActivityEntryPoint
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import com.google.android.material.R as materialR 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 waitingForDismissAllowingStateLoss = false
private var isFitToContentsDisabled = false private var isFitToContentsDisabled = false
var viewBinding: B? = null protected lateinit var exceptionResolver: ExceptionResolver
private set private set
@Deprecated("", ReplaceWith("requireViewBinding()")) var viewBinding: B? = null
protected val binding: B private set
get() = requireViewBinding()
protected val behavior: AdaptiveSheetBehavior? protected val behavior: AdaptiveSheetBehavior?
get() = AdaptiveSheetBehavior.from(this) get() = AdaptiveSheetBehavior.from(this)
@@ -54,6 +56,12 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
private set private set
private var lockCounter = 0 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( final override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, 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, private val host: MenuHost,
) : FlowCollector<Any?> { ) : FlowCollector<Any?> {
override suspend fun emit(value: Any?) { override suspend fun emit(value: Any?) = host.invalidateMenu()
host.invalidateMenu()
}
} }

View File

@@ -5,9 +5,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
fun interface ReversibleHandle { fun interface ReversibleHandle {
@@ -23,8 +23,3 @@ fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.D
it.printStackTraceDebug() 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.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View
import android.view.View.OnClickListener import android.view.View.OnClickListener
import androidx.annotation.ColorRes import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.view.children import androidx.core.view.children
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipDrawable
import com.google.android.material.chip.ChipGroup import com.google.android.material.chip.ChipGroup
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.castOrNull
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
class ChipsView @JvmOverloads constructor( class ChipsView @JvmOverloads constructor(
@@ -22,9 +22,7 @@ class ChipsView @JvmOverloads constructor(
private var isLayoutSuppressedCompat = false private var isLayoutSuppressedCompat = false
private var isLayoutCalledOnSuppressed = false private var isLayoutCalledOnSuppressed = false
private val chipOnClickListener = OnClickListener { private val chipOnClickListener = InternalChipClickListener()
onChipClickListener?.onChipClick(it as Chip, it.tag)
}
private val chipOnCloseListener = OnClickListener { private val chipOnCloseListener = OnClickListener {
val chip = it as Chip val chip = it as Chip
val data = it.tag val data = it.tag
@@ -70,8 +68,8 @@ class ChipsView @JvmOverloads constructor(
suppressLayoutCompat(true) suppressLayoutCompat(true)
try { try {
for ((i, model) in items.withIndex()) { for ((i, model) in items.withIndex()) {
val chip = getChildAt(i) as Chip? ?: addChip() val chip = getChildAt(i) as DataChip? ?: addChip()
bindChip(chip, model) chip.bind(model)
} }
if (childCount > items.size) { if (childCount > items.size) {
removeViews(items.size, 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> { private fun addChip() = DataChip(context).also { addView(it) }
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 suppressLayoutCompat(suppress: Boolean) { private fun suppressLayoutCompat(suppress: Boolean) {
isLayoutSuppressedCompat = suppress isLayoutSuppressedCompat = suppress
@@ -139,15 +92,74 @@ class ChipsView @JvmOverloads constructor(
} }
data class ChipModel( data class ChipModel(
val title: CharSequence, val title: CharSequence? = null,
@StringRes val titleResId: Int = 0,
@DrawableRes val icon: Int = 0, @DrawableRes val icon: Int = 0,
val isCheckable: Boolean = false,
@ColorRes val tint: Int = 0, @ColorRes val tint: Int = 0,
val isChecked: Boolean = false, val isChecked: Boolean = false,
val isDropdown: Boolean = false, val isDropdown: Boolean = false,
val data: Any? = null, 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 interface OnChipClickListener {
fun onChipClick(chip: Chip, data: Any?) 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> final override val replayCache: List<T>
get() = delegate.replayCache get() = delegate.replayCache
final override val value: T override val value: T
get() = delegate.value get() = delegate.value
final override suspend fun collect(collector: FlowCollector<T>): Nothing { 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.InvocationKind
import kotlin.contracts.contract import kotlin.contracts.contract
class MultiMutex<T : Any> : Set<T> { open class MultiMutex<T : Any> : Set<T> {
private val delegates = ArrayMap<T, Mutex>() private val delegates = ArrayMap<T, Mutex>()
@@ -20,19 +20,26 @@ class MultiMutex<T : Any> : Set<T> {
elements.all { x -> delegates.containsKey(x) } elements.all { x -> delegates.containsKey(x) }
} }
override fun isEmpty(): Boolean { override fun isEmpty(): Boolean = delegates.isEmpty()
return 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> { fun tryLock(element: T): Boolean {
return delegates.keys.iterator() val mutex = synchronized(delegates) {
delegates.getOrPut(element, ::Mutex)
}
return mutex.tryLock()
} }
suspend fun lock(element: T) { suspend fun lock(element: T) {
val mutex = synchronized(delegates) { val mutex = synchronized(delegates) {
delegates.getOrPut(element) { delegates.getOrPut(element, ::Mutex)
Mutex()
}
} }
mutex.lock() 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.Bitmap
import android.graphics.Color import android.graphics.Color
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.PowerManager import android.os.PowerManager
@@ -31,7 +30,6 @@ import android.view.Window
import android.webkit.WebView import android.webkit.WebView
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.IntegerRes import androidx.annotation.IntegerRes
import androidx.annotation.WorkerThread
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDialog import androidx.appcompat.app.AppCompatDialog
@@ -79,8 +77,6 @@ val Context.powerManager: PowerManager?
val Context.connectivityManager: ConnectivityManager val Context.connectivityManager: ConnectivityManager
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable { suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable {
val info = getForegroundInfo() val info = getForegroundInfo()
setForeground(info) setForeground(info)
@@ -131,8 +127,7 @@ fun SyncResult.onError(error: Throwable) {
when (error) { when (error) {
is IOException -> stats.numIoExceptions++ is IOException -> stats.numIoExceptions++
is OperationApplicationException, is OperationApplicationException,
is SQLException, is SQLException -> databaseError = true
-> databaseError = true
is JSONException -> stats.numParseExceptions++ is JSONException -> stats.numParseExceptions++
else -> if (BuildConfig.DEBUG) throw error else -> if (BuildConfig.DEBUG) throw error
@@ -253,7 +248,6 @@ fun Context.checkNotificationPermission(channelId: String?): Boolean {
return hasPermission return hasPermission
} }
@WorkerThread
suspend fun Bitmap.compressToPNG(output: File) = runInterruptible(Dispatchers.IO) { suspend fun Bitmap.compressToPNG(output: File) = runInterruptible(Dispatchers.IO) {
output.outputStream().use { os -> output.outputStream().use { os ->
if (!compress(Bitmap.CompressFormat.PNG, 100, 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 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 { fun ImageRequest.Builder.indicator(indicators: List<BaseProgressIndicator<*>>): ImageRequest.Builder {
return addListener(ImageRequestIndicatorListener(indicators)) return addListener(ImageRequestIndicatorListener(indicators))
} }

View File

@@ -37,3 +37,5 @@ fun JSONObject.toContentValues(): ContentValues {
} }
private fun String.escapeName() = "`$this`" 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 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 org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import java.util.concurrent.TimeUnit
fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo { fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo {
// TODO: Use Java 9's LocalDate.ofInstant(). // 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 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

@@ -4,16 +4,21 @@ import android.os.SystemClock
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.transformWhile
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> { 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") @Suppress("UNCHECKED_CAST")
fun <T1, T2, T3, T4, T5, T6, R> combine( fun <T1, T2, T3, T4, T5, T6, R> combine(
flow: Flow<T1>, 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?>.firstNotNull(): T = checkNotNull(first { x -> x != null })
suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { 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 package org.koitharu.kotatsu.core.util.ext
import android.content.Context
import androidx.annotation.FloatRange import androidx.annotation.FloatRange
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import java.util.UUID import java.util.UUID
@@ -40,3 +43,24 @@ fun CharSequence.sanitize(): CharSequence {
} }
fun Char.isReplacement() = this in '\uFFF0'..'\uFFFF' 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.view.isGone
import androidx.core.widget.TextViewCompat import androidx.core.widget.TextViewCompat
var TextView.textAndVisible: CharSequence? var TextView.textAndVisible: CharSequence?
get() = text?.takeIf { visibility == View.VISIBLE } get() = text?.takeIf { visibility == View.VISIBLE }
set(value) { set(value) {

View File

@@ -8,11 +8,9 @@ import androidx.annotation.AttrRes
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.FloatRange import androidx.annotation.FloatRange
import androidx.annotation.Px import androidx.annotation.Px
import androidx.annotation.StyleRes
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.res.use import androidx.core.content.res.use
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import com.google.android.material.R as materialR
fun Context.getThemeDrawable( fun Context.getThemeDrawable(
@AttrRes resId: Int, @AttrRes resId: Int,
@@ -77,7 +75,3 @@ fun TypedArray.getDrawableCompat(context: Context, index: Int): Drawable? {
val resId = getResourceId(index, 0) val resId = getResourceId(index, 0)
return if (resId != 0) ContextCompat.getDrawable(context, resId) else null 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.ActivityNotFoundException
import android.content.res.Resources import android.content.res.Resources
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.collection.arraySetOf
import coil.network.HttpException import coil.network.HttpException
import okio.FileNotFoundException import okio.FileNotFoundException
import okio.IOException import okio.IOException
import okio.ProtocolException
import org.acra.ktx.sendWithAcra import org.acra.ktx.sendWithAcra
import org.jsoup.HttpStatusException import org.jsoup.HttpStatusException
import org.koitharu.kotatsu.R 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.CloudFlareBlockedException
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException 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.NoDataReceivedException
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.exceptions.SyncApiException 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.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException 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_LOCALE_GENRES_NOT_SUPPORTED
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_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 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.ContentUnavailableException
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.exception.ParseException 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.SocketTimeoutException
import java.net.UnknownHostException 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" private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) { 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 AuthRequiredException -> resources.getString(R.string.auth_required)
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required) is CloudFlareProtectedException -> resources.getString(R.string.captcha_required)
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message) is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
is ActivityNotFoundException, is ActivityNotFoundException,
is UnsupportedOperationException, 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 UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message) is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
is FileNotFoundException -> resources.getString(R.string.file_not_found) is FileNotFoundException -> resources.getString(R.string.file_not_found)
is AccessDeniedException -> resources.getString(R.string.no_access_to_file) is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
is EmptyHistoryException -> resources.getString(R.string.history_is_empty) is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
is SyncApiException, is SyncApiException,
is ContentUnavailableException, is ContentUnavailableException -> message
-> message
is ParseException -> shortMessage is ParseException -> shortMessage
is UnknownHostException, is UnknownHostException,
is SocketTimeoutException, is SocketTimeoutException -> resources.getString(R.string.network_error)
-> resources.getString(R.string.network_error)
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received) 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 WrongPasswordException -> resources.getString(R.string.wrong_password)
is NotFoundException -> resources.getString(R.string.not_found_404) is NotFoundException -> resources.getString(R.string.not_found_404)
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source) 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 CloudFlareProtectedException -> R.drawable.ic_bot_large
is UnknownHostException, is UnknownHostException,
is SocketTimeoutException, is SocketTimeoutException,
-> R.drawable.ic_plug_large is ProtocolException -> R.drawable.ic_plug_large
is CloudFlareBlockedException -> R.drawable.ic_denied_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 { 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 { fun Throwable.isNetworkError(): Boolean {
@@ -117,15 +156,6 @@ fun Throwable.report() {
exception.sendWithAcra() 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 { fun Throwable.isWebViewUnavailable(): Boolean {
val trace = stackTraceToString() val trace = stackTraceToString()
return trace.contains("android.webkit.WebView.<init>") 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.source import okio.source
import okio.use import okio.use
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.local.data.util.withExtraCloseable import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import java.io.File import java.io.File
import java.util.zip.ZipFile import java.util.zip.ZipFile
@@ -12,6 +13,7 @@ import java.util.zip.ZipFile
const val URI_SCHEME_FILE = "file" const val URI_SCHEME_FILE = "file"
const val URI_SCHEME_ZIP = "file+zip" const val URI_SCHEME_ZIP = "file+zip"
@Blocking
fun Uri.exists(): Boolean = when (scheme) { fun Uri.exists(): Boolean = when (scheme) {
URI_SCHEME_FILE -> toFile().exists() URI_SCHEME_FILE -> toFile().exists()
URI_SCHEME_ZIP -> { URI_SCHEME_ZIP -> {
@@ -22,6 +24,7 @@ fun Uri.exists(): Boolean = when (scheme) {
else -> unsupportedUri(this) else -> unsupportedUri(this)
} }
@Blocking
fun Uri.isTargetNotEmpty(): Boolean = when (scheme) { fun Uri.isTargetNotEmpty(): Boolean = when (scheme) {
URI_SCHEME_FILE -> toFile().isNotEmpty() URI_SCHEME_FILE -> toFile().isNotEmpty()
URI_SCHEME_ZIP -> { URI_SCHEME_ZIP -> {
@@ -32,6 +35,7 @@ fun Uri.isTargetNotEmpty(): Boolean = when (scheme) {
else -> unsupportedUri(this) else -> unsupportedUri(this)
} }
@Blocking
fun Uri.source(): Source = when (scheme) { fun Uri.source(): Source = when (scheme) {
URI_SCHEME_FILE -> toFile().source() URI_SCHEME_FILE -> toFile().source()
URI_SCHEME_ZIP -> { URI_SCHEME_ZIP -> {
@@ -45,6 +49,8 @@ fun Uri.source(): Source = when (scheme) {
fun File.toZipUri(entryName: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryName") 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 { private fun unsupportedUri(uri: Uri): Nothing {
throw IllegalArgumentException("Bad uri $uri: only schemes $URI_SCHEME_FILE and $URI_SCHEME_ZIP are supported") throw IllegalArgumentException("Bad uri $uri: only schemes $URI_SCHEME_FILE and $URI_SCHEME_ZIP are supported")
} }

View File

@@ -8,7 +8,6 @@ import android.view.ViewGroup
import android.widget.Checkable import android.widget.Checkable
import androidx.appcompat.widget.ActionMenuView import androidx.appcompat.widget.ActionMenuView
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.view.SoftwareKeyboardControllerCompat
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.descendants import androidx.core.view.descendants
import androidx.core.view.isVisible import androidx.core.view.isVisible
@@ -23,14 +22,6 @@ import com.google.android.material.slider.Slider
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import kotlin.math.roundToInt import kotlin.math.roundToInt
fun View.hideKeyboard() {
SoftwareKeyboardControllerCompat(this).hide()
}
fun View.showKeyboard() {
SoftwareKeyboardControllerCompat(this).show()
}
fun View.hasGlobalPoint(x: Int, y: Int): Boolean { fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
if (visibility != View.VISIBLE) { if (visibility != View.VISIBLE) {
return false return false

View File

@@ -0,0 +1,94 @@
package org.koitharu.kotatsu.core.util.progress
import android.os.SystemClock
import androidx.annotation.AnyThread
import androidx.collection.CircularArray
import java.util.concurrent.TimeUnit
import kotlin.math.roundToLong
class RealtimeEtaEstimator {
private val ticks = CircularArray<Tick>(MAX_TICKS)
@Volatile
private var lastChange = 0L
@AnyThread
fun onProgressChanged(value: Int, total: Int) {
if (total <= 0 || value > total) {
reset()
return
}
val tick = Tick(value, total, SystemClock.elapsedRealtime())
synchronized(this) {
if (!ticks.isEmpty()) {
val last = ticks.last
if (last.value == tick.value && last.total == tick.total) {
ticks.popLast()
} else {
lastChange = tick.timestamp
}
} else {
lastChange = tick.timestamp
}
ticks.addLast(tick)
}
}
@AnyThread
fun reset() = synchronized(this) {
ticks.clear()
lastChange = 0L
}
@AnyThread
fun getEta(): Long {
val etl = getEstimatedTimeLeft()
return if (etl == NO_TIME || etl > MAX_TIME) NO_TIME else System.currentTimeMillis() + etl
}
@AnyThread
fun isStuck(): Boolean = synchronized(this) {
return ticks.size() >= MIN_ESTIMATE_TICKS && (SystemClock.elapsedRealtime() - lastChange) > STUCK_DELAY
}
private fun getEstimatedTimeLeft(): Long = synchronized(this) {
val ticksCount = ticks.size()
if (ticksCount < MIN_ESTIMATE_TICKS) {
return NO_TIME
}
val percentDiff = ticks.last.percent - ticks.first.percent
val timeDiff = ticks.last.timestamp - ticks.first.timestamp
if (percentDiff <= 0 || timeDiff <= 0) {
return NO_TIME
}
val averageTime = timeDiff / percentDiff
val percentLeft = 1.0 - ticks.last.percent
return (percentLeft * averageTime).roundToLong()
}
private class Tick(
@JvmField val value: Int,
@JvmField val total: Int,
@JvmField val timestamp: Long,
) {
init {
require(total > 0) { "total = $total" }
require(value >= 0) { "value = $value" }
require(value <= total) { "total = $total, value = $value" }
}
@JvmField
val percent = value.toDouble() / total.toDouble()
}
private companion object {
const val MAX_TICKS = 20
const val MIN_ESTIMATE_TICKS = 4
const val NO_TIME = -1L
const val STUCK_DELAY = 10_000L
val MAX_TIME = TimeUnit.DAYS.toMillis(1)
}
}

View File

@@ -1,69 +0,0 @@
package org.koitharu.kotatsu.core.util.progress
import android.os.SystemClock
import androidx.collection.IntList
import androidx.collection.MutableIntList
import java.util.concurrent.TimeUnit
import kotlin.math.roundToInt
import kotlin.math.roundToLong
private const val MIN_ESTIMATE_TICKS = 4
private const val NO_TIME = -1L
class TimeLeftEstimator {
private var times = MutableIntList()
private var lastTick: Tick? = null
private val tooLargeTime = TimeUnit.DAYS.toMillis(1)
fun tick(value: Int, total: Int) {
if (total < 0) {
emptyTick()
return
}
if (lastTick?.value == value) {
return
}
val tick = Tick(value, total, SystemClock.elapsedRealtime())
lastTick?.let {
val ticksCount = value - it.value
times.add(((tick.time - it.time) / ticksCount.toDouble()).roundToInt())
}
lastTick = tick
}
fun emptyTick() {
lastTick = null
}
fun getEstimatedTimeLeft(): Long {
val progress = lastTick ?: return NO_TIME
if (times.size < MIN_ESTIMATE_TICKS) {
return NO_TIME
}
val timePerTick = times.average()
val ticksLeft = progress.total - progress.value
val eta = (ticksLeft * timePerTick).roundToLong()
return if (eta < tooLargeTime) eta else NO_TIME
}
fun getEta(): Long {
val etl = getEstimatedTimeLeft()
return if (etl == NO_TIME) NO_TIME else System.currentTimeMillis() + etl
}
private fun IntList.average(): Double {
if (isEmpty()) {
return 0.0
}
var acc = 0L
forEach { acc += it }
return acc / size.toDouble()
}
private class Tick(
@JvmField val value: Int,
@JvmField val total: Int,
@JvmField val time: Long,
)
}

View File

@@ -27,7 +27,7 @@ import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.recoverNotNull import org.koitharu.kotatsu.parsers.util.recoverNotNull
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.tracker.domain.Tracker import org.koitharu.kotatsu.tracker.domain.CheckNewChaptersUseCase
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
@@ -37,7 +37,7 @@ class DetailsLoadUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val recoverUseCase: RecoverMangaUseCase, private val recoverUseCase: RecoverMangaUseCase,
private val imageGetter: Html.ImageGetter, private val imageGetter: Html.ImageGetter,
private val trackerProvider: Provider<Tracker>, private val newChaptersUseCaseProvider: Provider<CheckNewChaptersUseCase>,
) { ) {
operator fun invoke(intent: MangaIntent): Flow<MangaDetails> = channelFlow { operator fun invoke(intent: MangaIntent): Flow<MangaDetails> = channelFlow {
@@ -55,11 +55,32 @@ class DetailsLoadUseCase @Inject constructor(
try { try {
val details = getDetails(manga) val details = getDetails(manga)
launch { updateTracker(details) } launch { updateTracker(details) }
send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false)?.trim(), false)) send(
send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true)?.trim(), true)) MangaDetails(
details,
local?.peek(),
details.description?.parseAsHtml(withImages = false)?.trim(),
false,
),
)
send(
MangaDetails(
details,
local?.await(),
details.description?.parseAsHtml(withImages = true)?.trim(),
true,
),
)
} catch (e: IOException) { } catch (e: IOException) {
local?.await()?.manga?.also { localManga -> local?.await()?.manga?.also { localManga ->
send(MangaDetails(localManga, null, localManga.description?.parseAsHtml(withImages = false)?.trim(), true)) send(
MangaDetails(
localManga,
null,
localManga.description?.parseAsHtml(withImages = false)?.trim(),
true,
),
)
} ?: close(e) } ?: close(e)
} }
} }
@@ -97,7 +118,7 @@ class DetailsLoadUseCase @Inject constructor(
} }
private suspend fun updateTracker(details: Manga) = runCatchingCancellable { private suspend fun updateTracker(details: Manga) = runCatchingCancellable {
trackerProvider.get().syncWithDetails(details) newChaptersUseCaseProvider.get()(details)
}.onFailure { e -> }.onFailure { e ->
e.printStackTraceDebug() e.printStackTraceDebug()
} }

View File

@@ -5,7 +5,7 @@ import org.koitharu.kotatsu.core.model.findChapter
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.history.data.PROGRESS_NONE import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject import javax.inject.Inject

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.details.ui
import android.content.Context import android.content.Context
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem import org.koitharu.kotatsu.details.ui.model.toListItem
@@ -12,7 +11,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
fun MangaDetails.mapChapters( fun MangaDetails.mapChapters(
history: MangaHistory?, currentChapterId: Long,
newCount: Int, newCount: Int,
branch: String?, branch: String?,
bookmarks: List<Bookmark>, bookmarks: List<Bookmark>,
@@ -24,7 +23,6 @@ fun MangaDetails.mapChapters(
return emptyList() return emptyList()
} }
val bookmarked = bookmarks.mapToSet { it.chapterId } val bookmarked = bookmarks.mapToSet { it.chapterId }
val currentId = history?.chapterId ?: 0L
val newFrom = if (newCount == 0 || remoteChapters.isEmpty()) Int.MAX_VALUE else remoteChapters.size - newCount val newFrom = if (newCount == 0 || remoteChapters.isEmpty()) Int.MAX_VALUE else remoteChapters.size - newCount
val ids = buildSet(maxOf(remoteChapters.size, localChapters.size)) { val ids = buildSet(maxOf(remoteChapters.size, localChapters.size)) {
remoteChapters.mapTo(this) { it.id } remoteChapters.mapTo(this) { it.id }
@@ -36,14 +34,14 @@ fun MangaDetails.mapChapters(
} else { } else {
null null
} }
var isUnread = currentId !in ids var isUnread = currentChapterId !in ids
for (chapter in remoteChapters) { for (chapter in remoteChapters) {
val local = localMap?.remove(chapter.id) val local = localMap?.remove(chapter.id)
if (chapter.id == currentId) { if (chapter.id == currentChapterId) {
isUnread = true isUnread = true
} }
result += (local ?: chapter).toListItem( result += (local ?: chapter).toListItem(
isCurrent = chapter.id == currentId, isCurrent = chapter.id == currentChapterId,
isUnread = isUnread, isUnread = isUnread,
isNew = isUnread && result.size >= newFrom, isNew = isUnread && result.size >= newFrom,
isDownloaded = local != null, isDownloaded = local != null,
@@ -53,11 +51,11 @@ fun MangaDetails.mapChapters(
} }
if (!localMap.isNullOrEmpty()) { if (!localMap.isNullOrEmpty()) {
for (chapter in localMap.values) { for (chapter in localMap.values) {
if (chapter.id == currentId) { if (chapter.id == currentChapterId) {
isUnread = true isUnread = true
} }
result += chapter.toListItem( result += chapter.toListItem(
isCurrent = chapter.id == currentId, isCurrent = chapter.id == currentChapterId,
isUnread = isUnread, isUnread = isUnread,
isNew = false, isNew = false,
isDownloaded = !isLocal, isDownloaded = !isLocal,

View File

@@ -29,7 +29,7 @@ import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.request.SuccessResult import coil.request.SuccessResult
import coil.transform.CircleCropTransformation import coil.transform.RoundedCornersTransformation
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
@@ -68,6 +68,7 @@ import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.isTextTruncated import org.koitharu.kotatsu.core.util.ext.isTextTruncated
import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.parentView import org.koitharu.kotatsu.core.util.ext.parentView
@@ -160,7 +161,7 @@ class DetailsActivity :
} }
TitleExpandListener(viewBinding.textViewTitle).attach() TitleExpandListener(viewBinding.textViewTitle).attach()
viewModel.details.filterNotNull().observe(this, ::onMangaUpdated) viewModel.mangaDetails.filterNotNull().observe(this, ::onMangaUpdated)
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved) viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
viewModel.onError viewModel.onError
.filterNot { ChaptersPagesSheet.isShown(supportFragmentManager) } .filterNot { ChaptersPagesSheet.isShown(supportFragmentManager) }
@@ -356,23 +357,7 @@ class DetailsActivity :
chip.text = if (categories.isEmpty()) { chip.text = if (categories.isEmpty()) {
getString(R.string.add_to_favourites) getString(R.string.add_to_favourites)
} else { } else {
if (categories.size == 1) { categories.joinToStringWithLimit(this, FAV_LABEL_LIMIT) { it.title }
categories.first().title.ellipsize(FAV_LABEL_LIMIT)
}
buildString(FAV_LABEL_LIMIT + 6) {
for ((i, cat) in categories.withIndex()) {
if (i == 0) {
append(cat.title.ellipsize(FAV_LABEL_LIMIT - 4))
} else if (length + cat.title.length > FAV_LABEL_LIMIT) {
append(", ")
append(getString(R.string.list_ellipsize_pattern, categories.size - i))
break
} else {
append(", ")
append(cat.title)
}
}
}
} }
} }
@@ -490,7 +475,7 @@ class DetailsActivity :
.fallback(R.drawable.ic_web) .fallback(R.drawable.ic_web)
.error(R.drawable.ic_web) .error(R.drawable.ic_web)
.source(manga.source) .source(manga.source)
.transformations(CircleCropTransformation()) .transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.chip_icon_corner)))
.allowRgb565(true) .allowRgb565(true)
.enqueueWith(coil) .enqueueWith(coil)
} }

View File

@@ -12,32 +12,23 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import okio.FileNotFoundException
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.findById import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.getPreferredBranch import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.combine
import org.koitharu.kotatsu.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.onEachWhile import org.koitharu.kotatsu.core.util.ext.onEachWhile
import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.domain.BranchComparator import org.koitharu.kotatsu.details.domain.BranchComparator
import org.koitharu.kotatsu.details.domain.DetailsInteractor import org.koitharu.kotatsu.details.domain.DetailsInteractor
@@ -45,9 +36,9 @@ import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
import org.koitharu.kotatsu.details.domain.ReadingTimeUseCase import org.koitharu.kotatsu.details.domain.ReadingTimeUseCase
import org.koitharu.kotatsu.details.domain.RelatedMangaUseCase import org.koitharu.kotatsu.details.domain.RelatedMangaUseCase
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.model.MangaBranch import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.domain.MangaListMapper
@@ -57,6 +48,7 @@ import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
@@ -66,37 +58,42 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class DetailsViewModel @Inject constructor( class DetailsViewModel @Inject constructor(
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val bookmarksRepository: BookmarksRepository, bookmarksRepository: BookmarksRepository,
private val settings: AppSettings, settings: AppSettings,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>, private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>, @LocalStorageChanges localStorageChanges: SharedFlow<LocalManga?>,
private val downloadScheduler: DownloadWorker.Scheduler, downloadScheduler: DownloadWorker.Scheduler,
private val interactor: DetailsInteractor, private val interactor: DetailsInteractor,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
private val relatedMangaUseCase: RelatedMangaUseCase, private val relatedMangaUseCase: RelatedMangaUseCase,
private val mangaListMapper: MangaListMapper, private val mangaListMapper: MangaListMapper,
private val detailsLoadUseCase: DetailsLoadUseCase, private val detailsLoadUseCase: DetailsLoadUseCase,
private val progressUpdateUseCase: ProgressUpdateUseCase, private val progressUpdateUseCase: ProgressUpdateUseCase,
private val readingTimeUseCase: ReadingTimeUseCase, private val readingTimeUseCase: ReadingTimeUseCase,
private val statsRepository: StatsRepository, statsRepository: StatsRepository,
) : BaseViewModel() { ) : ChaptersPagesViewModel(
settings = settings,
interactor = interactor,
bookmarksRepository = bookmarksRepository,
historyRepository = historyRepository,
downloadScheduler = downloadScheduler,
deleteLocalMangaUseCase = deleteLocalMangaUseCase,
localStorageChanges = localStorageChanges,
) {
private val intent = MangaIntent(savedStateHandle) private val intent = MangaIntent(savedStateHandle)
private var loadingJob: Job private var loadingJob: Job
val mangaId = intent.mangaId val mangaId = intent.mangaId
val onActionDone = MutableEventFlow<ReversibleAction>() init {
val onSelectChapter = MutableEventFlow<Long>() mangaDetails.value = intent.manga?.let { MangaDetails(it, null, null, false) }
val onDownloadStarted = MutableEventFlow<Unit>() }
val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) })
val manga = details.map { x -> x?.toManga() }
.withErrorHandling()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val history = historyRepository.observeOne(mangaId) val history = historyRepository.observeOne(mangaId)
.withErrorHandling() .onEach { h ->
readingState.value = h?.let(::ReaderState)
}.withErrorHandling()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val favouriteCategories = interactor.observeFavourite(mangaId) val favouriteCategories = interactor.observeFavourite(mangaId)
@@ -109,31 +106,8 @@ class DetailsViewModel @Inject constructor(
val remoteManga = MutableStateFlow<Manga?>(null) val remoteManga = MutableStateFlow<Manga?>(null)
val newChaptersCount = details.flatMapLatest { d ->
if (d?.isLocal == false) {
interactor.observeNewChapters(mangaId)
} else {
flowOf(0)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
private val chaptersQuery = MutableStateFlow("")
val selectedBranch = MutableStateFlow<String?>(null)
val isChaptersReversed = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_REVERSE_CHAPTERS,
valueProducer = { isChaptersReverse },
)
val isChaptersInGridView = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_GRID_VIEW_CHAPTERS,
valueProducer = { isChaptersGridView },
)
val historyInfo: StateFlow<HistoryInfo> = combine( val historyInfo: StateFlow<HistoryInfo> = combine(
details, mangaDetails,
selectedBranch, selectedBranch,
history, history,
interactor.observeIncognitoMode(manga), interactor.observeIncognitoMode(manga),
@@ -145,11 +119,7 @@ class DetailsViewModel @Inject constructor(
initialValue = HistoryInfo(null, null, null, false), initialValue = HistoryInfo(null, null, null, false),
) )
val bookmarks = manga.flatMapLatest { val localSize = mangaDetails
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
val localSize = details
.map { it?.local } .map { it?.local }
.distinctUntilChanged() .distinctUntilChanged()
.combine(localStorageChanges.onStart { emit(null) }) { x, _ -> x } .combine(localStorageChanges.onStart { emit(null) }) { x, _ -> x }
@@ -163,7 +133,6 @@ class DetailsViewModel @Inject constructor(
} }
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), 0L) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), 0L)
val onMangaRemoved = MutableEventFlow<Manga>()
val isScrobblingAvailable: Boolean val isScrobblingAvailable: Boolean
get() = scrobblers.any { it.isEnabled } get() = scrobblers.any { it.isEnabled }
@@ -182,7 +151,7 @@ class DetailsViewModel @Inject constructor(
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList()) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
val branches: StateFlow<List<MangaBranch>> = combine( val branches: StateFlow<List<MangaBranch>> = combine(
details, mangaDetails,
selectedBranch, selectedBranch,
history, history,
) { m, b, h -> ) { m, b, h ->
@@ -201,35 +170,8 @@ class DetailsViewModel @Inject constructor(
}.sortedWith(BranchComparator()) }.sortedWith(BranchComparator())
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val isChaptersEmpty: StateFlow<Boolean> = details.map {
it != null && it.isLoaded && it.allChapters.isEmpty()
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
val chapters = combine(
combine(
details,
history,
selectedBranch,
newChaptersCount,
bookmarks,
isChaptersInGridView,
) { manga, history, branch, news, bookmarks, grid ->
manga?.mapChapters(
history,
news,
branch,
bookmarks,
grid,
).orEmpty()
},
isChaptersReversed,
chaptersQuery,
) { list, reversed, query ->
(if (reversed) list.asReversed() else list).filterSearch(query)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val readingTime = combine( val readingTime = combine(
details, mangaDetails,
selectedBranch, selectedBranch,
history, history,
) { m, b, h -> ) { m, b, h ->
@@ -242,18 +184,14 @@ class DetailsViewModel @Inject constructor(
init { init {
loadingJob = doLoad() loadingJob = doLoad()
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
localStorageChanges val manga = mangaDetails.firstOrNull { !it?.chapters.isNullOrEmpty() } ?: return@launchJob
.collect { onDownloadComplete(it) }
}
launchJob(Dispatchers.Default) {
val manga = details.firstOrNull { !it?.chapters.isNullOrEmpty() } ?: return@launchJob
val h = history.firstOrNull() val h = history.firstOrNull()
if (h != null) { if (h != null) {
progressUpdateUseCase(manga.toManga()) progressUpdateUseCase(manga.toManga())
} }
} }
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val manga = details.firstOrNull { it != null && it.isLocal } ?: return@launchJob val manga = mangaDetails.firstOrNull { it != null && it.isLocal } ?: return@launchJob
remoteManga.value = interactor.findRemote(manga.toManga()) remoteManga.value = interactor.findRemote(manga.toManga())
} }
} }
@@ -263,41 +201,6 @@ class DetailsViewModel @Inject constructor(
loadingJob = doLoad() loadingJob = doLoad()
} }
fun deleteLocal() {
val m = details.value?.local?.manga
if (m == null) {
errorEvent.call(FileNotFoundException())
return
}
launchLoadingJob(Dispatchers.Default) {
deleteLocalMangaUseCase(m)
onMangaRemoved.call(m)
}
}
fun removeBookmark(bookmark: Bookmark) {
launchJob(Dispatchers.Default) {
bookmarksRepository.removeBookmark(bookmark)
onActionDone.call(ReversibleAction(R.string.bookmark_removed, null))
}
}
fun setChaptersReversed(newValue: Boolean) {
settings.isChaptersReverse = newValue
}
fun setChaptersInGridView(newValue: Boolean) {
settings.isChaptersGridView = newValue
}
fun setSelectedBranch(branch: String?) {
selectedBranch.value = branch
}
fun performChapterSearch(query: String?) {
chaptersQuery.value = query?.trim().orEmpty()
}
fun updateScrobbling(index: Int, rating: Float, status: ScrobblingStatus?) { fun updateScrobbling(index: Int, rating: Float, status: ScrobblingStatus?) {
val scrobbler = getScrobbler(index) ?: return val scrobbler = getScrobbler(index) ?: return
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
@@ -319,34 +222,6 @@ class DetailsViewModel @Inject constructor(
} }
} }
fun markChapterAsCurrent(chapterId: Long) {
launchJob(Dispatchers.Default) {
val manga = checkNotNull(details.value)
val chapters = checkNotNull(manga.chapters[selectedBranchValue])
val chapterIndex = chapters.indexOfFirst { it.id == chapterId }
check(chapterIndex in chapters.indices) { "Chapter not found" }
val percent = chapterIndex / chapters.size.toFloat()
historyRepository.addOrUpdate(
manga = manga.toManga(),
chapterId = chapterId,
page = 0,
scroll = 0,
percent = percent,
force = true,
)
}
}
fun download(chaptersIds: Set<Long>?) {
launchJob(Dispatchers.Default) {
downloadScheduler.schedule(
details.requireValue().toManga(),
chaptersIds,
)
onDownloadStarted.call(Unit)
}
}
fun startChaptersSelection() { fun startChaptersSelection() {
val chapters = chapters.value val chapters = chapters.value
val chapter = chapters.find { val chapter = chapters.find {
@@ -374,28 +249,10 @@ class DetailsViewModel @Inject constructor(
selectedBranch.value = manga.getPreferredBranch(hist) selectedBranch.value = manga.getPreferredBranch(hist)
true true
}.collect { }.collect {
details.value = it mangaDetails.value = it
} }
} }
private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> {
if (query.isEmpty() || this.isEmpty()) {
return this
}
return filter {
it.chapter.name.contains(query, ignoreCase = true)
}
}
private suspend fun onDownloadComplete(downloadedManga: LocalManga?) {
downloadedManga ?: return
launchJob {
details.update {
interactor.updateLocal(it, downloadedManga)
}
}
}
private fun getScrobbler(index: Int): Scrobbler? { private fun getScrobbler(index: Int): Scrobbler? {
val info = scrobblingInfo.value.getOrNull(index) val info = scrobblingInfo.value.getOrNull(index)
val scrobbler = if (info != null) { val scrobbler = if (info != null) {

View File

@@ -4,7 +4,8 @@ import android.content.DialogInterface
import android.view.View import android.view.View
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.ui.dialog.setRecyclerViewList
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.download.ui.dialog.downloadOptionAD import org.koitharu.kotatsu.download.ui.dialog.downloadOptionAD
@@ -53,16 +54,14 @@ class DownloadDialogHelper(
callback.onItemClick(item, host) callback.onItemClick(item, host)
dialog?.dismiss() dialog?.dismiss()
} }
dialog = RecyclerViewAlertDialog.Builder<DownloadOption>(host.context) dialog = buildAlertDialog(host.context) {
.addAdapterDelegate(downloadOptionAD(listener)) setCancelable(true)
.setCancelable(true) setTitle(R.string.download)
.setTitle(R.string.download) setNegativeButton(android.R.string.cancel, null)
.setNegativeButton(android.R.string.cancel) setNeutralButton(R.string.settings) { _, _ ->
.setNeutralButton(R.string.settings) { _, _ ->
host.context.startActivity(SettingsActivity.newDownloadsSettingsIntent(host.context)) host.context.startActivity(SettingsActivity.newDownloadsSettingsIntent(host.context))
} }
.setItems(options) setRecyclerViewList(options, downloadOptionAD(listener))
.create() }.also { it.show() }
.also { it.show() }
} }
} }

View File

@@ -14,14 +14,13 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_BOOKMARKS import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_BOOKMARKS
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_CHAPTERS import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_CHAPTERS
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_PAGES import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_PAGES
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
class ChapterPagesMenuProvider( class ChapterPagesMenuProvider(
private val viewModel: DetailsViewModel, private val viewModel: ChaptersPagesViewModel,
private val sheet: BaseAdaptiveSheet<*>, private val sheet: BaseAdaptiveSheet<*>,
private val pager: ViewPager2, private val pager: ViewPager2,
private val settings: AppSettings, private val settings: AppSettings,

View File

@@ -7,7 +7,6 @@ import android.view.ViewGroup
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
@@ -30,7 +29,6 @@ import org.koitharu.kotatsu.core.util.ext.setTabsEnabled
import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetChaptersPagesBinding import org.koitharu.kotatsu.databinding.SheetChaptersPagesBinding
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import javax.inject.Inject import javax.inject.Inject
@@ -40,7 +38,7 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(), Actio
@Inject @Inject
lateinit var settings: AppSettings lateinit var settings: AppSettings
private val viewModel by activityViewModels<DetailsViewModel>() private val viewModel by ChaptersPagesViewModel.ActivityVMLazy(this)
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetChaptersPagesBinding { override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetChaptersPagesBinding {
return SheetChaptersPagesBinding.inflate(inflater, container, false) return SheetChaptersPagesBinding.inflate(inflater, container, false)
@@ -93,8 +91,8 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(), Actio
} }
override fun onActionModeStarted(mode: ActionMode) { override fun onActionModeStarted(mode: ActionMode) {
expandAndLock()
viewBinding?.toolbar?.menuView?.isVisible = false viewBinding?.toolbar?.menuView?.isVisible = false
view?.post(::expandAndLock)
} }
override fun onActionModeFinished(mode: ActionMode) { override fun onActionModeFinished(mode: ActionMode) {

View File

@@ -0,0 +1,233 @@
package org.koitharu.kotatsu.details.ui.pager
import android.app.Activity
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
import okio.FileNotFoundException
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.combine
import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.domain.DetailsInteractor
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.details.ui.mapChapters
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
abstract class ChaptersPagesViewModel(
@JvmField protected val settings: AppSettings,
private val interactor: DetailsInteractor,
private val bookmarksRepository: BookmarksRepository,
private val historyRepository: HistoryRepository,
private val downloadScheduler: DownloadWorker.Scheduler,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
private val localStorageChanges: SharedFlow<LocalManga?>,
) : BaseViewModel() {
val mangaDetails = MutableStateFlow<MangaDetails?>(null)
val readingState = MutableStateFlow<ReaderState?>(null)
val onActionDone = MutableEventFlow<ReversibleAction>()
val onSelectChapter = MutableEventFlow<Long>()
val onDownloadStarted = MutableEventFlow<Unit>()
val onMangaRemoved = MutableEventFlow<Manga>()
private val chaptersQuery = MutableStateFlow("")
val selectedBranch = MutableStateFlow<String?>(null)
val manga = mangaDetails.map { x -> x?.toManga() }
.withErrorHandling()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val isChaptersReversed = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_REVERSE_CHAPTERS,
valueProducer = { isChaptersReverse },
)
val isChaptersInGridView = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_GRID_VIEW_CHAPTERS,
valueProducer = { isChaptersGridView },
)
val newChaptersCount = mangaDetails.flatMapLatest { d ->
if (d?.isLocal == false) {
interactor.observeNewChapters(d.id)
} else {
flowOf(0)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
val isChaptersEmpty: StateFlow<Boolean> = mangaDetails.map {
it != null && it.isLoaded && it.allChapters.isEmpty()
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
val bookmarks = mangaDetails.flatMapLatest {
if (it != null) bookmarksRepository.observeBookmarks(it.toManga()) else flowOf(emptyList())
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
val chapters = combine(
combine(
mangaDetails,
readingState.map { it?.chapterId ?: 0L }.distinctUntilChanged(),
selectedBranch,
newChaptersCount,
bookmarks,
isChaptersInGridView,
) { manga, currentChapterId, branch, news, bookmarks, grid ->
manga?.mapChapters(
currentChapterId,
news,
branch,
bookmarks,
grid,
).orEmpty()
},
isChaptersReversed,
chaptersQuery,
) { list, reversed, query ->
(if (reversed) list.asReversed() else list).filterSearch(query)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
init {
launchJob(Dispatchers.Default) {
localStorageChanges
.collect { onDownloadComplete(it) }
}
}
fun setChaptersReversed(newValue: Boolean) {
settings.isChaptersReverse = newValue
}
fun setChaptersInGridView(newValue: Boolean) {
settings.isChaptersGridView = newValue
}
fun setSelectedBranch(branch: String?) {
selectedBranch.value = branch
}
fun performChapterSearch(query: String?) {
chaptersQuery.value = query?.trim().orEmpty()
}
fun getMangaOrNull(): Manga? = mangaDetails.value?.toManga()
fun requireManga() = mangaDetails.requireValue().toManga()
fun markChapterAsCurrent(chapterId: Long) {
launchJob(Dispatchers.Default) {
val manga = mangaDetails.requireValue()
val chapters = checkNotNull(manga.chapters[selectedBranch.value])
val chapterIndex = chapters.indexOfFirst { it.id == chapterId }
check(chapterIndex in chapters.indices) { "Chapter not found" }
val percent = chapterIndex / chapters.size.toFloat()
historyRepository.addOrUpdate(
manga = manga.toManga(),
chapterId = chapterId,
page = 0,
scroll = 0,
percent = percent,
force = true,
)
}
}
fun download(chaptersIds: Set<Long>?) {
launchJob(Dispatchers.Default) {
downloadScheduler.schedule(
requireManga(),
chaptersIds,
)
onDownloadStarted.call(Unit)
}
}
fun deleteLocal() {
val m = mangaDetails.value?.local?.manga
if (m == null) {
errorEvent.call(FileNotFoundException())
return
}
launchLoadingJob(Dispatchers.Default) {
deleteLocalMangaUseCase(m)
onMangaRemoved.call(m)
}
}
private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> {
if (query.isEmpty() || this.isEmpty()) {
return this
}
return filter {
it.chapter.name.contains(query, ignoreCase = true)
}
}
private suspend fun onDownloadComplete(downloadedManga: LocalManga?) {
downloadedManga ?: return
mangaDetails.update {
interactor.updateLocal(it, downloadedManga)
}
}
class ActivityVMLazy(
private val fragment: Fragment,
) : Lazy<ChaptersPagesViewModel> {
private var cached: ChaptersPagesViewModel? = null
override val value: ChaptersPagesViewModel
get() {
val viewModel = cached
return if (viewModel == null) {
val activity = fragment.requireActivity()
val vmClass = getViewModelClass(activity)
ViewModelProvider.create(
store = activity.viewModelStore,
factory = activity.defaultViewModelProviderFactory,
extras = activity.defaultViewModelCreationExtras,
)[vmClass].also { cached = it }
} else {
viewModel
}
}
override fun isInitialized(): Boolean = cached != null
private fun getViewModelClass(activity: Activity) = when (activity) {
is ReaderActivity -> ReaderViewModel::class.java
is DetailsActivity -> DetailsViewModel::class.java
else -> error("Wrong activity ${activity.javaClass.simpleName} for ${ChaptersPagesViewModel::class.java.simpleName}")
}
}
}

View File

@@ -8,7 +8,6 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import coil.ImageLoader import coil.ImageLoader
@@ -30,7 +29,7 @@ import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.FragmentMangaBookmarksBinding import org.koitharu.kotatsu.databinding.FragmentMangaBookmarksBinding
import org.koitharu.kotatsu.details.ui.DetailsViewModel import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
import org.koitharu.kotatsu.list.ui.GridSpanResolver import org.koitharu.kotatsu.list.ui.GridSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
@@ -40,9 +39,9 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class BookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(), class BookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
OnListItemClickListener<Bookmark>, ListSelectionController.Callback2 { OnListItemClickListener<Bookmark>, ListSelectionController.Callback {
private val activityViewModel by activityViewModels<DetailsViewModel>() private val activityViewModel by ChaptersPagesViewModel.ActivityVMLazy(this)
private val viewModel by viewModels<BookmarksViewModel>() private val viewModel by viewModels<BookmarksViewModel>()
@Inject @Inject
@@ -62,7 +61,7 @@ class BookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
activityViewModel.manga.observe(this, viewModel) activityViewModel.mangaDetails.observe(this, viewModel)
} }
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentMangaBookmarksBinding { override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentMangaBookmarksBinding {
@@ -125,7 +124,7 @@ class BookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
dismissParentDialog() dismissParentDialog()
} else { } else {
val intent = IntentBuilder(view.context) val intent = IntentBuilder(view.context)
.manga(activityViewModel.manga.value ?: return) .manga(activityViewModel.getMangaOrNull() ?: return)
.bookmark(item) .bookmark(item)
.incognito(true) .incognito(true)
.build() .build()

View File

@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -32,7 +33,7 @@ import javax.inject.Inject
class BookmarksViewModel @Inject constructor( class BookmarksViewModel @Inject constructor(
private val bookmarksRepository: BookmarksRepository, private val bookmarksRepository: BookmarksRepository,
settings: AppSettings, settings: AppSettings,
) : BaseViewModel(), FlowCollector<Manga?> { ) : BaseViewModel(), FlowCollector<MangaDetails?> {
private val manga = MutableStateFlow<Manga?>(null) private val manga = MutableStateFlow<Manga?>(null)
val onActionDone = MutableEventFlow<ReversibleAction>() val onActionDone = MutableEventFlow<ReversibleAction>()
@@ -50,8 +51,8 @@ class BookmarksViewModel @Inject constructor(
.filterNotNull() .filterNotNull()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState)) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState))
override suspend fun emit(value: Manga?) { override suspend fun emit(value: MangaDetails?) {
manga.value = value manga.value = value?.toManga()
} }
fun removeBookmarks(ids: Set<Long>) { fun removeBookmarks(ids: Set<Long>) {

View File

@@ -2,26 +2,21 @@ package org.koitharu.kotatsu.details.ui.pager.chapters
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.ancestors import androidx.core.view.ancestors
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
@@ -32,28 +27,25 @@ import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.findParentCallback import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.toCollection
import org.koitharu.kotatsu.core.util.ext.toSet
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
import org.koitharu.kotatsu.details.ui.withVolumeHeaders import org.koitharu.kotatsu.details.ui.withVolumeHeaders
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import kotlin.math.roundToInt import kotlin.math.roundToInt
@AndroidEntryPoint
class ChaptersFragment : class ChaptersFragment :
BaseFragment<FragmentChaptersBinding>(), BaseFragment<FragmentChaptersBinding>(),
OnListItemClickListener<ChapterListItem>, OnListItemClickListener<ChapterListItem> {
ListSelectionController.Callback2 {
private val viewModel by activityViewModels<DetailsViewModel>() private val viewModel by ChaptersPagesViewModel.ActivityVMLazy(this)
private var chaptersAdapter: ChaptersAdapter? = null private var chaptersAdapter: ChaptersAdapter? = null
private var selectionController: ListSelectionController? = null private var selectionController: ListSelectionController? = null
@@ -70,7 +62,7 @@ class ChaptersFragment :
appCompatDelegate = checkNotNull(findAppCompatDelegate()), appCompatDelegate = checkNotNull(findAppCompatDelegate()),
decoration = ChaptersSelectionDecoration(binding.root.context), decoration = ChaptersSelectionDecoration(binding.root.context),
registryOwner = this, registryOwner = this,
callback = this, callback = ChaptersSelectionCallback(viewModel, binding.recyclerViewChapters),
) )
viewModel.isChaptersInGridView.observe(viewLifecycleOwner) { chaptersInGridView -> viewModel.isChaptersInGridView.observe(viewLifecycleOwner) { chaptersInGridView ->
binding.recyclerViewChapters.layoutManager = if (chaptersInGridView) { binding.recyclerViewChapters.layoutManager = if (chaptersInGridView) {
@@ -116,7 +108,7 @@ class ChaptersFragment :
} else { } else {
startActivity( startActivity(
IntentBuilder(view.context) IntentBuilder(view.context)
.manga(viewModel.manga.value ?: return) .manga(viewModel.getMangaOrNull() ?: return)
.state(ReaderState(item.chapter.id, 0, 0)) .state(ReaderState(item.chapter.id, 0, 0))
.build(), .build(),
) )
@@ -127,126 +119,6 @@ class ChaptersFragment :
return selectionController?.onItemLongClick(item.chapter.id) ?: false return selectionController?.onItemLongClick(item.chapter.id) ?: false
} }
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_save -> {
viewModel.download(selectionController?.snapshot())
mode.finish()
true
}
R.id.action_delete -> {
val ids = selectionController?.peekCheckedIds()
val manga = viewModel.manga.value
when {
ids == null || ids.isEmpty() || manga == null -> Unit
ids.size == manga.chapters?.size -> viewModel.deleteLocal()
else -> {
LocalChaptersRemoveService.start(requireContext(), manga, ids.toSet())
Snackbar.make(
requireViewBinding().recyclerViewChapters,
R.string.chapters_will_removed_background,
Snackbar.LENGTH_LONG,
).show()
}
}
mode.finish()
true
}
R.id.action_select_range -> {
val items = chaptersAdapter?.items ?: return false
val ids = controller.peekCheckedIds().toCollection(HashSet())
val buffer = HashSet<Long>()
var isAdding = false
for (x in items) {
if (x !is ChapterListItem) {
continue
}
if (x.chapter.id in ids) {
isAdding = true
if (buffer.isNotEmpty()) {
ids.addAll(buffer)
buffer.clear()
}
} else if (isAdding) {
buffer.add(x.chapter.id)
}
}
controller.addAll(ids)
true
}
R.id.action_select_all -> {
val ids = chaptersAdapter?.items?.mapNotNull {
if (it is ChapterListItem) {
it.chapter.id
} else {
null
}
} ?: return false
controller.addAll(ids)
true
}
R.id.action_mark_current -> {
val ids = controller.peekCheckedIds()
if (ids.size == 1) {
viewModel.markChapterAsCurrent(ids.first())
} else {
return false
}
mode.finish()
true
}
else -> false
}
}
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
return true
}
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
val selectedIds = selectionController?.peekCheckedIds() ?: return false
val allItems = chaptersAdapter?.items.orEmpty()
val items = allItems.withIndex().mapNotNull<IndexedValue<ListModel>, IndexedValue<ChapterListItem>> { x ->
val value = x.value
@Suppress("UNCHECKED_CAST")
if (value is ChapterListItem && value.chapter.id in selectedIds) {
x as IndexedValue<ChapterListItem>
} else {
null
}
}
var canSave = true
var canDelete = true
items.forEach { (_, x) ->
val isLocal = x.isDownloaded || x.chapter.source == LocalMangaSource
if (isLocal) canSave = false else canDelete = false
}
menu.findItem(R.id.action_save).isVisible = canSave
menu.findItem(R.id.action_delete).isVisible = canDelete
menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size
menu.findItem(R.id.action_mark_current).isVisible = items.size == 1
mode.title = items.size.toString()
var hasGap = false
for (i in 0 until items.size - 1) {
if (items[i].index + 1 != items[i + 1].index) {
hasGap = true
break
}
}
menu.findItem(R.id.action_select_range).isVisible = hasGap
return true
}
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
viewBinding?.recyclerViewChapters?.invalidateItemDecorations()
}
override fun onWindowInsetsChanged(insets: Insets) = Unit override fun onWindowInsetsChanged(insets: Insets) = Unit
private fun onChaptersChanged(list: List<ListModel>) { private fun onChaptersChanged(list: List<ListModel>) {

View File

@@ -0,0 +1,122 @@
package org.koitharu.kotatsu.details.ui.pager.chapters
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.view.ActionMode
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.ui.list.BaseListSelectionCallback
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.util.ext.toCollection
import org.koitharu.kotatsu.core.util.ext.toSet
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
class ChaptersSelectionCallback(
private val viewModel: ChaptersPagesViewModel,
recyclerView: RecyclerView,
) : BaseListSelectionCallback(recyclerView) {
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
return true
}
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
val selectedIds = controller.peekCheckedIds()
val allItems = viewModel.chapters.value
val items = allItems.withIndex().filter { it.value.chapter.id in selectedIds }
var canSave = true
var canDelete = true
items.forEach { (_, x) ->
val isLocal = x.isDownloaded || x.chapter.source == LocalMangaSource
if (isLocal) canSave = false else canDelete = false
}
menu.findItem(R.id.action_save).isVisible = canSave
menu.findItem(R.id.action_delete).isVisible = canDelete
menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size
menu.findItem(R.id.action_mark_current).isVisible = items.size == 1
mode.title = items.size.toString()
var hasGap = false
for (i in 0 until items.size - 1) {
if (items[i].index + 1 != items[i + 1].index) {
hasGap = true
break
}
}
menu.findItem(R.id.action_select_range).isVisible = hasGap
return true
}
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_save -> {
viewModel.download(controller.snapshot())
mode.finish()
true
}
R.id.action_delete -> {
val ids = controller.peekCheckedIds()
val manga = viewModel.getMangaOrNull()
when {
ids.isEmpty() || manga == null -> Unit
ids.size == manga.chapters?.size -> viewModel.deleteLocal()
else -> {
LocalChaptersRemoveService.start(recyclerView.context, manga, ids.toSet())
Snackbar.make(
recyclerView,
R.string.chapters_will_removed_background,
Snackbar.LENGTH_LONG,
).show()
}
}
mode.finish()
true
}
R.id.action_select_range -> {
val items = viewModel.chapters.value
val ids = controller.peekCheckedIds().toCollection(HashSet())
val buffer = HashSet<Long>()
var isAdding = false
for (x in items) {
if (x.chapter.id in ids) {
isAdding = true
if (buffer.isNotEmpty()) {
ids.addAll(buffer)
buffer.clear()
}
} else if (isAdding) {
buffer.add(x.chapter.id)
}
}
controller.addAll(ids)
true
}
R.id.action_select_all -> {
val ids = viewModel.chapters.value.map {
it.chapter.id
}
controller.addAll(ids)
true
}
R.id.action_mark_current -> {
val ids = controller.peekCheckedIds()
if (ids.size == 1) {
viewModel.markChapterAsCurrent(ids.first())
} else {
return false
}
mode.finish()
true
}
else -> false
}
}
}

View File

@@ -7,7 +7,6 @@ import android.view.ViewGroup
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@@ -30,7 +29,7 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.showOrHide import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.databinding.FragmentPagesBinding import org.koitharu.kotatsu.databinding.FragmentPagesBinding
import org.koitharu.kotatsu.details.ui.DetailsViewModel import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
import org.koitharu.kotatsu.list.ui.GridSpanResolver import org.koitharu.kotatsu.list.ui.GridSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
@@ -46,15 +45,15 @@ class PagesFragment :
BaseFragment<FragmentPagesBinding>(), BaseFragment<FragmentPagesBinding>(),
OnListItemClickListener<PageThumbnail> { OnListItemClickListener<PageThumbnail> {
private val detailsViewModel by activityViewModels<DetailsViewModel>()
private val viewModel by viewModels<PagesViewModel>()
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
@Inject @Inject
lateinit var settings: AppSettings lateinit var settings: AppSettings
private val parentViewModel by ChaptersPagesViewModel.ActivityVMLazy(this)
private val viewModel by viewModels<PagesViewModel>()
private var thumbnailsAdapter: PageThumbnailAdapter? = null private var thumbnailsAdapter: PageThumbnailAdapter? = null
private var spanResolver: GridSpanResolver? = null private var spanResolver: GridSpanResolver? = null
private var scrollListener: ScrollListener? = null private var scrollListener: ScrollListener? = null
@@ -64,12 +63,12 @@ class PagesFragment :
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
combine( combine(
detailsViewModel.details, parentViewModel.mangaDetails,
detailsViewModel.history, parentViewModel.readingState,
detailsViewModel.selectedBranch, parentViewModel.selectedBranch,
) { details, history, branch -> ) { details, readingState, branch ->
if (details != null && (details.isLoaded || details.chapters.isNotEmpty())) { if (details != null && (details.isLoaded || details.chapters.isNotEmpty())) {
PagesViewModel.State(details.filterChapters(branch), history, branch) PagesViewModel.State(details.filterChapters(branch), readingState, branch)
} else { } else {
null null
} }
@@ -102,7 +101,7 @@ class PagesFragment :
it.spanCount = checkNotNull(spanResolver).spanCount it.spanCount = checkNotNull(spanResolver).spanCount
} }
} }
detailsViewModel.isChaptersEmpty.observe(viewLifecycleOwner, ::onNoChaptersChanged) parentViewModel.isChaptersEmpty.observe(viewLifecycleOwner, ::onNoChaptersChanged)
viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged) viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged)
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) } viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) }
@@ -127,7 +126,7 @@ class PagesFragment :
} else { } else {
startActivity( startActivity(
IntentBuilder(view.context) IntentBuilder(view.context)
.manga(detailsViewModel.manga.value ?: return) .manga(parentViewModel.getMangaOrNull() ?: return)
.state(ReaderState(item.page.chapterId, item.page.index, 0)) .state(ReaderState(item.page.chapterId, item.page.index, 0))
.build(), .build(),
) )

View File

@@ -7,7 +7,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
@@ -16,12 +15,13 @@ import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.domain.ChaptersLoader import org.koitharu.kotatsu.reader.domain.ChaptersLoader
import org.koitharu.kotatsu.reader.ui.ReaderState
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class PagesViewModel @Inject constructor( class PagesViewModel @Inject constructor(
private val chaptersLoader: ChaptersLoader, private val chaptersLoader: ChaptersLoader,
private val settings: AppSettings, settings: AppSettings,
) : BaseViewModel() { ) : BaseViewModel() {
private var loadingJob: Job? = null private var loadingJob: Job? = null
@@ -75,13 +75,13 @@ class PagesViewModel @Inject constructor(
private suspend fun doInit(state: State) { private suspend fun doInit(state: State) {
chaptersLoader.init(state.details) chaptersLoader.init(state.details)
val initialChapterId = state.history?.chapterId?.takeIf { val initialChapterId = state.readerState?.chapterId?.takeIf {
chaptersLoader.peekChapter(it) != null chaptersLoader.peekChapter(it) != null
} ?: state.details.allChapters.firstOrNull()?.id ?: return } ?: state.details.allChapters.firstOrNull()?.id ?: return
if (!chaptersLoader.hasPages(initialChapterId)) { if (!chaptersLoader.hasPages(initialChapterId)) {
chaptersLoader.loadSingleChapter(initialChapterId) chaptersLoader.loadSingleChapter(initialChapterId)
} }
updateList(state.history) updateList(state.readerState)
} }
private fun loadPrevNextChapter(isNext: Boolean): Job = launchJob(Dispatchers.Default) { private fun loadPrevNextChapter(isNext: Boolean): Job = launchJob(Dispatchers.Default) {
@@ -91,13 +91,13 @@ class PagesViewModel @Inject constructor(
val currentState = state.firstNotNull() val currentState = state.firstNotNull()
val currentId = (if (isNext) chaptersLoader.last() else chaptersLoader.first()).chapterId val currentId = (if (isNext) chaptersLoader.last() else chaptersLoader.first()).chapterId
chaptersLoader.loadPrevNextChapter(currentState.details, currentId, isNext) chaptersLoader.loadPrevNextChapter(currentState.details, currentId, isNext)
updateList(currentState.history) updateList(currentState.readerState)
} finally { } finally {
indicator.value = false indicator.value = false
} }
} }
private fun updateList(history: MangaHistory?) { private fun updateList(readerState: ReaderState?) {
val snapshot = chaptersLoader.snapshot() val snapshot = chaptersLoader.snapshot()
val pages = buildList(snapshot.size + chaptersLoader.size + 2) { val pages = buildList(snapshot.size + chaptersLoader.size + 2) {
var previousChapterId = 0L var previousChapterId = 0L
@@ -109,7 +109,7 @@ class PagesViewModel @Inject constructor(
previousChapterId = page.chapterId previousChapterId = page.chapterId
} }
this += PageThumbnail( this += PageThumbnail(
isCurrent = history?.let { isCurrent = readerState?.let {
page.chapterId == it.chapterId && page.index == it.page page.chapterId == it.chapterId && page.index == it.page
} ?: false, } ?: false,
page = page, page = page,
@@ -121,7 +121,7 @@ class PagesViewModel @Inject constructor(
data class State( data class State(
val details: MangaDetails, val details: MangaDetails,
val history: MangaHistory?, val readerState: ReaderState?,
val branch: String? val branch: String?
) )
} }

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.download.domain
data class DownloadProgress(
val totalChapters: Int,
val currentChapter: Int,
val totalPages: Int,
val currentPage: Int,
)

View File

@@ -1,7 +1,7 @@
package org.koitharu.kotatsu.download.domain package org.koitharu.kotatsu.download.domain
import androidx.work.Data import androidx.work.Data
import org.koitharu.kotatsu.history.data.PROGRESS_NONE import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.time.Instant import java.time.Instant
@@ -11,12 +11,14 @@ data class DownloadState(
val isIndeterminate: Boolean, val isIndeterminate: Boolean,
val isPaused: Boolean = false, val isPaused: Boolean = false,
val isStopped: Boolean = false, val isStopped: Boolean = false,
val error: String? = null, val error: Throwable? = null,
val errorMessage: String? = null,
val totalChapters: Int = 0, val totalChapters: Int = 0,
val currentChapter: Int = 0, val currentChapter: Int = 0,
val totalPages: Int = 0, val totalPages: Int = 0,
val currentPage: Int = 0, val currentPage: Int = 0,
val eta: Long = -1L, val eta: Long = -1L,
val isStuck: Boolean = false,
val localManga: LocalManga? = null, val localManga: LocalManga? = null,
val downloadedChapters: Int = 0, val downloadedChapters: Int = 0,
val timestamp: Long = System.currentTimeMillis(), val timestamp: Long = System.currentTimeMillis(),
@@ -39,8 +41,9 @@ data class DownloadState(
.putInt(DATA_MAX, max) .putInt(DATA_MAX, max)
.putInt(DATA_PROGRESS, progress) .putInt(DATA_PROGRESS, progress)
.putLong(DATA_ETA, eta) .putLong(DATA_ETA, eta)
.putBoolean(DATA_STUCK, isStuck)
.putLong(DATA_TIMESTAMP, timestamp) .putLong(DATA_TIMESTAMP, timestamp)
.putString(DATA_ERROR, error) .putString(DATA_ERROR, errorMessage)
.putInt(DATA_CHAPTERS, downloadedChapters) .putInt(DATA_CHAPTERS, downloadedChapters)
.putBoolean(DATA_INDETERMINATE, isIndeterminate) .putBoolean(DATA_INDETERMINATE, isIndeterminate)
.putBoolean(DATA_PAUSED, isPaused) .putBoolean(DATA_PAUSED, isPaused)
@@ -53,6 +56,7 @@ data class DownloadState(
private const val DATA_PROGRESS = "progress" private const val DATA_PROGRESS = "progress"
private const val DATA_CHAPTERS = "chapter_cnt" private const val DATA_CHAPTERS = "chapter_cnt"
private const val DATA_ETA = "eta" private const val DATA_ETA = "eta"
private const val DATA_STUCK = "stuck"
const val DATA_TIMESTAMP = "timestamp" const val DATA_TIMESTAMP = "timestamp"
private const val DATA_ERROR = "error" private const val DATA_ERROR = "error"
private const val DATA_INDETERMINATE = "indeterminate" private const val DATA_INDETERMINATE = "indeterminate"
@@ -72,6 +76,8 @@ data class DownloadState(
fun getEta(data: Data): Long = data.getLong(DATA_ETA, -1L) fun getEta(data: Data): Long = data.getLong(DATA_ETA, -1L)
fun isStuck(data: Data): Boolean = data.getBoolean(DATA_STUCK, false)
fun getTimestamp(data: Data): Instant = Instant.ofEpochMilli(data.getLong(DATA_TIMESTAMP, 0L)) fun getTimestamp(data: Data): Instant = Instant.ofEpochMilli(data.getLong(DATA_TIMESTAMP, 0L))
fun getDownloadedChapters(data: Data): Int = data.getInt(DATA_CHAPTERS, 0) fun getDownloadedChapters(data: Data): Int = data.getInt(DATA_CHAPTERS, 0)

View File

@@ -45,8 +45,9 @@ fun downloadItemAD(
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.button_cancel -> listener.onCancelClick(item) R.id.button_cancel -> listener.onCancelClick(item)
R.id.button_resume -> listener.onResumeClick(item, skip = false) R.id.button_resume -> listener.onResumeClick(item)
R.id.button_skip -> listener.onResumeClick(item, skip = true) R.id.button_skip -> listener.onSkipClick(item)
R.id.button_skip_all -> listener.onSkipAllClick(item)
R.id.button_pause -> listener.onPauseClick(item) R.id.button_pause -> listener.onPauseClick(item)
R.id.imageView_expand -> listener.onExpandClick(item) R.id.imageView_expand -> listener.onExpandClick(item)
else -> listener.onItemClick(item, v) else -> listener.onItemClick(item, v)
@@ -65,6 +66,7 @@ fun downloadItemAD(
binding.buttonPause.setOnClickListener(clickListener) binding.buttonPause.setOnClickListener(clickListener)
binding.buttonResume.setOnClickListener(clickListener) binding.buttonResume.setOnClickListener(clickListener)
binding.buttonSkip.setOnClickListener(clickListener) binding.buttonSkip.setOnClickListener(clickListener)
binding.buttonSkipAll.setOnClickListener(clickListener)
binding.imageViewExpand.setOnClickListener(clickListener) binding.imageViewExpand.setOnClickListener(clickListener)
itemView.setOnClickListener(clickListener) itemView.setOnClickListener(clickListener)
itemView.setOnLongClickListener(clickListener) itemView.setOnLongClickListener(clickListener)
@@ -136,9 +138,14 @@ fun downloadItemAD(
binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty()) binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty())
binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1)) binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1))
binding.textViewPercent.isVisible = true binding.textViewPercent.isVisible = true
binding.textViewDetails.textAndVisible = if (item.isPaused) item.error else item.getEtaString() binding.textViewDetails.textAndVisible = when {
item.isPaused -> item.getErrorMessage(context)
item.isStuck -> context.getString(R.string.stuck)
else -> item.getEtaString()
}
binding.buttonCancel.isVisible = true binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = item.isPaused binding.buttonResume.isVisible = item.isPaused
binding.buttonResume.setText(if (item.error == null) R.string.resume else R.string.retry)
binding.buttonSkip.isVisible = item.isPaused && item.error != null binding.buttonSkip.isVisible = item.isPaused && item.error != null
binding.buttonPause.isVisible = item.canPause binding.buttonPause.isVisible = item.canPause
} }
@@ -171,7 +178,7 @@ fun downloadItemAD(
binding.progressBar.isVisible = false binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false binding.textViewPercent.isVisible = false
binding.textViewDetails.textAndVisible = item.error binding.textViewDetails.textAndVisible = item.getErrorMessage(context)
binding.buttonCancel.isVisible = false binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false binding.buttonResume.isVisible = false
binding.buttonSkip.isVisible = false binding.buttonSkip.isVisible = false

View File

@@ -8,7 +8,11 @@ interface DownloadItemListener : OnListItemClickListener<DownloadItemModel> {
fun onPauseClick(item: DownloadItemModel) fun onPauseClick(item: DownloadItemModel)
fun onResumeClick(item: DownloadItemModel, skip: Boolean) fun onResumeClick(item: DownloadItemModel)
fun onSkipClick(item: DownloadItemModel)
fun onSkipAllClick(item: DownloadItemModel)
fun onExpandClick(item: DownloadItemModel) fun onExpandClick(item: DownloadItemModel)
} }

View File

@@ -1,15 +1,22 @@
package org.koitharu.kotatsu.download.ui.list package org.koitharu.kotatsu.download.ui.list
import android.content.Context
import android.graphics.Color
import android.text.format.DateUtils import android.text.format.DateUtils
import androidx.core.text.bold
import androidx.core.text.buildSpannedString
import androidx.core.text.color
import androidx.work.WorkInfo import androidx.work.WorkInfo
import coil.memory.MemoryCache import coil.memory.MemoryCache
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.time.Instant import java.time.Instant
import java.util.UUID import java.util.UUID
import com.google.android.material.R as materialR
data class DownloadItemModel( data class DownloadItemModel(
val id: UUID, val id: UUID,
@@ -21,6 +28,7 @@ data class DownloadItemModel(
val max: Int, val max: Int,
val progress: Int, val progress: Int,
val eta: Long, val eta: Long,
val isStuck: Boolean,
val timestamp: Instant, val timestamp: Instant,
val chaptersDownloaded: Int, val chaptersDownloaded: Int,
val isExpanded: Boolean, val isExpanded: Boolean,
@@ -51,6 +59,18 @@ data class DownloadItemModel(
null null
} }
fun getErrorMessage(context: Context): CharSequence? = if (error != null) {
buildSpannedString {
bold {
color(context.getThemeColor(materialR.attr.colorError, Color.RED)) {
append(error)
}
}
}
} else {
null
}
override fun compareTo(other: DownloadItemModel): Int { override fun compareTo(other: DownloadItemModel): Int {
return timestamp.compareTo(other.timestamp) return timestamp.compareTo(other.timestamp)
} }

View File

@@ -1,7 +1,5 @@
package org.koitharu.kotatsu.download.ui.list package org.koitharu.kotatsu.download.ui.list
import android.content.Context
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
@@ -22,18 +20,21 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.worker.PausingReceiver import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(), class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
DownloadItemListener, DownloadItemListener,
ListSelectionController.Callback2 { ListSelectionController.Callback {
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
@Inject
lateinit var scheduler: DownloadWorker.Scheduler
private val viewModel by viewModels<DownloadsViewModel>() private val viewModel by viewModels<DownloadsViewModel>()
private lateinit var selectionController: ListSelectionController private lateinit var selectionController: ListSelectionController
@@ -102,11 +103,19 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
} }
override fun onPauseClick(item: DownloadItemModel) { override fun onPauseClick(item: DownloadItemModel) {
sendBroadcast(PausingReceiver.getPauseIntent(this, item.id)) scheduler.pause(item.id)
} }
override fun onResumeClick(item: DownloadItemModel, skip: Boolean) { override fun onResumeClick(item: DownloadItemModel) {
sendBroadcast(PausingReceiver.getResumeIntent(this, item.id, skip)) scheduler.resume(item.id)
}
override fun onSkipClick(item: DownloadItemModel) {
scheduler.skip(item.id)
}
override fun onSkipAllClick(item: DownloadItemModel) {
scheduler.skipAll(item.id)
} }
override fun onSelectionChanged(controller: ListSelectionController, count: Int) { override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
@@ -171,9 +180,4 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
menu.findItem(R.id.action_remove)?.isVisible = canRemove menu.findItem(R.id.action_remove)?.isVisible = canRemove
return super.onPrepareActionMode(controller, mode, menu) return super.onPrepareActionMode(controller, mode, menu)
} }
companion object {
fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java)
}
} }

View File

@@ -5,9 +5,8 @@ import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
class DownloadsMenuProvider( class DownloadsMenuProvider(
@@ -42,24 +41,22 @@ class DownloadsMenuProvider(
} }
private fun confirmCancelAll() { private fun confirmCancelAll() {
MaterialAlertDialogBuilder(context, DIALOG_THEME_CENTERED) buildAlertDialog(context, isCentered = true) {
.setTitle(R.string.cancel_all) setTitle(R.string.cancel_all)
.setMessage(R.string.cancel_all_downloads_confirm) setMessage(R.string.cancel_all_downloads_confirm)
.setIcon(R.drawable.ic_cancel_multiple) setIcon(R.drawable.ic_cancel_multiple)
.setNegativeButton(android.R.string.cancel, null) setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.confirm) { _, _ -> setPositiveButton(R.string.confirm) { _, _ -> viewModel.cancelAll() }
viewModel.cancelAll() }.show()
}.show()
} }
private fun confirmRemoveCompleted() { private fun confirmRemoveCompleted() {
MaterialAlertDialogBuilder(context, DIALOG_THEME_CENTERED) buildAlertDialog(context, isCentered = true) {
.setTitle(R.string.remove_completed) setTitle(R.string.remove_completed)
.setMessage(R.string.remove_completed_downloads_confirm) setMessage(R.string.remove_completed_downloads_confirm)
.setIcon(R.drawable.ic_clear_all) setIcon(R.drawable.ic_clear_all)
.setNegativeButton(android.R.string.cancel, null) setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ -> setPositiveButton(R.string.clear) { _, _ -> viewModel.removeCompleted() }
viewModel.removeCompleted() }.show()
}.show()
} }
} }

View File

@@ -143,7 +143,7 @@ class DownloadsViewModel @Inject constructor(
var isResumed = false var isResumed = false
for (work in snapshot) { for (work in snapshot) {
if (work.workState == WorkInfo.State.RUNNING && work.isPaused) { if (work.workState == WorkInfo.State.RUNNING && work.isPaused) {
workScheduler.resume(work.id, skipError = false) workScheduler.resume(work.id)
isResumed = true isResumed = true
} }
} }
@@ -156,7 +156,7 @@ class DownloadsViewModel @Inject constructor(
val snapshot = works.value ?: return val snapshot = works.value ?: return
for (work in snapshot) { for (work in snapshot) {
if (work.id.mostSignificantBits in ids) { if (work.id.mostSignificantBits in ids) {
workScheduler.resume(work.id, skipError = false) workScheduler.resume(work.id)
} }
} }
onActionDone.call(ReversibleAction(R.string.downloads_resumed, null)) onActionDone.call(ReversibleAction(R.string.downloads_resumed, null))
@@ -268,6 +268,7 @@ class DownloadsViewModel @Inject constructor(
max = DownloadState.getMax(workData), max = DownloadState.getMax(workData),
progress = DownloadState.getProgress(workData), progress = DownloadState.getProgress(workData),
eta = DownloadState.getEta(workData), eta = DownloadState.getEta(workData),
isStuck = DownloadState.isStuck(workData),
timestamp = DownloadState.getTimestamp(workData), timestamp = DownloadState.getTimestamp(workData),
chaptersDownloaded = DownloadState.getDownloadedChapters(workData), chaptersDownloaded = DownloadState.getDownloadedChapters(workData),
isExpanded = isExpanded, isExpanded = isExpanded,

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.download.ui.worker
import android.app.Notification import android.app.Notification
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.text.format.DateUtils import android.text.format.DateUtils
import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationChannelCompat
@@ -21,8 +22,10 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ErrorReporterReceiver
import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
import org.koitharu.kotatsu.core.util.ext.isReportable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
@@ -57,7 +60,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
private val queueIntent = PendingIntentCompat.getActivity( private val queueIntent = PendingIntentCompat.getActivity(
context, context,
0, 0,
DownloadsActivity.newIntent(context), Intent(context, DownloadsActivity::class.java),
0, 0,
false, false,
) )
@@ -82,7 +85,15 @@ class DownloadNotificationFactory @AssistedInject constructor(
NotificationCompat.Action( NotificationCompat.Action(
R.drawable.ic_action_resume, R.drawable.ic_action_resume,
context.getString(R.string.resume), context.getString(R.string.resume),
PausingReceiver.createResumePendingIntent(context, uuid, skipError = false), PausingReceiver.createResumePendingIntent(context, uuid),
)
}
private val actionRetry by lazy {
NotificationCompat.Action(
R.drawable.ic_retry,
context.getString(R.string.retry),
actionResume.actionIntent,
) )
} }
@@ -90,7 +101,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
NotificationCompat.Action( NotificationCompat.Action(
R.drawable.ic_action_skip, R.drawable.ic_action_skip,
context.getString(R.string.skip), context.getString(R.string.skip),
PausingReceiver.createResumePendingIntent(context, uuid, skipError = true), PausingReceiver.createSkipPendingIntent(context, uuid),
) )
} }
@@ -160,8 +171,14 @@ class DownloadNotificationFactory @AssistedInject constructor(
} else { } else {
null null
} }
if (state.error != null) { if (state.errorMessage != null) {
builder.setContentText(context.getString(R.string.download_summary_pattern, percent, state.error)) builder.setContentText(
context.getString(
R.string.download_summary_pattern,
percent,
state.errorMessage,
),
)
} else { } else {
builder.setContentText(percent) builder.setContentText(percent)
} }
@@ -170,9 +187,11 @@ class DownloadNotificationFactory @AssistedInject constructor(
builder.setOngoing(true) builder.setOngoing(true)
builder.setSmallIcon(R.drawable.ic_stat_paused) builder.setSmallIcon(R.drawable.ic_stat_paused)
builder.addAction(actionCancel) builder.addAction(actionCancel)
builder.addAction(actionResume) if (state.errorMessage != null) {
if (state.error != null) { builder.addAction(actionRetry)
builder.addAction(actionSkip) builder.addAction(actionSkip)
} else {
builder.addAction(actionResume)
} }
} }
@@ -180,18 +199,27 @@ class DownloadNotificationFactory @AssistedInject constructor(
builder.setProgress(0, 0, false) builder.setProgress(0, 0, false)
builder.setSmallIcon(android.R.drawable.stat_notify_error) builder.setSmallIcon(android.R.drawable.stat_notify_error)
builder.setSubText(context.getString(R.string.error)) builder.setSubText(context.getString(R.string.error))
builder.setContentText(state.error) builder.setContentText(state.errorMessage)
builder.setAutoCancel(true) builder.setAutoCancel(true)
builder.setOngoing(false) builder.setOngoing(false)
builder.setCategory(NotificationCompat.CATEGORY_ERROR) builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setShowWhen(true) builder.setShowWhen(true)
builder.setWhen(System.currentTimeMillis()) builder.setWhen(System.currentTimeMillis())
builder.setStyle(NotificationCompat.BigTextStyle().bigText(state.error)) builder.setStyle(NotificationCompat.BigTextStyle().bigText(state.errorMessage))
if (state.error.isReportable()) {
builder.addAction(
NotificationCompat.Action(
0,
context.getString(R.string.report),
ErrorReporterReceiver.getPendingIntent(context, state.error),
),
)
}
} }
else -> { else -> {
builder.setProgress(state.max, state.progress, false) builder.setProgress(state.max, state.progress, false)
builder.setContentText(getProgressString(state.percent, state.eta)) builder.setContentText(getProgressString(state.percent, state.eta, state.isStuck))
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null) builder.setStyle(null)
builder.setOngoing(true) builder.setOngoing(true)
@@ -202,20 +230,20 @@ class DownloadNotificationFactory @AssistedInject constructor(
return builder.build() return builder.build()
} }
private fun getProgressString(percent: Float, eta: Long): CharSequence? { private fun getProgressString(percent: Float, eta: Long, isStuck: Boolean): CharSequence? {
val percentString = if (percent >= 0f) { val percentString = if (percent >= 0f) {
context.getString(R.string.percent_string_pattern, (percent * 100).format()) context.getString(R.string.percent_string_pattern, (percent * 100).format())
} else { } else {
null null
} }
val etaString = if (eta > 0L) { val etaString = when {
DateUtils.getRelativeTimeSpanString( eta <= 0L -> null
isStuck -> context.getString(R.string.stuck)
else -> DateUtils.getRelativeTimeSpanString(
eta, eta,
System.currentTimeMillis(), System.currentTimeMillis(),
DateUtils.SECOND_IN_MILLIS, DateUtils.SECOND_IN_MILLIS,
) )
} else {
null
} }
return when { return when {
percentString == null && etaString == null -> null percentString == null && etaString == null -> null

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.download.ui.worker package org.koitharu.kotatsu.download.ui.worker
import android.content.Intent
import android.view.View import android.view.View
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.FlowCollector
@@ -18,7 +19,7 @@ class DownloadStartedObserver(
snackbar.anchorView = it.bottomNav snackbar.anchorView = it.bottomNav
} }
snackbar.setAction(R.string.details) { snackbar.setAction(R.string.details) {
it.context.startActivity(DownloadsActivity.newIntent(it.context)) it.context.startActivity(Intent(it.context, DownloadsActivity::class.java))
} }
snackbar.show() snackbar.show()
} }

View File

@@ -30,6 +30,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
@@ -41,7 +42,6 @@ import okio.buffer
import okio.sink import okio.sink
import okio.use import okio.use
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
@@ -62,8 +62,10 @@ import org.koitharu.kotatsu.core.util.ext.getWorkInputData
import org.koitharu.kotatsu.core.util.ext.getWorkSpec import org.koitharu.kotatsu.core.util.ext.getWorkSpec
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.withTicker
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.core.util.progress.TimeLeftEstimator import org.koitharu.kotatsu.core.util.progress.RealtimeEtaEstimator
import org.koitharu.kotatsu.download.domain.DownloadProgress
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
@@ -71,7 +73,9 @@ import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.TempFileFilter import org.koitharu.kotatsu.local.data.TempFileFilter
import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.domain.MangaLock
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -91,6 +95,7 @@ class DownloadWorker @AssistedInject constructor(
@MangaHttpClient private val okHttp: OkHttpClient, @MangaHttpClient private val okHttp: OkHttpClient,
private val cache: PagesCache, private val cache: PagesCache,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val mangaLock: MangaLock,
private val mangaDataRepository: MangaDataRepository, private val mangaDataRepository: MangaDataRepository,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val settings: AppSettings, private val settings: AppSettings,
@@ -108,7 +113,7 @@ class DownloadWorker @AssistedInject constructor(
private val currentState: DownloadState private val currentState: DownloadState
get() = checkNotNull(lastPublishedState) get() = checkNotNull(lastPublishedState)
private val timeLeftEstimator = TimeLeftEstimator() private val etaEstimator = RealtimeEtaEstimator()
private val notificationThrottler = Throttler(400) private val notificationThrottler = Throttler(400)
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
@@ -130,17 +135,16 @@ class DownloadWorker @AssistedInject constructor(
notificationManager.notify(id.hashCode(), notification) notificationManager.notify(id.hashCode(), notification)
} }
Result.failure( Result.failure(
currentState.copy(eta = -1L).toWorkData(), currentState.copy(eta = -1L, isStuck = false).toWorkData(),
) )
} catch (e: IOException) {
e.printStackTraceDebug()
Result.retry()
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTraceDebug() e.printStackTraceDebug()
Result.failure( Result.failure(
currentState.copy( currentState.copy(
error = e.getDisplayMessage(applicationContext.resources), error = e,
errorMessage = e.getDisplayMessage(applicationContext.resources),
eta = -1L, eta = -1L,
isStuck = false,
).toWorkData(), ).toWorkData(),
) )
} finally { } finally {
@@ -169,7 +173,7 @@ class DownloadWorker @AssistedInject constructor(
var manga = subject var manga = subject
val chaptersToSkip = excludedIds.toMutableSet() val chaptersToSkip = excludedIds.toMutableSet()
val pausingReceiver = PausingReceiver(id, PausingHandle.current()) val pausingReceiver = PausingReceiver(id, PausingHandle.current())
withMangaLock(manga) { mangaLock.withLock(manga) {
ContextCompat.registerReceiver( ContextCompat.registerReceiver(
applicationContext, applicationContext,
pausingReceiver, pausingReceiver,
@@ -229,15 +233,23 @@ class DownloadWorker @AssistedInject constructor(
} }
} }
} }
}.collect { }.map {
DownloadProgress(
totalChapters = chapters.size,
currentChapter = chapterIndex,
totalPages = pages.size,
currentPage = pageCounter.getAndIncrement(),
)
}.withTicker(2L, TimeUnit.SECONDS).collect { progress ->
publishState( publishState(
currentState.copy( currentState.copy(
totalChapters = chapters.size, totalChapters = progress.totalChapters,
currentChapter = chapterIndex, currentChapter = progress.currentChapter,
totalPages = pages.size, totalPages = progress.totalPages,
currentPage = pageCounter.incrementAndGet(), currentPage = progress.currentPage,
isIndeterminate = false, isIndeterminate = false,
eta = timeLeftEstimator.getEta(), eta = etaEstimator.getEta(),
isStuck = etaEstimator.isStuck(),
), ),
) )
} }
@@ -248,15 +260,20 @@ class DownloadWorker @AssistedInject constructor(
} }
publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1)) publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1))
} }
publishState(currentState.copy(isIndeterminate = true, eta = -1L)) publishState(currentState.copy(isIndeterminate = true, eta = -1L, isStuck = false))
output.mergeWithExisting() output.mergeWithExisting()
output.finish() output.finish()
val localManga = LocalMangaInput.of(output.rootFile).getManga() val localManga = LocalMangaInput.of(output.rootFile).getManga()
localStorageChanges.emit(localManga) localStorageChanges.emit(localManga)
publishState(currentState.copy(localManga = localManga, eta = -1L)) publishState(currentState.copy(localManga = localManga, eta = -1L, isStuck = false))
} catch (e: Exception) { } catch (e: Exception) {
if (e !is CancellationException) { if (e !is CancellationException) {
publishState(currentState.copy(error = e.getDisplayMessage(applicationContext.resources))) publishState(
currentState.copy(
error = e,
errorMessage = e.getDisplayMessage(applicationContext.resources),
),
)
} }
throw e throw e
} finally { } finally {
@@ -281,12 +298,19 @@ class DownloadWorker @AssistedInject constructor(
try { try {
return block() return block()
} catch (e: IOException) { } catch (e: IOException) {
if (countDown <= 0) { val retryDelay = if (e is TooManyRequestExceptions) {
e.getRetryDelay()
} else {
DOWNLOAD_ERROR_DELAY
}
if (countDown <= 0 || retryDelay < 0 || retryDelay > MAX_RETRY_DELAY) {
publishState( publishState(
currentState.copy( currentState.copy(
isPaused = true, isPaused = true,
error = e.getDisplayMessage(applicationContext.resources), error = e,
errorMessage = e.getDisplayMessage(applicationContext.resources),
eta = -1L, eta = -1L,
isStuck = false,
), ),
) )
countDown = MAX_FAILSAFE_ATTEMPTS countDown = MAX_FAILSAFE_ATTEMPTS
@@ -298,15 +322,10 @@ class DownloadWorker @AssistedInject constructor(
return null return null
} }
} finally { } finally {
publishState(currentState.copy(isPaused = false, error = null)) publishState(currentState.copy(isPaused = false, error = null, errorMessage = null))
} }
} else { } else {
countDown-- countDown--
val retryDelay = if (e is TooManyRequestExceptions) {
e.retryAfter + DOWNLOAD_ERROR_DELAY
} else {
DOWNLOAD_ERROR_DELAY
}
delay(retryDelay) delay(retryDelay)
} }
} }
@@ -316,7 +335,7 @@ class DownloadWorker @AssistedInject constructor(
private suspend fun checkIsPaused() { private suspend fun checkIsPaused() {
val pausingHandle = PausingHandle.current() val pausingHandle = PausingHandle.current()
if (pausingHandle.isPaused) { if (pausingHandle.isPaused) {
publishState(currentState.copy(isPaused = true, eta = -1L)) publishState(currentState.copy(isPaused = true, eta = -1L, isStuck = false))
try { try {
pausingHandle.awaitResumed() pausingHandle.awaitResumed()
} finally { } finally {
@@ -354,9 +373,9 @@ class DownloadWorker @AssistedInject constructor(
val previousState = currentState val previousState = currentState
lastPublishedState = state lastPublishedState = state
if (previousState.isParticularProgress && state.isParticularProgress) { if (previousState.isParticularProgress && state.isParticularProgress) {
timeLeftEstimator.tick(state.progress, state.max) etaEstimator.onProgressChanged(state.progress, state.max)
} else { } else {
timeLeftEstimator.emptyTick() etaEstimator.reset()
notificationThrottler.reset() notificationThrottler.reset()
} }
val notification = notificationFactory.create(state) val notification = notificationFactory.create(state)
@@ -399,13 +418,6 @@ class DownloadWorker @AssistedInject constructor(
return result return result
} }
private suspend inline fun <T> withMangaLock(manga: Manga, block: () -> T) = try {
localMangaRepository.lockManga(manga.id)
block()
} finally {
localMangaRepository.unlockManga(manga.id)
}
@Reusable @Reusable
class Scheduler @Inject constructor( class Scheduler @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
@@ -458,15 +470,21 @@ class DownloadWorker @AssistedInject constructor(
workManager.cancelAllWorkByTag(TAG).await() workManager.cancelAllWorkByTag(TAG).await()
} }
fun pause(id: UUID) { fun pause(id: UUID) = context.sendBroadcast(
val intent = PausingReceiver.getPauseIntent(context, id) PausingReceiver.getPauseIntent(context, id),
context.sendBroadcast(intent) )
}
fun resume(id: UUID, skipError: Boolean) { fun resume(id: UUID) = context.sendBroadcast(
val intent = PausingReceiver.getResumeIntent(context, id, skipError) PausingReceiver.getResumeIntent(context, id),
context.sendBroadcast(intent) )
}
fun skip(id: UUID) = context.sendBroadcast(
PausingReceiver.getSkipIntent(context, id),
)
fun skipAll(id: UUID) = context.sendBroadcast(
PausingReceiver.getSkipAllIntent(context, id),
)
suspend fun delete(id: UUID) { suspend fun delete(id: UUID) {
workManager.deleteWork(id) workManager.deleteWork(id)
@@ -526,7 +544,8 @@ class DownloadWorker @AssistedInject constructor(
const val MAX_FAILSAFE_ATTEMPTS = 2 const val MAX_FAILSAFE_ATTEMPTS = 2
const val MAX_PAGES_PARALLELISM = 4 const val MAX_PAGES_PARALLELISM = 4
const val DOWNLOAD_ERROR_DELAY = 500L const val DOWNLOAD_ERROR_DELAY = 2_000L
const val MAX_RETRY_DELAY = 7_200_000L // 2 hours
const val SLOWDOWN_DELAY = 200L const val SLOWDOWN_DELAY = 200L
const val MANGA_ID = "manga_id" const val MANGA_ID = "manga_id"
const val CHAPTERS_IDS = "chapters" const val CHAPTERS_IDS = "chapters"

View File

@@ -10,7 +10,10 @@ import kotlin.coroutines.CoroutineContext
class PausingHandle : AbstractCoroutineContextElement(PausingHandle) { class PausingHandle : AbstractCoroutineContextElement(PausingHandle) {
private val paused = MutableStateFlow(false) private val paused = MutableStateFlow(false)
private val isSkipError = MutableStateFlow(false) private val skipError = MutableStateFlow(false)
@Volatile
private var skipAllErrors = false
@get:AnyThread @get:AnyThread
val isPaused: Boolean val isPaused: Boolean
@@ -27,18 +30,30 @@ class PausingHandle : AbstractCoroutineContextElement(PausingHandle) {
} }
@AnyThread @AnyThread
fun resume(skipError: Boolean) { fun resume() {
isSkipError.value = skipError skipError.value = false
paused.value = false paused.value = false
} }
@AnyThread
fun skip() {
skipError.value = true
paused.value = false
}
@AnyThread
fun skipAll() {
skipAllErrors = true
skip()
}
suspend fun yield() { suspend fun yield() {
if (paused.value) { if (paused.value) {
paused.first { !it } paused.first { !it }
} }
} }
fun skipCurrentError(): Boolean = isSkipError.compareAndSet(expect = true, update = false) fun skipCurrentError(): Boolean = skipError.compareAndSet(expect = true, update = skipAllErrors)
companion object : CoroutineContext.Key<PausingHandle> { companion object : CoroutineContext.Key<PausingHandle> {

View File

@@ -21,8 +21,9 @@ class PausingReceiver(
return return
} }
when (intent.action) { when (intent.action) {
ACTION_RESUME -> pausingHandle.resume(skipError = false) ACTION_RESUME -> pausingHandle.resume()
ACTION_SKIP -> pausingHandle.resume(skipError = true) ACTION_SKIP -> pausingHandle.skip()
ACTION_SKIP_ALL -> pausingHandle.skipAll()
ACTION_PAUSE -> pausingHandle.pause() ACTION_PAUSE -> pausingHandle.pause()
} }
} }
@@ -32,6 +33,7 @@ class PausingReceiver(
private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE" private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE"
private const val ACTION_RESUME = "org.koitharu.kotatsu.download.RESUME" private const val ACTION_RESUME = "org.koitharu.kotatsu.download.RESUME"
private const val ACTION_SKIP = "org.koitharu.kotatsu.download.SKIP" private const val ACTION_SKIP = "org.koitharu.kotatsu.download.SKIP"
private const val ACTION_SKIP_ALL = "org.koitharu.kotatsu.download.SKIP_ALL"
private const val EXTRA_UUID = "uuid" private const val EXTRA_UUID = "uuid"
private const val SCHEME = "workuid" private const val SCHEME = "workuid"
@@ -39,20 +41,18 @@ class PausingReceiver(
addAction(ACTION_PAUSE) addAction(ACTION_PAUSE)
addAction(ACTION_RESUME) addAction(ACTION_RESUME)
addAction(ACTION_SKIP) addAction(ACTION_SKIP)
addAction(ACTION_SKIP_ALL)
addDataScheme(SCHEME) addDataScheme(SCHEME)
addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB) addDataPath(id.toString(), PatternMatcher.PATTERN_LITERAL)
} }
fun getPauseIntent(context: Context, id: UUID) = Intent(ACTION_PAUSE) fun getPauseIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_PAUSE)
.setData(Uri.parse("$SCHEME://$id"))
.setPackage(context.packageName)
.putExtra(EXTRA_UUID, id.toString())
fun getResumeIntent(context: Context, id: UUID, skipError: Boolean) = Intent( fun getResumeIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_RESUME)
if (skipError) ACTION_SKIP else ACTION_RESUME,
).setData(Uri.parse("$SCHEME://$id")) fun getSkipIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_SKIP)
.setPackage(context.packageName)
.putExtra(EXTRA_UUID, id.toString()) fun getSkipAllIntent(context: Context, id: UUID) = createIntent(context, id, ACTION_SKIP_ALL)
fun createPausePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast( fun createPausePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast(
context, context,
@@ -62,13 +62,27 @@ class PausingReceiver(
false, false,
) )
fun createResumePendingIntent(context: Context, id: UUID, skipError: Boolean) = fun createResumePendingIntent(context: Context, id: UUID) =
PendingIntentCompat.getBroadcast( PendingIntentCompat.getBroadcast(
context, context,
0, 0,
getResumeIntent(context, id, skipError), getResumeIntent(context, id),
0, 0,
false, false,
) )
fun createSkipPendingIntent(context: Context, id: UUID) =
PendingIntentCompat.getBroadcast(
context,
0,
getSkipIntent(context, id),
0,
false,
)
private fun createIntent(context: Context, id: UUID, action: String) = Intent(action)
.setData(Uri.parse("$SCHEME://$id"))
.setPackage(context.packageName)
.putExtra(EXTRA_UUID, id.toString())
} }
} }

View File

@@ -27,6 +27,7 @@ import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.flattenLatest
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -61,7 +62,13 @@ class MangaSourcesRepository @Inject constructor(
suspend fun getEnabledSources(): List<MangaSource> { suspend fun getEnabledSources(): List<MangaSource> {
assimilateNewSources() assimilateNewSources()
val order = settings.sourcesSortOrder val order = settings.sourcesSortOrder
return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order) return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order).let { enabled ->
val external = getExternalSources()
val list = ArrayList<MangaSourceInfo>(enabled.size + external.size)
external.mapTo(list) { MangaSourceInfo(it, isEnabled = true, isPinned = true) }
list.addAll(enabled)
list
}
} }
suspend fun getPinnedSources(): Set<MangaSource> { suspend fun getPinnedSources(): Set<MangaSource> {
@@ -162,7 +169,7 @@ class MangaSourcesRepository @Inject constructor(
dao.observeEnabled(order).map { dao.observeEnabled(order).map {
it.toSources(skipNsfw, order) it.toSources(skipNsfw, order)
} }
}.flatMapLatest { it } }.flattenLatest()
.onStart { assimilateNewSources() } .onStart { assimilateNewSources() }
.combine(observeExternalSources()) { enabled, external -> .combine(observeExternalSources()) { enabled, external ->
val list = ArrayList<MangaSourceInfo>(enabled.size + external.size) val list = ArrayList<MangaSourceInfo>(enabled.size + external.size)
@@ -308,8 +315,6 @@ class MangaSourcesRepository @Inject constructor(
} }
private fun observeExternalSources(): Flow<List<ExternalMangaSource>> { private fun observeExternalSources(): Flow<List<ExternalMangaSource>> {
val intent = Intent("app.kotatsu.parser.PROVIDE_MANGA")
val pm = context.packageManager
return callbackFlow { return callbackFlow {
val receiver = object : BroadcastReceiver() { val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
@@ -333,15 +338,19 @@ class MangaSourcesRepository @Inject constructor(
}.onStart { }.onStart {
emit(null) emit(null)
}.map { }.map {
pm.queryIntentContentProviders(intent, 0).map { resolveInfo -> getExternalSources()
ExternalMangaSource(
packageName = resolveInfo.providerInfo.packageName,
authority = resolveInfo.providerInfo.authority,
)
}
}.distinctUntilChanged() }.distinctUntilChanged()
} }
private fun getExternalSources() = context.packageManager.queryIntentContentProviders(
Intent("app.kotatsu.parser.PROVIDE_MANGA"), 0,
).map { resolveInfo ->
ExternalMangaSource(
packageName = resolveInfo.providerInfo.packageName,
authority = resolveInfo.providerInfo.authority,
)
}
private fun List<MangaSourceEntity>.toSources( private fun List<MangaSourceEntity>.toSources(
skipNsfwSources: Boolean, skipNsfwSources: Boolean,
sortOrder: SourcesSortOrder?, sortOrder: SourcesSortOrder?,

View File

@@ -56,7 +56,7 @@ class ExploreFragment :
BaseFragment<FragmentExploreBinding>(), BaseFragment<FragmentExploreBinding>(),
RecyclerViewOwner, RecyclerViewOwner,
ExploreListEventListener, ExploreListEventListener,
OnListItemClickListener<MangaSourceItem>, ListSelectionController.Callback2 { OnListItemClickListener<MangaSourceItem>, ListSelectionController.Callback {
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
@@ -129,7 +129,7 @@ class ExploreFragment :
R.id.button_local -> MangaListActivity.newIntent(v.context, LocalMangaSource) R.id.button_local -> MangaListActivity.newIntent(v.context, LocalMangaSource)
R.id.button_bookmarks -> AllBookmarksActivity.newIntent(v.context) R.id.button_bookmarks -> AllBookmarksActivity.newIntent(v.context)
R.id.button_more -> SuggestionsActivity.newIntent(v.context) R.id.button_more -> SuggestionsActivity.newIntent(v.context)
R.id.button_downloads -> DownloadsActivity.newIntent(v.context) R.id.button_downloads -> Intent(v.context, DownloadsActivity::class.java)
R.id.button_random -> { R.id.button_random -> {
viewModel.openRandom() viewModel.openRandom()
return return
@@ -257,6 +257,7 @@ class ExploreFragment :
val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Intent.ACTION_DELETE Intent.ACTION_DELETE
} else { } else {
@Suppress("DEPRECATION")
Intent.ACTION_UNINSTALL_PACKAGE Intent.ACTION_UNINSTALL_PACKAGE
} }
context?.startActivity(Intent(action, uri)) context?.startActivity(Intent(action, uri))

View File

@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.RoomWarnings
import androidx.room.Upsert import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -51,6 +52,10 @@ abstract class FavouriteCategoriesDao {
@Query("SELECT MAX(sort_key) FROM favourite_categories WHERE deleted_at = 0") @Query("SELECT MAX(sort_key) FROM favourite_categories WHERE deleted_at = 0")
protected abstract suspend fun getMaxSortKey(): Int? protected abstract suspend fun getMaxSortKey(): Int?
@SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) // for the new_chapters column
@Query("SELECT favourite_categories.*, (SELECT SUM(chapters_new) FROM tracks WHERE tracks.manga_id IN (SELECT manga_id FROM favourites WHERE favourites.category_id = favourite_categories.category_id)) AS new_chapters FROM favourite_categories WHERE track = 1 AND show_in_lib = 1 AND deleted_at = 0 AND new_chapters > 0 ORDER BY new_chapters DESC LIMIT :limit")
abstract suspend fun getMostUpdatedCategories(limit: Int): List<FavouriteCategoryEntity>
suspend fun getNextSortKey(): Int { suspend fun getNextSortKey(): Int {
return (getMaxSortKey() ?: 0) + 1 return (getMaxSortKey() ?: 0) + 1
} }

View File

@@ -11,11 +11,15 @@ import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.core.db.MangaQueryBuilder
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_COMPLETED
@Dao @Dao
abstract class FavouritesDao { abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback {
/** SELECT **/ /** SELECT **/
@@ -27,27 +31,17 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit") @Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit")
abstract suspend fun findLast(limit: Int): List<FavouriteManga> abstract suspend fun findLast(limit: Int): List<FavouriteManga>
fun observeAll(order: ListSortOrder, limit: Int): Flow<List<FavouriteManga>> { fun observeAll(
val orderBy = getOrderBy(order) order: ListSortOrder,
val query = buildString { filterOptions: Set<ListFilterOption>,
append( limit: Int
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " + ): Flow<List<FavouriteManga>> = observeAll(0L, order, filterOptions, limit)
"WHERE favourites.deleted_at = 0 GROUP BY favourites.manga_id ORDER BY ",
)
append(orderBy)
if (limit > 0) {
append(" LIMIT ")
append(limit)
}
}
return observeAllImpl(SimpleSQLiteQuery(query))
}
@Transaction @Transaction
@Query("SELECT * FROM favourites WHERE deleted_at = 0 ORDER BY created_at DESC LIMIT :limit OFFSET :offset") @Query("SELECT * FROM favourites WHERE deleted_at = 0 ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
abstract suspend fun findAllRaw(offset: Int, limit: Int): List<FavouriteManga> abstract suspend fun findAllRaw(offset: Int, limit: Int): List<FavouriteManga>
@Query("SELECT DISTINCT manga_id FROM favourites WHERE deleted_at = 0 AND category_id IN (SELECT category_id FROM favourite_categories WHERE track = 1)") @Query("SELECT DISTINCT manga_id FROM favourites WHERE deleted_at = 0 AND category_id IN (SELECT category_id FROM favourite_categories WHERE track = 1 AND deleted_at = 0)")
abstract suspend fun findIdsWithTrack(): LongArray abstract suspend fun findIdsWithTrack(): LongArray
@Transaction @Transaction
@@ -57,22 +51,28 @@ abstract class FavouritesDao {
) )
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga> abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
fun observeAll(categoryId: Long, order: ListSortOrder, limit: Int): Flow<List<FavouriteManga>> { fun observeAll(
val orderBy = getOrderBy(order) categoryId: Long,
val query = buildString { order: ListSortOrder,
append( filterOptions: Set<ListFilterOption>,
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " + limit: Int
"WHERE category_id = ? AND deleted_at = 0 GROUP BY favourites.manga_id ORDER BY ", ): Flow<List<FavouriteManga>> = observeAllImpl(
MangaQueryBuilder(TABLE_FAVOURITES, this)
.join("LEFT JOIN manga ON favourites.manga_id = manga.manga_id")
.where("deleted_at = 0")
.where(
if (categoryId != 0L) {
"category_id = $categoryId"
} else {
"(SELECT show_in_lib FROM favourite_categories WHERE favourite_categories.category_id = favourites.category_id) = 1"
},
) )
append(orderBy) .filters(filterOptions)
if (limit > 0) { .groupBy("favourites.manga_id")
append(" LIMIT ") .orderBy(getOrderBy(order))
append(limit) .limit(limit)
} .build(),
} )
return observeAllImpl(SimpleSQLiteQuery(query, arrayOf<Any>(categoryId)))
}
suspend fun findCovers(categoryId: Long, order: ListSortOrder): List<Cover> { suspend fun findCovers(categoryId: Long, order: ListSortOrder): List<Cover> {
val orderBy = getOrderBy(order) val orderBy = getOrderBy(order)
@@ -94,7 +94,9 @@ abstract class FavouritesDao {
val query = SimpleSQLiteQuery( val query = SimpleSQLiteQuery(
"SELECT manga.cover_url AS url, manga.source AS source FROM favourites " + "SELECT manga.cover_url AS url, manga.source AS source FROM favourites " +
"LEFT JOIN manga ON favourites.manga_id = manga.manga_id " + "LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
"WHERE deleted_at = 0 GROUP BY manga.manga_id ORDER BY $orderBy LIMIT ?", "WHERE deleted_at = 0 AND " +
"(SELECT show_in_lib FROM favourite_categories WHERE favourite_categories.category_id = favourites.category_id) = 1 " +
"GROUP BY manga.manga_id ORDER BY $orderBy LIMIT ?",
arrayOf<Any>(limit), arrayOf<Any>(limit),
) )
return findCoversImpl(query) return findCoversImpl(query)
@@ -112,8 +114,8 @@ abstract class FavouritesDao {
@Query("SELECT favourite_categories.* FROM favourites LEFT JOIN favourite_categories ON favourite_categories.category_id = favourites.category_id WHERE favourites.manga_id = :mangaId AND favourites.deleted_at = 0") @Query("SELECT favourite_categories.* FROM favourites LEFT JOIN favourite_categories ON favourite_categories.category_id = favourites.category_id WHERE favourites.manga_id = :mangaId AND favourites.deleted_at = 0")
abstract fun observeCategories(mangaId: Long): Flow<List<FavouriteCategoryEntity>> abstract fun observeCategories(mangaId: Long): Flow<List<FavouriteCategoryEntity>>
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0 ORDER BY favourites.created_at ASC") @Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0 ORDER BY favourites.created_at ASC")
abstract suspend fun findCategoriesIds(mangaIds: Collection<Long>): List<Long> abstract suspend fun findCategoriesIds(mangaId: Long): List<Long>
@Query("SELECT COUNT(category_id) FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0") @Query("SELECT COUNT(category_id) FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0")
abstract suspend fun findCategoriesCount(mangaId: Long): Int abstract suspend fun findCategoriesCount(mangaId: Long): Int
@@ -191,4 +193,12 @@ abstract class FavouritesDao {
else -> throw IllegalArgumentException("Sort order $sortOrder is not supported") else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
} }
override fun getCondition(option: ListFilterOption): String? = when (option) {
ListFilterOption.Macro.COMPLETED -> "EXISTS(SELECT * FROM history WHERE history.manga_id = favourites.manga_id AND history.percent >= $PROGRESS_COMPLETED)"
ListFilterOption.Macro.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = favourites.manga_id) > 0"
ListFilterOption.Macro.NSFW -> "manga.nsfw = 1"
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE favourites.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})"
else -> null
}
} }

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.favourites.domain
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.MangaListQuickFilter
import javax.inject.Inject
class FavoritesListQuickFilter @Inject constructor(
private val settings: AppSettings,
private val repository: FavouritesRepository,
networkState: NetworkState,
) : MangaListQuickFilter(settings) {
init {
setFilterOption(ListFilterOption.Downloaded, !networkState.value)
}
override suspend fun getAvailableFilterOptions(): List<ListFilterOption> = buildList {
add(ListFilterOption.Downloaded)
if (settings.isTrackerEnabled) {
add(ListFilterOption.Macro.NEW_CHAPTERS)
}
add(ListFilterOption.Macro.COMPLETED)
}
}

View File

@@ -19,6 +19,7 @@ import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.favourites.data.toManga import org.koitharu.kotatsu.favourites.data.toManga
import org.koitharu.kotatsu.favourites.data.toMangaList import org.koitharu.kotatsu.favourites.data.toMangaList
import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject import javax.inject.Inject
@@ -26,6 +27,7 @@ import javax.inject.Inject
@Reusable @Reusable
class FavouritesRepository @Inject constructor( class FavouritesRepository @Inject constructor(
private val db: MangaDatabase, private val db: MangaDatabase,
private val localObserver: LocalFavoritesObserver,
) { ) {
suspend fun getAllManga(): List<Manga> { suspend fun getAllManga(): List<Manga> {
@@ -38,8 +40,11 @@ class FavouritesRepository @Inject constructor(
return entities.toMangaList() return entities.toMangaList()
} }
fun observeAll(order: ListSortOrder, limit: Int): Flow<List<Manga>> { fun observeAll(order: ListSortOrder, filterOptions: Set<ListFilterOption>, limit: Int): Flow<List<Manga>> {
return db.getFavouritesDao().observeAll(order, limit) if (ListFilterOption.Downloaded in filterOptions) {
return localObserver.observeAll(order, filterOptions - ListFilterOption.Downloaded, limit)
}
return db.getFavouritesDao().observeAll(order, filterOptions, limit)
.mapItems { it.toManga() } .mapItems { it.toManga() }
} }
@@ -48,14 +53,22 @@ class FavouritesRepository @Inject constructor(
return entities.toMangaList() return entities.toMangaList()
} }
fun observeAll(categoryId: Long, order: ListSortOrder, limit: Int): Flow<List<Manga>> { fun observeAll(
return db.getFavouritesDao().observeAll(categoryId, order, limit) categoryId: Long,
order: ListSortOrder,
filterOptions: Set<ListFilterOption>,
limit: Int
): Flow<List<Manga>> {
if (ListFilterOption.Downloaded in filterOptions) {
return localObserver.observeAll(categoryId, order, filterOptions - ListFilterOption.Downloaded, limit)
}
return db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit)
.mapItems { it.toManga() } .mapItems { it.toManga() }
} }
fun observeAll(categoryId: Long, limit: Int): Flow<List<Manga>> { fun observeAll(categoryId: Long, filterOptions: Set<ListFilterOption>, limit: Int): Flow<List<Manga>> {
return observeOrder(categoryId) return observeOrder(categoryId)
.flatMapLatest { order -> observeAll(categoryId, order, limit) } .flatMapLatest { order -> observeAll(categoryId, order, filterOptions, limit) }
} }
fun observeMangaCount(): Flow<Int> { fun observeMangaCount(): Flow<Int> {
@@ -119,8 +132,8 @@ class FavouritesRepository @Inject constructor(
return db.getFavouritesDao().findCategoriesCount(mangaId) != 0 return db.getFavouritesDao().findCategoriesCount(mangaId) != 0
} }
suspend fun getCategoriesIds(mangaIds: Collection<Long>): Set<Long> { suspend fun getCategoriesIds(mangaId: Long): Set<Long> {
return db.getFavouritesDao().findCategoriesIds(mangaIds).toSet() return db.getFavouritesDao().findCategoriesIds(mangaId).toSet()
} }
suspend fun createCategory( suspend fun createCategory(
@@ -227,6 +240,12 @@ class FavouritesRepository @Inject constructor(
.distinctUntilChanged() .distinctUntilChanged()
} }
suspend fun getMostUpdatedCategories(limit: Int): List<FavouriteCategory> {
return db.getFavouriteCategoriesDao().getMostUpdatedCategories(limit).map {
it.toFavouriteCategory()
}
}
private suspend fun recoverToFavourites(ids: Collection<Long>) { private suspend fun recoverToFavourites(ids: Collection<Long>) {
db.withTransaction { db.withTransaction {
for (id in ids) { for (id in ids) {

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