Compare commits

...

164 Commits

Author SHA1 Message Date
Koitharu
dddb00d5ef Improve local manga chapter names 2025-01-11 14:54:02 +02:00
Koitharu
c9d878a0b7 Upgrade agp 2025-01-11 14:37:49 +02:00
Koitharu
dcb92ed1af Fix manga importing 2025-01-11 14:37:30 +02:00
Maple Javora
749bc4a837 Translated using Weblate (Czech)
Currently translated at 97.0% (758 of 781 strings)

Co-authored-by: Maple Javora <jindrous101@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translation: Kotatsu/Strings
2025-01-11 11:21:58 +01:00
Alvoracz
94807b7788 Translated using Weblate (Czech)
Currently translated at 97.0% (758 of 781 strings)

Translated using Weblate (Czech)

Currently translated at 94.2% (736 of 781 strings)

Co-authored-by: Alvoracz <sedlor@seznam.cz>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translation: Kotatsu/Strings
2025-01-11 11:21:58 +01:00
Maple Javora
0fe7c66850 Translated using Weblate (Czech)
Currently translated at 94.3% (737 of 781 strings)

Translated using Weblate (Czech)

Currently translated at 94.2% (736 of 781 strings)

Co-authored-by: Maple Javora <jindrous101@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translation: Kotatsu/Strings
2025-01-11 11:21:58 +01:00
Alvoracz
20cd8413dc Translated using Weblate (Czech)
Currently translated at 94.2% (736 of 781 strings)

Co-authored-by: Alvoracz <sedlor@seznam.cz>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translation: Kotatsu/Strings
2025-01-11 11:21:58 +01:00
தமிழ்நேரம்
30df4ede6c Translated using Weblate (Tamil)
Currently translated at 100.0% (781 of 781 strings)

Translated using Weblate (Tamil)

Currently translated at 100.0% (9 of 9 strings)

Added translation using Weblate (Tamil)

Added translation using Weblate (Tamil)

Co-authored-by: தமிழ்நேரம் <anishprabu.t@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ta/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ta/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2025-01-11 11:21:58 +01:00
Milan Bhandari
4aa6baf569 Translated using Weblate (Nepali)
Currently translated at 30.7% (240 of 781 strings)

