Compare commits

...

84 Commits
v8.0 ... v8.1.5

Author SHA1 Message Date
Koitharu
7cf7a62881 Update details info card color
(cherry picked from commit 8d325aea0a)
2025-05-08 19:36:16 +03:00
kadirkid
c1e84715fb Switch per language support to manual
The current automatic support setup has a bug where the app language will change for users with Android 15 when there is a configuration change like rotating a screen. It seems that that using generateLocaleConfig on AGP 8.8+ triggers a bug in Android 15 (android:defaultLocale) which causes this issue

(cherry picked from commit 104d8da655)
2025-05-08 19:36:08 +03:00
Koitharu
a3cc5726ee Update parsers 2025-05-08 19:35:45 +03:00
Koitharu
3023c02f12 Update parsers 2025-05-03 08:37:46 +03:00
Koitharu
efff034dc6 Remove duplicated warnlist tags 2025-05-03 08:33:36 +03:00
Draken
2bb5673446 Update tags_warnlist
(cherry picked from commit 8d78b19128)
2025-05-03 08:31:39 +03:00
Koitharu
0983885fa2 Update private notifications visibility 2025-05-03 08:30:36 +03:00
Koitharu
4449996a91 Fix search suggestions
(cherry picked from commit 1a8045b89f)
2025-05-02 14:44:39 +03:00
Koitharu
9cf496b7c4 AVIF images downsampling
(cherry picked from commit 5d890cb3d0)
2025-05-02 14:44:25 +03:00
Koitharu
4fb1db47ab Fix image loading
(cherry picked from commit 257f583f78)
2025-05-02 14:44:12 +03:00
Koitharu
14b89fbee2 Use pagination for bookmarks backup 2025-04-19 08:31:47 +03:00
Koitharu
8291c55fc9 Fix some database-related crashes 2025-04-19 08:16:08 +03:00
Koitharu
46ddcb7518 Update page loading ui 2025-04-13 11:33:04 +03:00
Koitharu
cf2d1aa6fb Fix main navbar height 2025-04-13 11:21:29 +03:00
Koitharu
ab3dd8aacb Merge pull request #1374 from weblate/weblate-kotatsu-strings
Translations update from Hosted Weblate
2025-04-13 10:56:48 +03:00
srntskl-111
ae868fa9d1 Translated using Weblate (Indonesian)
Currently translated at 98.8% (805 of 814 strings)

