Compare commits

..

135 Commits

Author SHA1 Message Date
Koitharu
0ed2232ac2 Update parsers 2025-05-27 17:03:14 +03:00
Koitharu
8d9129daaf Fix serivce starting crash on startup
(cherry picked from commit 157d5e6c05)
2025-05-27 16:57:01 +03:00
Koitharu
f799606688 Fix snackbar positioning
(cherry picked from commit b1497f2ace)
2025-05-27 16:56:24 +03:00
Koitharu
64adc4f58d Fix race condition while js evaluation
(cherry picked from commit 41d7fd1b86)
2025-05-27 16:56:18 +03:00
Koitharu
f6aad3355a Update parsers
(cherry picked from commit 19d0fe97a0)
2025-05-27 16:56:14 +03:00
Koitharu
0badf10a8b Fix WebView crash
(cherry picked from commit fa37c72923)
2025-05-27 16:54:56 +03:00
Koitharu
ab2235d0ca Update parsers 2025-05-18 14:40:45 +03:00
Koitharu
cbf707b403 Fix locales config
(cherry picked from commit 61c068d4ee)
2025-05-18 14:36:09 +03:00
Koitharu
8971c7a6a2 Fix color filte activity strings
(cherry picked from commit 8f8abcc3f6)
2025-05-18 14:35:19 +03:00
Koitharu
1576c9cdde Fix search menu item duplication
(cherry picked from commit a4b9acd622)
2025-05-18 14:34:44 +03:00
Koitharu
beba4f029a Fix per-app locale selection 2025-05-08 20:46:58 +03:00
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
Koitharu
a3345d11e7 Update parsers 2025-03-18 19:08:41 +02:00
Koitharu
f1ab65ec32 Fixes 2025-03-18 19:06:46 +02:00
Koitharu
6282d25d3d Merge pull request #1333 from weblate/weblate-kotatsu-strings
Translations update from Hosted Weblate
2025-03-18 19:04:06 +02:00
Saterz_
47c3f9ff3b Translated using Weblate (French)
Currently translated at 99.8% (807 of 808 strings)