Co-authored-by: Milan Bhandari <githubmilan@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ne/
Translation: Kotatsu/Strings
2025-01-11 11:21:58 +01:00
abdelbasset jabrane
d8a4303c50 Translated using Weblate (Arabic)
Currently translated at 100.0% (781 of 781 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (781 of 781 strings)

Co-authored-by: abdelbasset jabrane <ribago9317@cubene.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2025-01-11 11:21:58 +01:00
Макар Разин
b355e2ee88 Translated using Weblate (Russian)
Currently translated at 100.0% (781 of 781 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2025-01-11 11:21:58 +01:00
Infy's Tagalog Translations
55e3b5fb9b Translated using Weblate (Filipino)
Currently translated at 100.0% (781 of 781 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-01-11 11:21:58 +01:00
gekka
a59853e37a Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (781 of 781 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-01-11 11:21:58 +01:00
Milo Ivir
ccc665d218 Translated using Weblate (Croatian)
Currently translated at 99.7% (776 of 778 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
2025-01-11 11:21:58 +01:00
Anon
02650f5c2a Translated using Weblate (Serbian)
Currently translated at 98.5% (767 of 778 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2025-01-11 11:21:58 +01:00
Hugo Cardoso
24172a1137 Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.4% (774 of 778 strings)

Translated using Weblate (Portuguese)

Currently translated at 99.4% (774 of 778 strings)

Co-authored-by: Hugo Cardoso <hugocardosolopes@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2025-01-11 11:21:58 +01:00
Draken
034d69d490 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (781 of 781 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (781 of 781 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (778 of 778 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-01-11 11:21:58 +01:00
Justine Kyle Cobar
12fc0542d3 Translated using Weblate (Filipino)
Currently translated at 100.0% (778 of 778 strings)

Co-authored-by: Justine Kyle Cobar <cobarjustinekyle583@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2025-01-11 11:21:58 +01:00
Nicola Bortoletto
dcf80ed396 Translated using Weblate (Italian)
Currently translated at 100.0% (781 of 781 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (778 of 778 strings)

Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-01-11 11:21:58 +01:00
Dragibus Noir
28badb7f6c Translated using Weblate (French)
Currently translated at 100.0% (781 of 781 strings)

Translated using Weblate (French)

Currently translated at 100.0% (778 of 778 strings)

Translated using Weblate (French)

Currently translated at 99.8% (777 of 778 strings)

Co-authored-by: Dragibus Noir <dragibusnoir@pm.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2025-01-11 11:21:58 +01:00
Itsmechinmoy
19cc158ef8 Translated using Weblate (Assamese)
Currently translated at 100.0% (9 of 9 strings)

Added translation using Weblate (Assamese)

Co-authored-by: Itsmechinmoy <itsmechinmoy@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/as/
Translation: Kotatsu/plurals
2025-01-11 11:21:57 +01:00
大王叫我来巡山
a2eeae3319 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (778 of 778 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-01-11 11:21:57 +01:00
Frosted
c9336a753d Translated using Weblate (Turkish)
Currently translated at 100.0% (781 of 781 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (778 of 778 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-01-11 11:21:57 +01:00
Anonymous
ea23468ecd Translated using Weblate (French)
Currently translated at 99.2% (772 of 778 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2025-01-11 11:21:57 +01:00
Koitharu
143643fcd8 Fix crashes 2025-01-11 12:21:40 +02:00
Koitharu
25eb05d305 Fix local chapters deletion 2025-01-09 08:41:27 +02:00
Koitharu
bf217b3cbf Skip description for ParcelableManga 2025-01-09 08:32:53 +02:00
Koitharu
9e2b60e15e Fix pages cache usage 2025-01-09 08:26:43 +02:00
Koitharu
4dba90361c Fix crashes 2025-01-09 08:19:43 +02:00
Koitharu
8dea483f64 Fix drawables state 2025-01-05 10:17:40 +02:00
Koitharu
dc2e603356 Improve drawable and views state management 2025-01-04 16:22:13 +02:00
Koitharu
14973298a0 Emoji flags in details 2025-01-04 13:53:13 +02:00
Koitharu
7efc47724e Improve mime-type handling 2025-01-04 12:01:48 +02:00
Koitharu
c51218240e Fix settings menu 2025-01-02 15:45:17 +02:00
Koitharu
2762caaa8f Option to enable all sources 2025-01-02 15:38:43 +02:00
Koitharu
70d66e5a90 Merge branch 'master' into devel 2025-01-01 15:39:51 +02:00
Koitharu
3173e30caf Fix details cover corners 2025-01-01 13:49:14 +02:00
Koitharu
0dccc66f54 Fix settings search 2025-01-01 13:36:02 +02:00
Koitharu
6b3dd23c01 UI fixes 2025-01-01 12:36:52 +02:00
Koitharu
1c6a125174 Skip non-existing local chapters 2025-01-01 12:16:28 +02:00
Koitharu
15f37644c0 Update list badges 2024-12-30 09:48:50 +02:00
Koitharu
c2079ebca5 Update dependencies 2024-12-30 07:51:45 +02:00
Koitharu
1146269992 Merge branch 'weblate-kotatsu-strings' of github.com:weblate/Kotatsu into weblate-weblate-kotatsu-strings 2024-12-23 10:43:10 +02:00
Koitharu
099362d198 Update reader ui 2024-12-23 10:38:27 +02:00
Koitharu
22d203fc60 Fix Telegram backups uploading 2024-12-22 09:01:18 +02:00
Anonymous
19602144ef Translated using Weblate (Norwegian Nynorsk)
Currently translated at 48.8% (380 of 778 strings)

Translated using Weblate (Finnish)

Currently translated at 33.0% (257 of 778 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 36.6% (285 of 778 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nn/
Translation: Kotatsu/Strings
2024-12-22 05:01:27 +00:00
Lennard
44bbcd7fe3 Translated using Weblate (Lithuanian)
Currently translated at 5.6% (44 of 778 strings)

Translated using Weblate (Punjabi)

Currently translated at 4.2% (33 of 778 strings)

Translated using Weblate (Czech)

Currently translated at 86.7% (675 of 778 strings)

Translated using Weblate (Arabic)

Currently translated at 85.8% (668 of 778 strings)

Translated using Weblate (English)

Currently translated at 100.0% (778 of 778 strings)

Co-authored-by: Lennard <lennardsdrojek42@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/lt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pa/
Translation: Kotatsu/Strings
2024-12-22 05:01:25 +00:00
Frosted
efe5e07c2c Translated using Weblate (Turkish)
Currently translated at 100.0% (778 of 778 strings)

Translated using Weblate (Turkish)

Currently translated at 99.6% (775 of 778 strings)

Translated using Weblate (Turkish)

Currently translated at 98.7% (766 of 776 strings)

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-12-22 05:01:24 +00:00
Geovani Amaral
4e633ff735 Translated using Weblate (Portuguese)
Currently translated at 100.0% (776 of 776 strings)

Co-authored-by: Geovani Amaral <geovani.af4@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-12-22 05:01:21 +00:00
Dragibus Noir
fef8333763 Translated using Weblate (French)
Currently translated at 99.7% (774 of 776 strings)

Co-authored-by: Dragibus Noir <dragibusnoir@pm.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2024-12-22 05:01:20 +00:00
大王叫我来巡山
a741f8451a Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (778 of 778 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (776 of 776 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-12-22 05:01:18 +00:00
Draken
55baf5a3f3 Translated using Weblate (Vietnamese)
Currently translated at 99.8% (777 of 778 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (776 of 776 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (772 of 772 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-12-22 05:01:16 +00:00
Anon
6390774d86 Translated using Weblate (Serbian)
Currently translated at 99.0% (769 of 776 strings)

Translated using Weblate (Serbian)

Currently translated at 99.0% (769 of 776 strings)

Translated using Weblate (Serbian)

Currently translated at 99.2% (766 of 772 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-12-22 05:01:13 +00:00
gekka
51a5128e70 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (772 of 772 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-12-22 05:01:12 +00:00
Erekotr
53d81507e4 Translated using Weblate (Turkish)
Currently translated at 98.9% (764 of 772 strings)

Co-authored-by: Erekotr <ereko1ereko55@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-12-22 05:01:11 +00:00
gallegonovato
dcf7236ba2 Translated using Weblate (Spanish)
Currently translated at 100.0% (776 of 776 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (772 of 772 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-12-22 05:01:09 +00:00
Koitharu
a54744abc6 Option to clear manga database 2024-12-20 12:30:48 +02:00
Koitharu
22e2411c77 Cache chapters list (close #812) 2024-12-20 11:33:16 +02:00
Koitharu
3f66c142b8 Merge branch 'master' into devel 2024-12-19 18:30:16 +02:00
Koitharu
734846a018 Fix checking for new chapters in some cases (#1212, #1195, #1190) 2024-12-18 18:24:41 +02:00
Koitharu
e75035b33a Update parsers 2024-12-18 15:38:56 +02:00
Koitharu
f675c606a2 Refactor navigation 2024-12-18 12:00:25 +02:00
Koitharu
a5199e2f06 New favorite dialog 2024-12-16 19:17:25 +02:00
Koitharu
1b80e48ed4 Telegram backups refactoring stage 2 2024-12-15 09:44:57 +02:00
Koitharu
07e81f21c7 Telegram backups refactoring stage 1 2024-12-14 16:26:37 +02:00
Koitharu
0dbd01f6fc Merge branch 'MAPKOBKA135-devel' into devel 2024-12-14 15:48:18 +02:00
Koitharu
4b453b58dd Fix reader slider visibility 2024-12-14 15:48:09 +02:00
Koitharu
1575bb5242 Merge branch 'devel' of github.com:MAPKOBKA135/Kotatsu into MAPKOBKA135-devel 2024-12-14 15:47:41 +02:00
Infy's Tagalog Translations
55137cf899 Translated using Weblate (Filipino)
Currently translated at 99.4% (762 of 766 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-12-14 15:41:11 +02:00
Lennard
f190ff810e Translated using Weblate (German)
Currently translated at 83.3% (639 of 767 strings)

Translated using Weblate (German)

Currently translated at 83.0% (636 of 766 strings)

Co-authored-by: Lennard <lennardsdrojek42@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2024-12-14 15:41:11 +02:00
Anon
47c13b46f7 Translated using Weblate (Serbian)
Currently translated at 99.6% (761 of 764 strings)

Translated using Weblate (Serbian)

Currently translated at 99.6% (759 of 762 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-12-14 15:41:11 +02:00
Frosted
2ad9f38906 Translated using Weblate (Turkish)
Currently translated at 100.0% (762 of 762 strings)

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-12-14 15:41:11 +02:00
gekka
2783c62ace Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (766 of 766 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (762 of 762 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-12-14 15:41:11 +02:00
Draken
c1a65f8055 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (766 of 766 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (762 of 762 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-12-14 15:41:11 +02:00
大王叫我来巡山
6e5d8e99ca Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (762 of 762 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-12-14 15:41:11 +02:00
Dragibus Noir
020c3b8bba Translated using Weblate (French)
Currently translated at 99.8% (763 of 764 strings)

Translated using Weblate (French)

Currently translated at 99.8% (761 of 762 strings)

Translated using Weblate (French)

Currently translated at 99.7% (760 of 762 strings)

Co-authored-by: Dragibus Noir <dragibusnoir@pm.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2024-12-14 15:41:11 +02:00
Koitharu
76162a06e3 Reader ui updates 2024-12-14 15:39:13 +02:00
Koitharu
19f398d309 Merge branch 'master' into devel 2024-12-14 14:25:48 +02:00
Koitharu
25ae23963e Update reader interface 2024-12-14 09:26:01 +02:00
Koitharu
146ba95af6 Details activity improvements 2024-12-11 13:22:07 +02:00
Koitharu
ee10b013a1 Branch selection in chapters list 2024-12-08 19:26:18 +02:00
Koitharu
8c79df3d35 Details ui updates 2024-12-08 18:20:46 +02:00
Koitharu
2c2db1ca96 Rollback kotlin 2024-12-08 09:58:18 +02:00
Koitharu
f556c0b127 Merge branch 'master' into devel 2024-12-07 15:55:32 +02:00
Koitharu
66645d93f8 Update parsers 2024-12-05 15:52:16 +02:00
Koitharu
f2582bce1d Update dependencies 2024-12-03 14:36:47 +02:00
Mac135135
3ef7c6adb0 Added an periodical backup to the telegram bot 2024-11-10 15:11:40 +03:00
Mac135135
62e7e5d8c3 Merge remote-tracking branch 'origin/devel' into devel 2024-11-10 14:19:06 +03:00
Mac135135
30e43d3bfe Merge remote-tracking branch 'origin/devel' into devel
# Conflicts:
#	app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt
#	app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt
2024-11-07 23:47:47 +03:00
Mac135135
0162eaed97 Merge remote-tracking branch 'origin/devel' into devel
# Conflicts:
#	app/src/main/res/values-es/strings.xml
#	app/src/main/res/values-zh-rCN/strings.xml
2024-09-30 01:10:25 +03:00
Koitharu
15ca4111c0 Reapply "Update sources catalog ui"
This reverts commit 8d5bde6e60.
2024-09-30 01:09:00 +03:00
Koitharu
dc45e0f5df Revert "Update sources catalog ui"
This reverts commit 597ad01e8f.
2024-09-30 01:09:00 +03:00
Koitharu
09b6a967a1 Refactor descrambling bitmap 2024-09-30 01:09:00 +03:00
AwkwardPeak7
1cff0eeac4 implement basic methods for descrambling images 2024-09-30 01:09:00 +03:00
Kristian de Frutos
44349c4ede Translated using Weblate (Czech)
Currently translated at 100.0% (636 of 636 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (636 of 636 strings)

Translated using Weblate (Czech)

Currently translated at 83.0% (528 of 636 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Kristian de Frutos <kristiandef@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/cs/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-09-30 01:09:00 +03:00
Koitharu
8e8953b07f Skip error for local manga list (close #1113, close #1115) 2024-09-30 01:09:00 +03:00
Felipe Nascimento
150e3d554f Translated using Weblate (Portuguese)
Currently translated at 98.6% (718 of 728 strings)

Co-authored-by: Felipe Nascimento <f.kgb@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-09-30 01:08:58 +03:00
Draken
be3b5a1897 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (728 of 728 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (724 of 724 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-09-30 01:08:58 +03:00
Matt
9be0e8595f Translated using Weblate (Japanese)
Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Matt <contact.mattdev@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ja/
Translation: Kotatsu/plurals
2024-09-30 01:08:56 +03:00
Oğuz Ersen
f38370592e Translated using Weblate (Turkish)
Currently translated at 100.0% (728 of 728 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (724 of 724 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-09-30 01:08:56 +03:00
Koitharu
6a54d42867 Update SSIV 2024-09-30 01:08:53 +03:00
Mac135135
49d29ae675 Added an periodical backup to the telegram bot 2024-09-30 01:08:53 +03:00
Mac135135
27d7a6a8cb Added an periodical backup to the telegram bot 2024-09-30 01:08:53 +03:00
Koitharu
e8d04644f8 Remove loggers and reorganize settings 2024-09-30 01:08:52 +03:00
Mac135135
26b512d42e Added an periodical backup to the telegram bot 2024-09-30 01:08:48 +03:00
Koitharu
4fb3173185 Update readme 2024-09-30 01:06:32 +03:00
Koitharu
826587b2c9 Translated using Weblate (Russian)
Currently translated at 99.7% (722 of 724 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-09-30 01:06:32 +03:00
Hosted Weblate
4efdb1d8d1 Update translation files
Updated by "Remove blank strings" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/
Translation: Kotatsu/Strings
2024-09-30 01:06:30 +03:00
Infy's Tagalog Translations
1b9f886d1b Translated using Weblate (Filipino)
Currently translated at 98.4% (713 of 724 strings)

Translated using Weblate (Filipino)

Currently translated at 98.8% (712 of 720 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-09-30 01:06:30 +03:00
Felipe Nascimento
3241ae5db5 Translated using Weblate (Portuguese)
Currently translated at 98.7% (711 of 720 strings)

Co-authored-by: Felipe Nascimento <f.kgb@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-09-30 01:06:30 +03:00
Макар Разин
30f1b2c73a Translated using Weblate (Russian)
Currently translated at 100.0% (720 of 720 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (720 of 720 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-09-30 01:06:30 +03:00
Koitharu
8d35101e98 Update parsers 2024-09-30 01:06:30 +03:00
Koitharu
41cfd99d32 Fix applying filter 2024-09-30 01:06:30 +03:00
Koitharu
c8d04e4eb7 Migrate external sources to new filter 2024-09-30 01:06:29 +03:00
Koitharu
956831f9d7 Fix sync auth activity ui 2024-09-30 01:06:29 +03:00
Draken
d65874080b Translated using Weblate (Vietnamese)
Currently translated at 100.0% (720 of 720 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-09-30 01:06:29 +03:00
Infy's Tagalog Translations
bf35a8ffd7 Translated using Weblate (Filipino)
Currently translated at 98.8% (712 of 720 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-09-30 01:06:29 +03:00
Oğuz Ersen
eeb8dd8c5b Translated using Weblate (Turkish)
Currently translated at 100.0% (720 of 720 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-09-30 01:06:25 +03:00
Faiz Faadhillah
299093f863 Improve Spen integration support 2024-09-30 01:06:25 +03:00
Koitharu
86dea2953a Update parsers 2024-09-30 01:06:25 +03:00
gallegonovato
81794e6eb2 Translated using Weblate (Spanish)
Currently translated at 100.0% (720 of 720 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
2024-09-30 01:06:25 +03:00
Draken
d43887e288 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (717 of 717 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (692 of 692 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-09-30 01:05:59 +03:00
Oğuz Ersen
e2cf22e054 Translated using Weblate (Turkish)
Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (717 of 717 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (710 of 710 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (692 of 692 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-09-30 01:05:59 +03:00
Koitharu
5a75fe77fd Various fixes 2024-09-30 01:05:41 +03:00
Koitharu
8c0617c525 Context menus 2024-09-30 01:05:41 +03:00
Anonymous
38b8966c16 Translated using Weblate (Hungarian)
Currently translated at 86.6% (622 of 718 strings)

Translated using Weblate (Nepali)

Currently translated at 32.3% (232 of 718 strings)

Translated using Weblate (Hindi)

Currently translated at 93.0% (668 of 718 strings)

Translated using Weblate (Portuguese)

Currently translated at 92.7% (666 of 718 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
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/ne/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-09-30 01:05:41 +03:00
desu sude
59f4ff8a3e Translated using Weblate (Latvian)
Currently translated at 24.4% (175 of 717 strings)

Co-authored-by: desu sude <cobsonslittlecocksleeve@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/lv/
Translation: Kotatsu/Strings
2024-09-30 01:05:41 +03:00
Anon
357263b496 Translated using Weblate (Serbian)
Currently translated at 100.0% (698 of 698 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-09-30 01:05:41 +03:00
Milo Ivir
4af6fc165b Translated using Weblate (Croatian)
Currently translated at 98.7% (689 of 698 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
2024-09-30 01:05:40 +03:00
Infy's Tagalog Translations
a4de58b9b3 Translated using Weblate (Filipino)
Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (698 of 698 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-09-30 01:05:40 +03:00
abc0922001
5696ad7fa2 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 92.1% (643 of 698 strings)

Co-authored-by: abc0922001 <abc0922001@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2024-09-30 01:05:40 +03:00
Amirreza Safavi
63bfca6d3e Translated using Weblate (Persian)
Currently translated at 41.6% (288 of 692 strings)

Co-authored-by: Amirreza Safavi <amirxcatsanddragons@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
2024-09-30 01:05:40 +03:00
gekka
0fecf996e1 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.7% (716 of 718 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.7% (696 of 698 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.7% (696 of 698 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.8% (691 of 692 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-09-30 01:05:40 +03:00
Shayan
3df2682332 Translated using Weblate (Persian)
Currently translated at 41.6% (288 of 692 strings)

Co-authored-by: Shayan <shayans31516@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
2024-09-30 01:05:40 +03:00
Amirreza Safavi
dd9df6e9dc Translated using Weblate (Persian)
Currently translated at 41.6% (288 of 692 strings)

Co-authored-by: Amirreza Safavi <amirxcatsanddragons@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
2024-09-30 01:05:40 +03:00
gallegonovato
0889c2cc28 Translated using Weblate (Spanish)
Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (717 of 717 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (692 of 692 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-09-30 01:05:40 +03:00
Draken
010b1264ae Translated using Weblate (Vietnamese)
Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (717 of 717 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (692 of 692 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-09-30 01:05:40 +03:00
Oğuz Ersen
66ff32e14d Translated using Weblate (Turkish)
Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (717 of 717 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (710 of 710 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (692 of 692 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-09-30 01:05:40 +03:00
Fikri Akbar
addb642cc9 Translated using Weblate (Indonesian)
Currently translated at 99.8% (688 of 689 strings)

Co-authored-by: Fikri Akbar <akbarfikri1221@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-09-30 01:05:40 +03:00
Koitharu
720c389dbd Search in history, favorites and local 2024-09-30 01:05:40 +03:00
Koitharu
2191d9c83b Fix sources catalog content types 2024-09-30 01:05:40 +03:00
Koitharu
0ee1cda0e4 Local manga source filter 2024-09-30 01:05:40 +03:00
Koitharu
90226b7b78 Update supported domains 2024-09-30 01:05:39 +03:00
Koitharu
6d84294533 Improve quick filters 2024-09-30 01:05:39 +03:00
Koitharu
36bd3cc438 Local manga index in database 2024-09-30 01:05:39 +03:00
Koitharu
e0c983f4eb Search manga with filters 2024-09-30 01:05:39 +03:00
Koitharu
ea5ce23335 Improve filter 2024-09-30 01:05:39 +03:00
Koitharu
26a33e5d9d Add new filter fields 2024-09-30 01:05:39 +03:00
Koitharu
9ab7159cb9 Update parsers and filters 2024-09-30 01:05:39 +03:00
Koitharu
ad21321a1d Update parsers 2024-09-30 01:05:39 +03:00
Koitharu
fe2bb05895 Update dependencies 2024-09-30 01:05:39 +03:00
Koitharu
e48beae324 Batch manga fix functionality 2024-09-30 01:05:39 +03:00
Amirreza Safavi
10109ab2c0 Translated using Weblate (Persian)
Currently translated at 77.7% (7 of 9 strings)

Translated using Weblate (Persian)

Currently translated at 37.4% (258 of 689 strings)

Co-authored-by: Amirreza Safavi <amirxcatsanddragons@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fa/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-09-30 01:05:38 +03:00
Anon
df17bb5af8 Translated using Weblate (Serbian)
Currently translated at 100.0% (689 of 689 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-09-30 01:05:38 +03:00
Henrique
b4592015fb Translated using Weblate (Portuguese (Brazil))
Currently translated at 96.6% (666 of 689 strings)

Co-authored-by: Henrique <heluis110@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-09-30 01:05:38 +03:00
Макар Разин
3fe9ec6918 Translated using Weblate (Russian)
Currently translated at 100.0% (689 of 689 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (689 of 689 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-09-30 01:05:38 +03:00
Dilip Patel
23ac9df844 grammar fix 2024-09-30 01:05:38 +03:00
Koitharu
c480992f63 Option to automatically download new chapters (close #425, close #602, close #955) 2024-09-30 01:05:38 +03:00
Koitharu
85d397def0 Update dependencies 2024-09-30 01:05:38 +03:00
Mac135135
7c74c87524 Merge remote-tracking branch 'origin/devel' into devel 2024-09-07 15:26:13 +03:00
Mac135135
f86ee7d5c2 Merge remote-tracking branch 'origin/devel' into devel 2024-08-04 19:46:52 +03:00
Mac135135
6e5519419d Merge remote-tracking branch 'origin/devel' into devel 2024-07-31 19:16:41 +03:00
Mac135135
2c53b63847 Merge remote-tracking branch 'origin/devel' into devel
# Conflicts:
#	app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
2024-07-11 01:35:18 +03:00
Mac135135
45b5e48676 Add functionality to expand manga title on click 2024-07-05 21:23:15 +03:00
315 changed files with 6409 additions and 3785 deletions

6
.idea/AndroidProjectSystem.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

View File

@@ -18,8 +18,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 35
versionCode = 697
versionName = '7.7.5'
versionCode = 700
versionName = '8.0-a1'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
@@ -74,6 +74,7 @@ android {
'-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=coil3.annotation.ExperimentalCoilApi',
'-opt-in=coil3.annotation.InternalCoilApi',
]
}
lint {

View File

@@ -46,7 +46,7 @@
android:allowBackup="true"
android:backupAgent="org.koitharu.kotatsu.settings.backup.AppBackupAgent"
android:dataExtractionRules="@xml/backup_rules"
android:enableOnBackInvokedCallback="true"
android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled"
android:fullBackupContent="@xml/backup_content"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher"
@@ -279,6 +279,10 @@
<service
android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService"
android:label="@string/local_manga_processing" />
<service
android:name="org.koitharu.kotatsu.local.ui.ImportService"
android:foregroundServiceType="dataSync"
android:label="@string/importing_manga" />
<service
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
android:label="@string/manga_shelf"

View File

@@ -29,8 +29,9 @@ class AutoFixUseCase @Inject constructor(
) {
suspend operator fun invoke(mangaId: Long): Pair<Manga, Manga?> {
val seed = checkNotNull(mangaDataRepository.findMangaById(mangaId)) { "Manga $mangaId not found" }
.getDetailsSafe()
val seed = checkNotNull(
mangaDataRepository.findMangaById(mangaId, withChapters = true),
) { "Manga $mangaId not found" }.getDetailsSafe()
if (seed.isHealthy()) {
return seed to null // no fix required
}

View File

@@ -1,7 +1,5 @@
package org.koitharu.kotatsu.alternatives.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
@@ -13,8 +11,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
@@ -22,7 +19,6 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
@@ -30,8 +26,6 @@ import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.search.ui.MangaListActivity
import javax.inject.Inject
@AndroidEntryPoint
@@ -65,7 +59,7 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
viewModel.content.observe(this, listAdapter)
viewModel.onMigrated.observeEvent(this) {
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
startActivity(DetailsActivity.newIntent(this, it))
router.openDetails(it)
finishAfterTransition()
}
}
@@ -82,16 +76,9 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
override fun onItemClick(item: MangaAlternativeModel, view: View) {
when (view.id) {
R.id.chip_source -> startActivity(
MangaListActivity.newIntent(
this,
item.manga.source,
MangaListFilter(query = viewModel.manga.title),
),
)
R.id.chip_source -> router.openSearch(item.manga.source, viewModel.manga.title)
R.id.button_migrate -> confirmMigration(item.manga)
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
else -> router.openDetails(item.manga)
}
}
@@ -114,10 +101,4 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
}
}.show()
}
companion object {
fun newIntent(context: Context, manga: Manga) = Intent(context, AlternativesActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
}
}

View File

@@ -13,7 +13,7 @@ import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
@@ -40,7 +40,7 @@ class AlternativesViewModel @Inject constructor(
private val settings: AppSettings,
) : BaseViewModel() {
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
val manga = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga
val onMigrated = MutableEventFlow<Manga>()
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))

View File

@@ -10,7 +10,6 @@ import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import coil3.ImageLoader
import coil3.request.ImageRequest
@@ -20,13 +19,13 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
import org.koitharu.kotatsu.core.ErrorReporterReceiver
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
@@ -122,7 +121,7 @@ class AutoFixService : CoroutineIntentService() {
).toBitmapOrNull(),
)
notification.setSubText(replacement.title)
val intent = DetailsActivity.newIntent(applicationContext, replacement)
val intent = AppRouter.detailsIntent(applicationContext, replacement)
notification.setContentIntent(
PendingIntentCompat.getActivity(
applicationContext,

View File

@@ -1,7 +1,5 @@
package org.koitharu.kotatsu.bookmarks.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.graphics.Insets
@@ -46,9 +44,4 @@ class AllBookmarksActivity :
right = insets.right,
)
}
companion object {
fun newIntent(context: Context) = Intent(context, AllBookmarksActivity::class.java)
}
}

View File

@@ -20,6 +20,8 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.nav.ReaderIntent
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
@@ -30,7 +32,6 @@ 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.databinding.FragmentListSimpleBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.GridSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
@@ -39,7 +40,6 @@ import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import javax.inject.Inject
@AndroidEntryPoint
@@ -115,26 +115,26 @@ class AllBookmarksFragment :
override fun onItemClick(item: Bookmark, view: View) {
if (selectionController?.onItemClick(item.pageId) != true) {
val intent = ReaderActivity.IntentBuilder(view.context)
val intent = ReaderIntent.Builder(view.context)
.bookmark(item)
.incognito(true)
.build()
startActivity(intent)
router.openReader(intent)
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
}
override fun onListHeaderClick(item: ListHeader, view: View) {
val manga = item.payload as? Manga ?: return
startActivity(DetailsActivity.newIntent(view.context, manga))
router.openDetails(manga)
}
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
return selectionController?.onItemLongClick(view, item.pageId) ?: false
return selectionController?.onItemLongClick(view, item.pageId) == true
}
override fun onItemContextClick(item: Bookmark, view: View): Boolean {
return selectionController?.onItemContextClick(view, item.pageId) ?: false
return selectionController?.onItemContextClick(view, item.pageId) == true
}
override fun onRetryClick(error: Throwable) = Unit
@@ -208,16 +208,4 @@ class AllBookmarksFragment :
invalidateSpanIndexCache()
}
}
companion object {
@Deprecated(
"",
ReplaceWith(
"BookmarksFragment()",
"org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment",
),
)
fun newInstance() = AllBookmarksFragment()
}
}

View File

@@ -1,27 +1,23 @@
package org.koitharu.kotatsu.browser
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.webkit.CookieManager
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
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.toUriOrNull
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -42,11 +38,10 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
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)
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
viewBinding.webView.webViewClient = BrowserClient(this)
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
@@ -59,7 +54,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
finishAfterTransition()
} else {
onTitleChanged(
intent?.getStringExtra(EXTRA_TITLE) ?: getString(R.string.loading_),
intent?.getStringExtra(AppRouter.KEY_TITLE) ?: getString(R.string.loading_),
url,
)
viewBinding.webView.loadUrl(url)
@@ -80,14 +75,8 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
}
R.id.action_browser -> {
val url = viewBinding.webView.url?.toUriOrNull()
if (url != null) {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = url
try {
startActivity(Intent.createChooser(intent, item.title))
} catch (_: ActivityNotFoundException) {
}
if (!router.openExternalBrowser(viewBinding.webView.url.orEmpty(), item.title)) {
Snackbar.make(viewBinding.webView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
}
true
}
@@ -136,17 +125,4 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
bottom = insets.bottom,
)
}
companion object {
private const val EXTRA_TITLE = "title"
private const val EXTRA_SOURCE = "source"
fun newIntent(context: Context, url: String, source: MangaSource?, title: String?): Intent {
return Intent(context, BrowserActivity::class.java)
.setData(Uri.parse(url))
.putExtra(EXTRA_TITLE, title)
.putExtra(EXTRA_SOURCE, source?.name)
}
}
}

View File

@@ -17,6 +17,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -38,7 +39,7 @@ class CaptchaNotifier(
.build()
manager.createNotificationChannel(channel)
val intent = CloudFlareActivity.newIntent(context, exception)
val intent = AppRouter.cloudFlareResolveIntent(context, exception)
.setData(exception.url.toUri())
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(channel.name)

View File

@@ -1,15 +1,12 @@
package org.koitharu.kotatsu.browser.cloudflare
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.webkit.CookieManager
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.graphics.Insets
import androidx.core.net.toUri
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
@@ -19,19 +16,17 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.yield
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.network.CommonHeaders
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.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -62,12 +57,11 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
return
}
cfClient = CloudFlareClient(cookieJar, this, url)
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
viewBinding.webView.configureForParser(intent?.getStringExtra(AppRouter.KEY_USER_AGENT))
viewBinding.webView.webViewClient = cfClient
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
onBackPressedDispatcher.addCallback(it)
}
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
if (savedInstanceState == null) {
onTitleChanged(getString(R.string.loading_), url)
viewBinding.webView.loadUrl(url)
@@ -140,7 +134,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
override fun onCheckPassed() {
pendingResult = RESULT_OK
val source = intent?.getStringExtra(ARG_SOURCE)
val source = intent?.getStringExtra(AppRouter.KEY_SOURCE)
if (source != null) {
CaptchaNotifier(this).dismiss(MangaSource(source))
}
@@ -182,38 +176,16 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
class Contract : ActivityResultContract<CloudFlareProtectedException, Boolean>() {
override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent {
return newIntent(context, input)
return AppRouter.cloudFlareResolveIntent(context, input)
}
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
return resultCode == Activity.RESULT_OK
return resultCode == RESULT_OK
}
}
companion object {
const val TAG = "CloudFlareActivity"
private const val ARG_UA = "ua"
private const val ARG_SOURCE = "_source"
fun newIntent(context: Context, exception: CloudFlareProtectedException) = newIntent(
context = context,
url = exception.url,
source = exception.source,
headers = exception.headers,
)
private fun newIntent(
context: Context,
url: String,
source: MangaSource?,
headers: Headers?,
) = Intent(context, CloudFlareActivity::class.java).apply {
data = url.toUri()
putExtra(ARG_SOURCE, source?.name)
headers?.get(CommonHeaders.USER_AGENT)?.let {
putExtra(ARG_UA, it)
}
}
}
}

View File

@@ -8,6 +8,7 @@ import android.net.Uri
import android.os.BadParcelableException
import androidx.core.app.PendingIntentCompat
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.report
@@ -15,20 +16,19 @@ import org.koitharu.kotatsu.core.util.ext.report
class ErrorReporterReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val e = intent?.getSerializableExtraCompat<Throwable>(EXTRA_ERROR) ?: return
val e = intent?.getSerializableExtraCompat<Throwable>(AppRouter.KEY_ERROR) ?: return
e.report()
}
companion object {
private const val EXTRA_ERROR = "err"
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = try {
val intent = Intent(context, ErrorReporterReceiver::class.java)
intent.setAction(ACTION_REPORT)
intent.setData(Uri.parse("err://${e.hashCode()}"))
intent.putExtra(EXTRA_ERROR, e)
intent.putExtra(AppRouter.KEY_ERROR, e)
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
} catch (e: BadParcelableException) {
e.printStackTraceDebug()

View File

@@ -0,0 +1,93 @@
package org.koitharu.kotatsu.core.backup
import android.content.Context
import androidx.annotation.CheckResult
import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.Response
import okhttp3.internal.closeQuietly
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.network.BaseHttpClient
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.parseJson
import java.io.File
import javax.inject.Inject
class TelegramBackupUploader @Inject constructor(
private val settings: AppSettings,
@BaseHttpClient private val client: OkHttpClient,
@ApplicationContext private val context: Context,
) {
private val botToken = context.getString(R.string.tg_backup_bot_token)
suspend fun uploadBackup(file: File) {
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
val multipartBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("chat_id", requireChatId())
.addFormDataPart("document", file.name, requestBody)
.build()
val request = Request.Builder()
.url(urlOf("sendDocument").build())
.post(multipartBody)
.build()
client.newCall(request).await().consume()
}
suspend fun sendTestMessage() {
val request = Request.Builder()
.url(urlOf("getMe").build())
.build()
client.newCall(request).await().consume()
sendMessage(context.getString(R.string.backup_tg_echo))
}
@CheckResult
fun openBotInApp(router: AppRouter): Boolean {
val botUsername = context.getString(R.string.tg_backup_bot_name)
return router.openExternalBrowser("tg://resolve?domain=$botUsername") ||
router.openExternalBrowser("https://t.me/$botUsername")
}
private suspend fun sendMessage(message: String) {
val url = urlOf("sendMessage")
.addQueryParameter("chat_id", requireChatId())
.addQueryParameter("text", message)
.build()
val request = Request.Builder()
.url(url)
.build()
client.newCall(request).await().consume()
}
private fun requireChatId() = checkNotNull(settings.backupTelegramChatId) {
"Telegram chat ID not set in settings"
}
private fun Response.consume() {
if (isSuccessful) {
closeQuietly()
return
}
val jo = parseJson()
if (!jo.getBooleanOrDefault("ok", true)) {
throw RuntimeException(jo.getStringOrNull("description"))
}
}
private fun urlOf(method: String) = HttpUrl.Builder()
.scheme("https")
.host("api.telegram.org")
.addPathSegment("bot$botToken")
.addPathSegment(method)
}

View File

@@ -12,11 +12,13 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
import org.koitharu.kotatsu.core.db.dao.ChaptersDao
import org.koitharu.kotatsu.core.db.dao.MangaDao
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
import org.koitharu.kotatsu.core.db.dao.PreferencesDao
import org.koitharu.kotatsu.core.db.dao.TagsDao
import org.koitharu.kotatsu.core.db.dao.TrackLogsDao
import org.koitharu.kotatsu.core.db.entity.ChapterEntity
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
@@ -36,6 +38,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
import org.koitharu.kotatsu.core.db.migrations.Migration21To22
import org.koitharu.kotatsu.core.db.migrations.Migration22To23
import org.koitharu.kotatsu.core.db.migrations.Migration23To24
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
@@ -63,14 +66,14 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 23
const val DATABASE_VERSION = 24
@Database(
entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class,
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, ChapterEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class,
TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, ScrobblingEntity::class,
MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class,
],
version = DATABASE_VERSION,
)
@@ -103,6 +106,8 @@ abstract class MangaDatabase : RoomDatabase() {
abstract fun getStatsDao(): StatsDao
abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao
abstract fun getChaptersDao(): ChaptersDao
}
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
@@ -128,6 +133,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration20To21(),
Migration21To22(),
Migration22To23(),
Migration23To24(),
)
fun MangaDatabase(context: Context): MangaDatabase = Room

View File

@@ -7,3 +7,4 @@ const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories"
const val TABLE_HISTORY = "history"
const val TABLE_MANGA_TAGS = "manga_tags"
const val TABLE_SOURCES = "sources"
const val TABLE_CHAPTERS = "chapters"

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.core.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import org.koitharu.kotatsu.core.db.entity.ChapterEntity
@Dao
abstract class ChaptersDao {
@Query("SELECT * FROM chapters WHERE manga_id = :mangaId ORDER BY `index` ASC")
abstract suspend fun findAll(mangaId: Long): List<ChapterEntity>
@Query("DELETE FROM chapters WHERE manga_id = :mangaId")
abstract suspend fun deleteAll(mangaId: Long)
@Query("DELETE FROM chapters WHERE manga_id NOT IN (SELECT manga_id FROM history WHERE deleted_at = 0) AND manga_id NOT IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)")
abstract suspend fun gc()
@Transaction
open suspend fun replaceAll(mangaId: Long, entities: Collection<ChapterEntity>) {
deleteAll(mangaId)
insert(entities)
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insert(entities: Collection<ChapterEntity>)
}

View File

@@ -20,6 +20,9 @@ abstract class MangaDao {
@Query("SELECT * FROM manga WHERE manga_id = :id")
abstract suspend fun find(id: Long): MangaWithTags?
@Query("SELECT EXISTS(SELECT * FROM manga WHERE manga_id = :id)")
abstract suspend operator fun contains(id: Long): Boolean
@Transaction
@Query("SELECT * FROM manga WHERE public_url = :publicUrl")
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
@@ -55,6 +58,19 @@ abstract class MangaDao {
@Delete
abstract suspend fun delete(subjects: Collection<MangaEntity>)
@Query(
"""
DELETE FROM manga WHERE NOT EXISTS(SELECT * FROM history WHERE history.manga_id == manga.manga_id)
AND NOT EXISTS(SELECT * FROM favourites WHERE favourites.manga_id == manga.manga_id)
AND NOT EXISTS(SELECT * FROM bookmarks WHERE bookmarks.manga_id == manga.manga_id)
AND NOT EXISTS(SELECT * FROM suggestions WHERE suggestions.manga_id == manga.manga_id)
AND NOT EXISTS(SELECT * FROM scrobblings WHERE scrobblings.manga_id == manga.manga_id)
AND NOT EXISTS(SELECT * FROM local_index WHERE local_index.manga_id == manga.manga_id)
AND manga.manga_id NOT IN (:idsToKeep)
""",
)
abstract suspend fun cleanup(idsToKeep: Set<Long>)
@Transaction
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
upsert(manga)

View File

@@ -10,7 +10,6 @@ import androidx.room.Upsert
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
@@ -61,21 +60,11 @@ abstract class MangaSourcesDao {
@Query("SELECT * FROM sources WHERE pinned = 1")
abstract suspend fun findAllPinned(): List<MangaSourceEntity>
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
val orderBy = getOrderBy(order)
fun observeAll(enabledOnly: Boolean, order: SourcesSortOrder): Flow<List<MangaSourceEntity>> =
observeImpl(getQuery(enabledOnly, order))
@Language("RoomSql")
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
return observeImpl(query)
}
suspend fun findAllEnabled(order: SourcesSortOrder): List<MangaSourceEntity> {
val orderBy = getOrderBy(order)
@Language("RoomSql")
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
return findAllImpl(query)
}
suspend fun findAll(enabledOnly: Boolean, order: SourcesSortOrder): List<MangaSourceEntity> =
findAllImpl(getQuery(enabledOnly, order))
@Transaction
open suspend fun setEnabled(source: String, isEnabled: Boolean) {
@@ -101,6 +90,17 @@ abstract class MangaSourcesDao {
@RawQuery
protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List<MangaSourceEntity>
private fun getQuery(enabledOnly: Boolean, order: SourcesSortOrder) = SimpleSQLiteQuery(
buildString {
append("SELECT * FROM sources ")
if (enabledOnly) {
append("WHERE enabled = 1 ")
}
append("ORDER BY pinned DESC, ")
append(getOrderBy(order))
},
)
private fun getOrderBy(order: SourcesSortOrder) = when (order) {
SourcesSortOrder.ALPHABETIC -> "source ASC"
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"

View File

@@ -0,0 +1,32 @@
package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import org.koitharu.kotatsu.core.db.TABLE_CHAPTERS
@Entity(
tableName = TABLE_CHAPTERS,
primaryKeys = ["manga_id", "chapter_id"],
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class ChapterEntity(
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "number") val number: Float,
@ColumnInfo(name = "volume") val volume: Int,
@ColumnInfo(name = "url") val url: String,
@ColumnInfo(name = "scanlator") val scanlator: String?,
@ColumnInfo(name = "upload_date") val uploadDate: Long,
@ColumnInfo(name = "branch") val branch: String?,
@ColumnInfo(name = "source") val source: String,
@ColumnInfo(name = "index") val index: Int,
)

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.db.entity
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -21,7 +22,7 @@ fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
fun Collection<TagEntity>.toMangaTagsList() = map(TagEntity::toMangaTag)
fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
fun MangaEntity.toManga(tags: Set<MangaTag>, chapters: List<ChapterEntity>?) = Manga(
id = this.id,
title = this.title,
altTitle = this.altTitle,
@@ -35,12 +36,27 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
author = this.author,
source = MangaSource(this.source),
tags = tags,
chapters = chapters?.toMangaChapters(),
)
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
fun MangaWithTags.toManga(chapters: List<ChapterEntity>? = null) = manga.toManga(tags.toMangaTags(), chapters)
fun Collection<MangaWithTags>.toMangaList() = map { it.toManga() }
fun ChapterEntity.toMangaChapter() = MangaChapter(
id = chapterId,
name = name,
number = number,
volume = volume,
url = url,
scanlator = scanlator,
uploadDate = uploadDate,
branch = branch,
source = MangaSource(source),
)
fun Collection<ChapterEntity>.toMangaChapters() = map { it.toMangaChapter() }
// Model to entity
fun Manga.toEntity() = MangaEntity(
@@ -67,6 +83,22 @@ fun MangaTag.toEntity() = TagEntity(
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
fun Iterable<IndexedValue<MangaChapter>>.toEntities(mangaId: Long) = map { (index, chapter) ->
ChapterEntity(
chapterId = chapter.id,
mangaId = mangaId,
name = chapter.name,
number = chapter.number,
volume = chapter.volume,
url = chapter.url,
scanlator = chapter.scanlator,
uploadDate = chapter.uploadDate,
branch = chapter.branch,
source = chapter.source.name,
index = index,
)
}
// Other
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration23To24 : Migration(23, 24) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS `chapters` (`chapter_id` INTEGER NOT NULL, `manga_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` REAL NOT NULL, `volume` INTEGER NOT NULL, `url` TEXT NOT NULL, `scanlator` TEXT, `upload_date` INTEGER NOT NULL, `branch` TEXT, `source` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `chapter_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
}
}

View File

@@ -1,3 +1,5 @@
package org.koitharu.kotatsu.core.exceptions
class CaughtException(cause: Throwable) : RuntimeException("${cause.javaClass.simpleName}(${cause.message})", cause)
class CaughtException(
override val cause: Throwable
) : RuntimeException("${cause.javaClass.simpleName}(${cause.message})", cause)

View File

@@ -0,0 +1,5 @@
package org.koitharu.kotatsu.core.exceptions
import okio.IOException
class WrapperIOException(override val cause: Exception) : IOException(cause)

View File

@@ -6,7 +6,6 @@ import androidx.core.util.Consumer
import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.parsers.exception.ParseException
@@ -32,10 +31,10 @@ class DialogErrorObserver(
if (canResolve(value)) {
dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener)
} else if (value is ParseException) {
val fm = fragmentManager
if (fm != null && value.isSerializable()) {
val router = router()
if (router != null && value.isSerializable()) {
dialogBuilder.setPositiveButton(R.string.details) { _, _ ->
ErrorDetailsDialog.show(fm, value, value.url)
router.showErrorDialog(value)
}
}
}

View File

@@ -4,6 +4,7 @@ import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.util.Consumer
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.LifecycleOwner
@@ -11,6 +12,7 @@ import androidx.lifecycle.coroutineScope
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
@@ -33,6 +35,8 @@ abstract class ErrorObserver(
return resolver != null && ExceptionResolver.canResolve(error)
}
protected fun router() = fragment?.router ?: (activity as? FragmentActivity)?.router
private fun isAlive(): Boolean {
return when {
fragment != null -> fragment.view != null
@@ -44,7 +48,7 @@ abstract class ErrorObserver(
protected fun resolve(error: Throwable) {
if (isAlive()) {
lifecycleScope.launch {
val isResolved = resolver?.resolve(error) ?: false
val isResolved = resolver?.resolve(error) == true
if (isActive) {
onResolved?.accept(isResolved)
}

View File

@@ -5,19 +5,20 @@ import android.widget.Toast
import androidx.activity.result.ActivityResultCaller
import androidx.annotation.StringRes
import androidx.collection.MutableScatterMap
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.util.ext.restartApplication
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
@@ -26,7 +27,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import java.security.cert.CertPathValidatorException
import javax.inject.Provider
@@ -49,8 +49,8 @@ class ExceptionResolver @AssistedInject constructor(
handleActivityResult(CloudFlareActivity.TAG, it)
}
fun showDetails(e: Throwable, url: String?) {
ErrorDetailsDialog.show(host.getChildFragmentManager(), e, url)
fun showErrorDetails(e: Throwable, url: String? = null) {
host.router()?.showErrorDialog(e, url)
}
suspend fun resolve(e: Throwable): Boolean = when (e) {
@@ -63,9 +63,7 @@ class ExceptionResolver @AssistedInject constructor(
}
is ProxyConfigException -> {
host.withContext {
startActivity(SettingsActivity.newProxySettingsIntent(this))
}
host.router()?.openProxySettings()
false
}
@@ -85,9 +83,7 @@ class ExceptionResolver @AssistedInject constructor(
true
} else {
host.withContext {
authHelper.startAuth(this, e.scrobbler).onFailure {
showDetails(it, null)
}
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
}
false
}
@@ -106,12 +102,12 @@ class ExceptionResolver @AssistedInject constructor(
sourceAuthContract.launch(source)
}
private fun openInBrowser(url: String) = host.withContext {
startActivity(BrowserActivity.newIntent(this, url, null, null))
private fun openInBrowser(url: String) {
host.router()?.openBrowser(url, null, null)
}
private fun openAlternatives(manga: Manga) = host.withContext {
startActivity(AlternativesActivity.newIntent(this, manga))
private fun openAlternatives(manga: Manga) {
host.router()?.openAlternatives(manga)
}
private fun handleActivityResult(tag: String, result: Boolean) {
@@ -140,6 +136,12 @@ class ExceptionResolver @AssistedInject constructor(
getContext()?.apply(block)
}
private fun Host.router(): AppRouter? = when (this) {
is FragmentActivity -> router
is Fragment -> router
else -> null
}
interface Host : ActivityResultCaller {
fun getChildFragmentManager(): FragmentManager

View File

@@ -5,7 +5,6 @@ import androidx.core.util.Consumer
import androidx.fragment.app.Fragment
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
@@ -33,10 +32,10 @@ class SnackbarErrorObserver(
resolve(value)
}
} else if (value is ParseException) {
val fm = fragmentManager
if (fm != null && value.isSerializable()) {
val router = router()
if (router != null && value.isSerializable()) {
snackbar.setAction(R.string.details) {
ErrorDetailsDialog.show(fm, value, value.url)
router.showErrorDialog(value)
}
}
}

View File

@@ -4,26 +4,26 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.os.Build
import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
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.toByteBuffer
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File
import java.io.InputStream
import java.nio.ByteBuffer
import java.nio.file.Files
object BitmapDecoderCompat {
private const val FORMAT_AVIF = "avif"
@Blocking
fun decode(file: File): Bitmap = when (val format = getMimeType(file)?.subtype) {
fun decode(file: File): Bitmap = when (val format = probeMimeType(file)?.subtype) {
FORMAT_AVIF -> file.inputStream().use { decodeAvif(it.toByteBuffer()) }
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(file))
@@ -33,7 +33,7 @@ object BitmapDecoderCompat {
}
@Blocking
fun decode(stream: InputStream, type: MediaType?, isMutable: Boolean = false): Bitmap {
fun decode(stream: InputStream, type: MimeType?, isMutable: Boolean = false): Bitmap {
val format = type?.subtype
if (format == FORMAT_AVIF) {
return decodeAvif(stream.toByteBuffer())
@@ -51,12 +51,20 @@ object BitmapDecoderCompat {
}
}
private fun getMimeType(file: File): MediaType? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Files.probeContentType(file.toPath())?.toMediaTypeOrNull()
} else {
MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)?.toMediaTypeOrNull()
@Blocking
fun probeMimeType(file: File): MimeType? {
return MimeTypes.probeMimeType(file) ?: detectBitmapType(file)
}
@Blocking
private fun detectBitmapType(file: File): MimeType? = runCatchingCancellable {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(file.path, options)?.recycle()
return options.outMimeType?.toMimeTypeOrNull()
}.getOrNull()
private fun checkBitmapNotNull(bitmap: Bitmap?, format: String?): Bitmap =
bitmap ?: throw ImageDecodeException(null, format)

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core.image
import android.net.Uri
import android.webkit.MimeTypeMap
import coil3.ImageLoader
import coil3.decode.DataSource
import coil3.decode.ImageSource
@@ -12,6 +11,7 @@ import coil3.toAndroidUri
import kotlinx.coroutines.runInterruptible
import okio.Path.Companion.toPath
import okio.openZip
import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.ext.isZipUri
import coil3.Uri as CoilUri
@@ -25,7 +25,7 @@ class CbzFetcher(
val entryName = requireNotNull(uri.fragment)
SourceFetchResult(
source = ImageSource(entryName.toPath(), options.fileSystem.openZip(filePath)),
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(entryName.substringAfterLast('.', "")),
mimeType = MimeTypes.getMimeTypeFromExtension(entryName)?.toString(),
dataSource = DataSource.DISK,
)
}

View File

@@ -2,11 +2,16 @@ package org.koitharu.kotatsu.core.model
import android.content.Context
import android.graphics.Color
import android.os.Build
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.text.style.ImageSpan
import android.text.style.RelativeSizeSpan
import android.text.style.SuperscriptSpan
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.text.inSpans
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
@@ -100,3 +105,16 @@ fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
) {
append(context.getString(R.string.nsfw))
}
fun SpannableStringBuilder.appendIcon(textView: TextView, @DrawableRes resId: Int): SpannableStringBuilder {
val icon = ContextCompat.getDrawable(textView.context, resId) ?: return this
icon.setTintList(textView.textColors)
val size = textView.lineHeight
icon.setBounds(0, 0, size, size)
val alignment = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ImageSpan.ALIGN_CENTER
} else {
ImageSpan.ALIGN_BOTTOM
}
return inSpans(ImageSpan(icon, alignment)) { append(' ') }
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.core.model
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.list.domain.ListFilterOption
fun ListFilterOption.toChipModel(isChecked: Boolean) = ChipsView.ChipModel(
title = titleText,
titleResId = titleResId,
icon = iconResId,
iconData = getIconData(),
isChecked = isChecked,
data = this,
)

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
@Parcelize
data class ParcelableManga(
val manga: Manga,
private val withDescription: Boolean = true,
) : Parcelable {
companion object : Parceler<ParcelableManga> {
@@ -27,7 +28,7 @@ data class ParcelableManga(
ParcelCompat.writeBoolean(parcel, isNsfw)
parcel.writeString(coverUrl)
parcel.writeString(largeCoverUrl)
parcel.writeString(description)
parcel.writeString(description.takeIf { withDescription })
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
parcel.writeSerializable(state)
parcel.writeString(author)
@@ -52,6 +53,7 @@ data class ParcelableManga(
chapters = null,
source = MangaSource(parcel.readString()),
),
withDescription = true,
)
}
}

View File

@@ -0,0 +1,602 @@
package org.koitharu.kotatsu.core.nav
import android.accounts.Account
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.view.View
import androidx.annotation.CheckResult
import androidx.core.net.toUri
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.findFragment
import androidx.lifecycle.LifecycleOwner
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
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.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaListFilter
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPage
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.core.util.ext.mapToArray
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet
import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingInfoSheet
import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
import org.koitharu.kotatsu.favourites.ui.FavouritesActivity
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteDialog
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment
import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
import org.koitharu.kotatsu.history.ui.HistoryActivity
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.list.ui.config.ListConfigBottomSheet
import org.koitharu.kotatsu.list.ui.config.ListConfigSection
import org.koitharu.kotatsu.local.ui.ImportDialogFragment
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.isNullOrEmpty
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity
import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.multi.SearchActivity
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.about.AppUpdateActivity
import org.koitharu.kotatsu.settings.backup.BackupDialogFragment
import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment
import org.koitharu.kotatsu.settings.reader.ReaderTapGridConfigActivity
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog
import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
import org.koitharu.kotatsu.settings.tracker.categories.TrackerCategoriesConfigSheet
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
class AppRouter private constructor(
private val activity: FragmentActivity?,
private val fragment: Fragment?,
) {
constructor(activity: FragmentActivity) : this(activity, null)
constructor(fragment: Fragment) : this(null, fragment)
/** Activities **/
fun openList(source: MangaSource, filter: MangaListFilter?) {
startActivity(listIntent(contextOrNull() ?: return, source, filter))
}
fun openList(tag: MangaTag) = openList(tag.source, MangaListFilter(tags = setOf(tag)))
fun openSearch(query: String) {
startActivity(
Intent(contextOrNull() ?: return, SearchActivity::class.java)
.putExtra(KEY_QUERY, query),
)
}
fun openSearch(source: MangaSource, query: String) = openList(source, MangaListFilter(query = query))
fun openDetails(manga: Manga) {
startActivity(detailsIntent(contextOrNull() ?: return, manga))
}
fun openDetails(mangaId: Long) {
startActivity(detailsIntent(contextOrNull() ?: return, mangaId))
}
fun openReader(manga: Manga, anchor: View? = null) {
openReader(
ReaderIntent.Builder(contextOrNull() ?: return)
.manga(manga)
.build(),
anchor,
)
}
fun openReader(intent: ReaderIntent, anchor: View? = null) {
startActivity(intent.intent, anchor?.let { view -> scaleUpActivityOptionsOf(view) })
}
fun openAlternatives(manga: Manga) {
startActivity(
Intent(contextOrNull() ?: return, AlternativesActivity::class.java)
.putExtra(KEY_MANGA, ParcelableManga(manga)),
)
}
fun openRelated(manga: Manga) {
startActivity(
Intent(contextOrNull(), RelatedMangaActivity::class.java)
.putExtra(KEY_MANGA, ParcelableManga(manga)),
)
}
fun openImage(url: String, source: MangaSource?, anchor: View? = null) {
startActivity(
Intent(contextOrNull(), ImageActivity::class.java)
.setData(url.toUri())
.putExtra(KEY_SOURCE, source?.name),
anchor?.let { scaleUpActivityOptionsOf(it) },
)
}
fun openBookmarks() = startActivity(AllBookmarksActivity::class.java)
fun openAppUpdate() = startActivity(AppUpdateActivity::class.java)
fun openSuggestions() {
startActivity(suggestionsIntent(contextOrNull() ?: return))
}
fun openSourcesCatalog() = startActivity(SourcesCatalogActivity::class.java)
fun openDownloads() = startActivity(DownloadsActivity::class.java)
fun openDirectoriesSettings() = startActivity(MangaDirectoriesActivity::class.java)
fun openBrowser(url: String, source: MangaSource?, title: String?) {
startActivity(
Intent(contextOrNull() ?: return, BrowserActivity::class.java)
.setData(url.toUri())
.putExtra(KEY_TITLE, title)
.putExtra(KEY_SOURCE, source?.name),
)
}
fun openColorFilterConfig(manga: Manga, page: MangaPage) {
startActivity(
Intent(contextOrNull(), ColorFilterConfigActivity::class.java)
.putExtra(KEY_MANGA, ParcelableManga(manga))
.putExtra(KEY_PAGES, ParcelableMangaPage(page)),
)
}
fun openHistory() = startActivity(HistoryActivity::class.java)
fun openFavorites() = startActivity(FavouritesActivity::class.java)
fun openFavorites(category: FavouriteCategory) {
startActivity(
Intent(contextOrNull() ?: return, FavouritesActivity::class.java)
.putExtra(KEY_ID, category.id)
.putExtra(KEY_TITLE, category.title),
)
}
fun openFavoriteCategories() = startActivity(FavouriteCategoriesActivity::class.java)
fun openFavoriteCategoryEdit(categoryId: Long) {
startActivity(
Intent(contextOrNull() ?: return, FavouritesCategoryEditActivity::class.java)
.putExtra(KEY_ID, categoryId),
)
}
fun openFavoriteCategoryCreate() = openFavoriteCategoryEdit(FavouritesCategoryEditActivity.NO_ID)
fun openMangaUpdates() {
startActivity(mangaUpdatesIntent(contextOrNull() ?: return))
}
fun openSettings() = startActivity(SettingsActivity::class.java)
fun openReaderSettings() {
startActivity(readerSettingsIntent(contextOrNull() ?: return))
}
fun openProxySettings() {
startActivity(proxySettingsIntent(contextOrNull() ?: return))
}
fun openDownloadsSetting() {
startActivity(downloadsSettingsIntent(contextOrNull() ?: return))
}
fun openSourceSettings(source: MangaSource) {
startActivity(sourceSettingsIntent(contextOrNull() ?: return, source))
}
fun openSuggestionsSettings() {
startActivity(suggestionsSettingsIntent(contextOrNull() ?: return))
}
fun openSourcesSettings() {
startActivity(sourcesSettingsIntent(contextOrNull() ?: return))
}
fun openReaderTapGridSettings() = startActivity(ReaderTapGridConfigActivity::class.java)
fun openScrobblerSettings(scrobbler: ScrobblerService) {
startActivity(
Intent(contextOrNull() ?: return, ScrobblerConfigActivity::class.java)
.putExtra(KEY_ID, scrobbler.id),
)
}
fun openSourceAuth(source: MangaSource) {
startActivity(sourceAuthIntent(contextOrNull() ?: return, source))
}
fun openManageSources() {
startActivity(
manageSourcesIntent(contextOrNull() ?: return),
)
}
fun openStatistic() = startActivity(StatsActivity::class.java)
@CheckResult
fun openExternalBrowser(url: String, chooserTitle: CharSequence? = null): Boolean {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = url.toUriOrNull() ?: return false
return startActivitySafe(
if (!chooserTitle.isNullOrEmpty()) {
Intent.createChooser(intent, chooserTitle)
} else {
intent
},
)
}
@CheckResult
fun openSystemSyncSettings(account: Account): Boolean {
val args = Bundle(1)
args.putParcelable(ACCOUNT_KEY, account)
val intent = Intent(ACTION_ACCOUNT_SYNC_SETTINGS)
intent.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, args)
return startActivitySafe(intent)
}
/** Dialogs **/
fun showDownloadDialog(manga: Manga, snackbarHost: View?) = showDownloadDialog(setOf(manga), snackbarHost)
fun showDownloadDialog(manga: Collection<Manga>, snackbarHost: View?) {
if (manga.isEmpty()) {
return
}
val fm = getFragmentManager() ?: return
if (snackbarHost != null) {
getLifecycleOwner()?.let { lifecycleOwner ->
DownloadDialogFragment.registerCallback(fm, lifecycleOwner, snackbarHost)
}
} else {
DownloadDialogFragment.unregisterCallback(fm)
}
DownloadDialogFragment().withArgs(1) {
putParcelableArray(KEY_MANGA, manga.mapToArray { ParcelableManga(it, withDescription = false) })
}.showDistinct()
}
fun showLocalInfoDialog(manga: Manga) {
LocalInfoDialog().withArgs(1) {
putParcelable(KEY_MANGA, ParcelableManga(manga))
}.showDistinct()
}
fun showDirectorySelectDialog() {
MangaDirectorySelectDialog().showDistinct()
}
fun showFavoriteDialog(manga: Manga) = showFavoriteDialog(setOf(manga))
fun showFavoriteDialog(manga: Collection<Manga>) {
if (manga.isEmpty()) {
return
}
FavoriteDialog().withArgs(1) {
putParcelableArrayList(
KEY_MANGA_LIST,
manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withDescription = false) },
)
}.showDistinct()
}
fun showErrorDialog(error: Throwable, url: String? = null) {
ErrorDetailsDialog().withArgs(2) {
putSerializable(KEY_ERROR, error)
putString(KEY_URL, url)
}.show()
}
fun showBackupRestoreDialog(fileUri: Uri) {
RestoreDialogFragment().withArgs(1) {
putString(KEY_FILE, fileUri.toString())
}.show()
}
fun showBackupCreateDialog() {
BackupDialogFragment().show()
}
fun showImportDialog() {
ImportDialogFragment().showDistinct()
}
fun showFilterSheet(): Boolean = if (isFilterSupported()) {
FilterSheetFragment().showDistinct()
} else {
false
}
fun showTagsCatalogSheet(excludeMode: Boolean) {
if (!isFilterSupported()) {
return
}
TagsCatalogSheet().withArgs(1) {
putBoolean(KEY_EXCLUDE, excludeMode)
}.showDistinct()
}
fun showListConfigSheet(section: ListConfigSection) {
ListConfigBottomSheet().withArgs(1) {
putParcelable(KEY_LIST_SECTION, section)
}.showDistinct()
}
fun showStatisticSheet(manga: Manga) {
MangaStatsSheet().withArgs(1) {
putParcelable(KEY_MANGA, ParcelableManga(manga))
}.showDistinct()
}
fun showReaderConfigSheet(mode: ReaderMode) {
ReaderConfigSheet().withArgs(1) {
putInt(KEY_READER_MODE, mode.id)
}.showDistinct()
}
fun showWelcomeSheet() {
WelcomeSheet().showDistinct()
}
fun showChapterPagesSheet() {
ChaptersPagesSheet().showDistinct()
}
fun showChapterPagesSheet(defaultTab: Int) {
ChaptersPagesSheet().withArgs(1) {
putInt(KEY_TAB, defaultTab)
}.showDistinct()
}
fun showScrobblingSelectorSheet(manga: Manga, scrobblerService: ScrobblerService?) {
ScrobblingSelectorSheet().withArgs(2) {
putParcelable(KEY_MANGA, ParcelableManga(manga))
if (scrobblerService != null) {
putInt(KEY_ID, scrobblerService.id)
}
}.show()
}
fun showScrobblingInfoSheet(index: Int) {
ScrobblingInfoSheet().withArgs(1) {
putInt(KEY_INDEX, index)
}.showDistinct()
}
fun showTrackerCategoriesConfigSheet() {
TrackerCategoriesConfigSheet().showDistinct()
}
/** Public utils **/
fun isFilterSupported(): Boolean = when {
fragment != null -> fragment.activity is FilterCoordinator.Owner
activity != null -> activity is FilterCoordinator.Owner
else -> false
}
fun isChapterPagesSheetShown(): Boolean {
val sheet = getFragmentManager()?.findFragmentByTag(fragmentTag<ChaptersPagesSheet>()) as? ChaptersPagesSheet
return sheet?.dialog?.isShowing == true
}
fun closeWelcomeSheet(): Boolean {
val fm = fragment?.parentFragmentManager ?: activity?.supportFragmentManager ?: return false
val sheet = fm.findFragmentByTag(fragmentTag<WelcomeSheet>()) as? WelcomeSheet ?: return false
sheet.dismissAllowingStateLoss()
return true
}
/** Private utils **/
private fun startActivity(intent: Intent, options: Bundle? = null) {
fragment?.startActivity(intent, options)
?: activity?.startActivity(intent, options)
}
private fun startActivitySafe(intent: Intent): Boolean = try {
startActivity(intent)
true
} catch (_: ActivityNotFoundException) {
false
}
private fun startActivity(activityClass: Class<out Activity>) {
startActivity(Intent(contextOrNull() ?: return, activityClass))
}
private fun getFragmentManager(): FragmentManager? {
return fragment?.childFragmentManager ?: activity?.supportFragmentManager
}
private fun contextOrNull(): Context? = activity ?: fragment?.context
private fun getLifecycleOwner(): LifecycleOwner? = activity ?: fragment?.viewLifecycleOwner
private fun DialogFragment.showDistinct(): Boolean {
val fm = this@AppRouter.getFragmentManager() ?: return false
val tag = javaClass.fragmentTag()
val existing = fm.findFragmentByTag(tag) as? DialogFragment?
if (existing != null && existing.isVisible && existing.arguments == this.arguments) {
return false
}
show(fm, tag)
return true
}
private fun DialogFragment.show() {
show(
this@AppRouter.getFragmentManager() ?: return,
javaClass.fragmentTag(),
)
}
companion object {
fun from(view: View): AppRouter? = runCatching {
AppRouter(view.findFragment<Fragment>())
}.getOrElse {
(view.context.findActivity() as? FragmentActivity)?.let(::AppRouter)
}
fun detailsIntent(context: Context, manga: Manga) = Intent(context, DetailsActivity::class.java)
.putExtra(KEY_MANGA, ParcelableManga(manga))
fun detailsIntent(context: Context, mangaId: Long) = Intent(context, DetailsActivity::class.java)
.putExtra(KEY_ID, mangaId)
fun listIntent(context: Context, source: MangaSource, filter: MangaListFilter?): Intent =
Intent(context, MangaListActivity::class.java)
.setAction(ACTION_MANGA_EXPLORE)
.putExtra(KEY_SOURCE, source.name)
.apply {
if (!filter.isNullOrEmpty()) {
putExtra(KEY_FILTER, ParcelableMangaListFilter(filter))
}
}
fun cloudFlareResolveIntent(context: Context, exception: CloudFlareProtectedException): Intent =
Intent(context, CloudFlareActivity::class.java).apply {
data = exception.url.toUri()
putExtra(KEY_SOURCE, exception.source?.name)
exception.headers.get(CommonHeaders.USER_AGENT)?.let {
putExtra(KEY_USER_AGENT, it)
}
}
fun suggestionsIntent(context: Context) = Intent(context, SuggestionsActivity::class.java)
fun mangaUpdatesIntent(context: Context) = Intent(context, UpdatesActivity::class.java)
fun readerSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_READER)
fun suggestionsSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_SUGGESTIONS)
fun trackerSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_TRACKER)
fun proxySettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_PROXY)
fun historySettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_HISTORY)
fun sourcesSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_SOURCES)
fun manageSourcesIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_MANAGE_SOURCES)
fun downloadsSettingsIntent(context: Context) =
Intent(context, SettingsActivity::class.java)
.setAction(ACTION_MANAGE_DOWNLOADS)
fun sourceSettingsIntent(context: Context, source: MangaSource): Intent = when (source) {
is MangaSourceInfo -> sourceSettingsIntent(context, source.mangaSource)
is ExternalMangaSource -> Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.setData(Uri.fromParts("package", source.packageName, null))
else -> Intent(context, SettingsActivity::class.java)
.setAction(ACTION_SOURCE)
.putExtra(KEY_SOURCE, source.name)
}
fun sourceAuthIntent(context: Context, source: MangaSource): Intent {
return Intent(context, SourceAuthActivity::class.java)
.putExtra(KEY_SOURCE, source.name)
}
const val KEY_EXCLUDE = "exclude"
const val KEY_FILTER = "filter"
const val KEY_ID = "id"
const val KEY_LIST_SECTION = "list_section"
const val KEY_MANGA = "manga"
const val KEY_MANGA_LIST = "manga_list"
const val KEY_PAGES = "pages"
const val KEY_QUERY = "query"
const val KEY_READER_MODE = "reader_mode"
const val KEY_SOURCE = "source"
const val KEY_TAB = "tab"
const val KEY_TITLE = "title"
const val KEY_USER_AGENT = "user_agent"
const val KEY_URL = "url"
const val KEY_ERROR = "error"
const val KEY_FILE = "file"
const val KEY_INDEX = "index"
const val KEY_DATA = "data"
const val ACTION_HISTORY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_HISTORY"
const val ACTION_MANAGE_DOWNLOADS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DOWNLOADS"
const val ACTION_MANAGE_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES_LIST"
const val ACTION_MANGA_EXPLORE = "${BuildConfig.APPLICATION_ID}.action.EXPLORE_MANGA"
const val ACTION_PROXY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_PROXY"
const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
const val ACTION_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES"
const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS"
const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER"
private const val ACCOUNT_KEY = "account"
private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS"
private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args"
private fun Class<out Fragment>.fragmentTag() = name // TODO
private inline fun <reified F : Fragment> fragmentTag() = F::class.java.fragmentTag()
}
}

View File

@@ -1,11 +1,12 @@
package org.koitharu.kotatsu.core.parser
package org.koitharu.kotatsu.core.nav
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.lifecycle.SavedStateHandle
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.nav.AppRouter.Companion.KEY_ID
import org.koitharu.kotatsu.core.nav.AppRouter.Companion.KEY_MANGA
import org.koitharu.kotatsu.core.util.ext.getParcelableCompat
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.parsers.model.Manga
@@ -25,7 +26,7 @@ class MangaIntent private constructor(
constructor(savedStateHandle: SavedStateHandle) : this(
manga = savedStateHandle.get<ParcelableManga>(KEY_MANGA)?.manga,
id = savedStateHandle[KEY_ID] ?: ID_NONE,
uri = savedStateHandle[BaseActivity.EXTRA_DATA],
uri = savedStateHandle[AppRouter.KEY_DATA],
)
constructor(args: Bundle?) : this(
@@ -41,9 +42,6 @@ class MangaIntent private constructor(
const val ID_NONE = 0L
const val KEY_MANGA = "manga"
const val KEY_ID = "id"
fun of(manga: Manga) = MangaIntent(manga, manga.id, null)
}
}

View File

@@ -0,0 +1,39 @@
package org.koitharu.kotatsu.core.nav
import android.app.ActivityOptions
import android.os.Bundle
import android.view.View
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
inline val FragmentActivity.router: AppRouter
get() = AppRouter(this)
inline val Fragment.router: AppRouter
get() = AppRouter(this)
tailrec fun Fragment.dismissParentDialog(): Boolean {
return when (val parent = parentFragment) {
null -> return false
is DialogFragment -> {
parent.dismiss()
true
}
else -> parent.dismissParentDialog()
}
}
fun scaleUpActivityOptionsOf(view: View): Bundle? = if (view.context.isAnimationsEnabled) {
ActivityOptions.makeScaleUpAnimation(
view,
0,
0,
view.width,
view.height,
).toBundle()
} else {
null
}

View File

@@ -0,0 +1,61 @@
package org.koitharu.kotatsu.core.nav
import android.content.Context
import android.content.Intent
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
@JvmInline
value class ReaderIntent private constructor(
val intent: Intent,
) {
class Builder(context: Context) {
private val intent = Intent(context, ReaderActivity::class.java)
.setAction(ACTION_MANGA_READ)
fun manga(manga: Manga) = apply {
intent.putExtra(AppRouter.KEY_MANGA, ParcelableManga(manga))
}
fun mangaId(mangaId: Long) = apply {
intent.putExtra(AppRouter.KEY_ID, mangaId)
}
fun incognito(incognito: Boolean) = apply {
intent.putExtra(EXTRA_INCOGNITO, incognito)
}
fun branch(branch: String?) = apply {
intent.putExtra(EXTRA_BRANCH, branch)
}
fun state(state: ReaderState?) = apply {
intent.putExtra(EXTRA_STATE, state)
}
fun bookmark(bookmark: Bookmark) = manga(
bookmark.manga,
).state(
ReaderState(
chapterId = bookmark.chapterId,
page = bookmark.page,
scroll = bookmark.scroll,
),
)
fun build() = ReaderIntent(intent)
}
companion object {
const val ACTION_MANGA_READ = "${BuildConfig.APPLICATION_ID}.action.READ_MANGA"
const val EXTRA_STATE = "state"
const val EXTRA_BRANCH = "branch"
const val EXTRA_INCOGNITO = "incognito"
}
}

View File

@@ -35,7 +35,7 @@ class AppProxySelector(
if (type == Proxy.Type.DIRECT) {
return Proxy.NO_PROXY
}
if (address.isNullOrEmpty() || port == 0) {
if (address.isNullOrEmpty() || port < 0 || port > 0xFFFF) {
throw ProxyConfigException()
}
cachedProxy?.let {

View File

@@ -1,19 +1,26 @@
package org.koitharu.kotatsu.core.network
import okhttp3.Interceptor
import okhttp3.MultipartBody
import okhttp3.Response
import okio.IOException
import org.koitharu.kotatsu.core.exceptions.WrapperIOException
import org.koitharu.kotatsu.core.network.CommonHeaders.CONTENT_ENCODING
class GZipInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val newRequest = chain.request().newBuilder()
newRequest.addHeader(CONTENT_ENCODING, "gzip")
return try {
override fun intercept(chain: Interceptor.Chain): Response = try {
val request = chain.request()
if (request.body is MultipartBody) {
chain.proceed(request)
} else {
val newRequest = request.newBuilder()
newRequest.addHeader(CONTENT_ENCODING, "gzip")
chain.proceed(newRequest.build())
} catch (e: NullPointerException) {
throw IOException(e)
}
} catch (e: IOException) {
throw e
} catch (e: Exception) {
throw WrapperIOException(e)
}
}

View File

@@ -23,6 +23,8 @@ import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.ReaderIntent
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -36,8 +38,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.search.ui.MangaListActivity
import javax.inject.Inject
import javax.inject.Singleton
@@ -155,9 +155,10 @@ class AppShortcutManager @Inject constructor(
.setIcon(icon)
.setLongLived(true)
.setIntent(
ReaderActivity.IntentBuilder(context)
ReaderIntent.Builder(context)
.mangaId(manga.id)
.build(),
.build()
.intent,
)
.build()
}
@@ -181,7 +182,7 @@ class AppShortcutManager @Inject constructor(
.setLongLabel(title)
.setIcon(icon)
.setLongLived(true)
.setIntent(MangaListActivity.newIntent(context, source, null))
.setIntent(AppRouter.listIntent(context, source, null))
.build()
}
}

View File

@@ -14,6 +14,8 @@ import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.nav.MangaIntent
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.parsers.model.Manga
@@ -27,6 +29,7 @@ import javax.inject.Provider
class MangaDataRepository @Inject constructor(
private val db: MangaDatabase,
private val resolverProvider: Provider<MangaLinkResolver>,
private val appShortcutManagerProvider: Provider<AppShortcutManager>,
) {
suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) {
@@ -45,8 +48,8 @@ class MangaDataRepository @Inject constructor(
entity.copy(
cfBrightness = colorFilter?.brightness ?: 0f,
cfContrast = colorFilter?.contrast ?: 0f,
cfInvert = colorFilter?.isInverted ?: false,
cfGrayscale = colorFilter?.isGrayscale ?: false,
cfInvert = colorFilter?.isInverted == true,
cfGrayscale = colorFilter?.isGrayscale == true,
),
)
}
@@ -70,8 +73,13 @@ class MangaDataRepository @Inject constructor(
.distinctUntilChanged()
}
suspend fun findMangaById(mangaId: Long): Manga? {
return db.getMangaDao().find(mangaId)?.toManga()
suspend fun findMangaById(mangaId: Long, withChapters: Boolean): Manga? {
val chapters = if (withChapters) {
db.getChaptersDao().findAll(mangaId).takeUnless { it.isEmpty() }
} else {
null
}
return db.getMangaDao().find(mangaId)?.toManga(chapters)
}
suspend fun findMangaByPublicUrl(publicUrl: String): Manga? {
@@ -80,7 +88,7 @@ class MangaDataRepository @Inject constructor(
suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
intent.manga != null -> intent.manga
intent.mangaId != 0L -> findMangaById(intent.mangaId)
intent.mangaId != 0L -> findMangaById(intent.mangaId, true)
intent.uri != null -> resolverProvider.get().resolve(intent.uri)
else -> null
}
@@ -97,10 +105,26 @@ class MangaDataRepository @Inject constructor(
val tags = manga.tags.toEntities()
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga.toEntity(), tags)
if (!manga.isLocal) {
manga.chapters?.let { chapters ->
db.getChaptersDao().replaceAll(manga.id, chapters.withIndex().toEntities(manga.id))
}
}
}
}
}
suspend fun updateChapters(manga: Manga) {
val chapters = manga.chapters
if (!chapters.isNullOrEmpty() && manga.id in db.getMangaDao()) {
db.getChaptersDao().replaceAll(manga.id, chapters.withIndex().toEntities(manga.id))
}
}
suspend fun gcChaptersCache() {
db.getChaptersDao().gc()
}
suspend fun findTags(source: MangaSource): Set<MangaTag> {
return db.getTagsDao().findTags(source.name).toMangaTags()
}
@@ -114,6 +138,14 @@ class MangaDataRepository @Inject constructor(
}
}
suspend fun cleanupDatabase() {
db.withTransaction {
gcChaptersCache()
val idsFromShortcuts = appShortcutManagerProvider.get().getMangaShortcuts()
db.getMangaDao().cleanup(idsFromShortcuts)
}
}
private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? {
return if (cfBrightness != 0f || cfContrast != 0f || cfInvert || cfGrayscale) {
ReaderColorFilter(cfBrightness, cfContrast, cfInvert, cfGrayscale)

View File

@@ -23,6 +23,7 @@ import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
import org.koitharu.kotatsu.core.util.ext.toList
import org.koitharu.kotatsu.core.util.ext.toMimeType
import org.koitharu.kotatsu.core.util.ext.use
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
@@ -78,13 +79,14 @@ class MangaLoaderContextImpl @Inject constructor(
override fun redrawImageResponse(response: Response, redraw: (image: Bitmap) -> Bitmap): Response {
return response.map { body ->
BitmapDecoderCompat.decode(body.byteStream(), body.contentType(), isMutable = true).use { bitmap ->
(redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper).use { result ->
Buffer().also {
result.compressTo(it.outputStream())
}.asResponseBody("image/jpeg".toMediaType())
BitmapDecoderCompat.decode(body.byteStream(), body.contentType()?.toMimeType(), isMutable = true)
.use { bitmap ->
(redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper).use { result ->
Buffer().also {
result.compressTo(it.outputStream())
}.asResponseBody("image/jpeg".toMediaType())
}
}
}
}
}

View File

@@ -42,7 +42,7 @@ class ExternalMangaRepository(
override var defaultSortOrder: SortOrder
get() = capabilities?.availableSortOrders?.firstOrNull() ?: SortOrder.ALPHABETICAL
set(_) = Unit
set(value) = Unit
override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptions.get()

View File

@@ -299,6 +299,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getInt(KEY_SOURCES_VERSION, 0)
set(value) = prefs.edit { putInt(KEY_SOURCES_VERSION, value) }
var isAllSourcesEnabled: Boolean
get() = prefs.getBoolean(KEY_SOURCES_ENABLED_ALL, false)
set(value) = prefs.edit { putBoolean(KEY_SOURCES_ENABLED_ALL, value) }
val isPagesNumbersEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
@@ -363,9 +367,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderBarEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_BAR, true)
val isReaderSliderEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_SLIDER, true)
val isReaderKeepScreenOn: Boolean
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
@@ -489,6 +490,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull()
set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) }
val isBackupTelegramUploadEnabled: Boolean
get() = prefs.getBoolean(KEY_BACKUP_TG_ENABLED, false)
val backupTelegramChatId: String?
get() = prefs.getString(KEY_BACKUP_TG_CHAT, null)?.nullIfEmpty()
val isReadingTimeEstimationEnabled: Boolean
get() = prefs.getBoolean(KEY_READING_TIME, true)
@@ -664,7 +671,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SYNC = "sync"
const val KEY_SYNC_SETTINGS = "sync_settings"
const val KEY_READER_BAR = "reader_bar"
const val KEY_READER_SLIDER = "reader_slider"
const val KEY_READER_BACKGROUND = "reader_background"
const val KEY_READER_SCREEN_ON = "reader_screen_on"
const val KEY_SHORTCUTS = "dynamic_shortcuts"
@@ -715,7 +721,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_FEED_HEADER = "feed_header"
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
const val KEY_SOURCES_VERSION = "sources_version"
const val KEY_SOURCES_ENABLED_ALL = "sources_enabled_all"
const val KEY_QUICK_FILTER = "quick_filter"
const val KEY_BACKUP_TG_ENABLED = "backup_periodic_tg_enabled"
const val KEY_BACKUP_TG_CHAT = "backup_periodic_tg_chat_id"
// keys for non-persistent preferences
const val KEY_APP_VERSION = "app_version"
@@ -729,6 +738,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PROXY_TEST = "proxy_test"
const val KEY_OPEN_BROWSER = "open_browser"
const val KEY_HANDLE_LINKS = "handle_links"
const val KEY_BACKUP_TG_OPEN = "backup_periodic_tg_open"
const val KEY_BACKUP_TG_TEST = "backup_periodic_tg_test"
const val KEY_CLEAR_MANGA_DATA = "manga_data_clear"
// old keys are for migration only
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"

View File

@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.flowOf
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
@@ -159,7 +160,7 @@ abstract class BaseActivity<B : ViewBinding> :
override fun isNsfwContent(): Flow<Boolean> = flowOf(false)
private fun putDataToExtras(intent: Intent?) {
intent?.putExtra(EXTRA_DATA, intent.data)
intent?.putExtra(AppRouter.KEY_DATA, intent.data)
}
protected fun setContentViewWebViewSafe(viewBindingProducer: () -> B): Boolean {
@@ -178,9 +179,4 @@ abstract class BaseActivity<B : ViewBinding> :
}
protected fun hasViewBinding() = ::viewBinding.isInitialized
companion object {
const val EXTRA_DATA = "data"
}
}

View File

@@ -1,8 +1,6 @@
package org.koitharu.kotatsu.core.ui
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.annotation.CallSuper
@@ -14,10 +12,8 @@ import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceScreen
import androidx.preference.get
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
@@ -89,14 +85,6 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
(activity as? SettingsActivity)?.setSectionTitle(title)
}
protected fun startActivitySafe(intent: Intent): Boolean = try {
startActivity(intent)
true
} catch (_: ActivityNotFoundException) {
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
false
}
private fun focusPreference(key: String) {
val pref = findPreference<Preference>(key)
if (pref == null) {

View File

@@ -10,14 +10,14 @@ import androidx.core.text.HtmlCompat
import androidx.core.text.htmlEncode
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.core.text.parseAsHtml
import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.getCauseUrl
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.withArgs
import org.koitharu.kotatsu.databinding.DialogErrorDetailsBinding
class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
@@ -27,7 +27,7 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val args = requireArguments()
exception = args.requireSerializable(ARG_ERROR)
exception = args.requireSerializable(AppRouter.KEY_ERROR)
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogErrorDetailsBinding {
@@ -41,7 +41,7 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
text = context.getString(
R.string.manga_error_description_pattern,
exception.message?.htmlEncode().orEmpty(),
arguments?.getString(ARG_URL),
arguments?.getString(AppRouter.KEY_URL) ?: exception.getCauseUrl(),
).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY)
}
}
@@ -71,16 +71,4 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
ClipData.newPlainText(getString(R.string.error), exception.stackTraceToString()),
)
}
companion object {
private const val TAG = "ErrorDetailsDialog"
private const val ARG_ERROR = "error"
private const val ARG_URL = "url"
fun show(fm: FragmentManager, error: Throwable, url: String?) = ErrorDetailsDialog().withArgs(2) {
putSerializable(ARG_ERROR, error)
putString(ARG_URL, url)
}.show(fm, TAG)
}
}

View File

@@ -6,11 +6,16 @@ import android.graphics.Canvas
import android.graphics.drawable.Animatable
import androidx.annotation.StyleRes
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import coil3.Image
import coil3.asImage
import coil3.getExtra
import coil3.request.ImageRequest
import com.google.android.material.animation.ArgbEvaluatorCompat
import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.mangaSourceKey
import kotlin.math.abs
class AnimatedFaviconDrawable(
@@ -23,12 +28,12 @@ class AnimatedFaviconDrawable(
private val period = context.getAnimationDuration(R.integer.config_longAnimTime) * 2
private val timeAnimator = TimeAnimator()
private val colorHigh = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground)
private val colorLow = ArgbEvaluatorCompat.getInstance().evaluate(0.3f, colorHigh, colorBackground)
private var colorHigh = MaterialColors.harmonize(colorForeground, currentBackgroundColor)
private var colorLow = ArgbEvaluatorCompat.getInstance().evaluate(0.3f, colorHigh, currentBackgroundColor)
init {
timeAnimator.setTimeListener(this)
updateColor()
onStateChange(state)
}
override fun draw(canvas: Canvas) {
@@ -39,9 +44,11 @@ class AnimatedFaviconDrawable(
super.draw(canvas)
}
override fun setAlpha(alpha: Int) = Unit
override fun getAlpha(): Int = 255
// override fun setAlpha(alpha: Int) = Unit
//
// override fun getAlpha(): Int = 255
//
// override fun isOpaque(): Boolean = false
override fun onTimeUpdate(animation: TimeAnimator?, totalTime: Long, deltaTime: Long) {
callback?.also {
@@ -60,13 +67,33 @@ class AnimatedFaviconDrawable(
override fun isRunning(): Boolean = timeAnimator.isStarted
override fun onStateChange(state: IntArray): Boolean {
val res = super.onStateChange(state)
colorHigh = MaterialColors.harmonize(currentForegroundColor, currentBackgroundColor)
colorLow = ArgbEvaluatorCompat.getInstance().evaluate(0.3f, colorHigh, currentBackgroundColor)
updateColor()
return res
}
private fun updateColor() {
if (period <= 0f) {
return
}
val ph = period / 2
val fraction = abs((System.currentTimeMillis() % period) - ph) / ph.toFloat()
colorForeground = ArgbEvaluatorCompat.getInstance()
currentForegroundColor = ArgbEvaluatorCompat.getInstance()
.evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh)
}
class Factory(
@StyleRes private val styleResId: Int,
) : ((ImageRequest) -> Image?) {
override fun invoke(request: ImageRequest): Image? {
val source = request.getExtra(mangaSourceKey) ?: return null
val context = request.context
val title = source.getTitle(context)
return AnimatedFaviconDrawable(context, styleResId, title).asImage()
}
}
}

View File

@@ -7,6 +7,7 @@ import android.graphics.ColorFilter
import android.graphics.PixelFormat
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import androidx.core.graphics.ColorUtils
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import com.google.android.material.animation.ArgbEvaluatorCompat
import org.koitharu.kotatsu.R
@@ -23,6 +24,7 @@ class AnimatedPlaceholderDrawable(context: Context) : Drawable(), Animatable, Ti
private val interpolator = FastOutSlowInInterpolator()
private val period = context.getAnimationDuration(R.integer.config_longAnimTime) * 2
private val timeAnimator = TimeAnimator()
private var currentAlpha: Int = 255
init {
timeAnimator.setTimeListener(this)
@@ -38,16 +40,17 @@ class AnimatedPlaceholderDrawable(context: Context) : Drawable(), Animatable, Ti
}
override fun setAlpha(alpha: Int) {
// this.alpha = alpha FIXME coil's crossfade
currentAlpha = alpha
updateColor()
}
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
@Suppress("DeprecatedCallableAddReplaceWith")
@Deprecated("Deprecated in Java")
override fun getOpacity(): Int = PixelFormat.OPAQUE
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
override fun getAlpha(): Int = 255
override fun getAlpha(): Int = currentAlpha
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
override fun onTimeUpdate(animation: TimeAnimator?, totalTime: Long, deltaTime: Long) {
callback?.also {
@@ -72,7 +75,10 @@ class AnimatedPlaceholderDrawable(context: Context) : Drawable(), Animatable, Ti
}
val ph = period / 2
val fraction = abs((System.currentTimeMillis() % period) - ph) / ph.toFloat()
currentColor = ArgbEvaluatorCompat.getInstance()
.evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh)
currentColor = ColorUtils.setAlphaComponent(
ArgbEvaluatorCompat.getInstance()
.evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh),
currentAlpha
)
}
}

View File

@@ -1,34 +1,47 @@
package org.koitharu.kotatsu.core.ui.image
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.StyleRes
import androidx.core.content.withStyledAttributes
import androidx.core.graphics.withClip
import coil3.Image
import coil3.asImage
import coil3.getExtra
import coil3.request.ImageRequest
import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.ext.hasFocusStateSpecified
import org.koitharu.kotatsu.core.util.ext.mangaSourceKey
open class FaviconDrawable(
context: Context,
@StyleRes styleResId: Int,
name: String,
) : Drawable() {
) : PaintDrawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
protected var colorBackground = Color.WHITE
override val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG)
protected var currentBackgroundColor = Color.WHITE
private set
private var colorBackground: ColorStateList = ColorStateList.valueOf(currentBackgroundColor)
protected var colorForeground = Color.DKGRAY
private var colorStroke = Color.LTGRAY
protected var currentForegroundColor = Color.DKGRAY
protected var currentStrokeColor = Color.LTGRAY
private set
private var colorStroke: ColorStateList = ColorStateList.valueOf(currentStrokeColor)
private val letter = name.take(1).uppercase()
private var cornerSize = 0f
private var intrinsicSize = -1
private val textBounds = Rect()
private val tempRect = Rect()
private val boundsF = RectF()
@@ -36,14 +49,17 @@ open class FaviconDrawable(
init {
context.withStyledAttributes(styleResId, R.styleable.FaviconFallbackDrawable) {
colorBackground = getColor(R.styleable.FaviconFallbackDrawable_backgroundColor, colorBackground)
colorStroke = getColor(R.styleable.FaviconFallbackDrawable_strokeColor, colorStroke)
colorBackground = getColorStateList(R.styleable.FaviconFallbackDrawable_backgroundColor) ?: colorBackground
colorStroke = getColorStateList(R.styleable.FaviconFallbackDrawable_strokeColor) ?: colorStroke
cornerSize = getDimension(R.styleable.FaviconFallbackDrawable_cornerSize, cornerSize)
paint.strokeWidth = getDimension(R.styleable.FaviconFallbackDrawable_strokeWidth, 0f) * 2f
intrinsicSize = getDimensionPixelSize(R.styleable.FaviconFallbackDrawable_drawableSize, intrinsicSize)
}
paint.textAlign = Paint.Align.CENTER
paint.isFakeBoldText = true
colorForeground = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground)
colorForeground = KotatsuColors.random(name)
currentForegroundColor = MaterialColors.harmonize(colorForeground, colorBackground.defaultColor)
onStateChange(state)
}
override fun draw(canvas: Canvas) {
@@ -67,31 +83,42 @@ open class FaviconDrawable(
clipPath.close()
}
override fun setAlpha(alpha: Int) {
paint.alpha = alpha
}
override fun getIntrinsicWidth(): Int = intrinsicSize
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.colorFilter = colorFilter
}
override fun getIntrinsicHeight(): Int = intrinsicSize
@Suppress("DeprecatedCallableAddReplaceWith")
@Deprecated("Deprecated in Java")
override fun getOpacity() = PixelFormat.TRANSPARENT
override fun isOpaque(): Boolean = cornerSize == 0f && colorBackground.isOpaque
override fun isStateful(): Boolean = colorStroke.isStateful || colorBackground.isStateful
@RequiresApi(Build.VERSION_CODES.S)
override fun hasFocusStateSpecified(): Boolean =
colorBackground.hasFocusStateSpecified() || colorStroke.hasFocusStateSpecified()
override fun onStateChange(state: IntArray): Boolean {
val prevStrokeColor = currentStrokeColor
val prevBackgroundColor = currentBackgroundColor
currentStrokeColor = colorStroke.getColorForState(state, colorStroke.defaultColor)
currentBackgroundColor = colorBackground.getColorForState(state, colorBackground.defaultColor)
if (currentBackgroundColor != prevBackgroundColor) {
currentForegroundColor = MaterialColors.harmonize(colorForeground, currentBackgroundColor)
}
return prevBackgroundColor != currentBackgroundColor || prevStrokeColor != currentStrokeColor
}
private fun doDraw(canvas: Canvas) {
// background
paint.color = colorBackground
paint.color = currentBackgroundColor
paint.style = Paint.Style.FILL
canvas.drawPaint(paint)
// letter
paint.color = colorForeground
paint.color = currentForegroundColor
val cx = (boundsF.left + boundsF.right) * 0.6f
val ty = boundsF.bottom * 0.7f + textBounds.height() * 0.5f - textBounds.bottom
canvas.drawText(letter, cx, ty, paint)
if (paint.strokeWidth > 0f) {
// stroke
paint.color = colorStroke
paint.color = currentStrokeColor
paint.style = Paint.Style.STROKE
canvas.drawPath(clipPath, paint)
}
@@ -103,4 +130,16 @@ open class FaviconDrawable(
paint.getTextBounds(text, 0, text.length, tempRect)
return testTextSize * width / tempRect.width()
}
class Factory(
@StyleRes private val styleResId: Int,
) : ((ImageRequest) -> Image?) {
override fun invoke(request: ImageRequest): Image? {
val source = request.getExtra(mangaSourceKey) ?: return null
val context = request.context
val title = source.getTitle(context)
return FaviconDrawable(context, styleResId, title).asImage()
}
}
}

View File

@@ -0,0 +1,53 @@
package org.koitharu.kotatsu.core.ui.image
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.drawable.Drawable
@Suppress("OVERRIDE_DEPRECATION")
abstract class PaintDrawable : Drawable() {
protected abstract val paint: Paint
override fun setAlpha(alpha: Int) {
paint.alpha = alpha
}
override fun getAlpha(): Int {
return paint.alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.colorFilter = colorFilter
}
override fun getColorFilter(): ColorFilter? {
return paint.colorFilter
}
override fun setDither(dither: Boolean) {
paint.isDither = dither
}
override fun setFilterBitmap(filter: Boolean) {
paint.isFilterBitmap = filter
}
override fun isFilterBitmap(): Boolean {
return paint.isFilterBitmap
}
override fun getOpacity(): Int {
if (paint.colorFilter != null) {
return PixelFormat.TRANSLUCENT
}
return when (paint.alpha) {
0 -> PixelFormat.TRANSPARENT
255 -> if (isOpaque()) PixelFormat.OPAQUE else PixelFormat.TRANSLUCENT
else -> PixelFormat.TRANSLUCENT
}
}
protected open fun isOpaque() = false
}

View File

@@ -0,0 +1,83 @@
package org.koitharu.kotatsu.core.ui.image
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PointF
import android.graphics.Rect
import android.os.Build
import android.widget.TextView
import androidx.annotation.RequiresApi
import androidx.core.graphics.PaintCompat
import org.koitharu.kotatsu.core.util.ext.hasFocusStateSpecified
class TextDrawable(
val text: String,
) : PaintDrawable() {
override val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG)
private val textBounds = Rect()
private val textPoint = PointF()
var textSize: Float
get() = paint.textSize
set(value) {
paint.textSize = value
measureTextBounds()
}
var textColor: ColorStateList = ColorStateList.valueOf(Color.BLACK)
set(value) {
field = value
onStateChange(state)
}
init {
onStateChange(state)
measureTextBounds()
}
override fun draw(canvas: Canvas) {
canvas.drawText(text, textPoint.x, textPoint.y, paint)
}
override fun onBoundsChange(bounds: Rect) {
textPoint.set(
bounds.exactCenterX() - textBounds.exactCenterX(),
bounds.exactCenterY() - textBounds.exactCenterY(),
)
}
override fun getIntrinsicWidth(): Int = textBounds.width()
override fun getIntrinsicHeight(): Int = textBounds.height()
override fun isStateful(): Boolean = textColor.isStateful
@RequiresApi(Build.VERSION_CODES.S)
override fun hasFocusStateSpecified(): Boolean = textColor.hasFocusStateSpecified()
override fun onStateChange(state: IntArray): Boolean {
val prevColor = paint.color
paint.color = textColor.getColorForState(state, textColor.defaultColor)
return paint.color != prevColor
}
private fun measureTextBounds() {
paint.getTextBounds(text, 0, text.length, textBounds)
onBoundsChange(bounds)
}
companion object {
fun compound(textView: TextView, text: String): TextDrawable? {
val drawable = TextDrawable(text)
drawable.textSize = textView.textSize
drawable.textColor = textView.textColors
return drawable.takeIf {
PaintCompat.hasGlyph(drawable.paint, text)
}
}
}
}

View File

@@ -0,0 +1,41 @@
package org.koitharu.kotatsu.core.ui.image
import android.graphics.drawable.Drawable
import android.view.Gravity
import android.widget.TextView
import androidx.annotation.GravityInt
import coil3.target.GenericViewTarget
class TextViewTarget(
override val view: TextView,
@GravityInt compoundDrawable: Int,
) : GenericViewTarget<TextView>() {
private val drawableIndex: Int = when (compoundDrawable) {
Gravity.START -> 0
Gravity.TOP -> 2
Gravity.END -> 3
Gravity.BOTTOM -> 4
else -> -1
}
override var drawable: Drawable?
get() = if (drawableIndex != -1) {
view.compoundDrawablesRelative[drawableIndex]
} else {
null
}
set(value) {
if (drawableIndex == -1) {
return
}
val drawables = view.compoundDrawablesRelative
drawables[drawableIndex] = value
view.setCompoundDrawablesRelativeWithIntrinsicBounds(
drawables[0],
drawables[1],
drawables[2],
drawables[3],
)
}
}

View File

@@ -0,0 +1,100 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.content.Context
import android.os.Parcel
import android.os.Parcelable
import android.os.Parcelable.Creator
import android.util.AttributeSet
import androidx.core.content.withStyledAttributes
import androidx.customview.view.AbsSavedState
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.textview.MaterialTextView
import org.koitharu.kotatsu.R
class BadgeView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : MaterialTextView(context, attrs, R.attr.badgeViewStyle) {
private var maxCharacterCount = Int.MAX_VALUE
var number: Int = 0
set(value) {
field = value
updateText()
}
init {
context.withStyledAttributes(attrs, R.styleable.BadgeView, R.attr.badgeViewStyle) {
maxCharacterCount = getInt(R.styleable.BadgeView_maxCharacterCount, maxCharacterCount)
number = getInt(R.styleable.BadgeView_number, number)
val shape = ShapeAppearanceModel.builder(
context,
getResourceId(R.styleable.BadgeView_shapeAppearance, 0),
0,
).build()
background = MaterialShapeDrawable(shape).also { bg ->
bg.fillColor = getColorStateList(R.styleable.BadgeView_backgroundColor)
}
}
}
override fun onSaveInstanceState(): Parcelable? {
val superState = super.onSaveInstanceState() ?: return null
return SavedState(superState, number)
}
override fun onRestoreInstanceState(state: Parcelable?) {
if (state is SavedState) {
super.onRestoreInstanceState(state.superState)
number = state.number
} else {
super.onRestoreInstanceState(state)
}
}
private fun updateText() {
if (number <= 0) {
text = null
return
}
val numberString = number.toString()
text = if (numberString.length > maxCharacterCount) {
buildString(maxCharacterCount) {
repeat(maxCharacterCount - 1) { append('9') }
append('+')
}
} else {
numberString
}
}
private class SavedState : AbsSavedState {
val number: Int
constructor(superState: Parcelable, number: Int) : super(superState) {
this.number = number
}
constructor(source: Parcel, classLoader: ClassLoader?) : super(source, classLoader) {
number = source.readInt()
}
override fun writeToParcel(out: Parcel, flags: Int) {
super.writeToParcel(out, flags)
out.writeInt(number)
}
companion object {
@Suppress("unused")
@JvmField
val CREATOR: Creator<SavedState> = object : Creator<SavedState> {
override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader)
override fun newArray(size: Int): Array<SavedState?> = arrayOfNulls(size)
}
}
}
}

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.ui.widgets
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.View.OnClickListener
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
@@ -34,7 +33,7 @@ import com.google.android.material.R as materialR
class ChipsView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = com.google.android.material.R.attr.chipGroupStyle,
defStyleAttr: Int = materialR.attr.chipGroupStyle,
) : ChipGroup(context, attrs, defStyleAttr) {
@Inject
@@ -49,6 +48,7 @@ class ChipsView @JvmOverloads constructor(
onChipCloseClickListener?.onChipCloseClick(chip, data) ?: onChipClickListener?.onChipClick(chip, data)
}
private val chipStyle: Int
private val iconsVisible: Boolean
var onChipClickListener: OnChipClickListener? = null
set(value) {
field = value
@@ -60,6 +60,7 @@ class ChipsView @JvmOverloads constructor(
init {
val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0)
chipStyle = ta.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip)
iconsVisible = ta.getBoolean(R.styleable.ChipsView_chipIconVisible, true)
ta.recycle()
if (isInEditMode) {
@@ -170,12 +171,7 @@ class ChipsView @JvmOverloads constructor(
private fun bindIcon(model: ChipModel) {
when {
model.isChecked -> {
imageRequest?.dispose()
imageRequest = null
chipIcon = null
isChipIconVisible = false
}
model.isChecked -> disposeIcon()
model.isLoading -> {
imageRequest?.dispose()
@@ -184,6 +180,8 @@ class ChipsView @JvmOverloads constructor(
setProgressIcon()
}
!iconsVisible -> disposeIcon()
model.iconData != null -> {
val placeholder = model.icon.ifZero { materialR.drawable.navigation_empty_icon }
imageRequest = ImageRequest.Builder(context)
@@ -207,14 +205,16 @@ class ChipsView @JvmOverloads constructor(
isChipIconVisible = true
}
else -> {
imageRequest?.dispose()
imageRequest = null
chipIcon = null
isChipIconVisible = false
}
else -> disposeIcon()
}
}
private fun disposeIcon() {
imageRequest?.dispose()
imageRequest = null
chipIcon = null
isChipIconVisible = false
}
}
private inner class InternalChipClickListener : OnClickListener {

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
@@ -11,7 +12,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
import androidx.viewpager2.widget.ViewPager2
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
import org.koitharu.kotatsu.core.util.ext.measureDimension
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.parsers.util.toIntUp
@@ -30,6 +31,7 @@ class DotsIndicator @JvmOverloads constructor(
private var smallDotAlpha = 0.6f
private var positionOffset: Float = 0f
private var position: Int = 0
private var dotsColor: ColorStateList = ColorStateList.valueOf(Color.DKGRAY)
private val inset = context.resources.resolveDp(1f)
var max: Int = 6
@@ -52,10 +54,10 @@ class DotsIndicator @JvmOverloads constructor(
init {
paint.style = Paint.Style.FILL
context.withStyledAttributes(attrs, R.styleable.DotsIndicator, defStyleAttr) {
paint.color = getColor(
R.styleable.DotsIndicator_dotColor,
context.getThemeColor(materialR.attr.colorOnBackground, Color.DKGRAY),
)
dotsColor = getColorStateList(R.styleable.DotsIndicator_dotColor)
?: context.getThemeColorStateList(materialR.attr.colorOnBackground)
?: dotsColor
paint.color = dotsColor.getColorForState(drawableState, dotsColor.defaultColor)
indicatorSize = getDimension(R.styleable.DotsIndicator_dotSize, indicatorSize)
dotSpacing = getDimension(R.styleable.DotsIndicator_dotSpacing, dotSpacing)
smallDotScale = getFloat(R.styleable.DotsIndicator_dotScale, smallDotScale).coerceIn(0f, 1f)
@@ -89,6 +91,13 @@ class DotsIndicator @JvmOverloads constructor(
}
}
override fun drawableStateChanged() {
if (dotsColor.isStateful) {
paint.color = dotsColor.getColorForState(drawableState, dotsColor.defaultColor)
}
super.drawableStateChanged()
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val dotSize = getDotSize()
val desiredHeight = (dotSize + paddingTop + paddingBottom).toIntUp()

View File

@@ -28,6 +28,7 @@ import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import com.google.android.material.R as materialR
@Deprecated("")
class ProgressButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,

View File

@@ -4,14 +4,14 @@ import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import com.google.android.material.button.MaterialButtonGroup
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ViewZoomBinding
class ZoomControl @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : LinearLayout(context, attrs), View.OnClickListener {
) : MaterialButtonGroup(context, attrs), View.OnClickListener {
private val binding = ViewZoomBinding.inflate(LayoutInflater.from(context), this)

View File

@@ -2,13 +2,13 @@ package org.koitharu.kotatsu.core.util
import android.content.Context
import android.text.Editable
import android.text.TextWatcher
import android.widget.EditText
import androidx.annotation.CallSuper
import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import java.lang.ref.WeakReference
abstract class EditTextValidator : TextWatcher {
abstract class EditTextValidator : DefaultTextWatcher {
private var editTextRef: WeakReference<EditText>? = null
@@ -17,10 +17,6 @@ abstract class EditTextValidator : TextWatcher {
"EditTextValidator is not attached to EditText"
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
@CallSuper
override fun afterTextChanged(s: Editable?) {
val editText = editTextRef?.get() ?: return

View File

@@ -0,0 +1,36 @@
package org.koitharu.kotatsu.core.util
import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.core.util.ext.indexOfContains
import org.koitharu.kotatsu.core.util.ext.iterator
class LocaleStringComparator : Comparator<String?> {
private val deviceLocales: List<String?>
init {
val localeList = LocaleListCompat.getAdjustedDefault()
deviceLocales = buildList(localeList.size() + 1) {
add(null)
val set = HashSet<String?>(localeList.size() + 1)
set.add(null)
for (locale in localeList) {
val lang = locale.getDisplayLanguage(locale)
if (set.add(lang)) {
add(lang)
}
}
}
}
override fun compare(a: String?, b: String?): Int {
val indexA = deviceLocales.indexOfContains(a, true)
val indexB = deviceLocales.indexOfContains(b, true)
return when {
indexA < 0 && indexB < 0 -> compareValues(a, b)
indexA < 0 -> 1
indexB < 0 -> -1
else -> compareValues(indexA, indexB)
}
}
}

View File

@@ -0,0 +1,35 @@
package org.koitharu.kotatsu.core.util
import android.graphics.Paint
import androidx.core.graphics.PaintCompat
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import java.util.Locale
object LocaleUtils {
private val paint = Paint()
fun getEmojiFlag(locale: Locale): String? {
val code = when (val c = locale.country.ifNullOrEmpty { locale.toLanguageTag() }.uppercase(Locale.ENGLISH)) {
"EN" -> "GB"
"JA" -> "JP"
else -> c
}
val emoji = countryCodeToEmojiFlag(code)
return if (PaintCompat.hasGlyph(paint, emoji)) {
emoji
} else {
null
}
}
private fun countryCodeToEmojiFlag(countryCode: String): String {
return countryCode.map { char ->
Character.codePointAt("$char", 0) - 0x41 + 0x1F1E6
}.map { codePoint ->
Character.toChars(codePoint)
}.joinToString(separator = "") { charArray ->
String(charArray)
}
}
}

View File

@@ -0,0 +1,46 @@
package org.koitharu.kotatsu.core.util
import android.os.Build
import android.webkit.MimeTypeMap
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.util.ext.MimeType
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.parsers.util.removeSuffix
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File
import java.nio.file.Files
import coil3.util.MimeTypeMap as CoilMimeTypeMap
object MimeTypes {
fun getMimeTypeFromExtension(fileName: String): MimeType? {
return CoilMimeTypeMap.getMimeTypeFromExtension(getNormalizedExtension(fileName) ?: return null)
?.toMimeTypeOrNull()
}
fun getMimeTypeFromUrl(url: String): MimeType? {
return CoilMimeTypeMap.getMimeTypeFromUrl(url)?.toMimeTypeOrNull()
}
fun getExtension(mimeType: MimeType?): String? {
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType?.toString() ?: return null)?.nullIfEmpty()
}
@Blocking
fun probeMimeType(file: File): MimeType? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
runCatchingCancellable {
Files.probeContentType(file.toPath())?.toMimeTypeOrNull()
}.getOrNull()?.let { return it }
}
return getMimeTypeFromExtension(file.name)
}
fun getNormalizedExtension(name: String): String? = name
.lowercase()
.removeSuffix('~')
.removeSuffix(".tmp")
.substringAfterLast('.', "")
.takeIf { it.length in 2..5 }
}

View File

@@ -5,7 +5,6 @@ import android.annotation.SuppressLint
import android.app.Activity
import android.app.ActivityManager
import android.app.ActivityManager.MemoryInfo
import android.app.ActivityOptions
import android.app.LocaleConfig
import android.content.ComponentName
import android.content.Context
@@ -23,19 +22,18 @@ import android.graphics.Bitmap
import android.graphics.Color
import android.net.ConnectivityManager
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.view.View
import android.view.ViewPropertyAnimator
import android.view.Window
import android.webkit.CookieManager
import android.webkit.WebView
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.CheckResult
import androidx.annotation.IntegerRes
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDialog
import androidx.core.app.ActivityCompat
import androidx.core.app.ActivityOptionsCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
@@ -86,12 +84,14 @@ suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable
setForeground(info)
}.isSuccess
@CheckResult
fun <I> ActivityResultLauncher<I>.resolve(context: Context, input: I): ResolveInfo? {
val pm = context.packageManager
val intent = contract.createIntent(context, input)
return pm.resolveActivity(intent, 0)
}
@CheckResult
fun <I> ActivityResultLauncher<I>.tryLaunch(
input: I,
options: ActivityOptionsCompat? = null,
@@ -171,7 +171,7 @@ fun Context.getAnimationDuration(@IntegerRes resId: Int): Long {
}
fun Context.isLowRamDevice(): Boolean {
return activityManager?.isLowRamDevice ?: false
return activityManager?.isLowRamDevice == true
}
fun Context.isPowerSaveMode(): Boolean {
@@ -185,18 +185,6 @@ val Context.ramAvailable: Long
return result.availMem
}
fun scaleUpActivityOptionsOf(view: View): Bundle? = if (view.context.isAnimationsEnabled) {
ActivityOptions.makeScaleUpAnimation(
view,
0,
0,
view.width,
view.height,
).toBundle()
} else {
null
}
@SuppressLint("DiscouragedApi")
fun Context.getLocalesConfig(): LocaleListCompat {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -277,6 +265,9 @@ fun WebView.configureForParser(userAgentOverride: String?) = with(settings) {
if (userAgentOverride != null) {
userAgentString = userAgentOverride
}
val cookieManager = CookieManager.getInstance()
cookieManager.setAcceptCookie(true)
cookieManager.setAcceptThirdPartyCookies(this@configureForParser, true)
}
fun Context.restartApplication() {

View File

@@ -110,3 +110,5 @@ fun <T : Parcelable> Parcelable.Creator<T>.unmarshall(bytes: ByteArray): T {
parcel.recycle()
}
}
inline fun buildBundle(capacity: Int, block: Bundle.() -> Unit): Bundle = Bundle(capacity).apply(block)

View File

@@ -108,3 +108,7 @@ fun <T, R> Collection<T>.mapSortedByCount(isDescending: Boolean = true, mapper:
}
return sorted.map { it.first }
}
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

@@ -7,15 +7,13 @@ import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.core.database.getStringOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.fs.FileSequence
import org.koitharu.kotatsu.core.util.MimeTypes
import java.io.BufferedReader
import java.io.File
import java.nio.file.attribute.BasicFileAttributes
@@ -41,12 +39,6 @@ fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).use { output ->
output.bufferedReader().use(BufferedReader::readText)
}
val ZipEntry.mimeType: MediaType?
get() {
val ext = name.substringAfterLast('.')
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)?.toMediaTypeOrNull()
}
fun File.getStorageName(context: Context): String = runCatching {
val manager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
@@ -115,3 +107,6 @@ fun File.walkCompat(includeDirectories: Boolean): Sequence<File> = if (Build.VER
val walk = walk()
if (includeDirectories) walk else walk.filter { it.isFile }
}
val File.normalizedExtension: String?
get() = MimeTypes.getNormalizedExtension(name)

View File

@@ -2,9 +2,7 @@ package org.koitharu.kotatsu.core.util.ext
import android.os.Bundle
import androidx.core.view.MenuProvider
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
@@ -18,36 +16,10 @@ inline fun <T : Fragment> T.withArgs(size: Int, block: Bundle.() -> Unit): T {
val Fragment.viewLifecycleScope
inline get() = viewLifecycleOwner.lifecycle.coroutineScope
fun DialogFragment.showAllowStateLoss(manager: FragmentManager, tag: String?) {
if (!manager.isStateSaved) {
show(manager, tag)
}
}
fun Fragment.addMenuProvider(provider: MenuProvider) {
requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.RESUMED)
}
fun DialogFragment.showDistinct(fm: FragmentManager, tag: String) {
val existing = fm.findFragmentByTag(tag) as? DialogFragment?
if (existing != null && existing.isVisible && existing.arguments == this.arguments) {
return
}
show(fm, tag)
}
tailrec fun Fragment.dismissParentDialog(): Boolean {
return when (val parent = parentFragment) {
null -> return false
is DialogFragment -> {
parent.dismiss()
true
}
else -> parent.dismissParentDialog()
}
}
@Suppress("UNCHECKED_CAST")
tailrec fun <T> Fragment.findParentCallback(cls: Class<T>): T? {
val parent = parentFragment

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.util.ext
import android.content.res.ColorStateList
import android.graphics.Bitmap
import android.graphics.Rect
import kotlin.math.roundToInt
@@ -18,3 +19,7 @@ inline fun <R> Bitmap.use(block: (Bitmap) -> R) = try {
} finally {
recycle()
}
fun ColorStateList.hasFocusStateSpecified(): Boolean {
return getColorForState(intArrayOf(android.R.attr.state_focused), defaultColor) != defaultColor
}

View File

@@ -0,0 +1,33 @@
package org.koitharu.kotatsu.core.util.ext
import okhttp3.MediaType
private const val TYPE_IMAGE = "image"
private val REGEX_MIME = Regex("^\\w+/([-+.\\w]+|\\*)$", RegexOption.IGNORE_CASE)
@JvmInline
value class MimeType(private val value: String) {
val type: String?
get() = value.substringBefore('/', "").takeIfSpecified()
val subtype: String?
get() = value.substringAfterLast('/', "").takeIfSpecified()
private fun String.takeIfSpecified(): String? = takeUnless {
it.isEmpty() || it == "*"
}
override fun toString(): String = value
}
fun MediaType.toMimeType(): MimeType = MimeType("$type/$subtype")
fun String.toMimeTypeOrNull(): MimeType? = if (REGEX_MIME.matches(this)) {
MimeType(lowercase())
} else {
null
}
val MimeType.isImage: Boolean
get() = type == TYPE_IMAGE

View File

@@ -24,6 +24,7 @@ import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.exceptions.SyncApiException
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.exceptions.WrapperIOException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.io.NullOutputStream
@@ -54,6 +55,8 @@ fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessag
?: resources.getString(R.string.error_occurred)
private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) {
is CaughtException -> cause.getDisplayMessageOrNull(resources)
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
is ScrobblerAuthRequiredException -> resources.getString(
R.string.scrobbler_auth_required,
resources.getString(scrobbler.titleResId),
@@ -141,7 +144,8 @@ fun Throwable.getCauseUrl(): String? = when (this) {
is ParseException -> url
is NotFoundException -> url
is TooManyRequestExceptions -> url
is CaughtException -> cause?.getCauseUrl()
is CaughtException -> cause.getCauseUrl()
is WrapperIOException -> cause.getCauseUrl()
is NoDataReceivedException -> url
is CloudFlareBlockedException -> url
is CloudFlareProtectedException -> url
@@ -175,7 +179,10 @@ fun Throwable.isReportable(): Boolean {
return true
}
if (this is CaughtException) {
return cause?.isReportable() == true
return cause.isReportable()
}
if (this is WrapperIOException) {
return cause.isReportable()
}
if (ExceptionResolver.canResolve(this)) {
return false

View File

@@ -1,7 +1,5 @@
package org.koitharu.kotatsu.core.util.iterator
import org.koitharu.kotatsu.R
class MappingIterator<T, R>(
private val upstream: Iterator<T>,
private val mapper: (T) -> R,

View File

@@ -1,8 +1,11 @@
package org.koitharu.kotatsu.details.domain
import org.koitharu.kotatsu.core.util.LocaleStringComparator
import org.koitharu.kotatsu.details.ui.model.MangaBranch
class BranchComparator : Comparator<MangaBranch> {
override fun compare(o1: MangaBranch, o2: MangaBranch): Int = compareValues(o1.name, o2.name)
private val delegate = LocaleStringComparator()
override fun compare(o1: MangaBranch, o2: MangaBranch): Int = delegate.compare(o1.name, o2.name)
}

View File

@@ -14,8 +14,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import okio.IOException
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.nav.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.peek
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
@@ -43,7 +43,14 @@ class DetailsLoadUseCase @Inject constructor(
operator fun invoke(intent: MangaIntent): Flow<MangaDetails> = channelFlow {
val manga = requireNotNull(mangaDataRepository.resolveIntent(intent)) {
"Cannot resolve intent $intent"
}.let { m ->
if (m.chapters.isNullOrEmpty()) {
getCachedDetails(m.id) ?: m
} else {
m
}
}
send(MangaDetails(manga, null, null, false))
val local = if (!manga.isLocal) {
async {
localMangaRepository.findSavedManga(manga)
@@ -51,9 +58,9 @@ class DetailsLoadUseCase @Inject constructor(
} else {
null
}
send(MangaDetails(manga, null, null, false))
try {
val details = getDetails(manga)
launch { mangaDataRepository.updateChapters(details) }
launch { updateTracker(details) }
send(
MangaDetails(
@@ -122,4 +129,8 @@ class DetailsLoadUseCase @Inject constructor(
}.onFailure { e ->
e.printStackTraceDebug()
}
private suspend fun getCachedDetails(mangaId: Long): Manga? = runCatchingCancellable {
mangaDataRepository.findMangaById(mangaId, withChapters = true)
}.getOrNull()
}

View File

@@ -15,7 +15,7 @@ class ReadingTimeUseCase @Inject constructor(
private val statsRepository: StatsRepository,
) {
suspend fun invoke(manga: MangaDetails?, branch: String?, history: MangaHistory?): ReadingTime? {
suspend operator fun invoke(manga: MangaDetails?, branch: String?, history: MangaHistory?): ReadingTime? {
if (!settings.isReadingTimeEstimationEnabled) {
return null
}

View File

@@ -67,7 +67,7 @@ fun MangaDetails.mapChapters(
return result
}
fun List<ChapterListItem>.withVolumeHeaders(context: Context): List<ListModel> {
fun List<ChapterListItem>.withVolumeHeaders(context: Context): MutableList<ListModel> {
var prevVolume = 0
val result = ArrayList<ListModel>((size * 1.4).toInt())
for (item in this) {

View File

@@ -1,15 +1,9 @@
package org.koitharu.kotatsu.details.ui
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.text.style.DynamicDrawableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.ImageSpan
import android.text.style.RelativeSizeSpan
import android.transition.TransitionManager
import android.view.Menu
import android.view.Gravity
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
@@ -18,8 +12,6 @@ import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
@@ -37,9 +29,11 @@ import coil3.request.lifecycle
import coil3.request.placeholder
import coil3.request.target
import coil3.request.transformations
import coil3.size.Precision
import coil3.size.Scale
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
@@ -55,28 +49,30 @@ import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.iconResId
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.nav.ReaderIntent
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.parser.MangaIntent
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.image.ChipIconTarget
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
import org.koitharu.kotatsu.core.ui.image.TextDrawable
import org.koitharu.kotatsu.core.ui.image.TextViewTarget
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.sheet.BottomSheetCollapseCallback
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
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.crossfade
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.drawable
import org.koitharu.kotatsu.core.util.ext.drawableStart
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.isTextTruncated
import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit
@@ -84,41 +80,29 @@ import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.databinding.LayoutDetailsTableBinding
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.data.ReadingTime
import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet
import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration
import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.list.domain.MangaListMapper
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.local.ui.info.LocalInfoDialog
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
import javax.inject.Inject
import kotlin.math.roundToInt
import com.google.android.material.R as materialR
@AndroidEntryPoint
@@ -140,30 +124,24 @@ class DetailsActivity :
private val viewModel: DetailsViewModel by viewModels()
private lateinit var menuProvider: DetailsMenuProvider
private lateinit var infoBinding: LayoutDetailsTableBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityDetailsBinding.inflate(layoutInflater))
infoBinding = LayoutDetailsTableBinding.bind(viewBinding.root)
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setDisplayShowTitleEnabled(false)
}
viewBinding.buttonRead.setOnClickListener(this)
viewBinding.buttonRead.setOnLongClickListener(this)
viewBinding.buttonRead.setOnContextClickListenerCompat(this)
viewBinding.buttonDownload?.setOnClickListener(this)
viewBinding.infoLayout.chipBranch.setOnClickListener(this)
viewBinding.infoLayout.chipSize.setOnClickListener(this)
viewBinding.infoLayout.chipSource.setOnClickListener(this)
viewBinding.infoLayout.chipFavorite.setOnClickListener(this)
viewBinding.infoLayout.chipAuthor.setOnClickListener(this)
viewBinding.infoLayout.chipTime.setOnClickListener(this)
viewBinding.chipFavorite.setOnClickListener(this)
infoBinding.textViewLocal.setOnClickListener(this)
infoBinding.textViewAuthor.setOnClickListener(this)
infoBinding.textViewSource.setOnClickListener(this)
viewBinding.imageViewCover.setOnClickListener(this)
viewBinding.buttonDescriptionMore.setOnClickListener(this)
viewBinding.buttonScrobblingMore.setOnClickListener(this)
viewBinding.buttonRelatedMore.setOnClickListener(this)
viewBinding.infoLayout.chipSource.setOnClickListener(this)
viewBinding.infoLayout.chipSize.setOnClickListener(this)
viewBinding.textViewDescription.addOnLayoutChangeListener(this)
viewBinding.swipeRefreshLayout.setOnRefreshListener(this)
viewBinding.textViewDescription.viewTreeObserver.addOnDrawListener(this)
@@ -172,16 +150,19 @@ class DetailsActivity :
TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView)
viewBinding.containerBottomSheet?.let { sheet ->
onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(sheet))
BottomSheetBehavior.from(sheet)
.addBottomSheetCallback(DetailsBottomSheetCallback(viewBinding.swipeRefreshLayout))
}
TitleExpandListener(viewBinding.textViewTitle).attach()
val appRouter = router
viewModel.mangaDetails.filterNotNull().observe(this, ::onMangaUpdated)
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
viewModel.onError
.filterNot { ChaptersPagesSheet.isShown(supportFragmentManager) }
.filterNot { appRouter.isChapterPagesSheetShown() }
.observeEvent(this, DetailsErrorObserver(this, viewModel, exceptionResolver))
viewModel.onActionDone
.filterNot { ChaptersPagesSheet.isShown(supportFragmentManager) }
.filterNot { appRouter.isChapterPagesSheetShown() }
.observeEvent(this, ReversibleActionObserver(viewBinding.scrollView, null))
combine(viewModel.historyInfo, viewModel.isLoading, ::Pair).observe(this) {
onHistoryChanged(it.first, it.second)
@@ -190,24 +171,24 @@ class DetailsActivity :
viewModel.scrobblingInfo.observe(this, ::onScrobblingInfoChanged)
viewModel.localSize.observe(this, ::onLocalSizeChanged)
viewModel.relatedManga.observe(this, ::onRelatedMangaChanged)
viewModel.readingTime.observe(this, ::onReadingTimeChanged)
viewModel.selectedBranch.observe(this) {
viewBinding.infoLayout.chipBranch.text = it.ifNullOrEmpty { getString(R.string.system_default) }
}
viewModel.favouriteCategories.observe(this, ::onFavoritesChanged)
val menuInvalidator = MenuInvalidator(this)
viewModel.isStatsAvailable.observe(this, menuInvalidator)
viewModel.remoteManga.observe(this, menuInvalidator)
viewModel.branches.observe(this) {
viewBinding.infoLayout.chipBranch.isVisible = it.size > 1 || !it.firstOrNull()?.name.isNullOrEmpty()
viewBinding.infoLayout.chipBranch.isCloseIconVisible = it.size > 1
val branch = it.singleOrNull()
infoBinding.textViewTranslation.textAndVisible = branch?.name
infoBinding.textViewTranslation.drawableStart = branch?.locale?.let {
LocaleUtils.getEmojiFlag(it)
}?.let {
TextDrawable.compound(infoBinding.textViewTranslation, it)
}
infoBinding.textViewTranslationLabel.isVisible = infoBinding.textViewTranslation.isVisible
}
viewModel.chapters.observe(this, PrefetchObserver(this))
viewModel.onDownloadStarted
.filterNot { ChaptersPagesSheet.isShown(supportFragmentManager) }
.filterNot { appRouter.isChapterPagesSheetShown() }
.observeEvent(this, DownloadStartedObserver(viewBinding.scrollView))
DownloadDialogFragment.registerCallback(this, viewBinding.scrollView)
menuProvider = DetailsMenuProvider(
activity = this,
viewModel = viewModel,
@@ -221,63 +202,32 @@ class DetailsActivity :
override fun onClick(v: View) {
when (v.id) {
R.id.button_read -> openReader(isIncognitoMode = false)
R.id.chip_branch -> showBranchPopupMenu(v)
R.id.button_download -> {
R.id.textView_author -> {
val manga = viewModel.manga.value ?: return
DownloadDialogFragment.show(supportFragmentManager, listOf(manga))
router.openSearch(manga.source, manga.author ?: return)
}
R.id.chip_author -> {
R.id.textView_source -> {
val manga = viewModel.manga.value ?: return
startActivity(
MangaListActivity.newIntent(
context = v.context,
source = manga.source,
filter = MangaListFilter(query = manga.author),
),
)
router.openList(manga.source, null)
}
R.id.chip_source -> {
R.id.textView_local -> {
val manga = viewModel.manga.value ?: return
startActivity(
MangaListActivity.newIntent(
context = v.context,
source = manga.source,
filter = null,
),
)
}
R.id.chip_size -> {
val manga = viewModel.manga.value ?: return
LocalInfoDialog.show(supportFragmentManager, manga)
router.showLocalInfoDialog(manga)
}
R.id.chip_favorite -> {
val manga = viewModel.manga.value ?: return
FavoriteSheet.show(supportFragmentManager, manga)
}
R.id.chip_time -> {
if (viewModel.isStatsAvailable.value) {
val manga = viewModel.manga.value ?: return
MangaStatsSheet.show(supportFragmentManager, manga)
} else {
// TODO
}
router.showFavoriteDialog(manga)
}
R.id.imageView_cover -> {
val manga = viewModel.manga.value ?: return
startActivity(
ImageActivity.newIntent(
v.context,
manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl },
manga.source,
),
scaleUpActivityOptionsOf(v),
router.openImage(
url = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl },
source = manga.source,
anchor = v,
)
}
@@ -293,12 +243,12 @@ class DetailsActivity :
R.id.button_scrobbling_more -> {
val manga = viewModel.manga.value ?: return
ScrobblingSelectorSheet.show(supportFragmentManager, manga, null)
router.showScrobblingSelectorSheet(manga, null)
}
R.id.button_related_more -> {
val manga = viewModel.manga.value ?: return
startActivity(RelatedMangaActivity.newIntent(v.context, manga))
router.openRelated(manga)
}
}
}
@@ -306,7 +256,7 @@ class DetailsActivity :
override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag ?: return
// TODO dialog
startActivity(MangaListActivity.newIntent(this, tag.source, MangaListFilter(tags = setOf(tag))))
router.openList(tag)
}
override fun onContextClick(v: View): Boolean = onLongClick(v)
@@ -344,9 +294,7 @@ class DetailsActivity :
}
override fun onItemClick(item: Bookmark, view: View) {
startActivity(
ReaderActivity.IntentBuilder(view.context).bookmark(item).incognito(true).build(),
)
router.openReader(ReaderIntent.Builder(view.context).bookmark(item).incognito(true).build())
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
@@ -378,7 +326,7 @@ class DetailsActivity :
}
private fun onFavoritesChanged(categories: Set<FavouriteCategory>) {
val chip = viewBinding.infoLayout.chipFavorite
val chip = viewBinding.chipFavorite
chip.setChipIconResource(if (categories.isEmpty()) R.drawable.ic_heart_outline else R.drawable.ic_heart)
chip.text = if (categories.isEmpty()) {
getString(R.string.add_to_favourites)
@@ -387,18 +335,14 @@ class DetailsActivity :
}
}
private fun onReadingTimeChanged(time: ReadingTime?) {
val chip = viewBinding.infoLayout.chipTime
chip.textAndVisible = time?.formatShort(chip.resources)
}
private fun onLocalSizeChanged(size: Long) {
val chip = viewBinding.infoLayout.chipSize
if (size == 0L) {
chip.isVisible = false
infoBinding.textViewLocal.isVisible = false
infoBinding.textViewLocalLabel.isVisible = false
} else {
chip.text = FileSize.BYTES.format(chip.context, size)
chip.isVisible = true
infoBinding.textViewLocal.text = FileSize.BYTES.format(this, size)
infoBinding.textViewLocal.isVisible = true
infoBinding.textViewLocalLabel.isVisible = true
}
}
@@ -417,7 +361,7 @@ class DetailsActivity :
coil, this,
StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)),
) { item, view ->
startActivity(newIntent(view.context, item))
router.openDetails(item)
},
).also { rv.adapter = it }
adapter.items = related
@@ -434,7 +378,7 @@ class DetailsActivity :
if (adapter != null) {
adapter.items = scrobblings
} else {
adapter = ScrollingInfoAdapter(this, coil, supportFragmentManager)
adapter = ScrollingInfoAdapter(this, coil, router)
adapter.items = scrobblings
viewBinding.recyclerViewScrobbling.adapter = adapter
viewBinding.recyclerViewScrobbling.addItemDecoration(ScrobblingItemDecoration())
@@ -442,62 +386,59 @@ class DetailsActivity :
}
private fun onMangaUpdated(details: MangaDetails) {
val manga = details.toManga()
loadCover(manga)
with(viewBinding) {
val manga = details.toManga()
// Main
loadCover(manga)
textViewTitle.text = manga.title
textViewSubtitle.textAndVisible = manga.altTitle
infoLayout.chipAuthor.textAndVisible = manga.author?.ellipsize(AUTHOR_LABEL_LIMIT)
textViewNsfw.isVisible = manga.isNsfw
textViewDescription.text = details.description.ifNullOrEmpty { getString(R.string.no_description) }
}
with(infoBinding) {
textViewAuthor.textAndVisible = manga.author
textViewAuthorLabel.isVisible = textViewAuthor.isVisible
if (manga.hasRating) {
ratingBar.rating = manga.rating * ratingBar.numStars
ratingBar.isVisible = true
ratingBarRating.rating = manga.rating * ratingBarRating.numStars
ratingBarRating.isVisible = true
textViewRatingLabel.isVisible = true
} else {
ratingBar.isVisible = false
ratingBarRating.isVisible = false
textViewRatingLabel.isVisible = false
}
manga.state?.let { state ->
textViewState.textAndVisible = resources.getString(state.titleResId)
imageViewState.setImageResource(state.iconResId)
imageViewState.isVisible = true
textViewStateLabel.isVisible = textViewState.isVisible
} ?: run {
textViewState.isVisible = false
imageViewState.isVisible = false
textViewStateLabel.isVisible = false
}
if (manga.source == LocalMangaSource || manga.source == UnknownMangaSource) {
infoLayout.chipSource.isVisible = false
textViewSource.isVisible = false
textViewSourceLabel.isVisible = false
} else {
infoLayout.chipSource.text = manga.source.getTitle(this@DetailsActivity)
infoLayout.chipSource.isVisible = true
textViewSource.textAndVisible = manga.source.getTitle(this@DetailsActivity)
textViewSourceLabel.isVisible = textViewSource.isVisible == true
}
textViewNsfw.isVisible = manga.isNsfw
// Chips
bindTags(manga)
textViewDescription.text = details.description.ifNullOrEmpty { getString(R.string.no_description) }
viewBinding.infoLayout.chipSource.also { chip ->
ImageRequest.Builder(this@DetailsActivity)
.data(manga.source.faviconUri())
.lifecycle(this@DetailsActivity)
.crossfade(false)
.size(resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
.target(ChipIconTarget(chip))
.placeholder(R.drawable.ic_web)
.fallback(R.drawable.ic_web)
.error(R.drawable.ic_web)
.mangaSourceExtra(manga.source)
.transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.chip_icon_corner)))
.allowRgb565(true)
.enqueueWith(coil)
}
title = manga.title
invalidateOptionsMenu()
val faviconPlaceholderFactory = FaviconDrawable.Factory(R.style.FaviconDrawable_Chip)
ImageRequest.Builder(this@DetailsActivity)
.data(manga.source.faviconUri())
.lifecycle(this@DetailsActivity)
.crossfade(false)
.precision(Precision.EXACT)
.size(resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
.target(TextViewTarget(textViewSource, Gravity.START))
.placeholder(faviconPlaceholderFactory)
.error(faviconPlaceholderFactory)
.fallback(faviconPlaceholderFactory)
.mangaSourceExtra(manga.source)
.transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.chip_icon_corner)))
.allowRgb565(true)
.enqueueWith(coil)
}
bindTags(manga)
title = manga.title
invalidateOptionsMenu()
}
private fun onMangaRemoved(manga: Manga) {
@@ -527,69 +468,32 @@ class DetailsActivity :
}
}
private fun onHistoryChanged(info: HistoryInfo, isLoading: Boolean) = with(viewBinding) {
buttonRead.setTitle(if (info.canContinue) R.string._continue else R.string.read)
buttonRead.subtitle = when {
private fun onHistoryChanged(info: HistoryInfo, isLoading: Boolean) = with(infoBinding) {
textViewChapters.text = when {
isLoading -> getString(R.string.loading_)
info.isIncognitoMode -> getString(R.string.incognito_mode)
info.isChapterMissing -> getString(R.string.chapter_is_missing)
info.currentChapter >= 0 -> getString(R.string.chapter_d_of_d, info.currentChapter + 1, info.totalChapters)
info.currentChapter >= 0 -> getString(
R.string.chapter_d_of_d,
info.currentChapter + 1,
info.totalChapters,
).withEstimatedTime(info.estimatedTime)
info.totalChapters == 0 -> getString(R.string.no_chapters)
info.totalChapters == -1 -> getString(R.string.error_occurred)
else -> resources.getQuantityString(R.plurals.chapters, info.totalChapters, info.totalChapters)
.withEstimatedTime(info.estimatedTime)
}
val isFirstCall = buttonRead.tag == null
buttonRead.tag = Unit
buttonRead.setProgress(info.percent.coerceIn(0f, 1f), !isFirstCall)
buttonDownload?.isEnabled = info.isValid && info.canDownload
buttonRead.isEnabled = info.isValid
}
private fun showBranchPopupMenu(v: View) {
val branches = viewModel.branches.value
if (branches.size <= 1) {
return
textViewProgress.textAndVisible = if (info.percent <= 0f) {
null
} else {
getString(R.string.percent_string_pattern, (info.percent * 100f).toInt().toString())
}
val menu = PopupMenu(v.context, v)
for ((i, branch) in branches.withIndex()) {
val title = buildSpannedString {
if (branch.isCurrent) {
inSpans(
ImageSpan(
this@DetailsActivity,
R.drawable.ic_current_chapter,
DynamicDrawableSpan.ALIGN_BASELINE,
),
) {
append(' ')
}
append(' ')
}
append(branch.name ?: getString(R.string.system_default))
append(' ')
append(' ')
inSpans(
ForegroundColorSpan(
v.context.getThemeColor(
android.R.attr.textColorSecondary,
Color.LTGRAY,
),
),
RelativeSizeSpan(0.74f),
) {
append(branch.count.toString())
}
}
val item = menu.menu.add(R.id.group_branches, Menu.NONE, i, title)
item.isCheckable = true
item.isChecked = branch.isSelected
}
menu.menu.setGroupCheckable(R.id.group_branches, true, true)
menu.setOnMenuItemClickListener {
viewModel.setSelectedBranch(branches.getOrNull(it.order)?.name)
true
}
menu.show()
progress.setProgressCompat(
(progress.max * info.percent.coerceIn(0f, 1f)).roundToInt(),
true,
)
textViewProgressLabel.isVisible = info.history != null
textViewProgress.isVisible = info.history != null
progress.isVisible = info.history != null
}
private fun openReader(isIncognitoMode: Boolean) {
@@ -598,8 +502,8 @@ class DetailsActivity :
Snackbar.make(viewBinding.scrollView, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT)
.show()
} else {
startActivity(
ReaderActivity.IntentBuilder(this)
router.openReader(
ReaderIntent.Builder(this)
.manga(manga)
.branch(viewModel.selectedBranchValue)
.incognito(isIncognitoMode)
@@ -642,6 +546,14 @@ class DetailsActivity :
request.enqueueWith(coil)
}
private fun String.withEstimatedTime(time: ReadingTime?): String {
if (time == null) {
return this
}
val timeFormatted = time.formatShort(resources)
return getString(R.string.chapters_time_pattern, this, timeFormatted)
}
private class PrefetchObserver(
private val context: Context,
) : FlowCollector<List<ChapterListItem>?> {
@@ -663,16 +575,5 @@ class DetailsActivity :
companion object {
private const val FAV_LABEL_LIMIT = 16
private const val AUTHOR_LABEL_LIMIT = 16
fun newIntent(context: Context, manga: Manga): Intent {
return Intent(context, DetailsActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
}
fun newIntent(context: Context, mangaId: Long): Intent {
return Intent(context, DetailsActivity::class.java)
.putExtra(MangaIntent.KEY_ID, mangaId)
}
}
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.details.ui
import android.view.View
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.bottomsheet.BottomSheetBehavior
class DetailsBottomSheetCallback(
private val swipeRefreshLayout: SwipeRefreshLayout,
) : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
swipeRefreshLayout.isEnabled = newState == BottomSheetBehavior.STATE_COLLAPSED
}
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
}

View File

@@ -5,7 +5,6 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.exceptions.resolve.ErrorObserver
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isNetworkError
import org.koitharu.kotatsu.core.util.ext.isSerializable
@@ -38,10 +37,10 @@ class DetailsErrorObserver(
}
value is ParseException -> {
val fm = fragmentManager
if (fm != null && value.isSerializable()) {
val router = router()
if (router != null && value.isSerializable()) {
snackbar.setAction(R.string.details) {
ErrorDetailsDialog.show(fm, value, value.url)
router.showErrorDialog(value)
}
}
}

View File

@@ -14,16 +14,11 @@ 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.alternatives.ui.AlternativesActivity
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.isLocal
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.download.ui.dialog.DownloadDialogFragment
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.multi.SearchActivity
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
class DetailsMenuProvider(
private val activity: FragmentActivity,
@@ -49,23 +44,21 @@ class DetailsMenuProvider(
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
val manga = viewModel.getMangaOrNull() ?: return false
when (menuItem.itemId) {
R.id.action_share -> {
viewModel.manga.value?.let {
val shareHelper = ShareHelper(activity)
if (it.isLocal) {
shareHelper.shareCbz(listOf(it.url.toUri().toFile()))
} else {
shareHelper.shareMangaLink(it)
}
val shareHelper = ShareHelper(activity)
if (manga.isLocal) {
shareHelper.shareCbz(listOf(manga.url.toUri().toFile()))
} else {
shareHelper.shareMangaLink(manga)
}
}
R.id.action_delete -> {
val title = viewModel.manga.value?.title.orEmpty()
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.delete_manga)
.setMessage(activity.getString(R.string.text_delete_local_manga, title))
.setMessage(activity.getString(R.string.text_delete_local_manga, manga.title))
.setPositiveButton(R.string.delete) { _, _ ->
viewModel.deleteLocal()
}
@@ -74,52 +67,38 @@ class DetailsMenuProvider(
}
R.id.action_save -> {
DownloadDialogFragment.show(activity.supportFragmentManager, listOfNotNull(viewModel.manga.value))
activity.router.showDownloadDialog(manga, snackbarHost)
}
R.id.action_browser -> {
viewModel.manga.value?.let {
activity.startActivity(BrowserActivity.newIntent(activity, it.publicUrl, it.source, it.title))
}
activity.router.openBrowser(url = manga.publicUrl, source = manga.source, title = manga.title)
}
R.id.action_online -> {
viewModel.remoteManga.value?.let {
activity.startActivity(DetailsActivity.newIntent(activity, it))
}
activity.router.openDetails(manga)
}
R.id.action_related -> {
viewModel.manga.value?.let {
activity.startActivity(SearchActivity.newIntent(activity, it.title))
}
activity.router.openSearch(manga.title)
}
R.id.action_alternatives -> {
viewModel.manga.value?.let {
activity.startActivity(AlternativesActivity.newIntent(activity, it))
}
activity.router.openAlternatives(manga)
}
R.id.action_stats -> {
viewModel.manga.value?.let {
MangaStatsSheet.show(activity.supportFragmentManager, it)
}
activity.router.showStatisticSheet(manga)
}
R.id.action_scrobbling -> {
viewModel.manga.value?.let {
ScrobblingSelectorSheet.show(activity.supportFragmentManager, it, null)
}
activity.router.showScrobblingSelectorSheet(manga, null)
}
R.id.action_shortcut -> {
viewModel.manga.value?.let {
activity.lifecycleScope.launch {
if (!appShortcutManager.requestPinShortcut(it)) {
Snackbar.make(snackbarHost, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
.show()
}
activity.lifecycleScope.launch {
if (!appShortcutManager.requestPinShortcut(manga)) {
Snackbar.make(snackbarHost, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
.show()
}
}
}

View File

@@ -21,7 +21,7 @@ import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.nav.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
@@ -112,12 +112,14 @@ class DetailsViewModel @Inject constructor(
history,
interactor.observeIncognitoMode(manga),
) { m, b, h, im ->
HistoryInfo(m, b, h, im)
}.stateIn(
scope = viewModelScope + Dispatchers.Default,
started = SharingStarted.Eagerly,
initialValue = HistoryInfo(null, null, null, false),
)
val estimatedTime = readingTimeUseCase.invoke(m, b, h)
HistoryInfo(m, b, h, im, estimatedTime)
}.withErrorHandling()
.stateIn(
scope = viewModelScope + Dispatchers.Default,
started = SharingStarted.Eagerly,
initialValue = HistoryInfo(null, null, null, false, null),
)
val localSize = mangaDetails
.map { it?.local }
@@ -170,14 +172,6 @@ class DetailsViewModel @Inject constructor(
}.sortedWith(BranchComparator())
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val readingTime = combine(
mangaDetails,
selectedBranch,
history,
) { m, b, h ->
readingTimeUseCase.invoke(m, b, h)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, null)
val selectedBranchValue: String?
get() = selectedBranch.value
@@ -222,14 +216,6 @@ class DetailsViewModel @Inject constructor(
}
}
fun startChaptersSelection() {
val chapters = chapters.value
val chapter = chapters.find {
it.isUnread && !it.isDownloaded
} ?: chapters.firstOrNull() ?: return
onSelectChapter.call(chapter.chapter.id)
}
fun removeFromHistory() {
launchJob(Dispatchers.Default) {
val handle = historyRepository.delete(setOf(mangaId))
@@ -240,14 +226,15 @@ class DetailsViewModel @Inject constructor(
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
detailsLoadUseCase.invoke(intent)
.onEachWhile {
if (it.allChapters.isEmpty()) {
return@onEachWhile false
if (it.allChapters.isNotEmpty()) {
val manga = it.toManga()
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = manga.getPreferredBranch(hist)
true
} else {
false
}
val manga = it.toManga()
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = manga.getPreferredBranch(hist)
true
}.collect {
mangaDetails.value = it
}

View File

@@ -0,0 +1,175 @@
package org.koitharu.kotatsu.details.ui
import android.content.Context
import android.graphics.Color
import android.text.style.DynamicDrawableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.ImageSpan
import android.text.style.RelativeSizeSpan
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.core.view.MenuCompat
import androidx.core.view.get
import androidx.lifecycle.LifecycleOwner
import com.google.android.material.button.MaterialButton
import com.google.android.material.button.MaterialSplitButton
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.ReaderIntent
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
class ReadButtonDelegate(
private val splitButton: MaterialSplitButton,
private val viewModel: DetailsViewModel,
private val router: AppRouter,
) : View.OnClickListener, PopupMenu.OnMenuItemClickListener, PopupMenu.OnDismissListener {
private val buttonRead = splitButton[0] as MaterialButton
private val buttonMenu = splitButton[1] as MaterialButton
private val context: Context
get() = buttonRead.context
override fun onClick(v: View) {
when (v.id) {
R.id.button_read -> openReader(isIncognitoMode = false)
R.id.button_read_menu -> showMenu()
}
}
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_incognito -> openReader(isIncognitoMode = true)
R.id.action_forget -> viewModel.removeFromHistory()
R.id.action_download -> {
router.showDownloadDialog(
manga = setOf(viewModel.getMangaOrNull() ?: return false),
snackbarHost = splitButton,
)
}
Menu.NONE -> {
val branch = viewModel.branches.value.getOrNull(item.order) ?: return false
viewModel.setSelectedBranch(branch.name)
}
else -> return false
}
return true
}
override fun onDismiss(menu: PopupMenu?) {
buttonMenu.isChecked = false
}
fun attach(lifecycleOwner: LifecycleOwner) {
buttonRead.setOnClickListener(this)
buttonMenu.setOnClickListener(this)
combine(viewModel.isLoading, viewModel.historyInfo, ::Pair)
.observe(lifecycleOwner) { (isLoading, historyInfo) ->
onHistoryChanged(isLoading, historyInfo)
}
}
private fun showMenu() {
val menu = PopupMenu(context, buttonMenu)
menu.inflate(R.menu.popup_read)
prepareMenu(menu.menu)
menu.setOnMenuItemClickListener(this)
menu.setForceShowIcon(true)
menu.setOnDismissListener(this)
buttonMenu.isChecked = true
menu.show()
}
private fun prepareMenu(menu: Menu) {
MenuCompat.setGroupDividerEnabled(menu, true)
menu.populateBranchList()
val history = viewModel.historyInfo.value
menu.findItem(R.id.action_incognito)?.isVisible = !history.isIncognitoMode
menu.findItem(R.id.action_forget)?.isVisible = history.history != null
menu.findItem(R.id.action_download)?.isVisible = viewModel.getMangaOrNull()?.isLocal == false
}
private fun openReader(isIncognitoMode: Boolean) {
val manga = viewModel.manga.value ?: return
if (viewModel.historyInfo.value.isChapterMissing) {
Snackbar.make(buttonRead, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT)
.show() // TODO
} else {
router.openReader(
ReaderIntent.Builder(context)
.manga(manga)
.branch(viewModel.selectedBranchValue)
.incognito(isIncognitoMode)
.build(),
)
if (isIncognitoMode) {
Toast.makeText(context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
}
}
private fun onHistoryChanged(isLoading: Boolean, info: HistoryInfo) {
buttonRead.setText(
when {
isLoading -> R.string.loading_
info.isIncognitoMode -> R.string.incognito
info.canContinue -> R.string._continue
else -> R.string.read
},
)
splitButton.isEnabled = !isLoading && info.isValid
}
private fun Menu.populateBranchList() {
val branches = viewModel.branches.value
if (branches.size <= 1) {
return
}
for ((i, branch) in branches.withIndex()) {
val title = buildSpannedString {
if (branch.isCurrent) {
inSpans(
ImageSpan(
context,
R.drawable.ic_current_chapter,
DynamicDrawableSpan.ALIGN_BASELINE,
),
) {
append(' ')
}
append(' ')
}
append(branch.name ?: context.getString(R.string.system_default))
append(' ')
append(' ')
inSpans(
ForegroundColorSpan(
context.getThemeColor(
android.R.attr.textColorSecondary,
Color.LTGRAY,
),
),
RelativeSizeSpan(0.74f),
) {
append(branch.count.toString())
}
}
val item = add(R.id.group_branches, Menu.NONE, i, title)
item.isCheckable = true
item.isChecked = branch.isSelected
}
setGroupCheckable(R.id.group_branches, true, true)
}
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.data.ReadingTime
data class HistoryInfo(
val totalChapters: Int,
@@ -10,6 +11,7 @@ data class HistoryInfo(
val isIncognitoMode: Boolean,
val isChapterMissing: Boolean,
val canDownload: Boolean,
val estimatedTime: ReadingTime?,
) {
val isValid: Boolean
get() = totalChapters >= 0
@@ -29,7 +31,8 @@ fun HistoryInfo(
manga: MangaDetails?,
branch: String?,
history: MangaHistory?,
isIncognitoMode: Boolean
isIncognitoMode: Boolean,
estimatedTime: ReadingTime?,
): HistoryInfo {
val chapters = if (manga?.chapters?.isEmpty() == true) {
emptyList()
@@ -48,5 +51,6 @@ fun HistoryInfo(
isIncognitoMode = isIncognitoMode,
isChapterMissing = history != null && manga?.isLoaded == true && manga.allChapters.none { it.id == history.chapterId },
canDownload = manga?.isLocal == false,
estimatedTime = estimatedTime,
)
}

View File

@@ -2,6 +2,7 @@ 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?,
@@ -10,6 +11,8 @@ 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
}
@@ -25,4 +28,16 @@ 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

@@ -25,13 +25,21 @@ class ChaptersPagesAdapter(
}
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
tab.setText(
tab.setIcon(
when (position) {
0 -> R.string.chapters
1 -> if (isPagesTabEnabled) R.string.pages else R.string.bookmarks
2 -> R.string.bookmarks
0 -> R.drawable.ic_list
1 -> if (isPagesTabEnabled) R.drawable.ic_grid else R.drawable.ic_bookmark
2 -> R.drawable.ic_bookmark
else -> 0
},
)
// tab.setText(
// when (position) {
// 0 -> R.string.chapters
// 1 -> if (isPagesTabEnabled) R.string.pages else R.string.bookmarks
// 2 -> R.string.bookmarks
// else -> 0
// },
// )
}
}

View File

@@ -6,10 +6,11 @@ import android.view.View
import android.view.ViewGroup
import androidx.appcompat.view.ActionMode
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
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.sheet.AdaptiveSheetBehavior.Companion.STATE_COLLAPSED
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior.Companion.STATE_DRAGGING
@@ -26,9 +27,9 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.recyclerView
import org.koitharu.kotatsu.core.util.ext.setTabsEnabled
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetChaptersPagesBinding
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.details.ui.ReadButtonDelegate
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import javax.inject.Inject
@@ -49,11 +50,14 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(), Actio
disableFitToContents()
val args = arguments ?: Bundle.EMPTY
var defaultTab = args.getInt(ARG_TAB, settings.defaultDetailsTab)
var defaultTab = args.getInt(AppRouter.KEY_TAB, settings.defaultDetailsTab)
val adapter = ChaptersPagesAdapter(this, settings.isPagesTabEnabled)
if (!adapter.isPagesTabEnabled) {
defaultTab = (defaultTab - 1).coerceAtLeast(TAB_CHAPTERS)
}
(viewModel as? DetailsViewModel)?.let { dvm ->
ReadButtonDelegate(binding.splitButtonRead, dvm, router).attach(viewLifecycleOwner)
}
binding.pager.offscreenPageLimit = adapter.itemCount
binding.pager.recyclerView?.isNestedScrollingEnabled = false
binding.pager.adapter = adapter
@@ -87,7 +91,9 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(), Actio
}
val binding = viewBinding ?: return
val isActionModeStarted = actionModeDelegate?.isActionModeStarted == true
binding.toolbar.menuView?.isVisible = newState != STATE_COLLAPSED && !isActionModeStarted
binding.toolbar.menuView?.isVisible = newState == STATE_EXPANDED && !isActionModeStarted
binding.splitButtonRead.isVisible = newState != STATE_EXPANDED && !isActionModeStarted
&& viewModel is DetailsViewModel
}
override fun onActionModeStarted(mode: ActionMode) {
@@ -138,22 +144,5 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(), Actio
const val TAB_CHAPTERS = 0
const val TAB_PAGES = 1
const val TAB_BOOKMARKS = 2
private const val ARG_TAB = "tag"
private const val TAG = "ChaptersPagesSheet"
fun show(fm: FragmentManager) {
ChaptersPagesSheet().showDistinct(fm, TAG)
}
fun show(fm: FragmentManager, defaultTab: Int) {
ChaptersPagesSheet().withArgs(1) {
putInt(ARG_TAB, defaultTab)
}.showDistinct(fm, TAG)
}
fun isShown(fm: FragmentManager): Boolean {
val sheet = fm.findFragmentByTag(TAG) as? ChaptersPagesSheet
return sheet?.dialog?.isShowing == true
}
}
}

View File

@@ -19,14 +19,17 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
import okio.FileNotFoundException
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.toChipModel
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.LocaleStringComparator
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.combine
import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.domain.DetailsInteractor
import org.koitharu.kotatsu.details.ui.DetailsActivity
@@ -36,6 +39,7 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.download.ui.worker.DownloadTask
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
@@ -57,7 +61,6 @@ abstract class ChaptersPagesViewModel(
val readingState = MutableStateFlow<ReaderState?>(null)
val onActionDone = MutableEventFlow<ReversibleAction>()
val onSelectChapter = MutableEventFlow<Long>()
val onDownloadStarted = MutableEventFlow<Unit>()
val onMangaRemoved = MutableEventFlow<Manga>()
@@ -119,6 +122,18 @@ abstract class ChaptersPagesViewModel(
(if (reversed) list.asReversed() else list).filterSearch(query)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val quickFilter = combine(
mangaDetails,
selectedBranch,
) { details, branch ->
val branches = details?.chapters?.keys?.sortedWithSafe(LocaleStringComparator()).orEmpty()
if (branches.size > 1) {
branches.map { ListFilterOption.Branch(it).toChipModel(it == branch) }
} else {
emptyList()
}
}
init {
launchJob(Dispatchers.Default) {
localStorageChanges

View File

@@ -18,13 +18,15 @@ import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.BookmarksSelectionDecoration
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.nav.ReaderIntent
import org.koitharu.kotatsu.core.nav.dismissParentDialog
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.dismissParentDialog
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe
@@ -34,7 +36,6 @@ import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
import org.koitharu.kotatsu.list.ui.GridSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
import javax.inject.Inject
@@ -124,21 +125,21 @@ class BookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
if (listener != null && listener.onBookmarkSelected(item)) {
dismissParentDialog()
} else {
val intent = IntentBuilder(view.context)
val intent = ReaderIntent.Builder(view.context)
.manga(activityViewModel.getMangaOrNull() ?: return)
.bookmark(item)
.incognito(true)
.build()
startActivity(intent)
router.openReader(intent)
}
}
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
return selectionController?.onItemLongClick(view, item.pageId) ?: false
return selectionController?.onItemLongClick(view, item.pageId) == true
}
override fun onItemContextClick(item: Bookmark, view: View): Boolean {
return selectionController?.onItemContextClick(view, item.pageId) ?: false
return selectionController?.onItemContextClick(view, item.pageId) == true
}
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {

View File

@@ -5,38 +5,38 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.Insets
import androidx.core.view.ancestors
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.ReaderIntent
import org.koitharu.kotatsu.core.nav.dismissParentDialog
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.dismissParentDialog
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.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
import org.koitharu.kotatsu.details.ui.withVolumeHeaders
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
import org.koitharu.kotatsu.reader.ui.ReaderState
import javax.inject.Inject
@@ -45,7 +45,7 @@ import kotlin.math.roundToInt
@AndroidEntryPoint
class ChaptersFragment :
BaseFragment<FragmentChaptersBinding>(),
OnListItemClickListener<ChapterListItem> {
OnListItemClickListener<ChapterListItem>, ChipsView.OnChipClickListener {
private val viewModel by ChaptersPagesViewModel.ActivityVMLazy(this)
@@ -86,15 +86,16 @@ class ChaptersFragment :
adapter = chaptersAdapter
ChapterGridSpanHelper.attach(this)
}
binding.chipsFilter.onChipClickListener = this
viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged)
viewModel.chapters
.map { it.withVolumeHeaders(requireContext()) }
.flowOn(Dispatchers.Default)
.observe(viewLifecycleOwner, this::onChaptersChanged)
viewModel.quickFilter.observe(viewLifecycleOwner, this::onFilterChanged)
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
binding.textViewHolder.isVisible = it
}
viewModel.onSelectChapter.observeEvent(viewLifecycleOwner, ::onSelectChapter)
}
override fun onDestroyView() {
@@ -111,8 +112,8 @@ class ChaptersFragment :
if (listener != null && listener.onChapterSelected(item.chapter)) {
dismissParentDialog()
} else {
startActivity(
IntentBuilder(view.context)
router.openReader(
ReaderIntent.Builder(view.context)
.manga(viewModel.getMangaOrNull() ?: return)
.state(ReaderState(item.chapter.id, 0, 0))
.build(),
@@ -121,11 +122,16 @@ class ChaptersFragment :
}
override fun onItemLongClick(item: ChapterListItem, view: View): Boolean {
return selectionController?.onItemLongClick(view, item.chapter.id) ?: false
return selectionController?.onItemLongClick(view, item.chapter.id) == true
}
override fun onItemContextClick(item: ChapterListItem, view: View): Boolean {
return selectionController?.onItemContextClick(view, item.chapter.id) ?: false
return selectionController?.onItemContextClick(view, item.chapter.id) == true
}
override fun onChipClick(chip: Chip, data: Any?) {
if (data !is ListFilterOption.Branch) return
viewModel.setSelectedBranch(data.titleText)
}
override fun onWindowInsetsChanged(insets: Insets) = Unit
@@ -148,22 +154,10 @@ class ChaptersFragment :
}
}
private suspend fun onSelectChapter(chapterId: Long) {
if (!isResumed) {
view?.ancestors?.firstNotNullOfOrNull { it as? ViewPager2 }?.setCurrentItem(0, true)
}
val position = withContext(Dispatchers.Default) {
val predicate: (ListModel) -> Boolean = { x -> x is ChapterListItem && x.chapter.id == chapterId }
val items = chaptersAdapter?.observeItems()?.firstOrNull { it.any(predicate) }
items?.indexOfFirst(predicate) ?: -1
}
if (position >= 0) {
selectionController?.startSelection(chapterId)
val lm = (viewBinding?.recyclerViewChapters?.layoutManager as? LinearLayoutManager)
if (lm != null) {
val offset = resources.getDimensionPixelOffset(R.dimen.chapter_list_item_height)
lm.scrollToPositionWithOffset(position, offset)
}
private fun onFilterChanged(list: List<ChipsView.ChipModel>) {
viewBinding?.chipsFilter?.run {
setChips(list)
isGone = list.isEmpty()
}
}

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.details.ui.pager.pages
import android.webkit.MimeTypeMap
import androidx.core.net.toUri
import coil3.ImageLoader
import coil3.decode.DataSource
@@ -21,8 +20,10 @@ import okio.Path.Companion.toOkioPath
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.ext.fetch
import org.koitharu.kotatsu.core.util.ext.isNetworkUri
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.mimeType
@@ -47,7 +48,7 @@ class MangaPageFetcher(
pagesCache.get(pageUrl)?.let { file ->
return SourceFetchResult(
source = ImageSource(file.toOkioPath(), options.fileSystem),
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension),
mimeType = MimeTypes.getMimeTypeFromExtension(file.name)?.toString(),
dataSource = DataSource.DISK,
)
}
@@ -67,13 +68,13 @@ class MangaPageFetcher(
if (!response.isSuccessful) {
throw HttpException(response.toNetworkResponse())
}
val mimeType = response.mimeType
val mimeType = response.mimeType?.toMimeTypeOrNull()
val file = response.requireBody().use {
pagesCache.put(pageUrl, it.source(), mimeType)
}
SourceFetchResult(
source = ImageSource(file.toOkioPath(), FileSystem.SYSTEM),
mimeType = mimeType,
mimeType = mimeType?.toString(),
dataSource = DataSource.NETWORK,
)
}

View File

@@ -22,6 +22,9 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.nav.ReaderIntent
import org.koitharu.kotatsu.core.nav.dismissParentDialog
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.BoundsScrollListener
@@ -29,7 +32,6 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.dismissParentDialog
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe
@@ -42,7 +44,6 @@ import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
@@ -151,8 +152,8 @@ class PagesFragment :
if (listener != null && listener.onPageSelected(item.page)) {
dismissParentDialog()
} else {
startActivity(
IntentBuilder(view.context)
router.openReader(
ReaderIntent.Builder(view.context)
.manga(parentViewModel.getMangaOrNull() ?: return)
.state(ReaderState(item.page.chapterId, item.page.index, 0))
.build(),
@@ -161,11 +162,11 @@ class PagesFragment :
}
override fun onItemLongClick(item: PageThumbnail, view: View): Boolean {
return selectionController?.onItemLongClick(view, item.page.id) ?: false
return selectionController?.onItemLongClick(view, item.page.id) == true
}
override fun onItemContextClick(item: PageThumbnail, view: View): Boolean {
return selectionController?.onItemContextClick(view, item.page.id) ?: false
return selectionController?.onItemContextClick(view, item.page.id) == true
}
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {

View File

@@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.call
@@ -37,7 +37,7 @@ class RelatedListViewModel @Inject constructor(
downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler) {
private val seed = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
private val seed = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga
private val repository = mangaRepositoryFactory.create(seed.source)
private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val listError = MutableStateFlow<Throwable?>(null)

View File

@@ -1,7 +1,5 @@
package org.koitharu.kotatsu.details.ui.related
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
@@ -9,12 +7,9 @@ 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.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.model.Manga
@AndroidEntryPoint
class RelatedMangaActivity : BaseActivity<ActivityContainerBinding>(), AppBarOwner {
@@ -41,10 +36,4 @@ class RelatedMangaActivity : BaseActivity<ActivityContainerBinding>(), AppBarOwn
right = insets.right,
)
}
companion object {
fun newIntent(context: Context, seed: Manga) = Intent(context, RelatedMangaActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(seed))
}
}

View File

@@ -1,10 +1,10 @@
package org.koitharu.kotatsu.details.ui.scrobbling
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import coil3.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
@@ -15,12 +15,12 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
fun scrobblingInfoAD(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
fragmentManager: FragmentManager,
router: AppRouter,
) = adapterDelegateViewBinding<ScrobblingInfo, ListModel, ItemScrobblingInfoBinding>(
{ layoutInflater, parent -> ItemScrobblingInfoBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener {
ScrobblingInfoSheet.show(fragmentManager, bindingAdapterPosition)
router.showScrobblingInfoSheet(bindingAdapterPosition)
}
bind {

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.details.ui.scrobbling
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
@@ -10,13 +9,14 @@ import android.widget.AdapterView
import android.widget.RatingBar
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.core.net.toUri
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import coil3.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
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.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
@@ -25,14 +25,10 @@ import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetScrobblingBinding
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import javax.inject.Inject
@AndroidEntryPoint
@@ -53,7 +49,7 @@ class ScrobblingInfoSheet :
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
scrobblerIndex = requireArguments().getInt(ARG_INDEX, scrobblerIndex)
scrobblerIndex = requireArguments().getInt(AppRouter.KEY_INDEX, scrobblerIndex)
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingBinding {
@@ -108,11 +104,11 @@ class ScrobblingInfoSheet :
override fun onClick(v: View) {
when (v.id) {
R.id.button_menu -> menu?.show()
R.id.imageView_cover -> {
val coverUrl = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.coverUrl ?: return
val options = scaleUpActivityOptionsOf(v)
startActivity(ImageActivity.newIntent(v.context, coverUrl, null), options)
}
R.id.imageView_cover -> router.openImage(
url = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.coverUrl ?: return,
source = null,
anchor = v,
)
}
}
@@ -139,10 +135,13 @@ class ScrobblingInfoSheet :
when (item.itemId) {
R.id.action_browser -> {
val url = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.externalUrl ?: return false
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(
Intent.createChooser(intent, getString(R.string.open_in_browser)),
)
if (!router.openExternalBrowser(url, getString(R.string.open_in_browser))) {
Snackbar.make(
viewBinding?.textViewDescription ?: return false,
R.string.operation_not_supported,
Snackbar.LENGTH_SHORT,
).show()
}
}
R.id.action_unregister -> {
@@ -153,20 +152,10 @@ class ScrobblingInfoSheet :
R.id.action_edit -> {
val manga = viewModel.manga.value ?: return false
val scrobblerService = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.scrobbler
ScrobblingSelectorSheet.show(parentFragmentManager, manga, scrobblerService)
router.showScrobblingSelectorSheet(manga, scrobblerService)
dismiss()
}
}
return true
}
companion object {
private const val TAG = "ScrobblingInfoBottomSheet"
private const val ARG_INDEX = "index"
fun show(fm: FragmentManager, index: Int) = ScrobblingInfoSheet().withArgs(1) {
putInt(ARG_INDEX, index)
}.show(fm, TAG)
}
}

View File

@@ -1,18 +1,18 @@
package org.koitharu.kotatsu.details.ui.scrobbling
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import coil3.ImageLoader
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.list.ui.model.ListModel
class ScrollingInfoAdapter(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
fragmentManager: FragmentManager,
router: AppRouter,
) : BaseListAdapter<ListModel>() {
init {
delegatesManager.addDelegate(scrobblingInfoAD(lifecycleOwner, coil, fragmentManager))
delegatesManager.addDelegate(scrobblingInfoAD(lifecycleOwner, coil, router))
}
}

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.download.ui.dialog
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
@@ -9,17 +8,16 @@ import android.view.ViewGroup
import android.widget.Spinner
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentResultListener
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
import androidx.lifecycle.LifecycleOwner
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.prefs.DownloadFormat
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs
@@ -27,17 +25,12 @@ import org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView
import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit
import org.koitharu.kotatsu.core.util.ext.mapToArray
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.DialogDownloadBinding
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.settings.storage.DirectoryModel
import javax.inject.Inject
@@ -75,6 +68,7 @@ class DownloadDialogFragment : AlertDialogFragment<DialogDownloadBinding>(), Vie
binding.buttonConfirm.setOnClickListener(this)
binding.textViewMore.setOnClickListener(this)
binding.textViewTip.isVisible = viewModel.manga.size == 1
binding.textViewSummary.text = viewModel.manga.joinToStringWithLimit(binding.root.context, 120) { it.title }
viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged)
@@ -324,7 +318,9 @@ class DownloadDialogFragment : AlertDialogFragment<DialogDownloadBinding>(), Vie
}
}
private class SnackbarResultListener(private val host: View) : FragmentResultListener {
private class SnackbarResultListener(
private val host: View,
) : FragmentResultListener {
override fun onFragmentResult(requestKey: String, result: Bundle) {
val isStarted = result.getBoolean(ARG_STARTED, true)
@@ -336,8 +332,9 @@ class DownloadDialogFragment : AlertDialogFragment<DialogDownloadBinding>(), Vie
(host.context.findActivity() as? BottomNavOwner)?.let {
snackbar.anchorView = it.bottomNav
}
snackbar.setAction(R.string.details) {
it.context.startActivity(Intent(it.context, DownloadsActivity::class.java))
val router = AppRouter.from(host)
if (router != null) {
snackbar.setAction(R.string.details) { router.openDownloads() }
}
snackbar.show()
}
@@ -345,28 +342,16 @@ class DownloadDialogFragment : AlertDialogFragment<DialogDownloadBinding>(), Vie
companion object {
private const val TAG = "DownloadDialogFragment"
private const val RESULT_KEY = "DOWNLOAD_STARTED"
private const val ARG_STARTED = "started"
private const val KEY_CHECKED_OPTION = "checked_opt"
const val ARG_MANGA = "manga"
fun show(fm: FragmentManager, manga: Collection<Manga>) = DownloadDialogFragment().withArgs(1) {
putParcelableArray(ARG_MANGA, manga.mapToArray { ParcelableManga(it) })
}.showDistinct(fm, TAG)
fun registerCallback(
fm: FragmentManager,
lifecycleOwner: LifecycleOwner,
snackbarHost: View
) = fm.setFragmentResultListener(RESULT_KEY, lifecycleOwner, SnackbarResultListener(snackbarHost))
fun registerCallback(activity: FragmentActivity, snackbarHost: View) =
activity.supportFragmentManager.setFragmentResultListener(
RESULT_KEY,
activity,
SnackbarResultListener(snackbarHost),
)
fun registerCallback(fragment: Fragment, snackbarHost: View) =
fragment.childFragmentManager.setFragmentResultListener(
RESULT_KEY,
fragment.viewLifecycleOwner,
SnackbarResultListener(snackbarHost),
)
fun unregisterCallback(fm: FragmentManager) = fm.clearFragmentResultListener(RESULT_KEY)
}
}

View File

@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.DownloadFormat
@@ -45,7 +46,7 @@ class DownloadDialogViewModel @Inject constructor(
private val settings: AppSettings,
) : BaseViewModel() {
val manga = savedStateHandle.require<Array<ParcelableManga>>(DownloadDialogFragment.ARG_MANGA).map {
val manga = savedStateHandle.require<Array<ParcelableManga>>(AppRouter.KEY_MANGA).map {
it.manga
}
private val mangaDetails = suspendLazy {

View File

@@ -12,6 +12,7 @@ import androidx.core.view.updatePadding
import coil3.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
@@ -20,7 +21,6 @@ import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import javax.inject.Inject
@@ -84,7 +84,7 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
if (selectionController.onItemClick(item.id.mostSignificantBits)) {
return
}
startActivity(DetailsActivity.newIntent(view.context, item.manga ?: return))
router.openDetails(item.manga ?: return)
}
override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean {

View File

@@ -1,16 +1,16 @@
package org.koitharu.kotatsu.download.ui.list
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import androidx.fragment.app.FragmentActivity
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.settings.SettingsActivity
class DownloadsMenuProvider(
private val context: Context,
private val activity: FragmentActivity,
private val viewModel: DownloadsViewModel,
) : MenuProvider {
@@ -24,10 +24,7 @@ class DownloadsMenuProvider(
R.id.action_resume -> viewModel.resumeAll()
R.id.action_cancel_all -> confirmCancelAll()
R.id.action_remove_completed -> confirmRemoveCompleted()
R.id.action_settings -> {
context.startActivity(SettingsActivity.newDownloadsSettingsIntent(context))
}
R.id.action_settings -> activity.router.openDownloadsSetting()
else -> return false
}
return true
@@ -41,7 +38,7 @@ class DownloadsMenuProvider(
}
private fun confirmCancelAll() {
buildAlertDialog(context, isCentered = true) {
buildAlertDialog(activity, isCentered = true) {
setTitle(R.string.cancel_all)
setMessage(R.string.cancel_all_downloads_confirm)
setIcon(R.drawable.ic_cancel_multiple)
@@ -51,7 +48,7 @@ class DownloadsMenuProvider(
}
private fun confirmRemoveCompleted() {
buildAlertDialog(context, isCentered = true) {
buildAlertDialog(activity, isCentered = true) {
setTitle(R.string.remove_completed)
setMessage(R.string.remove_completed_downloads_confirm)
setIcon(R.drawable.ic_clear_all)

View File

@@ -289,7 +289,7 @@ class DownloadsViewModel @Inject constructor(
}
return cacheMutex.withLock {
mangaCache.getOrElse(mangaId) {
mangaDataRepository.findMangaById(mangaId)?.also {
mangaDataRepository.findMangaById(mangaId, withChapters = true)?.also {
mangaCache[mangaId] = it
} ?: return null
}

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