Compare commits

...

165 Commits

Author SHA1 Message Date
Koitharu
af4870c39c Downscale phone screenshots in metadata 2021-10-25 20:34:27 +03:00
Koitharu
9581b3da65 Update tablet screenshots in metadata 2021-10-25 20:31:08 +03:00
Koitharu
358a907b74 Update phone screenshots in metadata 2021-10-25 08:44:44 +03:00
Koitharu
cdb1d8fe12 Fix manga cover in downloads queue 2021-10-24 17:32:45 +03:00
Koitharu
5513382aea Fix pages bottomsheet scrollbars 2021-10-20 19:34:40 +03:00
Koitharu
a7a5c8978d Fix page image saving 2021-10-20 19:07:36 +03:00
Koitharu
b139d5fca5 Fix saved manga index parsing 2021-10-20 18:50:08 +03:00
Koitharu
977da5b1b4 Optimize chapters list 2021-10-20 18:05:12 +03:00
Koitharu
78f2a13761 Chapter date parse optimization 2021-10-20 08:10:54 +03:00
Koitharu
7ded7fd12a Merge branch 'devel' of github.com:nv95/Kotatsu into devel 2021-10-20 07:41:30 +03:00
Koitharu
e50f79a25e VersionId test 2021-10-20 07:40:49 +03:00
Zakhar Timoshenko
904fc572d0 Adjustments 2021-10-20 07:39:32 +03:00
Zakhar Timoshenko
c280af9a5b Fix crash when trying to download a chapter with mobile internet 2021-10-20 07:39:32 +03:00
Zakhar Timoshenko
af6df6dfa2 Minor fixes 2021-10-20 07:39:32 +03:00
Zakhar Timoshenko
2380d69b11 Add chapter description (date, scanlator) 2021-10-20 07:39:32 +03:00
Zakhar Timoshenko
ad76d6d414 Use custom Snackbar for DetailsActivity 2021-10-20 07:39:32 +03:00
abidin toumi
d911ee12f2 Added translation using Weblate (Arabic) 2021-10-17 16:36:32 +03:00
J. Lavoie
e6ce03b516 Translated using Weblate (Finnish)
Currently translated at 100.0% (242 of 242 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fi/
2021-10-17 16:36:32 +03:00
J. Lavoie
17d7deef2d Translated using Weblate (Spanish)
Currently translated at 98.7% (239 of 242 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
2021-10-17 16:36:32 +03:00
Koitharu
c2222344a2 Fix pagination 2021-10-09 15:56:19 +03:00
Koitharu
0a8d677fe8 Search by author name 2021-10-09 15:44:57 +03:00
Koitharu
a4e1381238 Improve CoverImageView 2021-10-08 18:56:35 +03:00
Aliaksiej Razumaŭ
1b6837d406 Translated using Weblate (Belarusian)
Currently translated at 100.0% (242 of 242 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
2021-10-08 18:01:44 +03:00
J. Lavoie
2adf8a139c Translated using Weblate (French)
Currently translated at 100.0% (242 of 242 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
2021-10-08 18:01:44 +03:00
J. Lavoie
efe96a6e05 Translated using Weblate (Italian)
Currently translated at 100.0% (242 of 242 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
2021-10-08 18:01:44 +03:00
J. Lavoie
0360df999f Translated using Weblate (German)
Currently translated at 100.0% (242 of 242 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
2021-10-08 18:01:44 +03:00
Zakhar Timoshenko
5cb4758b38 Remove unused strings 2021-10-05 18:53:44 +03:00
Zakhar Timoshenko
fce9f543e1 Trust user-added CAs 2021-10-05 18:53:44 +03:00
Zakhar Timoshenko
9e6cb1837e Remove "Unknown" status label due confusion 2021-10-05 18:53:44 +03:00
J. Lavoie
f9e40e17c4 Translated using Weblate (Spanish)
Currently translated at 97.0% (233 of 240 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
2021-10-04 16:38:35 +03:00
J. Lavoie
a09d71cb13 Translated using Weblate (Finnish)
Currently translated at 100.0% (240 of 240 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fi/
2021-10-04 16:38:35 +03:00
J. Lavoie
de53445ac5 Translated using Weblate (French)
Currently translated at 100.0% (240 of 240 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
2021-10-04 16:38:35 +03:00
J. Lavoie
19fdd54dbd Translated using Weblate (Italian)
Currently translated at 100.0% (240 of 240 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
2021-10-04 16:38:35 +03:00
J. Lavoie
1c644188cd Translated using Weblate (German)
Currently translated at 100.0% (240 of 240 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
2021-10-04 16:38:35 +03:00
J. Lavoie
a50943ed01 Translated using Weblate (Russian)
Currently translated at 99.5% (239 of 240 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
2021-10-04 16:38:35 +03:00
J. Lavoie
1888aba335 Translated using Weblate (Spanish)
Currently translated at 96.6% (232 of 240 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
2021-10-04 16:38:35 +03:00
Zakhar Timoshenko
b390fd49ca Translated using Weblate (Belarusian)
Currently translated at 100.0% (240 of 240 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
2021-10-04 16:38:35 +03:00
J. Lavoie
33f0eb9f38 Added translation using Weblate (Finnish) 2021-10-04 16:38:35 +03:00
J. Lavoie
db91458abc Translated using Weblate (Spanish)
Currently translated at 97.4% (230 of 236 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
2021-10-04 16:38:35 +03:00
Aliaksiej Razumaŭ
ee2ed0159d Translated using Weblate (Belarusian)
Currently translated at 100.0% (236 of 236 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
2021-10-04 16:38:35 +03:00
Zakhar Timoshenko
0bdc3e024e MangaOwl adjustments 2021-10-04 16:38:13 +03:00
Zakhar Timoshenko
4aab4e636d Fix display of the snackbar in details activity 2021-10-04 16:38:13 +03:00
Zakhar Timoshenko
af114d74df Add new source: MangaOwl 2021-10-04 16:38:13 +03:00
Zakhar Timoshenko
c5921f8a62 Minor interface adjustments 2021-10-04 16:38:13 +03:00
Zakhar Timoshenko
be0c8f2c96 Add manga status label to details screen 2021-10-04 16:38:13 +03:00
Zakhar Timoshenko
bb685751cd Change Readmanga domain + set User-Agent 2021-10-04 16:38:13 +03:00
Koitharu
fd9737aa9c Update version 2021-09-26 17:20:43 +03:00
Koitharu
1dcb479d62 Add transition to search view 2021-09-23 19:36:01 +03:00
Zakhar Timoshenko
7618a05162 Fix inability to create a backup 2021-09-19 13:57:35 +03:00
Zakhar Timoshenko
174c6649e0 Use more correct detection of dark AMOLED theme 2021-09-19 13:57:35 +03:00
Zakhar Timoshenko
203608e9fd Minor fix download item 2021-09-19 13:57:35 +03:00
Zakhar Timoshenko
07e0ae884c Fix color when search opened with AMOLED mode 2021-09-19 13:57:35 +03:00
Zakhar Timoshenko
cd8e256364 Fixed the toolbar color with a light theme and AMOLED mode enabled 2021-09-19 13:57:35 +03:00
Zakhar Timoshenko
93998e460c Fix chopped shadows on thumbnails 2021-09-19 13:57:35 +03:00
Zakhar Timoshenko
71f205ca8b Fix AMOLED theme 2021-09-19 13:57:35 +03:00
Koitharu
f9cee7a8f5 Update gradle and dependencies 2021-09-13 08:36:05 +03:00
Koitharu
675e95da2b Show current filter in list header 2021-09-11 16:01:15 +03:00
Koitharu
c1b6cef362 Multiple tags support in (almost) all sources #19 2021-09-11 13:54:56 +03:00
Koitharu
4977464e69 ExHentai manga source 2021-09-11 11:40:37 +03:00
Koitharu
593624fdb9 Manga repository authorization support 2021-09-08 07:27:25 +03:00
Koitharu
c4585c81e1 Merge branch 'devel' into feature/multitag 2021-09-05 16:16:17 +03:00
Koitharu
27293f1bf8 Remove some findViewById 2021-09-05 16:00:15 +03:00
Zakhar Timoshenko
d1fd31701d Use measured height instead of magic numbers 2021-09-05 15:48:52 +03:00
Zakhar Timoshenko
d30c7e6e9c Made UI more like Google apps 2021-09-05 15:48:52 +03:00
Koitharu
0355b61e69 Base support for multiple tags in repositories 2021-09-05 15:48:12 +03:00
XeroOl
5d5ec719b7 Update MangareadRepository.kt 2021-09-01 19:53:03 +03:00
Koitharu
6596dca291 Fix cbz cover fetcher closing, update Kotlin and other small fixes 2021-08-24 18:04:02 +03:00
Koitharu
a296c98602 Update dependencies 2021-08-22 12:31:02 +03:00
Zakhar Timoshenko
be0718acf4 Fix app crash on the first use 2021-08-22 12:17:25 +03:00
Zakhar Timoshenko
c42d913d4c Translated using Weblate (Belarusian)
Currently translated at 100.0% (236 of 236 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
2021-08-18 08:11:14 +03:00
J. Lavoie
446649b2bb Translated using Weblate (French)
Currently translated at 100.0% (236 of 236 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
2021-08-18 08:11:14 +03:00
J. Lavoie
9f145557ea Translated using Weblate (Italian)
Currently translated at 100.0% (236 of 236 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
2021-08-18 08:11:14 +03:00
J. Lavoie
ae856fca74 Translated using Weblate (German)
Currently translated at 100.0% (236 of 236 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
2021-08-18 08:11:14 +03:00
Zakhar Timoshenko
6dc8a4ffb5 Use some animations in sheet toolbar 2021-08-18 08:10:59 +03:00
Zakhar Timoshenko
73498964a8 View close icon if thumbnails sheet is expanded 2021-08-18 08:10:59 +03:00
Zakhar Timoshenko
256f88cc60 Forgot to remove the layout behavior again, wow 2021-08-18 08:10:59 +03:00
Zakhar Timoshenko
16cc6fb117 Set horizontal margin to 32dp 2021-08-18 08:10:59 +03:00
Zakhar Timoshenko
7bb809f227 Improve thumbnail sheet 2021-08-18 08:10:59 +03:00
Zakhar Timoshenko
57111f628d Minor tweaks 2021-08-18 08:10:59 +03:00
Koitharu
0129e9e092 Merge branch 'weblate-kotatsu-strings' of https://github.com/weblate/Kotatsu into weblate-weblate-kotatsu-strings 2021-08-04 08:50:48 +03:00
Zakhar Timoshenko
d6c6132a04 Fixes 2021-08-04 08:46:23 +03:00
Zakhar Timoshenko
eb5976a796 Some changes in about section, fix links 2021-08-04 08:46:23 +03:00
Zakhar Timoshenko
253f4abba1 Minor fix 2021-08-04 08:46:23 +03:00
Zakhar Timoshenko
3a442817ce Add about section to settings, add some info stuff 2021-08-04 08:46:23 +03:00
Zakhar Timoshenko
594c359f1c Added information about the app to a separate activity 2021-08-04 08:46:23 +03:00
Zakhar Timoshenko
cc28d4fe54 Translated using Weblate (Russian)
Currently translated at 99.5% (224 of 225 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
2021-08-04 07:41:41 +02:00
Zakhar Timoshenko
95708367a1 Translated using Weblate (Belarusian)
Currently translated at 100.0% (225 of 225 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
2021-08-04 07:41:41 +02:00
J. Lavoie
89b915b206 Translated using Weblate (French)
Currently translated at 100.0% (225 of 225 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
2021-08-04 07:41:41 +02:00
J. Lavoie
e4da0a126c Translated using Weblate (Italian)
Currently translated at 100.0% (225 of 225 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
2021-08-04 07:41:41 +02:00
J. Lavoie
56f9cc2c88 Translated using Weblate (German)
Currently translated at 100.0% (225 of 225 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
2021-08-04 07:41:41 +02:00
Zakhar Timoshenko
6037c66a2d Translated using Weblate (Russian)
Currently translated at 99.5% (224 of 225 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
2021-08-04 07:41:41 +02:00
Koitharu
d25837b40b Merge branch 'devel' of https://github.com/nv95/Kotatsu into devel 2021-08-04 08:41:27 +03:00
Koitharu
fbd0f25b8f #8 Configure sort order for each favourites category 2021-08-04 08:40:32 +03:00
Allan Nordhøy
9c55fd166e Translated using Weblate (Norwegian Bokmål)
Currently translated at 86.6% (195 of 225 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
2021-07-28 08:20:18 +03:00
Allan Nordhøy
2ac1828a0c Translated using Weblate (English)
Currently translated at 100.0% (225 of 225 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
2021-07-28 08:20:18 +03:00
J. Lavoie
45e1502c9b Translated using Weblate (French)
Currently translated at 100.0% (225 of 225 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
2021-07-28 08:20:18 +03:00
J. Lavoie
e2608cf85a Translated using Weblate (Italian)
Currently translated at 100.0% (225 of 225 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
2021-07-28 08:20:18 +03:00
J. Lavoie
05bbfe77b2 Translated using Weblate (German)
Currently translated at 100.0% (225 of 225 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
2021-07-28 08:20:18 +03:00
Zakhar Timoshenko
34ad0a7c68 Translated using Weblate (Russian)
Currently translated at 99.5% (224 of 225 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
2021-07-28 08:20:18 +03:00
J. Lavoie
c67ce38350 Translated using Weblate (Spanish)
Currently translated at 93.3% (210 of 225 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
2021-07-28 08:20:18 +03:00
Zakhar Timoshenko
ad79ff2739 Translated using Weblate (Belarusian)
Currently translated at 100.0% (225 of 225 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
2021-07-28 08:20:18 +03:00
J. Lavoie
2e5afc73e7 Translated using Weblate (Italian)
Currently translated at 100.0% (221 of 221 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
2021-07-28 08:20:18 +03:00
J. Lavoie
73efe6fd83 Translated using Weblate (German)
Currently translated at 100.0% (221 of 221 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
2021-07-28 08:20:18 +03:00
HelaBasa
c59e3165b6 Translated using Weblate (Sinhala)
Currently translated at 4.0% (9 of 221 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/si/
2021-07-28 08:20:18 +03:00
J. Lavoie
ec8c5e0fd4 Translated using Weblate (French)
Currently translated at 100.0% (221 of 221 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
2021-07-28 08:20:18 +03:00
J. Lavoie
149ac9280c Translated using Weblate (Italian)
Currently translated at 90.9% (201 of 221 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
2021-07-28 08:20:18 +03:00
J. Lavoie
af20f65468 Translated using Weblate (German)
Currently translated at 88.6% (196 of 221 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
2021-07-28 08:20:18 +03:00
Koitharu
6f7efa9e26 Optimize layout 2021-07-28 08:03:16 +03:00
Koitharu
7f5ef227eb Show not downloaded chapters in local manga 2021-07-24 10:51:26 +03:00
Koitharu
e8e95a485b Downloads queue activity 2021-07-23 06:51:01 +03:00
Koitharu
77186d271d Fix list headers 2021-07-21 19:13:30 +03:00
Koitharu
ebeaf9703f Refactor download service 2021-07-21 19:13:29 +03:00
Koitharu
625b2769c6 Improve search ui 2021-07-21 19:13:29 +03:00
Koitharu
52e136ddef Source name in list header 2021-07-21 19:13:29 +03:00
Koitharu
78fe18735b Database migrations test 2021-07-21 19:13:29 +03:00
HelaBasa
2f89c0bb92 Added translation using Weblate (Sinhala) 2021-07-21 07:02:50 +03:00
J. Lavoie
fbac8881ce Translated using Weblate (French)
Currently translated at 100.0% (221 of 221 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
2021-07-21 07:02:50 +03:00
J. Lavoie
b51b3460c0 Translated using Weblate (Italian)
Currently translated at 31.6% (70 of 221 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
2021-07-21 07:02:50 +03:00
J. Lavoie
aaea4147a4 Translated using Weblate (German)
Currently translated at 37.1% (82 of 221 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
2021-07-21 07:02:50 +03:00
Koitharu
d2609c0560 Improve remote repository tests 2021-07-16 07:43:20 +03:00
Koitharu
6a3421df8a Adjust nullability in parsers 2021-07-14 07:01:11 +03:00
Koitharu
86be393335 Update dependencies 2021-07-14 07:00:41 +03:00
Allan Nordhøy
96d6f9d80d Translated using Weblate (Norwegian Bokmål)
Currently translated at 86.8% (192 of 221 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
2021-07-12 20:45:18 +03:00
Zakhar Timoshenko
384d0345f5 Translated using Weblate (Belarusian)
Currently translated at 100.0% (221 of 221 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
2021-07-12 20:45:18 +03:00
Zakhar Timoshenko
eb780a1449 Added translation using Weblate (French) 2021-07-12 20:45:18 +03:00
Zakhar Timoshenko
15d094a175 Added translation using Weblate (Portuguese) 2021-07-12 20:45:18 +03:00
Zakhar Timoshenko
eba5e484d6 Added translation using Weblate (Italian) 2021-07-12 20:45:18 +03:00
Zakhar Timoshenko
7402e8569a Added translation using Weblate (German) 2021-07-12 20:45:18 +03:00
Allan Nordhøy
8ae7863185 Added translation using Weblate (Norwegian Bokmål) 2021-07-12 20:45:18 +03:00
Hosted Weblate
75b9fd1b7a Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/
2021-07-12 20:45:18 +03:00
Hosted Weblate
dc46657fa6 Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/
2021-07-12 20:45:18 +03:00
ztimms73
d77177bbfd Change some colors of dark theme 2021-07-12 20:44:44 +03:00
ztimms73
7c6a97e264 Using Totoro vector icon 2021-07-12 20:44:44 +03:00
Koitharu
23f84c2416 Add Weblate badge to README 2021-07-12 20:43:40 +03:00
Koitharu
d41a813e41 Merge branch 'master' into devel 2021-07-12 08:48:34 +03:00
Koitharu
fae958f6ef Remove unused resources 2021-07-07 07:25:31 +03:00
Koitharu
d8db89326f Info about background restrictions 2021-07-07 07:13:02 +03:00
Koitharu
3804896788 Save backup instead of sharing 2021-07-06 20:26:52 +03:00
Koitharu
4aedea7e15 Improve accesibility in reader 2021-07-06 19:20:41 +03:00
Koitharu
ed89d76488 Fix resource shrinking 2021-07-03 16:12:16 +03:00
Koitharu
bbd43b51e3 Increase version 2021-06-28 15:46:13 +03:00
Koitharu
e5448fa8ab Add some widgets 2021-06-28 15:41:39 +03:00
Koitharu
a6a392c7bf Merge pull request #36 from ztimms73/fix-crashes
Fix possible crashes
2021-06-28 15:41:14 +03:00
ztimms73
08f92f9614 Fix possible crashes 2021-06-28 13:59:45 +03:00
Koitharu
c9cf09f4dd Merge pull request #34 from ztimms73/ui-patch
Some UI changes
2021-06-26 16:17:36 +03:00
Koitharu
ab1624c918 Replace ProgressBar with ProgressIndicator 2021-06-26 16:15:21 +03:00
Koitharu
62396111e3 Update dependencies 2021-06-26 15:51:57 +03:00
Koitharu
e37f6f31da Merge branch 'master' into devel 2021-06-26 15:19:50 +03:00
ztimms73
0b9013e8b2 Use expanded SearchView in Toolbar instead of an menu option 2021-06-21 14:06:08 +03:00
ztimms73
445128f462 Forgot set correct icon 2021-06-20 20:13:19 +03:00
ztimms73
f50a8b3112 Fix toolbars not scrolled when option was enabled 2021-06-20 20:11:17 +03:00
ztimms73
ed65145f83 Change some icons for empty views 2021-06-20 20:07:44 +03:00
ztimms73
49e08eaf2f UI changes 2021-06-20 19:30:29 +03:00
Koitharu
de3c4545e6 Fix statusbar color for actionmode 2021-06-18 07:47:36 +03:00
Koitharu
a483d21120 Merge branch 'redesign' of https://github.com/ztimms73/Kotatsu into devel 2021-06-18 07:35:24 +03:00
ztimms73
665d46b7c4 Some reformat 2021-06-18 03:20:41 +03:00
ztimms73
3d5a1e9b30 Finally update details layouts 2021-06-18 03:17:38 +03:00
ztimms73
e7e9c5fe9f Avoiding overlapping content 2021-06-17 23:35:19 +03:00
ztimms73
c71460fcd8 Minor fixes 2021-06-17 23:33:52 +03:00
ztimms73
75b1068d46 Little cleanup 2021-06-17 10:06:52 +03:00
ztimms73
4ac406aa2d Reformat and some fixes 2021-06-17 10:03:36 +03:00
ztimms73
f4f367850e Cleanup unused resources 2021-06-17 09:54:28 +03:00
ztimms73
b293e9f370 Minor fixes 2021-06-17 01:57:52 +03:00
ztimms73
4e2350e5fc Two lines of title are better than one 2021-06-17 01:41:31 +03:00
ztimms73
09412719b7 Redesign (#24) 2021-06-17 00:46:27 +03:00
307 changed files with 8703 additions and 3085 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@
/local.properties
/.idea/caches
/.idea/libraries
/.idea/dictionaries
/.idea/modules.xml
/.idea/misc.xml
/.idea/workspace.xml

View File

@@ -1,16 +0,0 @@
<component name="ProjectDictionaryState">
<dictionary name="admin">
<words>
<w>amoled</w>
<w>chucker</w>
<w>desu</w>
<w>failsafe</w>
<w>koin</w>
<w>kotatsu</w>
<w>manga</w>
<w>snackbar</w>
<w>upsert</w>
<w>webtoon</w>
</words>
</dictionary>
</component>

1
.idea/gradle.xml generated
View File

@@ -7,6 +7,7 @@
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="Embedded JDK" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />

View File

@@ -2,7 +2,7 @@
Kotatsu is a free and open source manga reader for Android.
![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/nv95/Kotatsu) [![Build Status](https://travis-ci.org/nv95/Kotatsu.svg?branch=master)](https://travis-ci.org/nv95/Kotatsu) ![License](https://img.shields.io/github/license/nv95/Kotatsu) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669)
![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/nv95/Kotatsu) [![Build Status](https://travis-ci.org/nv95/Kotatsu.svg?branch=master)](https://travis-ci.org/nv95/Kotatsu) ![License](https://img.shields.io/github/license/nv95/Kotatsu) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669)
### Download

View File

@@ -6,15 +6,17 @@ plugins {
}
android {
compileSdkVersion 30
compileSdkVersion 31
buildToolsVersion '30.0.3'
defaultConfig {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 30
versionCode 365
versionName '1.1'
targetSdkVersion 31
versionCode 370
versionName '2.0-b2'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
kapt {
arguments {
@@ -40,6 +42,9 @@ android {
buildFeatures {
viewBinding true
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
lintOptions {
disable 'MissingTranslation'
abortOnError false
@@ -61,24 +66,24 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
implementation 'androidx.core:core-ktx:1.5.0'
implementation 'androidx.activity:activity-ktx:1.2.3'
implementation 'androidx.fragment:fragment-ktx:1.3.4'
implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.activity:activity-ktx:1.3.1'
implementation 'androidx.fragment:fragment-ktx:1.3.6'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1'
implementation 'androidx.lifecycle:lifecycle-service:2.3.1'
implementation 'androidx.lifecycle:lifecycle-process:2.3.1'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-alpha01'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'androidx.work:work-runtime-ktx:2.5.0'
implementation 'com.google.android.material:material:1.3.0'
implementation 'androidx.work:work-runtime-ktx:2.7.0'
implementation 'com.google.android.material:material:1.4.0'
//noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.3.1'
@@ -88,19 +93,28 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.9.1'
implementation 'com.squareup.okio:okio:2.10.0'
implementation 'org.jsoup:jsoup:1.13.1'
implementation 'org.jsoup:jsoup:1.14.3'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.0'
implementation 'io.insert-koin:koin-android:3.1.0'
implementation 'io.coil-kt:coil-base:1.2.2'
implementation 'io.insert-koin:koin-android:3.1.2'
implementation 'io.coil-kt:coil-base:1.4.0'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.solkin:disk-lru-cache:1.2'
implementation 'com.github.solkin:disk-lru-cache:1.3'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7'
testImplementation 'junit:junit:4.13.2'
testImplementation 'com.google.truth:truth:1.1.3'
testImplementation 'org.json:json:20210307'
testImplementation 'io.insert-koin:koin-test-junit4:3.1.0'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2'
testImplementation 'io.insert-koin:koin-test-junit4:3.1.2'
androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test:core-ktx:1.4.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
androidTestImplementation 'androidx.room:room-testing:2.3.0'
androidTestImplementation 'com.google.truth:truth:1.1.3'
}

View File

@@ -0,0 +1,55 @@
package org.koitharu.kotatsu.core.db
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.koitharu.kotatsu.core.db.migrations.*
import java.io.IOException
@RunWith(AndroidJUnit4::class)
class MangaDatabaseTest {
@get:Rule
val helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
MangaDatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
)
@Test
@Throws(IOException::class)
fun migrateAll() {
helper.createDatabase(TEST_DB, 1).apply {
// TODO execSQL("")
close()
}
for (migration in migrations) {
helper.runMigrationsAndValidate(
TEST_DB,
migration.endVersion,
true,
migration
)
}
}
private companion object {
const val TEST_DB = "test-db"
val migrations = arrayOf(
Migration1To2(),
Migration2To3(),
Migration3To4(),
Migration4To5(),
Migration5To6(),
Migration6To7(),
Migration7To8(),
)
}
}

View File

@@ -21,9 +21,11 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
tools:ignore="UnusedAttribute">
<activity android:name="org.koitharu.kotatsu.main.ui.MainActivity">
<activity
android:name="org.koitharu.kotatsu.main.ui.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -32,12 +34,16 @@
android:name="android.app.default_searchable"
android:value=".ui.search.SearchActivity" />
</activity>
<activity android:name="org.koitharu.kotatsu.details.ui.DetailsActivity">
<activity
android:name="org.koitharu.kotatsu.details.ui.DetailsActivity"
android:exported="true">
<intent-filter>
<action android:name="${applicationId}.action.VIEW_MANGA" />
</intent-filter>
</activity>
<activity android:name="org.koitharu.kotatsu.reader.ui.ReaderActivity">
<activity
android:name="org.koitharu.kotatsu.reader.ui.ReaderActivity"
android:exported="true">
<intent-filter>
<action android:name="${applicationId}.action.READ_MANGA" />
</intent-filter>
@@ -50,13 +56,19 @@
android:label="@string/settings" />
<activity
android:name="org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity"
android:exported="true"
android:label="@string/settings">
<intent-filter>
<action android:name="android.intent.action.MANAGE_NETWORK_USAGE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity android:name="org.koitharu.kotatsu.browser.BrowserActivity" />
<activity
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity"
android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.core.ui.CrashActivity"
android:label="@string/error_occurred"
@@ -68,6 +80,7 @@
android:windowSoftInputMode="stateAlwaysHidden" />
<activity
android:name="org.koitharu.kotatsu.widget.shelf.ShelfConfigActivity"
android:exported="true"
android:label="@string/manga_shelf">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
@@ -83,9 +96,12 @@
<activity
android:name=".settings.protect.ProtectSetupActivity"
android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
android:label="@string/downloads" />
<service
android:name="org.koitharu.kotatsu.download.DownloadService"
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
android:foregroundServiceType="dataSync" />
<service
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"

View File

@@ -9,7 +9,7 @@ import org.koitharu.kotatsu.utils.ext.await
open class MangaLoaderContext(
private val okHttp: OkHttpClient,
private val cookieJar: CookieJar
val cookieJar: CookieJar
) : KoinComponent {
suspend fun httpGet(url: String, headers: Headers? = null): Response {
@@ -57,16 +57,6 @@ open class MangaLoaderContext(
open fun getSettings(source: MangaSource) = SourceSettings(get(), source)
fun insertCookies(domain: String, vararg cookies: String) {
val url = HttpUrl.Builder()
.scheme(SCHEME_HTTP)
.host(domain)
.build()
cookieJar.saveFromResponse(url, cookies.mapNotNull {
Cookie.parse(url, it)
})
}
private companion object {
private const val SCHEME_HTTP = "http"

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.base.ui
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
@@ -11,7 +12,6 @@ import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.ActionBarContextView
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.view.*
import androidx.viewbinding.ViewBinding
@@ -22,7 +22,6 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ext.getThemeColor
abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindowInsetsListener {
@@ -37,7 +36,7 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
override fun onCreate(savedInstanceState: Bundle?) {
if (get<AppSettings>().isAmoledTheme) {
setTheme(R.style.AppTheme_Amoled)
setTheme(R.style.AppTheme_AMOLED)
}
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -58,13 +57,18 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
protected fun setContentView(binding: B) {
this.binding = binding
super.setContentView(binding.root)
(binding.root.findViewById<View>(R.id.toolbar) as? Toolbar)?.let(this::setSupportActionBar)
val params = (binding.root.findViewById<View>(R.id.toolbar) as? Toolbar)?.layoutParams as AppBarLayout.LayoutParams
val toolbar = (binding.root.findViewById<View>(R.id.toolbar) as? Toolbar)
toolbar?.let(this::setSupportActionBar)
ViewCompat.setOnApplyWindowInsetsListener(binding.root, this)
if (get<AppSettings>().isToolbarHideWhenScrolling) {
params.scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_ENTER_ALWAYS
} else {
params.scrollFlags = SCROLL_FLAG_NO_SCROLL
val toolbarParams = (binding.root.findViewById<View>(R.id.toolbar_card) ?: toolbar)
?.layoutParams as? AppBarLayout.LayoutParams
if (toolbarParams != null) {
if (get<AppSettings>().isToolbarHideWhenScrolling) {
toolbarParams.scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_ENTER_ALWAYS or SCROLL_FLAG_SNAP
} else {
toolbarParams.scrollFlags = SCROLL_FLAG_NO_SCROLL
}
}
}
@@ -98,6 +102,12 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
(findViewById<View>(R.id.toolbar) as? Toolbar)?.let(this::setSupportActionBar)
}
protected fun isDarkAmoledTheme(): Boolean {
val uiMode = resources.configuration.uiMode
val isNight = uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
return isNight && get<AppSettings>().isAmoledTheme
}
override fun onSupportActionModeStarted(mode: ActionMode) {
super.onSupportActionModeStarted(mode)
val insets = ViewCompat.getRootWindowInsets(binding.root)
@@ -106,12 +116,6 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
view?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
window?.statusBarColor = ContextCompat.getColor(this, R.color.grey_dark)
}
override fun onSupportActionModeFinished(mode: ActionMode) {
super.onSupportActionModeFinished(mode)
window?.statusBarColor = getThemeColor(android.R.attr.statusBarColor)
}
override fun onBackPressed() {

View File

@@ -38,6 +38,7 @@ abstract class BaseFragment<B : ViewBinding> : Fragment(), OnApplyWindowInsetsLi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
lastInsets = Insets.NONE
ViewCompat.setOnApplyWindowInsetsListener(view, this)
}

View File

@@ -37,7 +37,7 @@ class SectionItemDecoration(
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDrawOver(c, parent, state)
val textView = headerView ?: parent.inflate<TextView>(R.layout.item_header).also {
val textView = headerView ?: parent.inflate<TextView>(R.layout.item_filter_header).also {
headerView = it
}
fixLayoutSize(textView, parent)

View File

@@ -0,0 +1,41 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.core.view.isGone
import com.google.android.material.R
import com.google.android.material.appbar.MaterialToolbar
import java.lang.reflect.Field
class AnimatedToolbar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.toolbarStyle,
) : MaterialToolbar(context, attrs, defStyleAttr) {
private var navButtonView: View? = null
get() {
if (field == null) {
runCatching {
field = navButtonViewField?.get(this) as? View
}
}
return field
}
override fun setNavigationIcon(icon: Drawable?) {
super.setNavigationIcon(icon)
navButtonView?.isGone = (icon == null)
}
private companion object {
val navButtonViewField: Field? = runCatching {
Toolbar::class.java.getDeclaredField("mNavButtonView")
.also { it.isAccessible = true }
}.getOrNull()
}
}

View File

@@ -4,16 +4,17 @@ import android.content.Context
import android.util.AttributeSet
import android.view.View.OnClickListener
import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import androidx.core.view.children
import com.google.android.material.R
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable
import com.google.android.material.chip.ChipGroup
import org.koitharu.kotatsu.utils.ext.getThemeColor
import org.koitharu.kotatsu.R
class ChipsView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.chipGroupStyle
defStyleAttr: Int = com.google.android.material.R.attr.chipGroupStyle
) : ChipGroup(context, attrs, defStyleAttr) {
private var isLayoutSuppressedCompat = false
@@ -21,12 +22,21 @@ class ChipsView @JvmOverloads constructor(
private var chipOnClickListener = OnClickListener {
onChipClickListener?.onChipClick(it as Chip, it.tag)
}
private var chipOnCloseListener = OnClickListener {
onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag)
}
var onChipClickListener: OnChipClickListener? = null
set(value) {
field = value
val isChipClickable = value != null
children.forEach { it.isClickable = isChipClickable }
}
var onChipCloseClickListener: OnChipCloseClickListener? = null
set(value) {
field = value
val isCloseIconVisible = value != null
children.forEach { (it as? Chip)?.isCloseIconVisible = isCloseIconVisible }
}
override fun requestLayout() {
if (isLayoutSuppressedCompat) {
@@ -36,15 +46,15 @@ class ChipsView @JvmOverloads constructor(
}
}
fun setChips(items: List<ChipModel>) {
fun setChips(items: Collection<ChipModel>) {
suppressLayoutCompat(true)
try {
for ((i, model) in items.withIndex()) {
val chip = getChildAt(i) as Chip? ?: addChip()
bindChip(chip, model)
}
for (i in items.size until childCount) {
removeViewAt(i)
if (childCount > items.size) {
removeViews(items.size, childCount - items.size)
}
} finally {
suppressLayoutCompat(false)
@@ -59,16 +69,19 @@ class ChipsView @JvmOverloads constructor(
chip.isCheckedIconVisible = true
chip.setChipIconResource(model.icon)
}
chip.isClickable = onChipClickListener != null
chip.tag = model.data
}
private fun addChip(): Chip {
val chip = Chip(context)
chip.setTextColor(context.getThemeColor(android.R.attr.textColorPrimary))
chip.isCloseIconVisible = false
val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip)
chip.setChipDrawable(drawable)
chip.setTextColor(ContextCompat.getColor(context, R.color.color_primary))
chip.isCloseIconVisible = onChipCloseClickListener != null
chip.setOnCloseIconClickListener(chipOnCloseListener)
chip.setEnsureMinTouchTargetSize(false)
chip.setOnClickListener(chipOnClickListener)
chip.isClickable = onChipClickListener != null
addView(chip)
return chip
}
@@ -93,4 +106,9 @@ class ChipsView @JvmOverloads constructor(
fun onChipClick(chip: Chip, data: Any?)
}
fun interface OnChipCloseClickListener {
fun onChipCloseClick(chip: Chip, data: Any?)
}
}

View File

@@ -6,10 +6,11 @@ import android.widget.LinearLayout
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.withStyledAttributes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.resolveAdjustedSize
class CoverImageView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0,
) : AppCompatImageView(context, attrs, defStyleAttr) {
private var orientation: Int = HORIZONTAL
@@ -21,25 +22,42 @@ class CoverImageView @JvmOverloads constructor(
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val w: Int
val h: Int
if (orientation == VERTICAL) {
val originalHeight = MeasureSpec.getSize(heightMeasureSpec)
super.onMeasure(
MeasureSpec.makeMeasureSpec(
(originalHeight * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).toInt(),
MeasureSpec.EXACTLY
),
MeasureSpec.makeMeasureSpec(originalHeight, MeasureSpec.EXACTLY)
val desiredHeight = (drawable?.intrinsicHeight?.coerceAtLeast(0) ?: 0) +
paddingTop + paddingBottom
h = resolveAdjustedSize(
desiredHeight.coerceAtLeast(suggestedMinimumHeight),
maxHeight,
heightMeasureSpec
)
val desiredWidth =
(h * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).toInt() + paddingLeft + paddingRight
w = resolveAdjustedSize(
desiredWidth.coerceAtLeast(suggestedMinimumWidth),
maxWidth,
widthMeasureSpec
)
} else {
val originalWidth = MeasureSpec.getSize(widthMeasureSpec)
super.onMeasure(
MeasureSpec.makeMeasureSpec(originalWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(
(originalWidth * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).toInt(),
MeasureSpec.EXACTLY
)
val desiredWidth = (drawable?.intrinsicWidth?.coerceAtLeast(0) ?: 0) +
paddingLeft + paddingRight
w = resolveAdjustedSize(
desiredWidth.coerceAtLeast(suggestedMinimumWidth),
maxWidth,
widthMeasureSpec
)
val desiredHeight =
(w * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).toInt() + paddingTop + paddingBottom
h = resolveAdjustedSize(
desiredHeight.coerceAtLeast(suggestedMinimumHeight),
maxHeight,
heightMeasureSpec
)
}
val widthSize = resolveSizeAndState(w, widthMeasureSpec, 0)
val heightSize = resolveSizeAndState(h, heightMeasureSpec, 0)
setMeasuredDimension(widthSize, heightSize)
}
companion object {

View File

@@ -0,0 +1,97 @@
/*
* Copyright 2018 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.koitharu.kotatsu.base.ui.widgets
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.Button
import android.widget.FrameLayout
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.core.view.postDelayed
import org.koitharu.kotatsu.R
/**
* A custom snackbar implementation allowing more control over placement and entry/exit animations.
*
* Xtimms: Well, my sufferings over the Snackbar in [DetailsActivity] will go away forever... Thanks, Google.
*
* https://github.com/google/iosched/blob/main/mobile/src/main/java/com/google/samples/apps/iosched/widget/FadingSnackbar.kt
*/
class FadingSnackbar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private val message: TextView
private val action: Button
init {
val view = LayoutInflater.from(context).inflate(R.layout.fading_snackbar_layout, this, true)
message = view.findViewById(R.id.snackbar_text)
action = view.findViewById(R.id.snackbar_action)
}
fun dismiss() {
if (visibility == VISIBLE && alpha == 1f) {
animate()
.alpha(0f)
.withEndAction { visibility = GONE }
.duration = EXIT_DURATION
}
}
fun show(
messageText: CharSequence? = null,
@StringRes actionId: Int? = null,
longDuration: Boolean = true,
actionClick: () -> Unit = { dismiss() },
dismissListener: () -> Unit = { }
) {
message.text = messageText
if (actionId != null) {
action.run {
visibility = VISIBLE
text = context.getString(actionId)
setOnClickListener {
actionClick()
}
}
} else {
action.visibility = GONE
}
alpha = 0f
visibility = VISIBLE
animate()
.alpha(1f)
.duration = ENTER_DURATION
val showDuration = ENTER_DURATION + if (longDuration) LONG_DURATION else SHORT_DURATION
postDelayed(showDuration) {
dismiss()
dismissListener()
}
}
companion object {
private const val ENTER_DURATION = 300L
private const val EXIT_DURATION = 200L
private const val SHORT_DURATION = 1_500L
private const val LONG_DURATION = 2_750L
}
}

View File

@@ -1,10 +1,8 @@
package org.koitharu.kotatsu.browser
import android.graphics.Bitmap
import android.webkit.WebResourceResponse
import android.webkit.WebView
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koitharu.kotatsu.core.network.WebViewClientCompat
@@ -27,19 +25,4 @@ class BrowserClient(private val callback: BrowserCallback) : WebViewClientCompat
super.onPageCommitVisible(view, url)
callback.onTitleChanged(view.title.orEmpty(), url)
}
override fun shouldInterceptRequestCompat(view: WebView, url: String): WebResourceResponse? {
return runCatching {
val request = Request.Builder()
.url(url)
.build()
val response = okHttp.newCall(request).execute()
val ct = response.body?.contentType()
WebResourceResponse(
"${ct?.type}/${ct?.subtype}",
ct?.charset()?.name() ?: "utf-8",
response.body?.byteStream()
)
}.getOrNull()
}
}

View File

@@ -118,6 +118,7 @@ class BackupRepository(private val db: MangaDatabase) {
jo.put("created_at", createdAt)
jo.put("sort_key", sortKey)
jo.put("title", title)
jo.put("order", order)
return jo
}

View File

@@ -5,6 +5,7 @@ import org.json.JSONObject
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.history.data.HistoryEntity
@@ -101,7 +102,8 @@ class RestoreRepository(private val db: MangaDatabase) {
categoryId = json.getInt("category_id"),
createdAt = json.getLong("created_at"),
sortKey = json.getInt("sort_key"),
title = json.getString("title")
title = json.getString("title"),
order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
)
private fun parseFavourite(json: JSONObject) = FavouriteEntity(

View File

@@ -20,6 +20,7 @@ val databaseModule
Migration5To6(),
Migration6To7(),
Migration7To8(),
Migration8To9(),
).addCallback(
DatabasePrePopulateCallback(androidContext().resources)
).build()

View File

@@ -4,13 +4,14 @@ import android.content.res.Resources
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.SortOrder
class DatabasePrePopulateCallback(private val resources: Resources) : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
db.execSQL(
"INSERT INTO favourite_categories (created_at, sort_key, title) VALUES (?,?,?)",
arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later))
"INSERT INTO favourite_categories (created_at, sort_key, title, `order`) VALUES (?,?,?,?)",
arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later), SortOrder.NEWEST.name)
)
}
}

View File

@@ -16,7 +16,7 @@ import org.koitharu.kotatsu.history.data.HistoryEntity
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class
], version = 8
], version = 9
)
abstract class MangaDatabase : RoomDatabase() {

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import org.koitharu.kotatsu.core.model.SortOrder
class Migration8To9 : Migration(8, 9) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `order` TEXT NOT NULL DEFAULT ${SortOrder.NEWEST.name}")
}
}

View File

@@ -33,7 +33,7 @@ data class VersionId(
companion object {
private fun variantWeight(variantType: String) =
when (variantType.toLowerCase(Locale.ROOT)) {
when (variantType.lowercase(Locale.ROOT)) {
"a", "alpha" -> 1
"b", "beta" -> 2
"rc" -> 4

View File

@@ -9,5 +9,6 @@ data class FavouriteCategory(
val id: Long,
val title: String,
val sortKey: Int,
val createdAt: Date
val order: SortOrder,
val createdAt: Date,
) : Parcelable

View File

@@ -9,6 +9,8 @@ data class MangaChapter(
val name: String,
val number: Int,
val url: String,
val scanlator: String? = null,
val uploadDate: Long,
val branch: String? = null,
val source: MangaSource
) : Parcelable

View File

@@ -5,6 +5,6 @@ import kotlinx.parcelize.Parcelize
@Parcelize
data class MangaFilter(
val sortOrder: SortOrder,
val tag: MangaTag?
val sortOrder: SortOrder?,
val tags: Set<MangaTag>,
) : Parcelable

View File

@@ -39,10 +39,13 @@ enum class MangaSource(
NINEMANGA_IT("NineManga Italiano", "it", NineMangaRepository.Italiano::class.java),
NINEMANGA_BR("NineManga Brasil", "pt", NineMangaRepository.Brazil::class.java),
NINEMANGA_FR("NineManga Français", "fr", NineMangaRepository.Francais::class.java),
EXHENTAI("ExHentai", null, ExHentaiRepository::class.java),
MANGAOWL("MangaOwl", "en", MangaOwlRepository::class.java)
;
@get:Throws(NoBeanDefFoundException::class)
@Deprecated("")
@Deprecated("", ReplaceWith("MangaRepository(this)",
"org.koitharu.kotatsu.core.parser.MangaRepository"))
val repository: MangaRepository
get() = GlobalContext.get().get(named(this))
}

View File

@@ -1,16 +1,19 @@
package org.koitharu.kotatsu.core.parser
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.qualifier.named
import org.koitharu.kotatsu.core.model.*
interface MangaRepository {
val sortOrders: Set<SortOrder>
suspend fun getList(
suspend fun getList2(
offset: Int,
query: String? = null,
tags: Set<MangaTag>? = null,
sortOrder: SortOrder? = null,
tag: MangaTag? = null
): List<Manga>
suspend fun getDetails(manga: Manga): Manga
@@ -20,4 +23,11 @@ interface MangaRepository {
suspend fun getPageUrl(page: MangaPage): String
suspend fun getTags(): Set<MangaTag>
companion object : KoinComponent {
operator fun invoke(source: MangaSource): MangaRepository {
return get(named(source))
}
}
}

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.core.parser
interface MangaRepositoryAuthProvider {
val authUrl: String
fun isAuthorized(): Boolean
}

View File

@@ -32,4 +32,6 @@ val parserModule
factory<MangaRepository>(named(MangaSource.NINEMANGA_RU)) { NineMangaRepository.Russian(get()) }
factory<MangaRepository>(named(MangaSource.NINEMANGA_IT)) { NineMangaRepository.Italiano(get()) }
factory<MangaRepository>(named(MangaSource.NINEMANGA_FR)) { NineMangaRepository.Francais(get()) }
factory<MangaRepository>(named(MangaSource.EXHENTAI)) { ExHentaiRepository(get()) }
factory<MangaRepository>(named(MangaSource.MANGAOWL)) { MangaOwlRepository(get()) }
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaTag
@@ -19,6 +20,9 @@ abstract class RemoteMangaRepository(
loaderContext.getSettings(source)
}
val title: String
get() = source.title
override val sortOrders: Set<SortOrder> get() = emptySet()
override suspend fun getPageUrl(page: MangaPage): String = page.url.withDomain()
@@ -75,4 +79,8 @@ abstract class RemoteMangaRepository(
h = 31 * h + id
return h
}
protected fun parseFailed(message: String? = null): Nothing {
throw ParseException(message)
}
}

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core.parser.site
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
@@ -17,45 +16,52 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
SortOrder.NEWEST
)
override suspend fun getList(
override suspend fun getList2(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?
tags: Set<MangaTag>?,
sortOrder: SortOrder?
): List<Manga> {
if (!query.isNullOrEmpty()) {
return if (offset == 0) search(query) else emptyList()
}
val page = (offset / 12f).toIntUp().inc()
val link = when {
tag != null -> "/manga?genre[]=${tag.key}&page=$page".withDomain()
else -> "/manga?page=$page".withDomain()
tags.isNullOrEmpty() -> "/manga?page=$page".withDomain()
else -> tags.joinToString(
prefix = "/manga?",
postfix = "&page=$page",
separator = "&",
) { tag -> "genre[]=${tag.key}" }.withDomain()
}
val doc = loaderContext.httpGet(link).parseHtml()
val root = doc.body().select("div.manga-block") ?: throw ParseException("Cannot find root")
val root = doc.body().select("div.manga-block") ?: parseFailed("Cannot find root")
val items = root.select("div.anime-card")
return items.mapNotNull { card ->
val href = card.selectFirst("a").attr("href")
val href = card.selectFirst("a")?.attr("href") ?: return@mapNotNull null
val status = card.select("tr")[2].text()
val fullTitle = card.selectFirst("h1.anime-card-title").text()
.substringBeforeLast('[')
val fullTitle = card.selectFirst("h1.anime-card-title")?.text()
?.substringBeforeLast('[') ?: return@mapNotNull null
val titleParts = fullTitle.splitTwoParts('/')
Manga(
id = generateUid(href),
title = titleParts?.first?.trim() ?: fullTitle,
coverUrl = card.selectFirst("img").attr("data-src").withDomain(),
coverUrl = card.selectFirst("img")?.attr("data-src")
?.withDomain().orEmpty(),
altTitle = titleParts?.second?.trim(),
author = null,
rating = Manga.NO_RATING,
url = href,
publicUrl = href.withDomain(),
tags = card.select("p.tupe.tag")?.select("a")?.mapNotNullToSet tags@{ x ->
tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x ->
MangaTag(
title = x.text(),
key = x.attr("href")?.substringAfterLast("=") ?: return@tags null,
key = x.attr("href").ifEmpty {
return@mapNotNull null
}.substringAfterLast("="),
source = source
)
}.orEmpty(),
},
state = when (status) {
"выпускаецца" -> MangaState.ONGOING
"завершанае" -> MangaState.FINISHED
@@ -68,18 +74,20 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
override suspend fun getDetails(manga: Manga): Manga {
val doc = loaderContext.httpGet(manga.publicUrl).parseHtml()
val root = doc.body().select("div.container") ?: throw ParseException("Cannot find root")
val root = doc.body().select("div.container") ?: parseFailed("Cannot find root")
return manga.copy(
description = root.select("div.manga-block.grid-12")[2].select("p").text(),
chapters = root.select("ul.series").flatMap { table ->
table.select("li")
}.map { it.selectFirst("a") }.mapIndexedNotNull { i, a ->
val href = a.select("a").first().attr("href").toRelativeUrl(getDomain())
val href = a?.select("a")?.first()?.attr("href")
?.toRelativeUrl(getDomain()) ?: return@mapIndexedNotNull null
MangaChapter(
id = generateUid(href),
name = a.select("a").first().text(),
name = "Глава " + a.selectFirst("a")?.text().orEmpty(),
number = i + 1,
url = href,
uploadDate = 0L,
source = source
)
}
@@ -112,16 +120,17 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
)
}
}
throw ParseException("Pages list not found at ${chapter.url.withDomain()}")
parseFailed("Pages list not found at ${chapter.url.withDomain()}")
}
override suspend fun getTags(): Set<MangaTag> {
val doc = loaderContext.httpGet("https://${getDomain()}/manga").parseHtml()
val root = doc.body().select("div#tabs-genres").select("ul#list.ul-three-colums")
return root.select("p.menu-tags.tupe").mapToSet { a ->
return root.select("p.menu-tags.tupe").mapToSet { p ->
val a = p.selectFirst("a") ?: parseFailed("a is null")
MangaTag(
title = a.select("a").text().capitalize(Locale.ROOT),
key = a.select("a").attr("data-name"),
title = a.text().toCamelCase(),
key = a.attr("data-name"),
source = source
)
}
@@ -130,30 +139,33 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
private suspend fun search(query: String): List<Manga> {
val domain = getDomain()
val doc = loaderContext.httpGet("https://$domain/search?q=$query").parseHtml()
val root = doc.body().select("div.manga-block").select("article.tab-2") ?: throw ParseException("Cannot find root")
val root = doc.body().select("div.manga-block").select("article.tab-2") ?: parseFailed("Cannot find root")
val items = root.select("div.anime-card")
return items.mapNotNull { card ->
val href = card.select("a").attr("href")
val status = card.select("tr")[2].text()
val fullTitle = card.selectFirst("h1.anime-card-title").text()
.substringBeforeLast('[')
val fullTitle = card.selectFirst("h1.anime-card-title")?.text()
?.substringBeforeLast('[') ?: return@mapNotNull null
val titleParts = fullTitle.splitTwoParts('/')
Manga(
id = generateUid(href),
title = titleParts?.first?.trim() ?: fullTitle,
coverUrl = card.selectFirst("img").attr("src").withDomain(),
coverUrl = card.selectFirst("img")?.attr("src")
?.withDomain().orEmpty(),
altTitle = titleParts?.second?.trim(),
author = null,
rating = Manga.NO_RATING,
url = href,
publicUrl = href.withDomain(),
tags = card.select("p.tupe.tag")?.select("a")?.mapNotNullToSet tags@{ x ->
tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x ->
MangaTag(
title = x.text(),
key = x.attr("href")?.substringAfterLast("=") ?: return@tags null,
key = x.attr("href").ifEmpty {
return@mapNotNull null
}.substringAfterLast("="),
source = source
)
}.orEmpty(),
},
state = when (status) {
"выпускаецца" -> MangaState.ONGOING
"завершанае" -> MangaState.FINISHED

View File

@@ -5,6 +5,7 @@ import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
import java.text.SimpleDateFormat
import java.util.*
abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(
@@ -17,11 +18,11 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
SortOrder.ALPHABETICAL
)
override suspend fun getList(
override suspend fun getList2(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?
tags: Set<MangaTag>?,
sortOrder: SortOrder?
): List<Manga> {
val domain = getDomain()
val url = when {
@@ -31,11 +32,15 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
}
"https://$domain/?do=search&subaction=search&story=${query.urlEncoded()}"
}
tag != null -> "https://$domain/tags/${tag.key}&n=${getSortKey2(sortOrder)}?offset=$offset"
!tags.isNullOrEmpty() -> tags.joinToString(
prefix = "https://$domain/tags/",
postfix = "&n=${getSortKey2(sortOrder)}?offset=$offset",
separator = "+",
) { tag -> tag.key }
else -> "https://$domain/${getSortKey(sortOrder)}?offset=$offset"
}
val doc = loaderContext.httpGet(url).parseHtml()
val root = doc.body().selectFirst("div.main_fon").getElementById("content")
val root = doc.body().selectFirst("div.main_fon")?.getElementById("content")
?: throw ParseException("Cannot find root")
return root.select("div.content_row").mapNotNull { row ->
val a = row.selectFirst("div.manga_row1")?.selectFirst("h2")?.selectFirst("a")
@@ -72,18 +77,18 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
val doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml()
val root =
doc.body().getElementById("dle-content") ?: throw ParseException("Cannot find root")
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
return manga.copy(
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
chapters = root.select("table.table_cha").flatMap { table ->
table.select("div.manga2")
}.map { it.selectFirst("a") }.reversed().mapIndexedNotNull { i, a ->
val href = a.relUrl("href")
chapters = root.select("table.table_cha tr:gt(1)").reversed().mapIndexedNotNull { i, tr ->
val href = tr?.selectFirst("a")?.relUrl("href") ?: return@mapIndexedNotNull null
MangaChapter(
id = generateUid(href),
name = a.text().trim(),
name = tr.selectFirst("a")?.text().orEmpty(),
number = i + 1,
url = href,
uploadDate = dateFormat.tryParse(tr.selectFirst("div.date")?.text()),
source = source
)
}
@@ -123,12 +128,12 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
override suspend fun getTags(): Set<MangaTag> {
val domain = getDomain()
val doc = loaderContext.httpGet("https://$domain/catalog").parseHtml()
val root = doc.body().selectFirst("div.main_fon").getElementById("side")
.select("ul").last()
val root = doc.body().selectFirst("div.main_fon")?.getElementById("side")
?.select("ul")?.last() ?: throw ParseException("Cannot find root")
return root.select("li.sidetag").mapToSet { li ->
val a = li.children().last()
val a = li.children().last() ?: throw ParseException("a is null")
MangaTag(
title = a.text().capitalize(),
title = a.text().toCamelCase(),
key = a.attr("href").substringAfterLast('/'),
source = source
)
@@ -150,4 +155,5 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
SortOrder.NEWEST -> "datedesc"
else -> "favdesc"
}
}

View File

@@ -6,7 +6,6 @@ import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
import java.util.*
import kotlin.collections.ArrayList
class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
@@ -21,11 +20,11 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
SortOrder.ALPHABETICAL
)
override suspend fun getList(
override suspend fun getList2(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?
tags: Set<MangaTag>?,
sortOrder: SortOrder?
): List<Manga> {
if (query != null && offset != 0) {
return emptyList()
@@ -38,9 +37,9 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
append(getSortKey(sortOrder))
append("&page=")
append((offset / 20) + 1)
if (tag != null) {
if (!tags.isNullOrEmpty()) {
append("&genres=")
append(tag.key)
appendAll(tags, ",") { it.key }
}
if (query != null) {
append("&search=")
@@ -94,11 +93,14 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
description = json.getString("description"),
chapters = chaptersList.mapIndexed { i, it ->
val chid = it.getLong("id")
val volChap = "Том " + it.getString("vol") + ". " + "Глава " + it.getString("ch")
val title = if (it.getString("title") == "null") "" else it.getString("title")
MangaChapter(
id = generateUid(chid),
source = manga.source,
url = "$baseChapterUrl$chid",
name = it.getStringOrNull("title") ?: "${manga.title} #${it.getDouble("ch")}",
uploadDate = it.getLong("date") * 1000,
name = if (title.isEmpty()) volChap else "$volChap: $title",
number = totalChapters - i
)
}.reversed()
@@ -122,12 +124,13 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
override suspend fun getTags(): Set<MangaTag> {
val doc = loaderContext.httpGet("https://${getDomain()}/manga/").parseHtml()
val root = doc.body().getElementById("animeFilter").selectFirst(".catalog-genres")
val root = doc.body().getElementById("animeFilter")
?.selectFirst(".catalog-genres") ?: throw ParseException("Root not found")
return root.select("li").mapToSet {
MangaTag(
source = source,
key = it.selectFirst("input").attr("data-genre"),
title = it.selectFirst("label").text()
key = it.selectFirst("input")?.attr("data-genre") ?: parseFailed(),
title = it.selectFirst("label")?.text() ?: parseFailed()
)
}
}

View File

@@ -0,0 +1,258 @@
package org.koitharu.kotatsu.core.parser.site
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.MangaRepositoryAuthProvider
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
import kotlin.math.pow
private const val DOMAIN_UNAUTHORIZED = "e-hentai.org"
private const val DOMAIN_AUTHORIZED = "exhentai.org"
class ExHentaiRepository(
loaderContext: MangaLoaderContext,
) : RemoteMangaRepository(loaderContext), MangaRepositoryAuthProvider {
override val source = MangaSource.EXHENTAI
override val defaultDomain: String
get() = if (isAuthorized()) DOMAIN_AUTHORIZED else DOMAIN_UNAUTHORIZED
override val authUrl: String
get() = "https://${getDomain()}/bounce_login.php"
private val ratingPattern = Regex("-?[0-9]+px")
private val authCookies = arrayOf("ipb_member_id", "ipb_pass_hash")
private var updateDm = false
init {
loaderContext.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "nw=1", "sl=dm_2")
loaderContext.cookieJar.insertCookies(DOMAIN_UNAUTHORIZED, "nw=1", "sl=dm_2")
}
override suspend fun getList2(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder?,
): List<Manga> {
val page = (offset / 25f).toIntUp()
var search = query?.urlEncoded().orEmpty()
val url = buildString {
append("https://")
append(getDomain())
append("/?page=")
append(page)
if (!tags.isNullOrEmpty()) {
var fCats = 0
for (tag in tags) {
tag.key.toIntOrNull()?.let { fCats = fCats or it } ?: run {
search += tag.key + " "
}
}
if (fCats != 0) {
append("&f_cats=")
append(1023 - fCats)
}
}
if (search.isNotEmpty()) {
append("&f_search=")
append(search.trim().replace(' ', '+'))
}
// by unknown reason cookie "sl=dm_2" is ignored, so, we should request it again
if (updateDm) {
append("&inline_set=dm_e")
}
}
val body = loaderContext.httpGet(url).parseHtml().body()
val root = body.selectFirst("table.itg")
?.selectFirst("tbody")
?: if (updateDm) {
parseFailed("Cannot find root")
} else {
updateDm = true
return getList2(offset, query, tags, sortOrder)
}
updateDm = false
return root.children().mapNotNull { tr ->
if (tr.childrenSize() != 2) return@mapNotNull null
val (td1, td2) = tr.children()
val glink = td2.selectFirst("div.glink") ?: parseFailed("glink not found")
val a = glink.parents().select("a").first() ?: parseFailed("link not found")
val href = a.relUrl("href")
val tagsDiv = glink.nextElementSibling() ?: parseFailed("tags div not found")
val mainTag = td2.selectFirst("div.cn")?.let { div ->
MangaTag(
title = div.text(),
key = tagIdByClass(div.classNames()) ?: return@let null,
source = source,
)
}
Manga(
id = generateUid(href),
title = glink.text().cleanupTitle(),
altTitle = null,
url = href,
publicUrl = a.absUrl("href"),
rating = td2.selectFirst("div.ir")?.parseRating() ?: Manga.NO_RATING,
isNsfw = true,
coverUrl = td1.selectFirst("img")?.absUrl("src").orEmpty(),
tags = setOfNotNull(mainTag),
state = null,
author = tagsDiv.getElementsContainingOwnText("artist:").first()
?.nextElementSibling()?.text(),
source = source,
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml()
val root = doc.body().selectFirst("div.gm") ?: parseFailed("Cannot find root")
val cover = root.getElementById("gd1")?.children()?.first()
val title = root.getElementById("gd2")
val taglist = root.getElementById("taglist")
val tabs = doc.body().selectFirst("table.ptt")?.selectFirst("tr")
return manga.copy(
title = title?.getElementById("gn")?.text()?.cleanupTitle() ?: manga.title,
altTitle = title?.getElementById("gj")?.text()?.cleanupTitle() ?: manga.altTitle,
publicUrl = doc.baseUri().ifEmpty { manga.publicUrl },
rating = root.getElementById("rating_label")?.text()
?.substringAfterLast(' ')
?.toFloatOrNull()
?.div(5f) ?: manga.rating,
largeCoverUrl = cover?.css("background")?.cssUrl(),
description = taglist?.select("tr")?.joinToString("<br>") { tr ->
val (tc, td) = tr.children()
val subtags = td.select("a").joinToString { it.html() }
"<b>${tc.html()}</b> $subtags"
},
chapters = tabs?.select("a")?.findLast { a ->
a.text().toIntOrNull() != null
}?.let { a ->
val count = a.text().toInt()
val chapters = ArrayList<MangaChapter>(count)
for (i in 1..count) {
val url = "${manga.url}?p=$i"
chapters += MangaChapter(
id = generateUid(url),
name = "${manga.title} #$i",
number = i,
url = url,
uploadDate = 0L,
source = source,
)
}
chapters
},
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = loaderContext.httpGet(chapter.url.withDomain()).parseHtml()
val root = doc.body().getElementById("gdt") ?: parseFailed("Root not found")
return root.select("a").mapNotNull { a ->
val url = a.relUrl("href")
MangaPage(
id = generateUid(url),
url = url,
referer = a.absUrl("href"),
preview = null,
source = source,
)
}
}
override suspend fun getPageUrl(page: MangaPage): String {
val doc = loaderContext.httpGet(page.url.withDomain()).parseHtml()
return doc.body().getElementById("img")?.absUrl("src")
?: parseFailed("Image not found")
}
override suspend fun getTags(): Set<MangaTag> {
val doc = loaderContext.httpGet("https://${getDomain()}").parseHtml()
val root = doc.body().getElementById("searchbox")?.selectFirst("table")
?: parseFailed("Root not found")
return root.select("div.cs").mapNotNullToSet { div ->
val id = div.id().substringAfterLast('_').toIntOrNull()
?: return@mapNotNullToSet null
MangaTag(
title = div.text(),
key = id.toString(),
source = source
)
}
}
override fun isAuthorized(): Boolean {
val authorized = isAuthorized(DOMAIN_UNAUTHORIZED)
if (authorized) {
if (!isAuthorized(DOMAIN_AUTHORIZED)) {
loaderContext.cookieJar.copyCookies(
DOMAIN_UNAUTHORIZED,
DOMAIN_AUTHORIZED,
authCookies,
)
loaderContext.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "yay=louder")
}
return true
}
return false
}
private fun isAuthorized(domain: String): Boolean {
val cookies = loaderContext.cookieJar.getCookies(domain).mapToSet { x -> x.name }
return authCookies.all { it in cookies }
}
private fun Element.parseRating(): Float {
return runCatching {
val style = requireNotNull(attr("style"))
val (v1, v2) = ratingPattern.find(style)!!.destructured
var p1 = v1.dropLast(2).toInt()
val p2 = v2.dropLast(2).toInt()
if (p2 != -1) {
p1 += 8
}
(80 - p1) / 80f
}.getOrDefault(Manga.NO_RATING)
}
private fun String.cleanupTitle(): String {
val result = StringBuilder(length)
var skip = false
for (c in this) {
when {
c == '[' -> skip = true
c == ']' -> skip = false
c.isWhitespace() && result.isEmpty() -> continue
!skip -> result.append(c)
}
}
while (result.lastOrNull()?.isWhitespace() == true) {
result.deleteCharAt(result.lastIndex)
}
return result.toString()
}
private fun String.cssUrl(): String? {
val fromIndex = indexOf("url(")
if (fromIndex == -1) {
return null
}
val toIndex = indexOf(')', startIndex = fromIndex)
return if (toIndex == -1) {
null
} else {
substring(fromIndex + 4, toIndex).trim()
}
}
private fun tagIdByClass(classNames: Collection<String>): String? {
val className = classNames.find { x -> x.startsWith("ct") } ?: return null
val num = className.drop(2).toIntOrNull(16) ?: return null
return 2.0.pow(num).toInt().toString()
}
}

View File

@@ -1,11 +1,14 @@
package org.koitharu.kotatsu.core.parser.site
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Response
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
import java.text.SimpleDateFormat
import java.util.*
abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
@@ -18,11 +21,11 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
SortOrder.RATING
)
override suspend fun getList(
override suspend fun getList2(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?
tags: Set<MangaTag>?,
sortOrder: SortOrder?
): List<Manga> {
val domain = getDomain()
val doc = when {
@@ -33,22 +36,24 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
"offset" to (offset upBy PAGE_SIZE_SEARCH).toString()
)
)
tag == null -> loaderContext.httpGet(
tags.isNullOrEmpty() -> loaderContext.httpGet(
"https://$domain/list?sortType=${
getSortKey(
sortOrder
)
}&offset=${offset upBy PAGE_SIZE}"
}&offset=${offset upBy PAGE_SIZE}", HEADER
)
else -> loaderContext.httpGet(
"https://$domain/list/genre/${tag.key}?sortType=${
tags.size == 1 -> loaderContext.httpGet(
"https://$domain/list/genre/${tags.first().key}?sortType=${
getSortKey(
sortOrder
)
}&offset=${offset upBy PAGE_SIZE}"
}&offset=${offset upBy PAGE_SIZE}", HEADER
)
}.parseHtml()
val root = doc.body().getElementById("mangaBox")
offset > 0 -> return emptyList()
else -> advancedSearch(domain, tags)
}.parseHtml().body()
val root = (doc.getElementById("mangaBox") ?: doc.getElementById("mangaResults"))
?.selectFirst("div.tiles.row") ?: throw ParseException("Cannot find root")
val baseHost = root.baseUri().toHttpUrl().host
return root.select("div.tile").mapNotNull { node ->
@@ -57,7 +62,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
if (descDiv.selectFirst("i.fa-user") != null) {
return@mapNotNull null //skip author
}
val href = imgDiv.selectFirst("a").attr("href")?.inContextOf(node)
val href = imgDiv.selectFirst("a")?.attr("href")?.inContextOf(node)
if (href == null || href.toHttpUrl().host != baseHost) {
return@mapNotNull null // skip external links
}
@@ -101,9 +106,10 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml()
val doc = loaderContext.httpGet(manga.url.withDomain(), HEADER).parseHtml()
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
?: throw ParseException("Cannot find root")
val dateFormat = SimpleDateFormat("dd.MM.yy", Locale.US)
return manga.copy(
description = root.selectFirst("div.manga-description")?.html(),
largeCoverUrl = root.selectFirst("div.subject-cower")?.selectFirst("img")?.attr(
@@ -119,13 +125,23 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
)
},
chapters = root.selectFirst("div.chapters-link")?.selectFirst("table")
?.select("a")?.asReversed()?.mapIndexed { i, a ->
?.select("tr:has(td > a)")?.asReversed()?.mapIndexedNotNull { i, tr ->
val a = tr.selectFirst("a") ?: return@mapIndexedNotNull null
val href = a.relUrl("href")
var translators = ""
val translatorElement = a.attr("title")
if (!translatorElement.isNullOrBlank()) {
translators = translatorElement
.replace("(Переводчик),", "&")
.removeSuffix(" (Переводчик)")
}
MangaChapter(
id = generateUid(href),
name = a.ownText().removePrefix(manga.title).trim(),
name = tr.selectFirst("a")?.text().orEmpty().removePrefix(manga.title).trim(),
number = i + 1,
url = href,
uploadDate = dateFormat.tryParse(tr.selectFirst("td.d-none")?.text()),
scanlator = translators,
source = source
)
}
@@ -133,7 +149,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = loaderContext.httpGet(chapter.url.withDomain() + "?mtr=1").parseHtml()
val doc = loaderContext.httpGet(chapter.url.withDomain() + "?mtr=1", HEADER).parseHtml()
val scripts = doc.select("script")
for (script in scripts) {
val data = script.html()
@@ -160,12 +176,12 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
}
override suspend fun getTags(): Set<MangaTag> {
val doc = loaderContext.httpGet("https://${getDomain()}/list/genres/sort_name").parseHtml()
val root = doc.body().getElementById("mangaBox").selectFirst("div.leftContent")
.selectFirst("table.table")
val doc = loaderContext.httpGet("https://${getDomain()}/list/genres/sort_name", HEADER).parseHtml()
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
?.selectFirst("table.table") ?: parseFailed("Cannot find root")
return root.select("a.element-link").mapToSet { a ->
MangaTag(
title = a.text().capitalize(),
title = a.text().toCamelCase(),
key = a.attr("href").substringAfterLast('/'),
source = source
)
@@ -182,9 +198,50 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
null -> "updated"
}
private suspend fun advancedSearch(domain: String, tags: Set<MangaTag>): Response {
val url = "https://$domain/search/advanced"
// Step 1: map catalog genres names to advanced-search genres ids
val tagsIndex = loaderContext.httpGet(url, HEADER).parseHtml()
.body().selectFirst("form.search-form")
?.select("div.form-group")
?.get(1) ?: parseFailed("Genres filter element not found")
val tagNames = tags.map { it.title.lowercase() }
val payload = HashMap<String, String>()
var foundGenres = 0
tagsIndex.select("li.property").forEach { li ->
val name = li.text().trim().lowercase()
val id = li.selectFirst("input")?.id()
?: parseFailed("Id for tag $name not found")
payload[id] = if (name in tagNames) {
foundGenres++
"in"
} else ""
}
if (foundGenres != tags.size) {
parseFailed("Some genres are not found")
}
// Step 2: advanced search
payload["q"] = ""
payload["s_high_rate"] = ""
payload["s_single"] = ""
payload["s_mature"] = ""
payload["s_completed"] = ""
payload["s_translated"] = ""
payload["s_many_chapters"] = ""
payload["s_wait_upload"] = ""
payload["s_sale"] = ""
payload["years"] = "1900,2099"
payload["+"] = "Искать".urlEncoded()
return loaderContext.httpPost(url, payload)
}
private companion object {
private const val PAGE_SIZE = 70
private const val PAGE_SIZE_SEARCH = 50
private val HEADER = Headers.Builder()
.add("User-Agent", "readmangafun")
.build()
}
}

View File

@@ -8,16 +8,16 @@ import org.koitharu.kotatsu.utils.ext.parseHtml
class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loaderContext) {
override val defaultDomain = "hentaichan.pro"
override val defaultDomain = "hentaichan.live"
override val source = MangaSource.HENCHAN
override suspend fun getList(
override suspend fun getList2(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?
tags: Set<MangaTag>?,
sortOrder: SortOrder?
): List<Manga> {
return super.getList(offset, query, sortOrder, tag).map {
return super.getList2(offset, query, tags, sortOrder).map {
val cover = it.coverUrl
if (cover.contains("_blur")) {
it.copy(coverUrl = cover.replace("_blur", ""))
@@ -36,7 +36,7 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
tags = root.selectFirst("div.sidetags")?.select("li.sidetag")?.mapToSet {
val a = it.children().last()
val a = it.children().last() ?: parseFailed("Invalid tag")
MangaTag(
title = a.text(),
key = a.attr("href").substringAfterLast('/'),
@@ -49,6 +49,7 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load
url = readLink,
source = source,
number = 1,
uploadDate = 0L,
name = manga.title
)
)

View File

@@ -9,8 +9,8 @@ import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
import java.text.SimpleDateFormat
import java.util.*
import kotlin.collections.ArrayList
open class MangaLibRepository(loaderContext: MangaLoaderContext) :
RemoteMangaRepository(loaderContext) {
@@ -27,11 +27,11 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
SortOrder.NEWEST
)
override suspend fun getList(
override suspend fun getList2(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?
tags: Set<MangaTag>?,
sortOrder: SortOrder?
): List<Manga> {
if (!query.isNullOrEmpty()) {
return if (offset == 0) search(query) else emptyList()
@@ -44,20 +44,21 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
append(getSortKey(sortOrder))
append("&page=")
append(page)
if (tag != null) {
append("&includeGenres[]=")
tags?.forEach { tag ->
append("&genres[include][]=")
append(tag.key)
}
}
val doc = loaderContext.httpGet(url).parseHtml()
val root = doc.body().getElementById("manga-list") ?: throw ParseException("Root not found")
val items = root.selectFirst("div.media-cards-grid").select("div.media-card-wrap")
val items = root.selectFirst("div.media-cards-grid")?.select("div.media-card-wrap")
?: return emptyList()
return items.mapNotNull { card ->
val a = card.selectFirst("a.media-card") ?: return@mapNotNull null
val href = a.relUrl("href")
Manga(
id = generateUid(href),
title = card.selectFirst("h3").text(),
title = card.selectFirst("h3")?.text().orEmpty(),
coverUrl = a.absUrl("data-src"),
altTitle = null,
author = null,
@@ -79,6 +80,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
val info = root.selectFirst("div.media-content")
val chaptersDoc = loaderContext.httpGet("$fullUrl?section=chapters").parseHtml()
val scripts = chaptersDoc.select("script")
val dateFormat = SimpleDateFormat("yyy-MM-dd", Locale.US)
var chapters: ArrayList<MangaChapter>? = null
scripts@ for (script in scripts) {
val raw = script.html().lines()
@@ -91,29 +93,32 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
for (i in 0 until total) {
val item = list.getJSONObject(i)
val chapterId = item.getLong("chapter_id")
val branchName = item.getStringOrNull("username")
val scanlator = item.getStringOrNull("username")
val url = buildString {
append(manga.url)
append("/v")
append(item.getInt("chapter_volume"))
append("/c")
append(item.getString("chapter_number"))
@Suppress("BlockingMethodInNonBlockingContext") // lint issue
append('/')
append(item.optString("chapter_string"))
}
var name = item.getString("chapter_name")
if (name.isNullOrBlank() || name == "null") {
name = "Том " + item.getInt("chapter_volume") +
" Глава " + item.getString("chapter_number")
}
val nameChapter = item.getStringOrNull("chapter_name")
val volume = item.getInt("chapter_volume")
val number = item.getString("chapter_number")
val fullNameChapter = "Том $volume. Глава $number"
chapters.add(
MangaChapter(
id = generateUid(chapterId),
url = url,
source = source,
branch = branchName,
number = total - i,
name = name
uploadDate = dateFormat.tryParse(
item.getString("chapter_created_at").substringBefore(" ")
),
scanlator = scanlator,
name = if (nameChapter.isNullOrBlank()) fullNameChapter else "$fullNameChapter - $nameChapter"
)
)
}
@@ -128,17 +133,17 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
rating = root.selectFirst("div.media-stats-item__score")
?.selectFirst("span")
?.text()?.toFloatOrNull()?.div(5f) ?: manga.rating,
author = info.getElementsMatchingOwnText("Автор").firstOrNull()
author = info?.getElementsMatchingOwnText("Автор")?.firstOrNull()
?.nextElementSibling()?.text() ?: manga.author,
tags = info.selectFirst("div.media-tags")
tags = info?.selectFirst("div.media-tags")
?.select("a.media-tag-item")?.mapToSet { a ->
MangaTag(
title = a.text().capitalize(),
title = a.text().toCamelCase(),
key = a.attr("href").substringAfterLast('='),
source = source
)
} ?: manga.tags,
description = info.selectFirst("div.media-description__text")?.html(),
description = info?.selectFirst("div.media-description__text")?.html(),
chapters = chapters
)
}
@@ -146,11 +151,11 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.withDomain()
val doc = loaderContext.httpGet(fullUrl).parseHtml()
if (doc.location()?.endsWith("/register") == true) {
if (doc.location().endsWith("/register")) {
throw AuthRequiredException("/login".inContextOf(doc))
}
val scripts = doc.head().select("script")
val pg = doc.body().getElementById("pg").html()
val pg = (doc.body().getElementById("pg")?.html() ?: parseFailed("Element #pg not found"))
.substringAfter('=')
.substringBeforeLast(';')
val pages = JSONArray(pg)
@@ -196,7 +201,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
result += MangaTag(
source = source,
key = x.getInt("id").toString(),
title = x.getString("name").capitalize()
title = x.getString("name").toCamelCase()
)
}
return result
@@ -234,8 +239,8 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
.toFloatOrNull()?.div(5f) ?: Manga.NO_RATING,
state = null,
source = source,
coverUrl = "https://$domain${covers.getString("thumbnail")}",
largeCoverUrl = "https://$domain${covers.getString("default")}"
coverUrl = covers.getString("thumbnail"),
largeCoverUrl = covers.getString("default")
)
}
}

View File

@@ -0,0 +1,161 @@
package org.koitharu.kotatsu.core.parser.site
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
import java.text.SimpleDateFormat
import java.util.*
class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
override val source = MangaSource.MANGAOWL
override val defaultDomain = "mangaowls.com"
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.POPULARITY,
SortOrder.NEWEST,
SortOrder.UPDATED
)
override suspend fun getList2(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder?,
): List<Manga> {
val page = (offset / 36f).toIntUp().inc()
val link = buildString {
append("https://")
append(getDomain())
when {
!query.isNullOrEmpty() -> {
append("/search/${page}?search=")
append(query.urlEncoded())
}
!tags.isNullOrEmpty() -> {
for (tag in tags) {
append(tag.key)
}
append("/${page}?type=${getAlternativeSortKey(sortOrder)}")
}
else -> {
append("/${getSortKey(sortOrder)}/${page}")
}
}
}
val doc = loaderContext.httpGet(link).parseHtml()
val slides = doc.body().select("ul.slides") ?: parseFailed("An error occurred while parsing")
val items = slides.select("div.col-md-2")
return items.mapNotNull { item ->
val href = item.selectFirst("h6 a")?.relUrl("href") ?: return@mapNotNull null
Manga(
id = generateUid(href),
title = item.selectFirst("h6 a")?.text() ?: return@mapNotNull null,
coverUrl = item.select("div.img-responsive").attr("abs:data-background-image"),
altTitle = null,
author = null,
rating = runCatching {
item.selectFirst("div.block-stars")
?.text()
?.toFloatOrNull()
?.div(10f)
}.getOrNull() ?: Manga.NO_RATING,
url = href,
publicUrl = href.withDomain(),
source = source
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = loaderContext.httpGet(manga.publicUrl).parseHtml()
val info = doc.body().selectFirst("div.single_detail") ?: parseFailed("An error occurred while parsing")
val table = doc.body().selectFirst("div.single-grid-right") ?: parseFailed("An error occurred while parsing")
val dateFormat = SimpleDateFormat("MM/dd/yyyy", Locale.US)
return manga.copy(
description = info.selectFirst(".description")?.html(),
largeCoverUrl = info.select("img").first()?.let { img ->
if (img.hasAttr("data-src")) img.attr("abs:data-src") else img.attr("abs:src")
},
author = info.selectFirst("p.fexi_header_para a.author_link")?.text(),
state = parseStatus(info.select("p.fexi_header_para:contains(status)").first()?.ownText()),
tags = manga.tags + info.select("div.col-xs-12.col-md-8.single-right-grid-right > p > a[href*=genres]")
.mapNotNull {
val a = it.selectFirst("a") ?: return@mapNotNull null
MangaTag(
title = a.text(),
key = a.attr("href"),
source = source
)
},
chapters = table.select("div.table.table-chapter-list").select("li.list-group-item.chapter_list").asReversed().mapIndexed { i, li ->
val a = li.select("a")
val href = a.attr("href").ifEmpty {
parseFailed("Link is missing")
}
MangaChapter(
id = generateUid(href),
name = a.select("label").text(),
number = i + 1,
url = href,
uploadDate = dateFormat.tryParse(li.selectFirst("small:last-of-type")?.text()),
source = MangaSource.MANGAOWL
)
}
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.withDomain()
val doc = loaderContext.httpGet(fullUrl).parseHtml()
val root = doc.body().select("div.item img.owl-lazy") ?: throw ParseException("Root not found")
return root.map { div ->
val url = div?.relUrl("data-src") ?: parseFailed("Page image not found")
MangaPage(
id = generateUid(url),
url = url,
referer = fullUrl,
source = MangaSource.MANGAOWL
)
}
}
private fun parseStatus(status: String?) = when {
status == null -> null
status.contains("Ongoing") -> MangaState.ONGOING
status.contains("Completed") -> MangaState.FINISHED
else -> null
}
override suspend fun getTags(): Set<MangaTag> {
val doc = loaderContext.httpGet("https://${getDomain()}/").parseHtml()
val root = doc.body().select("ul.dropdown-menu.multi-column.columns-3").select("li")
return root.mapToSet { p ->
val a = p.selectFirst("a") ?: parseFailed("a is null")
MangaTag(
title = a.text().toCamelCase(),
key = a.attr("href"),
source = source
)
}
}
private fun getSortKey(sortOrder: SortOrder?) =
when (sortOrder ?: sortOrders.minByOrNull { it.ordinal }) {
SortOrder.POPULARITY -> "popular"
SortOrder.NEWEST -> "new_release"
SortOrder.UPDATED -> "lastest"
else -> "lastest"
}
private fun getAlternativeSortKey(sortOrder: SortOrder?) =
when (sortOrder ?: sortOrders.minByOrNull { it.ordinal }) {
SortOrder.POPULARITY -> "0"
SortOrder.NEWEST -> "2"
SortOrder.UPDATED -> "3"
else -> "3"
}
}

View File

@@ -7,6 +7,8 @@ import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.utils.ext.*
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
class MangaTownRepository(loaderContext: MangaLoaderContext) :
@@ -23,11 +25,11 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
SortOrder.UPDATED
)
override suspend fun getList(
override suspend fun getList2(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?
tags: Set<MangaTag>?,
sortOrder: SortOrder?
): List<Manga> {
val sortKey = when (sortOrder) {
SortOrder.ALPHABETICAL -> "?name.az"
@@ -43,22 +45,28 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
}
"/search?name=${query.urlEncoded()}".withDomain()
}
tag != null -> "/directory/${tag.key}/$page.htm$sortKey".withDomain()
else -> "/directory/$page.htm$sortKey".withDomain()
tags.isNullOrEmpty() -> "/directory/$page.htm$sortKey".withDomain()
tags.size == 1 -> "/directory/${tags.first().key}/$page.htm$sortKey".withDomain()
else -> tags.joinToString(
prefix = "/search?page=$page".withDomain()
) { tag ->
"&genres[${tag.key}]=1"
}
}
val doc = loaderContext.httpGet(url).parseHtml()
val root = doc.body().selectFirst("ul.manga_pic_list")
?: throw ParseException("Root not found")
return root.select("li").mapNotNull { li ->
val a = li.selectFirst("a.manga_cover")
val href = a.relUrl("href")
val href = a?.relUrl("href")
?: return@mapNotNull null
val views = li.select("p.view")
val status = views.findOwnText { x -> x.startsWith("Status:") }
?.substringAfter(':')?.trim()?.toLowerCase(Locale.ROOT)
?.substringAfter(':')?.trim()?.lowercase(Locale.ROOT)
Manga(
id = generateUid(href),
title = a.attr("title"),
coverUrl = a.selectFirst("img").absUrl("src"),
coverUrl = a.selectFirst("img")?.absUrl("src").orEmpty(),
source = MangaSource.MANGATOWN,
altTitle = null,
rating = li.selectFirst("p.score")?.selectFirst("b")
@@ -87,11 +95,12 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
val doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml()
val root = doc.body().selectFirst("section.main")
?.selectFirst("div.article_content") ?: throw ParseException("Cannot find root")
val info = root.selectFirst("div.detail_info").selectFirst("ul")
val info = root.selectFirst("div.detail_info")?.selectFirst("ul")
val chaptersList = root.selectFirst("div.chapter_content")
?.selectFirst("ul.chapter_list")?.select("li")?.asReversed()
val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US)
return manga.copy(
tags = manga.tags + info.select("li").find { x ->
tags = manga.tags + info?.select("li")?.find { x ->
x.selectFirst("b")?.ownText() == "Genre(s):"
}?.select("a")?.mapNotNull { a ->
MangaTag(
@@ -100,9 +109,10 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
source = MangaSource.MANGATOWN
)
}.orEmpty(),
description = info.getElementById("show")?.ownText(),
description = info?.getElementById("show")?.ownText(),
chapters = chaptersList?.mapIndexedNotNull { i, li ->
val href = li.selectFirst("a").relUrl("href")
val href = li.selectFirst("a")?.relUrl("href")
?: return@mapIndexedNotNull null
val name = li.select("span").filter { it.className().isEmpty() }
.joinToString(" - ") { it.text() }.trim()
MangaChapter(
@@ -110,7 +120,11 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
url = href,
source = MangaSource.MANGATOWN,
number = i + 1,
name = if (name.isEmpty()) "${manga.title} - ${i + 1}" else name
uploadDate = parseChapterDate(
dateFormat,
li.selectFirst("span.time")?.text()
),
name = name.ifEmpty { "${manga.title} - ${i + 1}" }
)
}
)
@@ -121,7 +135,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
val doc = loaderContext.httpGet(fullUrl).parseHtml()
val root = doc.body().selectFirst("div.page_select")
?: throw ParseException("Cannot find root")
return root.selectFirst("select").select("option").mapNotNull {
return root.selectFirst("select")?.select("option")?.mapNotNull {
val href = it.relUrl("value")
if (href.endsWith("featured.html")) {
return@mapNotNull null
@@ -132,20 +146,20 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
referer = fullUrl,
source = MangaSource.MANGATOWN
)
}
} ?: parseFailed("Pages list not found")
}
override suspend fun getPageUrl(page: MangaPage): String {
val doc = loaderContext.httpGet(page.url.withDomain()).parseHtml()
return doc.getElementById("image").absUrl("src")
return doc.getElementById("image")?.absUrl("src") ?: parseFailed("Image not found")
}
override suspend fun getTags(): Set<MangaTag> {
val doc = loaderContext.httpGet("/directory/".withDomain()).parseHtml()
val root = doc.body().selectFirst("aside.right")
.getElementsContainingOwnText("Genres")
.first()
.nextElementSibling()
?.getElementsContainingOwnText("Genres")
?.first()
?.nextElementSibling() ?: parseFailed("Root not found")
return root.select("li").mapNotNullToSet { li ->
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
val key = a.attr("href").parseTagKey()
@@ -160,6 +174,15 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
}
}
private fun parseChapterDate(dateFormat: DateFormat, date: String?): Long {
return when {
date.isNullOrEmpty() -> 0L
date.contains("Today") -> Calendar.getInstance().timeInMillis
date.contains("Yesterday") -> Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -1) }.timeInMillis
else -> dateFormat.tryParse(date)
}
}
override fun onCreatePreferences(map: MutableMap<String, Any>) {
super.onCreatePreferences(map)
map[SourceSettings.KEY_USE_SSL] = true

View File

@@ -4,7 +4,10 @@ import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.WordSet
import org.koitharu.kotatsu.utils.ext.*
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
class MangareadRepository(
@@ -20,17 +23,19 @@ class MangareadRepository(
SortOrder.POPULARITY
)
override suspend fun getList(
override suspend fun getList2(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?
tags: Set<MangaTag>?,
sortOrder: SortOrder?
): List<Manga> {
if (offset % PAGE_SIZE != 0) {
return emptyList()
val tag = when {
tags.isNullOrEmpty() -> null
tags.size == 1 -> tags.first()
else -> throw NotImplementedError("Multiple genres are not supported by this source")
}
val payload = createRequestTemplate()
payload["page"] = (offset / PAGE_SIZE).toString()
payload["page"] = (offset / PAGE_SIZE.toFloat()).toIntUp().toString()
payload["vars[meta_key]"] = when (sortOrder) {
SortOrder.POPULARITY -> "_wp_manga_views"
SortOrder.UPDATED -> "_latest_update"
@@ -43,25 +48,26 @@ class MangareadRepository(
payload
).parseHtml()
return doc.select("div.row.c-tabs-item__content").map { div ->
val href = div.selectFirst("a").relUrl("href")
val href = div.selectFirst("a")?.relUrl("href")
?: parseFailed("Link not found")
val summary = div.selectFirst(".tab-summary")
Manga(
id = generateUid(href),
url = href,
publicUrl = href.inContextOf(div),
coverUrl = div.selectFirst("img").absUrl("src"),
title = summary.selectFirst("h3").text(),
coverUrl = div.selectFirst("img")?.absUrl("src").orEmpty(),
title = summary?.selectFirst("h3")?.text().orEmpty(),
rating = div.selectFirst("span.total_votes")?.ownText()
?.toFloatOrNull()?.div(5f) ?: -1f,
tags = summary.selectFirst(".mg_genres")?.select("a")?.mapToSet { a ->
tags = summary?.selectFirst(".mg_genres")?.select("a")?.mapToSet { a ->
MangaTag(
key = a.attr("href").removeSuffix("/").substringAfterLast('/'),
title = a.text(),
source = MangaSource.MANGAREAD
)
}.orEmpty(),
author = summary.selectFirst(".mg_author")?.selectFirst("a")?.ownText(),
state = when (summary.selectFirst(".mg_status")?.selectFirst(".summary-content")
author = summary?.selectFirst(".mg_author")?.selectFirst("a")?.ownText(),
state = when (summary?.selectFirst(".mg_status")?.selectFirst(".summary-content")
?.ownText()?.trim()) {
"OnGoing" -> MangaState.ONGOING
"Completed" -> MangaState.FINISHED
@@ -75,9 +81,9 @@ class MangareadRepository(
override suspend fun getTags(): Set<MangaTag> {
val doc = loaderContext.httpGet("https://${getDomain()}/manga/").parseHtml()
val root = doc.body().selectFirst("header")
.selectFirst("ul.second-menu")
?.selectFirst("ul.second-menu") ?: parseFailed("Root not found")
return root.select("li").mapNotNullToSet { li ->
val a = li.selectFirst("a")
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
val href = a.attr("href").removeSuffix("/")
.substringAfterLast("genres/", "")
if (href.isEmpty()) {
@@ -101,8 +107,8 @@ class MangareadRepository(
val root2 = doc.body().selectFirst("div.content-area")
?.selectFirst("div.c-page")
?: throw ParseException("Root2 not found")
val mangaId = doc.getElementsByAttribute("data-postid").firstOrNull()
?.attr("data-postid")?.toLongOrNull()
val mangaId = doc.getElementsByAttribute("data-post").firstOrNull()
?.attr("data-post")?.toLongOrNull()
?: throw ParseException("Cannot obtain manga id")
val doc2 = loaderContext.httpPost(
"https://${getDomain()}/wp-admin/admin-ajax.php",
@@ -111,6 +117,7 @@ class MangareadRepository(
"manga" to mangaId.toString()
)
).parseHtml()
val dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.US)
return manga.copy(
tags = root.selectFirst("div.genres-content")?.select("a")
?.mapNotNullToSet { a ->
@@ -127,12 +134,18 @@ class MangareadRepository(
?.joinToString { it.html() },
chapters = doc2.select("li").asReversed().mapIndexed { i, li ->
val a = li.selectFirst("a")
val href = a.relUrl("href")
val href = a?.relUrl("href").orEmpty().ifEmpty {
parseFailed("Link is missing")
}
MangaChapter(
id = generateUid(href),
name = a.ownText(),
name = a!!.ownText(),
number = i + 1,
url = href,
uploadDate = parseChapterDate(
dateFormat,
doc2.selectFirst("span.chapter-release-date i")?.text()
),
source = MangaSource.MANGAREAD
)
}
@@ -147,7 +160,7 @@ class MangareadRepository(
?: throw ParseException("Root not found")
return root.select("div.page-break").map { div ->
val img = div.selectFirst("img")
val url = img.relUrl("src")
val url = img?.relUrl("src") ?: parseFailed("Page image not found")
MangaPage(
id = generateUid(url),
url = url,
@@ -157,6 +170,71 @@ class MangareadRepository(
}
}
private fun parseChapterDate(dateFormat: DateFormat, date: String?): Long {
date ?: return 0
return when {
date.endsWith(" ago", ignoreCase = true) -> {
parseRelativeDate(date)
}
// Handle translated 'ago' in Portuguese.
date.endsWith(" atrás", ignoreCase = true) -> {
parseRelativeDate(date)
}
// Handle translated 'ago' in Turkish.
date.endsWith(" önce", ignoreCase = true) -> {
parseRelativeDate(date)
}
// Handle 'yesterday' and 'today', using midnight
date.startsWith("year", ignoreCase = true) -> {
Calendar.getInstance().apply {
add(Calendar.DAY_OF_MONTH, -1) // yesterday
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
date.startsWith("today", ignoreCase = true) -> {
Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
date.contains(Regex("""\d(st|nd|rd|th)""")) -> {
// Clean date (e.g. 5th December 2019 to 5 December 2019) before parsing it
date.split(" ").map {
if (it.contains(Regex("""\d\D\D"""))) {
it.replace(Regex("""\D"""), "")
} else {
it
}
}
.let { dateFormat.tryParse(it.joinToString(" ")) }
}
else -> dateFormat.tryParse(date)
}
}
// Parses dates in this form:
// 21 hours ago
private fun parseRelativeDate(date: String): Long {
val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0
val cal = Calendar.getInstance()
return when {
WordSet("hari", "gün", "jour", "día", "dia", "day").anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis
WordSet("jam", "saat", "heure", "hora", "hour").anyWordIn(date) -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis
WordSet("menit", "dakika", "min", "minute", "minuto").anyWordIn(date) -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis
WordSet("detik", "segundo", "second").anyWordIn(date) -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis
WordSet("month").anyWordIn(date) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis
WordSet("year").anyWordIn(date) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis
else -> 0
}
}
private companion object {
private const val PAGE_SIZE = 12
@@ -169,4 +247,4 @@ class MangareadRepository(
it.substring(0, pos) to it.substring(pos + 1)
}.toMutableMap()
}
}
}

View File

@@ -7,6 +7,7 @@ import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
import java.text.SimpleDateFormat
import java.util.*
abstract class NineMangaRepository(
@@ -16,62 +17,66 @@ abstract class NineMangaRepository(
) : RemoteMangaRepository(loaderContext) {
init {
loaderContext.insertCookies(getDomain(), "ninemanga_template_desk=yes")
loaderContext.cookieJar.insertCookies(getDomain(), "ninemanga_template_desk=yes")
}
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.POPULARITY,
)
override suspend fun getList(
override suspend fun getList2(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?,
tags: Set<MangaTag>?,
sortOrder: SortOrder?
): List<Manga> {
val page = (offset / PAGE_SIZE.toFloat()).toIntUp() + 1
val url = buildString {
append("https://")
append(getDomain())
if (query.isNullOrEmpty()) {
append("/category/")
if (tag != null) {
append(tag.key)
} else {
append("index")
when {
!query.isNullOrEmpty() -> {
append("/search/?name_sel=&wd=")
append(query.urlEncoded())
append("&page=")
}
!tags.isNullOrEmpty() -> {
append("/search/&category_id=")
for (tag in tags) {
append(tag.key)
append(',')
}
append("&page=")
}
else -> {
append("/category/index_")
}
append("_")
append(page)
append(".html")
} else {
append("/search/?name_sel=&wd=")
append(query.urlEncoded())
append("&page=")
append(page)
append(".html")
}
append(page)
append(".html")
}
val doc = loaderContext.httpGet(url, PREDEFINED_HEADERS).parseHtml()
val root = doc.body().selectFirst("ul.direlist")
?: throw ParseException("Cannot find root")
val baseHost = root.baseUri().toHttpUrl().host
return root.select("li").map { node ->
val href = node.selectFirst("a").absUrl("href")
val href = node.selectFirst("a")?.absUrl("href")
?: parseFailed("Link not found")
val relUrl = href.toRelativeUrl(baseHost)
val dd = node.selectFirst("dd")
Manga(
id = generateUid(relUrl),
url = relUrl,
publicUrl = href,
title = dd.selectFirst("a.bookname").text().toCamelCase(),
title = dd?.selectFirst("a.bookname")?.text()?.toCamelCase().orEmpty(),
altTitle = null,
coverUrl = node.selectFirst("img").absUrl("src"),
coverUrl = node.selectFirst("img")?.absUrl("src").orEmpty(),
rating = Manga.NO_RATING,
author = null,
tags = emptySet(),
state = null,
source = source,
description = dd.selectFirst("p").html(),
description = dd?.selectFirst("p")?.html(),
)
}
}
@@ -86,7 +91,7 @@ abstract class NineMangaRepository(
val infoRoot = root.selectFirst("div.bookintro")
?: throw ParseException("Cannot find info")
return manga.copy(
tags = infoRoot.getElementsByAttributeValue("itemprop", "genre")?.first()
tags = infoRoot.getElementsByAttributeValue("itemprop", "genre").first()
?.select("a")?.mapToSet { a ->
MangaTag(
title = a.text(),
@@ -94,19 +99,20 @@ abstract class NineMangaRepository(
source = source,
)
}.orEmpty(),
author = infoRoot.getElementsByAttributeValue("itemprop", "author")?.first()?.text(),
description = infoRoot.getElementsByAttributeValue("itemprop", "description")?.first()
author = infoRoot.getElementsByAttributeValue("itemprop", "author").first()?.text(),
state = parseStatus(infoRoot.select("li a.red").text()),
description = infoRoot.getElementsByAttributeValue("itemprop", "description").first()
?.html()?.substringAfter("</b>"),
chapters = root.selectFirst("div.chapterbox")?.selectFirst("ul")
?.select("li")?.asReversed()?.mapIndexed { i, li ->
val a = li.selectFirst("a")
val href = a.relUrl("href")
chapters = root.selectFirst("div.chapterbox")?.select("ul.sub_vol_ul > li")
?.asReversed()?.mapIndexed { i, li ->
val a = li.selectFirst("a.chapter_list_a")
val href = a?.relUrl("href")?.replace("%20", " ") ?: parseFailed("Link not found")
MangaChapter(
id = generateUid(href),
name = a.text(),
number = i + 1,
url = href,
branch = null,
uploadDate = parseChapterDateByLang(li.selectFirst("span")?.text().orEmpty()),
source = source,
)
}
@@ -135,17 +141,62 @@ abstract class NineMangaRepository(
}
override suspend fun getTags(): Set<MangaTag> {
val doc = loaderContext.httpGet("https://${getDomain()}/category/", PREDEFINED_HEADERS)
val doc = loaderContext.httpGet("https://${getDomain()}/search/?type=high", PREDEFINED_HEADERS)
.parseHtml()
val root = doc.body().selectFirst("ul.genreidex")
return root.select("li").mapToSet { li ->
val a = li.selectFirst("a")
val root = doc.body().getElementById("search_form")
return root?.select("li.cate_list")?.mapNotNullToSet { li ->
val cateId = li.attr("cate_id") ?: return@mapNotNullToSet null
val a = li.selectFirst("a") ?: return@mapNotNullToSet null
MangaTag(
title = a.text(),
key = a.attr("href").substringBetweenLast("/", "."),
title = a.text().toTitleCase(),
key = cateId,
source = source
)
} ?: parseFailed("Root not found")
}
private fun parseStatus(status: String) = when {
status.contains("Ongoing") -> MangaState.ONGOING
status.contains("Completed") -> MangaState.FINISHED
else -> null
}
private fun parseChapterDateByLang(date: String): Long {
val dateWords = date.split(" ")
if (dateWords.size == 3) {
if (dateWords[1].contains(",")) {
SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).tryParse(date)
} else {
val timeAgo = Integer.parseInt(dateWords[0])
return Calendar.getInstance().apply {
when (dateWords[1]) {
"minutes" -> Calendar.MINUTE // EN-FR
"hours" -> Calendar.HOUR // EN
"minutos" -> Calendar.MINUTE // ES
"horas" -> Calendar.HOUR
// "minutos" -> Calendar.MINUTE // BR
"hora" -> Calendar.HOUR
"минут" -> Calendar.MINUTE // RU
"часа" -> Calendar.HOUR
"Stunden" -> Calendar.HOUR // DE
"minuti" -> Calendar.MINUTE // IT
"ore" -> Calendar.HOUR
"heures" -> Calendar.HOUR // FR ("minutes" also French word)
else -> null
}?.let {
add(it, -timeAgo)
}
}.timeInMillis
}
}
return 0L
}
class English(loaderContext: MangaLoaderContext) : NineMangaRepository(

View File

@@ -5,6 +5,6 @@ import org.koitharu.kotatsu.core.model.MangaSource
class ReadmangaRepository(loaderContext: MangaLoaderContext) : GroupleRepository(loaderContext) {
override val defaultDomain = "readmanga.live"
override val defaultDomain = "readmanga.io"
override val source = MangaSource.READMANGA_RU
}

View File

@@ -8,8 +8,8 @@ import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
import java.text.SimpleDateFormat
import java.util.*
import kotlin.collections.ArrayList
class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
@@ -24,11 +24,11 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
SortOrder.NEWEST
)
override suspend fun getList(
override suspend fun getList2(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?
tags: Set<MangaTag>?,
sortOrder: SortOrder?
): List<Manga> {
val domain = getDomain()
val urlBuilder = StringBuilder()
@@ -40,8 +40,9 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
} else {
urlBuilder.append("/api/search/catalog/?ordering=")
.append(getSortKey(sortOrder))
if (tag != null) {
urlBuilder.append("&genres=" + tag.key)
tags?.forEach { tag ->
urlBuilder.append("&genres=")
urlBuilder.append(tag.key)
}
}
urlBuilder
@@ -93,6 +94,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
val chapters = loaderContext.httpGet(
url = "https://api.$domain/api/titles/chapters/?branch_id=$branchId"
).parseJson().getJSONArray("content")
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
return manga.copy(
description = content.getString("description"),
state = when (content.optJSONObject("status")?.getInt("id")) {
@@ -109,12 +111,16 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
},
chapters = chapters.mapIndexed { i, jo ->
val id = jo.getLong("id")
val name = jo.getString("name")
val name = jo.getString("name").toTitleCase(Locale.ROOT)
val publishers = jo.getJSONArray("publishers")
MangaChapter(
id = generateUid(id),
url = "/api/titles/chapters/$id/",
number = chapters.length() - i,
name = buildString {
append("Том ")
append(jo.getString("tome"))
append(". ")
append("Глава ")
append(jo.getString("chapter"))
if (name.isNotEmpty()) {
@@ -122,6 +128,8 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
append(name)
}
},
uploadDate = dateFormat.tryParse(jo.getString("upload_date")),
scanlator = publishers.optJSONObject(0)?.getStringOrNull("name"),
source = MangaSource.REMANGA
)
}.asReversed()

View File

@@ -29,6 +29,7 @@ class YaoiChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loa
name = a.text().trim(),
number = i + 1,
url = href,
uploadDate = 0L,
source = source
)
}

View File

@@ -14,6 +14,9 @@ import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.utils.delegates.prefs.*
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
class AppSettings private constructor(private val prefs: SharedPreferences) :
SharedPreferences by prefs {
@@ -121,6 +124,12 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
}
}
fun dateFormat(format: String? = prefs.getString(KEY_DATE_FORMAT, "")): DateFormat =
when (format) {
"" -> DateFormat.getDateInstance(DateFormat.SHORT)
else -> SimpleDateFormat(format, Locale.getDefault())
}
@Deprecated("Use observe()")
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener)
@@ -152,6 +161,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
const val KEY_APP_SECTION = "app_section"
const val KEY_THEME = "theme"
const val KEY_THEME_AMOLED = "amoled_theme"
const val KEY_DATE_FORMAT = "date_format"
const val KEY_HIDE_TOOLBAR = "hide_toolbar"
const val KEY_SOURCES_ORDER = "sources_order"
const val KEY_SOURCES_HIDDEN = "sources_hidden"
@@ -166,8 +176,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
const val KEY_LOCAL_STORAGE = "local_storage"
const val KEY_READER_SWITCHERS = "reader_switchers"
const val KEY_TRACK_SOURCES = "track_sources"
const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_UPDATE_AUTO = "app_update_auto"
const val KEY_TRACK_WARNING = "track_warning"
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
@@ -183,5 +192,14 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
const val KEY_RESTORE = "restore"
const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
// About
const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_UPDATE_AUTO = "app_update_auto"
const val KEY_APP_TRANSLATION = "about_app_translation"
const val KEY_APP_GRATITUDES = "about_gratitudes"
const val KEY_FEEDBACK_4PDA = "about_feedback_4pda"
const val KEY_FEEDBACK_GITHUB = "about_feedback_github"
const val KEY_SUPPORT_DEVELOPER = "about_support_developer"
}
}

View File

@@ -27,5 +27,6 @@ interface SourceSettings {
const val KEY_DOMAIN = "domain"
const val KEY_USE_SSL = "ssl"
const val KEY_AUTH = "auth"
}
}

View File

@@ -15,19 +15,20 @@ import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
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.download.DownloadService
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
OnListItemClickListener<MangaChapter>, ActionMode.Callback, AdapterView.OnItemSelectedListener {
OnListItemClickListener<ChapterListItem>,
ActionMode.Callback,
AdapterView.OnItemSelectedListener {
private val viewModel by sharedViewModel<DetailsViewModel>()
@@ -105,9 +106,9 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
else -> super.onOptionsItemSelected(item)
}
override fun onItemClick(item: MangaChapter, view: View) {
override fun onItemClick(item: ChapterListItem, view: View) {
if (selectionDecoration?.checkedItemsCount != 0) {
selectionDecoration?.toggleItemChecked(item.id)
selectionDecoration?.toggleItemChecked(item.chapter.id)
if (selectionDecoration?.checkedItemsCount == 0) {
actionMode?.finish()
} else {
@@ -116,6 +117,10 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
}
return
}
if (item.isMissing) {
(activity as? DetailsActivity)?.showChapterMissingDialog(item.chapter.id)
return
}
val options = ActivityOptions.makeScaleUpAnimation(
view,
0,
@@ -127,17 +132,17 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
ReaderActivity.newIntent(
view.context,
viewModel.manga.value ?: return,
ReaderState(item.id, 0, 0)
ReaderState(item.chapter.id, 0, 0)
), options.toBundle()
)
}
override fun onItemLongClick(item: MangaChapter, view: View): Boolean {
override fun onItemLongClick(item: ChapterListItem, view: View): Boolean {
if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
}
return actionMode?.also {
selectionDecoration?.setItemIsChecked(item.id, true)
selectionDecoration?.setItemIsChecked(item.chapter.id, true)
binding.recyclerViewChapters.invalidateItemDecorations()
it.invalidate()
} != null
@@ -148,7 +153,7 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
R.id.action_save -> {
DownloadService.start(
context ?: return false,
viewModel.manga.value ?: return false,
viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
selectionDecoration?.checkedItemsIds
)
mode.finish()
@@ -174,17 +179,20 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
val manga = viewModel.manga.value
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
menu.findItem(R.id.action_save).isVisible = manga?.source != MangaSource.LOCAL
mode.title = manga?.title
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val count = selectionDecoration?.checkedItemsCount ?: return false
val selectedIds = selectionDecoration?.checkedItemsIds ?: return false
val items = chaptersAdapter?.items?.filter { x -> x.chapter.id in selectedIds }.orEmpty()
menu.findItem(R.id.action_save).isVisible = items.none { x ->
x.chapter.source == MangaSource.LOCAL
}
mode.subtitle = resources.getQuantityString(
R.plurals.chapters_from_x,
count,
count,
items.size,
items.size,
chaptersAdapter?.itemCount ?: 0
)
return true

View File

@@ -33,9 +33,12 @@ import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.download.DownloadService
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.buildAlertDialog
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
@@ -82,13 +85,15 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
finishAfterTransition()
}
else -> {
Snackbar.make(binding.pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG)
.show()
binding.snackbar.show(e.getDisplayMessage(resources))
}
}
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.snackbar.updatePadding(
bottom = insets.bottom
)
binding.toolbar.updatePadding(
top = insets.top,
left = insets.left,
@@ -228,6 +233,33 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
binding.pager.isUserInputEnabled = true
}
fun showChapterMissingDialog(chapterId: Long) {
val remoteManga = viewModel.getRemoteManga()
if (remoteManga == null) {
Snackbar.make(binding.pager, R.string.chapter_is_missing, Snackbar.LENGTH_LONG)
.show()
return
}
buildAlertDialog(this) {
setMessage(R.string.chapter_is_missing_text)
setTitle(R.string.chapter_is_missing)
setNegativeButton(android.R.string.cancel, null)
setPositiveButton(R.string.read) { _, _ ->
startActivity(
ReaderActivity.newIntent(
this@DetailsActivity,
remoteManga,
ReaderState(chapterId, 0, 0)
)
)
}
setNeutralButton(R.string.download) { _, _ ->
DownloadService.start(this@DetailsActivity, remoteManga, setOf(chapterId))
}
setCancelable(true)
}.show()
}
companion object {
const val ACTION_MANGA_VIEW = "${BuildConfig.APPLICATION_ID}.action.VIEW_MANGA"

View File

@@ -5,6 +5,7 @@ import android.text.Spanned
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.Insets
import androidx.core.net.toUri
import androidx.core.text.parseAsHtml
@@ -13,7 +14,6 @@ import androidx.core.view.updatePadding
import coil.ImageLoader
import coil.util.CoilUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject
@@ -23,20 +23,21 @@ import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaState
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.utils.FileSizeUtils
import org.koitharu.kotatsu.utils.ext.*
import kotlin.math.roundToInt
class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickListener,
View.OnLongClickListener {
private val viewModel by sharedViewModel<DetailsViewModel>()
private val coil by inject<ImageLoader>(mode = LazyThreadSafetyMode.NONE)
private var tagsJob: Job? = null
override fun onInflateView(
inflater: LayoutInflater,
@@ -45,6 +46,10 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.textViewAuthor.setOnClickListener(this)
binding.buttonFavorite.setOnClickListener(this)
binding.buttonRead.setOnClickListener(this)
binding.buttonRead.setOnLongClickListener(this)
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
@@ -53,6 +58,8 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
private fun onMangaUpdated(manga: Manga) {
with(binding) {
// Main
imageViewCover.newImageRequest(manga.largeCoverUrl ?: manga.coverUrl)
.referer(manga.publicUrl)
.fallback(R.drawable.ic_placeholder)
@@ -61,19 +68,64 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
.enqueueWith(coil)
textViewTitle.text = manga.title
textViewSubtitle.textAndVisible = manga.altTitle
textViewAuthor.textAndVisible = manga.author
sourceContainer.isVisible = manga.source != MangaSource.LOCAL
textViewSource.text = manga.source.title
textViewDescription.text =
manga.description?.parseAsHtml()?.takeUnless(Spanned::isBlank)
?: getString(R.string.no_description)
if (manga.rating == Manga.NO_RATING) {
ratingBar.isVisible = false
} else {
ratingBar.progress = (ratingBar.max * manga.rating).roundToInt()
ratingBar.isVisible = true
when (manga.state) {
MangaState.FINISHED -> {
textViewState.apply {
textAndVisible = resources.getString(R.string.state_finished)
drawableStart = ResourcesCompat.getDrawable(resources, R.drawable.ic_state_finished, context.theme)
}
}
MangaState.ONGOING -> {
textViewState.apply {
textAndVisible = resources.getString(R.string.state_ongoing)
drawableStart = ResourcesCompat.getDrawable(resources, R.drawable.ic_state_ongoing, context.theme)
}
}
else -> textViewState.isVisible = false
}
imageViewFavourite.setOnClickListener(this@DetailsFragment)
buttonRead.setOnClickListener(this@DetailsFragment)
buttonRead.setOnLongClickListener(this@DetailsFragment)
// Info containers
if (manga.chapters?.isNotEmpty() == true) {
chaptersContainer.isVisible = true
textViewChapters.text = manga.chapters.let {
resources.getQuantityString(
R.plurals.chapters,
it.size,
manga.chapters.size
)
}
} else {
chaptersContainer.isVisible = false
}
if (manga.rating == Manga.NO_RATING) {
ratingContainer.isVisible = false
} else {
textViewRating.text = String.format("%.1f", manga.rating * 5)
ratingContainer.isVisible = true
}
val file = manga.url.toUri().toFileOrNull()
if (file != null) {
viewLifecycleScope.launch {
val size = withContext(Dispatchers.IO) {
file.length()
}
textViewSize.text = FileSizeUtils.formatBytes(requireContext(), size)
}
sizeContainer.isVisible = true
} else {
sizeContainer.isVisible = false
}
// Buttons
buttonRead.isEnabled = !manga.chapters.isNullOrEmpty()
// Chips
bindTags(manga)
}
}
@@ -91,31 +143,49 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
}
private fun onFavouriteChanged(isFavourite: Boolean) {
binding.imageViewFavourite.setImageResource(
with(binding.buttonFavorite) {
if (isFavourite) {
R.drawable.ic_heart
this.setIconResource(R.drawable.ic_heart)
} else {
R.drawable.ic_heart_outline
this.setIconResource(R.drawable.ic_heart_outline)
}
)
}
}
private fun onLoadingStateChanged(isLoading: Boolean) {
binding.progressBar.isVisible = isLoading
if (isLoading) {
binding.progressBar.show()
} else {
binding.progressBar.hide()
}
}
override fun onClick(v: View) {
val manga = viewModel.manga.value
val manga = viewModel.manga.value ?: return
when (v.id) {
R.id.imageView_favourite -> {
FavouriteCategoriesDialog.show(childFragmentManager, manga ?: return)
R.id.button_favorite -> {
FavouriteCategoriesDialog.show(childFragmentManager, manga)
}
R.id.button_read -> {
val chapterId = viewModel.readingHistory.value?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
(activity as? DetailsActivity)?.showChapterMissingDialog(chapterId)
} else {
startActivity(
ReaderActivity.newIntent(
context ?: return,
manga,
null
)
)
}
}
R.id.textView_author -> {
startActivity(
ReaderActivity.newIntent(
context ?: return,
manga ?: return,
null
SearchActivity.newIntent(
context = v.context,
source = manga.source,
query = manga.author ?: return,
)
)
}
@@ -160,37 +230,13 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
}
private fun bindTags(manga: Manga) {
tagsJob?.cancel()
tagsJob = viewLifecycleScope.launch {
val tags = ArrayList<ChipsView.ChipModel>(manga.tags.size + 2)
if (manga.author != null) {
tags += ChipsView.ChipModel(
title = manga.author,
icon = R.drawable.ic_chip_user
)
}
for (tag in manga.tags) {
tags += ChipsView.ChipModel(
binding.chipsTags.setChips(
manga.tags.map { tag ->
ChipsView.ChipModel(
title = tag.title,
icon = R.drawable.ic_chip_tag
icon = 0
)
}
val file = manga.url.toUri().toFileOrNull()
if (file != null) {
val size = withContext(Dispatchers.IO) {
file.length()
}
tags += ChipsView.ChipModel(
title = FileSizeUtils.formatBytes(requireContext(), size),
icon = R.drawable.ic_chip_storage
)
} else {
tags += ChipsView.ChipModel(
title = manga.source.title,
icon = R.drawable.ic_chip_web
)
}
binding.chipsTags.setChips(tags)
}
)
}
}

View File

@@ -11,7 +11,11 @@ import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.ChapterExtra
@@ -29,7 +33,7 @@ class DetailsViewModel(
private val localMangaRepository: LocalMangaRepository,
private val trackingRepository: TrackingRepository,
private val mangaDataRepository: MangaDataRepository,
private val settings: AppSettings
private val settings: AppSettings,
) : BaseViewModel() {
private val mangaData = MutableStateFlow<Manga?>(intent.manga)
@@ -53,6 +57,18 @@ class DetailsViewModel(
trackingRepository.getNewChaptersCount(mangaId)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
private val remoteManga = MutableStateFlow<Manga?>(null)
/*private val remoteManga = mangaData.mapLatest {
if (it?.source == MangaSource.LOCAL) {
runCatching {
val m = localMangaRepository.getRemoteManga(it) ?: return@mapLatest null
MangaRepository(m.source).getDetails(m)
}.getOrNull()
} else {
null
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)*/
private val chaptersReversed = settings.observe()
.filter { it == AppSettings.KEY_REVERSE_CHAPTERS }
.map { settings.chaptersReverse }
@@ -85,24 +101,19 @@ class DetailsViewModel(
val chapters = combine(
mangaData.map { it?.chapters.orEmpty() },
remoteManga,
history.map { it?.chapterId },
newChapters,
chaptersReversed,
selectedBranch
) { chapters, currentId, newCount, reversed, branch ->
val currentIndex = chapters.indexOfFirst { it.id == currentId }
val firstNewIndex = chapters.size - newCount
val res = chapters.mapIndexed { index, chapter ->
chapter.toListItem(
when {
index >= firstNewIndex -> ChapterExtra.NEW
index == currentIndex -> ChapterExtra.CURRENT
index < currentIndex -> ChapterExtra.READ
else -> ChapterExtra.UNREAD
}
)
}.filter { it.chapter.branch == branch }
if (reversed) res.asReversed() else res
) { chapters, sourceManga, currentId, newCount, branch ->
val sourceChapters = sourceManga?.chapters
if (sourceChapters.isNullOrEmpty()) {
mapChapters(chapters, currentId, newCount, branch)
} else {
mapChaptersWithSource(chapters, sourceChapters, currentId, newCount, branch)
}
}.combine(chaptersReversed) { list, reversed ->
if (reversed) list.asReversed() else list
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
init {
@@ -121,6 +132,12 @@ class DetailsViewModel(
?.maxByOrNull { it.value.size }?.key
}
mangaData.value = manga
if (manga.source == MangaSource.LOCAL) {
remoteManga.value = runCatching {
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null
MangaRepository(m.source).getDetails(m)
}.getOrNull()
}
}
}
@@ -142,4 +159,85 @@ class DetailsViewModel(
fun setSelectedBranch(branch: String?) {
selectedBranch.value = branch
}
fun getRemoteManga(): Manga? {
return remoteManga.value
}
private fun mapChapters(
chapters: List<MangaChapter>,
currentId: Long?,
newCount: Int,
branch: String?,
): List<ChapterListItem> {
val result = ArrayList<ChapterListItem>(chapters.size)
val dateFormat = settings.dateFormat()
val currentIndex = chapters.indexOfFirst { it.id == currentId }
val firstNewIndex = chapters.size - newCount
for (i in chapters.indices) {
val chapter = chapters[i]
if (chapter.branch != branch) {
continue
}
result += chapter.toListItem(
extra = when {
i >= firstNewIndex -> ChapterExtra.NEW
i == currentIndex -> ChapterExtra.CURRENT
i < currentIndex -> ChapterExtra.READ
else -> ChapterExtra.UNREAD
},
isMissing = false,
dateFormat = dateFormat,
)
}
return result
}
private fun mapChaptersWithSource(
chapters: List<MangaChapter>,
sourceChapters: List<MangaChapter>,
currentId: Long?,
newCount: Int,
branch: String?,
): List<ChapterListItem> {
val chaptersMap = chapters.associateByTo(HashMap(chapters.size)) { it.id }
val result = ArrayList<ChapterListItem>(sourceChapters.size)
val currentIndex = sourceChapters.indexOfFirst { it.id == currentId }
val firstNewIndex = sourceChapters.size - newCount
val dateFormat = settings.dateFormat()
for (i in sourceChapters.indices) {
val chapter = sourceChapters[i]
if (chapter.branch != branch) {
continue
}
val localChapter = chaptersMap.remove(chapter.id)
result += localChapter?.toListItem(
extra = when {
i >= firstNewIndex -> ChapterExtra.NEW
i == currentIndex -> ChapterExtra.CURRENT
i < currentIndex -> ChapterExtra.READ
else -> ChapterExtra.UNREAD
},
isMissing = false,
dateFormat = dateFormat,
) ?: chapter.toListItem(
extra = when {
i >= firstNewIndex -> ChapterExtra.NEW
i == currentIndex -> ChapterExtra.CURRENT
i < currentIndex -> ChapterExtra.READ
else -> ChapterExtra.UNREAD
},
isMissing = true,
dateFormat = dateFormat,
)
}
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
result.ensureCapacity(result.size + chaptersMap.size)
chaptersMap.values.mapTo(result) {
it.toListItem(ChapterExtra.UNREAD, false, dateFormat)
}
result.sortBy { it.chapter.number }
}
return result
}
}

View File

@@ -3,28 +3,29 @@ package org.koitharu.kotatsu.details.ui.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.databinding.ItemChapterBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.history.domain.ChapterExtra
import org.koitharu.kotatsu.utils.ext.getThemeColor
import org.koitharu.kotatsu.utils.ext.textAndVisible
fun chapterListItemAD(
clickListener: OnListItemClickListener<MangaChapter>
clickListener: OnListItemClickListener<ChapterListItem>,
) = adapterDelegateViewBinding<ChapterListItem, ChapterListItem, ItemChapterBinding>(
{ inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) }
) {
itemView.setOnClickListener {
clickListener.onItemClick(item.chapter, it)
clickListener.onItemClick(item, it)
}
itemView.setOnLongClickListener {
clickListener.onItemLongClick(item.chapter, it)
clickListener.onItemLongClick(item, it)
}
bind { payload ->
bind {
binding.textViewTitle.text = item.chapter.name
binding.textViewNumber.text = item.chapter.number.toString()
binding.textViewDescription.textAndVisible = item.description()
when (item.extra) {
ChapterExtra.UNREAD -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_default)
@@ -43,5 +44,8 @@ fun chapterListItemAD(
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse))
}
}
binding.textViewTitle.alpha = if (item.isMissing) 0.3f else 1f
binding.textViewDescription.alpha = if (item.isMissing) 0.3f else 1f
binding.textViewNumber.alpha = if (item.isMissing) 0.3f else 1f
}
}

View File

@@ -3,12 +3,11 @@ package org.koitharu.kotatsu.details.ui.adapter
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import kotlin.jvm.internal.Intrinsics
class ChaptersAdapter(
onItemClickListener: OnListItemClickListener<MangaChapter>
onItemClickListener: OnListItemClickListener<ChapterListItem>,
) : AsyncListDifferDelegationAdapter<ChapterListItem>(DiffCallback()) {
init {
@@ -38,7 +37,7 @@ class ChaptersAdapter(
}
override fun getChangePayload(oldItem: ChapterListItem, newItem: ChapterListItem): Any? {
if (oldItem.extra != newItem.extra) {
if (oldItem.extra != newItem.extra && oldItem.chapter == newItem.chapter) {
return newItem.extra
}
return null

View File

@@ -5,5 +5,17 @@ import org.koitharu.kotatsu.history.domain.ChapterExtra
data class ChapterListItem(
val chapter: MangaChapter,
val extra: ChapterExtra
)
val extra: ChapterExtra,
val isMissing: Boolean,
val uploadDate: String?,
) {
fun description(): CharSequence? {
val scanlator = chapter.scanlator?.takeUnless { it.isBlank() }
return when {
uploadDate != null && scanlator != null -> "$uploadDate$scanlator"
scanlator != null -> scanlator
else -> uploadDate
}
}
}

View File

@@ -2,8 +2,15 @@ package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.history.domain.ChapterExtra
import java.text.DateFormat
fun MangaChapter.toListItem(extra: ChapterExtra) = ChapterListItem(
fun MangaChapter.toListItem(
extra: ChapterExtra,
isMissing: Boolean,
dateFormat: DateFormat,
) = ChapterListItem(
chapter = this,
extra = extra
extra = extra,
isMissing = isMissing,
uploadDate = if (uploadDate != 0L) dateFormat.format(uploadDate) else null
)

View File

@@ -1,153 +0,0 @@
package org.koitharu.kotatsu.download
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.graphics.drawable.Drawable
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import kotlin.math.roundToInt
class DownloadNotification(private val context: Context) {
private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
private val manager =
context.applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
&& manager.getNotificationChannel(CHANNEL_ID) == null
) {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.downloads),
NotificationManager.IMPORTANCE_LOW
)
channel.enableVibration(false)
channel.enableLights(false)
channel.setSound(null, null)
manager.createNotificationChannel(channel)
}
builder.setOnlyAlertOnce(true)
builder.setDefaults(0)
builder.color = ContextCompat.getColor(context, R.color.blue_primary)
}
fun fillFrom(manga: Manga) {
builder.setContentTitle(manga.title)
builder.setContentText(context.getString(R.string.manga_downloading_))
builder.setProgress(1, 0, true)
builder.setSmallIcon(android.R.drawable.stat_sys_download)
builder.setLargeIcon(null)
builder.setContentIntent(null)
builder.setStyle(null)
}
fun setCancelId(startId: Int) {
if (startId == 0) {
builder.clearActions()
} else {
val intent = DownloadService.getCancelIntent(context, startId)
builder.addAction(
R.drawable.ic_cross,
context.getString(android.R.string.cancel),
PendingIntent.getService(
context,
startId,
intent,
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
)
}
}
fun setError(e: Throwable) {
val message = e.getDisplayMessage(context.resources)
builder.setProgress(0, 0, false)
builder.setSmallIcon(android.R.drawable.stat_notify_error)
builder.setSubText(context.getString(R.string.error))
builder.setContentText(message)
builder.setAutoCancel(true)
builder.setContentIntent(null)
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
}
fun setLargeIcon(icon: Drawable?) {
builder.setLargeIcon(icon?.toBitmap())
}
fun setProgress(chaptersTotal: Int, pagesTotal: Int, chapter: Int, page: Int) {
val max = chaptersTotal * PROGRESS_STEP
val progress =
chapter * PROGRESS_STEP + (page / pagesTotal.toFloat() * PROGRESS_STEP).roundToInt()
val percent = (progress / max.toFloat() * 100).roundToInt()
builder.setProgress(max, progress, false)
builder.setContentText("%d%%".format(percent))
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
}
fun setWaitingForNetwork() {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.waiting_for_network))
builder.setStyle(null)
}
fun setPostProcessing() {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.processing_))
builder.setStyle(null)
}
fun setDone(manga: Manga) {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.download_complete))
builder.setContentIntent(createIntent(context, manga))
builder.setAutoCancel(true)
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
builder.setCategory(null)
builder.setStyle(null)
}
fun setCancelling() {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.cancelling_))
builder.setContentIntent(null)
builder.setStyle(null)
}
fun update(id: Int = NOTIFICATION_ID) {
manager.notify(id, builder.build())
}
fun dismiss(id: Int = NOTIFICATION_ID) {
manager.cancel(id)
}
operator fun invoke(): Notification = builder.build()
companion object {
const val NOTIFICATION_ID = 201
const val CHANNEL_ID = "download"
private const val PROGRESS_STEP = 20
private fun createIntent(context: Context, manga: Manga) = PendingIntent.getActivity(
context,
manga.hashCode(),
DetailsActivity.newIntent(context, manga),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
}
}

View File

@@ -1,274 +0,0 @@
package org.koitharu.kotatsu.download
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.os.PowerManager
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import coil.ImageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.IOException
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
import org.koin.core.context.GlobalContext
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseService
import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.MangaZip
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.utils.CacheUtils
import org.koitharu.kotatsu.utils.ext.*
import java.io.File
import java.util.concurrent.TimeUnit
import kotlin.collections.set
import kotlin.math.absoluteValue
class DownloadService : BaseService() {
private lateinit var notification: DownloadNotification
private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var connectivityManager: ConnectivityManager
private val okHttp by inject<OkHttpClient>()
private val cache by inject<PagesCache>()
private val settings by inject<AppSettings>()
private val imageLoader by inject<ImageLoader>()
private val jobs = HashMap<Int, Job>()
private val mutex = Mutex()
override fun onCreate() {
super.onCreate()
notification = DownloadNotification(this)
connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
when (intent?.action) {
ACTION_DOWNLOAD_START -> {
val manga = intent.getParcelableExtra<Manga>(EXTRA_MANGA)
val chapters = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet()
if (manga != null) {
jobs[startId] = downloadManga(manga, chapters, startId)
Toast.makeText(this, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
} else {
stopSelf(startId)
}
}
ACTION_DOWNLOAD_CANCEL -> {
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
jobs.remove(cancelId)?.cancel()
stopSelf(startId)
}
else -> stopSelf(startId)
}
return START_NOT_STICKY
}
private fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int): Job {
return lifecycleScope.launch(Dispatchers.Default) {
mutex.lock()
wakeLock.acquire(TimeUnit.HOURS.toMillis(1))
notification.fillFrom(manga)
notification.setCancelId(startId)
withContext(Dispatchers.Main) {
startForeground(DownloadNotification.NOTIFICATION_ID, notification())
}
val destination = settings.getStorageDir(this@DownloadService)
checkNotNull(destination) { getString(R.string.cannot_find_available_storage) }
var output: MangaZip? = null
try {
val repo = mangaRepositoryOf(manga.source)
val cover = runCatching {
imageLoader.execute(
ImageRequest.Builder(this@DownloadService)
.data(manga.coverUrl)
.build()
).drawable
}.getOrNull()
notification.setLargeIcon(cover)
notification.update()
val data = if (manga.chapters == null) repo.getDetails(manga) else manga
output = MangaZip.findInDir(destination, data)
output.prepare(data)
val coverUrl = data.largeCoverUrl ?: data.coverUrl
downloadFile(coverUrl, data.publicUrl, destination).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
}
val chapters = if (chaptersIds == null) {
data.chapters.orEmpty()
} else {
data.chapters.orEmpty().filter { x -> x.id in chaptersIds }
}
for ((chapterIndex, chapter) in chapters.withIndex()) {
if (chaptersIds == null || chapter.id in chaptersIds) {
val pages = repo.getPages(chapter)
for ((pageIndex, page) in pages.withIndex()) {
failsafe@ do {
try {
val url = repo.getPageUrl(page)
val file =
cache[url] ?: downloadFile(url, page.referer, destination)
output.addPage(
chapter,
file,
pageIndex,
MimeTypeMap.getFileExtensionFromUrl(url)
)
} catch (e: IOException) {
notification.setWaitingForNetwork()
notification.update()
connectivityManager.waitForNetwork()
continue@failsafe
}
} while (false)
notification.setProgress(
chapters.size,
pages.size,
chapterIndex,
pageIndex
)
notification.update()
}
}
}
notification.setCancelId(0)
notification.setPostProcessing()
notification.update()
if (!output.compress()) {
throw RuntimeException("Cannot create target file")
}
val result = get<LocalMangaRepository>().getFromFile(output.file)
notification.setDone(result)
notification.dismiss()
notification.update(manga.id.toInt().absoluteValue)
} catch (_: CancellationException) {
withContext(NonCancellable) {
notification.setCancelling()
notification.setCancelId(0)
notification.update()
}
} catch (e: Throwable) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
notification.setError(e)
notification.setCancelId(0)
notification.dismiss()
notification.update(manga.id.toInt().absoluteValue)
} finally {
withContext(NonCancellable) {
jobs.remove(startId)
output?.cleanup()
destination.sub(TEMP_PAGE_FILE).deleteAwait()
withContext(Dispatchers.Main) {
stopForeground(true)
notification.dismiss()
stopSelf(startId)
}
if (wakeLock.isHeld) {
wakeLock.release()
}
mutex.unlock()
}
}
}
}
private suspend fun downloadFile(url: String, referer: String, destination: File): File {
val request = Request.Builder()
.url(url)
.header(CommonHeaders.REFERER, referer)
.cacheControl(CacheUtils.CONTROL_DISABLED)
.get()
.build()
val call = okHttp.newCall(request)
var attempts = MAX_DOWNLOAD_ATTEMPTS
val file = destination.sub(TEMP_PAGE_FILE)
while (true) {
try {
val response = call.clone().await()
withContext(Dispatchers.IO) {
file.outputStream().use { out ->
checkNotNull(response.body).byteStream().copyTo(out)
}
}
return file
} catch (e: IOException) {
attempts--
if (attempts <= 0) {
throw e
} else {
delay(DOWNLOAD_ERROR_DELAY)
}
}
}
}
companion object {
private const val ACTION_DOWNLOAD_START =
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_START"
private const val ACTION_DOWNLOAD_CANCEL =
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
private const val EXTRA_MANGA = "manga"
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
private const val EXTRA_CANCEL_ID = "cancel_id"
private const val MAX_DOWNLOAD_ATTEMPTS = 3
private const val DOWNLOAD_ERROR_DELAY = 500L
private const val TEMP_PAGE_FILE = "page.tmp"
fun start(context: Context, manga: Manga, chaptersIds: Collection<Long>? = null) {
confirmDataTransfer(context) {
val intent = Intent(context, DownloadService::class.java)
intent.action = ACTION_DOWNLOAD_START
intent.putExtra(EXTRA_MANGA, manga)
if (chaptersIds != null) {
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
}
ContextCompat.startForegroundService(context, intent)
}
}
fun getCancelIntent(context: Context, startId: Int) =
Intent(context, DownloadService::class.java)
.setAction(ACTION_DOWNLOAD_CANCEL)
.putExtra(ACTION_DOWNLOAD_CANCEL, startId)
private fun confirmDataTransfer(context: Context, callback: () -> Unit) {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val settings = GlobalContext.get().get<AppSettings>()
if (cm.isActiveNetworkMetered && settings.isTrafficWarningEnabled) {
CheckBoxAlertDialog.Builder(context)
.setTitle(R.string.warning)
.setMessage(R.string.network_consumption_warning)
.setCheckBoxText(R.string.dont_ask_again)
.setCheckBoxChecked(false)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string._continue) { _, doNotAsk ->
settings.isTrafficWarningEnabled = !doNotAsk
callback()
}.create()
.show()
} else {
callback()
}
}
}
}

View File

@@ -0,0 +1,239 @@
package org.koitharu.kotatsu.download.domain
import android.content.Context
import android.graphics.drawable.Drawable
import android.net.ConnectivityManager
import android.webkit.MimeTypeMap
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.IOException
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.MangaZip
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.utils.CacheUtils
import org.koitharu.kotatsu.utils.ext.await
import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.waitForNetwork
import java.io.File
class DownloadManager(
private val context: Context,
private val settings: AppSettings,
private val imageLoader: ImageLoader,
private val okHttp: OkHttpClient,
private val cache: PagesCache,
private val localMangaRepository: LocalMangaRepository,
) {
private val connectivityManager = context.getSystemService(
Context.CONNECTIVITY_SERVICE
) as ConnectivityManager
private val coverWidth = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_width
)
private val coverHeight = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_height
)
fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int) = flow<State> {
emit(State.Preparing(startId, manga, null))
var cover: Drawable? = null
val destination = settings.getStorageDir(context)
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
var output: MangaZip? = null
try {
val repo = MangaRepository(manga.source)
cover = runCatching {
imageLoader.execute(
ImageRequest.Builder(context)
.data(manga.coverUrl)
.size(coverWidth, coverHeight)
.scale(Scale.FILL)
.build()
).drawable
}.getOrNull()
emit(State.Preparing(startId, manga, cover))
val data = if (manga.chapters == null) repo.getDetails(manga) else manga
output = MangaZip.findInDir(destination, data)
output.prepare(data)
val coverUrl = data.largeCoverUrl ?: data.coverUrl
downloadFile(coverUrl, data.publicUrl, destination).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
}
val chapters = if (chaptersIds == null) {
data.chapters.orEmpty()
} else {
data.chapters.orEmpty().filter { x -> x.id in chaptersIds }
}
for ((chapterIndex, chapter) in chapters.withIndex()) {
if (chaptersIds == null || chapter.id in chaptersIds) {
val pages = repo.getPages(chapter)
for ((pageIndex, page) in pages.withIndex()) {
failsafe@ do {
try {
val url = repo.getPageUrl(page)
val file =
cache[url] ?: downloadFile(url, page.referer, destination)
output.addPage(
chapter,
file,
pageIndex,
MimeTypeMap.getFileExtensionFromUrl(url)
)
} catch (e: IOException) {
emit(State.WaitingForNetwork(startId, manga, cover))
connectivityManager.waitForNetwork()
continue@failsafe
}
} while (false)
emit(State.Progress(
startId, manga, cover,
totalChapters = chapters.size,
currentChapter = chapterIndex,
totalPages = pages.size,
currentPage = pageIndex,
))
}
}
}
emit(State.PostProcessing(startId, manga, cover))
if (!output.compress()) {
throw RuntimeException("Cannot create target file")
}
val localManga = localMangaRepository.getFromFile(output.file)
emit(State.Done(startId, manga, cover, localManga))
} catch (_: CancellationException) {
emit(State.Cancelling(startId, manga, cover))
} catch (e: Throwable) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
emit(State.Error(startId, manga, cover, e))
} finally {
withContext(NonCancellable) {
output?.cleanup()
File(destination, TEMP_PAGE_FILE).deleteAwait()
}
}
}.catch { e ->
emit(State.Error(startId, manga, null, e))
}
private suspend fun downloadFile(url: String, referer: String, destination: File): File {
val request = Request.Builder()
.url(url)
.header(CommonHeaders.REFERER, referer)
.cacheControl(CacheUtils.CONTROL_DISABLED)
.get()
.build()
val call = okHttp.newCall(request)
var attempts = MAX_DOWNLOAD_ATTEMPTS
val file = File(destination, TEMP_PAGE_FILE)
while (true) {
try {
val response = call.clone().await()
withContext(Dispatchers.IO) {
file.outputStream().use { out ->
checkNotNull(response.body).byteStream().copyTo(out)
}
}
return file
} catch (e: IOException) {
attempts--
if (attempts <= 0) {
throw e
} else {
delay(DOWNLOAD_ERROR_DELAY)
}
}
}
}
sealed interface State {
val startId: Int
val manga: Manga
val cover: Drawable?
data class Queued(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : State
data class Preparing(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : State
data class Progress(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
val totalChapters: Int,
val currentChapter: Int,
val totalPages: Int,
val currentPage: Int,
): State {
val max: Int = totalChapters * totalPages
val progress: Int = totalPages * currentChapter + currentPage + 1
val percent: Float = progress.toFloat() / max
}
data class WaitingForNetwork(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
): State
data class Done(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
val localManga: Manga,
) : State
data class Error(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
val error: Throwable,
) : State
data class Cancelling(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
): State
data class PostProcessing(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : State
}
private companion object {
private const val MAX_DOWNLOAD_ATTEMPTS = 3
private const val DOWNLOAD_ERROR_DELAY = 500L
private const val TEMP_PAGE_FILE = "page.tmp"
}
}

View File

@@ -0,0 +1,106 @@
package org.koitharu.kotatsu.download.ui
import androidx.core.view.isVisible
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.utils.JobStateFlow
import org.koitharu.kotatsu.utils.ext.*
fun downloadItemAD(
scope: CoroutineScope,
coil: ImageLoader,
) = adapterDelegateViewBinding<JobStateFlow<DownloadManager.State>, JobStateFlow<DownloadManager.State>, ItemDownloadBinding>(
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }
) {
var job: Job? = null
bind {
job?.cancel()
job = item.onFirst { state ->
binding.imageViewCover.newImageRequest(state.manga.coverUrl)
.referer(state.manga.publicUrl)
.placeholder(state.cover)
.fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder)
.allowRgb565(true)
.enqueueWith(coil)
}.onEach { state ->
binding.textViewTitle.text = state.manga.title
when (state) {
is DownloadManager.State.Cancelling -> {
binding.textViewStatus.setText(R.string.cancelling_)
binding.progressBar.setIndeterminateCompat(true)
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.Done -> {
binding.textViewStatus.setText(R.string.download_complete)
binding.progressBar.setIndeterminateCompat(false)
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.Error -> {
binding.textViewStatus.setText(R.string.error_occurred)
binding.progressBar.setIndeterminateCompat(false)
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.text = state.error.getDisplayMessage(context.resources)
binding.textViewDetails.isVisible = true
}
is DownloadManager.State.PostProcessing -> {
binding.textViewStatus.setText(R.string.processing_)
binding.progressBar.setIndeterminateCompat(true)
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.Preparing -> {
binding.textViewStatus.setText(R.string.preparing_)
binding.progressBar.setIndeterminateCompat(true)
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.Progress -> {
binding.textViewStatus.setText(R.string.manga_downloading_)
binding.progressBar.setIndeterminateCompat(false)
binding.progressBar.isVisible = true
binding.progressBar.max = state.max
binding.progressBar.setProgressCompat(state.progress, true)
binding.textViewPercent.text = (state.percent * 100f).format(1) + "%"
binding.textViewPercent.isVisible = true
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.Queued -> {
binding.textViewStatus.setText(R.string.queued)
binding.progressBar.setIndeterminateCompat(false)
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadManager.State.WaitingForNetwork -> {
binding.textViewStatus.setText(R.string.waiting_for_network)
binding.progressBar.setIndeterminateCompat(false)
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
}
}.launchIn(scope)
}
onViewRecycled {
job?.cancel()
job = null
}
}

View File

@@ -0,0 +1,59 @@
package org.koitharu.kotatsu.download.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koin.android.ext.android.get
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.utils.LifecycleAwareServiceConnection
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val adapter = DownloadsAdapter(lifecycleScope, get())
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter
LifecycleAwareServiceConnection.bindService(
this,
this,
Intent(this, DownloadService::class.java),
0
).service.flatMapLatest { binder ->
(binder as? DownloadService.DownloadBinder)?.downloads ?: flowOf(null)
}.onEach {
adapter.items = it?.toList().orEmpty()
binding.textViewHolder.isVisible = it.isNullOrEmpty()
}.launchIn(lifecycleScope)
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.recyclerView.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom
)
binding.toolbar.updatePadding(
left = insets.left,
right = insets.right,
top = insets.top
)
}
companion object {
fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java)
}
}

View File

@@ -0,0 +1,40 @@
package org.koitharu.kotatsu.download.ui
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlinx.coroutines.CoroutineScope
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.utils.JobStateFlow
class DownloadsAdapter(
scope: CoroutineScope,
coil: ImageLoader,
) : AsyncListDifferDelegationAdapter<JobStateFlow<DownloadManager.State>>(DiffCallback()) {
init {
delegatesManager.addDelegate(downloadItemAD(scope, coil))
setHasStableIds(true)
}
override fun getItemId(position: Int): Long {
return items[position].value.startId.toLong()
}
private class DiffCallback : DiffUtil.ItemCallback<JobStateFlow<DownloadManager.State>>() {
override fun areItemsTheSame(
oldItem: JobStateFlow<DownloadManager.State>,
newItem: JobStateFlow<DownloadManager.State>,
): Boolean {
return oldItem.value.startId == newItem.value.startId
}
override fun areContentsTheSame(
oldItem: JobStateFlow<DownloadManager.State>,
newItem: JobStateFlow<DownloadManager.State>,
): Boolean {
return oldItem.value == newItem.value
}
}
}

View File

@@ -0,0 +1,144 @@
package org.koitharu.kotatsu.download.ui.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.download.ui.DownloadsActivity
import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.format
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DownloadNotification(
private val context: Context,
startId: Int,
) {
private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
private val cancelAction = NotificationCompat.Action(
R.drawable.ic_cross,
context.getString(android.R.string.cancel),
PendingIntent.getBroadcast(
context,
startId,
DownloadService.getCancelIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
)
private val listIntent = PendingIntent.getActivity(
context,
REQUEST_LIST,
DownloadsActivity.newIntent(context),
PendingIntentCompat.FLAG_IMMUTABLE,
)
init {
builder.setOnlyAlertOnce(true)
builder.setDefaults(0)
builder.color = ContextCompat.getColor(context, R.color.blue_primary)
}
fun create(state: DownloadManager.State): Notification {
builder.setContentTitle(state.manga.title)
builder.setContentText(context.getString(R.string.manga_downloading_))
builder.setProgress(1, 0, true)
builder.setSmallIcon(android.R.drawable.stat_sys_download)
builder.setContentIntent(listIntent)
builder.setStyle(null)
builder.setLargeIcon(state.cover?.toBitmap())
builder.clearActions()
when (state) {
is DownloadManager.State.Cancelling -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.cancelling_))
builder.setContentIntent(null)
builder.setStyle(null)
}
is DownloadManager.State.Done -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.download_complete))
builder.setContentIntent(createMangaIntent(context, state.localManga))
builder.setAutoCancel(true)
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
builder.setCategory(null)
builder.setStyle(null)
}
is DownloadManager.State.Error -> {
val message = state.error.getDisplayMessage(context.resources)
builder.setProgress(0, 0, false)
builder.setSmallIcon(android.R.drawable.stat_notify_error)
builder.setSubText(context.getString(R.string.error))
builder.setContentText(message)
builder.setAutoCancel(true)
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
}
is DownloadManager.State.PostProcessing -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.processing_))
builder.setStyle(null)
}
is DownloadManager.State.Queued,
is DownloadManager.State.Preparing -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.preparing_))
builder.setStyle(null)
builder.addAction(cancelAction)
}
is DownloadManager.State.Progress -> {
builder.setProgress(state.max, state.progress, false)
builder.setContentText((state.percent * 100).format() + "%")
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.addAction(cancelAction)
}
is DownloadManager.State.WaitingForNetwork -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.waiting_for_network))
builder.setStyle(null)
builder.addAction(cancelAction)
}
}
return builder.build()
}
private fun createMangaIntent(context: Context, manga: Manga) = PendingIntent.getActivity(
context,
manga.hashCode(),
DetailsActivity.newIntent(context, manga),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
companion object {
private const val CHANNEL_ID = "download"
private const val REQUEST_LIST = 6
fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = NotificationManagerCompat.from(context)
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.downloads),
NotificationManager.IMPORTANCE_LOW
)
channel.enableVibration(false)
channel.enableLights(false)
channel.setSound(null, null)
manager.createNotificationChannel(channel)
}
}
}
}
}

View File

@@ -0,0 +1,201 @@
package org.koitharu.kotatsu.download.ui.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.os.Binder
import android.os.IBinder
import android.os.PowerManager
import android.widget.Toast
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.get
import org.koin.core.context.GlobalContext
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseService
import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.utils.JobStateFlow
import org.koitharu.kotatsu.utils.ext.toArraySet
import java.util.concurrent.TimeUnit
import kotlin.collections.set
class DownloadService : BaseService() {
private lateinit var notificationManager: NotificationManagerCompat
private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var downloadManager: DownloadManager
private val jobs = LinkedHashMap<Int, JobStateFlow<DownloadManager.State>>()
private val jobCount = MutableStateFlow(0)
private val mutex = Mutex()
private val controlReceiver = ControlReceiver()
private var binder: DownloadBinder? = null
override fun onCreate() {
super.onCreate()
notificationManager = NotificationManagerCompat.from(this)
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
downloadManager = DownloadManager(this, get(), get(), get(), get(), get())
DownloadNotification.createChannel(this)
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
val manga = intent?.getParcelableExtra<Manga>(EXTRA_MANGA)
val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet()
return if (manga != null) {
jobs[startId] = downloadManga(startId, manga, chapters)
jobCount.value = jobs.size
Toast.makeText(this, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
START_REDELIVER_INTENT
} else {
stopSelf(startId)
START_NOT_STICKY
}
}
override fun onBind(intent: Intent): IBinder {
super.onBind(intent)
return binder ?: DownloadBinder(this).also { binder = it }
}
override fun onDestroy() {
unregisterReceiver(controlReceiver)
binder = null
super.onDestroy()
}
private fun downloadManga(
startId: Int,
manga: Manga,
chaptersIds: Set<Long>?,
): JobStateFlow<DownloadManager.State> {
val initialState = DownloadManager.State.Queued(startId, manga, null)
val stateFlow = MutableStateFlow<DownloadManager.State>(initialState)
val job = lifecycleScope.launch {
mutex.withLock {
wakeLock.acquire(TimeUnit.HOURS.toMillis(1))
val notification = DownloadNotification(this@DownloadService, startId)
startForeground(startId, notification.create(initialState))
try {
withContext(Dispatchers.Default) {
downloadManager.downloadManga(manga, chaptersIds, startId)
.collect { state ->
stateFlow.value = state
notificationManager.notify(startId, notification.create(state))
}
}
if (stateFlow.value is DownloadManager.State.Done) {
sendBroadcast(
Intent(ACTION_DOWNLOAD_COMPLETE)
.putExtra(EXTRA_MANGA, manga)
)
}
} finally {
ServiceCompat.stopForeground(
this@DownloadService,
if (isActive) {
ServiceCompat.STOP_FOREGROUND_DETACH
} else {
ServiceCompat.STOP_FOREGROUND_REMOVE
}
)
if (wakeLock.isHeld) {
wakeLock.release()
}
stopSelf(startId)
}
}
}
return JobStateFlow(stateFlow, job)
}
inner class ControlReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
when (intent?.action) {
ACTION_DOWNLOAD_CANCEL -> {
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
jobs.remove(cancelId)?.cancel()
jobCount.value = jobs.size
}
}
}
}
class DownloadBinder(private val service: DownloadService) : Binder() {
val downloads: Flow<Collection<JobStateFlow<DownloadManager.State>>>
get() = service.jobCount.mapLatest { service.jobs.values }
}
companion object {
const val ACTION_DOWNLOAD_COMPLETE =
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
private const val ACTION_DOWNLOAD_CANCEL =
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
private const val EXTRA_MANGA = "manga"
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
private const val EXTRA_CANCEL_ID = "cancel_id"
fun start(context: Context, manga: Manga, chaptersIds: Collection<Long>? = null) {
if (chaptersIds?.isEmpty() == true) {
return
}
confirmDataTransfer(context) {
val intent = Intent(context, DownloadService::class.java)
intent.putExtra(EXTRA_MANGA, manga)
if (chaptersIds != null) {
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
}
ContextCompat.startForegroundService(context, intent)
}
}
fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
.putExtra(ACTION_DOWNLOAD_CANCEL, startId)
private fun confirmDataTransfer(context: Context, callback: () -> Unit) {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val settings = GlobalContext.get().get<AppSettings>()
if (cm.isActiveNetworkMetered && settings.isTrafficWarningEnabled) {
CheckBoxAlertDialog.Builder(context)
.setTitle(R.string.warning)
.setMessage(R.string.network_consumption_warning)
.setCheckBoxText(R.string.dont_ask_again)
.setCheckBoxChecked(false)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string._continue) { _, doNotAsk ->
settings.isTrafficWarningEnabled = !doNotAsk
callback()
}.create()
.show()
} else {
callback()
}
}
}
}

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.favourites.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.model.FavouriteCategory
@Dao
abstract class FavouriteCategoriesDao {
@@ -13,6 +12,9 @@ abstract class FavouriteCategoriesDao {
@Query("SELECT * FROM favourite_categories ORDER BY sort_key")
abstract fun observeAll(): Flow<List<FavouriteCategoryEntity>>
@Query("SELECT * FROM favourite_categories WHERE category_id = :id")
abstract fun observe(id: Long): Flow<FavouriteCategoryEntity>
@Insert(onConflict = OnConflictStrategy.ABORT)
abstract suspend fun insert(category: FavouriteCategoryEntity): Long
@@ -23,10 +25,13 @@ abstract class FavouriteCategoriesDao {
abstract suspend fun delete(id: Long)
@Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id")
abstract suspend fun update(id: Long, title: String)
abstract suspend fun updateTitle(id: Long, title: String)
@Query("UPDATE favourite_categories SET `order` = :order WHERE category_id = :id")
abstract suspend fun updateOrder(id: Long, order: String)
@Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id")
abstract suspend fun update(id: Long, sortKey: Int)
abstract suspend fun updateSortKey(id: Long, sortKey: Int)
@Query("SELECT MAX(sort_key) FROM favourite_categories")
protected abstract suspend fun getMaxSortKey(): Int?

View File

@@ -4,6 +4,7 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.SortOrder
import java.util.*
@Entity(tableName = "favourite_categories")
@@ -12,13 +13,15 @@ data class FavouriteCategoryEntity(
@ColumnInfo(name = "category_id") val categoryId: Int,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "sort_key") val sortKey: Int,
@ColumnInfo(name = "title") val title: String
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "order") val order: String,
) {
fun toFavouriteCategory(id: Long? = null) = FavouriteCategory(
id = id ?: categoryId.toLong(),
title = title,
sortKey = sortKey,
createdAt = Date(createdAt)
order = SortOrder.values().find { x -> x.name == order } ?: SortOrder.NEWEST,
createdAt = Date(createdAt),
)
}

View File

@@ -1,8 +1,11 @@
package org.koitharu.kotatsu.favourites.data
import androidx.room.*
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.model.SortOrder
@Dao
abstract class FavouritesDao {
@@ -11,9 +14,13 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC")
abstract suspend fun findAll(): List<FavouriteManga>
@Transaction
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC")
abstract fun observeAll(): Flow<List<FavouriteManga>>
fun observeAll(order: SortOrder): Flow<List<FavouriteManga>> {
val orderBy = getOrderBy(order)
val query = SimpleSQLiteQuery(
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id GROUP BY favourites.manga_id ORDER BY $orderBy",
)
return observeAllRaw(query)
}
@Transaction
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
@@ -23,9 +30,14 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC")
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
@Transaction
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC")
abstract fun observeAll(categoryId: Long): Flow<List<FavouriteManga>>
fun observeAll(categoryId: Long, order: SortOrder): Flow<List<FavouriteManga>> {
val orderBy = getOrderBy(order)
val query = SimpleSQLiteQuery(
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id WHERE category_id = ? GROUP BY favourites.manga_id ORDER BY $orderBy",
arrayOf<Any>(categoryId),
)
return observeAllRaw(query)
}
@Transaction
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
@@ -63,4 +75,16 @@ abstract class FavouritesDao {
insert(entity)
}
}
@Transaction
@RawQuery(observedEntities = [FavouriteEntity::class])
protected abstract fun observeAllRaw(query: SupportSQLiteQuery): Flow<List<FavouriteManga>>
private fun getOrderBy(sortOrder: SortOrder) = when(sortOrder) {
SortOrder.RATING -> "rating DESC"
SortOrder.NEWEST,
SortOrder.UPDATED -> "created_at DESC"
SortOrder.ALPHABETICAL -> "title ASC"
else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
}
}

View File

@@ -3,12 +3,14 @@ package org.koitharu.kotatsu.favourites.domain
import androidx.room.withTransaction
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.utils.ext.mapItems
@@ -21,26 +23,26 @@ class FavouritesRepository(private val db: MangaDatabase) {
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
}
fun observeAll(): Flow<List<Manga>> {
return db.favouritesDao.observeAll()
fun observeAll(order: SortOrder): Flow<List<Manga>> {
return db.favouritesDao.observeAll(order)
.mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
}
suspend fun getAllManga(offset: Int): List<Manga> {
val entities = db.favouritesDao.findAll(offset, 20)
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
}
suspend fun getManga(categoryId: Long): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId)
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
}
fun observeAll(categoryId: Long): Flow<List<Manga>> {
return db.favouritesDao.observeAll(categoryId)
fun observeAll(categoryId: Long, order: SortOrder): Flow<List<Manga>> {
return db.favouritesDao.observeAll(categoryId, order)
.mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
}
fun observeAll(categoryId: Long): Flow<List<Manga>> {
return observeOrder(categoryId)
.flatMapLatest { order -> observeAll(categoryId, order) }
}
suspend fun getManga(categoryId: Long, offset: Int): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId, offset, 20)
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
@@ -77,25 +79,30 @@ class FavouritesRepository(private val db: MangaDatabase) {
title = title,
createdAt = System.currentTimeMillis(),
sortKey = db.favouriteCategoriesDao.getNextSortKey(),
categoryId = 0
categoryId = 0,
order = SortOrder.UPDATED.name,
)
val id = db.favouriteCategoriesDao.insert(entity)
return entity.toFavouriteCategory(id)
}
suspend fun renameCategory(id: Long, title: String) {
db.favouriteCategoriesDao.update(id, title)
db.favouriteCategoriesDao.updateTitle(id, title)
}
suspend fun removeCategory(id: Long) {
db.favouriteCategoriesDao.delete(id)
}
suspend fun setCategoryOrder(id: Long, order: SortOrder) {
db.favouriteCategoriesDao.updateOrder(id, order.name)
}
suspend fun reorderCategories(orderedIds: List<Long>) {
val dao = db.favouriteCategoriesDao
db.withTransaction {
for ((i, id) in orderedIds.withIndex()) {
dao.update(id, i)
dao.updateSortKey(id, i)
}
}
}
@@ -117,4 +124,10 @@ class FavouritesRepository(private val db: MangaDatabase) {
suspend fun removeFromFavourites(manga: Manga) {
db.favouritesDao.delete(manga.id)
}
private fun observeOrder(categoryId: Long): Flow<SortOrder> {
return db.favouriteCategoriesDao.observe(categoryId)
.map { x -> SortOrder.values().find { it.name == x.order } ?: SortOrder.NEWEST }
.distinctUntilChanged()
}
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.favourites.ui
import android.os.Bundle
import android.view.*
import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
@@ -11,15 +12,17 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel
import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.utils.RecycledViewPoolHolder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.showPopupMenu
import java.util.*
import kotlin.collections.ArrayList
class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
FavouritesTabLongClickListener, CategoriesEditDelegate.CategoriesEditCallback,
@@ -65,10 +68,22 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.tabs.updatePadding(
left = insets.left,
right = insets.right
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
binding.root.updatePadding(
top = headerHeight - insets.top
)
binding.pager.updatePadding(
top = -headerHeight
)
binding.tabs.apply {
updatePadding(
left = insets.left,
right = insets.right
)
updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
}
}
private fun onCategoriesChanged(categories: List<FavouriteCategory>) {
@@ -100,11 +115,19 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
override fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean {
val menuRes = if (category.id == 0L) R.menu.popup_category_empty else R.menu.popup_category
tabView.showPopupMenu(menuRes) {
tabView.showPopupMenu(menuRes, { menu ->
createOrderSubmenu(menu, category)
}) {
when (it.itemId) {
R.id.action_remove -> editDelegate.deleteCategory(category)
R.id.action_rename -> editDelegate.renameCategory(category)
R.id.action_create -> editDelegate.createCategory()
R.id.action_order -> return@showPopupMenu false
else -> {
val order = CategoriesActivity.SORT_ORDERS.getOrNull(it.order)
?: return@showPopupMenu false
viewModel.setCategoryOrder(category.id, order)
}
}
true
}
@@ -125,11 +148,26 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
private fun wrapCategories(categories: List<FavouriteCategory>): List<FavouriteCategory> {
val data = ArrayList<FavouriteCategory>(categories.size + 1)
data += FavouriteCategory(0L, getString(R.string.all_favourites), -1, Date())
data += FavouriteCategory(0L, getString(R.string.all_favourites), -1, SortOrder.NEWEST, Date())
data += categories
return data
}
private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) {
val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return
for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
val menuItem = submenu.add(
R.id.group_order,
Menu.NONE,
i,
item.titleRes
)
menuItem.isCheckable = true
menuItem.isChecked = item == category.order
}
submenu.setGroupCheckable(R.id.group_order, true, true)
}
companion object {
fun newInstance() = FavouritesContainerFragment()

View File

@@ -36,7 +36,7 @@ class FavouritesPagerAdapter(
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
val item = differ.currentList[position]
tab.text = item.title
tab.view.tag = item
tab.view.tag = item.id
tab.view.setOnLongClickListener(this)
}
@@ -45,7 +45,8 @@ class FavouritesPagerAdapter(
}
override fun onLongClick(v: View): Boolean {
val item = v.tag as? FavouriteCategory ?: return false
val itemId = v.tag as? Long ?: return false
val item = differ.currentList.find { x -> x.id == itemId } ?: return false
return longClickListener.onTabLongClick(v, item)
}

View File

@@ -5,6 +5,7 @@ import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.Color
import android.os.Bundle
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.Insets
@@ -20,6 +21,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.showPopupMenu
@@ -44,6 +46,7 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
adapter = CategoriesAdapter(this)
editDelegate = CategoriesEditDelegate(this, this)
binding.recyclerView.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL))
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter
binding.fabAdd.setOnClickListener(this)
reorderHelper = ItemTouchHelper(ReorderHelperCallback())
@@ -60,10 +63,17 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
}
override fun onItemClick(item: FavouriteCategory, view: View) {
view.showPopupMenu(R.menu.popup_category) {
view.showPopupMenu(R.menu.popup_category, { menu ->
createOrderSubmenu(menu, item)
}) {
when (it.itemId) {
R.id.action_remove -> editDelegate.deleteCategory(item)
R.id.action_rename -> editDelegate.renameCategory(item)
R.id.action_order -> return@showPopupMenu false
else -> {
val order = SORT_ORDERS.getOrNull(it.order) ?: return@showPopupMenu false
viewModel.setCategoryOrder(item.id, order)
}
}
true
}
@@ -116,6 +126,21 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
viewModel.createCategory(name)
}
private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) {
val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return
for ((i, item) in SORT_ORDERS.withIndex()) {
val menuItem = submenu.add(
R.id.group_order,
Menu.NONE,
i,
item.titleRes
)
menuItem.isCheckable = true
menuItem.isChecked = item == category.order
}
submenu.setGroupCheckable(R.id.group_order, true, true)
}
private inner class ReorderHelperCallback : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0
) {
@@ -144,6 +169,12 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
companion object {
val SORT_ORDERS = arrayOf(
SortOrder.ALPHABETICAL,
SortOrder.NEWEST,
SortOrder.RATING,
)
fun newIntent(context: Context) = Intent(context, CategoriesActivity::class.java)
}
}

View File

@@ -6,7 +6,7 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory
class CategoriesAdapter(
onItemClickListener: OnListItemClickListener<FavouriteCategory>
onItemClickListener: OnListItemClickListener<FavouriteCategory>,
) : AsyncListDifferDelegationAdapter<FavouriteCategory>(DiffCallback()) {
init {
@@ -20,12 +20,27 @@ class CategoriesAdapter(
private class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() {
override fun areItemsTheSame(oldItem: FavouriteCategory, newItem: FavouriteCategory): Boolean {
override fun areItemsTheSame(
oldItem: FavouriteCategory,
newItem: FavouriteCategory,
): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: FavouriteCategory, newItem: FavouriteCategory): Boolean {
override fun areContentsTheSame(
oldItem: FavouriteCategory,
newItem: FavouriteCategory,
): Boolean {
return oldItem.id == newItem.id && oldItem.title == newItem.title
&& oldItem.order == newItem.order
}
override fun getChangePayload(
oldItem: FavouriteCategory,
newItem: FavouriteCategory,
): Any? = when {
oldItem.title == newItem.title && oldItem.order != newItem.order -> newItem.order
else -> super.getChangePayload(oldItem, newItem)
}
}
}

View File

@@ -4,10 +4,10 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import java.util.*
import kotlin.collections.ArrayList
class FavouritesCategoriesViewModel(
private val repository: FavouritesRepository
@@ -19,23 +19,29 @@ class FavouritesCategoriesViewModel(
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
fun createCategory(name: String) {
launchJob(Dispatchers.Default) {
launchJob {
repository.addCategory(name)
}
}
fun renameCategory(id: Long, name: String) {
launchJob(Dispatchers.Default) {
launchJob {
repository.renameCategory(id, name)
}
}
fun deleteCategory(id: Long) {
launchJob(Dispatchers.Default) {
launchJob {
repository.removeCategory(id)
}
}
fun setCategoryOrder(id: Long, order: SortOrder) {
launchJob {
repository.setCategoryOrder(id, order)
}
}
fun reorderCategories(oldPos: Int, newPos: Int) {
val prevJob = reorderJob
reorderJob = launchJob(Dispatchers.Default) {

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.list.ui.MangaListViewModel
@@ -22,12 +23,18 @@ class FavouritesListViewModel(
) : MangaListViewModel(settings) {
override val content = combine(
if (categoryId == 0L) repository.observeAll() else repository.observeAll(categoryId),
if (categoryId == 0L) {
repository.observeAll(SortOrder.NEWEST)
} else {
repository.observeAll(categoryId)
},
createListModeFlow()
) { list, mode ->
when {
list.isEmpty() -> listOf(
EmptyState(
R.drawable.ic_heart_outline,
R.string.text_empty_holder_primary,
if (categoryId == 0L) {
R.string.you_have_not_favourites_yet
} else {

View File

@@ -20,7 +20,6 @@ import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.onFirst
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.collections.ArrayList
class HistoryListViewModel(
private val repository: HistoryRepository,
@@ -44,7 +43,7 @@ class HistoryListViewModel(
createListModeFlow()
) { list, grouped, mode ->
when {
list.isEmpty() -> listOf(EmptyState(R.string.text_history_holder))
list.isEmpty() -> listOf(EmptyState(R.drawable.ic_history, R.string.text_history_holder_primary, R.string.text_history_holder_secondary))
else -> mapList(list, grouped, mode)
}
}.onFirst {
@@ -81,8 +80,11 @@ class HistoryListViewModel(
grouped: Boolean,
mode: ListMode
): List<ListModel> {
val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size)
val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size + 1)
var prevDate: DateTimeAgo? = null
if (!grouped) {
result += ListHeader(null, R.string.history)
}
for ((manga, history) in list) {
if (grouped) {
val date = timeAgo(history.updatedAt)

View File

@@ -4,16 +4,15 @@ import android.os.Bundle
import android.view.*
import androidx.annotation.CallSuper
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.view.GravityCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.drawerlayout.widget.DrawerLayout
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
@@ -37,11 +36,10 @@ import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.filter.FilterAdapter
import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.utils.RecycledViewPoolHolder
import org.koitharu.kotatsu.utils.ext.clearItemDecorations
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.toggleDrawer
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import org.koitharu.kotatsu.utils.ext.*
abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
PaginationScrollListener.Callback, OnListItemClickListener<Manga>, OnFilterChangedListener,
@@ -73,7 +71,13 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
super.onViewCreated(view, savedInstanceState)
drawer = binding.root as? DrawerLayout
drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
listAdapter = MangaListAdapter(get(), viewLifecycleOwner, this, ::resolveException)
listAdapter = MangaListAdapter(
coil = get(),
lifecycleOwner = viewLifecycleOwner,
clickListener = this,
onRetryClick = ::resolveException,
onTagRemoveClick = viewModel::onRemoveFilterTag
)
paginationListener = PaginationScrollListener(4, this)
with(binding.recyclerView) {
setHasFixedSize(true)
@@ -81,6 +85,10 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
addOnScrollListener(paginationListener!!)
}
with(binding.swipeRefreshLayout) {
setColorSchemeColors(
ContextCompat.getColor(context, R.color.color_primary),
ContextCompat.getColor(context, R.color.color_primary_variant)
)
setOnRefreshListener(this@MangaListFragment)
isEnabled = isSwipeRefreshEnabled
}
@@ -215,22 +223,29 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
activity?.invalidateOptionsMenu()
}
@CallSuper
override fun onFilterChanged(filter: MangaFilter) {
drawer?.closeDrawers()
}
override fun onFilterChanged(filter: MangaFilter) = Unit
override fun onWindowInsetsChanged(insets: Insets) {
binding.recyclerView.updatePadding(
bottom = insets.bottom
)
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
binding.recyclerViewFilter.updatePadding(
top = headerHeight,
bottom = insets.bottom
)
binding.root.updatePadding(
left = insets.left,
right = insets.right
)
if (activity is MainActivity) {
binding.recyclerView.updatePadding(
top = headerHeight,
bottom = insets.bottom
)
binding.swipeRefreshLayout.setProgressViewOffset(
true,
headerHeight + resources.resolveDp(-72),
headerHeight + resources.resolveDp(10)
)
}
}
private fun onGridScaleChanged(scale: Float) {
@@ -246,13 +261,9 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
when (mode) {
ListMode.LIST -> {
layoutManager = LinearLayoutManager(context)
addItemDecoration(
DividerItemDecoration(
context,
RecyclerView.VERTICAL
)
)
updatePadding(left = 0, right = 0)
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
addItemDecoration(SpacingItemDecoration(spacing))
updatePadding(left = spacing, right = spacing)
}
ListMode.DETAILED_LIST -> {
layoutManager = LinearLayoutManager(context)
@@ -282,7 +293,7 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
final override fun getSectionTitle(position: Int): CharSequence? {
return when (binding.recyclerViewFilter.adapter?.getItemViewType(position)) {
FilterAdapter.VIEW_TYPE_SORT -> getString(R.string.sort_order)
FilterAdapter.VIEW_TYPE_TAG -> getString(R.string.genre)
FilterAdapter.VIEW_TYPE_TAG -> getString(R.string.genres)
else -> null
}
}

View File

@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -36,6 +37,8 @@ abstract class MangaListViewModel(
}
}
open fun onRemoveFilterTag(tag: MangaTag) = Unit
abstract fun onRefresh()
abstract fun onRetry()

View File

@@ -0,0 +1,23 @@
package org.koitharu.kotatsu.list.ui.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.databinding.ItemCurrentFilterBinding
import org.koitharu.kotatsu.list.ui.model.CurrentFilterModel
import org.koitharu.kotatsu.list.ui.model.ListModel
fun currentFilterAD(
onTagRemoveClick: (MangaTag) -> Unit,
) = adapterDelegateViewBinding<CurrentFilterModel, ListModel, ItemCurrentFilterBinding>(
{ inflater, parent -> ItemCurrentFilterBinding.inflate(inflater, parent, false) }
) {
binding.chipsTags.onChipCloseClickListener = ChipsView.OnChipCloseClickListener { chip, data ->
onTagRemoveClick(data as? MangaTag ?: return@OnChipCloseClickListener)
}
bind {
binding.chipsTags.setChips(item.chips)
}
}

View File

@@ -1,14 +1,23 @@
package org.koitharu.kotatsu.list.ui.adapter
import android.widget.TextView
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import org.koitharu.kotatsu.R
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
fun emptyStateListAD() = adapterDelegate<EmptyState, ListModel>(R.layout.item_empty_state) {
fun emptyStateListAD() = adapterDelegateViewBinding<EmptyState, ListModel, ItemEmptyStateBinding>(
{ inflater, parent -> ItemEmptyStateBinding.inflate(inflater, parent, false) }
) {
bind {
(itemView as TextView).setText(item.text)
with(binding.icon) {
setImageResource(item.icon)
}
with(binding.textPrimary) {
setText(item.textPrimary)
}
with(binding.textSecondary) {
setText(item.textSecondary)
}
}
}

View File

@@ -0,0 +1,19 @@
package org.koitharu.kotatsu.list.ui.adapter
import android.widget.TextView
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
fun listHeaderAD() = adapterDelegate<ListHeader, ListModel>(R.layout.item_header) {
bind {
val textView = (itemView as TextView)
if (item.text != null) {
textView.text = item.text
} else {
textView.setText(item.textRes)
}
}
}

View File

@@ -6,6 +6,7 @@ import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
@@ -17,7 +18,8 @@ class MangaListAdapter(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga>,
onRetryClick: (Throwable) -> Unit
onRetryClick: (Throwable) -> Unit,
onTagRemoveClick: (MangaTag) -> Unit,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init {
@@ -37,6 +39,8 @@ class MangaListAdapter(
.addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(onRetryClick))
.addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(onRetryClick))
.addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD())
.addDelegate(ITEM_TYPE_HEADER, listHeaderAD())
.addDelegate(ITEM_TYPE_FILTER, currentFilterAD(onTagRemoveClick))
}
fun setItems(list: List<ListModel>, commitCallback: Runnable) {
@@ -77,5 +81,7 @@ class MangaListAdapter(
const val ITEM_TYPE_ERROR_STATE = 6
const val ITEM_TYPE_ERROR_FOOTER = 7
const val ITEM_TYPE_EMPTY = 8
const val ITEM_TYPE_HEADER = 9
const val ITEM_TYPE_FILTER = 10
}
}

View File

@@ -6,20 +6,15 @@ import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
import org.koitharu.kotatsu.core.model.MangaFilter
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.model.SortOrder
import java.util.*
import kotlin.collections.ArrayList
class FilterAdapter(
sortOrders: List<SortOrder> = emptyList(),
tags: List<MangaTag> = emptyList(),
private val sortOrders: List<SortOrder> = emptyList(),
private val tags: List<MangaTag> = emptyList(),
state: MangaFilter?,
private val listener: OnFilterChangedListener
) : RecyclerView.Adapter<BaseViewHolder<*, Boolean, *>>() {
private val sortOrders = ArrayList<SortOrder>(sortOrders)
private val tags = ArrayList(Collections.singletonList(null) + tags)
private var currentState = state ?: MangaFilter(sortOrders.first(), null)
private var currentState = state ?: MangaFilter(sortOrders.firstOrNull(), emptySet())
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) {
VIEW_TYPE_SORT -> FilterSortHolder(parent).apply {
@@ -29,7 +24,7 @@ class FilterAdapter(
}
VIEW_TYPE_TAG -> FilterTagHolder(parent).apply {
itemView.setOnClickListener {
setCheckedTag(boundData)
setCheckedTag(boundData ?: return@setOnClickListener, !isChecked)
}
}
else -> throw IllegalArgumentException("Unknown viewType $viewType")
@@ -45,7 +40,7 @@ class FilterAdapter(
}
is FilterTagHolder -> {
val item = tags[position - sortOrders.size]
holder.bind(item, item == currentState.tag)
holder.bind(item, item in currentState.tags)
}
}
}
@@ -55,19 +50,25 @@ class FilterAdapter(
else -> VIEW_TYPE_TAG
}
fun setCheckedTag(tag: MangaTag?) {
if (tag != currentState.tag) {
val oldItemPos = tags.indexOf(currentState.tag)
val newItemPos = tags.indexOf(tag)
currentState = currentState.copy(tag = tag)
if (oldItemPos in tags.indices) {
notifyItemChanged(sortOrders.size + oldItemPos)
fun setCheckedTag(tag: MangaTag, isChecked: Boolean) {
currentState = if (tag in currentState.tags) {
if (!isChecked) {
currentState.copy(tags = currentState.tags - tag)
} else {
return
}
if (newItemPos in tags.indices) {
notifyItemChanged(sortOrders.size + newItemPos)
} else {
if (isChecked) {
currentState.copy(tags = currentState.tags + tag)
} else {
return
}
listener.onFilterChanged(currentState)
}
val index = tags.indexOf(tag)
if (index in tags.indices) {
notifyItemChanged(sortOrders.size + index)
}
listener.onFilterChanged(currentState)
}
fun setCheckedSort(sort: SortOrder) {

View File

@@ -12,7 +12,7 @@ class FilterSortHolder(parent: ViewGroup) :
) {
override fun onBind(data: SortOrder, extra: Boolean) {
binding.radio.setText(data.titleRes)
binding.radio.isChecked = extra
binding.root.setText(data.titleRes)
binding.root.isChecked = extra
}
}

View File

@@ -2,18 +2,20 @@ package org.koitharu.kotatsu.list.ui.filter
import android.view.LayoutInflater
import android.view.ViewGroup
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
class FilterTagHolder(parent: ViewGroup) :
BaseViewHolder<MangaTag?, Boolean, ItemCheckableSingleBinding>(
ItemCheckableSingleBinding.inflate(LayoutInflater.from(parent.context), parent, false)
BaseViewHolder<MangaTag, Boolean, ItemCheckableMultipleBinding>(
ItemCheckableMultipleBinding.inflate(LayoutInflater.from(parent.context), parent, false)
) {
override fun onBind(data: MangaTag?, extra: Boolean) {
binding.radio.text = data?.title ?: context.getString(R.string.all)
binding.radio.isChecked = extra
val isChecked: Boolean
get() = binding.root.isChecked
override fun onBind(data: MangaTag, extra: Boolean) {
binding.root.text = data.title
binding.root.isChecked = extra
}
}

View File

@@ -0,0 +1,7 @@
package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
data class CurrentFilterModel(
val chips: Collection<ChipsView.ChipModel>,
) : ListModel

View File

@@ -1,7 +1,10 @@
package org.koitharu.kotatsu.list.ui.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
data class EmptyState(
@StringRes val text: Int
@DrawableRes val icon: Int,
@StringRes val textPrimary: Int,
@StringRes val textSecondary: Int
) : ListModel

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.list.ui.model
import androidx.annotation.StringRes
data class ListHeader(
val text: CharSequence?,
@StringRes val textRes: Int,
) : ListModel

View File

@@ -6,7 +6,6 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.resolve.ResolvableException
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.prefs.ListMode
import kotlin.math.roundToInt
fun Manga.toListModel() = MangaListModel(
id = id,
@@ -20,7 +19,7 @@ fun Manga.toListDetailedModel() = MangaListDetailedModel(
id = id,
title = title,
subtitle = altTitle,
rating = if (rating == Manga.NO_RATING) null else "${(rating * 10).roundToInt()}/10",
rating = if (rating == Manga.NO_RATING) null else String.format("%.1f", rating * 5),
tags = tags.joinToString(", ") { it.title },
coverUrl = coverUrl,
manga = this

View File

@@ -15,5 +15,5 @@ val localModule
single { LocalMangaRepository(androidContext()) }
factory<MangaRepository>(named(MangaSource.LOCAL)) { get<LocalMangaRepository>() }
viewModel { LocalListViewModel(get(), get(), get(), get(), androidContext()) }
viewModel { LocalListViewModel(get(), get(), get(), get()) }
}

View File

@@ -20,19 +20,22 @@ class CbzFetcher : Fetcher<Uri> {
pool: BitmapPool,
data: Uri,
size: Size,
options: Options
options: Options,
): FetchResult {
val zip = ZipFile(data.schemeSpecificPart)
val entry = zip.getEntry(data.fragment)
val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name)
return SourceResult(
source = zip.getInputStream(entry).source().buffer(),
source = ExtraCloseableBufferedSource(
zip.getInputStream(entry).source().buffer(),
zip,
),
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext),
dataSource = DataSource.DISK
)
}
override fun key(data: Uri): String? = data.toString()
override fun key(data: Uri) = data.toString()
override fun handles(data: Uri) = data.scheme == "cbz"
}

View File

@@ -7,7 +7,7 @@ import java.util.*
class CbzFilter : FilenameFilter {
override fun accept(dir: File, name: String): Boolean {
val ext = name.substringAfterLast('.', "").toLowerCase(Locale.ROOT)
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
return ext == "cbz" || ext == "zip"
}
}

View File

@@ -0,0 +1,18 @@
package org.koitharu.kotatsu.local.data
import okhttp3.internal.closeQuietly
import okio.BufferedSource
import okio.Closeable
class ExtraCloseableBufferedSource(
private val delegate: BufferedSource,
vararg closeable: Closeable,
) : BufferedSource by delegate {
private val extraCloseable = closeable
override fun close() {
delegate.close()
extraCloseable.forEach { x -> x.closeQuietly() }
}
}

View File

@@ -7,6 +7,8 @@ import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.utils.ext.getBooleanOrDefault
import org.koitharu.kotatsu.utils.ext.getLongOrDefault
import org.koitharu.kotatsu.utils.ext.getStringOrNull
import org.koitharu.kotatsu.utils.ext.mapToSet
@@ -20,9 +22,11 @@ class MangaIndex(source: String?) {
json.put("title_alt", manga.altTitle)
json.put("url", manga.url)
json.put("public_url", manga.publicUrl)
json.put("author", manga.author)
json.put("cover", manga.coverUrl)
json.put("description", manga.description)
json.put("rating", manga.rating)
json.put("nsfw", manga.isNsfw)
json.put("source", manga.source.name)
json.put("cover_large", manga.largeCoverUrl)
json.put("tags", JSONArray().also { a ->
@@ -48,8 +52,11 @@ class MangaIndex(source: String?) {
altTitle = json.getStringOrNull("title_alt"),
url = json.getString("url"),
publicUrl = json.getStringOrNull("public_url").orEmpty(),
author = json.getStringOrNull("author"),
largeCoverUrl = json.getStringOrNull("cover_large"),
source = source,
rating = json.getDouble("rating").toFloat(),
isNsfw = json.getBooleanOrDefault("nsfw", false),
coverUrl = json.getString("cover"),
description = json.getStringOrNull("description"),
tags = json.getJSONArray("tags").mapToSet { x ->
@@ -59,7 +66,7 @@ class MangaIndex(source: String?) {
source = source
)
},
chapters = getChapters(json.getJSONObject("chapters"), source)
chapters = getChapters(json.getJSONObject("chapters"), source),
)
}.getOrNull()
@@ -72,6 +79,8 @@ class MangaIndex(source: String?) {
jo.put("number", chapter.number)
jo.put("url", chapter.url)
jo.put("name", chapter.name)
jo.put("uploadDate", chapter.uploadDate)
jo.put("scanlator", chapter.scanlator)
jo.put("branch", chapter.branch)
jo.put("entries", "%03d\\d{3}".format(chapter.number))
chapters.put(chapter.id.toString(), jo)
@@ -98,8 +107,10 @@ class MangaIndex(source: String?) {
name = v.getString("name"),
url = v.getString("url"),
number = v.getInt("number"),
uploadDate = v.getLongOrDefault("uploadDate", 0L),
scanlator = v.getStringOrNull("scanlator"),
branch = v.getStringOrNull("branch"),
source = source
source = source,
)
)
}

View File

@@ -15,9 +15,7 @@ import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.local.data.MangaZip
import org.koitharu.kotatsu.utils.AlphanumComparator
import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.readText
import org.koitharu.kotatsu.utils.ext.sub
import org.koitharu.kotatsu.utils.ext.*
import java.io.File
import java.util.*
import java.util.zip.ZipEntry
@@ -27,17 +25,16 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
private val filenameFilter = CbzFilter()
override suspend fun getList(
override suspend fun getList2(
offset: Int,
query: String?,
sortOrder: SortOrder?,
tag: MangaTag?
tags: Set<MangaTag>?,
sortOrder: SortOrder?
): List<Manga> {
require(offset == 0) {
"LocalMangaRepository does not support pagination"
}
val files = getAvailableStorageDirs(context)
.flatMap { x -> x.listFiles(filenameFilter)?.toList().orEmpty() }
val files = getAllFiles()
return files.mapNotNull { x -> runCatching { getFromFile(x) }.getOrNull() }
}
@@ -78,9 +75,9 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
}
}
fun delete(manga: Manga): Boolean {
suspend fun delete(manga: Manga): Boolean {
val file = Uri.parse(manga.url).toFile()
return file.delete()
return file.deleteAwait()
}
@SuppressLint("DefaultLocale")
@@ -98,11 +95,14 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
entryName = index.getCoverEntry()
?: findFirstEntry(zip.entries(), isImage = true)?.name.orEmpty()
),
chapters = info.chapters?.map { c -> c.copy(url = fileUri) }
chapters = info.chapters?.map { c ->
c.copy(url = fileUri,
source = MangaSource.LOCAL)
}
)
}
// fallback
val title = file.nameWithoutExtension.replace("_", " ").capitalize()
val title = file.nameWithoutExtension.replace("_", " ").toCamelCase()
val chapters = ArraySet<String>()
for (x in zip.entries()) {
if (!x.isDirectory) {
@@ -120,9 +120,10 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
chapters = chapters.sortedWith(AlphanumComparator()).mapIndexed { i, s ->
MangaChapter(
id = "$i$s".longHashCode(),
name = if (s.isEmpty()) title else s,
name = s.ifEmpty { title },
number = i + 1,
source = MangaSource.LOCAL,
uploadDate = 0L,
url = uriBuilder.fragment(s).build().toString()
)
}
@@ -134,13 +135,36 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
Uri.parse(localManga.url).toFile()
}.getOrNull() ?: return null
return withContext(Dispatchers.IO) {
val zip = ZipFile(file)
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return@withContext null
index.getMangaInfo()
@Suppress("BlockingMethodInNonBlockingContext")
ZipFile(file).use { zip ->
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return@withContext null
index.getMangaInfo()
}
}
}
suspend fun findSavedManga(remoteManga: Manga): Manga? = withContext(Dispatchers.IO) {
val files = getAllFiles()
for (file in files) {
@Suppress("BlockingMethodInNonBlockingContext")
val index = ZipFile(file).use { zip ->
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
entry?.let(zip::readText)?.let(::MangaIndex)
} ?: continue
val info = index.getMangaInfo() ?: continue
if (info.id == remoteManga.id) {
val fileUri = file.toUri().toString()
return@withContext info.copy(
source = MangaSource.LOCAL,
url = fileUri,
chapters = info.chapters?.map { c -> c.copy(url = fileUri) }
)
}
}
null
}
private fun zipUri(file: File, entryName: String) =
Uri.fromParts("cbz", file.path, entryName).toString()
@@ -165,12 +189,16 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
override suspend fun getTags() = emptySet<MangaTag>()
private fun getAllFiles() = getAvailableStorageDirs(context).flatMap { dir ->
dir.listFiles(filenameFilter)?.toList().orEmpty()
}
companion object {
private const val DIR_NAME = "manga"
fun isFileSupported(name: String): Boolean {
val ext = name.substringAfterLast('.').toLowerCase(Locale.ROOT)
val ext = name.substringAfterLast('.').lowercase(Locale.ROOT)
return ext == "cbz" || ext == "zip"
}

View File

@@ -1,6 +1,6 @@
package org.koitharu.kotatsu.local.ui
import android.content.ActivityNotFoundException
import android.content.*
import android.net.Uri
import android.os.Bundle
import android.view.Menu
@@ -15,6 +15,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.utils.ext.ellipsize
@@ -25,12 +26,32 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri> {
ActivityResultContracts.OpenDocument(),
this
)
private val downloadReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == DownloadService.ACTION_DOWNLOAD_COMPLETE) {
viewModel.onRefresh()
}
}
}
override fun onAttach(context: Context) {
super.onAttach(context)
context.registerReceiver(
downloadReceiver,
IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE)
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.onMangaRemoved.observe(viewLifecycleOwner, ::onItemRemoved)
}
override fun onDetach() {
requireContext().unregisterReceiver(downloadReceiver)
super.onDetach()
}
override fun onScrolledToEnd() = Unit
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@@ -65,7 +86,7 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri> {
override fun onActivityResult(result: Uri?) {
if (result != null) {
viewModel.importFile(result)
viewModel.importFile(context?.applicationContext ?: return, result)
}
}

View File

@@ -14,15 +14,12 @@ import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.utils.MediaStoreCompat
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.sub
import java.io.File
import java.io.IOException
class LocalListViewModel(
@@ -30,12 +27,12 @@ class LocalListViewModel(
private val historyRepository: HistoryRepository,
private val settings: AppSettings,
private val shortcutsRepository: ShortcutsRepository,
private val context: Context
) : MangaListViewModel(settings) {
val onMangaRemoved = SingleLiveEvent<Manga>()
private val listError = MutableStateFlow<Throwable?>(null)
private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val headerModel = ListHeader(null, R.string.local_storage)
override val content = combine(
mangaList,
@@ -45,8 +42,11 @@ class LocalListViewModel(
when {
error != null -> listOf(error.toErrorState(canRetry = true))
list == null -> listOf(LoadingState)
list.isEmpty() -> listOf(EmptyState(R.string.text_local_holder))
else -> list.toUi(mode)
list.isEmpty() -> listOf(EmptyState(R.drawable.ic_storage, R.string.text_local_holder_primary, R.string.text_local_holder_secondary))
else -> ArrayList<ListModel>(list.size + 1).apply {
add(headerModel)
list.toUi(this, mode)
}
}
}.asLiveDataDistinct(
viewModelScope.coroutineContext + Dispatchers.Default,
@@ -61,7 +61,7 @@ class LocalListViewModel(
launchLoadingJob(Dispatchers.Default) {
try {
listError.value = null
mangaList.value = repository.getList(0)
mangaList.value = repository.getList2(0)
} catch (e: Throwable) {
listError.value = e
}
@@ -70,7 +70,7 @@ class LocalListViewModel(
override fun onRetry() = onRefresh()
fun importFile(uri: Uri) {
fun importFile(context: Context, uri: Uri) {
launchLoadingJob {
val contentResolver = context.contentResolver
withContext(Dispatchers.IO) {
@@ -79,8 +79,9 @@ class LocalListViewModel(
if (!LocalMangaRepository.isFileSupported(name)) {
throw UnsupportedFileException("Unsupported file on $uri")
}
val dest = settings.getStorageDir(context)?.sub(name)
val dest = settings.getStorageDir(context)?.let { File(it, name) }
?: throw IOException("External files dir unavailable")
@Suppress("BlockingMethodInNonBlockingContext")
contentResolver.openInputStream(uri)?.use { source ->
dest.outputStream().use { output ->
source.copyTo(output)

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.main.ui
import com.google.android.material.appbar.AppBarLayout
interface AppBarOwner {
val appBar: AppBarLayout
}

View File

@@ -6,18 +6,23 @@ import android.content.res.Configuration
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.view.*
import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.transition.TransitionManager
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar
import org.koin.android.ext.android.get
@@ -27,7 +32,9 @@ import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSection
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.ActivityMainBinding
import org.koitharu.kotatsu.databinding.NavigationHeaderBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.favourites.ui.FavouritesContainerFragment
import org.koitharu.kotatsu.history.ui.HistoryListFragment
@@ -39,41 +46,75 @@ import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.search.ui.suggestion.SearchUI
import org.koitharu.kotatsu.settings.AppUpdateChecker
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import org.koitharu.kotatsu.tracker.ui.FeedFragment
import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.resolveDp
import org.koitharu.kotatsu.utils.ext.*
class MainActivity : BaseActivity<ActivityMainBinding>(),
NavigationView.OnNavigationItemSelectedListener,
View.OnClickListener, SearchSuggestionListener, MenuItem.OnActionExpandListener {
NavigationView.OnNavigationItemSelectedListener, AppBarOwner,
View.OnClickListener, View.OnFocusChangeListener, SearchSuggestionListener {
private val viewModel by viewModel<MainViewModel>(mode = LazyThreadSafetyMode.NONE)
private val searchSuggestionViewModel by viewModel<SearchSuggestionViewModel>(
mode = LazyThreadSafetyMode.NONE
)
private lateinit var navHeaderBinding: NavigationHeaderBinding
private lateinit var drawerToggle: ActionBarDrawerToggle
private var searchUi: SearchUI? = null
private var searchViewElevation = 0f
override val appBar: AppBarLayout
get() = binding.appbar
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityMainBinding.inflate(layoutInflater))
searchViewElevation = binding.toolbarCard.cardElevation
navHeaderBinding = NavigationHeaderBinding.inflate(layoutInflater)
drawerToggle = ActionBarDrawerToggle(
this,
binding.drawer,
binding.toolbar,
R.string.open_menu,
R.string.close_menu
)
this,
binding.drawer,
binding.toolbar,
R.string.open_menu,
R.string.close_menu
)
drawerToggle.setHomeAsUpIndicator(ContextCompat.getDrawable(this, R.drawable.ic_arrow_back))
drawerToggle.setToolbarNavigationClickListener {
binding.searchView.hideKeyboard()
onBackPressed()
}
binding.drawer.addDrawerListener(drawerToggle)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
binding.navigationView.setNavigationItemSelectedListener(this)
if (get<AppSettings>().isAmoledTheme && get<AppSettings>().theme == AppCompatDelegate.MODE_NIGHT_YES) {
binding.appbar.setBackgroundColor(Color.BLACK)
binding.toolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.color_background))
} else {
binding.toolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.color_surface))
}
with(binding.searchView) {
onFocusChangeListener = this@MainActivity
searchSuggestionListener = this@MainActivity
}
with(binding.navigationView) {
val menuView =
findViewById<RecyclerView>(com.google.android.material.R.id.design_navigation_view)
ViewCompat.setOnApplyWindowInsetsListener(navHeaderBinding.root) { v, insets ->
val systemWindowInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.updatePadding(top = systemWindowInsets.top)
// NavigationView doesn't dispatch insets to the menu view, so pad the bottom here.
menuView.updatePadding(bottom = systemWindowInsets.bottom)
insets
}
addHeaderView(navHeaderBinding.root)
itemBackground = navigationItemBackground(context)
setNavigationItemSelectedListener(this@MainActivity)
}
with(binding.fab) {
imageTintList = ColorStateList.valueOf(Color.WHITE)
@@ -86,9 +127,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
openDefaultSection()
}
if (savedInstanceState == null) {
TrackWorker.setup(applicationContext)
AppUpdateChecker(this).launchIfNeeded()
OnboardDialogFragment.showWelcome(get(), supportFragmentManager)
onFirstStart()
}
viewModel.onOpenReader.observe(this, this::onOpenReader)
@@ -97,6 +136,12 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
viewModel.remoteSources.observe(this, this::updateSideMenu)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
drawerToggle.isDrawerIndicatorEnabled =
binding.drawer.getDrawerLockMode(GravityCompat.START) == DrawerLayout.LOCK_MODE_UNLOCKED
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
drawerToggle.syncState()
@@ -108,23 +153,20 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
}
override fun onBackPressed() {
if (binding.drawer.isDrawerOpen(binding.navigationView)) {
binding.drawer.closeDrawer(binding.navigationView)
} else {
super.onBackPressed()
val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH)
binding.searchView.clearFocus()
when {
binding.drawer.isDrawerOpen(binding.navigationView) -> binding.drawer.closeDrawer(
binding.navigationView)
fragment != null -> supportFragmentManager.commit {
remove(fragment)
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
runOnCommit { onSearchClosed() }
}
else -> super.onBackPressed()
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_main, menu)
searchUi = menu.findItem(R.id.action_search)?.let { menuItem ->
onMenuItemActionCollapse(menuItem)
menuItem.setOnActionExpandListener(this)
SearchUI.from(menuItem, this)
}
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return drawerToggle.onOptionsItemSelected(item) || when (item.itemId) {
else -> super.onOptionsItemSelected(item)
@@ -173,16 +215,30 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.toolbar.updatePadding(
top = insets.top,
left = insets.left,
right = insets.right
)
binding.fab.updateLayoutParams<ViewGroup.MarginLayoutParams> {
binding.toolbarCard.updateLayoutParams<MarginLayoutParams> {
topMargin = insets.top + resources.resolveDp(8)
}
binding.fab.updateLayoutParams<MarginLayoutParams> {
bottomMargin = insets.bottom + topMargin
leftMargin = insets.left + topMargin
rightMargin = insets.right + topMargin
}
binding.container.updateLayoutParams<MarginLayoutParams> {
topMargin = -(binding.appbar.measureHeight())
}
}
override fun onFocusChange(v: View?, hasFocus: Boolean) {
val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH)
if (v?.id == R.id.searchView && hasFocus) {
if (fragment == null) {
supportFragmentManager.commit {
add(R.id.container, SearchSuggestionFragment.newInstance(), TAG_SEARCH)
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
runOnCommit { onSearchOpened() }
}
}
}
}
override fun onMangaClick(manga: Manga) {
@@ -190,6 +246,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
}
override fun onQueryClick(query: String, submit: Boolean) {
binding.searchView.query = query
if (submit) {
if (query.isNotEmpty()) {
val source = searchSuggestionViewModel.getLocalSearchSource()
@@ -200,8 +257,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
}
searchSuggestionViewModel.saveQuery(query)
}
} else {
searchUi?.query = query
}
}
@@ -219,28 +274,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
}.show()
}
override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH)
if (fragment == null) {
supportFragmentManager.commit {
add(R.id.container, SearchSuggestionFragment.newInstance(), TAG_SEARCH)
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
}
}
return true
}
override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH)
if (fragment != null) {
supportFragmentManager.commit {
remove(fragment)
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
}
}
return true
}
private fun onOpenReader(manga: Manga) {
val options = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ActivityOptions.makeClipRevealAnimation(
@@ -309,6 +342,53 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
binding.fab.isVisible = fragment is HistoryListFragment
}
private fun onSearchOpened() {
binding.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
drawerToggle.isDrawerIndicatorEnabled = false
TransitionManager.beginDelayedTransition(binding.appbar)
// Avoiding shadows on the sides if the color is transparent, so we make the AppBarLayout white/grey/black
if (isDarkAmoledTheme()) {
binding.toolbar.setBackgroundColor(Color.BLACK)
} else {
binding.appbar.setBackgroundColor(ContextCompat.getColor(this, R.color.color_surface))
}
binding.toolbarCard.apply {
cardElevation = 0f
// Remove margin
updateLayoutParams<MarginLayoutParams> {
leftMargin = 0
rightMargin = 0
}
}
binding.appbar.elevation = searchViewElevation
}
private fun onSearchClosed() {
binding.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED)
drawerToggle.isDrawerIndicatorEnabled = true
if (isDarkAmoledTheme()) {
binding.toolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.color_background))
}
TransitionManager.beginDelayedTransition(binding.appbar)
// Returning transparent color
binding.appbar.setBackgroundColor(Color.TRANSPARENT)
binding.appbar.elevation = 0f
binding.toolbarCard.apply {
cardElevation = searchViewElevation
updateLayoutParams<MarginLayoutParams> {
leftMargin = resources.resolveDp(16)
rightMargin = resources.resolveDp(16)
}
}
}
private fun onFirstStart() {
TrackWorker.setup(applicationContext)
AppUpdateChecker(this@MainActivity).launchIfNeeded()
OnboardDialogFragment.showWelcome(get(), supportFragmentManager)
}
private companion object {
const val TAG_PRIMARY = "primary"

View File

@@ -9,22 +9,25 @@ import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.koin.android.ext.android.get
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.DialogChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.history.domain.ChapterExtra
import org.koitharu.kotatsu.utils.ext.withArgs
class ChaptersDialog : AlertDialogFragment<DialogChaptersBinding>(),
OnListItemClickListener<MangaChapter> {
OnListItemClickListener<ChapterListItem> {
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
container: ViewGroup?,
) = DialogChaptersBinding.inflate(inflater, container, false)
override fun onBuildDialog(builder: AlertDialog.Builder) {
@@ -44,6 +47,7 @@ class ChaptersDialog : AlertDialogFragment<DialogChaptersBinding>(),
}
val currentId = arguments?.getLong(ARG_CURRENT_ID, 0L) ?: 0L
val currentPosition = chapters.indexOfFirst { it.id == currentId }
val dateFormat = get<AppSettings>().dateFormat()
binding.recyclerViewChapters.adapter = ChaptersAdapter(this).apply {
setItems(chapters.mapIndexed { index, chapter ->
chapter.toListItem(
@@ -51,7 +55,9 @@ class ChaptersDialog : AlertDialogFragment<DialogChaptersBinding>(),
index < currentPosition -> ChapterExtra.READ
index == currentPosition -> ChapterExtra.CURRENT
else -> ChapterExtra.UNREAD
}
},
isMissing = false,
dateFormat = dateFormat,
)
}) {
if (currentPosition >= 0) {
@@ -66,11 +72,11 @@ class ChaptersDialog : AlertDialogFragment<DialogChaptersBinding>(),
}
}
override fun onItemClick(item: MangaChapter, view: View) {
override fun onItemClick(item: ChapterListItem, view: View) {
((parentFragment as? OnChapterChangeListener)
?: (activity as? OnChapterChangeListener))?.let {
dismiss()
it.onChapterChanged(item)
it.onChapterChanged(item.chapter)
}
}

View File

@@ -13,7 +13,10 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.view.*
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.postDelayed
import androidx.core.view.updatePadding
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar
@@ -38,7 +41,7 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReader
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.reader.ui.pager.reversed.ReversedReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.wetoon.WebtoonReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonReaderFragment
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
import org.koitharu.kotatsu.utils.GridTouchHelper
@@ -192,6 +195,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
override fun onActivityResult(result: Boolean) {
if (result) {
viewModel.saveCurrentState(reader?.getCurrentState())
viewModel.saveCurrentPage(contentResolver)
}
}
@@ -220,7 +224,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
}
override fun onGridTouch(area: Int) {
controlDelegate.onGridTouch(area)
controlDelegate.onGridTouch(area, binding.container)
}
override fun onProcessTouch(rawX: Int, rawY: Int): Boolean {

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.reader.ui
import android.view.KeyEvent
import android.view.SoundEffectConstants
import android.view.View
import androidx.lifecycle.LifecycleCoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
@@ -30,18 +32,27 @@ class ReaderControlDelegate(
}.launchIn(scope)
}
fun onGridTouch(area: Int) {
fun onGridTouch(area: Int, view: View) {
when (area) {
GridTouchHelper.AREA_CENTER -> {
listener.toggleUiVisibility()
view.playSoundEffect(SoundEffectConstants.CLICK)
}
GridTouchHelper.AREA_TOP -> if (isTapSwitchEnabled) {
listener.switchPageBy(-1)
view.playSoundEffect(SoundEffectConstants.NAVIGATION_UP)
}
GridTouchHelper.AREA_TOP,
GridTouchHelper.AREA_LEFT -> if (isTapSwitchEnabled) {
listener.switchPageBy(-1)
view.playSoundEffect(SoundEffectConstants.NAVIGATION_LEFT)
}
GridTouchHelper.AREA_BOTTOM -> if (isTapSwitchEnabled) {
listener.switchPageBy(1)
view.playSoundEffect(SoundEffectConstants.NAVIGATION_DOWN)
}
GridTouchHelper.AREA_BOTTOM,
GridTouchHelper.AREA_RIGHT -> if (isTapSwitchEnabled) {
listener.switchPageBy(1)
view.playSoundEffect(SoundEffectConstants.NAVIGATION_RIGHT)
}
}
}

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