Co-authored-by: Saterz_ <saterzstudio@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2025-03-18 07:39:01 +01:00
gekka
5cd2f1b9e6 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.5% (804 of 808 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-03-18 07:39:01 +01:00
Frosted
5d9b18ec11 Translated using Weblate (Turkish)
Currently translated at 100.0% (808 of 808 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-18 07:39:00 +01:00
Nicola Bortoletto
5aec1f644d Translated using Weblate (Italian)
Currently translated at 100.0% (808 of 808 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-18 07:39:00 +01:00
Draken
aee092f0b3 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (808 of 808 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (807 of 807 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-03-18 07:39:00 +01:00
I bit my tongue
9cc1cdac62 typos and coherence check 2025-03-16 21:13:54 +00:00
Koitharu
1e73739ddb Synchronized SieveCache wrapper 2025-03-16 12:54:00 +02:00
Koitharu
d1d7cc9adf Fix crashes 2025-03-16 12:43:31 +02:00
Koitharu
6a0ad7f79b Improve FileNotFoundException handling (#1332) 2025-03-15 10:38:41 +02:00
Koitharu
f7c70577ae Fix local chapters names (close #1323) 2025-03-15 09:00:25 +02:00
Koitharu
937ed798cf Update parsers 2025-03-15 08:16:30 +02:00
Koitharu
8da4f0e180 Merge pull request #1309 from weblate/weblate-kotatsu-strings
Translations update from Hosted Weblate
2025-03-15 08:13:53 +02:00
Drama Lover
170d12f143 Translated using Weblate (Arabic)
Currently translated at 88.8% (8 of 9 strings)

Co-authored-by: Drama Lover <loverdrama053@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ar/
Translation: Kotatsu/plurals
2025-03-14 19:37:56 +00:00
Akhil Raj
0fe3409577 Translated using Weblate (Malayalam)
Currently translated at 11.1% (1 of 9 strings)

Co-authored-by: Akhil Raj <akhilakae07@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ml/
Translation: Kotatsu/plurals
2025-03-14 19:37:55 +00:00
Laura
36e431a1ca Translated using Weblate (French)
Currently translated at 100.0% (805 of 805 strings)

Added translation using Weblate (Arabic (Algerian))

Translated using Weblate (Arabic)

Currently translated at 100.0% (805 of 805 strings)

Co-authored-by: Laura <hankmaroua@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2025-03-14 19:37:54 +00:00
Ore Ki
f30ebda851 Translated using Weblate (Indonesian)
Currently translated at 95.5% (769 of 805 strings)

Translated using Weblate (Indonesian)

Currently translated at 95.4% (768 of 805 strings)

Co-authored-by: Ore Ki <ramadrizkyyy@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-03-14 19:37:53 +00:00
Koitharu
0f021a2d6e Translated using Weblate (Russian)
Currently translated at 100.0% (803 of 803 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2025-03-14 19:37:53 +00:00
Alvoracz
f816c8ca6e Translated using Weblate (Czech)
Currently translated at 99.5% (799 of 803 strings)

Co-authored-by: Alvoracz <sedlor@seznam.cz>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translation: Kotatsu/Strings
2025-03-14 19:37:52 +00:00
Y Ok
fe0c4605f7 Translated using Weblate (Indonesian)
Currently translated at 95.5% (767 of 803 strings)

Co-authored-by: Y Ok <yok111263@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-03-14 19:37:51 +00:00
Milo Ivir
196bbff103 Translated using Weblate (Croatian)
Currently translated at 100.0% (795 of 795 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
2025-03-14 19:37:51 +00:00
Anon
80b26e62e9 Translated using Weblate (Serbian)
Currently translated at 98.6% (784 of 795 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2025-03-14 19:37:50 +00:00
Infy's Tagalog Translations
f877637fd2 Translated using Weblate (Filipino)
Currently translated at 99.7% (801 of 803 strings)

Translated using Weblate (Filipino)

Currently translated at 99.7% (793 of 795 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-03-14 19:37:49 +00:00
Frosted
5037b4ef84 Translated using Weblate (Turkish)
Currently translated at 100.0% (805 of 805 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (803 of 803 strings)

Translated using Weblate (Turkish)

Currently translated at 99.8% (802 of 803 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (799 of 799 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (798 of 798 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (795 of 795 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-14 19:37:49 +00:00
gekka
11b7696d31 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.7% (801 of 803 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.7% (797 of 799 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.7% (796 of 798 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.7% (793 of 795 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.6% (792 of 795 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-03-14 19:37:48 +00:00
Draken
4ad361dab8 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (805 of 805 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (803 of 803 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (795 of 795 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-03-14 19:37:47 +00:00
Nicola Bortoletto
1b88857e4d Translated using Weblate (Italian)
Currently translated at 100.0% (805 of 805 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (803 of 803 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (799 of 799 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (795 of 795 strings)

Translated using Weblate (Italian)

Currently translated at 99.8% (794 of 795 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-14 19:37:46 +00:00
Koitharu
7823bff063 Fixes and improvements batch 2025-03-14 20:59:37 +02:00
Koitharu
947de6c7c9 Fix tags highlighting 2025-03-11 08:44:55 +02:00
Koitharu
f689bf0cf7 Improve manga link sharing 2025-03-11 08:33:11 +02:00
Koitharu
b3028258ca Apply proxy settings to WebView 2025-03-08 18:51:03 +02:00
Koitharu
2c8476cabd Improve alternatives search functionality 2025-03-08 14:33:23 +02:00
Koitharu
5373e58807 UI tuning 2025-03-08 12:26:39 +02:00
Koitharu
4fdb781622 Merge pull request #1320 from dragonx943/patch-2
Update sync domain
2025-03-08 11:45:46 +02:00
Koitharu
0981ba771a Improve window insets handling 2025-03-08 10:40:20 +02:00
Koitharu
7cec7f5359 Fix window insets handling 2025-03-05 16:56:16 +02:00
Draken
8e55739685 Update constants.xml 2025-03-05 17:10:55 +07:00
Koitharu
d4a2d97071 Fixes 2025-03-04 14:33:50 +02:00
Koitharu
d51790811a Update error details dialog 2025-03-04 14:05:45 +02: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
316 changed files with 4294 additions and 2167 deletions

View File

@@ -19,15 +19,16 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 35
versionCode = 1003
versionName = '8.0-b3'
versionCode = 1014
versionName = '8.1.8'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
arg('room.generateKotlin', 'true')
}
androidResources {
generateLocaleConfig true
// https://issuetracker.google.com/issues/408030127
generateLocaleConfig false
}
}
buildTypes {
@@ -75,6 +76,8 @@ android {
'-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=coil3.annotation.ExperimentalCoilApi',
'-opt-in=coil3.annotation.InternalCoilApi',
'-Xjspecify-annotations=strict',
'-Xtype-enhancement-improvements-strict-mode',
]
}
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.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
class KotatsuApp : BaseApp() {
@@ -67,7 +66,6 @@ class KotatsuApp : BaseApp() {
setClassInstanceLimit(PagesCache::class.java, 1)
setClassInstanceLimit(MangaLoaderContext::class.java, 1)
setClassInstanceLimit(PageLoader::class.java, 1)
setClassInstanceLimit(ReaderViewModel::class.java, 1)
penaltyLog()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
penaltyListener(notifier.executor, notifier)

View File

@@ -5,6 +5,7 @@ import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import leakcanary.LeakCanary
import org.koitharu.kotatsu.KotatsuApp
import org.koitharu.kotatsu.R
@@ -24,6 +25,7 @@ class SettingsMenuProvider(
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
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) {
@@ -44,6 +46,13 @@ class SettingsMenuProvider(
true
}
R.id.action_ssiv_debug -> {
val checked = !menuItem.isChecked
menuItem.isChecked = checked
SubsamplingScaleImageView.isDebug = checked
true
}
else -> false
}
}

View File

@@ -4,6 +4,12 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
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
android:id="@+id/action_leakcanary"
android:checkable="true"

View File

@@ -52,6 +52,7 @@
android:hasFragileUserData="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:largeHeap="true"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"

View File

@@ -7,6 +7,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.toLocale
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaParserSource
@@ -14,6 +15,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.search.domain.SearchKind
import org.koitharu.kotatsu.search.domain.SearchV2Helper
import java.util.Locale
import javax.inject.Inject
private const val MAX_PARALLELISM = 4
@@ -24,8 +26,8 @@ class AlternativesUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
suspend operator fun invoke(manga: Manga): Flow<Manga> {
val sources = getSources(manga.source)
suspend operator fun invoke(manga: Manga, throughDisabledSources: Boolean): Flow<Manga> {
val sources = getSources(manga.source, throughDisabledSources)
if (sources.isEmpty()) {
return emptyFlow()
}
@@ -39,12 +41,14 @@ class AlternativesUseCase @Inject constructor(
searchHelper(manga.title, SearchKind.TITLE)?.manga
}
}.getOrNull()
list?.forEach {
launch {
val details = runCatchingCancellable {
mangaRepositoryFactory.create(it.source).getDetails(it)
}.getOrDefault(it)
send(details)
list?.forEach { m ->
if (m.id != manga.id) {
launch {
val details = runCatchingCancellable {
mangaRepositoryFactory.create(m.source).getDetails(m)
}.getOrDefault(m)
send(details)
}
}
}
}
@@ -52,19 +56,23 @@ class AlternativesUseCase @Inject constructor(
}
}
private suspend fun getSources(ref: MangaSource): List<MangaSource> {
val result = ArrayList<MangaSource>(MangaParserSource.entries.size - 2)
result.addAll(sourcesRepository.getEnabledSources())
result.sortByDescending { it.priority(ref) }
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
return result
}
private suspend fun getSources(ref: MangaSource, disabled: Boolean): List<MangaSource> = if (disabled) {
sourcesRepository.getDisabledSources()
} else {
sourcesRepository.getEnabledSources()
}.sortedByDescending { it.priority(ref) }
private fun MangaSource.priority(ref: MangaSource): Int {
var res = 0
if (this is MangaParserSource && ref is MangaParserSource) {
if (locale == ref.locale) res += 2
if (contentType == ref.contentType) res++
if (locale == ref.locale) {
res += 4
} else if (locale.toLocale() == Locale.getDefault()) {
res += 2
}
if (contentType == ref.contentType) {
res++
}
}
return res
}

View File

@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.concat
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.concurrent.TimeUnit
@@ -35,7 +36,8 @@ class AutoFixUseCase @Inject constructor(
if (seed.isHealthy()) {
return seed to null // no fix required
}
val replacement = alternativesUseCase(seed)
val replacement = alternativesUseCase(seed, throughDisabledSources = false)
.concat(alternativesUseCase(seed, throughDisabledSources = true))
.filter { it.isHealthy() }
.runningFold<Manga, Manga?>(null) { best, candidate ->
if (best == null || best < candidate) {

View File

@@ -1,10 +1,11 @@
package org.koitharu.kotatsu.alternatives.ui
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.widget.Toast
import androidx.activity.viewModels
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import coil3.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
@@ -15,12 +16,15 @@ import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.systemBarsInsets
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.adapter.buttonFooterAD
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
@@ -30,6 +34,7 @@ import javax.inject.Inject
@AndroidEntryPoint
class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
ListStateHolderListener,
OnListItemClickListener<MangaAlternativeModel> {
@Inject
@@ -49,15 +54,15 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, null))
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
.addDelegate(ListItemType.FOOTER_BUTTON, buttonFooterAD(this))
with(viewBinding.recyclerView) {
consumeInsetsAsPadding(Gravity.START or Gravity.END or Gravity.BOTTOM)
setHasFixedSize(true)
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
adapter = listAdapter
}
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
viewModel.content.observe(this, listAdapter)
viewModel.list.observe(this, listAdapter)
viewModel.onMigrated.observeEvent(this) {
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
router.openDetails(it)
@@ -65,6 +70,24 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
}
}
override fun onApplyWindowInsets(
v: View,
insets: WindowInsetsCompat
): WindowInsetsCompat {
val barsInsets = insets.systemBarsInsets
viewBinding.recyclerView.updatePadding(
left = barsInsets.left,
right = barsInsets.right,
bottom = barsInsets.bottom,
)
viewBinding.appbar.updatePadding(
left = barsInsets.left,
right = barsInsets.right,
top = barsInsets.top,
)
return insets.consumeAllSystemBarsInsets()
}
override fun onItemClick(item: MangaAlternativeModel, view: View) {
when (view.id) {
R.id.chip_source -> router.openSearch(item.manga.source, viewModel.manga.title)
@@ -73,6 +96,12 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
}
}
override fun onRetryClick(error: Throwable) = viewModel.retry()
override fun onEmptyActionClick() = Unit
override fun onFooterButtonClick() = viewModel.continueSearch()
private fun confirmMigration(target: Manga) {
buildAlertDialog(this, isCentered = true) {
setIcon(R.drawable.ic_replace)

View File

@@ -1,13 +1,17 @@
package org.koitharu.kotatsu.alternatives.ui
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEmpty
import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
@@ -18,16 +22,19 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.append
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.model.ButtonFooter
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrDefault
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import javax.inject.Inject
@HiltViewModel
@@ -41,39 +48,62 @@ class AlternativesViewModel @Inject constructor(
val manga = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga
val onMigrated = MutableEventFlow<Manga>()
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
private var includeDisabledSources = MutableStateFlow(false)
private val results = MutableStateFlow<List<MangaAlternativeModel>>(emptyList())
private var migrationJob: Job? = null
private var searchJob: Job? = null
private val mangaDetails = suspendLazy {
mangaRepositoryFactory.create(manga.source).getDetails(manga)
}
val onMigrated = MutableEventFlow<Manga>()
val list: StateFlow<List<ListModel>> = combine(
results,
isLoading,
includeDisabledSources,
) { list, loading, includeDisabled ->
when {
list.isEmpty() -> listOf(
when {
loading -> LoadingState
else -> EmptyState(
icon = R.drawable.ic_empty_common,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary,
actionStringRes = 0,
)
},
)
loading -> list + LoadingFooter()
includeDisabled -> list
else -> list + ButtonFooter(R.string.search_disabled_sources)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
init {
launchJob(Dispatchers.Default) {
val ref = runCatchingCancellable {
mangaRepositoryFactory.create(manga.source).getDetails(manga)
}.getOrDefault(manga)
val refCount = ref.chaptersCount()
alternativesUseCase(ref)
.map {
MangaAlternativeModel(
mangaModel = mangaListMapper.toListModel(it, ListMode.GRID) as MangaGridModel,
referenceChapters = refCount,
)
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
acc.filterIsInstance<MangaAlternativeModel>() + item + LoadingFooter()
}.onEmpty {
emit(
listOf(
EmptyState(
icon = R.drawable.ic_empty_common,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary,
actionStringRes = 0,
),
),
)
}.collect {
content.value = it
}
content.value = content.value.filterNot { it is LoadingFooter }
doSearch(throughDisabledSources = false)
}
fun retry() {
searchJob?.cancel()
results.value = emptyList()
includeDisabledSources.value = false
doSearch(throughDisabledSources = false)
}
fun continueSearch() {
if (includeDisabledSources.value) {
return
}
val prevJob = searchJob
searchJob = launchLoadingJob(Dispatchers.Default) {
includeDisabledSources.value = true
prevJob?.join()
doSearch(throughDisabledSources = true)
}
}
@@ -86,4 +116,21 @@ class AlternativesViewModel @Inject constructor(
onMigrated.call(target)
}
}
private fun doSearch(throughDisabledSources: Boolean) {
val prevJob = searchJob
searchJob = launchLoadingJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
val ref = mangaDetails.getOrDefault(manga)
val refCount = ref.chaptersCount()
alternativesUseCase.invoke(ref, throughDisabledSources)
.collect {
val model = MangaAlternativeModel(
mangaModel = mangaListMapper.toListModel(it, ListMode.GRID) as MangaGridModel,
referenceChapters = refCount,
)
results.append(model)
}
}
}
}

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

View File

@@ -17,9 +17,9 @@ abstract class BookmarksDao {
@Transaction
@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")
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.bookmarks.ui
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
@@ -10,6 +9,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.view.ActionMode
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
import coil3.ImageLoader
@@ -26,10 +26,11 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.systemBarsInsets
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
import org.koitharu.kotatsu.list.ui.GridSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
@@ -85,7 +86,6 @@ class AllBookmarksFragment :
)
val spanSizeLookup = SpanSizeLookup()
with(binding.recyclerView) {
consumeInsetsAsPadding(Gravity.BOTTOM or Gravity.START or Gravity.END)
setHasFixedSize(true)
val spanResolver = GridSpanResolver(resources)
addItemDecoration(TypedListSpacingDecoration(context, false))
@@ -107,6 +107,18 @@ class AllBookmarksFragment :
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val barsInsets = insets.systemBarsInsets
val basePadding = resources.getDimensionPixelOffset(R.dimen.list_spacing_normal)
viewBinding?.recyclerView?.setPadding(
barsInsets.left + basePadding,
barsInsets.top + basePadding,
barsInsets.right + basePadding,
barsInsets.bottom + basePadding,
)
return insets.consumeAllSystemBarsInsets()
}
override fun onDestroyView() {
super.onDestroyView()
bookmarksAdapter = null

View File

@@ -0,0 +1,107 @@
package org.koitharu.kotatsu.browser
import android.os.Bundle
import android.view.View
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
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.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.consumeAll
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import javax.inject.Inject
@AndroidEntryPoint
abstract class BaseBrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
@Inject
lateinit var proxyProvider: ProxyProvider
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
return
}
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
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(
v: View,
insets: WindowInsetsCompat
): WindowInsetsCompat {
val type = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()
val barsInsets = insets.getInsets(type)
viewBinding.webView.updatePadding(
left = barsInsets.left,
right = barsInsets.right,
bottom = barsInsets.bottom,
)
viewBinding.appbar.updatePadding(
left = barsInsets.left,
right = barsInsets.right,
top = barsInsets.top,
)
return insets.consumeAll(type)
}
override fun onPause() {
viewBinding.webView.onPause()
super.onPause()
}
override fun onResume() {
super.onResume()
viewBinding.webView.onResume()
}
override fun onDestroy() {
super.onDestroy()
if (hasViewBinding()) {
viewBinding.webView.stopLoading()
viewBinding.webView.destroy()
}
}
override fun onLoadingStateChanged(isLoading: Boolean) {
viewBinding.progressBar.isVisible = isLoading
}
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
this.title = title
supportActionBar?.subtitle = subtitle
}
override fun onHistoryChanged() {
onBackPressedCallback.onHistoryChanged()
}
}

View File

@@ -1,64 +1,45 @@
package org.koitharu.kotatsu.browser
import android.os.Bundle
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
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.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.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import javax.inject.Inject
import com.google.android.material.R as materialR
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaSource
@AndroidEntryPoint
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
class BrowserActivity : BaseBrowserActivity() {
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
return
}
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
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.consumeInsetsAsPadding(Gravity.START or Gravity.END or Gravity.BOTTOM)
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
viewBinding.webView.webViewClient = BrowserClient(this)
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
onBackPressedDispatcher.addCallback(onBackPressedCallback)
if (savedInstanceState != null) {
return
}
val url = intent?.dataString
if (url.isNullOrEmpty()) {
finishAfterTransition()
} else {
onTitleChanged(
intent?.getStringExtra(AppRouter.KEY_TITLE) ?: getString(R.string.loading_),
url,
)
viewBinding.webView.loadUrl(url)
lifecycleScope.launch {
try {
proxyProvider.applyWebViewConfig()
} catch (e: Exception) {
e.printStackTraceDebug()
Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
}
if (savedInstanceState == null) {
val url = intent?.dataString
if (url.isNullOrEmpty()) {
finishAfterTransition()
} else {
onTitleChanged(
intent?.getStringExtra(AppRouter.KEY_TITLE) ?: getString(R.string.loading_),
url,
)
viewBinding.webView.loadUrl(url)
}
}
}
}
@@ -84,35 +65,4 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
else -> super.onOptionsItemSelected(item)
}
override fun onPause() {
viewBinding.webView.onPause()
super.onPause()
}
override fun onResume() {
super.onResume()
viewBinding.webView.onResume()
}
override fun onDestroy() {
super.onDestroy()
if (hasViewBinding()) {
viewBinding.webView.stopLoading()
viewBinding.webView.destroy()
}
}
override fun onLoadingStateChanged(isLoading: Boolean) {
viewBinding.progressBar.isVisible = isLoading
}
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
this.title = title
supportActionBar?.subtitle = subtitle
}
override fun onHistoryChanged() {
onBackPressedCallback.onHistoryChanged()
}
}

View File

@@ -4,7 +4,13 @@ import android.graphics.Bitmap
import android.webkit.WebView
import android.webkit.WebViewClient
open class BrowserClient(private val callback: BrowserCallback) : WebViewClient() {
open class BrowserClient(
private val callback: BrowserCallback
) : WebViewClient() {
/**
* https://stackoverflow.com/questions/57414530/illegalstateexception-reasonphrase-cant-be-empty-with-android-webview
*/
override fun onPageFinished(webView: WebView, url: String) {
super.onPageFinished(webView, url)
@@ -16,7 +22,7 @@ open class BrowserClient(private val callback: BrowserCallback) : WebViewClient(
callback.onLoadingStateChanged(isLoading = true)
}
override fun onPageCommitVisible(view: WebView, url: String?) {
override fun onPageCommitVisible(view: WebView, url: String) {
super.onPageCommitVisible(view, url)
callback.onTitleChanged(view.title.orEmpty(), url)
}

View File

@@ -3,13 +3,12 @@ package org.koitharu.kotatsu.browser.cloudflare
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -18,21 +17,20 @@ import kotlinx.coroutines.yield
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
import org.koitharu.kotatsu.browser.BaseBrowserActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
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.util.ifNullOrEmpty
import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint
class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCallback {
class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
private var pendingResult = RESULT_CANCELED
@@ -40,43 +38,27 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
lateinit var cookieJar: MutableCookieJar
private lateinit var cfClient: CloudFlareClient
private var onBackPressedCallback: WebViewBackPressedCallback? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
return
}
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
val url = intent?.dataString
if (url.isNullOrEmpty()) {
finishAfterTransition()
return
}
cfClient = CloudFlareClient(cookieJar, this, url)
viewBinding.webView.configureForParser(intent?.getStringExtra(AppRouter.KEY_USER_AGENT))
viewBinding.webView.consumeInsetsAsPadding(Gravity.START or Gravity.END or Gravity.BOTTOM)
viewBinding.webView.webViewClient = cfClient
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
onBackPressedDispatcher.addCallback(it)
lifecycleScope.launch {
try {
proxyProvider.applyWebViewConfig()
} catch (e: Exception) {
Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
}
if (savedInstanceState == null) {
onTitleChanged(getString(R.string.loading_), url)
viewBinding.webView.loadUrl(url)
}
}
if (savedInstanceState == null) {
onTitleChanged(getString(R.string.loading_), url)
viewBinding.webView.loadUrl(url)
}
}
override fun onDestroy() {
runCatching {
viewBinding.webView
}.onSuccess {
it.stopLoading()
it.destroy()
}
super.onDestroy()
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
@@ -99,21 +81,13 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
else -> super.onOptionsItemSelected(item)
}
override fun onResume() {
super.onResume()
viewBinding.webView.onResume()
}
override fun onPause() {
viewBinding.webView.onPause()
super.onPause()
}
override fun finish() {
setResult(pendingResult)
super.finish()
}
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
override fun onPageLoaded() {
viewBinding.progressBar.isInvisible = true
}
@@ -131,18 +105,9 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
finishAfterTransition()
}
override fun onLoadingStateChanged(isLoading: Boolean) {
viewBinding.progressBar.isVisible = isLoading
}
override fun onHistoryChanged() {
onBackPressedCallback?.onHistoryChanged()
}
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
setTitle(title)
supportActionBar?.subtitle =
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
supportActionBar?.subtitle = subtitle?.toString()?.toHttpUrlOrNull()?.host.ifNullOrEmpty { subtitle }
}
private fun restartCheck() {

View File

@@ -4,8 +4,6 @@ import org.koitharu.kotatsu.browser.BrowserCallback
interface CloudFlareCallback : BrowserCallback {
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) = Unit
fun onPageLoaded()

View File

@@ -22,7 +22,7 @@ class CloudFlareClient(
checkClearance()
}
override fun onPageCommitVisible(view: WebView, url: String?) {
override fun onPageCommitVisible(view: WebView, url: String) {
super.onPageCommitVisible(view, url)
callback.onPageLoaded()
}

View File

@@ -99,7 +99,7 @@ interface AppModule {
@Provides
@Singleton
fun provideCoil(
@ApplicationContext context: Context,
@LocalizedAppContext context: Context,
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
mangaRepositoryFactory: MangaRepository.Factory,
imageProxyInterceptor: ImageProxyInterceptor,

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.cache
import androidx.collection.LruCache
import org.koitharu.kotatsu.core.util.SynchronizedSieveCache
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.concurrent.TimeUnit
import org.koitharu.kotatsu.core.cache.MemoryContentCache.Key as CacheKey
@@ -8,11 +9,9 @@ class ExpiringLruCache<T>(
val maxSize: Int,
private val lifetime: Long,
private val timeUnit: TimeUnit,
) : Iterable<CacheKey> {
) {
private val cache = LruCache<CacheKey, ExpiringValue<T>>(maxSize)
override fun iterator(): Iterator<CacheKey> = cache.snapshot().keys.iterator()
private val cache = SynchronizedSieveCache<CacheKey, ExpiringValue<T>>(maxSize)
operator fun get(key: CacheKey): T? {
val value = cache[key] ?: return null
@@ -23,7 +22,8 @@ class ExpiringLruCache<T>(
}
operator fun set(key: CacheKey, value: T) {
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
val value = ExpiringValue(value, lifetime, timeUnit)
cache.put(key, value)
}
fun clear() {
@@ -37,4 +37,8 @@ class ExpiringLruCache<T>(
fun remove(key: CacheKey) {
cache.remove(key)
}
fun removeAll(source: MangaSource) {
cache.removeIf { key, _ -> key.source == source }
}
}

View File

@@ -81,11 +81,7 @@ class MemoryContentCache @Inject constructor(application: Application) : Compone
}
private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) {
cache.forEach { key ->
if (key.source == source) {
cache.remove(key)
}
}
cache.removeAll(source)
}
data class Key(

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.prefs.AppSettings
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.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
@@ -163,7 +164,7 @@ class ExceptionResolver @AssistedInject constructor(
is ScrobblerAuthRequiredException,
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 SSLException,
is CertPathValidatorException -> R.string.fix

View File

@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.main.ui.owners.BottomSheetOwner
import org.koitharu.kotatsu.parsers.exception.ParseException
class SnackbarErrorObserver(
@@ -24,8 +25,9 @@ class SnackbarErrorObserver(
override suspend fun emit(value: Throwable) {
val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
if (activity is BottomNavOwner) {
snackbar.anchorView = activity.bottomNav
when (activity) {
is BottomNavOwner -> snackbar.anchorView = activity.bottomNav
is BottomSheetOwner -> snackbar.anchorView = activity.bottomSheet
}
if (canResolve(value)) {
snackbar.setAction(ExceptionResolver.getResolveStringId(value)) {

View File

@@ -43,6 +43,9 @@ class AppUpdateRepository @Inject constructor(
append("/releases?page=1&per_page=10")
}
val isUpdateAvailable: Boolean
get() = availableUpdate.value != null
fun observeAvailableUpdate() = availableUpdate.asStateFlow()
suspend fun getAvailableVersions(): List<AppVersion> {

View File

@@ -1,18 +1,23 @@
package org.koitharu.kotatsu.core.image
import android.graphics.Bitmap
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DecodeResult
import coil3.decode.DecodeUtils
import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.request.maxBitmapSize
import coil3.util.component1
import coil3.util.component2
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import kotlinx.coroutines.runInterruptible
import org.aomedia.avif.android.AvifDecoder
import org.aomedia.avif.android.AvifDecoder.Info
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
import org.koitharu.kotatsu.core.util.ext.readByteBuffer
class AvifImageDecoder(
private val source: ImageSource,
@@ -20,27 +25,52 @@ class AvifImageDecoder(
) : Decoder {
override suspend fun decode(): DecodeResult = runInterruptible {
val bytes = source.source().use {
it.inputStream().toByteBuffer()
}
val info = Info()
if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) {
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,
val bytes = source.source().readByteBuffer()
val decoder = AvifDecoder.create(bytes) ?: throw ImageDecodeException(
uri = source.fileOrNull()?.toString(),
format = "avif",
message = "Requested to decode byte buffer which cannot be handled by AvifDecoder",
)
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 {

View File

@@ -2,15 +2,22 @@ package org.koitharu.kotatsu.core.image
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.ImageDecoder
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.graphics.createBitmap
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.Info
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.util.MimeTypes
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.toMimeTypeOrNull
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -24,7 +31,7 @@ object BitmapDecoderCompat {
@Blocking
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) {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(file))
} 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
fun probeMimeType(file: File): MimeType? {
return MimeTypes.probeMimeType(file) ?: detectBitmapType(file)
@@ -62,7 +82,7 @@ object BitmapDecoderCompat {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(file.path, options)?.recycle()
return options.outMimeType?.toMimeTypeOrNull()
options.outMimeType?.toMimeTypeOrNull()
}.getOrNull()
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 bitmap = Bitmap.createBitmap(info.width, info.height, config)
val bitmap = createBitmap(info.width, info.height, config)
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
bitmap.recycle()
throw ImageDecodeException(null, FORMAT_AVIF)

View File

@@ -25,7 +25,7 @@ class CbzFetcher(
val entryName = requireNotNull(uri.fragment)
val fs = options.fileSystem.openZip(filePath)
SourceFetchResult(
source = ImageSource(entryName.toPath(), fs, closeable = fs),
source = ImageSource(entryName.toPath(), fs),
mimeType = MimeTypes.getMimeTypeFromExtension(entryName)?.toString(),
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.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.Rect
import android.os.Build
import coil3.Extras
@@ -11,7 +10,6 @@ import coil3.asImage
import coil3.decode.DecodeResult
import coil3.decode.DecodeUtils
import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult
import coil3.getExtra
import coil3.request.Options
@@ -25,24 +23,37 @@ import coil3.size.Scale
import coil3.size.Size
import coil3.size.isOriginal
import coil3.size.pxOrElse
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.util.ext.copyWithNewSource
import kotlin.math.roundToInt
class RegionBitmapDecoder(
private val source: ImageSource,
private val fetchResult: SourceFetchResult,
private val options: Options,
private val imageLoader: ImageLoader,
) : Decoder {
override suspend fun decode(): DecodeResult = runInterruptible {
val regionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BitmapRegionDecoder.newInstance(source.source().inputStream())
} else {
@Suppress("DEPRECATION")
BitmapRegionDecoder.newInstance(source.source().inputStream(), false)
override suspend fun decode(): DecodeResult? {
val regionDecoder = BitmapDecoderCompat.createRegionDecoder(fetchResult.source.source().inputStream())
if (regionDecoder == null) {
val revivedFetchResult = fetchResult.copyWithNewSource()
return try {
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()
try {
return try {
val rect = bitmapOptions.configureScale(regionDecoder.width, regionDecoder.height)
bitmapOptions.configureConfig()
val bitmap = regionDecoder.decodeRegion(rect, bitmapOptions)
@@ -149,7 +160,7 @@ class RegionBitmapDecoder(
result: SourceFetchResult,
options: Options,
imageLoader: ImageLoader
): Decoder = RegionBitmapDecoder(result.source, options)
): Decoder = RegionBitmapDecoder(result, options, imageLoader)
override fun equals(other: Any?) = other is Factory

View File

@@ -6,6 +6,7 @@ import android.text.SpannableStringBuilder
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.collection.MutableObjectIntMap
import androidx.core.net.toUri
import androidx.core.os.LocaleListCompat
import androidx.core.text.buildSpannedString
import androidx.core.text.strikeThrough
@@ -125,7 +126,8 @@ val Manga.isBroken: Boolean
get() = source == UnknownMangaSource
val Manga.appUrl: Uri
get() = Uri.parse("https://kotatsu.app/manga").buildUpon()
get() = "https://kotatsu.app/manga".toUri()
.buildUpon()
.appendQueryParameter("source", source.name)
.appendQueryParameter("name", title)
.appendQueryParameter("url", url)
@@ -147,6 +149,8 @@ fun Manga.chaptersCount(): Int {
return max
}
fun Manga.isNsfw(): Boolean = contentRating == ContentRating.ADULT || source.isNsfw()
fun MangaListFilter.getSummary() = buildSpannedString {
if (!query.isNullOrEmpty()) {
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.getThemeColor
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.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
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 {
override val name = "LOCAL"
@@ -79,6 +81,8 @@ tailrec fun MangaSource.unwrap(): MangaSource = if (this is MangaSourceInfo) {
this
}
fun MangaSource.getLocale(): Locale? = (unwrap() as? MangaParserSource)?.locale?.toLocaleOrNull()
fun MangaSource.getSummary(context: Context): String? = when (val source = unwrap()) {
is MangaParserSource -> {
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(
ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)),
ForegroundColorSpan(context.getThemeColor(appcompatR.attr.colorError, Color.RED)),
RelativeSizeSpan(0.74f),
SuperscriptSpan(),
) {

View File

@@ -12,6 +12,8 @@ import android.provider.Settings
import android.view.View
import androidx.annotation.CheckResult
import androidx.annotation.UiContext
import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
@@ -27,9 +29,13 @@ import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.image.CoilMemoryCacheKey
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.model.appUrl
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.isBroken
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaListFilter
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPage
@@ -43,6 +49,8 @@ import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.details.ui.DetailsActivity
@@ -72,6 +80,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.parsers.util.isNullOrEmpty
import org.koitharu.kotatsu.parsers.util.mapToArray
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity
@@ -96,6 +105,8 @@ import org.koitharu.kotatsu.stats.ui.StatsActivity
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity
import java.io.File
import androidx.appcompat.R as appcompatR
class AppRouter private constructor(
private val activity: FragmentActivity?,
@@ -170,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(
Intent(contextOrNull(), ImageActivity::class.java)
.setData(url.toUri())
.putExtra(KEY_SOURCE, source?.name),
.putExtra(KEY_SOURCE, source?.name)
.putExtra(KEY_PREVIEW, preview),
anchor?.let { scaleUpActivityOptionsOf(it) },
)
}
@@ -353,6 +365,7 @@ class AppRouter private constructor(
fun showTagDialog(tag: MangaTag) {
buildAlertDialog(contextOrNull() ?: return) {
setIcon(R.drawable.ic_tag)
setTitle(tag.title)
setItems(
arrayOf(
@@ -372,6 +385,7 @@ class AppRouter private constructor(
fun showAuthorDialog(author: String, source: MangaSource) {
buildAlertDialog(contextOrNull() ?: return) {
setIcon(R.drawable.ic_user)
setTitle(author)
setItems(
arrayOf(
@@ -389,6 +403,37 @@ class AppRouter private constructor(
}.show()
}
fun showShareDialog(manga: Manga) {
if (manga.isBroken) {
return
}
if (manga.isLocal) {
manga.url.toUri().toFileOrNull()?.let {
shareFile(it)
}
return
}
buildAlertDialog(contextOrNull() ?: return) {
setIcon(context.getThemeDrawable(appcompatR.attr.actionModeShareDrawable))
setTitle(R.string.share)
setItems(
arrayOf(
context.getString(R.string.link_to_manga_in_app),
context.getString(R.string.link_to_manga_on_s, manga.source.getTitle(context)),
),
) { _, which ->
val link = when (which) {
0 -> manga.appUrl.toString()
1 -> manga.publicUrl
else -> return@setItems
}
shareLink(link, manga.title)
}
setNegativeButton(android.R.string.cancel, null)
setCancelable(true)
}.show()
}
fun showErrorDialog(error: Throwable, url: String? = null) {
ErrorDetailsDialog().withArgs(2) {
putSerializable(KEY_ERROR, error)
@@ -544,8 +589,11 @@ class AppRouter private constructor(
/** Private utils **/
private fun startActivity(intent: Intent, options: Bundle? = null) {
fragment?.startActivity(intent, options)
?: activity?.startActivity(intent, options)
fragment?.also {
if (it.host != null) {
it.startActivity(intent, options)
}
} ?: activity?.startActivity(intent, options)
}
private fun startActivitySafe(intent: Intent): Boolean = try {
@@ -563,6 +611,25 @@ class AppRouter private constructor(
return fragment?.childFragmentManager ?: activity?.supportFragmentManager
}
private fun shareLink(link: String, title: String) {
val context = contextOrNull() ?: return
ShareCompat.IntentBuilder(context)
.setText(link)
.setType(TYPE_TEXT)
.setChooserTitle(context.getString(R.string.share_s, title.ellipsize(12)))
.startChooser()
}
private fun shareFile(file: File) { // TODO directory sharing support
val context = contextOrNull() ?: return
val intentBuilder = ShareCompat.IntentBuilder(context)
.setType(TYPE_CBZ)
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", file)
intentBuilder.addStream(uri)
intentBuilder.setChooserTitle(context.getString(R.string.share_s, file.name))
intentBuilder.startChooser()
}
@UiContext
private fun contextOrNull(): Context? = activity ?: fragment?.context
@@ -687,6 +754,12 @@ class AppRouter private constructor(
.putExtra(KEY_SOURCE, source.name)
}
fun isShareSupported(manga: Manga): Boolean = when {
manga.isBroken -> false
manga.isLocal -> manga.url.toUri().toFileOrNull() != null
else -> true
}
const val KEY_DATA = "data"
const val KEY_ENTRIES = "entries"
const val KEY_ERROR = "error"
@@ -700,6 +773,7 @@ class AppRouter private constructor(
const val KEY_MANGA = "manga"
const val KEY_MANGA_LIST = "manga_list"
const val KEY_PAGES = "pages"
const val KEY_PREVIEW = "preview"
const val KEY_QUERY = "query"
const val KEY_READER_MODE = "reader_mode"
const val KEY_SORT_ORDER = "sort_order"
@@ -724,6 +798,10 @@ class AppRouter private constructor(
private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS"
private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args"
private const val TYPE_TEXT = "text/plain"
private const val TYPE_IMAGE = "image/*"
private const val TYPE_CBZ = "application/x-cbz"
private fun Class<out Fragment>.fragmentTag() = name // TODO
private inline fun <reified F : Fragment> fragmentTag() = F::class.java.fragmentTag()

View File

@@ -1,51 +0,0 @@
package org.koitharu.kotatsu.core.network
import okio.IOException
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.ProxySelector
import java.net.SocketAddress
import java.net.URI
class AppProxySelector(
private val settings: AppSettings,
) : ProxySelector() {
init {
setDefault(this)
}
private var cachedProxy: Proxy? = null
override fun select(uri: URI?): List<Proxy> {
return listOf(getProxy())
}
override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {
ioe?.printStackTraceDebug()
}
private fun getProxy(): Proxy {
val type = settings.proxyType
val address = settings.proxyAddress
val port = settings.proxyPort
if (type == Proxy.Type.DIRECT) {
return Proxy.NO_PROXY
}
if (address.isNullOrEmpty() || port < 0 || port > 0xFFFF) {
throw ProxyConfigException()
}
cachedProxy?.let {
val addr = it.address() as? InetSocketAddress
if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) {
return it
}
}
val proxy = Proxy(type, InetSocketAddress(address, port))
cachedProxy = proxy
return proxy
}
}

View File

@@ -65,7 +65,7 @@ class CommonHeadersInterceptor @Inject constructor(
private fun Interceptor.interceptSafe(chain: Chain): Response = runCatchingCancellable {
intercept(chain)
}.getOrElse { e ->
if (e is IOException) {
if (e is IOException || e is Error) {
throw e
} else {
// only IOException can be safely thrown from an Interceptor

View File

@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.imageproxy.RealImageProxyInterceptor
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
@@ -62,14 +63,15 @@ interface NetworkModule {
cache: Cache,
cookieJar: CookieJar,
settings: AppSettings,
proxyProvider: ProxyProvider,
): OkHttpClient = OkHttpClient.Builder().apply {
assertNotInMainThread()
connectTimeout(20, TimeUnit.SECONDS)
readTimeout(60, TimeUnit.SECONDS)
writeTimeout(20, TimeUnit.SECONDS)
cookieJar(cookieJar)
proxySelector(AppProxySelector(settings))
proxyAuthenticator(ProxyAuthenticator(settings))
proxySelector(proxyProvider.selector)
proxyAuthenticator(proxyProvider.authenticator)
dns(DoHManager(cache, settings))
if (settings.isSSLBypassEnabled) {
disableCertificateVerification()

View File

@@ -1,45 +0,0 @@
package org.koitharu.kotatsu.core.network
import okhttp3.Authenticator
import okhttp3.Credentials
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import org.koitharu.kotatsu.core.prefs.AppSettings
import java.net.PasswordAuthentication
import java.net.Proxy
class ProxyAuthenticator(
private val settings: AppSettings,
) : Authenticator, java.net.Authenticator() {
init {
setDefault(this)
}
override fun authenticate(route: Route?, response: Response): Request? {
if (!isProxyEnabled()) {
return null
}
if (response.request.header(CommonHeaders.PROXY_AUTHORIZATION) != null) {
return null
}
val login = settings.proxyLogin ?: return null
val password = settings.proxyPassword ?: return null
val credential = Credentials.basic(login, password)
return response.request.newBuilder()
.header(CommonHeaders.PROXY_AUTHORIZATION, credential)
.build()
}
override fun getPasswordAuthentication(): PasswordAuthentication? {
if (!isProxyEnabled()) {
return null
}
val login = settings.proxyLogin ?: return null
val password = settings.proxyPassword ?: return null
return PasswordAuthentication(login, password.toCharArray())
}
private fun isProxyEnabled() = settings.proxyType != Proxy.Type.DIRECT
}

View File

@@ -0,0 +1,150 @@
package org.koitharu.kotatsu.core.network.proxy
import androidx.webkit.ProxyConfig
import androidx.webkit.ProxyController
import androidx.webkit.WebViewFeature
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import okhttp3.Authenticator
import okhttp3.Credentials
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.net.InetSocketAddress
import java.net.PasswordAuthentication
import java.net.Proxy
import java.net.ProxySelector
import java.net.SocketAddress
import java.net.URI
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import java.net.Authenticator as JavaAuthenticator
@Singleton
class ProxyProvider @Inject constructor(
private val settings: AppSettings,
) {
private var cachedProxy: Proxy? = null
val selector = object : ProxySelector() {
override fun select(uri: URI?): List<Proxy> {
return listOf(getProxy())
}
override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: okio.IOException?) {
ioe?.printStackTraceDebug()
}
}
val authenticator = ProxyAuthenticator()
init {
ProxySelector.setDefault(selector)
JavaAuthenticator.setDefault(authenticator)
}
suspend fun applyWebViewConfig() {
val isProxyEnabled = isProxyEnabled()
if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
if (isProxyEnabled) {
throw IllegalArgumentException("Proxy for WebView is not supported") // TODO localize
}
} else {
val controller = ProxyController.getInstance()
if (settings.proxyType == Proxy.Type.DIRECT) {
suspendCoroutine { cont ->
controller.clearProxyOverride(
(cont.context[CoroutineDispatcher] ?: Dispatchers.Main).asExecutor(),
) {
cont.resume(Unit)
}
}
} else {
val url = buildString {
when (settings.proxyType) {
Proxy.Type.DIRECT -> Unit
Proxy.Type.HTTP -> append("http")
Proxy.Type.SOCKS -> append("socks")
}
append("://")
append(settings.proxyAddress)
append(':')
append(settings.proxyPort)
}
if (settings.proxyType == Proxy.Type.SOCKS) {
System.setProperty("java.net.socks.username", settings.proxyLogin);
System.setProperty("java.net.socks.password", settings.proxyPassword);
}
val proxyConfig = ProxyConfig.Builder()
.addProxyRule(url)
.build()
suspendCoroutine { cont ->
controller.setProxyOverride(
proxyConfig,
(cont.context[CoroutineDispatcher] ?: Dispatchers.Main).asExecutor(),
) {
cont.resume(Unit)
}
}
}
}
}
private fun isProxyEnabled() = settings.proxyType != Proxy.Type.DIRECT
private fun getProxy(): Proxy {
val type = settings.proxyType
val address = settings.proxyAddress
val port = settings.proxyPort
if (type == Proxy.Type.DIRECT) {
return Proxy.NO_PROXY
}
if (address.isNullOrEmpty() || port < 0 || port > 0xFFFF) {
throw ProxyConfigException()
}
cachedProxy?.let {
val addr = it.address() as? InetSocketAddress
if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) {
return it
}
}
val proxy = Proxy(type, InetSocketAddress(address, port))
cachedProxy = proxy
return proxy
}
inner class ProxyAuthenticator : Authenticator, JavaAuthenticator() {
override fun authenticate(route: Route?, response: Response): Request? {
if (!isProxyEnabled()) {
return null
}
if (response.request.header(CommonHeaders.PROXY_AUTHORIZATION) != null) {
return null
}
val login = settings.proxyLogin ?: return null
val password = settings.proxyPassword ?: return null
val credential = Credentials.basic(login, password)
return response.request.newBuilder()
.header(CommonHeaders.PROXY_AUTHORIZATION, credential)
.build()
}
public override fun getPasswordAuthentication(): PasswordAuthentication? {
if (!isProxyEnabled()) {
return null
}
val login = settings.proxyLogin ?: return null
val password = settings.proxyPassword ?: return null
return PasswordAuthentication(login, password.toCharArray())
}
}
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.core.network.webview
import android.webkit.WebView
import android.webkit.WebViewClient
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
class ContinuationResumeWebViewClient(
private val continuation: Continuation<Unit>,
) : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
view?.webViewClient = WebViewClient() // reset to default
continuation.resume(Unit)
}
}

View File

@@ -133,7 +133,7 @@ class AppShortcutManager @Inject constructor(
}
}
private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat {
private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat = withContext(Dispatchers.Default) {
val icon = runCatchingCancellable {
coil.execute(
ImageRequest.Builder(context)
@@ -149,7 +149,7 @@ class AppShortcutManager @Inject constructor(
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
)
mangaRepository.storeManga(manga)
return ShortcutInfoCompat.Builder(context, manga.id.toString())
ShortcutInfoCompat.Builder(context, manga.id.toString())
.setShortLabel(manga.title)
.setLongLabel(manga.title)
.setIcon(icon)
@@ -159,8 +159,7 @@ class AppShortcutManager @Inject constructor(
.mangaId(manga.id)
.build()
.intent,
)
.build()
).build()
}
private suspend fun buildShortcutInfo(source: MangaSource): ShortcutInfoCompat = withContext(Dispatchers.Default) {

View File

@@ -9,7 +9,10 @@ import androidx.core.os.LocaleListCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Response
@@ -18,6 +21,7 @@ import okio.Buffer
import org.koitharu.kotatsu.core.image.BitmapDecoderCompat
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.network.webview.ContinuationResumeWebViewClient
import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
@@ -33,6 +37,7 @@ import org.koitharu.kotatsu.parsers.network.UserAgents
import org.koitharu.kotatsu.parsers.util.map
import java.lang.ref.WeakReference
import java.util.Locale
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.EmptyCoroutineContext
@@ -48,13 +53,28 @@ class MangaLoaderContextImpl @Inject constructor(
private var webViewCached: WeakReference<WebView>? = null
private val webViewUserAgent by lazy { obtainWebViewUserAgent() }
private val jsMutex = Mutex()
private val jsTimeout = TimeUnit.SECONDS.toMillis(4)
@Deprecated("Provide a base url")
@SuppressLint("SetJavaScriptEnabled")
override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main.immediate) {
val webView = obtainWebView()
suspendCoroutine { cont ->
webView.evaluateJavascript(script) { result ->
cont.resume(result?.takeUnless { it == "null" })
override suspend fun evaluateJs(script: String): String? = evaluateJs("", script)
override suspend fun evaluateJs(baseUrl: String, script: String): String? = withTimeout(jsTimeout) {
jsMutex.withLock {
withContext(Dispatchers.Main.immediate) {
val webView = obtainWebView()
if (baseUrl.isNotEmpty()) {
suspendCoroutine { cont ->
webView.webViewClient = ContinuationResumeWebViewClient(cont)
webView.loadDataWithBaseURL(baseUrl, " ", "text/html", null, null)
}
}
suspendCoroutine { cont ->
webView.evaluateJavascript(script) { result ->
cont.resume(result?.takeUnless { it == "null" })
}
}
}
}
}

View File

@@ -18,7 +18,6 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
@@ -58,13 +57,7 @@ class ParserMangaRepository(
val domains: Array<out String>
get() = parser.configKeyDomain.presetValues
override fun intercept(chain: Interceptor.Chain): Response {
return if (parser is Interceptor) {
parser.intercept(chain)
} else {
chain.proceed(chain.request())
}
}
override fun intercept(chain: Interceptor.Chain): Response = parser.intercept(chain)
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> {
return mirrorSwitchInterceptor.withMirrorSwitching {
@@ -96,7 +89,7 @@ class ParserMangaRepository(
parser.getDetails(manga)
}
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
fun getAuthProvider(): MangaParserAuthProvider? = parser.authorizationProvider
fun getRequestHeaders() = parser.getRequestHeaders()

View File

@@ -105,6 +105,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getEnumValue(KEY_LIST_MODE_FAVORITES, listMode)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_FAVORITES, value) }
val isTagsWarningsEnabled: Boolean
get() = prefs.getBoolean(KEY_TAGS_WARNINGS, true)
var isNsfwContentDisabled: Boolean
get() = prefs.getBoolean(KEY_DISABLE_NSFW, false)
set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) }
@@ -359,6 +362,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isSuggestionsExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false)
val isSuggestionsIncludeDisabledSources: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_DISABLED_SOURCES, false)
val isSuggestionsNotificationAvailable: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_NOTIFICATIONS, false)
@@ -569,7 +575,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
fun getAllValues(): Map<String, *> = prefs.all
fun upsertAll(m: Map<String, *>) = prefs.edit { putAll(m) }
fun upsertAll(m: Map<String, *>) = prefs.edit {
clear()
putAll(m)
}
private fun isBackgroundNetworkRestricted(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
@@ -655,6 +664,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SUGGESTIONS_WIFI_ONLY = "suggestions_wifi"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
const val KEY_SUGGESTIONS_DISABLED_SOURCES = "suggestions_disabled_sources"
const val KEY_SUGGESTIONS_NOTIFICATIONS = "suggestions_notifications"
const val KEY_SHIKIMORI = "shikimori"
const val KEY_ANILIST = "anilist"
@@ -725,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_CHAT = "backup_periodic_tg_chat_id"
const val KEY_MANGA_LIST_BADGES = "manga_list_badges"
const val KEY_TAGS_WARNINGS = "tags_warnings"
// keys for non-persistent preferences
const val KEY_APP_VERSION = "app_version"
@@ -741,6 +752,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_BACKUP_TG_TEST = "backup_periodic_tg_test"
const val KEY_CLEAR_MANGA_DATA = "manga_data_clear"
const val KEY_STORAGE_USAGE = "storage_usage"
const val KEY_WEBVIEW_CLEAR = "webview_clear"
// old keys are for migration only
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"

View File

@@ -15,6 +15,8 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
import androidx.fragment.app.FragmentManager
import androidx.viewbinding.ViewBinding
import dagger.hilt.android.EntryPointAccessors
@@ -27,10 +29,12 @@ import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
import androidx.appcompat.R as appcompatR
abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(),
ExceptionResolver.Host,
OnApplyWindowInsetsListener,
ScreenshotPolicyHelper.ContentContainer {
private var isAmoledTheme = false
@@ -78,16 +82,10 @@ abstract class BaseActivity<B : ViewBinding> :
}
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
override fun setContentView(layoutResID: Int) {
super.setContentView(layoutResID)
setupToolbar()
}
override fun setContentView(layoutResID: Int) = throw UnsupportedOperationException()
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
override fun setContentView(view: View?) {
super.setContentView(view)
setupToolbar()
}
override fun setContentView(view: View?) = throw UnsupportedOperationException()
override fun getContext() = this
@@ -96,10 +94,20 @@ abstract class BaseActivity<B : ViewBinding> :
protected fun setContentView(binding: B) {
this.viewBinding = binding
super.setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(binding.root, this)
val toolbar = (binding.root.findViewById<View>(R.id.toolbar) as? Toolbar)
toolbar?.let(this::setSupportActionBar)
}
protected fun setDisplayHomeAsUp(isEnabled: Boolean, showUpAsClose: Boolean) {
supportActionBar?.run {
setDisplayHomeAsUpEnabled(isEnabled)
if (showUpAsClose) {
setHomeAsUpIndicator(appcompatR.drawable.abc_ic_clear_material)
}
}
}
override fun onSupportNavigateUp(): Boolean {
val fm = supportFragmentManager
if (fm.isStateSaved) {
@@ -125,10 +133,6 @@ abstract class BaseActivity<B : ViewBinding> :
return super.onKeyDown(keyCode, event)
}
private fun setupToolbar() {
(findViewById<View>(R.id.toolbar) as? Toolbar)?.let(this::setSupportActionBar)
}
protected fun isDarkAmoledTheme(): Boolean {
val uiMode = resources.configuration.uiMode
val isNight = uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES

View File

@@ -5,6 +5,8 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding
import dagger.hilt.android.EntryPointAccessors
@@ -12,6 +14,7 @@ import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
abstract class BaseFragment<B : ViewBinding> :
OnApplyWindowInsetsListener,
Fragment(),
ExceptionResolver.Host {
@@ -42,6 +45,7 @@ abstract class BaseFragment<B : ViewBinding> :
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
ViewCompat.setOnApplyWindowInsetsListener(view, this)
onViewBindingCreated(requireViewBinding(), savedInstanceState)
}

View File

@@ -2,9 +2,11 @@ package org.koitharu.kotatsu.core.ui
import android.content.Context
import android.os.Bundle
import android.view.Gravity
import android.view.View
import androidx.annotation.StringRes
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceScreen
@@ -12,13 +14,18 @@ import androidx.preference.get
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
import org.koitharu.kotatsu.core.util.ext.container
import org.koitharu.kotatsu.core.util.ext.end
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.start
import org.koitharu.kotatsu.core.util.ext.systemBarsInsets
import org.koitharu.kotatsu.settings.SettingsActivity
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -26,6 +33,7 @@ import com.google.android.material.R as materialR
@AndroidEntryPoint
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(),
OnApplyWindowInsetsListener,
RecyclerViewOwner,
ExceptionResolver.Host {
@@ -46,10 +54,23 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
ViewCompat.setOnApplyWindowInsetsListener(view, this)
val themedContext = (view.parentView ?: view).context
view.setBackgroundColor(themedContext.getThemeColor(android.R.attr.colorBackground))
listView.clipToPadding = false
listView.consumeInsetsAsPadding(Gravity.BOTTOM or Gravity.START or Gravity.END)
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val barsInsets = insets.systemBarsInsets
val isTablet = !resources.getBoolean(R.bool.is_tablet)
val isMaster = container?.id == R.id.container_master
listView.setPaddingRelative(
if (isTablet && !isMaster) 0 else barsInsets.start(v),
0,
if (isTablet && isMaster) 0 else barsInsets.end(v),
barsInsets.bottom,
)
return insets.consumeAllSystemBarsInsets()
}
override fun onResume() {

View File

@@ -1,12 +1,16 @@
package org.koitharu.kotatsu.core.ui
import android.os.Bundle
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import com.google.android.material.appbar.AppBarLayout
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.consumeSystemBarsInsets
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
@@ -26,7 +30,7 @@ abstract class FragmentContainerActivity(private val fragmentClass: Class<out Fr
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityContainerBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
setDisplayHomeAsUp(true, false)
val fm = supportFragmentManager
if (fm.findFragmentById(R.id.container) == null) {
fm.commit {
@@ -36,5 +40,15 @@ abstract class FragmentContainerActivity(private val fragmentClass: Class<out Fr
}
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
viewBinding.appbar.updatePadding(
left = bars.left,
right = bars.right,
top = bars.top,
)
return insets.consumeSystemBarsInsets(top = true)
}
protected open fun getFragmentExtras(): Bundle? = intent.extras
}

View File

@@ -2,26 +2,34 @@ package org.koitharu.kotatsu.core.ui.dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.text.HtmlCompat
import androidx.core.text.htmlEncode
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.core.text.parseAsHtml
import androidx.core.view.isVisible
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.copyToClipboard
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.report
import org.koitharu.kotatsu.core.util.ext.requireSerializable
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.databinding.DialogErrorDetailsBinding
import javax.inject.Inject
class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
@AndroidEntryPoint
class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>(), View.OnClickListener {
private lateinit var exception: Throwable
@Inject
lateinit var appUpdateRepository: AppUpdateRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val args = requireArguments()
@@ -34,31 +42,50 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
override fun onViewBindingCreated(binding: DialogErrorDetailsBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
with(binding.textViewMessage) {
movementMethod = LinkMovementMethodCompat.getInstance()
text = context.getString(
R.string.manga_error_description_pattern,
exception.message?.htmlEncode().orEmpty(),
arguments?.getString(AppRouter.KEY_URL) ?: exception.getCauseUrl(),
).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY)
}
binding.buttonBrowser.setOnClickListener(this)
binding.textViewSummary.text = exception.message
val isUrlAvailable = exception.getCauseUrl()?.isHttpUrl() == true
binding.buttonBrowser.isVisible = isUrlAvailable
binding.textViewBrowser.isVisible = isUrlAvailable
binding.textViewDescription.setTextAndVisible(
if (appUpdateRepository.isUpdateAvailable) {
R.string.error_disclaimer_app_outdated
} else if (exception.isReportable()) {
R.string.error_disclaimer_report
} else {
0
},
)
}
@Suppress("NAME_SHADOWING")
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
val builder = super.onBuildDialog(builder)
.setCancelable(true)
.setNegativeButton(android.R.string.cancel, null)
.setTitle(R.string.error_occurred)
.setNegativeButton(R.string.close, null)
.setTitle(R.string.error_details)
.setNeutralButton(androidx.preference.R.string.copy) { _, _ ->
context?.copyToClipboard(getString(R.string.error), exception.stackTraceToString())
}
if (exception.isReportable()) {
builder.setPositiveButton(R.string.report) { _, _ ->
if (appUpdateRepository.isUpdateAvailable) {
builder.setPositiveButton(R.string.update) { _, _ ->
router.openAppUpdate()
dismiss()
}
} else if (exception.isReportable()) {
builder.setPositiveButton(R.string.report) { _, _ ->
exception.report(silent = true)
dismiss()
}
}
return builder
}
override fun onClick(v: View) {
router.openBrowser(
url = exception.getCauseUrl() ?: return,
source = null,
title = null,
)
}
}

View File

@@ -29,12 +29,14 @@ import androidx.core.view.GravityCompat
import androidx.core.view.ancestors
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isLayoutReversed
import org.koitharu.kotatsu.databinding.FastScrollerBinding
import kotlin.math.roundToInt
import androidx.appcompat.R as appcompatR
import com.google.android.material.R as materialR
private const val SCROLLBAR_HIDE_DELAY = 1000L
@@ -131,7 +133,7 @@ class FastScroller @JvmOverloads constructor(
clipChildren = false
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 trackColor = context.getThemeColor(materialR.attr.colorOutline, Color.LTGRAY)
@ColorInt var textColor = context.getThemeColor(android.R.attr.textColorPrimaryInverse, Color.WHITE)
@@ -245,8 +247,8 @@ class FastScroller @JvmOverloads constructor(
*/
fun setLayoutParams(viewGroup: ViewGroup) {
val recyclerViewId = recyclerView?.id ?: NO_ID
val marginTop = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_top)
val marginBottom = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_bottom)
val offsetTop = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_top)
val offsetBottom = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_bottom)
require(recyclerViewId != NO_ID) { "RecyclerView must have a view ID" }
@@ -263,31 +265,43 @@ class FastScroller @JvmOverloads constructor(
applyTo(viewGroup)
}
layoutParams = (layoutParams as ConstraintLayout.LayoutParams).apply {
updateLayoutParams<ConstraintLayout.LayoutParams> {
height = 0
setMargins(offset, marginTop, offset, marginBottom)
marginStart = offset
marginEnd = offset
topMargin = offsetTop
bottomMargin = offsetBottom
}
}
is CoordinatorLayout -> layoutParams = (layoutParams as CoordinatorLayout.LayoutParams).apply {
is CoordinatorLayout -> updateLayoutParams<CoordinatorLayout.LayoutParams> {
height = LayoutParams.MATCH_PARENT
anchorGravity = GravityCompat.END
anchorId = recyclerViewId
setMargins(offset, marginTop, offset, marginBottom)
marginStart = offset
marginEnd = offset
topMargin = offsetTop
bottomMargin = offsetBottom
}
is FrameLayout -> layoutParams = (layoutParams as FrameLayout.LayoutParams).apply {
is FrameLayout -> updateLayoutParams<FrameLayout.LayoutParams> {
height = LayoutParams.MATCH_PARENT
gravity = GravityCompat.END
setMargins(offset, marginTop, offset, marginBottom)
marginStart = offset
marginEnd = offset
topMargin = offsetTop
bottomMargin = offsetBottom
}
is RelativeLayout -> layoutParams = (layoutParams as RelativeLayout.LayoutParams).apply {
is RelativeLayout -> updateLayoutParams<RelativeLayout.LayoutParams> {
height = 0
addRule(RelativeLayout.ALIGN_TOP, recyclerViewId)
addRule(RelativeLayout.ALIGN_BOTTOM, recyclerViewId)
addRule(RelativeLayout.ALIGN_END, recyclerViewId)
setMargins(offset, marginTop, offset, marginBottom)
marginStart = offset
marginEnd = offset
topMargin = offsetTop
bottomMargin = offsetBottom
}
else -> throw IllegalArgumentException("Parent ViewGroup must be a ConstraintLayout, CoordinatorLayout, FrameLayout, or RelativeLayout")

View File

@@ -14,6 +14,8 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDialog
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.appcompat.view.ActionMode
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
@@ -29,7 +31,9 @@ import org.koitharu.kotatsu.core.ui.BaseActivityEntryPoint
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import com.google.android.material.R as materialR
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(), ExceptionResolver.Host {
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(),
OnApplyWindowInsetsListener,
ExceptionResolver.Host {
private var waitingForDismissAllowingStateLoss = false
private var isFitToContentsDisabled = false
@@ -74,6 +78,7 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(), E
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
ViewCompat.setOnApplyWindowInsetsListener(view, this)
val binding = requireViewBinding()
if (actionModeDelegate == null) {
actionModeDelegate = (activity as? BaseActivity<*>)?.actionModeDelegate

View File

@@ -21,19 +21,12 @@ class BottomSheetCollapseCallback(
object : BottomSheetBehavior.BottomSheetCallback() {
@SuppressLint("SwitchIntDef")
override fun onStateChanged(view: View, state: Int) {
when (state) {
STATE_EXPANDED,
STATE_HALF_EXPANDED -> isEnabled = true
STATE_COLLAPSED,
STATE_HIDDEN -> isEnabled = false
}
}
override fun onStateChanged(view: View, state: Int) = onStateChanged(state)
override fun onSlide(p0: View, p1: Float) = Unit
},
)
onStateChanged(behavior.state)
}
override fun handleOnBackPressed() = behavior.handleBackInvoked()
@@ -43,4 +36,14 @@ class BottomSheetCollapseCallback(
override fun handleOnBackProgressed(backEvent: BackEventCompat) = behavior.updateBackProgress(backEvent)
override fun handleOnBackStarted(backEvent: BackEventCompat) = behavior.startBackProgress(backEvent)
private fun onStateChanged(state: Int) {
when (state) {
STATE_EXPANDED,
STATE_HALF_EXPANDED -> isEnabled = true
STATE_COLLAPSED,
STATE_HIDDEN -> isEnabled = false
}
}
}

View File

@@ -1,51 +0,0 @@
package org.koitharu.kotatsu.core.ui.util
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import androidx.annotation.GravityInt
import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import org.koitharu.kotatsu.core.util.ext.consumeRelative
import org.koitharu.kotatsu.core.util.ext.end
import org.koitharu.kotatsu.core.util.ext.start
class InsetsToMarginsListener(
@GravityInt
private val sides: Int,
private val baseMargins: Insets,
) : OnApplyWindowInsetsListener {
private val insetType = WindowInsetsCompat.Type.systemBars()
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val barsInsets = insets.getInsets(insetType)
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
if (sides and Gravity.START == Gravity.START) {
marginStart = barsInsets.start(v) + baseMargins.start(v)
}
if (sides and Gravity.TOP == Gravity.TOP) {
topMargin = barsInsets.top + baseMargins.top
}
if (sides and Gravity.END == Gravity.END) {
marginEnd = barsInsets.end(v) + baseMargins.end(v)
}
if (sides and Gravity.BOTTOM == Gravity.BOTTOM) {
bottomMargin = barsInsets.bottom + baseMargins.bottom
}
}
return WindowInsetsCompat.Builder(insets)
.setInsets(
insetType,
barsInsets.consumeRelative(
v,
start = sides and Gravity.START == Gravity.START,
top = sides and Gravity.TOP == Gravity.TOP,
end = sides and Gravity.END == Gravity.END,
bottom = sides and Gravity.BOTTOM == Gravity.BOTTOM,
),
).build()
}
}

View File

@@ -1,61 +0,0 @@
package org.koitharu.kotatsu.core.ui.util
import android.view.Gravity
import android.view.View
import androidx.annotation.GravityInt
import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.WindowInsetsCompat
import org.koitharu.kotatsu.core.util.ext.consumeRelative
import org.koitharu.kotatsu.core.util.ext.end
import org.koitharu.kotatsu.core.util.ext.start
class InsetsToPaddingListener(
@GravityInt
private val sides: Int,
private val basePaddings: Insets,
) : OnApplyWindowInsetsListener {
private val insetType = WindowInsetsCompat.Type.systemBars()
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val barsInsets = insets.getInsets(insetType)
v.setPaddingRelative(
/* start = */
if (sides and Gravity.START == Gravity.START) {
barsInsets.start(v) + basePaddings.start(v)
} else {
v.paddingStart
},
/* top = */
if (sides and Gravity.TOP == Gravity.TOP) {
barsInsets.top + basePaddings.top
} else {
v.paddingTop
},
/* end = */
if (sides and Gravity.END == Gravity.END) {
barsInsets.end(v) + basePaddings.end(v)
} else {
v.paddingEnd
},
/* bottom = */
if (sides and Gravity.BOTTOM == Gravity.BOTTOM) {
barsInsets.bottom + basePaddings.bottom
} else {
v.paddingBottom
},
)
return WindowInsetsCompat.Builder(insets)
.setInsets(
insetType,
barsInsets.consumeRelative(
v,
start = sides and Gravity.START == Gravity.START,
top = sides and Gravity.TOP == Gravity.TOP,
end = sides and Gravity.END == Gravity.END,
bottom = sides and Gravity.BOTTOM == Gravity.BOTTOM,
),
).build()
}
}

View File

@@ -4,18 +4,21 @@ import android.view.View
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.main.ui.owners.BottomSheetOwner
class ReversibleActionObserver(
private val snackbarHost: View,
private val snackbarAnchor: View? = null,
) : FlowCollector<ReversibleAction> {
override suspend fun emit(value: ReversibleAction) {
val handle = value.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
val snackbar = Snackbar.make(snackbarHost, value.stringResId, length)
if (snackbarAnchor?.isShown == true) {
snackbar.anchorView = snackbarAnchor
when (val activity = snackbarHost.context.findActivity()) {
is BottomNavOwner -> snackbar.anchorView = activity.bottomNav
is BottomSheetOwner -> snackbar.anchorView = activity.bottomSheet
}
if (handle != null) {
snackbar.setAction(R.string.undo) { handle.reverseAsync() }

View File

@@ -6,6 +6,7 @@ import android.view.View
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.view.children
import coil3.ImageLoader
import coil3.request.Disposable
@@ -125,6 +126,9 @@ class ChipsView @JvmOverloads constructor(
private var model: ChipModel? = null
private var imageRequest: Disposable? = null
private val defaultStrokeColor = chipStrokeColor
private val defaultTextColor = textColors
init {
val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
setChipDrawable(drawable)
@@ -154,6 +158,14 @@ class ChipsView @JvmOverloads constructor(
isChecked = false
isCheckable = false
}
if (model.tint == 0) {
chipStrokeColor = defaultStrokeColor
setTextColor(defaultTextColor)
} else {
val tint = ContextCompat.getColorStateList(context, model.tint)
chipStrokeColor = tint
setTextColor(tint)
}
bindIcon(model)
isCheckedIconVisible = model.isChecked
isCloseIconVisible = if (model.isCloseable || model.isDropdown) {

View File

@@ -8,7 +8,7 @@ import android.view.animation.DecelerateInterpolator
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.ViewCompat
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.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.measureHeight
@@ -16,7 +16,7 @@ import org.koitharu.kotatsu.core.util.ext.measureHeight
class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
context: Context? = null,
attrs: AttributeSet? = null,
) : CoordinatorLayout.Behavior<BottomNavigationView>(context, attrs) {
) : CoordinatorLayout.Behavior<NavigationBarView>(context, attrs) {
@ViewCompat.NestedScrollType
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
}
override fun onDependentViewChanged(
parent: CoordinatorLayout,
child: BottomNavigationView,
child: NavigationBarView,
dependency: View,
): Boolean {
val appBarSize = dependency.measureHeight()
@@ -54,7 +54,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
override fun onStartNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: BottomNavigationView,
child: NavigationBarView,
directTargetChild: View,
target: View,
axes: Int,
@@ -70,7 +70,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
override fun onNestedPreScroll(
coordinatorLayout: CoordinatorLayout,
child: BottomNavigationView,
child: NavigationBarView,
target: View,
dx: Int,
dy: Int,
@@ -85,7 +85,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
override fun onStopNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: BottomNavigationView,
child: NavigationBarView,
target: View,
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 = ValueAnimator().apply {
interpolator = DecelerateInterpolator()

View File

@@ -3,10 +3,12 @@ package org.koitharu.kotatsu.core.ui.widgets
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.TimeInterpolator
import android.annotation.SuppressLint
import android.content.Context
import android.os.Parcel
import android.os.Parcelable
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.ViewPropertyAnimator
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
@@ -15,9 +17,11 @@ import androidx.core.view.isVisible
import androidx.customview.view.AbsSavedState
import androidx.interpolator.view.animation.FastOutLinearInInterpolator
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.measureHeight
import kotlin.math.max
import com.google.android.material.R as materialR
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_DOWN_ANIMATION_DURATION = 175L
private const val MAX_ITEM_COUNT = 6
class SlidingBottomNavigationView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = materialR.attr.bottomNavigationStyle,
@StyleRes defStyleRes: Int = materialR.style.Widget_Design_BottomNavigationView,
) : BottomNavigationView(context, attrs, defStyleAttr, defStyleRes),
) : NavigationBarView(context, attrs, defStyleAttr, defStyleRes),
CoordinatorLayout.AttachedBehavior {
private var currentAnimator: ViewPropertyAnimator? = null
@@ -55,6 +61,49 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
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 {
val superState = super.onSaveInstanceState()
return SavedState(superState, currentState, translationY)

View File

@@ -10,6 +10,8 @@ import android.widget.LinearLayout
import androidx.annotation.AttrRes
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.WindowInsetsCompat
import org.koitharu.kotatsu.core.util.ext.end
import org.koitharu.kotatsu.core.util.ext.start
class WindowInsetHolder @JvmOverloads constructor(
context: Context,
@@ -24,9 +26,9 @@ class WindowInsetHolder @JvmOverloads constructor(
val barsInsets = WindowInsetsCompat.toWindowInsetsCompat(insets, this)
.getInsets(WindowInsetsCompat.Type.systemBars())
val gravity = getLayoutGravity()
val newWidth = when (gravity and Gravity.HORIZONTAL_GRAVITY_MASK) {
Gravity.LEFT -> barsInsets.left
Gravity.RIGHT -> barsInsets.right
val newWidth = when (gravity and Gravity.RELATIVE_HORIZONTAL_GRAVITY_MASK) {
Gravity.START -> barsInsets.start(this)
Gravity.END -> barsInsets.end(this)
else -> 0
}
val newHeight = when (gravity and Gravity.VERTICAL_GRAVITY_MASK) {

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

View File

@@ -15,6 +15,7 @@ private const val TYPE_TEXT = "text/plain"
private const val TYPE_IMAGE = "image/*"
private const val TYPE_CBZ = "application/x-cbz"
@Deprecated("")
class ShareHelper(private val context: Context) {
fun shareMangaLink(manga: Manga) {

View File

@@ -0,0 +1,36 @@
package org.koitharu.kotatsu.core.util
import androidx.collection.SieveCache
class SynchronizedSieveCache<K : Any, V : Any>(
private val delegate: SieveCache<K, V>,
) {
constructor(maxSize: Int) : this(SieveCache<K, V>(maxSize))
private val lock = Any()
operator fun get(key: K): V? = synchronized(lock) {
delegate[key]
}
fun put(key: K, value: V): V? = synchronized(lock) {
delegate.put(key, value)
}
fun remove(key: K) = synchronized(lock) {
delegate.remove(key)
}
fun evictAll() = synchronized(lock) {
delegate.evictAll()
}
fun trimToSize(maxSize: Int) = synchronized(lock) {
delegate.trimToSize(maxSize)
}
fun removeIf(predicate: (K, V) -> Boolean) = synchronized(lock) {
delegate.removeIf(predicate)
}
}

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core.util.ext
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.app.ActivityManager
import android.app.ActivityManager.MemoryInfo
@@ -53,6 +52,7 @@ import okio.use
import org.json.JSONException
import org.jsoup.internal.StringUtil.StringJoiner
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xmlpull.v1.XmlPullParser
@@ -140,7 +140,6 @@ val Context.ramAvailable: Long
return result.availMem
}
@SuppressLint("DiscouragedApi")
fun Context.getLocalesConfig(): LocaleListCompat {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
LocaleConfig(this).supportedLocales?.let {
@@ -149,8 +148,7 @@ fun Context.getLocalesConfig(): LocaleListCompat {
}
val tagsList = StringJoiner(",")
try {
val resId = resources.getIdentifier("_generated_res_locale_config", "xml", packageName)
val xpp: XmlPullParser = resources.getXml(resId)
val xpp: XmlPullParser = resources.getXml(R.xml.locales_config)
while (xpp.eventType != XmlPullParser.END_DOCUMENT) {
if (xpp.eventType == XmlPullParser.START_TAG) {
if (xpp.name == "locale") {
@@ -217,6 +215,7 @@ fun WebView.configureForParser(userAgentOverride: String?) = with(settings) {
WebViewCompat.setAudioMuted(this@configureForParser, true)
}
databaseEnabled = true
allowContentAccess = false
if (userAgentOverride != null) {
userAgentString = userAgentOverride
}

View File

@@ -6,10 +6,13 @@ import android.widget.ImageView
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.drawable.toDrawable
import androidx.lifecycle.LifecycleOwner
import androidx.annotation.CheckResult
import coil3.Extras
import coil3.ImageLoader
import coil3.asDrawable
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.SourceFetchResult
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import coil3.request.ImageResult
@@ -28,12 +31,14 @@ import coil3.toBitmap
import coil3.util.CoilUtils
import com.google.android.material.progressindicator.BaseProgressIndicator
import org.koitharu.kotatsu.R
import okio.buffer
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.image.RegionBitmapDecoder
import org.koitharu.kotatsu.core.ui.image.AnimatedPlaceholderDrawable
import org.koitharu.kotatsu.core.util.progress.ImageRequestIndicatorListener
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import androidx.appcompat.R as appcompatR
import com.google.android.material.R as materialR
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 {
val errorColor = ColorUtils.blendARGB(
context.getThemeColor(materialR.attr.colorErrorContainer),
context.getThemeColor(materialR.attr.colorBackgroundFloating),
context.getThemeColor(appcompatR.attr.colorBackgroundFloating),
0.25f,
)
return placeholder(AnimatedPlaceholderDrawable(context))
@@ -162,3 +167,14 @@ private class CompositeImageRequestListener(
val mangaKey = Extras.Key<Manga?>(null)
val bookmarkKey = Extras.Key<Bookmark?>(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 }
}
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 ->
(x == null && element == null) || (x != null && element != null && x.contains(element, ignoreCase))
}

View File

@@ -28,9 +28,9 @@ fun File.subdir(name: String) = File(this, name).also {
if (!it.exists()) it.mkdirs()
}
fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() }
fun File.takeIfReadable() = takeIf { it.isReadable() }
fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() }
fun File.takeIfWriteable() = takeIf { it.isWriteable() }
fun File.isNotEmpty() = length() != 0L
@@ -110,3 +110,11 @@ fun File.walkCompat(includeDirectories: Boolean): Sequence<File> = if (Build.VER
val File.normalizedExtension: String?
get() = MimeTypes.getNormalizedExtension(name)
fun File.isReadable() = runCatching {
canRead()
}.getOrDefault(false)
fun File.isWriteable() = runCatching {
canWrite()
}.getOrDefault(false)

View File

@@ -4,10 +4,12 @@ import android.os.SystemClock
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
@@ -18,6 +20,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.flow.update
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.suspendlazy.SuspendLazy
import java.util.concurrent.TimeUnit
@@ -58,6 +61,8 @@ inline fun <T, R> Flow<List<T>>.mapItems(crossinline transform: (T) -> R): Flow<
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> {
var lastEmittedAt = 0L
return transformLatest { value ->
@@ -142,3 +147,12 @@ suspend fun <T> SendChannel<T>.sendNotNull(item: T?) {
send(item)
}
}
fun <T> MutableStateFlow<List<T>>.append(item: T) {
update { list -> list + item }
}
fun <T> Flow<T>.concat(other: Flow<T>) = flow {
emitAll(this@concat)
emitAll(other)
}

View File

@@ -2,7 +2,9 @@ package org.koitharu.kotatsu.core.util.ext
import android.os.Bundle
import androidx.core.view.MenuProvider
import androidx.core.view.ancestors
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
@@ -29,3 +31,8 @@ tailrec fun <T> Fragment.findParentCallback(cls: Class<T>): T? {
else -> parent.findParentCallback(cls)
}
}
val Fragment.container: FragmentContainerView?
get() = view?.ancestors?.firstNotNullOfOrNull {
it as? FragmentContainerView // TODO check if direct parent
}

View File

@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
import okhttp3.ResponseBody
import okio.BufferedSink
import okio.BufferedSource
import okio.FileSystem
import okio.IOException
import okio.Path
@@ -30,6 +31,14 @@ suspend fun BufferedSink.writeAllCancellable(source: Source) = withContext(Dispa
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 {
val outStream = ByteArrayOutputStream(available())
copyTo(outStream)

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.core.util.ext
import android.view.View
import androidx.core.graphics.Insets
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.InsetsType
fun Insets.end(view: View): Int {
return if (view.isRtl) left else right
@@ -11,20 +13,69 @@ fun Insets.start(view: View): Int {
return if (view.isRtl) right else left
}
fun Insets.consume(
@Deprecated("")
val WindowInsetsCompat.systemBarsInsets: Insets
get() = getInsets(WindowInsetsCompat.Type.systemBars())
@Deprecated("")
fun WindowInsetsCompat.consumeSystemBarsInsets(
left: Boolean = false,
top: Boolean = false,
right: Boolean = false,
bottom: Boolean = false,
): Insets = Insets.of(
/* left = */ if (left) 0 else this.left,
/* top = */ if (top) 0 else this.top,
/* right = */ if (right) 0 else this.right,
/* bottom = */ if (bottom) 0 else this.bottom,
)
): WindowInsetsCompat {
val barsInsets = systemBarsInsets
val insets = Insets.of(
if (left) 0 else barsInsets.left,
if (top) 0 else barsInsets.top,
if (right) 0 else barsInsets.right,
if (bottom) 0 else barsInsets.bottom,
)
return WindowInsetsCompat.Builder(this)
.setInsets(WindowInsetsCompat.Type.systemBars(), insets)
.build()
}
fun WindowInsetsCompat.consume(
v: View,
@InsetsType typeMask: Int,
start: Boolean = false,
top: Boolean = false,
end: Boolean = false,
bottom: Boolean = false,
): WindowInsetsCompat {
val insets = getInsets(typeMask)
val newInsets = Insets.of(
/* left = */ if (if (v.isRtl) end else start) 0 else insets.left,
/* top = */ if (top) 0 else insets.top,
/* right = */ if (if (v.isRtl) start else end) 0 else insets.right,
/* bottom = */ if (bottom) 0 else insets.bottom,
)
return WindowInsetsCompat.Builder(this)
.setInsets(typeMask, newInsets)
.build()
}
fun Insets.consumeRelative(
fun WindowInsetsCompat.consumeAll(
@InsetsType typeMask: Int,
): WindowInsetsCompat = WindowInsetsCompat.Builder(this)
.setInsets(typeMask, Insets.NONE)
.build()
@Deprecated("")
fun WindowInsetsCompat.consumeSystemBarsInsets(
view: View,
start: Boolean = false,
top: Boolean = false,
end: Boolean = false,
bottom: Boolean = false,
): WindowInsetsCompat = consume(view, WindowInsetsCompat.Type.systemBars(), start, top, end, bottom)
@Deprecated("")
fun WindowInsetsCompat.consumeAllSystemBarsInsets() = consumeAll(WindowInsetsCompat.Type.systemBars())
@Deprecated("")
fun Insets.consume(
view: View,
start: Boolean = false,
top: Boolean = false,

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 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) {
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.content.Context
import android.content.res.Resources
import android.os.Build
import androidx.annotation.PluralsRes
import androidx.annotation.Px
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 {
getQuantityString(resId, quantity, *formatArgs)
} catch (e: Resources.NotFoundException) {
e.report(silent = true)
e.printStackTraceDebug()
formatArgs.firstOrNull()?.toString() ?: quantity.toString()
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.VANILLA_ICE_CREAM) { // known issue
e.printStackTraceDebug()
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.IncompatiblePluginException
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.SyncApiException
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
@@ -41,6 +42,7 @@ import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
import java.io.File
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.SocketException
@@ -52,6 +54,8 @@ private const val MSG_NO_SPACE_LEFT = "No space left on device"
private const val MSG_CONNECTION_RESET = "Connection reset"
private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
private val FNFE_MESSAGE_REGEX = Regex("^(/[^\\s:]+)?.+?\\s([A-Z]{2,6})?\\s.+$")
fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessageOrNull(resources)
?: resources.getString(R.string.error_occurred)
@@ -86,8 +90,9 @@ private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = w
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
is FileNotFoundException -> resources.getString(R.string.file_not_found)
is FileNotFoundException -> parseMessage(resources) ?: message
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 ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
is SyncApiException,
@@ -225,3 +230,35 @@ fun Throwable.isWebViewUnavailable(): Boolean {
@Suppress("FunctionName")
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)
fun FileNotFoundException.getFile(): File? {
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
return groups.getOrNull(1)?.let { File(it) }
}
fun FileNotFoundException.parseMessage(resources: Resources): String? {
/*
Examples:
/storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system)
/storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory)
/storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error)
*/
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
val path = groups.getOrNull(1)
val error = groups.getOrNull(2)
val baseMessageIs = when (error) {
"EROFS" -> R.string.no_write_permission_to_file
"ENOENT" -> R.string.file_not_found
else -> return null
}
return if (path.isNullOrEmpty()) {
resources.getString(baseMessageIs)
} else {
resources.getString(
R.string.inline_preference_pattern,
resources.getString(baseMessageIs),
path,
)
}
}

View File

@@ -6,14 +6,10 @@ import android.view.View
import android.view.View.MeasureSpec
import android.view.ViewGroup
import android.widget.Checkable
import androidx.annotation.GravityInt
import androidx.appcompat.widget.ActionMenuView
import androidx.appcompat.widget.Toolbar
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.children
import androidx.core.view.descendants
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView
@@ -27,8 +23,6 @@ import com.google.android.material.slider.RangeSlider
import com.google.android.material.slider.Slider
import com.google.android.material.tabs.TabLayout
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
import org.koitharu.kotatsu.core.ui.util.InsetsToMarginsListener
import org.koitharu.kotatsu.core.ui.util.InsetsToPaddingListener
import kotlin.math.roundToInt
fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
@@ -160,9 +154,9 @@ fun TabLayout.setTabsEnabled(enabled: Boolean) {
fun BaseProgressIndicator<*>.showOrHide(value: Boolean) {
if (value) {
if (!isVisible) show()
show()
} else {
if (isVisible) hide()
hide()
}
}
@@ -194,19 +188,3 @@ fun Chip.setProgressIcon() {
chipIcon = progressDrawable
progressDrawable.start()
}
private fun View.marginsSnapshot(): Insets = (layoutParams as? ViewGroup.MarginLayoutParams)?.let { lp ->
Insets.of(lp.leftMargin, lp.topMargin, lp.rightMargin, lp.bottomMargin)
} ?: Insets.NONE
private fun View.paddingSnapshot(): Insets = Insets.of(paddingLeft, paddingTop, paddingRight, paddingBottom)
fun View.consumeInsetsAsPadding(@GravityInt sides: Int) = ViewCompat.setOnApplyWindowInsetsListener(
this,
InsetsToPaddingListener(sides, paddingSnapshot()),
)
fun View.consumeInsetsAsMargins(@GravityInt sides: Int) = ViewCompat.setOnApplyWindowInsetsListener(
this,
InsetsToMarginsListener(sides, marginsSnapshot()),
)

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.details.data
import org.koitharu.kotatsu.core.model.getLocale
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.local.domain.model.LocalManga
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.nullIfEmpty
import org.koitharu.kotatsu.reader.data.filterChapters
import java.util.Locale
data class MangaDetails(
private val manga: Manga,
@@ -39,6 +41,13 @@ data class MangaDetails(
fun toManga() = manga
fun getLocale(): Locale? {
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
return it
}
return manga.source.getLocale()
}
fun filterChapters(branch: String?) = MangaDetails(
manga = manga.filterChapters(branch),
localManga = localManga?.run {
@@ -69,4 +78,16 @@ data class MangaDetails(
}
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,18 +2,16 @@ package org.koitharu.kotatsu.details.ui
import android.content.Context
import android.os.Bundle
import android.transition.TransitionManager
import android.text.SpannedString
import android.view.Gravity
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
@@ -21,6 +19,7 @@ import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.core.view.updatePaddingRelative
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import androidx.transition.TransitionManager
import coil3.ImageLoader
import coil3.request.ImageRequest
import coil3.request.SuccessResult
@@ -38,7 +37,6 @@ import coil3.transform.RoundedCornersTransformation
import coil3.util.CoilUtils
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.chip.Chip
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
@@ -48,6 +46,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.R
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.LocalMangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource
@@ -59,7 +58,6 @@ import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
@@ -72,7 +70,7 @@ import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.LocaleUtils
import org.koitharu.kotatsu.core.util.ext.consumeRelative
import org.koitharu.kotatsu.core.util.ext.consume
import org.koitharu.kotatsu.core.util.ext.copyToClipboard
import org.koitharu.kotatsu.core.util.ext.crossfade
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
@@ -81,6 +79,7 @@ import org.koitharu.kotatsu.core.util.ext.drawableStart
import org.koitharu.kotatsu.core.util.ext.end
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.isTextTruncated
import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
@@ -99,17 +98,19 @@ import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration
import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver
import org.koitharu.kotatsu.main.ui.owners.BottomSheetOwner
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import javax.inject.Inject
import kotlin.math.roundToInt
@@ -118,10 +119,14 @@ import com.google.android.material.R as materialR
@AndroidEntryPoint
class DetailsActivity :
BaseActivity<ActivityDetailsBinding>(),
View.OnClickListener, OnApplyWindowInsetsListener,
View.OnLongClickListener, PopupMenu.OnMenuItemClickListener, View.OnLayoutChangeListener,
ViewTreeObserver.OnDrawListener, ChipsView.OnChipClickListener, OnListItemClickListener<Bookmark>,
OnContextClickListenerCompat, SwipeRefreshLayout.OnRefreshListener {
View.OnClickListener,
View.OnLayoutChangeListener,
ViewTreeObserver.OnDrawListener,
ChipsView.OnChipClickListener,
OnListItemClickListener<Bookmark>,
SwipeRefreshLayout.OnRefreshListener,
AuthorSpan.OnAuthorClickListener,
BottomSheetOwner {
@Inject
lateinit var shortcutManager: AppShortcutManager
@@ -129,24 +134,21 @@ class DetailsActivity :
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var listMapper: MangaListMapper
private val viewModel: DetailsViewModel by viewModels()
private lateinit var menuProvider: DetailsMenuProvider
private lateinit var infoBinding: LayoutDetailsTableBinding
override val bottomSheet: View?
get() = viewBinding.containerBottomSheet
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityDetailsBinding.inflate(layoutInflater))
infoBinding = LayoutDetailsTableBinding.bind(viewBinding.root)
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setDisplayShowTitleEnabled(false)
}
setDisplayHomeAsUp(true, false)
supportActionBar?.setDisplayShowTitleEnabled(false)
viewBinding.chipFavorite.setOnClickListener(this)
infoBinding.textViewLocal.setOnClickListener(this)
infoBinding.textViewAuthor.setOnClickListener(this)
infoBinding.textViewSource.setOnClickListener(this)
viewBinding.imageViewCover.setOnClickListener(this)
viewBinding.textViewTitle.setOnClickListener(this)
@@ -156,17 +158,18 @@ class DetailsActivity :
viewBinding.textViewDescription.addOnLayoutChangeListener(this)
viewBinding.swipeRefreshLayout.setOnRefreshListener(this)
viewBinding.textViewDescription.viewTreeObserver.addOnDrawListener(this)
infoBinding.textViewAuthor.movementMethod = LinkMovementMethodCompat.getInstance()
viewBinding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
viewBinding.chipsTags.onChipClickListener = this
TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView)
viewBinding.containerBottomSheet?.let { sheet ->
sheet.setOnClickListener(this)
sheet.addOnLayoutChangeListener(this)
onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(sheet))
BottomSheetBehavior.from(sheet).addBottomSheetCallback(
DetailsBottomSheetCallback(viewBinding.swipeRefreshLayout, checkNotNull(viewBinding.navbarDim)),
)
}
ViewCompat.setOnApplyWindowInsetsListener(viewBinding.root, this)
val appRouter = router
viewModel.mangaDetails.filterNotNull().observe(this, ::onMangaUpdated)
@@ -177,7 +180,7 @@ class DetailsActivity :
.observeEvent(this, DetailsErrorObserver(this, viewModel, exceptionResolver))
viewModel.onActionDone
.filterNot { appRouter.isChapterPagesSheetShown() }
.observeEvent(this, ReversibleActionObserver(viewBinding.scrollView, null))
.observeEvent(this, ReversibleActionObserver(viewBinding.scrollView))
combine(viewModel.historyInfo, viewModel.isLoading, ::Pair).observe(this) {
onHistoryChanged(it.first, it.second)
}
@@ -189,16 +192,7 @@ class DetailsActivity :
val menuInvalidator = MenuInvalidator(this)
viewModel.isStatsAvailable.observe(this, menuInvalidator)
viewModel.remoteManga.observe(this, menuInvalidator)
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.tags.observe(this, ::onTagsChanged)
viewModel.chapters.observe(this, PrefetchObserver(this))
viewModel.onDownloadStarted
.filterNot { appRouter.isChapterPagesSheetShown() }
@@ -212,43 +206,42 @@ class DetailsActivity :
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) {
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 -> {
val manga = viewModel.manga.value ?: return
val manga = viewModel.getMangaOrNull() ?: return
router.openList(manga.source, null, null)
}
R.id.textView_local -> {
val manga = viewModel.manga.value ?: return
val manga = viewModel.getMangaOrNull() ?: return
router.showLocalInfoDialog(manga)
}
R.id.chip_favorite -> {
val manga = viewModel.manga.value ?: return
val manga = viewModel.getMangaOrNull() ?: return
router.showFavoriteDialog(manga)
}
R.id.imageView_cover -> {
val manga = viewModel.manga.value ?: return
val manga = viewModel.getMangaOrNull() ?: return
router.openImage(
url = viewModel.coverUrl.value ?: return,
source = manga.source,
preview = CoilMemoryCacheKey.from(viewBinding.imageViewCover),
anchor = v,
)
}
R.id.button_description_more -> {
val tv = viewBinding.textViewDescription
TransitionManager.beginDelayedTransition(tv.parentView)
if (tv.context.isAnimationsEnabled) {
tv.parentView?.let {
TransitionManager.beginDelayedTransition(it)
}
}
if (tv.maxLines in 1 until Integer.MAX_VALUE) {
tv.maxLines = Integer.MAX_VALUE
} else {
@@ -257,17 +250,17 @@ class DetailsActivity :
}
R.id.button_scrobbling_more -> {
val manga = viewModel.manga.value ?: return
val manga = viewModel.getMangaOrNull() ?: return
router.showScrobblingSelectorSheet(manga, null)
}
R.id.button_related_more -> {
val manga = viewModel.manga.value ?: return
val manga = viewModel.getMangaOrNull() ?: return
router.openRelated(manga)
}
R.id.textView_title -> {
val title = viewModel.manga.value?.title?.nullIfEmpty() ?: return
val title = viewModel.getMangaOrNull()?.title?.nullIfEmpty() ?: return
buildAlertDialog(this) {
setMessage(title)
setNegativeButton(R.string.close, null)
@@ -279,45 +272,15 @@ class DetailsActivity :
}
}
override fun onAuthorClick(author: String) {
router.showAuthorDialog(author, viewModel.getMangaOrNull()?.source ?: return)
}
override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag ?: return
router.showTagDialog(tag)
}
override fun onContextClick(v: View): Boolean = onLongClick(v)
override fun onLongClick(v: View): Boolean = when (v.id) {
R.id.button_read -> {
val menu = PopupMenu(v.context, v)
menu.inflate(R.menu.popup_read)
menu.menu.findItem(R.id.action_forget)?.isVisible = viewModel.historyInfo.value.run {
!isIncognitoMode && history != null
}
menu.setOnMenuItemClickListener(this)
menu.setForceShowIcon(true)
menu.show()
true
}
else -> false
}
override fun onMenuItemClick(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_incognito -> {
openReader(isIncognitoMode = true)
true
}
R.id.action_forget -> {
viewModel.removeFromHistory()
true
}
else -> false
}
}
override fun onItemClick(item: Bookmark, view: View) {
router.openReader(ReaderIntent.Builder(view.context).bookmark(item).incognito(true).build())
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
@@ -346,7 +309,6 @@ class DetailsActivity :
oldBottom: Int
) {
with(viewBinding) {
buttonDescriptionMore.isVisible = textViewDescription.isTextTruncated
containerBottomSheet?.let { sheet ->
val peekHeight = BottomSheetBehavior.from(sheet).peekHeight
if (scrollView.paddingBottom != peekHeight) {
@@ -357,7 +319,8 @@ class DetailsActivity :
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val barsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val typeMask = WindowInsetsCompat.Type.systemBars()
val barsInsets = insets.getInsets(typeMask)
if (viewBinding.cardChapters != null) {
// landscape
viewBinding.cardChapters?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
@@ -372,12 +335,11 @@ class DetailsActivity :
viewBinding.appbar.updatePaddingRelative(
start = barsInsets.start(v),
)
return WindowInsetsCompat.Builder(insets)
.setInsets(
WindowInsetsCompat.Type.systemBars(),
barsInsets.consumeRelative(v, end = true, bottom = true),
).build()
return insets.consume(v, typeMask, bottom = true, end = true)
} else {
viewBinding.navbarDim?.updateLayoutParams {
height = barsInsets.bottom
}
return insets
}
}
@@ -447,11 +409,21 @@ class DetailsActivity :
with(viewBinding) {
textViewTitle.text = manga.title
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) }
}
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
if (manga.hasRating) {
ratingBarRating.rating = manga.rating * ratingBarRating.numStars
@@ -492,7 +464,6 @@ class DetailsActivity :
.allowRgb565(true)
.enqueueWith(coil)
}
bindTags(manga)
title = manga.title
invalidateOptionsMenu()
}
@@ -536,28 +507,9 @@ class DetailsActivity :
progress.isVisible = info.history != null
}
private fun openReader(isIncognitoMode: Boolean) {
val manga = viewModel.manga.value ?: return
if (viewModel.historyInfo.value.isChapterMissing) {
Snackbar.make(viewBinding.scrollView, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT)
.show()
} else {
router.openReader(
ReaderIntent.Builder(this)
.manga(manga)
.branch(viewModel.selectedBranchValue)
.incognito(isIncognitoMode)
.build(),
)
if (isIncognitoMode) {
Toast.makeText(this, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
}
}
private fun bindTags(manga: Manga) {
viewBinding.chipsTags.isVisible = manga.tags.isNotEmpty()
viewBinding.chipsTags.setChips(listMapper.mapTags(manga.tags))
private fun onTagsChanged(tags: Collection<ChipsView.ChipModel>) {
viewBinding.chipsTags.isVisible = tags.isNotEmpty()
viewBinding.chipsTags.setChips(tags)
}
private fun loadCover(imageUrl: String?) {
@@ -593,6 +545,24 @@ class DetailsActivity :
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 val context: Context,
) : FlowCollector<List<ChapterListItem>?> {

View File

@@ -3,11 +3,10 @@ package org.koitharu.kotatsu.details.ui
import android.view.View
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.bottomsheet.BottomSheetBehavior
import org.koitharu.kotatsu.core.ui.widgets.WindowInsetHolder
class DetailsBottomSheetCallback(
private val swipeRefreshLayout: SwipeRefreshLayout,
private val navbarDimView: WindowInsetHolder,
private val navbarDimView: View,
) : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {

View File

@@ -26,6 +26,7 @@ class DetailsErrorObserver(
override suspend fun emit(value: Throwable) {
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) {
snackbar.duration = Snackbar.LENGTH_INDEFINITE
}

View File

@@ -5,20 +5,18 @@ import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.core.view.MenuProvider
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
class DetailsMenuProvider(
private val activity: FragmentActivity,
@@ -27,15 +25,19 @@ class DetailsMenuProvider(
private val appShortcutManager: AppShortcutManager,
) : MenuProvider {
private val router: AppRouter
get() = activity.router
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_details, menu)
}
override fun onPrepareMenu(menu: Menu) {
val manga = viewModel.manga.value
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_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_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
@@ -47,51 +49,44 @@ class DetailsMenuProvider(
val manga = viewModel.getMangaOrNull() ?: return false
when (menuItem.itemId) {
R.id.action_share -> {
val shareHelper = ShareHelper(activity)
if (manga.isLocal) {
shareHelper.shareCbz(listOf(manga.url.toUri().toFile()))
} else {
shareHelper.shareMangaLink(manga)
}
router.showShareDialog(manga)
}
R.id.action_delete -> {
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.delete_manga)
.setMessage(activity.getString(R.string.text_delete_local_manga, manga.title))
.setPositiveButton(R.string.delete) { _, _ ->
viewModel.deleteLocal()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
buildAlertDialog(activity) {
setTitle(R.string.delete_manga)
setMessage(activity.getString(R.string.text_delete_local_manga, manga.title))
setPositiveButton(R.string.delete) { _, _ -> viewModel.deleteLocal() }
setNegativeButton(android.R.string.cancel, null)
}.show()
}
R.id.action_save -> {
activity.router.showDownloadDialog(manga, snackbarHost)
router.showDownloadDialog(manga, snackbarHost)
}
R.id.action_browser -> {
activity.router.openBrowser(url = manga.publicUrl, source = manga.source, title = manga.title)
router.openBrowser(url = manga.publicUrl, source = manga.source, title = manga.title)
}
R.id.action_online -> {
activity.router.openDetails(manga)
router.openDetails(viewModel.remoteManga.value ?: return false)
}
R.id.action_related -> {
activity.router.openSearch(manga.title)
router.openSearch(manga.title)
}
R.id.action_alternatives -> {
activity.router.openAlternatives(manga)
router.openAlternatives(manga)
}
R.id.action_stats -> {
activity.router.showStatisticSheet(manga)
router.showStatisticSheet(manga)
}
R.id.action_scrobbling -> {
activity.router.showScrobblingSelectorSheet(manga, null)
router.showScrobblingSelectorSheet(manga, null)
}
R.id.action_shortcut -> {

View File

@@ -152,6 +152,10 @@ class DetailsViewModel @Inject constructor(
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
val tags = manga.mapLatest {
mangaListMapper.mapTags(it?.tags.orEmpty())
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val branches: StateFlow<List<MangaBranch>> = combine(
mangaDetails,
selectedBranch,

View File

@@ -125,15 +125,16 @@ class ReadButtonDelegate(
}
private fun onHistoryChanged(isLoading: Boolean, info: HistoryInfo) {
val isChaptersLoading = isLoading && (info.totalChapters <= 0 || info.isChapterMissing)
buttonRead.setText(
when {
isLoading -> R.string.loading_
isChaptersLoading -> R.string.loading_
info.isIncognitoMode -> R.string.incognito
info.canContinue -> R.string._continue
else -> R.string.read
},
)
splitButton.isEnabled = !isLoading && info.isValid
splitButton.isEnabled = !isChaptersLoading && info.isValid
}
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.getThemeColor
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import androidx.appcompat.R as appcompatR
import com.google.android.material.R as materialR
class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
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 iconOffset = context.resources.getDimensionPixelOffset(R.dimen.chapter_check_offset)
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(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74,
@@ -32,7 +33,7 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor
init {
paint.color = ColorUtils.setAlphaComponent(
context.getThemeColor(materialR.attr.colorPrimary, Color.DKGRAY),
context.getThemeColor(appcompatR.attr.colorPrimary, Color.DKGRAY),
98,
)
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.model.ListModel
import java.util.Locale
data class MangaBranch(
val name: String?,
@@ -11,8 +10,6 @@ data class MangaBranch(
val isCurrent: Boolean,
) : ListModel {
val locale: Locale? by lazy(::findAppropriateLocale)
override fun areItemsTheSame(other: ListModel): Boolean {
return other is MangaBranch && other.name == name
}
@@ -28,16 +25,4 @@ data class MangaBranch(
override fun toString(): String {
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

@@ -5,6 +5,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.view.ActionMode
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
@@ -88,13 +89,15 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(),
viewModel.newChaptersCount.observe(viewLifecycleOwner, ::onNewChaptersChanged)
if (dialog != null) {
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.pager, this))
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.pager, null))
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.pager))
viewModel.onDownloadStarted.observeEvent(viewLifecycleOwner, DownloadStartedObserver(binding.pager))
} else {
PeekHeightController(arrayOf(binding.headerBar, binding.toolbar)).attach()
}
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets
override fun onStateChanged(sheet: View, newState: Int) {
if (newState == STATE_DRAGGING || newState == STATE_SETTLING) {
return

View File

@@ -100,7 +100,11 @@ abstract class ChaptersPagesViewModel(
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
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())
val chapters = combine(

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.details.ui.pager.bookmarks
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
@@ -9,6 +8,7 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.view.ActionMode
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
@@ -29,11 +29,12 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.systemBarsInsets
import org.koitharu.kotatsu.databinding.FragmentMangaBookmarksBinding
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
import org.koitharu.kotatsu.list.ui.GridSpanResolver
@@ -94,7 +95,6 @@ class BookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
headerClickListener = null,
)
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) // before rv initialization
binding.recyclerView.consumeInsetsAsPadding(Gravity.START or Gravity.BOTTOM or Gravity.END)
with(binding.recyclerView) {
addItemDecoration(TypedListSpacingDecoration(context, false))
setHasFixedSize(true)
@@ -116,6 +116,17 @@ class BookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val barsInsets = insets.systemBarsInsets
viewBinding?.recyclerView?.setPadding(
barsInsets.left,
barsInsets.top,
barsInsets.right,
barsInsets.bottom,
)
return insets.consumeAllSystemBarsInsets()
}
override fun onDestroyView() {
spanResolver = null
bookmarksAdapter = null

View File

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

View File

@@ -4,8 +4,6 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
@@ -49,7 +47,6 @@ import kotlin.math.roundToInt
class ChaptersFragment :
BaseFragment<FragmentChaptersBinding>(),
OnListItemClickListener<ChapterListItem>,
OnApplyWindowInsetsListener,
RecyclerViewOwner,
ChipsView.OnChipClickListener {
@@ -84,7 +81,6 @@ class ChaptersFragment :
LinearLayoutManager(context)
}
}
ViewCompat.setOnApplyWindowInsetsListener(binding.root, this)
with(binding.recyclerViewChapters) {
addItemDecoration(TypedListSpacingDecoration(context, true))
checkNotNull(selectionController).attachToRecyclerView(this)

View File

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

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.details.ui.pager.pages
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
@@ -10,6 +9,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.appcompat.view.ActionMode
import androidx.collection.ArraySet
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
@@ -33,7 +33,7 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
import org.koitharu.kotatsu.core.util.ext.consumeAll
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe
@@ -118,7 +118,6 @@ class PagesFragment :
clickListener = this@PagesFragment,
)
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) // before rv initialization
binding.recyclerView.consumeInsetsAsPadding(Gravity.START or Gravity.BOTTOM or Gravity.END)
with(binding.recyclerView) {
addItemDecoration(TypedListSpacingDecoration(context, false))
checkNotNull(selectionController).attachToRecyclerView(this)
@@ -150,6 +149,18 @@ class PagesFragment :
super.onDestroyView()
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val typeBask = WindowInsetsCompat.Type.systemBars()
val barsInsets = insets.getInsets(typeBask)
viewBinding?.recyclerView?.setPadding(
barsInsets.left,
barsInsets.top,
barsInsets.right,
barsInsets.bottom,
)
return insets.consumeAll(typeBask)
}
override fun onItemClick(item: PageThumbnail, view: View) {
if (selectionController?.onItemClick(item.page.id) == true) {
return

View File

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

View File

@@ -10,6 +10,8 @@ import android.widget.RatingBar
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels
import coil3.ImageLoader
import com.google.android.material.snackbar.Snackbar
@@ -18,6 +20,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.consume
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
@@ -81,6 +84,15 @@ class ScrobblingInfoSheet :
menu = null
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val typeMask = WindowInsetsCompat.Type.systemBars()
viewBinding?.root?.updatePadding(
bottom = insets.getInsets(typeMask).bottom,
)
return insets.consume(v, typeMask, bottom = true)
}
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
viewModel.updateScrobbling(
index = scrobblerIndex,

View File

@@ -16,7 +16,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import java.time.Instant
import java.util.UUID
import com.google.android.material.R as materialR
import androidx.appcompat.R as appcompatR
data class DownloadItemModel(
val id: UUID,
@@ -62,7 +62,7 @@ data class DownloadItemModel(
fun getErrorMessage(context: Context): CharSequence? = if (error != null) {
buildSpannedString {
bold {
color(context.getThemeColor(materialR.attr.colorError, Color.RED)) {
color(context.getThemeColor(appcompatR.attr.colorError, Color.RED)) {
append(error)
}
}
@@ -72,7 +72,7 @@ data class DownloadItemModel(
}
override fun compareTo(other: DownloadItemModel): Int {
return timestamp.compareTo(other.timestamp)
return timestamp compareTo other.timestamp
}
override fun areItemsTheSame(other: ListModel): Boolean {

View File

@@ -1,13 +1,15 @@
package org.koitharu.kotatsu.download.ui.list
import android.os.Bundle
import android.view.Gravity
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import coil3.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
@@ -17,7 +19,6 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
@@ -42,7 +43,7 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
setDisplayHomeAsUp(true, false)
val downloadsAdapter = DownloadsAdapter(this, coil, this)
val decoration = TypedListSpacingDecoration(this, false)
selectionController = ListSelectionController(
@@ -52,7 +53,6 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
callback = this,
)
with(viewBinding.recyclerView) {
consumeInsetsAsPadding(Gravity.START or Gravity.END or Gravity.BOTTOM)
setHasFixedSize(true)
addItemDecoration(decoration)
adapter = downloadsAdapter
@@ -68,6 +68,23 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
viewModel.hasCancellableWorks.observe(this, menuInvalidator)
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
viewBinding.recyclerView.updatePadding(
left = bars.left,
right = bars.right,
bottom = bars.bottom,
)
viewBinding.appbar.updatePadding(
left = bars.left,
right = bars.right,
top = bars.top,
)
return return WindowInsetsCompat.Builder(insets)
.setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE)
.build()
}
override fun onItemClick(item: DownloadItemModel, view: View) {
if (selectionController.onItemClick(item.id.mostSignificantBits)) {
return

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.util.ext.getItem
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import androidx.appcompat.R as appcompatR
import com.google.android.material.R as materialR
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 iconOffset = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_offset)
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(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74,

View File

@@ -25,6 +25,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ErrorReporterReceiver
import org.koitharu.kotatsu.core.LocalizedAppContext
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.util.ext.getDrawableOrThrow
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.runCatchingCancellable
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_SILENT = "download_bg"
@@ -70,7 +71,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
private val actionCancel by lazy {
NotificationCompat.Action(
materialR.drawable.material_ic_clear_black_24dp,
appcompatR.drawable.abc_ic_clear_material,
context.getString(android.R.string.cancel),
workManager.createCancelPendingIntent(uuid),
)
@@ -140,10 +141,10 @@ class DownloadNotificationFactory @AssistedInject constructor(
builder.setSubText(null)
builder.setShowWhen(false)
builder.setVisibility(
if (state != null && state.manga.isNsfw) {
NotificationCompat.VISIBILITY_PRIVATE
if (state != null && state.manga.isNsfw()) {
NotificationCompat.VISIBILITY_SECRET
} else {
NotificationCompat.VISIBILITY_PUBLIC
NotificationCompat.VISIBILITY_PRIVATE
},
)
when {

View File

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

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