Co-authored-by: srntskl-111 <maskhraish@outlook.co.id>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-04-13 09:55:44 +02:00
Frosted
4ecbf5978e Translated using Weblate (Turkish)
Currently translated at 100.0% (814 of 814 strings)

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2025-04-13 09:55:43 +02:00
Koitharu
31586cf48f Fix avif image decoding 2025-04-13 10:47:37 +03:00
Koitharu
3725a6e58f Update page loading ui 2025-04-13 10:01:24 +03:00
Koitharu
313c2ab2bf Respect rounded corners for page numbers (#1360) 2025-04-08 09:09:36 +03:00
Koitharu
fe5d37f45e Fix hiding page loading indicator (close #1357) 2025-04-08 09:09:36 +03:00
Koitharu
92f6221ba0 Merge pull request #1367 from weblate/weblate-kotatsu-strings
Translations update from Hosted Weblate
2025-04-08 09:03:34 +03:00
大王叫我来巡山
0590a0c56f Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.6% (811 of 814 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-04-07 18:01:57 +02:00
Koitharu
13ffc3a515 Translated using Weblate (Russian)
Currently translated at 100.0% (814 of 814 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2025-04-07 18:01:55 +02:00
Nicola Bortoletto
74b36226f2 Translated using Weblate (Italian)
Currently translated at 100.0% (814 of 814 strings)

Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-04-07 18:01:53 +02:00
Boqirz
d501d0304a Translated using Weblate (Indonesian)
Currently translated at 98.7% (804 of 814 strings)

Co-authored-by: Boqirz <alveromodar@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-04-07 18:01:51 +02:00
Draken
1059933c87 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (814 of 814 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (814 of 814 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-04-07 18:01:49 +02:00
Koitharu
5fa58b931e Fix Cloudflare protection resolving 2025-04-06 18:10:34 +03:00
Koitharu
ddecc72de7 Update page state management in reader 2025-04-06 16:26:13 +03:00
Koitharu
d35a0c5e1e Allow to open reader when details is not loaded yet 2025-04-03 19:41:44 +03:00
Koitharu
340994ce77 Fix reader slider behavior 2025-04-03 13:59:26 +03:00
Koitharu
42b2f21c4d Fix bottom navigation insets #1341 2025-04-03 13:19:10 +03:00
Koitharu
e4b9da54dd Update parsers 2025-04-03 12:22:51 +03:00
Koitharu
ccc41314ae UI fixes 2025-04-03 12:21:06 +03:00
Koitharu
93eb6a19a5 Update page loading ui 2025-04-03 12:21:06 +03:00
Koitharu
e4f2e19d2c Merge pull request #1358 from weblate/weblate-kotatsu-strings
Translations update from Hosted Weblate
2025-04-03 12:19:44 +03:00
Thinker
73a687c9a7 Translated using Weblate (Bengali)
Currently translated at 21.8% (178 of 814 strings)

Co-authored-by: Thinker <sayakkundu711@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/bn/
Translation: Kotatsu/Strings
2025-04-03 03:49:15 +02:00
Draken
32ca3c11fa Translated using Weblate (Vietnamese)
Currently translated at 100.0% (814 of 814 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-04-03 03:49:14 +02:00
Deivinni Silva
0d648dd188 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (814 of 814 strings)

Co-authored-by: Deivinni Silva <deivinnimds3656@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2025-04-03 03:49:14 +02:00
Frosted
86b7989c89 Translated using Weblate (Turkish)
Currently translated at 100.0% (814 of 814 strings)

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2025-04-03 03:49:14 +02:00
Alvoracz
01be6ab596 Translated using Weblate (Czech)
Currently translated at 99.3% (809 of 814 strings)

Co-authored-by: Alvoracz <sedlor@seznam.cz>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translation: Kotatsu/Strings
2025-04-03 03:49:14 +02:00
Infy's Tagalog Translations
a3d01e8d34 Translated using Weblate (Filipino)
Currently translated at 99.7% (812 of 814 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
2025-04-03 03:49:14 +02:00
gekka
808bd47b64 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.6% (811 of 814 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-04-03 03:49:13 +02:00
Nicola Bortoletto
f4b506b26b Translated using Weblate (Italian)
Currently translated at 99.8% (813 of 814 strings)

Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-04-03 03:49:13 +02:00
Koitharu
1f0d2e2039 Fix crash when open non-http url in browser 2025-03-31 10:19:31 +03:00
Koitharu
e3e315e2a6 Skip local directories with .notamanga file 2025-03-30 10:43:05 +03:00
Koitharu
bfc733784f Option to disable dangerous genres highlighting #1348 2025-03-30 10:34:27 +03:00
Koitharu
3ff25de252 Fix crash 2025-03-30 10:21:42 +03:00
Koitharu
3c726c1c56 Update dependencies 2025-03-30 10:21:42 +03:00
Koitharu
9cb7ff691f Merge pull request #1349 from weblate/weblate-kotatsu-strings
Translations update from Hosted Weblate
2025-03-30 10:21:06 +03:00
lenn
645ae3124f Translated using Weblate (Polish)
Currently translated at 96.0% (780 of 812 strings)

Co-authored-by: lenn <l3ennec@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2025-03-29 14:02:13 +01:00
Ore Ki
a3d1922913 Translated using Weblate (Indonesian)
Currently translated at 100.0% (812 of 812 strings)

Co-authored-by: Ore Ki <ramadrizkyyy@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-03-29 14:02:11 +01:00
gekka
62d2ea8f15 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.6% (809 of 812 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.5% (808 of 812 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-03-29 14:02:09 +01:00
Draken
823752076b Translated using Weblate (Vietnamese)
Currently translated at 100.0% (812 of 812 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (812 of 812 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-03-29 14:02:06 +01:00
Lorenzo Stella
3cbd392c72 Translated using Weblate (English)
Currently translated at 100.0% (812 of 812 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 2.4% (20 of 812 strings)

Translated using Weblate (English)

Currently translated at 100.0% (812 of 812 strings)

Translated using Weblate (English)

Currently translated at 100.0% (812 of 812 strings)

Co-authored-by: Lorenzo Stella <lorenzo.stella.1408@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en_GB/
Translation: Kotatsu/Strings
2025-03-29 14:02:04 +01:00
Anonymous
57f62f5860 Translated using Weblate (Malayalam)
Currently translated at 2.8% (23 of 812 strings)

Translated using Weblate (Sinhala)

Currently translated at 2.7% (22 of 812 strings)

Translated using Weblate (Croatian)

Currently translated at 97.5% (792 of 812 strings)

Translated using Weblate (Hungarian)

Currently translated at 76.3% (620 of 812 strings)

Translated using Weblate (Malay)

Currently translated at 38.4% (312 of 812 strings)

Translated using Weblate (Estonian)

Currently translated at 67.6% (549 of 812 strings)

Translated using Weblate (Thai)

Currently translated at 55.7% (453 of 812 strings)

Translated using Weblate (Czech)

Currently translated at 98.0% (796 of 812 strings)

Translated using Weblate (Vietnamese)

Currently translated at 99.1% (805 of 812 strings)

Translated using Weblate (Kazakh)

Currently translated at 66.9% (544 of 812 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 46.9% (381 of 812 strings)

Translated using Weblate (Filipino)

Currently translated at 98.2% (798 of 812 strings)

Translated using Weblate (Hindi)

Currently translated at 82.2% (668 of 812 strings)

Translated using Weblate (Polish)

Currently translated at 93.1% (756 of 812 strings)

Translated using Weblate (Korean)

Currently translated at 41.9% (341 of 812 strings)

Translated using Weblate (Greek)

Currently translated at 66.2% (538 of 812 strings)

Translated using Weblate (Serbian)

Currently translated at 97.9% (795 of 812 strings)

Translated using Weblate (Arabic)

Currently translated at 98.7% (802 of 812 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 80.9% (657 of 812 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.0% (804 of 812 strings)

Translated using Weblate (Ukrainian)

Currently translated at 81.6% (663 of 812 strings)

Translated using Weblate (Russian)

Currently translated at 98.5% (800 of 812 strings)

Translated using Weblate (Japanese)

Currently translated at 57.1% (464 of 812 strings)

Translated using Weblate (Turkish)

Currently translated at 99.6% (809 of 812 strings)

Translated using Weblate (French)

Currently translated at 99.6% (809 of 812 strings)

Translated using Weblate (Portuguese)

Currently translated at 96.6% (785 of 812 strings)

Translated using Weblate (Italian)

Currently translated at 99.5% (808 of 812 strings)

Translated using Weblate (German)

Currently translated at 78.3% (636 of 812 strings)

Translated using Weblate (Spanish)

Currently translated at 94.9% (771 of 812 strings)

Translated using Weblate (Belarusian)

Currently translated at 92.1% (748 of 812 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/el/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/et/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hu/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/kk/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ko/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ml/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ms/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nn/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/si/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2025-03-29 14:02:02 +01:00
Koitharu
648fab6be5 Fix Open online manga variant 2025-03-27 20:39:36 +02:00
Koitharu
817ae68e67 Option to toggle ssiv debug 2025-03-26 19:24:32 +02:00
Koitharu
7c4b91ddc4 UI Fixes 2025-03-26 19:23:06 +02:00
Koitharu
d54e015195 Fix Favorite/Favourite strings 2025-03-25 10:08:16 +02:00
Koitharu
e369d1ba9d Merge remote-tracking branch 'weblate/devel' into devel 2025-03-25 10:01:27 +02:00
Koitharu
1a4358998b Merge pull request #1334 from Arararararagi/patch-1
typos and coherence check in strings
2025-03-25 09:47:37 +02:00
MrCrystallized
c53a833d9d Translated using Weblate (Bengali)
Currently translated at 22.1% (180 of 812 strings)

Translated using Weblate (Bengali)

Currently translated at 88.8% (8 of 9 strings)

Co-authored-by: MrCrystallized <mrcrystallizedbruh@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/bn/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/bn/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2025-03-24 21:54:54 +01:00
Lorenzo Stella
afff700ad3 Translated using Weblate (Romanian)
Currently translated at 11.0% (90 of 812 strings)

Translated using Weblate (Khmer (Central))

Currently translated at 17.7% (144 of 812 strings)

Translated using Weblate (Latvian)

Currently translated at 21.6% (176 of 812 strings)

Translated using Weblate (Lithuanian)

Currently translated at 5.5% (45 of 812 strings)

Translated using Weblate (Catalan)

Currently translated at 10.9% (89 of 812 strings)

Translated using Weblate (Hungarian)

Currently translated at 76.6% (622 of 812 strings)

Translated using Weblate (Punjabi)

Currently translated at 4.1% (34 of 812 strings)

Translated using Weblate (Malay)

Currently translated at 38.6% (314 of 812 strings)

Translated using Weblate (Thai)

Currently translated at 56.0% (455 of 812 strings)

Translated using Weblate (Kazakh)

Currently translated at 67.2% (546 of 812 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 47.1% (383 of 812 strings)

Translated using Weblate (Nepali)

Currently translated at 30.5% (248 of 812 strings)

Translated using Weblate (Hindi)

Currently translated at 82.6% (671 of 812 strings)

Translated using Weblate (Polish)

Currently translated at 93.5% (760 of 812 strings)

Translated using Weblate (Korean)

Currently translated at 42.2% (343 of 812 strings)

Translated using Weblate (Greek)

Currently translated at 66.5% (540 of 812 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 81.2% (660 of 812 strings)

Translated using Weblate (Ukrainian)

Currently translated at 82.0% (666 of 812 strings)

Translated using Weblate (Japanese)

Currently translated at 57.3% (466 of 812 strings)

Translated using Weblate (Persian)

Currently translated at 35.5% (289 of 812 strings)

Translated using Weblate (Finnish)

Currently translated at 32.0% (260 of 812 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (812 of 812 strings)

Translated using Weblate (German)

Currently translated at 78.6% (639 of 812 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 35.4% (288 of 812 strings)

Translated using Weblate (Spanish)

Currently translated at 95.4% (775 of 812 strings)

Translated using Weblate (English)

Currently translated at 100.0% (812 of 812 strings)

Co-authored-by: Lorenzo Stella <lorenzo.stella.1408@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ca/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/el/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hu/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/kk/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/km/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ko/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/lt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/lv/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ms/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ne/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nn/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pa/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ro/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2025-03-24 21:54:54 +01:00
Anon
5bc00bc7f5 Translated using Weblate (Serbian)
Currently translated at 98.2% (798 of 812 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2025-03-24 21:54:54 +01:00
Dragibus Noir
e2ace90cdb Translated using Weblate (French)
Currently translated at 100.0% (812 of 812 strings)

Co-authored-by: Dragibus Noir <dragibusnoir@pm.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2025-03-24 21:54:54 +01:00
Lumiini
1afbd2b6a8 Translated using Weblate (Finnish)
Currently translated at 66.6% (6 of 9 strings)

Co-authored-by: Lumiini <bennokaynak@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fi/
Translation: Kotatsu/plurals
2025-03-24 21:54:53 +01:00
Frosted
d36c5af0c4 Translated using Weblate (Turkish)
Currently translated at 100.0% (812 of 812 strings)

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2025-03-24 21:54:53 +01:00
Nicola Bortoletto
705bb2b084 Translated using Weblate (Italian)
Currently translated at 100.0% (812 of 812 strings)

Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-03-24 21:54:53 +01:00
gekka
a208d13930 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.5% (808 of 812 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-03-24 21:54:53 +01:00
Kusou
44d8861b7f Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (808 of 808 strings)

Co-authored-by: Kusou <orion26br@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2025-03-24 21:54:53 +01:00
ruban s
9821f06ca1 Translated using Weblate (Tamil)
Currently translated at 100.0% (808 of 808 strings)

Co-authored-by: ruban s <rubans013@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ta/
Translation: Kotatsu/Strings
2025-03-24 21:54:52 +01:00
Amato Fugosi
92f9f56f59 Translated using Weblate (Bengali)
Currently translated at 19.9% (161 of 808 strings)

Translated using Weblate (Bengali)

Currently translated at 88.8% (8 of 9 strings)

Co-authored-by: Amato Fugosi <fugosiamato@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/bn/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/bn/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2025-03-24 21:54:52 +01:00
Koitharu
424c4d8827 Multiple authors support 2025-03-24 14:18:04 +02:00
Koitharu
24cf2a2725 Use page preview in reader while loading 2025-03-22 16:18:35 +02:00
Koitharu
1a5c3c1f6f Update dependencies 2025-03-21 09:03:31 +02:00
Koitharu
0b8fbf892a Improve details activity 2025-03-20 18:00:58 +02:00
Koitharu
a2f9356b8a Fix crashes 2025-03-20 08:20:43 +02:00
Koitharu
7003463bac Option to use disabled sources for suggestions 2025-03-20 07:52:08 +02:00
Koitharu
7a663fa9c1 Merge branch 'devel' 2025-03-18 19:36:42 +02:00
I bit my tongue
9cc1cdac62 typos and coherence check 2025-03-16 21:13:54 +00:00
Koitharu
8d7f44d2da Fix DateTimeAgo formatting
(cherry picked from commit 5f0514638a)
2025-02-24 18:09:03 +02:00
Koitharu
930d4dfd83 Fix checking for removed manga updates (close #1064)
(cherry picked from commit 1b8d35d424)
2025-02-24 18:07:49 +02:00
Koitharu
290cb652ee Update parsers 2025-02-24 18:05:51 +02:00
195 changed files with 2254 additions and 1250 deletions

View File

@@ -19,16 +19,80 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 35 targetSdk = 35
versionCode = 1004 versionCode = 1011
versionName = '8.0' versionName = '8.1.5'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {
arg('room.generateKotlin', 'true') arg('room.generateKotlin', 'true')
} }
androidResources { androidResources {
generateLocaleConfig true generateLocaleConfig false
} }
resourceConfigurations += [
"en",
"ab",
"ar",
"arq",
"as",
"be",
"bn",
"ca",
"cs",
"de",
"el",
"en-rGB",
"enm",
"es",
"et",
"eu",
"fa",
"fi",
"fil",
"fr",
"frp",
"gu",
"hi",
"hr",
"hu",
"in",
"it",
"iw",
"ja",
"kk",
"km",
"ko",
"lt",
"lv",
"lzh",
"ml",
"ms",
"my",
"nb-rNO",
"ne",
"nn",
"or",
"pa",
"pa-rPK",
"pl",
"pt",
"pt-rBR",
"ro",
"ru",
"si",
"sr",
"sv",
"ta",
"th",
"tr",
"uk",
"vi",
"zh-rCN",
"zh-rTW",
// Specific BCP 47 locales
"b+zh+Hans+MO",
"b+zh+Hant+MO"
]
} }
buildTypes { buildTypes {
debug { debug {
@@ -75,6 +139,8 @@ android {
'-opt-in=kotlin.contracts.ExperimentalContracts', '-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=coil3.annotation.ExperimentalCoilApi', '-opt-in=coil3.annotation.ExperimentalCoilApi',
'-opt-in=coil3.annotation.InternalCoilApi', '-opt-in=coil3.annotation.InternalCoilApi',
'-Xjspecify-annotations=strict',
'-Xtype-enhancement-improvements-strict-mode',
] ]
} }
room { room {

View File

@@ -12,7 +12,6 @@ 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() {
@@ -67,7 +66,6 @@ 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()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
penaltyListener(notifier.executor, notifier) penaltyListener(notifier.executor, notifier)

View File

@@ -5,6 +5,7 @@ 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.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import leakcanary.LeakCanary import leakcanary.LeakCanary
import org.koitharu.kotatsu.KotatsuApp import org.koitharu.kotatsu.KotatsuApp
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -24,6 +25,7 @@ class SettingsMenuProvider(
override fun onPrepareMenu(menu: Menu) { override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu) super.onPrepareMenu(menu)
menu.findItem(R.id.action_leakcanary).isChecked = application.isLeakCanaryEnabled menu.findItem(R.id.action_leakcanary).isChecked = application.isLeakCanaryEnabled
menu.findItem(R.id.action_ssiv_debug).isChecked = SubsamplingScaleImageView.isDebug
} }
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
@@ -44,6 +46,13 @@ class SettingsMenuProvider(
true true
} }
R.id.action_ssiv_debug -> {
val checked = !menuItem.isChecked
menuItem.isChecked = checked
SubsamplingScaleImageView.isDebug = checked
true
}
else -> false else -> false
} }
} }

View File

@@ -4,6 +4,12 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<item
android:id="@+id/action_ssiv_debug"
android:checkable="true"
android:title="SSIV debug"
app:showAsAction="never"
tools:ignore="HardcodedText" />
<item <item
android:id="@+id/action_leakcanary" android:id="@+id/action_leakcanary"
android:checkable="true" android:checkable="true"

View File

@@ -31,7 +31,7 @@ import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
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 javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR import androidx.appcompat.R as appcompatR
@AndroidEntryPoint @AndroidEntryPoint
class AutoFixService : CoroutineIntentService() { class AutoFixService : CoroutineIntentService() {
@@ -95,7 +95,7 @@ class AutoFixService : CoroutineIntentService() {
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setCategory(NotificationCompat.CATEGORY_PROGRESS) .setCategory(NotificationCompat.CATEGORY_PROGRESS)
.addAction( .addAction(
materialR.drawable.material_ic_clear_black_24dp, appcompatR.drawable.abc_ic_clear_material,
applicationContext.getString(android.R.string.cancel), applicationContext.getString(android.R.string.cancel),
jobContext.getCancelIntent(), jobContext.getCancelIntent(),
) )

View File

@@ -17,9 +17,9 @@ abstract class BookmarksDao {
@Transaction @Transaction
@Query( @Query(
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent", "SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent LIMIT :limit OFFSET :offset",
) )
abstract suspend fun findAll(): Map<MangaWithTags, List<BookmarkEntity>> abstract suspend fun findAll(offset: Int, limit: Int): Map<MangaWithTags, List<BookmarkEntity>>
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent") @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent")
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?> abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>

View File

@@ -6,10 +6,18 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.consumeAll import org.koitharu.kotatsu.core.util.ext.consumeAll
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -18,6 +26,9 @@ abstract class BaseBrowserActivity : BaseActivity<ActivityBrowserBinding>(), Bro
@Inject @Inject
lateinit var proxyProvider: ProxyProvider lateinit var proxyProvider: ProxyProvider
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
private lateinit var onBackPressedCallback: WebViewBackPressedCallback private lateinit var onBackPressedCallback: WebViewBackPressedCallback
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -28,8 +39,22 @@ abstract class BaseBrowserActivity : BaseActivity<ActivityBrowserBinding>(), Bro
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar) viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView) onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
onBackPressedDispatcher.addCallback(onBackPressedCallback) onBackPressedDispatcher.addCallback(onBackPressedCallback)
val mangaSource = MangaSource(intent?.getStringExtra(AppRouter.KEY_SOURCE))
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
val userAgent = intent?.getStringExtra(AppRouter.KEY_USER_AGENT)?.nullIfEmpty()
?: repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
viewBinding.webView.configureForParser(userAgent)
onCreate2(savedInstanceState, mangaSource, repository)
} }
protected abstract fun onCreate2(
savedInstanceState: Bundle?,
source: MangaSource,
repository: ParserMangaRepository?
)
override fun onApplyWindowInsets( override fun onApplyWindowInsets(
v: View, v: View,
insets: WindowInsetsCompat insets: WindowInsetsCompat

View File

@@ -8,31 +8,19 @@ import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import javax.inject.Inject import org.koitharu.kotatsu.parsers.model.MangaSource
@AndroidEntryPoint @AndroidEntryPoint
class BrowserActivity : BaseBrowserActivity() { class BrowserActivity : BaseBrowserActivity() {
@Inject override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
lateinit var mangaRepositoryFactory: MangaRepository.Factory setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
viewBinding.webView.webViewClient = BrowserClient(this)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setDisplayHomeAsUp(true, true)
val mangaSource = MangaSource(intent?.getStringExtra(AppRouter.KEY_SOURCE))
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
viewBinding.webView.configureForParser(userAgent)
viewBinding.webView.webViewClient = BrowserClient(proxyProvider, this)
lifecycleScope.launch { lifecycleScope.launch {
try { try {
proxyProvider.applyWebViewConfig() proxyProvider.applyWebViewConfig()

View File

@@ -3,10 +3,8 @@ package org.koitharu.kotatsu.browser
import android.graphics.Bitmap import android.graphics.Bitmap
import android.webkit.WebView import android.webkit.WebView
import androidx.webkit.WebViewClientCompat import androidx.webkit.WebViewClientCompat
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
open class BrowserClient( open class BrowserClient(
private val proxyProvider: ProxyProvider,
private val callback: BrowserCallback private val callback: BrowserCallback
) : WebViewClientCompat() { ) : WebViewClientCompat() {

View File

@@ -22,9 +22,11 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.util.ext.configureForParser import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -37,16 +39,14 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
private lateinit var cfClient: CloudFlareClient private lateinit var cfClient: CloudFlareClient
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
super.onCreate(savedInstanceState) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
setDisplayHomeAsUp(true, true)
val url = intent?.dataString val url = intent?.dataString
if (url.isNullOrEmpty()) { if (url.isNullOrEmpty()) {
finishAfterTransition() finishAfterTransition()
return return
} }
cfClient = CloudFlareClient(proxyProvider, cookieJar, this, url) cfClient = CloudFlareClient(cookieJar, this, url)
viewBinding.webView.configureForParser(intent?.getStringExtra(AppRouter.KEY_USER_AGENT))
viewBinding.webView.webViewClient = cfClient viewBinding.webView.webViewClient = cfClient
lifecycleScope.launch { lifecycleScope.launch {
try { try {
@@ -107,8 +107,7 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) { override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
setTitle(title) setTitle(title)
supportActionBar?.subtitle = supportActionBar?.subtitle = subtitle?.toString()?.toHttpUrlOrNull()?.host.ifNullOrEmpty { subtitle }
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
} }
private fun restartCheck() { private fun restartCheck() {

View File

@@ -4,17 +4,15 @@ import android.graphics.Bitmap
import android.webkit.WebView import android.webkit.WebView
import org.koitharu.kotatsu.browser.BrowserClient import org.koitharu.kotatsu.browser.BrowserClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
private const val LOOP_COUNTER = 3 private const val LOOP_COUNTER = 3
class CloudFlareClient( class CloudFlareClient(
proxyProvider: ProxyProvider,
private val cookieJar: MutableCookieJar, private val cookieJar: MutableCookieJar,
private val callback: CloudFlareCallback, private val callback: CloudFlareCallback,
private val targetUrl: String, private val targetUrl: String,
) : BrowserClient(proxyProvider, callback) { ) : BrowserClient(callback) {
private val oldClearance = getClearance() private val oldClearance = getClearance()
private var counter = 0 private var counter = 0

View File

@@ -102,6 +102,9 @@ open class BaseApp : Application(), Configuration.Provider {
override fun attachBaseContext(base: Context) { override fun attachBaseContext(base: Context) {
super.attachBaseContext(base) super.attachBaseContext(base)
if (ACRA.isACRASenderServiceProcess()) {
return
}
initAcra { initAcra {
buildConfigClass = BuildConfig::class.java buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON reportFormat = StringFormat.JSON

View File

@@ -28,7 +28,7 @@ class BackupRepository @Inject constructor(
var offset = 0 var offset = 0
val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray()) val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray())
while (true) { while (true) {
val history = db.getHistoryDao().findAll(offset, PAGE_SIZE) val history = db.getHistoryDao().findAll(offset = offset, limit = PAGE_SIZE)
if (history.isEmpty()) { if (history.isEmpty()) {
break break
} }
@@ -59,7 +59,7 @@ class BackupRepository @Inject constructor(
var offset = 0 var offset = 0
val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray()) val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray())
while (true) { while (true) {
val favourites = db.getFavouritesDao().findAllRaw(offset, PAGE_SIZE) val favourites = db.getFavouritesDao().findAllRaw(offset = offset, limit = PAGE_SIZE)
if (favourites.isEmpty()) { if (favourites.isEmpty()) {
break break
} }
@@ -78,19 +78,26 @@ class BackupRepository @Inject constructor(
} }
suspend fun dumpBookmarks(): BackupEntry { suspend fun dumpBookmarks(): BackupEntry {
var offset = 0
val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray()) val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray())
val all = db.getBookmarksDao().findAll() while (true) {
for ((m, b) in all) { val bookmarks = db.getBookmarksDao().findAll(offset = offset, limit = PAGE_SIZE)
val json = JSONObject() if (bookmarks.isEmpty()) {
val manga = JsonSerializer(m.manga).toJson() break
json.put("manga", manga) }
val tags = JSONArray() offset += bookmarks.size
m.tags.forEach { tags.put(JsonSerializer(it).toJson()) } for ((m, b) in bookmarks) {
json.put("tags", tags) val json = JSONObject()
val bookmarks = JSONArray() val manga = JsonSerializer(m.manga).toJson()
b.forEach { bookmarks.put(JsonSerializer(it).toJson()) } json.put("manga", manga)
json.put("bookmarks", bookmarks) val tags = JSONArray()
entry.data.put(json) m.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
json.put("tags", tags)
val bookmarks = JSONArray()
b.forEach { bookmarks.put(JsonSerializer(it).toJson()) }
json.put("bookmarks", bookmarks)
entry.data.put(json)
}
} }
return entry return entry
} }

View File

@@ -0,0 +1,7 @@
package org.koitharu.kotatsu.core.exceptions
import android.net.Uri
class NonFileUriException(
val uri: Uri,
) : IllegalArgumentException("Cannot resolve file name of \"$uri\"")

View File

@@ -20,6 +20,7 @@ import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
import org.koitharu.kotatsu.core.util.ext.restartApplication import org.koitharu.kotatsu.core.util.ext.restartApplication
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
@@ -163,7 +164,7 @@ class ExceptionResolver @AssistedInject constructor(
is ScrobblerAuthRequiredException, 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.isHttpUrl()) 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

View File

@@ -1,18 +1,23 @@
package org.koitharu.kotatsu.core.image package org.koitharu.kotatsu.core.image
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
import coil3.ImageLoader import coil3.ImageLoader
import coil3.asImage import coil3.asImage
import coil3.decode.DecodeResult import coil3.decode.DecodeResult
import coil3.decode.DecodeUtils
import coil3.decode.Decoder import coil3.decode.Decoder
import coil3.decode.ImageSource import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult import coil3.fetch.SourceFetchResult
import coil3.request.Options import coil3.request.Options
import coil3.request.maxBitmapSize
import coil3.util.component1
import coil3.util.component2
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.aomedia.avif.android.AvifDecoder import org.aomedia.avif.android.AvifDecoder
import org.aomedia.avif.android.AvifDecoder.Info import org.koitharu.kotatsu.core.util.ext.readByteBuffer
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
class AvifImageDecoder( class AvifImageDecoder(
private val source: ImageSource, private val source: ImageSource,
@@ -20,27 +25,52 @@ class AvifImageDecoder(
) : Decoder { ) : Decoder {
override suspend fun decode(): DecodeResult = runInterruptible { override suspend fun decode(): DecodeResult = runInterruptible {
val bytes = source.source().use { val bytes = source.source().readByteBuffer()
it.inputStream().toByteBuffer() val decoder = AvifDecoder.create(bytes) ?: throw ImageDecodeException(
} uri = source.fileOrNull()?.toString(),
val info = Info() format = "avif",
if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) { message = "Requested to decode byte buffer which cannot be handled by AvifDecoder",
throw ImageDecodeException(
null,
"avif",
"Requested to decode byte buffer which cannot be handled by AvifDecoder",
)
}
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
val bitmap = Bitmap.createBitmap(info.width, info.height, config)
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
bitmap.recycle()
throw ImageDecodeException(null, "avif")
}
DecodeResult(
image = bitmap.asImage(),
isSampled = false,
) )
try {
val config = if (decoder.depth == 8 || decoder.alphaPresent) {
Bitmap.Config.ARGB_8888
} else {
Bitmap.Config.RGB_565
}
val bitmap = createBitmap(decoder.width, decoder.height, config)
val result = decoder.nextFrame(bitmap)
if (result != 0) {
bitmap.recycle()
throw ImageDecodeException(
uri = source.fileOrNull()?.toString(),
format = "avif",
message = AvifDecoder.resultToString(result),
)
}
// downscaling
val (dstWidth, dstHeight) = DecodeUtils.computeDstSize(
srcWidth = bitmap.width,
srcHeight = bitmap.height,
targetSize = options.size,
scale = options.scale,
maxSize = options.maxBitmapSize,
)
if (dstWidth < bitmap.width || dstHeight < bitmap.height) {
val scaled = bitmap.scale(dstWidth, dstHeight)
bitmap.recycle()
DecodeResult(
image = scaled.asImage(),
isSampled = true,
)
} else {
DecodeResult(
image = bitmap.asImage(),
isSampled = false,
)
}
} finally {
decoder.release()
}
} }
class Factory : Decoder.Factory { class Factory : Decoder.Factory {

View File

@@ -2,15 +2,22 @@ package org.koitharu.kotatsu.core.image
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.ImageDecoder import android.graphics.ImageDecoder
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.graphics.createBitmap
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import okio.IOException
import okio.buffer
import okio.source
import org.aomedia.avif.android.AvifDecoder import org.aomedia.avif.android.AvifDecoder
import org.aomedia.avif.android.AvifDecoder.Info import org.aomedia.avif.android.AvifDecoder.Info
import org.jetbrains.annotations.Blocking import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.ext.MimeType import org.koitharu.kotatsu.core.util.ext.MimeType
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.readByteBuffer
import org.koitharu.kotatsu.core.util.ext.toByteBuffer import org.koitharu.kotatsu.core.util.ext.toByteBuffer
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -24,7 +31,7 @@ object BitmapDecoderCompat {
@Blocking @Blocking
fun decode(file: File): Bitmap = when (val format = probeMimeType(file)?.subtype) { fun decode(file: File): Bitmap = when (val format = probeMimeType(file)?.subtype) {
FORMAT_AVIF -> file.inputStream().use { decodeAvif(it.toByteBuffer()) } FORMAT_AVIF -> file.source().buffer().use { decodeAvif(it.readByteBuffer()) }
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(file)) ImageDecoder.decodeBitmap(ImageDecoder.createSource(file))
} else { } else {
@@ -51,6 +58,19 @@ object BitmapDecoderCompat {
} }
} }
@Blocking
fun createRegionDecoder(inoutStream: InputStream): BitmapRegionDecoder? = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BitmapRegionDecoder.newInstance(inoutStream)
} else {
@Suppress("DEPRECATION")
BitmapRegionDecoder.newInstance(inoutStream, false)
}
} catch (e: IOException) {
e.printStackTraceDebug()
null
}
@Blocking @Blocking
fun probeMimeType(file: File): MimeType? { fun probeMimeType(file: File): MimeType? {
return MimeTypes.probeMimeType(file) ?: detectBitmapType(file) return MimeTypes.probeMimeType(file) ?: detectBitmapType(file)
@@ -62,7 +82,7 @@ object BitmapDecoderCompat {
inJustDecodeBounds = true inJustDecodeBounds = true
} }
BitmapFactory.decodeFile(file.path, options)?.recycle() BitmapFactory.decodeFile(file.path, options)?.recycle()
return options.outMimeType?.toMimeTypeOrNull() options.outMimeType?.toMimeTypeOrNull()
}.getOrNull() }.getOrNull()
private fun checkBitmapNotNull(bitmap: Bitmap?, format: String?): Bitmap = private fun checkBitmapNotNull(bitmap: Bitmap?, format: String?): Bitmap =
@@ -78,7 +98,7 @@ object BitmapDecoderCompat {
) )
} }
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565 val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
val bitmap = Bitmap.createBitmap(info.width, info.height, config) val bitmap = createBitmap(info.width, info.height, config)
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) { if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
bitmap.recycle() bitmap.recycle()
throw ImageDecodeException(null, FORMAT_AVIF) throw ImageDecodeException(null, FORMAT_AVIF)

View File

@@ -25,7 +25,7 @@ class CbzFetcher(
val entryName = requireNotNull(uri.fragment) val entryName = requireNotNull(uri.fragment)
val fs = options.fileSystem.openZip(filePath) val fs = options.fileSystem.openZip(filePath)
SourceFetchResult( SourceFetchResult(
source = ImageSource(entryName.toPath(), fs, closeable = fs), source = ImageSource(entryName.toPath(), fs),
mimeType = MimeTypes.getMimeTypeFromExtension(entryName)?.toString(), mimeType = MimeTypes.getMimeTypeFromExtension(entryName)?.toString(),
dataSource = DataSource.DISK, dataSource = DataSource.DISK,
) )

View File

@@ -0,0 +1,48 @@
package org.koitharu.kotatsu.core.image
import android.os.Parcel
import android.os.Parcelable
import android.view.View
import androidx.collection.ArrayMap
import coil3.memory.MemoryCache
import coil3.request.SuccessResult
import coil3.util.CoilUtils
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
@Parcelize
class CoilMemoryCacheKey(
val data: MemoryCache.Key
) : Parcelable {
companion object : Parceler<CoilMemoryCacheKey> {
override fun CoilMemoryCacheKey.write(parcel: Parcel, flags: Int) = with(data) {
parcel.writeString(key)
parcel.writeInt(extras.size)
for (entry in extras.entries) {
parcel.writeString(entry.key)
parcel.writeString(entry.value)
}
}
override fun create(parcel: Parcel): CoilMemoryCacheKey = CoilMemoryCacheKey(
MemoryCache.Key(
key = parcel.readString().orEmpty(),
extras = run {
val size = parcel.readInt()
val map = ArrayMap<String, String>(size)
repeat(size) {
map.put(parcel.readString(), parcel.readString())
}
map
},
),
)
fun from(view: View): CoilMemoryCacheKey? {
return (CoilUtils.result(view) as? SuccessResult)?.memoryCacheKey?.let {
CoilMemoryCacheKey(it)
}
}
}
}

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.core.image
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.Rect import android.graphics.Rect
import android.os.Build import android.os.Build
import coil3.Extras import coil3.Extras
@@ -11,7 +10,6 @@ import coil3.asImage
import coil3.decode.DecodeResult import coil3.decode.DecodeResult
import coil3.decode.DecodeUtils import coil3.decode.DecodeUtils
import coil3.decode.Decoder import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult import coil3.fetch.SourceFetchResult
import coil3.getExtra import coil3.getExtra
import coil3.request.Options import coil3.request.Options
@@ -25,24 +23,37 @@ import coil3.size.Scale
import coil3.size.Size import coil3.size.Size
import coil3.size.isOriginal import coil3.size.isOriginal
import coil3.size.pxOrElse import coil3.size.pxOrElse
import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.core.util.ext.copyWithNewSource
import kotlin.math.roundToInt import kotlin.math.roundToInt
class RegionBitmapDecoder( class RegionBitmapDecoder(
private val source: ImageSource, private val fetchResult: SourceFetchResult,
private val options: Options, private val options: Options,
private val imageLoader: ImageLoader,
) : Decoder { ) : Decoder {
override suspend fun decode(): DecodeResult = runInterruptible { override suspend fun decode(): DecodeResult? {
val regionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val regionDecoder = BitmapDecoderCompat.createRegionDecoder(fetchResult.source.source().inputStream())
BitmapRegionDecoder.newInstance(source.source().inputStream()) if (regionDecoder == null) {
} else { val revivedFetchResult = fetchResult.copyWithNewSource()
@Suppress("DEPRECATION") return try {
BitmapRegionDecoder.newInstance(source.source().inputStream(), false) val fallbackDecoder = imageLoader.components.newDecoder(
result = revivedFetchResult,
options = options,
imageLoader = imageLoader,
startIndex = 0,
)?.first
if (fallbackDecoder == null || fallbackDecoder is RegionBitmapDecoder) {
null
} else {
fallbackDecoder.decode()
}
} finally {
revivedFetchResult.source.close()
}
} }
checkNotNull(regionDecoder)
val bitmapOptions = BitmapFactory.Options() val bitmapOptions = BitmapFactory.Options()
try { return try {
val rect = bitmapOptions.configureScale(regionDecoder.width, regionDecoder.height) val rect = bitmapOptions.configureScale(regionDecoder.width, regionDecoder.height)
bitmapOptions.configureConfig() bitmapOptions.configureConfig()
val bitmap = regionDecoder.decodeRegion(rect, bitmapOptions) val bitmap = regionDecoder.decodeRegion(rect, bitmapOptions)
@@ -149,7 +160,7 @@ class RegionBitmapDecoder(
result: SourceFetchResult, result: SourceFetchResult,
options: Options, options: Options,
imageLoader: ImageLoader imageLoader: ImageLoader
): Decoder = RegionBitmapDecoder(result.source, options) ): Decoder = RegionBitmapDecoder(result, options, imageLoader)
override fun equals(other: Any?) = other is Factory override fun equals(other: Any?) = other is Factory

View File

@@ -149,6 +149,8 @@ fun Manga.chaptersCount(): Int {
return max return max
} }
fun Manga.isNsfw(): Boolean = contentRating == ContentRating.ADULT || source.isNsfw()
fun MangaListFilter.getSummary() = buildSpannedString { fun MangaListFilter.getSummary() = buildSpannedString {
if (!query.isNullOrEmpty()) { if (!query.isNullOrEmpty()) {
append(query) append(query)

View File

@@ -18,11 +18,13 @@ import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.core.util.ext.getDisplayName import org.koitharu.kotatsu.core.util.ext.getDisplayName
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.core.util.ext.toLocale
import org.koitharu.kotatsu.core.util.ext.toLocaleOrNull
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
import org.koitharu.kotatsu.parsers.util.splitTwoParts import org.koitharu.kotatsu.parsers.util.splitTwoParts
import com.google.android.material.R as materialR import java.util.Locale
import androidx.appcompat.R as appcompatR
data object LocalMangaSource : MangaSource { data object LocalMangaSource : MangaSource {
override val name = "LOCAL" override val name = "LOCAL"
@@ -79,6 +81,8 @@ tailrec fun MangaSource.unwrap(): MangaSource = if (this is MangaSourceInfo) {
this this
} }
fun MangaSource.getLocale(): Locale? = (unwrap() as? MangaParserSource)?.locale?.toLocaleOrNull()
fun MangaSource.getSummary(context: Context): String? = when (val source = unwrap()) { fun MangaSource.getSummary(context: Context): String? = when (val source = unwrap()) {
is MangaParserSource -> { is MangaParserSource -> {
val type = context.getString(source.contentType.titleResId) val type = context.getString(source.contentType.titleResId)
@@ -99,7 +103,7 @@ fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()
} }
fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans( fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)), ForegroundColorSpan(context.getThemeColor(appcompatR.attr.colorError, Color.RED)),
RelativeSizeSpan(0.74f), RelativeSizeSpan(0.74f),
SuperscriptSpan(), SuperscriptSpan(),
) { ) {

View File

@@ -29,6 +29,7 @@ import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
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.image.CoilMemoryCacheKey
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.MangaSourceInfo import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.model.appUrl import org.koitharu.kotatsu.core.model.appUrl
@@ -105,7 +106,7 @@ import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity
import java.io.File import java.io.File
import com.google.android.material.R as materialR import androidx.appcompat.R as appcompatR
class AppRouter private constructor( class AppRouter private constructor(
private val activity: FragmentActivity?, private val activity: FragmentActivity?,
@@ -180,11 +181,12 @@ class AppRouter private constructor(
) )
} }
fun openImage(url: String, source: MangaSource?, anchor: View? = null) { fun openImage(url: String, source: MangaSource?, anchor: View? = null, preview: CoilMemoryCacheKey? = null) {
startActivity( startActivity(
Intent(contextOrNull(), ImageActivity::class.java) Intent(contextOrNull(), ImageActivity::class.java)
.setData(url.toUri()) .setData(url.toUri())
.putExtra(KEY_SOURCE, source?.name), .putExtra(KEY_SOURCE, source?.name)
.putExtra(KEY_PREVIEW, preview),
anchor?.let { scaleUpActivityOptionsOf(it) }, anchor?.let { scaleUpActivityOptionsOf(it) },
) )
} }
@@ -412,7 +414,7 @@ class AppRouter private constructor(
return return
} }
buildAlertDialog(contextOrNull() ?: return) { buildAlertDialog(contextOrNull() ?: return) {
setIcon(context.getThemeDrawable(materialR.attr.actionModeShareDrawable)) setIcon(context.getThemeDrawable(appcompatR.attr.actionModeShareDrawable))
setTitle(R.string.share) setTitle(R.string.share)
setItems( setItems(
arrayOf( arrayOf(
@@ -587,8 +589,11 @@ class AppRouter private constructor(
/** Private utils **/ /** Private utils **/
private fun startActivity(intent: Intent, options: Bundle? = null) { private fun startActivity(intent: Intent, options: Bundle? = null) {
fragment?.startActivity(intent, options) fragment?.also {
?: activity?.startActivity(intent, options) if (it.host != null) {
it.startActivity(intent, options)
}
} ?: activity?.startActivity(intent, options)
} }
private fun startActivitySafe(intent: Intent): Boolean = try { private fun startActivitySafe(intent: Intent): Boolean = try {
@@ -768,6 +773,7 @@ class AppRouter private constructor(
const val KEY_MANGA = "manga" const val KEY_MANGA = "manga"
const val KEY_MANGA_LIST = "manga_list" const val KEY_MANGA_LIST = "manga_list"
const val KEY_PAGES = "pages" const val KEY_PAGES = "pages"
const val KEY_PREVIEW = "preview"
const val KEY_QUERY = "query" const val KEY_QUERY = "query"
const val KEY_READER_MODE = "reader_mode" const val KEY_READER_MODE = "reader_mode"
const val KEY_SORT_ORDER = "sort_order" const val KEY_SORT_ORDER = "sort_order"

View File

@@ -105,6 +105,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getEnumValue(KEY_LIST_MODE_FAVORITES, listMode) get() = prefs.getEnumValue(KEY_LIST_MODE_FAVORITES, listMode)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_FAVORITES, value) } set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_FAVORITES, value) }
val isTagsWarningsEnabled: Boolean
get() = prefs.getBoolean(KEY_TAGS_WARNINGS, true)
var isNsfwContentDisabled: Boolean var isNsfwContentDisabled: Boolean
get() = prefs.getBoolean(KEY_DISABLE_NSFW, false) get() = prefs.getBoolean(KEY_DISABLE_NSFW, false)
set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) } set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) }
@@ -359,6 +362,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isSuggestionsExcludeNsfw: Boolean val isSuggestionsExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false) get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false)
val isSuggestionsIncludeDisabledSources: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_DISABLED_SOURCES, false)
val isSuggestionsNotificationAvailable: Boolean val isSuggestionsNotificationAvailable: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_NOTIFICATIONS, false) get() = prefs.getBoolean(KEY_SUGGESTIONS_NOTIFICATIONS, false)
@@ -658,6 +664,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SUGGESTIONS_WIFI_ONLY = "suggestions_wifi" const val KEY_SUGGESTIONS_WIFI_ONLY = "suggestions_wifi"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw" const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags" const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
const val KEY_SUGGESTIONS_DISABLED_SOURCES = "suggestions_disabled_sources"
const val KEY_SUGGESTIONS_NOTIFICATIONS = "suggestions_notifications" const val KEY_SUGGESTIONS_NOTIFICATIONS = "suggestions_notifications"
const val KEY_SHIKIMORI = "shikimori" const val KEY_SHIKIMORI = "shikimori"
const val KEY_ANILIST = "anilist" const val KEY_ANILIST = "anilist"
@@ -728,6 +735,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_BACKUP_TG_ENABLED = "backup_periodic_tg_enabled" const val KEY_BACKUP_TG_ENABLED = "backup_periodic_tg_enabled"
const val KEY_BACKUP_TG_CHAT = "backup_periodic_tg_chat_id" const val KEY_BACKUP_TG_CHAT = "backup_periodic_tg_chat_id"
const val KEY_MANGA_LIST_BADGES = "manga_list_badges" const val KEY_MANGA_LIST_BADGES = "manga_list_badges"
const val KEY_TAGS_WARNINGS = "tags_warnings"
// keys for non-persistent preferences // keys for non-persistent preferences
const val KEY_APP_VERSION = "app_version" const val KEY_APP_VERSION = "app_version"

View File

@@ -29,7 +29,7 @@ import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
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
import com.google.android.material.R as materialR import androidx.appcompat.R as appcompatR
abstract class BaseActivity<B : ViewBinding> : abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(), AppCompatActivity(),
@@ -103,7 +103,7 @@ abstract class BaseActivity<B : ViewBinding> :
supportActionBar?.run { supportActionBar?.run {
setDisplayHomeAsUpEnabled(isEnabled) setDisplayHomeAsUpEnabled(isEnabled)
if (showUpAsClose) { if (showUpAsClose) {
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) setHomeAsUpIndicator(appcompatR.drawable.abc_ic_clear_material)
} }
} }
} }

View File

@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.copyToClipboard import org.koitharu.kotatsu.core.util.ext.copyToClipboard
import org.koitharu.kotatsu.core.util.ext.getCauseUrl import org.koitharu.kotatsu.core.util.ext.getCauseUrl
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
import org.koitharu.kotatsu.core.util.ext.isReportable import org.koitharu.kotatsu.core.util.ext.isReportable
import org.koitharu.kotatsu.core.util.ext.report import org.koitharu.kotatsu.core.util.ext.report
import org.koitharu.kotatsu.core.util.ext.requireSerializable import org.koitharu.kotatsu.core.util.ext.requireSerializable
@@ -43,7 +44,7 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>(), Vie
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
binding.buttonBrowser.setOnClickListener(this) binding.buttonBrowser.setOnClickListener(this)
binding.textViewSummary.text = exception.message binding.textViewSummary.text = exception.message
val isUrlAvailable = !exception.getCauseUrl().isNullOrEmpty() val isUrlAvailable = exception.getCauseUrl()?.isHttpUrl() == true
binding.buttonBrowser.isVisible = isUrlAvailable binding.buttonBrowser.isVisible = isUrlAvailable
binding.textViewBrowser.isVisible = isUrlAvailable binding.textViewBrowser.isVisible = isUrlAvailable
binding.textViewDescription.setTextAndVisible( binding.textViewDescription.setTextAndVisible(

View File

@@ -36,6 +36,7 @@ import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isLayoutReversed import org.koitharu.kotatsu.core.util.ext.isLayoutReversed
import org.koitharu.kotatsu.databinding.FastScrollerBinding import org.koitharu.kotatsu.databinding.FastScrollerBinding
import kotlin.math.roundToInt import kotlin.math.roundToInt
import androidx.appcompat.R as appcompatR
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
private const val SCROLLBAR_HIDE_DELAY = 1000L private const val SCROLLBAR_HIDE_DELAY = 1000L
@@ -132,7 +133,7 @@ class FastScroller @JvmOverloads constructor(
clipChildren = false clipChildren = false
orientation = HORIZONTAL orientation = HORIZONTAL
@ColorInt var bubbleColor = context.getThemeColor(materialR.attr.colorControlNormal, Color.DKGRAY) @ColorInt var bubbleColor = context.getThemeColor(appcompatR.attr.colorControlNormal, Color.DKGRAY)
@ColorInt var handleColor = bubbleColor @ColorInt var handleColor = bubbleColor
@ColorInt var trackColor = context.getThemeColor(materialR.attr.colorOutline, Color.LTGRAY) @ColorInt var trackColor = context.getThemeColor(materialR.attr.colorOutline, Color.LTGRAY)
@ColorInt var textColor = context.getThemeColor(android.R.attr.textColorPrimaryInverse, Color.WHITE) @ColorInt var textColor = context.getThemeColor(android.R.attr.textColorPrimaryInverse, Color.WHITE)

View File

@@ -8,7 +8,7 @@ import android.view.animation.DecelerateInterpolator
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.navigation.NavigationBarView
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.measureHeight import org.koitharu.kotatsu.core.util.ext.measureHeight
@@ -16,7 +16,7 @@ import org.koitharu.kotatsu.core.util.ext.measureHeight
class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor( class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
context: Context? = null, context: Context? = null,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
) : CoordinatorLayout.Behavior<BottomNavigationView>(context, attrs) { ) : CoordinatorLayout.Behavior<NavigationBarView>(context, attrs) {
@ViewCompat.NestedScrollType @ViewCompat.NestedScrollType
private var lastStartedType: Int = 0 private var lastStartedType: Int = 0
@@ -34,13 +34,13 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
} }
} }
override fun layoutDependsOn(parent: CoordinatorLayout, child: BottomNavigationView, dependency: View): Boolean { override fun layoutDependsOn(parent: CoordinatorLayout, child: NavigationBarView, dependency: View): Boolean {
return dependency is AppBarLayout return dependency is AppBarLayout
} }
override fun onDependentViewChanged( override fun onDependentViewChanged(
parent: CoordinatorLayout, parent: CoordinatorLayout,
child: BottomNavigationView, child: NavigationBarView,
dependency: View, dependency: View,
): Boolean { ): Boolean {
val appBarSize = dependency.measureHeight() val appBarSize = dependency.measureHeight()
@@ -54,7 +54,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
override fun onStartNestedScroll( override fun onStartNestedScroll(
coordinatorLayout: CoordinatorLayout, coordinatorLayout: CoordinatorLayout,
child: BottomNavigationView, child: NavigationBarView,
directTargetChild: View, directTargetChild: View,
target: View, target: View,
axes: Int, axes: Int,
@@ -70,7 +70,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
override fun onNestedPreScroll( override fun onNestedPreScroll(
coordinatorLayout: CoordinatorLayout, coordinatorLayout: CoordinatorLayout,
child: BottomNavigationView, child: NavigationBarView,
target: View, target: View,
dx: Int, dx: Int,
dy: Int, dy: Int,
@@ -85,7 +85,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
override fun onStopNestedScroll( override fun onStopNestedScroll(
coordinatorLayout: CoordinatorLayout, coordinatorLayout: CoordinatorLayout,
child: BottomNavigationView, child: NavigationBarView,
target: View, target: View,
type: Int, type: Int,
) { ) {
@@ -94,7 +94,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
} }
} }
private fun animateBottomNavigationVisibility(child: BottomNavigationView, isVisible: Boolean) { private fun animateBottomNavigationVisibility(child: NavigationBarView, isVisible: Boolean) {
offsetAnimator?.cancel() offsetAnimator?.cancel()
offsetAnimator = ValueAnimator().apply { offsetAnimator = ValueAnimator().apply {
interpolator = DecelerateInterpolator() interpolator = DecelerateInterpolator()

View File

@@ -3,10 +3,12 @@ package org.koitharu.kotatsu.core.ui.widgets
import android.animation.Animator import android.animation.Animator
import android.animation.AnimatorListenerAdapter import android.animation.AnimatorListenerAdapter
import android.animation.TimeInterpolator import android.animation.TimeInterpolator
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.MotionEvent
import android.view.ViewPropertyAnimator import android.view.ViewPropertyAnimator
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.StyleRes import androidx.annotation.StyleRes
@@ -15,9 +17,11 @@ import androidx.core.view.isVisible
import androidx.customview.view.AbsSavedState import androidx.customview.view.AbsSavedState
import androidx.interpolator.view.animation.FastOutLinearInInterpolator import androidx.interpolator.view.animation.FastOutLinearInInterpolator
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.bottomnavigation.BottomNavigationMenuView
import com.google.android.material.navigation.NavigationBarView
import org.koitharu.kotatsu.core.util.ext.applySystemAnimatorScale import org.koitharu.kotatsu.core.util.ext.applySystemAnimatorScale
import org.koitharu.kotatsu.core.util.ext.measureHeight import org.koitharu.kotatsu.core.util.ext.measureHeight
import kotlin.math.max
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
private const val STATE_DOWN = 1 private const val STATE_DOWN = 1
@@ -26,12 +30,14 @@ private const val STATE_UP = 2
private const val SLIDE_UP_ANIMATION_DURATION = 225L private const val SLIDE_UP_ANIMATION_DURATION = 225L
private const val SLIDE_DOWN_ANIMATION_DURATION = 175L private const val SLIDE_DOWN_ANIMATION_DURATION = 175L
private const val MAX_ITEM_COUNT = 6
class SlidingBottomNavigationView @JvmOverloads constructor( class SlidingBottomNavigationView @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = materialR.attr.bottomNavigationStyle, @AttrRes defStyleAttr: Int = materialR.attr.bottomNavigationStyle,
@StyleRes defStyleRes: Int = materialR.style.Widget_Design_BottomNavigationView, @StyleRes defStyleRes: Int = materialR.style.Widget_Design_BottomNavigationView,
) : BottomNavigationView(context, attrs, defStyleAttr, defStyleRes), ) : NavigationBarView(context, attrs, defStyleAttr, defStyleRes),
CoordinatorLayout.AttachedBehavior { CoordinatorLayout.AttachedBehavior {
private var currentAnimator: ViewPropertyAnimator? = null private var currentAnimator: ViewPropertyAnimator? = null
@@ -55,6 +61,49 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
return behavior return behavior
} }
/** From BottomNavigationView **/
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
super.onTouchEvent(event)
// Consume all events to avoid views under the BottomNavigationView from receiving touch events.
return true
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val minHeightSpec = makeMinHeightSpec(heightMeasureSpec)
super.onMeasure(widthMeasureSpec, minHeightSpec)
if (MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY) {
setMeasuredDimension(
measuredWidth,
max(
measuredHeight,
suggestedMinimumHeight + paddingTop + paddingBottom,
),
)
}
}
private fun makeMinHeightSpec(measureSpec: Int): Int {
var minHeight = suggestedMinimumHeight
if (MeasureSpec.getMode(measureSpec) != MeasureSpec.EXACTLY && minHeight > 0) {
minHeight += paddingTop + paddingBottom
return MeasureSpec.makeMeasureSpec(
max(MeasureSpec.getSize(measureSpec), minHeight), MeasureSpec.AT_MOST,
)
}
return measureSpec
}
override fun getMaxItemCount(): Int = MAX_ITEM_COUNT
@SuppressLint("RestrictedApi")
override fun createNavigationBarMenuView(context: Context) = BottomNavigationMenuView(context)
/** End **/
override fun onSaveInstanceState(): Parcelable { override fun onSaveInstanceState(): Parcelable {
val superState = super.onSaveInstanceState() val superState = super.onSaveInstanceState()
return SavedState(superState, currentState, translationY) return SavedState(superState, currentState, translationY)

View File

@@ -1,28 +0,0 @@
package org.koitharu.kotatsu.core.util
import android.view.View
import android.view.ViewTreeObserver
/**
* ProgressIndicator become INVISIBLE instead of GONE by hide() call.
* It`s final so we need this workaround
*/
class GoneOnInvisibleListener(
private val view: View,
) : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
if (view.visibility == View.INVISIBLE) {
view.visibility = View.GONE
}
}
fun attach() {
view.viewTreeObserver.addOnGlobalLayoutListener(this)
onGlobalLayout()
}
fun detach() {
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
}
}

View File

@@ -5,6 +5,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
class RetainedLifecycleCoroutineScope( class RetainedLifecycleCoroutineScope(
@@ -14,7 +15,9 @@ class RetainedLifecycleCoroutineScope(
override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate
init { init {
lifecycle.addOnClearedListener(this) launch(Dispatchers.Main.immediate) {
lifecycle.addOnClearedListener(this@RetainedLifecycleCoroutineScope)
}
} }
override fun onCleared() { override fun onCleared() {

View File

@@ -6,10 +6,13 @@ import android.widget.ImageView
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.drawable.toDrawable
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.annotation.CheckResult
import coil3.Extras import coil3.Extras
import coil3.ImageLoader import coil3.ImageLoader
import coil3.asDrawable import coil3.asDrawable
import coil3.decode.ImageSource
import coil3.fetch.FetchResult import coil3.fetch.FetchResult
import coil3.fetch.SourceFetchResult
import coil3.request.ErrorResult import coil3.request.ErrorResult
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.ImageResult import coil3.request.ImageResult
@@ -28,12 +31,14 @@ import coil3.toBitmap
import coil3.util.CoilUtils import coil3.util.CoilUtils
import com.google.android.material.progressindicator.BaseProgressIndicator import com.google.android.material.progressindicator.BaseProgressIndicator
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import okio.buffer
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.image.RegionBitmapDecoder import org.koitharu.kotatsu.core.image.RegionBitmapDecoder
import org.koitharu.kotatsu.core.ui.image.AnimatedPlaceholderDrawable import org.koitharu.kotatsu.core.ui.image.AnimatedPlaceholderDrawable
import org.koitharu.kotatsu.core.util.progress.ImageRequestIndicatorListener import org.koitharu.kotatsu.core.util.progress.ImageRequestIndicatorListener
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 androidx.appcompat.R as appcompatR
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
fun ImageView.newImageRequest(lifecycleOwner: LifecycleOwner, data: Any?): ImageRequest.Builder? { fun ImageView.newImageRequest(lifecycleOwner: LifecycleOwner, data: Any?): ImageRequest.Builder? {
@@ -112,7 +117,7 @@ fun ImageRequest.Builder.bookmarkExtra(bookmark: Bookmark): ImageRequest.Builder
fun ImageRequest.Builder.defaultPlaceholders(context: Context): ImageRequest.Builder { fun ImageRequest.Builder.defaultPlaceholders(context: Context): ImageRequest.Builder {
val errorColor = ColorUtils.blendARGB( val errorColor = ColorUtils.blendARGB(
context.getThemeColor(materialR.attr.colorErrorContainer), context.getThemeColor(materialR.attr.colorErrorContainer),
context.getThemeColor(materialR.attr.colorBackgroundFloating), context.getThemeColor(appcompatR.attr.colorBackgroundFloating),
0.25f, 0.25f,
) )
return placeholder(AnimatedPlaceholderDrawable(context)) return placeholder(AnimatedPlaceholderDrawable(context))
@@ -162,3 +167,14 @@ private class CompositeImageRequestListener(
val mangaKey = Extras.Key<Manga?>(null) val mangaKey = Extras.Key<Manga?>(null)
val bookmarkKey = Extras.Key<Bookmark?>(null) val bookmarkKey = Extras.Key<Bookmark?>(null)
val mangaSourceKey = Extras.Key<MangaSource?>(null) val mangaSourceKey = Extras.Key<MangaSource?>(null)
@CheckResult
fun SourceFetchResult.copyWithNewSource(): SourceFetchResult = SourceFetchResult(
source = ImageSource(
source = source.fileSystem.source(source.file()).buffer(),
fileSystem = source.fileSystem,
metadata = source.metadata,
),
mimeType = mimeType,
dataSource = dataSource,
)

View File

@@ -87,6 +87,10 @@ fun <T, R> Collection<T>.mapSortedByCount(isDescending: Boolean = true, mapper:
return sorted.map { it.first } return sorted.map { it.first }
} }
fun Collection<CharSequence?>.contains(element: CharSequence?, ignoreCase: Boolean): Boolean = any { x ->
(x == null && element == null) || (x != null && element != null && x.contains(element, ignoreCase))
}
fun Collection<CharSequence?>.indexOfContains(element: CharSequence?, ignoreCase: Boolean): Int = indexOfFirst { x -> fun Collection<CharSequence?>.indexOfContains(element: CharSequence?, ignoreCase: Boolean): Int = indexOfFirst { x ->
(x == null && element == null) || (x != null && element != null && x.contains(element, ignoreCase)) (x == null && element == null) || (x != null && element != null && x.contains(element, ignoreCase))
} }

View File

@@ -61,6 +61,8 @@ inline fun <T, R> Flow<List<T>>.mapItems(crossinline transform: (T) -> R): Flow<
return map { list -> list.map(transform) } return map { list -> list.map(transform) }
} }
fun <T> Flow<T>.throttle(timeoutMillis: Long): Flow<T> = throttle { timeoutMillis }
fun <T> Flow<T>.throttle(timeoutMillis: (T) -> Long): Flow<T> { fun <T> Flow<T>.throttle(timeoutMillis: (T) -> Long): Flow<T> {
var lastEmittedAt = 0L var lastEmittedAt = 0L
return transformLatest { value -> return transformLatest { value ->

View File

@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.ResponseBody import okhttp3.ResponseBody
import okio.BufferedSink import okio.BufferedSink
import okio.BufferedSource
import okio.FileSystem import okio.FileSystem
import okio.IOException import okio.IOException
import okio.Path import okio.Path
@@ -30,6 +31,14 @@ suspend fun BufferedSink.writeAllCancellable(source: Source) = withContext(Dispa
writeAll(source.cancellable()) writeAll(source.cancellable())
} }
fun BufferedSource.readByteBuffer(): ByteBuffer {
val bytes = readByteArray()
return ByteBuffer.allocateDirect(bytes.size)
.put(bytes)
.rewind() as ByteBuffer
}
@Deprecated("")
fun InputStream.toByteBuffer(): ByteBuffer { fun InputStream.toByteBuffer(): ByteBuffer {
val outStream = ByteArrayOutputStream(available()) val outStream = ByteArrayOutputStream(available())
copyTo(outStream) copyTo(outStream)

View File

@@ -21,7 +21,13 @@ inline fun <T> LocaleListCompat.mapToSet(block: (Locale) -> T): Set<T> {
fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException() fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException()
fun String.toLocale() = Locale(this) fun String.toLocale(): Locale = Locale.forLanguageTag(this)
fun String.toLocaleOrNull() = if (isEmpty()) {
null
} else {
toLocale().takeUnless { it.displayName == this }
}
fun Locale?.getDisplayName(context: Context): String = when (this) { fun Locale?.getDisplayName(context: Context): String = when (this) {
null -> context.getString(R.string.all_languages) null -> context.getString(R.string.all_languages)

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.util.ext
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import android.os.Build
import androidx.annotation.PluralsRes import androidx.annotation.PluralsRes
import androidx.annotation.Px import androidx.annotation.Px
import androidx.core.util.TypedValueCompat import androidx.core.util.TypedValueCompat
@@ -30,7 +31,10 @@ fun Context.getSystemBoolean(resName: String, fallback: Boolean): Boolean {
fun Resources.getQuantityStringSafe(@PluralsRes resId: Int, quantity: Int, vararg formatArgs: Any): String = try { fun Resources.getQuantityStringSafe(@PluralsRes resId: Int, quantity: Int, vararg formatArgs: Any): String = try {
getQuantityString(resId, quantity, *formatArgs) getQuantityString(resId, quantity, *formatArgs)
} catch (e: Resources.NotFoundException) { } catch (e: Resources.NotFoundException) {
e.report(silent = true) if (Build.VERSION.SDK_INT == Build.VERSION_CODES.VANILLA_ICE_CREAM) { // known issue
e.printStackTraceDebug() e.printStackTraceDebug()
formatArgs.firstOrNull()?.toString() ?: quantity.toString() formatArgs.firstOrNull()?.toString() ?: quantity.toString()
} else {
throw e
}
} }

View File

@@ -22,6 +22,7 @@ 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.IncompatiblePluginException
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
import org.koitharu.kotatsu.core.exceptions.NonFileUriException
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException 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.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
@@ -91,6 +92,7 @@ private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = w
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message) is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
is FileNotFoundException -> parseMessage(resources) ?: message is FileNotFoundException -> parseMessage(resources) ?: message
is AccessDeniedException -> resources.getString(R.string.no_access_to_file) is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
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 ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
is SyncApiException, is SyncApiException,

View File

@@ -10,7 +10,6 @@ import androidx.appcompat.widget.ActionMenuView
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
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.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@@ -155,9 +154,9 @@ fun TabLayout.setTabsEnabled(enabled: Boolean) {
fun BaseProgressIndicator<*>.showOrHide(value: Boolean) { fun BaseProgressIndicator<*>.showOrHide(value: Boolean) {
if (value) { if (value) {
if (!isVisible) show() show()
} else { } else {
if (isVisible) hide() hide()
} }
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.details.data package org.koitharu.kotatsu.details.data
import org.koitharu.kotatsu.core.model.getLocale
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
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
@@ -7,6 +8,7 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.reader.data.filterChapters import org.koitharu.kotatsu.reader.data.filterChapters
import java.util.Locale
data class MangaDetails( data class MangaDetails(
private val manga: Manga, private val manga: Manga,
@@ -39,6 +41,13 @@ data class MangaDetails(
fun toManga() = manga fun toManga() = manga
fun getLocale(): Locale? {
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
return it
}
return manga.source.getLocale()
}
fun filterChapters(branch: String?) = MangaDetails( fun filterChapters(branch: String?) = MangaDetails(
manga = manga.filterChapters(branch), manga = manga.filterChapters(branch),
localManga = localManga?.run { localManga = localManga?.run {
@@ -69,4 +78,16 @@ data class MangaDetails(
} }
return result return result
} }
private fun findAppropriateLocale(name: String?): Locale? {
if (name.isNullOrEmpty()) {
return null
}
return Locale.getAvailableLocales().find { lc ->
name.contains(lc.getDisplayName(lc), ignoreCase = true) ||
name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) ||
name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) ||
name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true)
}
}
} }

View File

@@ -0,0 +1,29 @@
package org.koitharu.kotatsu.details.ui
import android.text.Spannable
import android.text.TextPaint
import android.text.style.ClickableSpan
import android.view.View
import android.widget.TextView
class AuthorSpan(private val listener: OnAuthorClickListener) : ClickableSpan() {
override fun onClick(widget: View) {
val text = (widget as? TextView)?.text as? Spannable ?: return
val start = text.getSpanStart(this)
val end = text.getSpanEnd(this)
val selected = text.substring(start, end).trim()
if (selected.isNotEmpty()) {
listener.onAuthorClick(selected)
}
}
override fun updateDrawState(ds: TextPaint) {
ds.setColor(ds.linkColor)
}
fun interface OnAuthorClickListener {
fun onAuthorClick(author: String)
}
}

View File

@@ -2,12 +2,15 @@ package org.koitharu.kotatsu.details.ui
import android.content.Context import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.text.SpannedString
import android.view.Gravity import android.view.Gravity
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
import android.widget.Toast import android.widget.Toast
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.core.text.method.LinkMovementMethodCompat import androidx.core.text.method.LinkMovementMethodCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isGone import androidx.core.view.isGone
@@ -43,6 +46,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
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.image.CoilMemoryCacheKey
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.core.model.UnknownMangaSource
@@ -100,10 +104,12 @@ import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -115,7 +121,7 @@ class DetailsActivity :
View.OnClickListener, View.OnClickListener,
View.OnLayoutChangeListener, ViewTreeObserver.OnDrawListener, View.OnLayoutChangeListener, ViewTreeObserver.OnDrawListener,
ChipsView.OnChipClickListener, OnListItemClickListener<Bookmark>, ChipsView.OnChipClickListener, OnListItemClickListener<Bookmark>,
SwipeRefreshLayout.OnRefreshListener { SwipeRefreshLayout.OnRefreshListener, AuthorSpan.OnAuthorClickListener {
@Inject @Inject
lateinit var shortcutManager: AppShortcutManager lateinit var shortcutManager: AppShortcutManager
@@ -135,7 +141,6 @@ class DetailsActivity :
supportActionBar?.setDisplayShowTitleEnabled(false) supportActionBar?.setDisplayShowTitleEnabled(false)
viewBinding.chipFavorite.setOnClickListener(this) viewBinding.chipFavorite.setOnClickListener(this)
infoBinding.textViewLocal.setOnClickListener(this) infoBinding.textViewLocal.setOnClickListener(this)
infoBinding.textViewAuthor.setOnClickListener(this)
infoBinding.textViewSource.setOnClickListener(this) infoBinding.textViewSource.setOnClickListener(this)
viewBinding.imageViewCover.setOnClickListener(this) viewBinding.imageViewCover.setOnClickListener(this)
viewBinding.textViewTitle.setOnClickListener(this) viewBinding.textViewTitle.setOnClickListener(this)
@@ -145,6 +150,7 @@ class DetailsActivity :
viewBinding.textViewDescription.addOnLayoutChangeListener(this) viewBinding.textViewDescription.addOnLayoutChangeListener(this)
viewBinding.swipeRefreshLayout.setOnRefreshListener(this) viewBinding.swipeRefreshLayout.setOnRefreshListener(this)
viewBinding.textViewDescription.viewTreeObserver.addOnDrawListener(this) viewBinding.textViewDescription.viewTreeObserver.addOnDrawListener(this)
infoBinding.textViewAuthor.movementMethod = LinkMovementMethodCompat.getInstance()
viewBinding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance() viewBinding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
viewBinding.chipsTags.onChipClickListener = this viewBinding.chipsTags.onChipClickListener = this
TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView) TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView)
@@ -179,16 +185,6 @@ class DetailsActivity :
viewModel.isStatsAvailable.observe(this, menuInvalidator) viewModel.isStatsAvailable.observe(this, menuInvalidator)
viewModel.remoteManga.observe(this, menuInvalidator) viewModel.remoteManga.observe(this, menuInvalidator)
viewModel.tags.observe(this, ::onTagsChanged) viewModel.tags.observe(this, ::onTagsChanged)
viewModel.branches.observe(this) {
val branch = it.singleOrNull()
infoBinding.textViewTranslation.textAndVisible = branch?.name
infoBinding.textViewTranslation.drawableStart = branch?.locale?.let {
LocaleUtils.getEmojiFlag(it)
}?.let {
TextDrawable.compound(infoBinding.textViewTranslation, it)
}
infoBinding.textViewTranslationLabel.isVisible = infoBinding.textViewTranslation.isVisible
}
viewModel.chapters.observe(this, PrefetchObserver(this)) viewModel.chapters.observe(this, PrefetchObserver(this))
viewModel.onDownloadStarted viewModel.onDownloadStarted
.filterNot { appRouter.isChapterPagesSheetShown() } .filterNot { appRouter.isChapterPagesSheetShown() }
@@ -202,36 +198,31 @@ class DetailsActivity :
addMenuProvider(menuProvider) addMenuProvider(menuProvider)
} }
override fun isNsfwContent(): Flow<Boolean> = viewModel.manga.map { it?.isNsfw == true } override fun isNsfwContent(): Flow<Boolean> = viewModel.manga.map { it?.contentRating == ContentRating.ADULT }
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.textView_author -> {
val manga = viewModel.manga.value
val author = manga?.author ?: return
router.showAuthorDialog(author, manga.source)
}
R.id.textView_source -> { R.id.textView_source -> {
val manga = viewModel.manga.value ?: return val manga = viewModel.getMangaOrNull() ?: return
router.openList(manga.source, null, null) router.openList(manga.source, null, null)
} }
R.id.textView_local -> { R.id.textView_local -> {
val manga = viewModel.manga.value ?: return val manga = viewModel.getMangaOrNull() ?: return
router.showLocalInfoDialog(manga) router.showLocalInfoDialog(manga)
} }
R.id.chip_favorite -> { R.id.chip_favorite -> {
val manga = viewModel.manga.value ?: return val manga = viewModel.getMangaOrNull() ?: return
router.showFavoriteDialog(manga) router.showFavoriteDialog(manga)
} }
R.id.imageView_cover -> { R.id.imageView_cover -> {
val manga = viewModel.manga.value ?: return val manga = viewModel.getMangaOrNull() ?: return
router.openImage( router.openImage(
url = viewModel.coverUrl.value ?: return, url = viewModel.coverUrl.value ?: return,
source = manga.source, source = manga.source,
preview = CoilMemoryCacheKey.from(viewBinding.imageViewCover),
anchor = v, anchor = v,
) )
} }
@@ -251,17 +242,17 @@ class DetailsActivity :
} }
R.id.button_scrobbling_more -> { R.id.button_scrobbling_more -> {
val manga = viewModel.manga.value ?: return val manga = viewModel.getMangaOrNull() ?: return
router.showScrobblingSelectorSheet(manga, null) router.showScrobblingSelectorSheet(manga, null)
} }
R.id.button_related_more -> { R.id.button_related_more -> {
val manga = viewModel.manga.value ?: return val manga = viewModel.getMangaOrNull() ?: return
router.openRelated(manga) router.openRelated(manga)
} }
R.id.textView_title -> { R.id.textView_title -> {
val title = viewModel.manga.value?.title?.nullIfEmpty() ?: return val title = viewModel.getMangaOrNull()?.title?.nullIfEmpty() ?: return
buildAlertDialog(this) { buildAlertDialog(this) {
setMessage(title) setMessage(title)
setNegativeButton(R.string.close, null) setNegativeButton(R.string.close, null)
@@ -273,6 +264,10 @@ class DetailsActivity :
} }
} }
override fun onAuthorClick(author: String) {
router.showAuthorDialog(author, viewModel.getMangaOrNull()?.source ?: return)
}
override fun onChipClick(chip: Chip, data: Any?) { override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag ?: return val tag = data as? MangaTag ?: return
router.showTagDialog(tag) router.showTagDialog(tag)
@@ -306,7 +301,6 @@ class DetailsActivity :
oldBottom: Int oldBottom: Int
) { ) {
with(viewBinding) { with(viewBinding) {
buttonDescriptionMore.isVisible = textViewDescription.isTextTruncated
containerBottomSheet?.let { sheet -> containerBottomSheet?.let { sheet ->
val peekHeight = BottomSheetBehavior.from(sheet).peekHeight val peekHeight = BottomSheetBehavior.from(sheet).peekHeight
if (scrollView.paddingBottom != peekHeight) { if (scrollView.paddingBottom != peekHeight) {
@@ -407,11 +401,21 @@ class DetailsActivity :
with(viewBinding) { with(viewBinding) {
textViewTitle.text = manga.title textViewTitle.text = manga.title
textViewSubtitle.textAndVisible = manga.altTitles.joinToString("\n") textViewSubtitle.textAndVisible = manga.altTitles.joinToString("\n")
textViewNsfw.isVisible = manga.isNsfw textViewNsfw16.isVisible = manga.contentRating == ContentRating.SUGGESTIVE
textViewNsfw18.isVisible = manga.contentRating == ContentRating.ADULT
textViewDescription.text = details.description.ifNullOrEmpty { getString(R.string.no_description) } textViewDescription.text = details.description.ifNullOrEmpty { getString(R.string.no_description) }
} }
with(infoBinding) { with(infoBinding) {
textViewAuthor.textAndVisible = manga.author val translation = details.getLocale()
infoBinding.textViewTranslation.textAndVisible = translation?.getDisplayLanguage(translation)
?.toTitleCase(translation)
infoBinding.textViewTranslation.drawableStart = translation?.let {
LocaleUtils.getEmojiFlag(it)
}?.let {
TextDrawable.compound(infoBinding.textViewTranslation, it)
}
infoBinding.textViewTranslationLabel.isVisible = infoBinding.textViewTranslation.isVisible
textViewAuthor.textAndVisible = manga.getAuthorsString()
textViewAuthorLabel.isVisible = textViewAuthor.isVisible textViewAuthorLabel.isVisible = textViewAuthor.isVisible
if (manga.hasRating) { if (manga.hasRating) {
ratingBarRating.rating = manga.rating * ratingBarRating.numStars ratingBarRating.rating = manga.rating * ratingBarRating.numStars
@@ -533,6 +537,24 @@ class DetailsActivity :
return getString(R.string.chapters_time_pattern, this, timeFormatted) return getString(R.string.chapters_time_pattern, this, timeFormatted)
} }
private fun Manga.getAuthorsString(): SpannedString? {
if (authors.isEmpty()) {
return null
}
return buildSpannedString {
authors.forEach { a ->
if (a.isNotEmpty()) {
if (isNotEmpty()) {
append(", ")
}
inSpans(AuthorSpan(this@DetailsActivity)) {
append(a)
}
}
}
}.nullIfEmpty()
}
private class PrefetchObserver( private class PrefetchObserver(
private val context: Context, private val context: Context,
) : FlowCollector<List<ChapterListItem>?> { ) : FlowCollector<List<ChapterListItem>?> {

View File

@@ -26,6 +26,7 @@ class DetailsErrorObserver(
override suspend fun emit(value: Throwable) { override suspend fun emit(value: Throwable) {
val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT) val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
snackbar.setAnchorView(activity.viewBinding.containerBottomSheet)
if (value is NotFoundException || value is UnsupportedSourceException) { if (value is NotFoundException || value is UnsupportedSourceException) {
snackbar.duration = Snackbar.LENGTH_INDEFINITE snackbar.duration = Snackbar.LENGTH_INDEFINITE
} }

View File

@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
class DetailsMenuProvider( class DetailsMenuProvider(
private val activity: FragmentActivity, private val activity: FragmentActivity,
@@ -36,7 +37,7 @@ class DetailsMenuProvider(
menu.findItem(R.id.action_share).isVisible = manga != null && AppRouter.isShareSupported(manga) menu.findItem(R.id.action_share).isVisible = manga != null && AppRouter.isShareSupported(manga)
menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != LocalMangaSource menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != LocalMangaSource
menu.findItem(R.id.action_delete).isVisible = manga?.source == LocalMangaSource menu.findItem(R.id.action_delete).isVisible = manga?.source == LocalMangaSource
menu.findItem(R.id.action_browser).isVisible = manga?.source != LocalMangaSource menu.findItem(R.id.action_browser).isVisible = manga?.publicUrl?.isHttpUrl() == true
menu.findItem(R.id.action_alternatives).isVisible = manga?.source != LocalMangaSource menu.findItem(R.id.action_alternatives).isVisible = manga?.source != LocalMangaSource
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity) menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
@@ -69,7 +70,7 @@ class DetailsMenuProvider(
} }
R.id.action_online -> { R.id.action_online -> {
router.openDetails(manga) router.openDetails(viewModel.remoteManga.value ?: return false)
} }
R.id.action_related -> { R.id.action_related -> {

View File

@@ -125,15 +125,16 @@ class ReadButtonDelegate(
} }
private fun onHistoryChanged(isLoading: Boolean, info: HistoryInfo) { private fun onHistoryChanged(isLoading: Boolean, info: HistoryInfo) {
val isChaptersLoading = isLoading && (info.totalChapters <= 0 || info.isChapterMissing)
buttonRead.setText( buttonRead.setText(
when { when {
isLoading -> R.string.loading_ isChaptersLoading -> R.string.loading_
info.isIncognitoMode -> R.string.incognito info.isIncognitoMode -> R.string.incognito
info.canContinue -> R.string._continue info.canContinue -> R.string._continue
else -> R.string.read else -> R.string.read
}, },
) )
splitButton.isEnabled = !isLoading && info.isValid splitButton.isEnabled = !isChaptersLoading && info.isValid
} }
private fun Menu.populateBranchList() { private fun Menu.populateBranchList() {

View File

@@ -15,16 +15,17 @@ import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.core.util.ext.getItem import org.koitharu.kotatsu.core.util.ext.getItem
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import androidx.appcompat.R as appcompatR
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val radius = context.resources.getDimension(materialR.dimen.abc_control_corner_material) private val radius = context.resources.getDimension(appcompatR.dimen.abc_control_corner_material)
private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle) private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.chapter_check_offset) private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.chapter_check_offset)
private val iconSize = context.resources.getDimensionPixelOffset(R.dimen.chapter_check_size) private val iconSize = context.resources.getDimensionPixelOffset(R.dimen.chapter_check_size)
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED) private val strokeColor = context.getThemeColor(appcompatR.attr.colorPrimary, Color.RED)
private val fillColor = ColorUtils.setAlphaComponent( private val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74, 0x74,
@@ -32,7 +33,7 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor
init { init {
paint.color = ColorUtils.setAlphaComponent( paint.color = ColorUtils.setAlphaComponent(
context.getThemeColor(materialR.attr.colorPrimary, Color.DKGRAY), context.getThemeColor(appcompatR.attr.colorPrimary, Color.DKGRAY),
98, 98,
) )
paint.style = Paint.Style.FILL paint.style = Paint.Style.FILL

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.details.ui.model
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 java.util.Locale
data class MangaBranch( data class MangaBranch(
val name: String?, val name: String?,
@@ -11,8 +10,6 @@ data class MangaBranch(
val isCurrent: Boolean, val isCurrent: Boolean,
) : ListModel { ) : ListModel {
val locale: Locale? by lazy(::findAppropriateLocale)
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {
return other is MangaBranch && other.name == name return other is MangaBranch && other.name == name
} }
@@ -28,16 +25,4 @@ data class MangaBranch(
override fun toString(): String { override fun toString(): String {
return "$name: $count" return "$name: $count"
} }
private fun findAppropriateLocale(): Locale? {
if (name.isNullOrEmpty()) {
return null
}
return Locale.getAvailableLocales().find { lc ->
name.contains(lc.getDisplayName(lc), ignoreCase = true) ||
name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) ||
name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) ||
name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true)
}
}
} }

View File

@@ -7,7 +7,6 @@ import android.view.ViewGroup
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.transition.TransitionManager
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@@ -27,7 +26,6 @@ import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
import org.koitharu.kotatsu.core.util.ext.findCurrentPagerFragment import org.koitharu.kotatsu.core.util.ext.findCurrentPagerFragment
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.menuView import org.koitharu.kotatsu.core.util.ext.menuView
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
@@ -106,9 +104,6 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(),
} }
val binding = viewBinding ?: return val binding = viewBinding ?: return
val isActionModeStarted = actionModeDelegate?.isActionModeStarted == true val isActionModeStarted = actionModeDelegate?.isActionModeStarted == true
if (sheet.context.isAnimationsEnabled) {
TransitionManager.beginDelayedTransition(binding.toolbar)
}
binding.toolbar.menuView?.isVisible = newState == STATE_EXPANDED && !isActionModeStarted binding.toolbar.menuView?.isVisible = newState == STATE_EXPANDED && !isActionModeStarted
binding.splitButtonRead.isVisible = newState != STATE_EXPANDED && !isActionModeStarted binding.splitButtonRead.isVisible = newState != STATE_EXPANDED && !isActionModeStarted
&& viewModel is DetailsViewModel && viewModel is DetailsViewModel

View File

@@ -100,7 +100,11 @@ abstract class ChaptersPagesViewModel(
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
val bookmarks = mangaDetails.flatMapLatest { val bookmarks = mangaDetails.flatMapLatest {
if (it != null) bookmarksRepository.observeBookmarks(it.toManga()) else flowOf(emptyList()) if (it != null) {
bookmarksRepository.observeBookmarks(it.toManga()).withErrorHandling()
} else {
flowOf(emptyList())
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList()) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
val chapters = combine( val chapters = combine(

View File

@@ -71,7 +71,7 @@ class BookmarksViewModel @Inject constructor(
if (b.isNullOrEmpty()) { if (b.isNullOrEmpty()) {
continue continue
} }
result += ListHeader(chapter.name) result += ListHeader(chapter)
result.addAll(b) result.addAll(b)
} }
if (result.isEmpty()) { if (result.isEmpty()) {

View File

@@ -3,10 +3,12 @@ package org.koitharu.kotatsu.details.ui.pager.pages
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil3.ImageLoader import coil3.ImageLoader
import coil3.request.allowRgb565 import coil3.request.allowRgb565
import coil3.request.transformations
import coil3.size.Scale import coil3.size.Scale
import coil3.size.Size import coil3.size.Size
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.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.decodeRegion import org.koitharu.kotatsu.core.util.ext.decodeRegion
@@ -43,6 +45,7 @@ fun pageThumbnailAD(
size(thumbSize) size(thumbSize)
scale(Scale.FILL) scale(Scale.FILL)
allowRgb565(true) allowRgb565(true)
transformations(TrimTransformation())
decodeRegion(0) decodeRegion(0)
mangaSourceExtra(item.page.source) mangaSourceExtra(item.page.source)
enqueueWith(coil) enqueueWith(coil)

View File

@@ -130,7 +130,7 @@ class PagesViewModel @Inject constructor(
for (page in snapshot) { for (page in snapshot) {
if (page.chapterId != previousChapterId) { if (page.chapterId != previousChapterId) {
chaptersLoader.peekChapter(page.chapterId)?.let { chaptersLoader.peekChapter(page.chapterId)?.let {
add(ListHeader(it.name)) add(ListHeader(it))
} }
previousChapterId = page.chapterId previousChapterId = page.chapterId
} }

View File

@@ -16,7 +16,7 @@ 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 import androidx.appcompat.R as appcompatR
data class DownloadItemModel( data class DownloadItemModel(
val id: UUID, val id: UUID,
@@ -62,7 +62,7 @@ data class DownloadItemModel(
fun getErrorMessage(context: Context): CharSequence? = if (error != null) { fun getErrorMessage(context: Context): CharSequence? = if (error != null) {
buildSpannedString { buildSpannedString {
bold { bold {
color(context.getThemeColor(materialR.attr.colorError, Color.RED)) { color(context.getThemeColor(appcompatR.attr.colorError, Color.RED)) {
append(error) append(error)
} }
} }
@@ -72,7 +72,7 @@ data class DownloadItemModel(
} }
override fun compareTo(other: DownloadItemModel): Int { override fun compareTo(other: DownloadItemModel): Int {
return timestamp.compareTo(other.timestamp) return timestamp compareTo other.timestamp
} }
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {

View File

@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.core.util.ext.getItem import org.koitharu.kotatsu.core.util.ext.getItem
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import androidx.appcompat.R as appcompatR
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
class DownloadsSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { class DownloadsSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
@@ -23,7 +24,7 @@ class DownloadsSelectionDecoration(context: Context) : AbstractSelectionItemDeco
private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle) private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_offset) private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_offset)
private val iconSize = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_size) private val iconSize = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_size)
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED) private val strokeColor = context.getThemeColor(appcompatR.attr.colorPrimary, Color.RED)
private val fillColor = ColorUtils.setAlphaComponent( private val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74, 0x74,

View File

@@ -25,6 +25,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ErrorReporterReceiver import org.koitharu.kotatsu.core.ErrorReporterReceiver
import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.core.LocalizedAppContext
import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.AppRouter
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.isReportable
@@ -36,7 +37,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.UUID import java.util.UUID
import com.google.android.material.R as materialR import androidx.appcompat.R as appcompatR
private const val CHANNEL_ID_DEFAULT = "download" private const val CHANNEL_ID_DEFAULT = "download"
private const val CHANNEL_ID_SILENT = "download_bg" private const val CHANNEL_ID_SILENT = "download_bg"
@@ -70,7 +71,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
private val actionCancel by lazy { private val actionCancel by lazy {
NotificationCompat.Action( NotificationCompat.Action(
materialR.drawable.material_ic_clear_black_24dp, appcompatR.drawable.abc_ic_clear_material,
context.getString(android.R.string.cancel), context.getString(android.R.string.cancel),
workManager.createCancelPendingIntent(uuid), workManager.createCancelPendingIntent(uuid),
) )
@@ -140,10 +141,10 @@ class DownloadNotificationFactory @AssistedInject constructor(
builder.setSubText(null) builder.setSubText(null)
builder.setShowWhen(false) builder.setShowWhen(false)
builder.setVisibility( builder.setVisibility(
if (state != null && state.manga.isNsfw) { if (state != null && state.manga.isNsfw()) {
NotificationCompat.VISIBILITY_PRIVATE NotificationCompat.VISIBILITY_SECRET
} else { } else {
NotificationCompat.VISIBILITY_PUBLIC NotificationCompat.VISIBILITY_PRIVATE
}, },
) )
when { when {

View File

@@ -6,7 +6,6 @@ import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.room.withTransaction import androidx.room.withTransaction
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -354,7 +353,7 @@ class MangaSourcesRepository @Inject constructor(
.conflate() .conflate()
} }
private fun getExternalSources() = context.packageManager.queryIntentContentProviders( fun getExternalSources(): List<ExternalMangaSource> = context.packageManager.queryIntentContentProviders(
Intent("app.kotatsu.parser.PROVIDE_MANGA"), 0, Intent("app.kotatsu.parser.PROVIDE_MANGA"), 0,
).map { resolveInfo -> ).map { resolveInfo ->
ExternalMangaSource( ExternalMangaSource(

View File

@@ -36,15 +36,15 @@ class RecoverMangaUseCase @Inject constructor(
) = Manga( ) = Manga(
id = broken.id, id = broken.id,
title = current.title, title = current.title,
altTitle = current.altTitle, altTitles = current.altTitles,
url = current.url, url = current.url,
publicUrl = current.publicUrl, publicUrl = current.publicUrl,
rating = current.rating, rating = current.rating,
isNsfw = current.isNsfw, contentRating = current.contentRating,
coverUrl = current.coverUrl, coverUrl = current.coverUrl,
tags = current.tags, tags = current.tags,
state = current.state, state = current.state,
author = current.author, authors = current.authors,
largeCoverUrl = current.largeCoverUrl, largeCoverUrl = current.largeCoverUrl,
description = current.description, description = current.description,
chapters = current.chapters, chapters = current.chapters,

View File

@@ -14,12 +14,13 @@ import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.core.util.ext.getItem import org.koitharu.kotatsu.core.util.ext.getItem
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
import androidx.appcompat.R as appcompatR
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
class SourceSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { class SourceSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED) private val strokeColor = context.getThemeColor(appcompatR.attr.colorPrimary, Color.RED)
private val fillColor = ColorUtils.setAlphaComponent( private val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74, 0x74,

View File

@@ -13,13 +13,14 @@ import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.core.util.ext.getItem import org.koitharu.kotatsu.core.util.ext.getItem
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import androidx.appcompat.R as appcompatR
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
class CategoriesSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { class CategoriesSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val radius = context.resources.getDimension(R.dimen.list_selector_corner) private val radius = context.resources.getDimension(R.dimen.list_selector_corner)
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED) private val strokeColor = context.getThemeColor(appcompatR.attr.colorPrimary, Color.RED)
private val fillColor = ColorUtils.setAlphaComponent( private val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74, 0x74,

View File

@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.util.ext.setThemeTextAppearance
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ViewFilterFieldBinding import org.koitharu.kotatsu.databinding.ViewFilterFieldBinding
import java.util.LinkedList import java.util.LinkedList
import androidx.appcompat.R as appcompatR
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
class FilterFieldLayout @JvmOverloads constructor( class FilterFieldLayout @JvmOverloads constructor(
@@ -100,7 +101,7 @@ class FilterFieldLayout @JvmOverloads constructor(
label.drawableStart = ContextCompat.getDrawable(context, R.drawable.ic_error_small) label.drawableStart = ContextCompat.getDrawable(context, R.drawable.ic_error_small)
TextViewCompat.setCompoundDrawableTintList( TextViewCompat.setCompoundDrawableTintList(
label, label,
context.getThemeColorStateList(materialR.attr.colorControlNormal), context.getThemeColorStateList(appcompatR.attr.colorControlNormal),
) )
addView(label) addView(label)
errorView = label errorView = label

View File

@@ -14,7 +14,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR import androidx.appcompat.R as appcompatR
class FilterHeaderProducer @Inject constructor( class FilterHeaderProducer @Inject constructor(
private val searchRepository: MangaSearchRepository, private val searchRepository: MangaSearchRepository,
@@ -129,7 +129,7 @@ class FilterHeaderProducer @Inject constructor(
result.addFirst( result.addFirst(
ChipsView.ChipModel( ChipsView.ChipModel(
title = snapshot.query, title = snapshot.query,
icon = materialR.drawable.abc_ic_search_api_material, icon = appcompatR.drawable.abc_ic_search_api_material,
isCloseable = true, isCloseable = true,
data = snapshot.query, data = snapshot.query,
), ),

View File

@@ -11,21 +11,20 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.swiperefreshlayout.widget.CircularProgressDrawable import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import coil3.Image
import coil3.ImageLoader import coil3.ImageLoader
import coil3.asDrawable
import coil3.request.CachePolicy import coil3.request.CachePolicy
import coil3.request.ErrorResult import coil3.request.ErrorResult
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.SuccessResult import coil3.request.SuccessResult
import coil3.request.lifecycle import coil3.request.lifecycle
import coil3.target.ViewTarget import coil3.target.GenericViewTarget
import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
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 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
import org.koitharu.kotatsu.core.image.CoilMemoryCacheKey
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
@@ -36,6 +35,7 @@ import org.koitharu.kotatsu.core.util.ext.end
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getDisplayIcon import org.koitharu.kotatsu.core.util.ext.getDisplayIcon
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
@@ -44,7 +44,7 @@ import org.koitharu.kotatsu.core.util.ext.start
import org.koitharu.kotatsu.databinding.ActivityImageBinding import org.koitharu.kotatsu.databinding.ActivityImageBinding
import org.koitharu.kotatsu.databinding.ItemErrorStateBinding import org.koitharu.kotatsu.databinding.ItemErrorStateBinding
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR import androidx.appcompat.R as appcompatR
@AndroidEntryPoint @AndroidEntryPoint
class ImageActivity : BaseActivity<ActivityImageBinding>(), class ImageActivity : BaseActivity<ActivityImageBinding>(),
@@ -63,7 +63,6 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(),
setContentView(ActivityImageBinding.inflate(layoutInflater)) setContentView(ActivityImageBinding.inflate(layoutInflater))
viewBinding.buttonBack.setOnClickListener(this) viewBinding.buttonBack.setOnClickListener(this)
viewBinding.buttonMenu.setOnClickListener(this) viewBinding.buttonMenu.setOnClickListener(this)
val imageUrl = requireNotNull(intent.data)
val menuProvider = ImageMenuProvider( val menuProvider = ImageMenuProvider(
activity = this, activity = this,
@@ -74,14 +73,14 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(),
viewModel.isLoading.observe(this, ::onLoadingStateChanged) viewModel.isLoading.observe(this, ::onLoadingStateChanged)
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.root, null)) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.root, null))
viewModel.onImageSaved.observeEvent(this, ::onImageSaved) viewModel.onImageSaved.observeEvent(this, ::onImageSaved)
loadImage(imageUrl) loadImage()
} }
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.button_back -> dispatchNavigateUp() R.id.button_back -> dispatchNavigateUp()
R.id.button_menu -> menuMediator.onLongClick(v) R.id.button_menu -> menuMediator.onLongClick(v)
else -> loadImage(intent.data) else -> loadImage()
} }
} }
@@ -122,10 +121,11 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(),
return insets.consumeAll(typeMask) return insets.consumeAll(typeMask)
} }
private fun loadImage(url: Uri?) { private fun loadImage() {
ImageRequest.Builder(this) ImageRequest.Builder(this)
.data(url) .data(intent.data)
.memoryCachePolicy(CachePolicy.DISABLED) .memoryCacheKey(intent.getParcelableExtraCompat<CoilMemoryCacheKey>(AppRouter.KEY_PREVIEW)?.data)
.memoryCachePolicy(CachePolicy.READ_ONLY)
.lifecycle(this) .lifecycle(this)
.listener(this) .listener(this)
.mangaSourceExtra(MangaSource(intent.getStringExtra(AppRouter.KEY_SOURCE))) .mangaSourceExtra(MangaSource(intent.getStringExtra(AppRouter.KEY_SOURCE)))
@@ -147,22 +147,24 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(),
button.setImageDrawable( button.setImageDrawable(
CircularProgressDrawable(this).also { CircularProgressDrawable(this).also {
it.setStyle(CircularProgressDrawable.LARGE) it.setStyle(CircularProgressDrawable.LARGE)
it.setColorSchemeColors(getThemeColor(materialR.attr.colorControlNormal)) it.setColorSchemeColors(getThemeColor(appcompatR.attr.colorControlNormal))
it.start() it.start()
}, },
) )
} else { } else {
button.setImageResource(materialR.drawable.abc_ic_menu_overflow_material) button.setImageResource(appcompatR.drawable.abc_ic_menu_overflow_material)
} }
} }
private class SsivTarget( private class SsivTarget(
override val view: SubsamplingScaleImageView, override val view: SubsamplingScaleImageView,
) : ViewTarget<SubsamplingScaleImageView> { ) : GenericViewTarget<SubsamplingScaleImageView>() {
override fun onError(error: Image?) = setDrawable(error?.asDrawable(view.resources)) override var drawable: Drawable? = null
set(value) {
override fun onSuccess(result: Image) = setDrawable(result.asDrawable(view.resources)) field = value
setImageDrawable(value)
}
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
return (this === other) || (other is SsivTarget && view == other.view) return (this === other) || (other is SsivTarget && view == other.view)
@@ -172,7 +174,7 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(),
override fun toString() = "SsivTarget(view=$view)" override fun toString() = "SsivTarget(view=$view)"
private fun setDrawable(drawable: Drawable?) { private fun setImageDrawable(drawable: Drawable?) {
if (drawable != null) { if (drawable != null) {
view.setImage(ImageSource.bitmap(drawable.toBitmap())) view.setImage(ImageSource.bitmap(drawable.toBitmap()))
} else { } else {

View File

@@ -140,7 +140,7 @@ class MangaListMapper @Inject constructor(
@ColorRes @ColorRes
private fun getTagTint(tag: MangaTag): Int { private fun getTagTint(tag: MangaTag): Int {
return if (tag.title.lowercase() in dict) { return if (settings.isTagsWarningsEnabled && tag.title.lowercase() in dict) {
R.color.warning R.color.warning
} else { } else {
0 0
@@ -148,7 +148,7 @@ class MangaListMapper @Inject constructor(
} }
private fun readTagsDict(context: Context): ScatterSet<String> = private fun readTagsDict(context: Context): ScatterSet<String> =
context.resources.openRawResource(R.raw.tags_redlist).use { context.resources.openRawResource(R.raw.tags_warnlist).use {
val set = MutableScatterSet<String>() val set = MutableScatterSet<String>()
it.bufferedReader().forEachLine { x -> it.bufferedReader().forEachLine { x ->
val line = x.trim() val line = x.trim()

View File

@@ -15,12 +15,13 @@ import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.core.util.ext.getItem import org.koitharu.kotatsu.core.util.ext.getItem
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.model.MangaListModel
import androidx.appcompat.R as appcompatR
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
protected val paint = Paint(Paint.ANTI_ALIAS_FLAG) protected val paint = Paint(Paint.ANTI_ALIAS_FLAG)
protected val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED) protected val strokeColor = context.getThemeColor(appcompatR.attr.colorPrimary, Color.RED)
protected val fillColor = ColorUtils.setAlphaComponent( protected val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74, 0x74,

View File

@@ -32,7 +32,7 @@ fun mangaListDetailedItemAD(
bind { payloads -> bind { payloads ->
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title
binding.textViewAuthor.textAndVisible = item.manga.author binding.textViewAuthor.textAndVisible = item.manga.authors.joinToString(", ")
binding.progressView.setProgress( binding.progressView.setProgress(
value = item.progress, value = item.progress,
animate = ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads, animate = ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads,

View File

@@ -2,7 +2,9 @@ package org.koitharu.kotatsu.list.ui.model
import android.content.Context import android.content.Context
import androidx.annotation.StringRes import androidx.annotation.StringRes
import org.koitharu.kotatsu.core.model.getLocalizedTitle
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.parsers.model.MangaChapter
data class ListHeader private constructor( data class ListHeader private constructor(
private val textRaw: Any, private val textRaw: Any,
@@ -25,6 +27,13 @@ data class ListHeader private constructor(
badge: String? = null, badge: String? = null,
) : this(textRaw = textRes, buttonTextRes, payload, badge) ) : this(textRaw = textRes, buttonTextRes, payload, badge)
constructor(
chapter: MangaChapter,
@StringRes buttonTextRes: Int = 0,
payload: Any? = null,
badge: String? = null,
) : this(textRaw = chapter, buttonTextRes, payload, badge)
constructor( constructor(
dateTimeAgo: DateTimeAgo, dateTimeAgo: DateTimeAgo,
@StringRes buttonTextRes: Int = 0, @StringRes buttonTextRes: Int = 0,
@@ -36,6 +45,7 @@ data class ListHeader private constructor(
is CharSequence -> textRaw is CharSequence -> textRaw
is Int -> if (textRaw != 0) context.getString(textRaw) else null is Int -> if (textRaw != 0) context.getString(textRaw) else null
is DateTimeAgo -> textRaw.format(context) is DateTimeAgo -> textRaw.format(context)
is MangaChapter -> textRaw.getLocalizedTitle(context.resources)
else -> null else -> null
} }

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.local.data package org.koitharu.kotatsu.local.data
import android.net.Uri
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -17,7 +16,6 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.AlphanumComparator import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.isWriteable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.takeIfWriteable import org.koitharu.kotatsu.core.util.ext.takeIfWriteable
import org.koitharu.kotatsu.core.util.ext.withChildren import org.koitharu.kotatsu.core.util.ext.withChildren
@@ -45,6 +43,7 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val MAX_PARALLELISM = 4 private const val MAX_PARALLELISM = 4
private const val FILENAME_SKIP = ".notamanga"
@Singleton @Singleton
class LocalMangaRepository @Inject constructor( class LocalMangaRepository @Inject constructor(
@@ -140,7 +139,7 @@ class LocalMangaRepository @Inject constructor(
} }
suspend fun delete(manga: Manga): Boolean { suspend fun delete(manga: Manga): Boolean {
val file = Uri.parse(manga.url).toFile() val file = manga.url.toUri().toFile()
val result = file.deleteAwait() val result = file.deleteAwait()
if (result) { if (result) {
localMangaIndex.delete(manga.id) localMangaIndex.delete(manga.id)
@@ -256,8 +255,10 @@ class LocalMangaRepository @Inject constructor(
private suspend fun getAllFiles() = storageManager.getReadableDirs() private suspend fun getAllFiles() = storageManager.getReadableDirs()
.asSequence() .asSequence()
.flatMap { dir -> .flatMap { dir ->
dir.withChildren { children -> children.filterNot { it.isHidden }.toList() } dir.withChildren { children -> children.filterNot { it.isHidden || it.shouldSkip() }.toList() }
} }
private fun Collection<LocalManga>.unwrap(): List<Manga> = map { it.manga } private fun Collection<LocalManga>.unwrap(): List<Manga> = map { it.manga }
private fun File.shouldSkip(): Boolean = isDirectory && File(this, FILENAME_SKIP).exists()
} }

View File

@@ -18,6 +18,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.Cache import okhttp3.Cache
import org.koitharu.kotatsu.core.exceptions.NonFileUriException
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.getStorageName import org.koitharu.kotatsu.core.util.ext.getStorageName
@@ -92,11 +93,11 @@ class LocalStorageManager @Inject constructor(
getAvailableStorageDirs() getAvailableStorageDirs()
} }
suspend fun resolveUri(uri: Uri): File? = runInterruptible(Dispatchers.IO) { suspend fun resolveUri(uri: Uri): File = runInterruptible(Dispatchers.IO) {
if (uri.isFileUri()) { if (uri.isFileUri()) {
uri.toFile() uri.toFile()
} else { } else {
uri.resolveFile(context) uri.resolveFile(context) ?: throw NonFileUriException(uri)
} }
} }

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.local.domain.model
import android.net.Uri import android.net.Uri
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import org.koitharu.kotatsu.core.util.ext.contains
import org.koitharu.kotatsu.core.util.ext.creationTime import org.koitharu.kotatsu.core.util.ext.creationTime
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
@@ -26,8 +27,8 @@ data class LocalManga(
fun isMatchesQuery(query: String): Boolean { fun isMatchesQuery(query: String): Boolean {
return manga.title.contains(query, ignoreCase = true) || return manga.title.contains(query, ignoreCase = true) ||
manga.altTitle?.contains(query, ignoreCase = true) == true || manga.altTitles.contains(query, ignoreCase = true) ||
manga.author?.contains(query, ignoreCase = true) == true manga.authors.contains(query, ignoreCase = true)
} }
fun containsTags(tags: Collection<String>): Boolean { fun containsTags(tags: Collection<String>): Boolean {

View File

@@ -21,7 +21,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.setProgressIcon import org.koitharu.kotatsu.core.util.ext.setProgressIcon
import org.koitharu.kotatsu.databinding.DialogLocalInfoBinding import org.koitharu.kotatsu.databinding.DialogLocalInfoBinding
import com.google.android.material.R as materialR import androidx.appcompat.R as appcompatR
@AndroidEntryPoint @AndroidEntryPoint
class LocalInfoDialog : AlertDialogFragment<DialogLocalInfoBinding>(), View.OnClickListener { class LocalInfoDialog : AlertDialogFragment<DialogLocalInfoBinding>(), View.OnClickListener {
@@ -86,7 +86,7 @@ class LocalInfoDialog : AlertDialogFragment<DialogLocalInfoBinding>(), View.OnCl
val total = size + available val total = size + available
val segment = SegmentedBarView.Segment( val segment = SegmentedBarView.Segment(
percent = (size.toDouble() / total.toDouble()).toFloat(), percent = (size.toDouble() / total.toDouble()).toFloat(),
color = KotatsuColors.segmentColor(view.context, materialR.attr.colorPrimary), color = KotatsuColors.segmentColor(view.context, appcompatR.attr.colorPrimary),
) )
requireViewBinding().labelUsed.text = view.context.getString( requireViewBinding().labelUsed.text = view.context.getString(
R.string.memory_usage_pattern, R.string.memory_usage_pattern,

View File

@@ -72,7 +72,7 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.settings.backup.PeriodicalBackupService import org.koitharu.kotatsu.settings.backup.PeriodicalBackupService
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR import androidx.appcompat.R as appcompatR
private const val TAG_SEARCH = "search" private const val TAG_SEARCH = "search"
@@ -231,6 +231,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
topMargin = barsInsets.top topMargin = barsInsets.top
bottomMargin = barsInsets.bottom bottomMargin = barsInsets.bottom
} }
updateContainerBottomMargin()
return insets.consume(v, typeMask, start = viewBinding.navRail != null) return insets.consume(v, typeMask, start = viewBinding.navRail != null)
} }
@@ -429,9 +430,9 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
supportActionBar?.apply { supportActionBar?.apply {
setHomeAsUpIndicator( setHomeAsUpIndicator(
if (isOpened) { if (isOpened) {
materialR.drawable.abc_ic_ab_back_material appcompatR.drawable.abc_ic_ab_back_material
} else { } else {
materialR.drawable.abc_ic_search_api_material appcompatR.drawable.abc_ic_search_api_material
}, },
) )
setHomeActionContentDescription( setHomeActionContentDescription(

View File

@@ -12,7 +12,6 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.navigation.NavigationBarView import com.google.android.material.navigation.NavigationBarView
import com.google.android.material.transition.MaterialFadeThrough import com.google.android.material.transition.MaterialFadeThrough
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -26,6 +25,7 @@ import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksFragment
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.NavItem import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView
import org.koitharu.kotatsu.core.util.ext.smoothScrollToTop import org.koitharu.kotatsu.core.util.ext.smoothScrollToTop
import org.koitharu.kotatsu.explore.ui.ExploreFragment import org.koitharu.kotatsu.explore.ui.ExploreFragment
import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment
@@ -232,7 +232,7 @@ class MainNavigationDelegate(
} }
private fun setNavbarIsLabeled(value: Boolean) { private fun setNavbarIsLabeled(value: Boolean) {
if (navBar is BottomNavigationView) { if (navBar is SlidingBottomNavigationView) {
navBar.minimumHeight = navBar.resources.getDimensionPixelSize( navBar.minimumHeight = navBar.resources.getDimensionPixelSize(
if (value) { if (value) {
materialR.dimen.m3_bottom_nav_min_height materialR.dimen.m3_bottom_nav_min_height

View File

@@ -8,20 +8,6 @@ fun Manga.filterChapters(branch: String?): Manga {
return withChapters(chapters = chapters?.filter { it.branch == branch }) return withChapters(chapters = chapters?.filter { it.branch == branch })
} }
private fun Manga.withChapters(chapters: List<MangaChapter>?) = Manga( private fun Manga.withChapters(chapters: List<MangaChapter>?) = copy(
id = id,
title = title,
altTitle = altTitle,
url = url,
publicUrl = publicUrl,
rating = rating,
isNsfw = isNsfw,
coverUrl = coverUrl,
tags = tags,
state = state,
author = author,
largeCoverUrl = largeCoverUrl,
description = description,
chapters = chapters, chapters = chapters,
source = source, )
)

View File

@@ -3,14 +3,22 @@ package org.koitharu.kotatsu.reader.domain
import android.content.Context import android.content.Context
import android.graphics.Rect import android.graphics.Rect
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.annotation.CheckResult
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import androidx.collection.set import androidx.collection.set
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import coil3.BitmapImage
import coil3.Image
import coil3.ImageLoader
import coil3.memory.MemoryCache
import coil3.request.ImageRequest
import coil3.request.transformations
import coil3.toBitmap
import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.ImageSource
import dagger.hilt.android.ActivityRetainedLifecycle import dagger.hilt.android.ActivityRetainedLifecycle
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ActivityRetainedScoped import dagger.hilt.android.scopes.ActivityRetainedScoped
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
@@ -36,9 +44,9 @@ import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.parser.CachingMangaRepository import org.koitharu.kotatsu.core.parser.CachingMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP
import org.koitharu.kotatsu.core.util.ext.cancelChildrenAndJoin import org.koitharu.kotatsu.core.util.ext.cancelChildrenAndJoin
import org.koitharu.kotatsu.core.util.ext.compressToPNG import org.koitharu.kotatsu.core.util.ext.compressToPNG
@@ -49,6 +57,8 @@ import org.koitharu.kotatsu.core.util.ext.isFileUri
import org.koitharu.kotatsu.core.util.ext.isNotEmpty import org.koitharu.kotatsu.core.util.ext.isNotEmpty
import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode
import org.koitharu.kotatsu.core.util.ext.isZipUri import org.koitharu.kotatsu.core.util.ext.isZipUri
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.ramAvailable import org.koitharu.kotatsu.core.util.ext.ramAvailable
import org.koitharu.kotatsu.core.util.ext.toMimeType import org.koitharu.kotatsu.core.util.ext.toMimeType
@@ -76,13 +86,14 @@ class PageLoader @Inject constructor(
lifecycle: ActivityRetainedLifecycle, lifecycle: ActivityRetainedLifecycle,
@MangaHttpClient private val okHttp: OkHttpClient, @MangaHttpClient private val okHttp: OkHttpClient,
private val cache: PagesCache, private val cache: PagesCache,
private val coil: ImageLoader,
private val settings: AppSettings, private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val imageProxyInterceptor: ImageProxyInterceptor, private val imageProxyInterceptor: ImageProxyInterceptor,
private val downloadSlowdownDispatcher: DownloadSlowdownDispatcher, private val downloadSlowdownDispatcher: DownloadSlowdownDispatcher,
) { ) {
val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default val loaderScope = lifecycle.lifecycleScope + InternalErrorHandler() + Dispatchers.Default
private val tasks = LongSparseArray<ProgressDeferred<Uri, Float>>() private val tasks = LongSparseArray<ProgressDeferred<Uri, Float>>()
private val semaphore = Semaphore(3) private val semaphore = Semaphore(3)
@@ -121,6 +132,41 @@ class PageLoader @Inject constructor(
} }
} }
suspend fun loadPreview(page: MangaPage): ImageSource? {
val preview = page.preview
if (preview.isNullOrEmpty()) {
return null
}
val request = ImageRequest.Builder(context)
.data(preview)
.mangaSourceExtra(page.source)
.transformations(TrimTransformation())
.build()
return coil.execute(request).image?.toImageSource()
}
fun peekPreviewSource(preview: String?): ImageSource? {
if (preview.isNullOrEmpty()) {
return null
}
coil.memoryCache?.let { cache ->
val key = MemoryCache.Key(preview)
cache[key]?.image?.let {
return if (it is BitmapImage) {
ImageSource.cachedBitmap(it.toBitmap())
} else {
ImageSource.bitmap(it.toBitmap())
}
}
}
coil.diskCache?.let { cache ->
cache.openSnapshot(preview)?.use { snapshot ->
return ImageSource.file(snapshot.data.toFile())
}
}
return null
}
fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred<Uri, Float> { fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred<Uri, Float> {
var task = tasks[page.id]?.takeIf { it.isValid() } var task = tasks[page.id]?.takeIf { it.isValid() }
if (force) { if (force) {
@@ -139,9 +185,10 @@ class PageLoader @Inject constructor(
return loadPageAsync(page, force).await() return loadPageAsync(page, force).await()
} }
@CheckResult
suspend fun convertBimap(uri: Uri): Uri = convertLock.withLock { suspend fun convertBimap(uri: Uri): Uri = convertLock.withLock {
if (uri.isZipUri()) { if (uri.isZipUri()) {
val bitmap = runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
ZipFile(uri.schemeSpecificPart).use { zip -> ZipFile(uri.schemeSpecificPart).use { zip ->
val entry = zip.getEntry(uri.fragment) val entry = zip.getEntry(uri.fragment)
context.ensureRamAtLeast(entry.size * 2) context.ensureRamAtLeast(entry.size * 2)
@@ -149,8 +196,9 @@ class PageLoader @Inject constructor(
BitmapDecoderCompat.decode(it, MimeTypes.getMimeTypeFromExtension(entry.name)) BitmapDecoderCompat.decode(it, MimeTypes.getMimeTypeFromExtension(entry.name))
} }
} }
}.use { image ->
cache.put(uri.toString(), image).toUri()
} }
cache.put(uri.toString(), bitmap).toUri()
} else { } else {
val file = uri.toFile() val file = uri.toFile()
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
@@ -237,7 +285,7 @@ class PageLoader @Inject constructor(
if (!skipCache) { if (!skipCache) {
cache.get(pageUrl)?.let { return it.toUri() } cache.get(pageUrl)?.let { return it.toUri() }
} }
val uri = Uri.parse(pageUrl) val uri = pageUrl.toUri()
return when { return when {
uri.isZipUri() -> if (uri.scheme == URI_SCHEME_ZIP) { uri.isZipUri() -> if (uri.scheme == URI_SCHEME_ZIP) {
uri uri
@@ -264,6 +312,12 @@ class PageLoader @Inject constructor(
return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES) return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES)
} }
private fun Image.toImageSource(): ImageSource = if (this is BitmapImage) {
ImageSource.cachedBitmap(toBitmap())
} else {
ImageSource.bitmap(toBitmap())
}
private fun Deferred<Uri>.isValid(): Boolean { private fun Deferred<Uri>.isValid(): Boolean {
return getCompletionResultOrNull()?.map { uri -> return getCompletionResultOrNull()?.map { uri ->
uri.exists() && uri.isTargetNotEmpty() uri.exists() && uri.isTargetNotEmpty()

View File

@@ -127,8 +127,10 @@ class ReaderActionsView @JvmOverloads constructor(
} }
override fun onStartTrackingTouch(slider: Slider) { override fun onStartTrackingTouch(slider: Slider) {
isSliderChanged = false if (!isSliderTracking) {
isSliderTracking = true isSliderChanged = false
isSliderTracking = true
}
} }
override fun onStopTrackingTouch(slider: Slider) { override fun onStopTrackingTouch(slider: Slider) {

View File

@@ -105,7 +105,7 @@ class ReaderActivity :
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityReaderBinding.inflate(layoutInflater)) setContentView(ActivityReaderBinding.inflate(layoutInflater))
readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings) readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings)
setDisplayHomeAsUp(true, false) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
touchHelper = TapGridDispatcher(this, this) touchHelper = TapGridDispatcher(this, this)
scrollTimer = scrollTimerFactory.create(this, this) scrollTimer = scrollTimerFactory.create(this, this)
pageSaveHelper = pageSaveHelperFactory.create(this) pageSaveHelper = pageSaveHelperFactory.create(this)
@@ -146,7 +146,7 @@ class ReaderActivity :
.setAnchorView(viewBinding.toolbarDocked) .setAnchorView(viewBinding.toolbarDocked)
.show() .show()
} }
viewModel.readerSettings.observe(this) { viewModel.readerSettingsProducer.observe(this) {
viewBinding.infoBar.applyColorScheme(isBlackOnWhite = it.background.isLight(this)) viewBinding.infoBar.applyColorScheme(isBlackOnWhite = it.background.isLight(this))
} }
viewModel.isZoomControlsEnabled.observe(this) { viewModel.isZoomControlsEnabled.observe(this) {

View File

@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
@@ -50,6 +51,7 @@ import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase 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.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
@@ -84,6 +86,7 @@ class ReaderViewModel @Inject constructor(
interactor: DetailsInteractor, interactor: DetailsInteractor,
deleteLocalMangaUseCase: DeleteLocalMangaUseCase, deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
downloadScheduler: DownloadWorker.Scheduler, downloadScheduler: DownloadWorker.Scheduler,
readerSettingsProducerFactory: ReaderSettings.Producer.Factory,
) : ChaptersPagesViewModel( ) : ChaptersPagesViewModel(
settings = settings, settings = settings,
interactor = interactor, interactor = interactor,
@@ -169,15 +172,11 @@ class ReaderViewModel @Inject constructor(
} }
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false)
val readerSettings = ReaderSettings( val readerSettingsProducer = readerSettingsProducerFactory.create(
parentScope = viewModelScope, manga.mapNotNull { it?.id },
settings = settings,
colorFilterFlow = manga.flatMapLatest {
if (it == null) flowOf(null) else dataRepository.observeColorFilter(it.id)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null),
) )
val isMangaNsfw = manga.map { it?.isNsfw == true } val isMangaNsfw = manga.map { it?.contentRating == ContentRating.ADULT }
val isBookmarkAdded = readingState.flatMapLatest { state -> val isBookmarkAdded = readingState.flatMapLatest { state ->
val manga = mangaDetails.value?.toManga() val manga = mangaDetails.value?.toManga()

View File

@@ -1,66 +1,69 @@
package org.koitharu.kotatsu.reader.ui.config package org.koitharu.kotatsu.reader.ui.config
import android.content.SharedPreferences
import android.graphics.Bitmap import android.graphics.Bitmap
import android.view.View import android.view.View
import androidx.annotation.CheckResult import androidx.annotation.CheckResult
import androidx.lifecycle.MediatorLiveData import androidx.collection.scatterSetOf
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.davemorrissey.labs.subscaleview.decoder.SkiaImageDecoder import com.davemorrissey.labs.subscaleview.decoder.SkiaImageDecoder
import com.davemorrissey.labs.subscaleview.decoder.SkiaImageRegionDecoder import com.davemorrissey.labs.subscaleview.decoder.SkiaImageRegionDecoder
import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder
import kotlinx.coroutines.CoroutineScope import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderBackground import org.koitharu.kotatsu.core.prefs.ReaderBackground
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.util.MediatorStateFlow
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
class ReaderSettings( data class ReaderSettings(
private val parentScope: CoroutineScope, val zoomMode: ZoomMode,
private val settings: AppSettings, val background: ReaderBackground,
private val colorFilterFlow: StateFlow<ReaderColorFilter?>, val colorFilter: ReaderColorFilter?,
) : MediatorLiveData<ReaderSettings>() { val isReaderOptimizationEnabled: Boolean,
val bitmapConfig: Bitmap.Config,
val isPagesNumbersEnabled: Boolean,
val isPagesCropEnabledStandard: Boolean,
val isPagesCropEnabledWebtoon: Boolean,
) {
private val internalObserver = InternalObserver() private constructor(settings: AppSettings, colorFilterOverride: ReaderColorFilter?) : this(
private var collectJob: Job? = null zoomMode = settings.zoomMode,
background = settings.readerBackground,
val zoomMode: ZoomMode colorFilter = colorFilterOverride?.takeUnless { it.isEmpty } ?: settings.readerColorFilter,
get() = settings.zoomMode isReaderOptimizationEnabled = settings.isReaderOptimizationEnabled,
bitmapConfig = if (settings.is32BitColorsEnabled) {
val background: ReaderBackground
get() = settings.readerBackground
val colorFilter: ReaderColorFilter?
get() = colorFilterFlow.value?.takeUnless { it.isEmpty } ?: settings.readerColorFilter
val isReaderOptimizationEnabled: Boolean
get() = settings.isReaderOptimizationEnabled
val bitmapConfig: Bitmap.Config
get() = if (settings.is32BitColorsEnabled) {
Bitmap.Config.ARGB_8888 Bitmap.Config.ARGB_8888
} else { } else {
Bitmap.Config.RGB_565 Bitmap.Config.RGB_565
} },
isPagesNumbersEnabled = settings.isPagesNumbersEnabled,
val isPagesNumbersEnabled: Boolean isPagesCropEnabledStandard = settings.isPagesCropEnabled(ReaderMode.STANDARD),
get() = settings.isPagesNumbersEnabled isPagesCropEnabledWebtoon = settings.isPagesCropEnabled(ReaderMode.WEBTOON),
)
fun applyBackground(view: View) { fun applyBackground(view: View) {
view.background = background.resolve(view.context) view.background = background.resolve(view.context)
} }
fun isPagesCropEnabled(isWebtoon: Boolean) = settings.isPagesCropEnabled( fun isPagesCropEnabled(isWebtoon: Boolean) = if (isWebtoon) {
if (isWebtoon) ReaderMode.WEBTOON else ReaderMode.STANDARD, isPagesCropEnabledWebtoon
) } else {
isPagesCropEnabledStandard
}
@CheckResult @CheckResult
fun applyBitmapConfig(ssiv: SubsamplingScaleImageView): Boolean { fun applyBitmapConfig(ssiv: SubsamplingScaleImageView): Boolean {
@@ -78,33 +81,13 @@ class ReaderSettings(
} }
} }
override fun onInactive() { class Producer @AssistedInject constructor(
super.onInactive() @Assisted private val mangaId: Flow<Long>,
settings.unsubscribe(internalObserver) private val settings: AppSettings,
collectJob?.cancel() private val mangaDataRepository: MangaDataRepository,
collectJob = null ) : MediatorStateFlow<ReaderSettings>(ReaderSettings(settings, null)) {
}
override fun onActive() { private val settingsKeys = scatterSetOf(
super.onActive()
settings.subscribe(internalObserver)
collectJob?.cancel()
collectJob = parentScope.launch {
colorFilterFlow.collect(internalObserver)
}
}
override fun getValue() = this
private fun notifyChanged() {
value = value
}
private inner class InternalObserver :
FlowCollector<ReaderColorFilter?>,
SharedPreferences.OnSharedPreferenceChangeListener {
private val settingsKeys = setOf(
AppSettings.KEY_ZOOM_MODE, AppSettings.KEY_ZOOM_MODE,
AppSettings.KEY_PAGES_NUMBERS, AppSettings.KEY_PAGES_NUMBERS,
AppSettings.KEY_READER_BACKGROUND, AppSettings.KEY_READER_BACKGROUND,
@@ -114,18 +97,38 @@ class ReaderSettings(
AppSettings.KEY_CF_BRIGHTNESS, AppSettings.KEY_CF_BRIGHTNESS,
AppSettings.KEY_CF_INVERTED, AppSettings.KEY_CF_INVERTED,
AppSettings.KEY_CF_GRAYSCALE, AppSettings.KEY_CF_GRAYSCALE,
AppSettings.KEY_READER_CROP,
) )
private var job: Job? = null
override suspend fun emit(value: ReaderColorFilter?) { override fun onActive() {
withContext(Dispatchers.Main.immediate) { assert(job?.isActive != true)
notifyChanged() job?.cancel()
job = processLifecycleScope.launch(Dispatchers.Default) {
observeImpl()
} }
} }
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { override fun onInactive() {
if (key in settingsKeys) { job?.cancel()
notifyChanged() job = null
}
private suspend fun observeImpl() {
combine(
mangaId.flatMapLatest { mangaDataRepository.observeColorFilter(it) },
settings.observe().filter { x -> x == null || x in settingsKeys }.onStart { emit(null) },
) { mangaCf, settingsKey ->
ReaderSettings(settings, mangaCf)
}.collect {
publishValue(it)
} }
} }
@AssistedFactory
interface Factory {
fun create(mangaId: Flow<Long>): Producer
}
} }
} }

View File

@@ -1,40 +1,58 @@
package org.koitharu.kotatsu.reader.ui.pager package org.koitharu.kotatsu.reader.ui.pager
import android.content.ComponentCallbacks2
import android.content.ComponentCallbacks2.TRIM_MEMORY_COMPLETE
import android.content.Context import android.content.Context
import android.content.res.Configuration
import android.view.View
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
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.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.ui.list.lifecycle.LifecycleAwareViewHolder import org.koitharu.kotatsu.core.ui.list.lifecycle.LifecycleAwareViewHolder
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding
import org.koitharu.kotatsu.parsers.util.ifZero
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.PageHolderDelegate.State import org.koitharu.kotatsu.reader.ui.pager.vm.PageState
import org.koitharu.kotatsu.reader.ui.pager.vm.PageViewModel
import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonHolder import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonHolder
abstract class BasePageHolder<B : ViewBinding>( abstract class BasePageHolder<B : ViewBinding>(
protected val binding: B, protected val binding: B,
loader: PageLoader, loader: PageLoader,
protected val settings: ReaderSettings, readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
) : LifecycleAwareViewHolder(binding.root, lifecycleOwner), PageHolderDelegate.Callback { ) : LifecycleAwareViewHolder(binding.root, lifecycleOwner), DefaultOnImageEventListener, ComponentCallbacks2 {
@Suppress("LeakingThis") protected val viewModel = PageViewModel(
protected val delegate = PageHolderDelegate(
loader = loader, loader = loader,
readerSettings = settings, settingsProducer = readerSettingsProducer,
callback = this,
networkState = networkState, networkState = networkState,
exceptionResolver = exceptionResolver, exceptionResolver = exceptionResolver,
isWebtoon = this is WebtoonHolder, isWebtoon = this is WebtoonHolder,
) )
protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root) protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root)
protected abstract val ssiv: SubsamplingScaleImageView
protected val settings: ReaderSettings
get() = viewModel.settingsProducer.value
val context: Context val context: Context
get() = itemView.context get() = itemView.context
@@ -42,51 +60,139 @@ abstract class BasePageHolder<B : ViewBinding>(
var boundData: ReaderPage? = null var boundData: ReaderPage? = null
private set private set
override fun onConfigChanged() { init {
settings.applyBackground(itemView) lifecycleScope.launch(Dispatchers.Main) {
ssiv.bindToLifecycle(this@BasePageHolder)
ssiv.isEagerLoadingEnabled = !context.isLowRamDevice()
ssiv.addOnImageEventListener(viewModel)
ssiv.addOnImageEventListener(this@BasePageHolder)
}
val clickListener = View.OnClickListener { v ->
when (v.id) {
R.id.button_retry -> viewModel.retry(
page = boundData?.toMangaPage() ?: return@OnClickListener,
isFromUser = true,
)
R.id.button_error_details -> viewModel.showErrorDetails(boundData?.url)
}
}
bindingInfo.buttonRetry.setOnClickListener(clickListener)
bindingInfo.buttonErrorDetails.setOnClickListener(clickListener)
} }
fun requireData(): ReaderPage { @CallSuper
return checkNotNull(boundData) { "Calling requireData() before bind()" } protected open fun onConfigChanged(settings: ReaderSettings) {
settings.applyBackground(itemView)
if (settings.applyBitmapConfig(ssiv)) {
reloadImage()
} else if (viewModel.state.value is PageState.Shown) {
onReady()
}
ssiv.applyDownSampling(isResumed())
}
fun reloadImage() {
val source = (viewModel.state.value as? PageState.Shown)?.source ?: return
ssiv.setImage(source)
} }
fun bind(data: ReaderPage) { fun bind(data: ReaderPage) {
boundData = data boundData = data
viewModel.onBind(data.toMangaPage())
onBind(data) onBind(data)
} }
protected abstract fun onBind(data: ReaderPage) @CallSuper
protected open fun onBind(data: ReaderPage) = Unit
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
context.registerComponentCallbacks(delegate) context.registerComponentCallbacks(this)
viewModel.state.observe(this, ::onStateChanged)
viewModel.settingsProducer.observe(this, ::onConfigChanged)
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (delegate.state == State.ERROR && !delegate.isLoading()) { ssiv.applyDownSampling(isForeground = true)
boundData?.let { delegate.retry(it.toMangaPage(), isFromUser = false) } if (viewModel.state.value is PageState.Error && !viewModel.isLoading()) {
boundData?.let { viewModel.retry(it.toMangaPage(), isFromUser = false) }
} }
} }
override fun onPause() {
super.onPause()
ssiv.applyDownSampling(isForeground = false)
}
override fun onDestroy() { override fun onDestroy() {
context.unregisterComponentCallbacks(delegate) context.unregisterComponentCallbacks(this)
super.onDestroy() super.onDestroy()
} }
@CallSuper open fun onAttachedToWindow() = Unit
open fun onAttachedToWindow() {
delegate.onAttachedToWindow()
}
@CallSuper open fun onDetachedFromWindow() = Unit
open fun onDetachedFromWindow() {
delegate.onDetachedFromWindow()
}
@CallSuper @CallSuper
open fun onRecycled() { open fun onRecycled() {
delegate.onRecycle() viewModel.onRecycle()
ssiv.recycle()
}
override fun onTrimMemory(level: Int) {
// TODO
}
override fun onConfigurationChanged(newConfig: Configuration) = Unit
@Deprecated("Deprecated in Java")
final override fun onLowMemory() = onTrimMemory(TRIM_MEMORY_COMPLETE)
protected open fun onStateChanged(state: PageState) {
bindingInfo.layoutError.isVisible = state is PageState.Error
bindingInfo.layoutProgress.isGone = state.isFinalState()
val progress = (state as? PageState.Loading)?.progress ?: -1
if (progress in 0..100) {
bindingInfo.progressBar.isIndeterminate = false
bindingInfo.progressBar.setProgressCompat(progress, true)
bindingInfo.textViewStatus.text = context.getString(R.string.percent_string_pattern, progress.toString())
} else {
bindingInfo.progressBar.isIndeterminate = true
bindingInfo.textViewStatus.setText(R.string.loading_)
}
when (state) {
is PageState.Converting -> {
bindingInfo.textViewStatus.setText(R.string.processing_)
}
is PageState.Empty -> Unit
is PageState.Error -> {
val e = state.error
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
bindingInfo.buttonRetry.setText(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
)
bindingInfo.buttonErrorDetails.isVisible = e.isSerializable()
bindingInfo.layoutError.isVisible = true
bindingInfo.progressBar.hide()
}
is PageState.Loaded -> {
bindingInfo.textViewStatus.setText(R.string.preparing_)
ssiv.setImage(state.source)
}
is PageState.Loading -> {
if (state.preview != null && ssiv.getState() == null) {
ssiv.setImage(state.preview)
}
}
is PageState.Shown -> Unit
}
} }
protected fun SubsamplingScaleImageView.applyDownSampling(isForeground: Boolean) { protected fun SubsamplingScaleImageView.applyDownSampling(isForeground: Boolean) {

View File

@@ -142,7 +142,7 @@ abstract class BasePagerReaderFragment : BaseReaderFragment<FragmentReaderPagerB
override fun onCreateAdapter(): BaseReaderAdapter<*> = PagesAdapter( override fun onCreateAdapter(): BaseReaderAdapter<*> = PagesAdapter(
lifecycleOwner = viewLifecycleOwner, lifecycleOwner = viewLifecycleOwner,
loader = pageLoader, loader = pageLoader,
settings = viewModel.readerSettings, readerSettingsProducer = viewModel.readerSettingsProducer,
networkState = networkState, networkState = networkState,
exceptionResolver = exceptionResolver, exceptionResolver = exceptionResolver,
) )

View File

@@ -15,7 +15,7 @@ import kotlin.coroutines.suspendCoroutine
@Suppress("LeakingThis") @Suppress("LeakingThis")
abstract class BaseReaderAdapter<H : BasePageHolder<*>>( abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
private val loader: PageLoader, private val loader: PageLoader,
private val readerSettings: ReaderSettings, private val readerSettingsProducer: ReaderSettings.Producer,
private val networkState: NetworkState, private val networkState: NetworkState,
private val exceptionResolver: ExceptionResolver, private val exceptionResolver: ExceptionResolver,
) : RecyclerView.Adapter<H>() { ) : RecyclerView.Adapter<H>() {
@@ -58,7 +58,7 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
final override fun onCreateViewHolder( final override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
viewType: Int, viewType: Int,
): H = onCreateViewHolder(parent, loader, readerSettings, networkState, exceptionResolver) ): H = onCreateViewHolder(parent, loader, readerSettingsProducer, networkState, exceptionResolver)
suspend fun setItems(items: List<ReaderPage>) = suspendCoroutine { cont -> suspend fun setItems(items: List<ReaderPage>) = suspendCoroutine { cont ->
differ.submitList(items) { differ.submitList(items) {
@@ -69,7 +69,7 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
protected abstract fun onCreateViewHolder( protected abstract fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
): H ): H

View File

@@ -1,247 +0,0 @@
package org.koitharu.kotatsu.reader.ui.pager
import android.content.ComponentCallbacks2
import android.content.res.Configuration
import android.graphics.Rect
import android.net.Uri
import androidx.lifecycle.Observer
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
import com.davemorrissey.labs.subscaleview.ImageSource
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import java.io.IOException
class PageHolderDelegate(
private val loader: PageLoader,
private val readerSettings: ReaderSettings,
private val callback: Callback,
private val networkState: NetworkState,
private val exceptionResolver: ExceptionResolver,
private val isWebtoon: Boolean,
) : DefaultOnImageEventListener, Observer<ReaderSettings>, ComponentCallbacks2 {
private val scope = loader.loaderScope + Dispatchers.Main.immediate
var state = State.EMPTY
private set
private var job: Job? = null
private var uri: Uri? = null
private var cachedBounds: Rect? = null
private var error: Throwable? = null
init {
scope.launch(Dispatchers.Main) { // the same as post() -- wait until child fields init
callback.onConfigChanged()
}
}
fun isLoading() = job?.isActive == true
fun onBind(page: MangaPage) {
val prevJob = job
job = scope.launch {
prevJob?.cancelAndJoin()
doLoad(page, force = false)
}
}
fun retry(page: MangaPage, isFromUser: Boolean) {
val prevJob = job
job = scope.launch {
prevJob?.cancelAndJoin()
val e = error
if (e != null && ExceptionResolver.canResolve(e)) {
if (!isFromUser) {
return@launch
}
exceptionResolver.resolve(e)
}
doLoad(page, force = true)
}
}
fun showErrorDetails(url: String?) {
val e = error ?: return
exceptionResolver.showErrorDetails(e, url)
}
fun onAttachedToWindow() {
readerSettings.observeForever(this)
}
fun onDetachedFromWindow() {
readerSettings.removeObserver(this)
}
fun onRecycle() {
state = State.EMPTY
uri = null
cachedBounds = null
error = null
job?.cancel()
}
fun reload() {
if (state == State.SHOWN) {
uri?.let {
callback.onImageReady(it.toImageSource(cachedBounds))
}
}
}
override fun onReady() {
state = State.SHOWING
error = null
callback.onImageShowing(readerSettings)
}
override fun onImageLoaded() {
state = State.SHOWN
error = null
callback.onImageShown()
}
override fun onImageLoadError(e: Throwable) {
e.printStackTraceDebug()
val uri = this.uri
error = e
if (state == State.LOADED && e is IOException && uri != null && uri.toFileOrNull()?.exists() != false) {
tryConvert(uri, e)
} else {
state = State.ERROR
callback.onError(e)
}
}
override fun onChanged(value: ReaderSettings) {
if (state == State.SHOWN) {
callback.onImageShowing(readerSettings)
}
callback.onConfigChanged()
}
override fun onConfigurationChanged(newConfig: Configuration) = Unit
@Suppress("OVERRIDE_DEPRECATION")
override fun onLowMemory() = Unit
override fun onTrimMemory(level: Int) {
callback.onTrimMemory()
}
private fun tryConvert(uri: Uri, e: Exception) {
val prevJob = job
job = scope.launch {
prevJob?.join()
state = State.CONVERTING
try {
val newUri = loader.convertBimap(uri)
cachedBounds = if (readerSettings.isPagesCropEnabled(isWebtoon)) {
loader.getTrimmedBounds(newUri)
} else {
null
}
state = State.CONVERTED
callback.onImageReady(newUri.toImageSource(cachedBounds))
} catch (ce: CancellationException) {
throw ce
} catch (e2: Throwable) {
e2.printStackTrace()
e.addSuppressed(e2)
state = State.ERROR
callback.onError(e)
}
}
}
private suspend fun doLoad(data: MangaPage, force: Boolean) {
state = State.LOADING
error = null
callback.onLoadingStarted()
yield()
try {
val task = withContext(Dispatchers.Default) {
loader.loadPageAsync(data, force)
}
uri = coroutineScope {
val progressObserver = observeProgress(this, task.progressAsFlow())
val file = task.await()
progressObserver.cancelAndJoin()
file
}
state = State.LOADED
cachedBounds = if (readerSettings.isPagesCropEnabled(isWebtoon)) {
loader.getTrimmedBounds(checkNotNull(uri))
} else {
null
}
callback.onImageReady(checkNotNull(uri).toImageSource(cachedBounds))
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
e.printStackTraceDebug()
state = State.ERROR
error = e
callback.onError(e)
if (e is IOException && !networkState.value) {
networkState.awaitForConnection()
retry(data, isFromUser = false)
}
}
}
private fun observeProgress(scope: CoroutineScope, progress: Flow<Float>) = progress
.debounce(250)
.onEach { callback.onProgressChanged((100 * it).toInt()) }
.launchIn(scope)
private fun Uri.toImageSource(bounds: Rect?): ImageSource {
val source = ImageSource.uri(this)
return if (bounds != null) {
source.region(bounds)
} else {
source
}
}
enum class State {
EMPTY, LOADING, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR
}
interface Callback {
fun onLoadingStarted()
fun onError(e: Throwable)
fun onImageReady(source: ImageSource)
fun onImageShowing(settings: ReaderSettings)
fun onImageShown()
fun onProgressChanged(progress: Int)
fun onConfigChanged()
fun onTrimMemory()
}
}

View File

@@ -17,10 +17,17 @@ class DoublePageHolder(
owner: LifecycleOwner, owner: LifecycleOwner,
binding: ItemPageBinding, binding: ItemPageBinding,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : PageHolder(owner, binding, loader, settings, networkState, exceptionResolver) { ) : PageHolder(
owner = owner,
binding = binding,
loader = loader,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
) {
private val isEven: Boolean private val isEven: Boolean
get() = bindingAdapterPosition and 1 == 0 get() = bindingAdapterPosition and 1 == 0
@@ -35,7 +42,7 @@ class DoublePageHolder(
.gravity = (if (isEven) Gravity.START else Gravity.END) or Gravity.BOTTOM .gravity = (if (isEven) Gravity.START else Gravity.END) or Gravity.BOTTOM
} }
override fun onImageShowing(settings: ReaderSettings) { override fun onReady() {
with(binding.ssiv) { with(binding.ssiv) {
maxScale = 2f * maxOf( maxScale = 2f * maxOf(
width / sWidth.toFloat(), width / sWidth.toFloat(),

View File

@@ -13,22 +13,22 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class DoublePagesAdapter( class DoublePagesAdapter(
private val lifecycleOwner: LifecycleOwner, private val lifecycleOwner: LifecycleOwner,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<DoublePageHolder>(loader, settings, networkState, exceptionResolver) { ) : BaseReaderAdapter<DoublePageHolder>(loader, readerSettingsProducer, networkState, exceptionResolver) {
override fun onCreateViewHolder( override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) = DoublePageHolder( ) = DoublePageHolder(
owner = lifecycleOwner, owner = lifecycleOwner,
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false), binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader, loader = loader,
settings = settings, readerSettingsProducer = readerSettingsProducer,
networkState = networkState, networkState = networkState,
exceptionResolver = exceptionResolver, exceptionResolver = exceptionResolver,
) )

View File

@@ -90,7 +90,7 @@ open class DoubleReaderFragment : BaseReaderFragment<FragmentReaderDoubleBinding
override fun onCreateAdapter() = DoublePagesAdapter( override fun onCreateAdapter() = DoublePagesAdapter(
lifecycleOwner = viewLifecycleOwner, lifecycleOwner = viewLifecycleOwner,
loader = pageLoader, loader = pageLoader,
settings = viewModel.readerSettings, readerSettingsProducer = viewModel.readerSettingsProducer,
networkState = networkState, networkState = networkState,
exceptionResolver = exceptionResolver, exceptionResolver = exceptionResolver,
) )

View File

@@ -1,13 +1,21 @@
package org.koitharu.kotatsu.reader.ui.pager.reversed package org.koitharu.kotatsu.reader.ui.pager.reversed
import android.graphics.PointF import android.graphics.PointF
import android.os.Build
import android.view.Gravity import android.view.Gravity
import android.view.RoundedCorner
import android.view.ViewGroup
import android.view.WindowInsets
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.annotation.RequiresApi
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
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.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.ext.isRtl
import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
@@ -17,17 +25,24 @@ class ReversedPageHolder(
owner: LifecycleOwner, owner: LifecycleOwner,
binding: ItemPageBinding, binding: ItemPageBinding,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : PageHolder(owner, binding, loader, settings, networkState, exceptionResolver) { ) : PageHolder(
owner = owner,
binding = binding,
loader = loader,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
) {
init { init {
(binding.textViewNumber.layoutParams as FrameLayout.LayoutParams) (binding.textViewNumber.layoutParams as FrameLayout.LayoutParams)
.gravity = Gravity.START or Gravity.BOTTOM .gravity = Gravity.START or Gravity.BOTTOM
} }
override fun onImageShowing(settings: ReaderSettings) { override fun onReady() {
with(binding.ssiv) { with(binding.ssiv) {
maxScale = 2f * maxOf( maxScale = 2f * maxOf(
width / sWidth.toFloat(), width / sWidth.toFloat(),

View File

@@ -13,22 +13,22 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class ReversedPagesAdapter( class ReversedPagesAdapter(
private val lifecycleOwner: LifecycleOwner, private val lifecycleOwner: LifecycleOwner,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<ReversedPageHolder>(loader, settings, networkState, exceptionResolver) { ) : BaseReaderAdapter<ReversedPageHolder>(loader, readerSettingsProducer, networkState, exceptionResolver) {
override fun onCreateViewHolder( override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) = ReversedPageHolder( ) = ReversedPageHolder(
owner = lifecycleOwner, owner = lifecycleOwner,
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false), binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader, loader = loader,
settings = settings, readerSettingsProducer = readerSettingsProducer,
networkState = networkState, networkState = networkState,
exceptionResolver = exceptionResolver, exceptionResolver = exceptionResolver,
) )

View File

@@ -19,7 +19,7 @@ class ReversedReaderFragment : BasePagerReaderFragment() {
override fun onCreateAdapter() = ReversedPagesAdapter( override fun onCreateAdapter() = ReversedPagesAdapter(
lifecycleOwner = viewLifecycleOwner, lifecycleOwner = viewLifecycleOwner,
loader = pageLoader, loader = pageLoader,
settings = viewModel.readerSettings, readerSettingsProducer = viewModel.readerSettingsProducer,
networkState = networkState, networkState = networkState,
exceptionResolver = exceptionResolver, exceptionResolver = exceptionResolver,
) )

View File

@@ -2,22 +2,28 @@ package org.koitharu.kotatsu.reader.ui.pager.standard
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.graphics.PointF import android.graphics.PointF
import android.os.Build
import android.view.Gravity
import android.view.RoundedCorner
import android.view.View import android.view.View
import android.view.WindowInsets
import android.view.animation.DecelerateInterpolator import android.view.animation.DecelerateInterpolator
import android.widget.FrameLayout
import androidx.annotation.RequiresApi
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.setMargins
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
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.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.ui.widgets.ZoomControl import org.koitharu.kotatsu.core.ui.widgets.ZoomControl
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.parsers.util.ifZero
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
@@ -27,73 +33,48 @@ open class PageHolder(
owner: LifecycleOwner, owner: LifecycleOwner,
binding: ItemPageBinding, binding: ItemPageBinding,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageBinding>(binding, loader, settings, networkState, exceptionResolver, owner), ) : BasePageHolder<ItemPageBinding>(
View.OnClickListener, binding = binding,
ZoomControl.ZoomControlListener { loader = loader,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
lifecycleOwner = owner,
), ZoomControl.ZoomControlListener, OnApplyWindowInsetsListener {
override val ssiv = binding.ssiv
init { init {
binding.ssiv.bindToLifecycle(owner) ViewCompat.setOnApplyWindowInsetsListener(binding.root, this)
binding.ssiv.isEagerLoadingEnabled = !context.isLowRamDevice()
binding.ssiv.addOnImageEventListener(delegate)
@Suppress("LeakingThis")
bindingInfo.buttonRetry.setOnClickListener(this)
@Suppress("LeakingThis")
bindingInfo.buttonErrorDetails.setOnClickListener(this)
} }
override fun onResume() { override fun onApplyWindowInsets(
super.onResume() v: View,
binding.ssiv.applyDownSampling(isForeground = true) insets: WindowInsetsCompat
} ): WindowInsetsCompat {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
override fun onPause() { insets.toWindowInsets()?.let {
super.onPause() applyRoundedCorners(it)
binding.ssiv.applyDownSampling(isForeground = false) }
}
override fun onConfigChanged() {
super.onConfigChanged()
if (settings.applyBitmapConfig(binding.ssiv)) {
delegate.reload()
} }
binding.ssiv.applyDownSampling(isResumed()) return insets
}
override fun onConfigChanged(settings: ReaderSettings) {
super.onConfigChanged(settings)
binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled
} }
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
override fun onBind(data: ReaderPage) { override fun onBind(data: ReaderPage) {
delegate.onBind(data.toMangaPage()) super.onBind(data)
binding.textViewNumber.text = (data.index + 1).toString() binding.textViewNumber.text = (data.index + 1).toString()
} }
override fun onRecycled() { override fun onReady() {
super.onRecycled()
binding.ssiv.recycle()
}
override fun onLoadingStarted() {
bindingInfo.layoutError.isVisible = false
bindingInfo.progressBar.show()
binding.ssiv.recycle()
}
override fun onProgressChanged(progress: Int) {
if (progress in 0..100) {
bindingInfo.progressBar.isIndeterminate = false
bindingInfo.progressBar.setProgressCompat(progress, true)
} else {
bindingInfo.progressBar.isIndeterminate = true
}
}
override fun onImageReady(source: ImageSource) {
binding.ssiv.setImage(source)
}
override fun onImageShowing(settings: ReaderSettings) {
binding.ssiv.maxScale = 2f * maxOf( binding.ssiv.maxScale = 2f * maxOf(
binding.ssiv.width / binding.ssiv.sWidth.toFloat(), binding.ssiv.width / binding.ssiv.sWidth.toFloat(),
binding.ssiv.height / binding.ssiv.sHeight.toFloat(), binding.ssiv.height / binding.ssiv.sHeight.toFloat(),
@@ -133,31 +114,6 @@ open class PageHolder(
} }
} }
override fun onImageShown() {
bindingInfo.progressBar.hide()
}
override fun onTrimMemory() {
// TODO https://developer.android.com/topic/performance/memory
}
final override fun onClick(v: View) {
when (v.id) {
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return, isFromUser = true)
R.id.button_error_details -> delegate.showErrorDetails(boundData?.url)
}
}
override fun onError(e: Throwable) {
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
bindingInfo.buttonRetry.setText(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
)
bindingInfo.buttonErrorDetails.isVisible = e.isSerializable()
bindingInfo.layoutError.isVisible = true
bindingInfo.progressBar.hide()
}
override fun onZoomIn() { override fun onZoomIn() {
scaleBy(1.2f) scaleBy(1.2f)
} }
@@ -166,6 +122,29 @@ open class PageHolder(
scaleBy(0.8f) scaleBy(0.8f)
} }
@SuppressLint("RtlHardcoded")
@RequiresApi(Build.VERSION_CODES.S)
protected open fun applyRoundedCorners(insets: WindowInsets) {
binding.textViewNumber.updateLayoutParams<FrameLayout.LayoutParams> {
val baseMargin = context.resources.getDimensionPixelOffset(R.dimen.margin_small)
val absoluteGravity = Gravity.getAbsoluteGravity(gravity, layoutDirection)
val corner = when {
absoluteGravity and Gravity.LEFT == Gravity.LEFT -> {
insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT)
}
absoluteGravity and Gravity.RIGHT == Gravity.RIGHT -> {
insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT)
}
else -> {
null
}
}
setMargins(baseMargin + (corner?.radius ?: 0))
}
}
private fun scaleBy(factor: Float) { private fun scaleBy(factor: Float) {
val ssiv = binding.ssiv val ssiv = binding.ssiv
val center = ssiv.getCenter() ?: return val center = ssiv.getCenter() ?: return

View File

@@ -13,22 +13,27 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class PagesAdapter( class PagesAdapter(
private val lifecycleOwner: LifecycleOwner, private val lifecycleOwner: LifecycleOwner,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<PageHolder>(loader, settings, networkState, exceptionResolver) { ) : BaseReaderAdapter<PageHolder>(
loader = loader,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
) {
override fun onCreateViewHolder( override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) = PageHolder( ) = PageHolder(
owner = lifecycleOwner, owner = lifecycleOwner,
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false), binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader, loader = loader,
settings = settings, readerSettingsProducer = readerSettingsProducer,
networkState = networkState, networkState = networkState,
exceptionResolver = exceptionResolver, exceptionResolver = exceptionResolver,
) )

View File

@@ -0,0 +1,31 @@
package org.koitharu.kotatsu.reader.ui.pager.vm
import com.davemorrissey.labs.subscaleview.ImageSource
sealed class PageState {
data object Empty : PageState()
data class Loading(
val preview: ImageSource?,
val progress: Int,
) : PageState()
data class Loaded(
val source: ImageSource,
val isConverted: Boolean,
) : PageState()
class Converting() : PageState()
data class Shown(
val source: ImageSource,
val isConverted: Boolean,
) : PageState()
data class Error(
val error: Throwable,
) : PageState()
fun isFinalState(): Boolean = this is Error || this is Shown
}

View File

@@ -0,0 +1,187 @@
package org.koitharu.kotatsu.reader.ui.pager.vm
import android.graphics.Rect
import android.net.Uri
import androidx.annotation.WorkerThread
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
import com.davemorrissey.labs.subscaleview.ImageSource
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext
import okio.IOException
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.throttle
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
class PageViewModel(
private val loader: PageLoader,
val settingsProducer: ReaderSettings.Producer,
private val networkState: NetworkState,
private val exceptionResolver: ExceptionResolver,
private val isWebtoon: Boolean,
) : DefaultOnImageEventListener {
private val scope = loader.loaderScope + Dispatchers.Main.immediate
private var job: Job? = null
private var cachedBounds: Rect? = null
val state = MutableStateFlow<PageState>(PageState.Empty)
fun isLoading() = job?.isActive == true
fun onBind(page: MangaPage) {
val prevJob = job
job = scope.launch(Dispatchers.Default) {
prevJob?.cancelAndJoin()
doLoad(page, force = false)
}
}
fun retry(page: MangaPage, isFromUser: Boolean) {
val prevJob = job
job = scope.launch {
prevJob?.cancelAndJoin()
val e = (state.value as? PageState.Error)?.error
if (e != null && ExceptionResolver.canResolve(e)) {
if (isFromUser) {
exceptionResolver.resolve(e)
}
}
withContext(Dispatchers.Default) {
doLoad(page, force = true)
}
}
}
fun showErrorDetails(url: String?) {
val e = (state.value as? PageState.Error)?.error ?: return
exceptionResolver.showErrorDetails(e, url)
}
fun onRecycle() {
state.value = PageState.Empty
cachedBounds = null
job?.cancel()
}
override fun onImageLoaded() {
state.update { currentState ->
if (currentState is PageState.Loaded) {
PageState.Shown(currentState.source, currentState.isConverted)
} else {
currentState
}
}
}
override fun onImageLoadError(e: Throwable) {
e.printStackTraceDebug()
state.update { currentState ->
if (currentState is PageState.Loaded) {
val uri = (currentState.source as? ImageSource.Uri)?.uri
if (!currentState.isConverted && uri != null && e is IOException) {
tryConvert(uri, e)
PageState.Converting()
} else {
PageState.Error(e)
}
} else {
currentState
}
}
}
private fun tryConvert(uri: Uri, e: Exception) {
val prevJob = job
job = scope.launch(Dispatchers.Default) {
prevJob?.join()
state.value = PageState.Converting()
try {
val newUri = loader.convertBimap(uri)
cachedBounds = if (settingsProducer.value.isPagesCropEnabled(isWebtoon)) {
loader.getTrimmedBounds(newUri)
} else {
null
}
state.value = PageState.Loaded(newUri.toImageSource(cachedBounds), isConverted = true)
} catch (ce: CancellationException) {
throw ce
} catch (e2: Throwable) {
e2.printStackTrace()
e.addSuppressed(e2)
state.value = PageState.Error(e)
}
}
}
@WorkerThread
private suspend fun doLoad(data: MangaPage, force: Boolean) = coroutineScope {
state.value = PageState.Loading(null, -1)
val previewJob = launch {
val preview = loader.loadPreview(data) ?: return@launch
state.update {
if (it is PageState.Loading) it.copy(preview = preview) else it
}
}
try {
val task = loader.loadPageAsync(data, force)
val progressObserver = observeProgress(this, task.progressAsFlow())
val uri = task.await()
progressObserver.cancelAndJoin()
previewJob.cancel()
cachedBounds = if (settingsProducer.value.isPagesCropEnabled(isWebtoon)) {
loader.getTrimmedBounds(uri)
} else {
null
}
state.value = PageState.Loaded(uri.toImageSource(cachedBounds), isConverted = false)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
e.printStackTraceDebug()
state.value = PageState.Error(e)
if (e is IOException && !networkState.value) {
networkState.awaitForConnection()
retry(data, isFromUser = false)
}
}
}
private fun observeProgress(scope: CoroutineScope, progress: Flow<Float>) = progress
.throttle(250)
.onEach {
val progressValue = (100 * it).toInt()
state.update { currentState ->
if (currentState is PageState.Loading) {
currentState.copy(progress = progressValue)
} else {
currentState
}
}
}.launchIn(scope)
private fun Uri.toImageSource(bounds: Rect?): ImageSource {
val source = ImageSource.uri(this)
return if (bounds != null) {
source.region(bounds)
} else {
source
}
}
}

View File

@@ -13,15 +13,15 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class WebtoonAdapter( class WebtoonAdapter(
private val lifecycleOwner: LifecycleOwner, private val lifecycleOwner: LifecycleOwner,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<WebtoonHolder>(loader, settings, networkState, exceptionResolver) { ) : BaseReaderAdapter<WebtoonHolder>(loader, readerSettingsProducer, networkState, exceptionResolver) {
override fun onCreateViewHolder( override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) = WebtoonHolder( ) = WebtoonHolder(
@@ -32,7 +32,7 @@ class WebtoonAdapter(
false, false,
), ),
loader = loader, loader = loader,
settings = settings, readerSettingsProducer = readerSettingsProducer,
networkState = networkState, networkState = networkState,
exceptionResolver = exceptionResolver, exceptionResolver = exceptionResolver,
) )

View File

@@ -1,99 +1,39 @@
package org.koitharu.kotatsu.reader.ui.pager.webtoon package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.view.View import android.view.View
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import com.davemorrissey.labs.subscaleview.ImageSource
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.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.GoneOnInvisibleListener
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
import org.koitharu.kotatsu.parsers.util.ifZero
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
class WebtoonHolder( class WebtoonHolder(
owner: LifecycleOwner, owner: LifecycleOwner,
binding: ItemPageWebtoonBinding, binding: ItemPageWebtoonBinding,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, networkState, exceptionResolver, owner), ) : BasePageHolder<ItemPageWebtoonBinding>(
View.OnClickListener { binding = binding,
loader = loader,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
lifecycleOwner = owner,
) {
override val ssiv = binding.ssiv
private var scrollToRestore = 0 private var scrollToRestore = 0
private val goneOnInvisibleListener = GoneOnInvisibleListener(bindingInfo.progressBar)
init { init {
binding.ssiv.bindToLifecycle(owner) bindingInfo.progressBar.setVisibilityAfterHide(View.GONE)
binding.ssiv.addOnImageEventListener(delegate)
bindingInfo.buttonRetry.setOnClickListener(this)
bindingInfo.buttonErrorDetails.setOnClickListener(this)
} }
override fun onResume() { override fun onReady() {
super.onResume()
binding.ssiv.applyDownSampling(isForeground = true)
}
override fun onPause() {
super.onPause()
binding.ssiv.applyDownSampling(isForeground = false)
}
override fun onConfigChanged() {
super.onConfigChanged()
if (settings.applyBitmapConfig(binding.ssiv)) {
delegate.reload()
}
binding.ssiv.applyDownSampling(isResumed())
}
override fun onBind(data: ReaderPage) {
delegate.onBind(data.toMangaPage())
}
override fun onRecycled() {
super.onRecycled()
binding.ssiv.recycle()
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
goneOnInvisibleListener.attach()
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
goneOnInvisibleListener.detach()
}
override fun onLoadingStarted() {
bindingInfo.layoutError.isVisible = false
bindingInfo.progressBar.show()
binding.ssiv.recycle()
}
override fun onProgressChanged(progress: Int) {
if (progress in 0..100) {
bindingInfo.progressBar.isIndeterminate = false
bindingInfo.progressBar.setProgressCompat(progress, true)
} else {
bindingInfo.progressBar.isIndeterminate = true
}
}
override fun onImageReady(source: ImageSource) {
binding.ssiv.setImage(source)
}
override fun onImageShowing(settings: ReaderSettings) {
binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter() binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter()
with(binding.ssiv) { with(binding.ssiv) {
scrollTo( scrollTo(
@@ -107,31 +47,6 @@ class WebtoonHolder(
} }
} }
override fun onImageShown() {
bindingInfo.progressBar.hide()
}
override fun onTrimMemory() {
// TODO
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return, isFromUser = true)
R.id.button_error_details -> delegate.showErrorDetails(boundData?.url)
}
}
override fun onError(e: Throwable) {
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
bindingInfo.buttonRetry.setText(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
)
bindingInfo.buttonErrorDetails.isVisible = e.isSerializable()
bindingInfo.layoutError.isVisible = true
bindingInfo.progressBar.hide()
}
fun getScrollY() = binding.ssiv.getScroll() fun getScrollY() = binding.ssiv.getScroll()
fun restoreScroll(scroll: Int) { fun restoreScroll(scroll: Int) {

View File

@@ -67,7 +67,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
rv.addItemDecoration(WebtoonGapsDecoration()) rv.addItemDecoration(WebtoonGapsDecoration())
} }
} }
viewModel.readerSettings.observe(viewLifecycleOwner) { viewModel.readerSettingsProducer.observe(viewLifecycleOwner) {
it.applyBackground(binding.root) it.applyBackground(binding.root)
} }
} }
@@ -81,7 +81,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
override fun onCreateAdapter() = WebtoonAdapter( override fun onCreateAdapter() = WebtoonAdapter(
lifecycleOwner = viewLifecycleOwner, lifecycleOwner = viewLifecycleOwner,
loader = pageLoader, loader = pageLoader,
settings = viewModel.readerSettings, readerSettingsProducer = viewModel.readerSettingsProducer,
networkState = networkState, networkState = networkState,
exceptionResolver = exceptionResolver, exceptionResolver = exceptionResolver,
) )

View File

@@ -18,6 +18,7 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.getCauseUrl import org.koitharu.kotatsu.core.util.ext.getCauseUrl
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
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.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
@@ -87,15 +88,15 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner {
} }
private fun openInBrowser(url: String?) { private fun openInBrowser(url: String?) {
if (url.isNullOrEmpty()) { if (url?.isHttpUrl() == true) {
Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
.show()
} else {
router.openBrowser( router.openBrowser(
url = url, url = url,
source = viewModel.source, source = viewModel.source,
title = viewModel.source.getTitle(requireContext()), title = viewModel.source.getTitle(requireContext()),
) )
} else {
Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
.show()
} }
} }

View File

@@ -31,7 +31,7 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.ui.config.adapter.ScrobblingMangaAdapter import org.koitharu.kotatsu.scrobbling.common.ui.config.adapter.ScrobblingMangaAdapter
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR import androidx.appcompat.R as appcompatR
@AndroidEntryPoint @AndroidEntryPoint
class ScrobblerConfigActivity : BaseActivity<ActivityScrobblerConfigBinding>(), class ScrobblerConfigActivity : BaseActivity<ActivityScrobblerConfigBinding>(),
@@ -114,7 +114,7 @@ class ScrobblerConfigActivity : BaseActivity<ActivityScrobblerConfigBinding>(),
private fun onUserChanged(user: ScrobblerUser?) { private fun onUserChanged(user: ScrobblerUser?) {
if (user == null) { if (user == null) {
viewBinding.imageViewAvatar.disposeImageRequest() viewBinding.imageViewAvatar.disposeImageRequest()
viewBinding.imageViewAvatar.setImageResource(materialR.drawable.abc_ic_menu_overflow_material) viewBinding.imageViewAvatar.setImageResource(appcompatR.drawable.abc_ic_menu_overflow_material)
return return
} }
viewBinding.imageViewAvatar.newImageRequest(this, user.avatar) viewBinding.imageViewAvatar.newImageRequest(this, user.avatar)

View File

@@ -37,7 +37,7 @@ class MangaSearchRepository @Inject constructor(
suspend fun getMangaSuggestion(query: String, limit: Int, source: MangaSource?): List<Manga> { suspend fun getMangaSuggestion(query: String, limit: Int, source: MangaSource?): List<Manga> {
return when { return when {
query.isEmpty() -> db.getSuggestionDao().getRandom(limit).map { MangaWithTags(it.manga, it.tags) } query.isEmpty() -> db.getSuggestionDao().getRandom(limit).map { MangaWithTags(it.manga, emptyList()) }
source != null -> db.getMangaDao().searchByTitle("%$query%", source.name, limit) source != null -> db.getMangaDao().searchByTitle("%$query%", source.name, limit)
else -> db.getMangaDao().searchByTitle("%$query%", limit) else -> db.getMangaDao().searchByTitle("%$query%", limit)
}.let { }.let {

View File

@@ -7,6 +7,7 @@ import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.parser.MangaDataRepository 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.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.contains
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
@@ -83,7 +84,7 @@ class SearchV2Helper @AssistedInject constructor(
} }
SearchKind.AUTHOR -> retainAll { m -> SearchKind.AUTHOR -> retainAll { m ->
m.author.isNullOrEmpty() || m.author.equals(query, ignoreCase = true) m.authors.isEmpty() || m.authors.contains(query, ignoreCase = true)
} }
SearchKind.SIMPLE, // no filtering expected SearchKind.SIMPLE, // no filtering expected
@@ -99,7 +100,7 @@ class SearchV2Helper @AssistedInject constructor(
} }
SearchKind.AUTHOR -> sortByDescending { m -> SearchKind.AUTHOR -> sortByDescending { m ->
m.author?.equals(query, ignoreCase = true) == true m.authors.contains(query, ignoreCase = true)
} }
SearchKind.TAG -> sortByDescending { m -> SearchKind.TAG -> sortByDescending { m ->

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -67,7 +68,7 @@ class SearchViewModel @Inject constructor(
val list: StateFlow<List<ListModel>> = combine( val list: StateFlow<List<ListModel>> = combine(
results, results,
isLoading, isLoading.dropWhile { !it },
includeDisabledSources, includeDisabledSources,
) { list, loading, includeDisabled -> ) { list, loading, includeDisabled ->
when { when {

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