Compare commits

...

89 Commits
v3.0 ... v3.2

Author SHA1 Message Date
Koitharu
e34acf010e Update parsers 2022-04-23 19:31:02 +03:00
Koitharu
0fb29174c5 Fix webview useragent 2022-04-23 19:31:01 +03:00
Xtimms
ca45774cdb Merge remote-tracking branch 'origin/devel' into devel 2022-04-23 19:16:46 +03:00
Xtimms
cccc2c4fe4 Create issue template 2022-04-23 19:16:15 +03:00
Koitharu
c73af2d45f Update version 2022-04-23 15:37:16 +03:00
lowak
acf7102d07 Translated using Weblate (Swedish)
Currently translated at 100.0% (274 of 274 strings)

Co-authored-by: lowak <lowak@pm.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sv/
Translation: Kotatsu/Strings
2022-04-23 15:33:11 +03:00
Koitharu
75fcd31758 Fix locking app on screen rotation 2022-04-23 15:31:52 +03:00
Koitharu
7bffb5f22d Select source domains using AutoCompleteTextView 2022-04-23 15:20:57 +03:00
Koitharu
c220bd5517 Merge branch 'feature/direct-download' into devel 2022-04-23 13:57:24 +03:00
Koitharu
7c827b45d5 Merge branch 'feature/improve-list' into devel 2022-04-23 13:55:03 +03:00
Koitharu
e91d9ee38e Fix list selection corners 2022-04-23 13:54:48 +03:00
Koitharu
b6a86a6538 Cache and reuse RemoteMangaRepository instances 2022-04-23 10:39:33 +03:00
Koitharu
16b6b6c071 Add dummy manga parser for development 2022-04-23 10:31:10 +03:00
Xtimms
695feef4a6 Improve simple manga list 2022-04-21 20:15:59 +03:00
Koitharu
6bf4e0cf89 Use PageLoader for thumbnails 2022-04-20 12:57:07 +03:00
Koitharu
44d8d0f246 Fix search keyboard #150 2022-04-20 12:17:32 +03:00
Koitharu
e617e8d6d3 Add password toggle to ProtectActivity 2022-04-20 12:06:49 +03:00
Koitharu
1f411b7530 Cleanup temporary files 2022-04-20 11:50:55 +03:00
Koitharu
d64bd9d9d3 Estimate remeaning download time 2022-04-20 11:18:33 +03:00
Koitharu
f33dc8f797 Update feed ui 2022-04-20 09:25:46 +03:00
Koitharu
e63ae12c8c Delete local chapters in a service 2022-04-19 16:04:24 +03:00
Koitharu
cbd3d439cd Support multiple branches in saved manga 2022-04-19 14:35:02 +03:00
Koitharu
83eb0d9f23 Fix isLoading live data 2022-04-19 12:53:45 +03:00
Koitharu
3c739eed8e Fix empty chapters label 2022-04-19 12:43:51 +03:00
Koitharu
d77646adf1 Fix duplicate zip entry error 2022-04-19 11:32:42 +03:00
Koitharu
5b5e6cba57 Fix download error retry 2022-04-19 11:08:55 +03:00
Koitharu
8fc9b27840 Option to slowdown downloads and configure parallelism 2022-04-19 10:28:05 +03:00
Koitharu
fa536220eb Search and parallelism in LocalMangaRepository.getList 2022-04-19 09:25:12 +03:00
Koitharu
98f16774c4 Delete whole manga if all chapters are removed 2022-04-19 08:22:54 +03:00
Koitharu
ce8f57c3ca Disable update checking if not supported #147 2022-04-18 20:11:23 +03:00
Koitharu
be66106336 Removing selected chapters from local storage 2022-04-18 20:00:43 +03:00
Koitharu
16c8641a07 Fix concurrent downloading #146 2022-04-18 16:46:35 +03:00
Koitharu
d3e9ce874a Download manga to cbz directly 2022-04-18 16:42:37 +03:00
Koitharu
aaf9c6a0bf Update parsers 2022-04-18 09:26:48 +03:00
Koitharu
c2276eb2cb Fix cover size changes in details 2022-04-17 10:24:49 +03:00
Koitharu
5fbae1256b Fix branch detection #143 2022-04-17 09:58:52 +03:00
Koitharu
d61ba80bf6 Add additional checks to download task #50 2022-04-17 09:47:21 +03:00
Koitharu
74c9fa9488 Option to save manga from history and favourites lists 2022-04-17 09:15:16 +03:00
Koitharu
ce732ccca0 Merge branch 'weblate-kotatsu-strings' into devel 2022-04-16 10:05:37 +03:00
Sergio Varela
6b99e360e0 Added translation using Weblate (Basque)
Added translation using Weblate (Basque)

Co-authored-by: Sergio Varela <sergitroll9@gmail.com>
2022-04-16 09:46:16 +03:00
lowak
1c73d54a94 Translated using Weblate (Swedish)
Currently translated at 100.0% (274 of 274 strings)

Translated using Weblate (Swedish)

Currently translated at 41.6% (114 of 274 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (8 of 8 strings)

Added translation using Weblate (Swedish)

Added translation using Weblate (Swedish)

Co-authored-by: lowak <lowak@pm.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/sv/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sv/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-16 09:46:16 +03:00
Luiz-bro
36e21caf96 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (274 of 274 strings)

Co-authored-by: Luiz-bro <luiznneto1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-16 09:46:16 +03:00
Santiago José Gutiérrez Llanos
f7f9c53466 Translated using Weblate (Spanish)
Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (274 of 274 strings)

Co-authored-by: Santiago José Gutiérrez Llanos <gutierrezapata17@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-16 09:46:16 +03:00
Allan Nordhøy
3f2ee2a925 Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (8 of 8 strings)

Added translation using Weblate (Norwegian Bokmål)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/nb_NO/
Translation: Kotatsu/plurals
2022-04-16 09:46:16 +03:00
J. Lavoie
b1c069f62f Translated using Weblate (Italian)
Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (German)

Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (French)

Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Finnish)

Currently translated at 99.2% (272 of 274 strings)

Translated using Weblate (French)

Currently translated at 100.0% (274 of 274 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (274 of 274 strings)

Translated using Weblate (German)

Currently translated at 100.0% (274 of 274 strings)

Added translation using Weblate (Italian)

Added translation using Weblate (German)

Added translation using Weblate (French)

Translated using Weblate (Finnish)

Currently translated at 99.6% (270 of 271 strings)

Translated using Weblate (French)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (German)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Finnish)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (French)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (German)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Belarusian)

Currently translated at 98.8% (266 of 269 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/it/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-16 09:46:16 +03:00
Luiz-bro
22d48fce8f Added translation using Weblate (Portuguese (Brazil))
Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (268 of 269 strings)

Co-authored-by: Luiz-bro <luiznneto1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2022-04-16 09:46:16 +03:00
kuragehime
6b2666c701 Translated using Weblate (Japanese)
Currently translated at 100.0% (8 of 8 strings)

Added translation using Weblate (Japanese)

Translated using Weblate (Japanese)

Currently translated at 100.0% (274 of 274 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (269 of 269 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ja/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-16 09:46:16 +03:00
Oğuz Ersen
414f438762 Translated using Weblate (Turkish)
Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (274 of 274 strings)

Added translation using Weblate (Turkish)

Translated using Weblate (Turkish)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (269 of 269 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/tr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-16 09:46:16 +03:00
Aliaksiej Razumaŭ
54f60040b5 Translated using Weblate (Belarusian)
Currently translated at 100.0% (270 of 270 strings)

Co-authored-by: Aliaksiej Razumaŭ <belarusaed@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
2022-04-16 09:46:16 +03:00
Koitharu
b0515033da Increase version 2022-04-16 09:40:50 +03:00
Koitharu
d37eb07301 Fix startForeground for DownloadService 2022-04-16 09:19:10 +03:00
Sergio Varela
c2fa27712c Added translation using Weblate (Basque)
Added translation using Weblate (Basque)

Co-authored-by: Sergio Varela <sergitroll9@gmail.com>
2022-04-15 12:57:32 +02:00
lowak
10a2589c10 Translated using Weblate (Swedish)
Currently translated at 100.0% (274 of 274 strings)

Translated using Weblate (Swedish)

Currently translated at 41.6% (114 of 274 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (8 of 8 strings)

Added translation using Weblate (Swedish)

Added translation using Weblate (Swedish)

Co-authored-by: lowak <lowak@pm.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/sv/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sv/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-15 12:57:31 +02:00
Luiz-bro
05eb96e7c0 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (274 of 274 strings)

Co-authored-by: Luiz-bro <luiznneto1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-15 12:57:31 +02:00
Santiago José Gutiérrez Llanos
15a08ad6ae Translated using Weblate (Spanish)
Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (274 of 274 strings)

Co-authored-by: Santiago José Gutiérrez Llanos <gutierrezapata17@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-15 12:57:31 +02:00
Allan Nordhøy
3e437c2ecb Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (8 of 8 strings)

Added translation using Weblate (Norwegian Bokmål)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/nb_NO/
Translation: Kotatsu/plurals
2022-04-15 12:57:30 +02:00
J. Lavoie
fea667b87c Translated using Weblate (Italian)
Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (German)

Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (French)

Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Finnish)

Currently translated at 99.2% (272 of 274 strings)

Translated using Weblate (French)

Currently translated at 100.0% (274 of 274 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (274 of 274 strings)

Translated using Weblate (German)

Currently translated at 100.0% (274 of 274 strings)

Added translation using Weblate (Italian)

Added translation using Weblate (German)

Added translation using Weblate (French)

Translated using Weblate (Finnish)

Currently translated at 99.6% (270 of 271 strings)

Translated using Weblate (French)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (German)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Finnish)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (French)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (German)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (269 of 269 strings)

Translated using Weblate (Belarusian)

Currently translated at 98.8% (266 of 269 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/it/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-15 12:57:30 +02:00
Luiz-bro
93eaaac084 Added translation using Weblate (Portuguese (Brazil))
Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (268 of 269 strings)

Co-authored-by: Luiz-bro <luiznneto1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2022-04-15 12:57:29 +02:00
kuragehime
a60df582a2 Translated using Weblate (Japanese)
Currently translated at 100.0% (8 of 8 strings)

Added translation using Weblate (Japanese)

Translated using Weblate (Japanese)

Currently translated at 100.0% (274 of 274 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (269 of 269 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ja/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-15 12:57:29 +02:00
Oğuz Ersen
aec1d4e0d6 Translated using Weblate (Turkish)
Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (274 of 274 strings)

Added translation using Weblate (Turkish)

Translated using Weblate (Turkish)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (269 of 269 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/tr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-04-15 12:57:28 +02:00
Aliaksiej Razumaŭ
507f2e883c Translated using Weblate (Belarusian)
Currently translated at 100.0% (270 of 270 strings)

Co-authored-by: Aliaksiej Razumaŭ <belarusaed@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
2022-04-15 12:57:28 +02:00
Koitharu
5e82c75893 Cleanup extensions 2022-04-15 09:09:40 +03:00
Koitharu
9c9a389aa5 Rethrow CancellationException 2022-04-14 08:07:57 +03:00
Koitharu
1b3af70690 Add WindowInsetHolder view 2022-04-14 08:06:16 +03:00
Koitharu
2e17efe82b Update parsers 2022-04-11 18:43:52 +03:00
Koitharu
5bed854b9c Refactor entity mapping 2022-04-10 11:00:05 +03:00
Koitharu
7262b403f0 Hide reading fab if history is empty 2022-04-10 10:25:11 +03:00
Koitharu
a6fcbefc7b Update strings 2022-04-09 18:47:25 +03:00
Koitharu
7f9ea0efa0 Merge branch 'feature/multiselect' into devel 2022-04-09 18:39:25 +03:00
Koitharu
934861322e Migrate to ExtendedFloatingActionButton 2022-04-09 18:35:07 +03:00
Koitharu
e008fbab9b Merge branch 'devel' into feature/multiselect 2022-04-09 08:29:57 +03:00
Koitharu
2cd9ea19fd Update dependencies 2022-04-09 08:28:01 +03:00
Koitharu
699a249620 Merge branch 'documentation-update' of https://github.com/grrrrr/Kotatsu into devel 2022-04-09 07:35:16 +03:00
Koitharu
6c87d5b0bc Add check to avoid TransactionTooLargeException 2022-04-08 18:15:04 +03:00
Koitharu
c92bdae842 Add tags blacklist option for suggestions 2022-04-08 14:56:45 +03:00
Koitharu
6ca9608a80 Remove CurlLoggingInterceptor 2022-04-07 17:23:59 +03:00
Koitharu
8f9c0cbff1 Fix tags suggestion 2022-04-07 17:20:02 +03:00
Koitharu
cc6b114e4d Improve suggestions worker 2022-04-07 17:04:11 +03:00
grrrrr
3d5c2123d4 Update full_description.txt
- Remove HTML code so displaying on sites such as f-droid does not create a lot of wasted space
2022-04-06 19:13:43 +00:00
grrrrr
36b4e16b7c Update full_description.txt
- Remote HTML code so displaying on sites such as f-droid does not create a lot of wasted space
- add additional features taken from updated README.md
2022-04-06 19:12:15 +00:00
grrrrr
3ebd074e93 Update README.md
change feature "localized in" to "available in
2022-04-06 19:10:23 +00:00
grrrrr
e9b2b545a4 Update README.md
- Add additional features (password protection and localization) to list.
- Add details on how to contribute to translation
2022-04-06 19:05:33 +00:00
Koitharu
cca6d5fa04 Migrate to expedited jobs 2022-04-06 18:38:37 +03:00
Koitharu
36a7a3ebbc Fix DownloadService foreground notification #50 2022-04-06 17:24:10 +03:00
Koitharu
48ec9a1ea9 Merge branch 'feature/settings' into devel 2022-04-06 17:23:08 +03:00
Koitharu
76a9a0d1ab ActionMode selection in manga lists 2022-04-06 17:21:09 +03:00
Koitharu
f2175b40c0 Improve android AutoBackup support 2022-04-05 07:40:00 +03:00
Koitharu
85b992ca32 Remove SimpleSettingsActivity 2022-04-04 10:02:20 +03:00
Koitharu
41fb351fe0 Use master-detals pattern for settings 2022-04-04 09:41:57 +03:00
267 changed files with 4976 additions and 2634 deletions

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: ⚠️ Source issue
url: https://github.com/nv95/kotatsu-parsers/issues/new
about: Issues and requests for sources should be opened in the kotatsu-parsers repository instead

93
.github/ISSUE_TEMPLATE/report_issue.yml vendored Normal file
View File

@@ -0,0 +1,93 @@
name: 🐞 Issue report
description: Report an issue in Kotatsu
labels: [bug]
body:
- type: textarea
id: reproduce-steps
attributes:
label: Steps to reproduce
description: Provide an example of the issue.
placeholder: |
Example:
1. First step
2. Second step
3. Issue here
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: Explain what you should expect to happen.
placeholder: |
Example:
"This should happen..."
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: Actual behavior
description: Explain what actually happens.
placeholder: |
Example:
"This happened instead..."
validations:
required: true
- type: input
id: kotatsu-version
attributes:
label: Kotatsu version
description: You can find your Kotatsu version in **Settings → About**.
placeholder: |
Example: "3.2"
validations:
required: true
- type: input
id: android-version
attributes:
label: Android version
description: You can find this somewhere in your Android settings.
placeholder: |
Example: "Android 12"
validations:
required: true
- type: input
id: device
attributes:
label: Device
description: List your device and model.
placeholder: |
Example: "LG Nexus 5X"
validations:
required: true
- type: textarea
id: other-details
attributes:
label: Other details
placeholder: |
Additional details and attachments.
- type: checkboxes
id: acknowledgements
attributes:
label: Acknowledgements
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
options:
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true
- label: I have written a short but informative title.
required: true
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
required: true
- label: I have updated the app to version **[3.2](https://github.com/nv95/Kotatsu/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true

View File

@@ -0,0 +1,39 @@
name: ⭐ Feature request
description: Suggest a feature to improve Kotatsu
labels: [feature request]
body:
- type: textarea
id: feature-description
attributes:
label: Describe your suggested feature
description: How can Kotatsu be improved?
placeholder: |
Example:
"It should work like this..."
validations:
required: true
- type: textarea
id: other-details
attributes:
label: Other details
placeholder: |
Additional details and attachments.
- type: checkboxes
id: acknowledgements
attributes:
label: Acknowledgements
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
options:
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true
- label: I have written a short but informative title.
required: true
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
required: true
- label: I have updated the app to version **[3.2](https://github.com/nv95/Kotatsu/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/kotlinScripting.xml
.DS_Store
/build
/captures

2
.idea/gradle.xml generated
View File

@@ -7,7 +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="gradleJvm" value="Android Studio default JDK" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />

View File

@@ -1,8 +1,10 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="BooleanLiteralArgument" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="BooleanLiteralArgument" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="Destructure" enabled="true" level="INFO" enabled_by_default="true" />
<inspection_tool class="KeySetIterationMayUseEntrySet" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ReplaceCollectionCountWithSize" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="TrailingComma" enabled="true" level="INFORMATION" enabled_by_default="true" />
<inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>

View File

@@ -25,6 +25,8 @@ Download APK from Github Releases:
* Tablet-optimized material design UI
* Standard and Webtoon-optimized reader
* Notifications about new chapters with updates feed
* Available in multiple languages
* Password protect access to the app
### Screenshots
@@ -35,6 +37,14 @@ Download APK from Github Releases:
| ![](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/1.png) | ![](https://github.com/nv95/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/2.png) |
|-----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
### Localization
<a href="https://hosted.weblate.org/engage/kotatsu/">
<img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status" />
</a>
Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages, please head over to the Weblate <a href="https://hosted.weblate.org/engage/kotatsu/">project page</a>
### License
[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html)

View File

@@ -14,8 +14,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 32
versionCode 400
versionName '3.0'
versionCode 404
versionName '3.2'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -65,12 +65,12 @@ android {
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation('com.github.nv95:kotatsu-parsers:3ea7e92e64') {
implementation('com.github.nv95:kotatsu-parsers:72cd6fbadf') {
exclude group: 'org.json', module: 'json'
}
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.activity:activity-ktx:1.4.0'
@@ -86,7 +86,7 @@ dependencies {
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'com.google.android.material:material:1.6.0-beta01'
implementation 'com.google.android.material:material:1.6.0-rc01'
//noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1'
@@ -100,7 +100,7 @@ dependencies {
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'io.insert-koin:koin-android:3.1.5'
implementation 'io.insert-koin:koin-android:3.1.6'
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.4'
@@ -108,7 +108,7 @@ dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1'
testImplementation 'io.insert-koin:koin-test-junit4:3.1.5'
androidTestImplementation 'androidx.test:runner:1.4.0'

View File

@@ -0,0 +1,40 @@
package org.koitharu.kotatsu.core.parser
import java.util.*
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
/**
* This parser is just for parser development, it should not be used in releases
*/
class DummyParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("", null)
override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)
override suspend fun getDetails(manga: Manga): Manga {
TODO("Not yet implemented")
}
override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder?
): List<Manga> {
TODO("Not yet implemented")
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
TODO("Not yet implemented")
}
override suspend fun getTags(): Set<MangaTag> {
TODO("Not yet implemented")
}
}

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.newParser
fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaParser {
return if (source == MangaSource.DUMMY) {
DummyParser(loaderContext)
} else {
source.newParser(loaderContext)
}
}

View File

@@ -8,23 +8,23 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name="org.koitharu.kotatsu.KotatsuApp"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_descriptor"
android:backupAgent="org.koitharu.kotatsu.settings.backup.AppBackupAgent"
android:dataExtractionRules="@xml/backup_rules"
android:fullBackupContent="@xml/backup_content"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Kotatsu"
android:networkSecurityConfig="@xml/network_security_config"
tools:ignore="UnusedAttribute">
<activity
android:name="org.koitharu.kotatsu.main.ui.MainActivity"
android:exported="true">
@@ -58,15 +58,6 @@
<activity
android:name="org.koitharu.kotatsu.settings.SettingsActivity"
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"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
@@ -104,12 +95,14 @@
android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
android:launchMode="singleTop"
android:label="@string/downloads" />
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity"/>
<service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
android:foregroundServiceType="dataSync" />
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
<service
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" />

View File

@@ -2,22 +2,19 @@ package org.koitharu.kotatsu.base.domain
import androidx.room.withTransaction
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.mapToSet
class MangaDataRepository(private val db: MangaDatabase) {
suspend fun savePreferences(manga: Manga, mode: ReaderMode) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
val tags = manga.tags.toEntities()
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags)
db.mangaDao.upsert(manga.toEntity(), tags)
db.preferencesDao.upsert(
MangaPrefsEntity(
mangaId = manga.id,
@@ -37,21 +34,19 @@ class MangaDataRepository(private val db: MangaDatabase) {
suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
intent.manga != null -> intent.manga
intent.mangaId != 0L -> db.mangaDao.find(intent.mangaId)?.toManga()
intent.mangaId != 0L -> findMangaById(intent.mangaId)
else -> null // TODO resolve uri
}
suspend fun storeManga(manga: Manga) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
val tags = manga.tags.toEntities()
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags)
db.mangaDao.upsert(manga.toEntity(), tags)
}
}
suspend fun findTags(source: MangaSource): Set<MangaTag> {
return db.tagsDao.findTags(source.name).mapToSet {
it.toMangaTag()
}
return db.tagsDao.findTags(source.name).toMangaTags()
}
}

View File

@@ -14,7 +14,7 @@ import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.medianOrNull
import org.koitharu.kotatsu.parsers.util.medianOrNull
import java.io.InputStream
import java.util.zip.ZipFile

View File

@@ -7,6 +7,7 @@ import android.view.KeyEvent
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.ActionBarContextView
@@ -20,11 +21,13 @@ import androidx.viewbinding.ViewBinding
import org.koin.android.ext.android.get
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(),
WindowInsetsDelegate.WindowInsetsListener {
protected lateinit var binding: B
@@ -36,6 +39,8 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
@Suppress("LeakingThis")
protected val insetsDelegate = WindowInsetsDelegate(this)
val actionModeDelegate = ActionModeDelegate()
override fun onCreate(savedInstanceState: Bundle?) {
val settings = get<AppSettings>()
when {
@@ -90,8 +95,10 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
return isNight && get<AppSettings>().isAmoledTheme
}
@CallSuper
override fun onSupportActionModeStarted(mode: ActionMode) {
super.onSupportActionModeStarted(mode)
actionModeDelegate.onSupportActionModeStarted(mode)
val insets = ViewCompat.getRootWindowInsets(binding.root)
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
val view = findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar)
@@ -100,6 +107,12 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
}
}
@CallSuper
override fun onSupportActionModeFinished(mode: ActionMode) {
super.onSupportActionModeFinished(mode)
actionModeDelegate.onSupportActionModeFinished(mode)
}
override fun onBackPressed() {
if ( // https://issuetracker.google.com/issues/139738913
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&
@@ -111,4 +124,4 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
super.onBackPressed()
}
}
}
}

View File

@@ -6,10 +6,12 @@ import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
abstract class BaseFragment<B : ViewBinding> : Fragment(),
abstract class BaseFragment<B : ViewBinding> :
Fragment(),
WindowInsetsDelegate.WindowInsetsListener {
private var viewBinding: B? = null
@@ -23,6 +25,9 @@ abstract class BaseFragment<B : ViewBinding> : Fragment(),
@Suppress("LeakingThis")
protected val insetsDelegate = WindowInsetsDelegate(this)
protected val actionModeDelegate: ActionModeDelegate
get() = (requireActivity() as BaseActivity<*>).actionModeDelegate
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -47,4 +52,4 @@ abstract class BaseFragment<B : ViewBinding> : Fragment(),
protected fun bindingOrNull() = viewBinding
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
}
}

View File

@@ -6,14 +6,18 @@ import androidx.annotation.CallSuper
import androidx.annotation.StringRes
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.SettingsHeadersFragment
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : PreferenceFragmentCompat(),
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(),
WindowInsetsDelegate.WindowInsetsListener,
RecyclerViewOwner {
@@ -39,16 +43,20 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : Pre
override fun onResume() {
super.onResume()
if (titleId != 0) {
activity?.setTitle(titleId)
setTitle(getString(titleId))
}
}
@CallSuper
override fun onWindowInsetsChanged(insets: Insets) {
listView.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom
)
}
}
@Suppress("UsePropertyAccessSyntax")
protected fun setTitle(title: CharSequence) {
(parentFragment as? SettingsHeadersFragment)?.setTitle(title)
?: activity?.setTitle(title)
}
}

View File

@@ -1,18 +1,18 @@
package org.koitharu.kotatsu.base.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.*
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData
import org.koitharu.kotatsu.utils.SingleLiveEvent
abstract class BaseViewModel : ViewModel() {
val onError = SingleLiveEvent<Throwable>()
val isLoading = MutableLiveData(false)
val isLoading = CountedBooleanLiveData()
protected fun launchJob(
context: CoroutineContext = EmptyCoroutineContext,

View File

@@ -0,0 +1,37 @@
package org.koitharu.kotatsu.base.ui
import android.app.Service
import android.content.Intent
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
abstract class CoroutineIntentService : BaseService() {
private val mutex = Mutex()
protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
launchCoroutine(intent, startId)
return Service.START_REDELIVER_INTENT
}
private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch {
mutex.withLock {
try {
withContext(dispatcher) {
processIntent(intent)
}
} finally {
stopSelf(startId)
}
}
}
protected abstract suspend fun processIntent(intent: Intent?)
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.base.ui.dialog
import android.content.Context
import android.content.DialogInterface
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
@@ -12,7 +13,6 @@ import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemStorageBinding
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.utils.ext.inflate
import java.io.File
class StorageSelectDialog private constructor(private val delegate: AlertDialog) :
@@ -66,7 +66,7 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
val volumes = getAvailableVolumes(storageManager)
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: parent.inflate(R.layout.item_storage)
val view = convertView ?: LayoutInflater.from(parent.context).inflate(R.layout.item_storage, parent, false)
val binding = (view.tag as? ItemStorageBinding) ?: ItemStorageBinding.bind(view).also {
view.tag = it
}

View File

@@ -0,0 +1,111 @@
package org.koitharu.kotatsu.base.ui.list.decor
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.RectF
import android.view.View
import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID
abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
private val bounds = Rect()
private val boundsF = RectF()
private val selection = HashSet<Long>()
protected var hasBackground: Boolean = true
protected var hasForeground: Boolean = false
protected var isIncludeDecorAndMargins: Boolean = true
val checkedItemsCount: Int
get() = selection.size
val checkedItemsIds: Set<Long>
get() = selection
fun toggleItemChecked(id: Long) {
if (!selection.remove(id)) {
selection.add(id)
}
}
fun setItemIsChecked(id: Long, isChecked: Boolean) {
if (isChecked) {
selection.add(id)
} else {
selection.remove(id)
}
}
fun checkAll(ids: Collection<Long>) {
selection.addAll(ids)
}
fun clearSelection() {
selection.clear()
}
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
if (hasBackground) {
doDraw(canvas, parent, state, false)
} else {
super.onDraw(canvas, parent, state)
}
}
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
if (hasForeground) {
doDraw(canvas, parent, state, true)
} else {
super.onDrawOver(canvas, parent, state)
}
}
private fun doDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State, isOver: Boolean) {
val checkpoint = canvas.save()
if (parent.clipToPadding) {
canvas.clipRect(
parent.paddingLeft, parent.paddingTop, parent.width - parent.paddingRight,
parent.height - parent.paddingBottom
)
}
for (child in parent.children) {
val itemId = getItemId(parent, child)
if (itemId != NO_ID && itemId in selection) {
if (isIncludeDecorAndMargins) {
parent.getDecoratedBoundsWithMargins(child, bounds)
} else {
bounds.set(child.left, child.top, child.right, child.bottom)
}
boundsF.set(bounds)
boundsF.offset(child.translationX, child.translationY)
if (isOver) {
onDrawForeground(canvas, parent, child, boundsF, state)
} else {
onDrawBackground(canvas, parent, child, boundsF, state)
}
}
}
canvas.restoreToCount(checkpoint)
}
protected open fun getItemId(parent: RecyclerView, child: View) = parent.getChildItemId(child)
protected open fun onDrawBackground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) = Unit
protected open fun onDrawForeground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) = Unit
}

View File

@@ -0,0 +1,35 @@
package org.koitharu.kotatsu.base.ui.list.decor
import android.graphics.Rect
import android.util.SparseIntArray
import android.view.View
import androidx.core.util.getOrDefault
import androidx.core.util.set
import androidx.recyclerview.widget.RecyclerView
class TypedSpacingItemDecoration(
vararg spacingMapping: Pair<Int, Int>,
private val fallbackSpacing: Int = 0,
) : RecyclerView.ItemDecoration() {
private val mapping = SparseIntArray(spacingMapping.size)
init {
spacingMapping.forEach { (k, v) -> mapping[k] = v }
}
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val itemType = parent.getChildViewHolder(view)?.itemViewType
val spacing = if (itemType == null) {
fallbackSpacing
} else {
mapping.getOrDefault(itemType, fallbackSpacing)
}
outRect.set(spacing, spacing, spacing, spacing)
}
}

View File

@@ -0,0 +1,50 @@
package org.koitharu.kotatsu.base.ui.util
import androidx.appcompat.view.ActionMode
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
class ActionModeDelegate {
private var activeActionMode: ActionMode? = null
private var listeners: MutableList<ActionModeListener>? = null
val isActionModeStarted: Boolean
get() = activeActionMode != null
fun onSupportActionModeStarted(mode: ActionMode) {
activeActionMode = mode
listeners?.forEach { it.onActionModeStarted(mode) }
}
fun onSupportActionModeFinished(mode: ActionMode) {
activeActionMode = null
listeners?.forEach { it.onActionModeFinished(mode) }
}
fun addListener(listener: ActionModeListener) {
if (listeners == null) {
listeners = ArrayList()
}
checkNotNull(listeners).add(listener)
}
fun removeListener(listener: ActionModeListener) {
listeners?.remove(listener)
}
fun addListener(listener: ActionModeListener, owner: LifecycleOwner) {
addListener(listener)
owner.lifecycle.addObserver(ListenerLifecycleObserver(listener))
}
private inner class ListenerLifecycleObserver(
private val listener: ActionModeListener,
) : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
removeListener(listener)
}
}
}

View File

@@ -0,0 +1,10 @@
package org.koitharu.kotatsu.base.ui.util
import androidx.appcompat.view.ActionMode
interface ActionModeListener {
fun onActionModeStarted(mode: ActionMode)
fun onActionModeFinished(mode: ActionMode)
}

View File

@@ -0,0 +1,20 @@
package org.koitharu.kotatsu.base.ui.util
import androidx.lifecycle.MutableLiveData
class CountedBooleanLiveData : MutableLiveData<Boolean>(false) {
private var counter = 0
override fun setValue(value: Boolean) {
if (value) {
counter++
} else {
counter--
}
val newValue = counter > 0
if (newValue != this.value) {
super.setValue(newValue)
}
}
}

View File

@@ -0,0 +1,48 @@
package org.koitharu.kotatsu.base.ui.util
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior
import androidx.core.view.ViewCompat
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
class ShrinkOnScrollBehavior : Behavior<ExtendedFloatingActionButton> {
@Suppress("unused") constructor() : super()
@Suppress("unused") constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
override fun onStartNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: ExtendedFloatingActionButton,
directTargetChild: View,
target: View,
axes: Int,
type: Int
): Boolean {
return axes == ViewCompat.SCROLL_AXIS_VERTICAL
}
override fun onNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: ExtendedFloatingActionButton,
target: View,
dxConsumed: Int,
dyConsumed: Int,
dxUnconsumed: Int,
dyUnconsumed: Int,
type: Int,
consumed: IntArray
) {
if (dyConsumed > 0) {
if (child.isExtended) {
child.shrink()
}
} else if (dyConsumed < 0) {
if (!child.isExtended) {
child.extend()
}
}
}
}

View File

@@ -13,12 +13,12 @@ import android.graphics.drawable.shapes.RectShape
import android.util.AttributeSet
import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatCheckedTextView
import androidx.core.content.res.use
import androidx.core.content.withStyledAttributes
import com.google.android.material.ripple.RippleUtils
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.getThemeColorStateList
@SuppressLint("RestrictedApi")
class ListItemTextView @JvmOverloads constructor(
@@ -119,8 +119,7 @@ class ListItemTextView @JvmOverloads constructor(
}
private fun getRippleColorFallback(context: Context): ColorStateList {
return context.obtainStyledAttributes(intArrayOf(android.R.attr.colorControlHighlight)).use {
it.getColorStateList(0)
} ?: ColorStateList.valueOf(Color.TRANSPARENT)
return context.getThemeColorStateList(android.R.attr.colorControlHighlight)
?: ColorStateList.valueOf(Color.TRANSPARENT)
}
}

View File

@@ -0,0 +1,72 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
import android.view.View
import android.view.WindowInsets
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.annotation.AttrRes
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.WindowInsetsCompat
class WindowInsetHolder @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
private var desiredHeight = 0
private var desiredWidth = 0
@SuppressLint("RtlHardcoded")
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
val barsInsets = WindowInsetsCompat.toWindowInsetsCompat(insets, this)
.getInsets(WindowInsetsCompat.Type.systemBars())
val gravity = getLayoutGravity()
val newWidth = when (gravity and Gravity.HORIZONTAL_GRAVITY_MASK) {
Gravity.LEFT -> barsInsets.left
Gravity.RIGHT -> barsInsets.right
else -> 0
}
val newHeight = when (gravity and Gravity.VERTICAL_GRAVITY_MASK) {
Gravity.TOP -> barsInsets.top
Gravity.BOTTOM -> barsInsets.bottom
else -> 0
}
if (newWidth != desiredWidth || newHeight != desiredHeight) {
desiredWidth = newWidth
desiredHeight = newHeight
requestLayout()
}
return super.dispatchApplyWindowInsets(insets)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
super.onMeasure(
if (desiredWidth == 0 || widthMode == MeasureSpec.EXACTLY) {
widthMeasureSpec
} else {
MeasureSpec.makeMeasureSpec(desiredWidth, widthMode)
},
if (desiredHeight == 0 || heightMode == MeasureSpec.EXACTLY) {
heightMeasureSpec
} else {
MeasureSpec.makeMeasureSpec(desiredHeight, heightMode)
},
)
}
private fun getLayoutGravity(): Int {
return when (val lp = layoutParams) {
is FrameLayout.LayoutParams -> lp.gravity
is LinearLayout.LayoutParams -> lp.gravity
is CoordinatorLayout.LayoutParams -> lp.gravity
else -> Gravity.NO_GRAVITY
}
}
}

View File

@@ -11,10 +11,11 @@ import android.view.MenuItem
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import com.google.android.material.R as materialR
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.network.UserAgentInterceptor
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import com.google.android.material.R as materialR
@SuppressLint("SetJavaScriptEnabled")
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
@@ -28,6 +29,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
}
with(binding.webView.settings) {
javaScriptEnabled = true
userAgentString = UserAgentInterceptor.userAgent
}
binding.webView.webViewClient = BrowserClient(this)
binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar)

View File

@@ -2,15 +2,11 @@ package org.koitharu.kotatsu.browser
import android.graphics.Bitmap
import android.webkit.WebView
import okhttp3.OkHttpClient
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koitharu.kotatsu.core.network.WebViewClientCompat
class BrowserClient(private val callback: BrowserCallback) : WebViewClientCompat(), KoinComponent {
private val okHttp by inject<OkHttpClient>(mode = LazyThreadSafetyMode.SYNCHRONIZED)
override fun onPageFinished(webView: WebView, url: String) {
super.onPageFinished(webView, url)
callback.onLoadingStateChanged(isLoading = false)

View File

@@ -1,51 +0,0 @@
package org.koitharu.kotatsu.core.backup
import android.content.Context
import java.io.File
import java.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.MutableZipFile
import org.koitharu.kotatsu.utils.ext.format
class BackupArchive(file: File) : MutableZipFile(file) {
init {
if (!dir.exists()) {
dir.mkdirs()
}
}
suspend fun put(entry: BackupEntry) {
put(entry.name, entry.data.toString(2))
}
suspend fun getEntry(name: String): BackupEntry {
val json = withContext(Dispatchers.Default) {
JSONArray(getContent(name))
}
return BackupEntry(name, json)
}
companion object {
private const val DIR_BACKUPS = "backups"
suspend fun createNew(context: Context): BackupArchive = runInterruptible(Dispatchers.IO) {
val dir = context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
}
dir.mkdirs()
val filename = buildString {
append(context.getString(R.string.app_name).lowercase(Locale.ROOT))
append('_')
append(Date().format("ddMMyyyy"))
append(".bak")
}
BackupArchive(File(dir, filename))
}
}
}

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.core.backup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.Closeable
import org.json.JSONArray
import java.io.File
import java.util.zip.ZipFile
class BackupZipInput(val file: File) : Closeable {
private val zipFile = ZipFile(file)
suspend fun getEntry(name: String): BackupEntry = runInterruptible(Dispatchers.IO) {
val entry = zipFile.getEntry(name)
val json = zipFile.getInputStream(entry).use {
JSONArray(it.bufferedReader().readText())
}
BackupEntry(name, json)
}
override fun close() {
zipFile.close()
}
}

View File

@@ -0,0 +1,45 @@
package org.koitharu.kotatsu.core.backup
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.Closeable
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.zip.ZipOutput
import org.koitharu.kotatsu.utils.ext.format
import java.io.File
import java.util.*
import java.util.zip.Deflater
class BackupZipOutput(val file: File) : Closeable {
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
suspend fun put(entry: BackupEntry) {
output.put(entry.name, entry.data.toString(2))
}
suspend fun finish() {
output.finish()
}
override fun close() {
output.close()
}
}
private const val DIR_BACKUPS = "backups"
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
val dir = context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
}
dir.mkdirs()
val filename = buildString {
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
append('_')
append(Date().format("ddMMyyyy"))
append(".bk.zip")
}
BackupZipOutput(File(dir, filename))
}

View File

@@ -1,28 +1,9 @@
package org.koitharu.kotatsu.core.db
import androidx.room.Room
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
import org.koitharu.kotatsu.core.db.migrations.*
val databaseModule
get() = module {
single {
Room.databaseBuilder(
androidContext(),
MangaDatabase::class.java,
"kotatsu-db"
).addMigrations(
Migration1To2(),
Migration2To3(),
Migration3To4(),
Migration4To5(),
Migration5To6(),
Migration6To7(),
Migration7To8(),
Migration8To9(),
).addCallback(
DatabasePrePopulateCallback(androidContext().resources)
).build()
}
single { MangaDatabase.create(androidContext()) }
}

View File

@@ -1,9 +1,12 @@
package org.koitharu.kotatsu.core.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import org.koitharu.kotatsu.core.db.dao.*
import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.db.migrations.*
import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
@@ -40,4 +43,24 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val trackLogsDao: TrackLogsDao
abstract val suggestionDao: SuggestionDao
companion object {
fun create(context: Context): MangaDatabase = Room.databaseBuilder(
context,
MangaDatabase::class.java,
"kotatsu-db"
).addMigrations(
Migration1To2(),
Migration2To3(),
Migration3To4(),
Migration4To5(),
Migration5To6(),
Migration6To7(),
Migration7To8(),
Migration8To9(),
).addCallback(
DatabasePrePopulateCallback(context.resources)
).build()
}
}

View File

@@ -12,7 +12,7 @@ abstract class TagsDao {
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
GROUP BY manga_tags.tag_id
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit"""
)
@@ -22,7 +22,7 @@ abstract class TagsDao {
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE tags.source = :source
GROUP BY manga_tags.tag_id
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit"""
)
@@ -32,7 +32,7 @@ abstract class TagsDao {
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE tags.source = :source AND title LIKE :query
GROUP BY manga_tags.tag_id
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit"""
)
@@ -42,7 +42,7 @@ abstract class TagsDao {
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE title LIKE :query
GROUP BY manga_tags.tag_id
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit"""
)

View File

@@ -0,0 +1,76 @@
package org.koitharu.kotatsu.core.db.entity
import java.util.*
import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
// Entity to model
fun TagEntity.toMangaTag() = MangaTag(
key = this.key,
title = this.title.toTitleCase(),
source = MangaSource.valueOf(this.source),
)
fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
id = this.id,
title = this.title,
altTitle = this.altTitle,
state = this.state?.let { MangaState.valueOf(it) },
rating = this.rating,
isNsfw = this.isNsfw,
url = this.url,
publicUrl = this.publicUrl,
coverUrl = this.coverUrl,
largeCoverUrl = this.largeCoverUrl,
author = this.author,
source = MangaSource.valueOf(this.source),
tags = tags
)
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
fun TrackLogWithManga.toTrackingLogItem() = TrackingLogItem(
id = trackLog.id,
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
manga = manga.toManga(tags.toMangaTags()),
createdAt = Date(trackLog.createdAt)
)
// Model to entity
fun Manga.toEntity() = MangaEntity(
id = id,
url = url,
publicUrl = publicUrl,
source = source.name,
largeCoverUrl = largeCoverUrl,
coverUrl = coverUrl,
altTitle = altTitle,
rating = rating,
isNsfw = isNsfw,
state = state?.name,
title = title,
author = author,
)
fun MangaTag.toEntity() = TagEntity(
title = title,
key = key,
source = source.name,
id = "${key}_${source.name}".longHashCode()
)
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
// Other
@Suppress("FunctionName")
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
SortOrder.valueOf(name)
}.getOrDefault(fallback)

View File

@@ -3,10 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
@Entity(tableName = "manga")
class MangaEntity(
@@ -16,46 +12,11 @@ class MangaEntity(
@ColumnInfo(name = "alt_title") val altTitle: String?,
@ColumnInfo(name = "url") val url: String,
@ColumnInfo(name = "public_url") val publicUrl: String,
@ColumnInfo(name = "rating") val rating: Float, //normalized value [0..1] or -1
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
@ColumnInfo(name = "nsfw") val isNsfw: Boolean,
@ColumnInfo(name = "cover_url") val coverUrl: String,
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
@ColumnInfo(name = "state") val state: String?,
@ColumnInfo(name = "author") val author: String?,
@ColumnInfo(name = "source") val source: String
) {
fun toManga(tags: Set<MangaTag> = emptySet()) = Manga(
id = this.id,
title = this.title,
altTitle = this.altTitle,
state = this.state?.let { MangaState.valueOf(it) },
rating = this.rating,
isNsfw = this.isNsfw,
url = this.url,
publicUrl = this.publicUrl,
coverUrl = this.coverUrl,
largeCoverUrl = this.largeCoverUrl,
author = this.author,
source = MangaSource.valueOf(this.source),
tags = tags
)
companion object {
fun from(manga: Manga) = MangaEntity(
id = manga.id,
url = manga.url,
publicUrl = manga.publicUrl,
source = manga.source.name,
largeCoverUrl = manga.largeCoverUrl,
coverUrl = manga.coverUrl,
altTitle = manga.altTitle,
rating = manga.rating,
isNsfw = manga.isNsfw,
state = manga.state?.name,
title = manga.title,
author = manga.author
)
}
}
)

View File

@@ -6,13 +6,15 @@ import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(
tableName = "preferences", foreignKeys = [
tableName = "preferences",
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
)]
)
]
)
class MangaPrefsEntity(
@PrimaryKey(autoGenerate = false)

View File

@@ -5,7 +5,8 @@ import androidx.room.Entity
import androidx.room.ForeignKey
@Entity(
tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"], foreignKeys = [
tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"],
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
import org.koitharu.kotatsu.utils.ext.mapToSet
class MangaWithTags(
@Embedded val manga: MangaEntity,
@@ -12,10 +11,5 @@ class MangaWithTags(
entityColumn = "tag_id",
associateBy = Junction(MangaTagsEntity::class)
)
val tags: List<TagEntity>
) {
fun toManga() = manga.toManga(tags.mapToSet {
it.toMangaTag()
})
}
val tags: List<TagEntity>,
)

View File

@@ -3,10 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.parsers.util.toTitleCase
@Entity(tableName = "tags")
class TagEntity(
@@ -15,21 +11,4 @@ class TagEntity(
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "key") val key: String,
@ColumnInfo(name = "source") val source: String
) {
fun toMangaTag() = MangaTag(
key = this.key,
title = this.title.toTitleCase(),
source = MangaSource.valueOf(this.source)
)
companion object {
fun fromMangaTag(tag: MangaTag) = TagEntity(
title = tag.title,
key = tag.key,
source = tag.source.name,
id = "${tag.key}_${tag.source.name}".longHashCode()
)
}
}
)

View File

@@ -6,7 +6,8 @@ import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(
tableName = "tracks", foreignKeys = [
tableName = "tracks",
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],

View File

@@ -6,7 +6,8 @@ import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@Entity(
tableName = "track_logs", foreignKeys = [
tableName = "track_logs",
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
@@ -20,5 +21,5 @@ class TrackLogEntity(
@ColumnInfo(name = "id") val id: Long = 0L,
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "chapters") val chapters: String,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis()
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
)

View File

@@ -3,9 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.utils.ext.mapToSet
import java.util.*
class TrackLogWithManga(
@Embedded val trackLog: TrackLogEntity,
@@ -19,13 +16,5 @@ class TrackLogWithManga(
entityColumn = "tag_id",
associateBy = Junction(MangaTagsEntity::class)
)
val tags: List<TagEntity>
) {
fun toTrackingLogItem() = TrackingLogItem(
id = trackLog.id,
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
manga = manga.toManga(tags.mapToSet { x -> x.toMangaTag() }),
createdAt = Date(trackLog.createdAt)
)
}
val tags: List<TagEntity>,
)

View File

@@ -1,25 +1,6 @@
package org.koitharu.kotatsu.core.model
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet
fun Manga.withoutChapters() = if (chapters.isNullOrEmpty()) {
this
} else {
Manga(
id = id,
title = title,
altTitle = altTitle,
url = url,
publicUrl = publicUrl,
rating = rating,
isNsfw = isNsfw,
coverUrl = coverUrl,
tags = tags,
state = state,
author = author,
largeCoverUrl = largeCoverUrl,
description = description,
chapters = null,
source = source,
)
}
fun Collection<Manga>.ids() = mapToSet { it.id }

View File

@@ -4,7 +4,7 @@ import android.os.Parcel
import androidx.core.os.ParcelCompat
import org.koitharu.kotatsu.parsers.model.*
fun Manga.writeToParcel(out: Parcel, flags: Int) {
fun Manga.writeToParcel(out: Parcel, flags: Int, withChapters: Boolean) {
out.writeLong(id)
out.writeString(title)
out.writeString(altTitle)
@@ -18,7 +18,11 @@ fun Manga.writeToParcel(out: Parcel, flags: Int) {
out.writeParcelable(ParcelableMangaTags(tags), flags)
out.writeSerializable(state)
out.writeString(author)
out.writeParcelable(chapters?.let(::ParcelableMangaChapters), flags)
if (withChapters) {
out.writeParcelable(chapters?.let(::ParcelableMangaChapters), flags)
} else {
out.writeString(null)
}
out.writeSerializable(source)
}

View File

@@ -2,24 +2,39 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import android.util.Log
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.parsers.model.Manga
// Limits to avoid TransactionTooLargeException
private const val MAX_SAFE_SIZE = 1024 * 512 // Assume that 512 kb is safe parcel size
private const val MAX_SAFE_CHAPTERS_COUNT = 40 // this is 100% safe
class ParcelableManga(
val manga: Manga,
private val withChapters: Boolean,
) : Parcelable {
constructor(parcel: Parcel) : this(parcel.readManga())
init {
if (BuildConfig.DEBUG && manga.chapters != null) {
Log.w("ParcelableManga", "Passing manga with chapters as Parcelable is dangerous!")
}
}
constructor(parcel: Parcel) : this(parcel.readManga(), true)
override fun writeToParcel(parcel: Parcel, flags: Int) {
manga.writeToParcel(parcel, flags)
val chapters = manga.chapters
if (!withChapters || chapters == null) {
manga.writeToParcel(parcel, flags, withChapters = false)
return
}
if (chapters.size <= MAX_SAFE_CHAPTERS_COUNT) {
// fast path
manga.writeToParcel(parcel, flags, withChapters = true)
return
}
val tempParcel = Parcel.obtain()
manga.writeToParcel(tempParcel, flags, withChapters = true)
val size = tempParcel.dataSize()
if (size < MAX_SAFE_SIZE) {
parcel.appendFrom(tempParcel, 0, size)
} else {
manga.writeToParcel(parcel, flags, withChapters = false)
}
tempParcel.recycle()
}
override fun describeContents(): Int {

View File

@@ -1,56 +0,0 @@
package org.koitharu.kotatsu.core.network
import android.util.Log
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import okio.Buffer
import java.io.IOException
import java.nio.charset.StandardCharsets
private const val TAG = "CURL"
class CurlLoggingInterceptor(
private val extraCurlOptions: String? = null,
) : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val request: Request = chain.request()
var compressed = false
val curlCmd = StringBuilder("curl")
if (extraCurlOptions != null) {
curlCmd.append(" ").append(extraCurlOptions)
}
curlCmd.append(" -X ").append(request.method)
val headers = request.headers
var i = 0
val count = headers.size
while (i < count) {
val name = headers.name(i)
val value = headers.value(i)
if ("Accept-Encoding".equals(name, ignoreCase = true) && "gzip".equals(value,
ignoreCase = true)
) {
compressed = true
}
curlCmd.append(" -H " + "\"").append(name).append(": ").append(value).append("\"")
i++
}
val requestBody = request.body
if (requestBody != null) {
val buffer = Buffer()
requestBody.writeTo(buffer)
val contentType = requestBody.contentType()
val charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
curlCmd.append(" --data $'")
.append(buffer.readString(charset).replace("\n", "\\n"))
.append("'")
}
curlCmd.append(if (compressed) " --compressed " else " ").append(request.url)
Log.d(TAG, "╭--- cURL (" + request.url + ")")
Log.d(TAG, curlCmd.toString())
Log.d(TAG, "╰--- (copy and paste the above line to a terminal)")
return chain.proceed(request)
}
}

View File

@@ -5,7 +5,6 @@ import okhttp3.CookieJar
import okhttp3.OkHttpClient
import org.koin.dsl.bind
import org.koin.dsl.module
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.parsers.MangaLoaderContext
@@ -22,9 +21,6 @@ val networkModule
cache(get<LocalStorageManager>().createHttpCache())
addInterceptor(UserAgentInterceptor())
addInterceptor(CloudFlareInterceptor())
if (BuildConfig.DEBUG) {
addNetworkInterceptor(CurlLoggingInterceptor())
}
}.build()
}
single<MangaLoaderContext> { MangaLoaderContextImpl(get(), get(), get()) }

View File

@@ -1,10 +1,10 @@
package org.koitharu.kotatsu.core.network
import android.os.Build
import java.util.*
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import java.util.*
class UserAgentInterceptor : Interceptor {
@@ -30,5 +30,14 @@ class UserAgentInterceptor : Interceptor {
Build.DEVICE,
Locale.getDefault().language
)
val userAgentChrome
get() = (
"Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/100.0.4896.127 Mobile Safari/537.36"
).format(
Build.VERSION.RELEASE,
Build.MODEL,
)
}
}

View File

@@ -24,7 +24,7 @@ class ShortcutsRepository(
private val context: Context,
private val coil: ImageLoader,
private val historyRepository: HistoryRepository,
private val mangaRepository: MangaDataRepository
private val mangaRepository: MangaDataRepository,
) {
private val iconSize by lazy {

View File

@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.core.parser
import java.lang.ref.WeakReference
import java.util.*
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
@@ -28,11 +30,18 @@ interface MangaRepository {
companion object : KoinComponent {
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
operator fun invoke(source: MangaSource): MangaRepository {
return if (source == MangaSource.LOCAL) {
get<LocalMangaRepository>()
} else {
RemoteMangaRepository(source, get())
if (source == MangaSource.LOCAL) {
return get<LocalMangaRepository>()
}
cache[source]?.get()?.let { return it }
return synchronized(cache) {
cache[source]?.get()?.let { return it }
val repository = RemoteMangaRepository(MangaParser(source, get()))
cache[source] = WeakReference(repository)
repository
}
}
}

View File

@@ -1,19 +1,15 @@
package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.newParser
class RemoteMangaRepository(
override val source: MangaSource,
loaderContext: MangaLoaderContext,
) : MangaRepository {
class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository {
private val parser: MangaParser = source.newParser(loaderContext)
override val source: MangaSource
get() = parser.source
override val sortOrders: Set<SortOrder>
get() = parser.sortOrders
@@ -28,7 +24,7 @@ class RemoteMangaRepository(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder?
sortOrder: SortOrder?,
): List<Manga> = parser.getList(offset, query, tags, sortOrder)
override suspend fun getDetails(manga: Manga): Manga = parser.getDetails(manga)
@@ -48,4 +44,4 @@ class RemoteMangaRepository(
}
private fun getConfig() = parser.config as SourceSettings
}
}

View File

@@ -11,23 +11,34 @@ import androidx.collection.arraySetOf
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.getEnumValue
import org.koitharu.kotatsu.utils.ext.putEnumValue
import org.koitharu.kotatsu.utils.ext.toUriOrNull
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
class AppSettings(context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
remove(MangaSource.LOCAL)
if (!BuildConfig.DEBUG) {
remove(MangaSource.DUMMY)
}
}
val remoteMangaSources: Set<MangaSource>
get() = Collections.unmodifiableSet(remoteSources)
var listMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.DETAILED_LIST)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
@@ -104,10 +115,9 @@ class AppSettings(context: Context) {
get() = prefs.getString(KEY_APP_PASSWORD, null)
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
var sourcesOrder: List<Int>
var sourcesOrder: List<String>
get() = prefs.getString(KEY_SOURCES_ORDER, null)
?.split('|')
?.mapNotNull(String::toIntOrNull)
.orEmpty()
set(value) = prefs.edit {
putString(KEY_SOURCES_ORDER, value.joinToString("|"))
@@ -141,6 +151,12 @@ class AppSettings(context: Context) {
}
}
val isDownloadsSlowdownEnabled: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_SLOWDOWN, false)
val downloadsParallelism: Int
get() = prefs.getInt(KEY_DOWNLOADS_PARALLELISM, 2)
val isSuggestionsEnabled: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
@@ -165,12 +181,23 @@ class AppSettings(context: Context) {
else -> SimpleDateFormat(format, Locale.getDefault())
}
fun getSuggestionsTagsBlacklistRegex(): Regex? {
val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',')
if (string.isNullOrEmpty()) {
return null
}
val tags = string.split(',')
val regex = tags.joinToString(prefix = "(", separator = "|", postfix = ")") { tag ->
Regex.escape(tag.trim())
}
return Regex(regex, RegexOption.IGNORE_CASE)
}
fun getMangaSources(includeHidden: Boolean): List<MangaSource> {
val list = MangaSource.values().toMutableList()
list.remove(MangaSource.LOCAL)
val list = remoteSources.toMutableList()
val order = sourcesOrder
list.sortBy { x ->
val e = order.indexOf(x.ordinal)
val e = order.indexOf(x.name)
if (e == -1) order.size + x.ordinal else e
}
if (!includeHidden) {
@@ -212,7 +239,7 @@ class AppSettings(context: Context) {
const val KEY_DYNAMIC_THEME = "dynamic_theme"
const val KEY_THEME_AMOLED = "amoled_theme"
const val KEY_DATE_FORMAT = "date_format"
const val KEY_SOURCES_ORDER = "sources_order"
const val KEY_SOURCES_ORDER = "sources_order_2"
const val KEY_SOURCES_HIDDEN = "sources_hidden"
const val KEY_TRAFFIC_WARNING = "traffic_warning"
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
@@ -247,17 +274,18 @@ class AppSettings(context: Context) {
const val KEY_PAGES_PRELOAD = "pages_preload"
const val KEY_SUGGESTIONS = "suggestions"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source"
const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism"
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
// 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_DISCORD = "about_feedback_discord"
const val KEY_FEEDBACK_GITHUB = "about_feedback_github"
const val KEY_SUPPORT_DEVELOPER = "about_support_developer"
private const val NETWORK_NEVER = 0
private const val NETWORK_ALWAYS = 1
@@ -270,4 +298,4 @@ class AppSettings(context: Context) {
private val isSamsung
get() = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
}
}
}

View File

@@ -3,6 +3,9 @@ package org.koitharu.kotatsu.core.ui
import android.content.res.Resources
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.format
import java.util.*
sealed class DateTimeAgo : ListModel {
@@ -72,9 +75,33 @@ sealed class DateTimeAgo : ListModel {
override fun hashCode(): Int = days
}
class Absolute(private val date: Date) : DateTimeAgo() {
private val day = date.daysDiff(0)
override fun format(resources: Resources): String {
return date.format("d MMMM")
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Absolute
if (day != other.day) return false
return true
}
override fun hashCode(): Int {
return day
}
}
object LongAgo : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getString(R.string.long_ago)
}
}
}
}

View File

@@ -0,0 +1,118 @@
package org.koitharu.kotatsu.core.zip
import androidx.annotation.WorkerThread
import androidx.collection.ArraySet
import okio.Closeable
import java.io.File
import java.io.FileInputStream
import java.util.zip.Deflater
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream
class ZipOutput(
val file: File,
compressionLevel: Int = Deflater.DEFAULT_COMPRESSION,
) : Closeable {
private val entryNames = ArraySet<String>()
private var isClosed = false
private val output = ZipOutputStream(file.outputStream()).apply {
setLevel(compressionLevel)
}
@WorkerThread
fun put(name: String, file: File): Boolean {
return output.appendFile(file, name)
}
@WorkerThread
fun put(name: String, content: String): Boolean {
return output.appendText(content, name)
}
@WorkerThread
fun addDirectory(name: String): Boolean {
val entry = if (name.endsWith("/")) {
ZipEntry(name)
} else {
ZipEntry("$name/")
}
return if (entryNames.add(entry.name)) {
output.putNextEntry(entry)
output.closeEntry()
true
} else {
false
}
}
@WorkerThread
fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean {
return if (entryNames.add(entry.name)) {
val zipEntry = ZipEntry(entry.name)
output.putNextEntry(zipEntry)
other.getInputStream(entry).use { input ->
input.copyTo(output)
}
output.closeEntry()
true
} else {
false
}
}
fun finish() {
output.finish()
output.flush()
}
override fun close() {
if (!isClosed) {
output.close()
isClosed = true
}
}
@WorkerThread
private fun ZipOutputStream.appendFile(fileToZip: File, name: String): Boolean {
if (fileToZip.isDirectory) {
val entry = if (name.endsWith("/")) {
ZipEntry(name)
} else {
ZipEntry("$name/")
}
if (!entryNames.add(entry.name)) {
return false
}
putNextEntry(entry)
closeEntry()
fileToZip.listFiles()?.forEach { childFile ->
appendFile(childFile, "$name/${childFile.name}")
}
} else {
FileInputStream(fileToZip).use { fis ->
if (!entryNames.add(name)) {
return false
}
val zipEntry = ZipEntry(name)
putNextEntry(zipEntry)
fis.copyTo(this)
closeEntry()
}
}
return true
}
@WorkerThread
private fun ZipOutputStream.appendText(content: String, name: String): Boolean {
if (!entryNames.add(name)) {
return false
}
val zipEntry = ZipEntry(name)
putNextEntry(zipEntry)
content.byteInputStream().copyTo(this)
closeEntry()
return true
}
}

View File

@@ -9,9 +9,9 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.SearchView
import androidx.core.graphics.Insets
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
@@ -22,6 +22,7 @@ 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.ui.service.DownloadService
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
@@ -67,8 +68,8 @@ class ChaptersFragment :
viewModel.isChaptersReversed.observe(viewLifecycleOwner) {
activity?.invalidateOptionsMenu()
}
viewModel.hasChapters.observe(viewLifecycleOwner) {
binding.textViewHolder.isGone = it
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
binding.textViewHolder.isVisible = it
activity?.invalidateOptionsMenu()
}
}
@@ -94,7 +95,7 @@ class ChaptersFragment :
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.action_reversed).isChecked = viewModel.isChaptersReversed.value == true
menu.findItem(R.id.action_search).isVisible = viewModel.hasChapters.value == true
menu.findItem(R.id.action_search).isVisible = viewModel.isChaptersEmpty.value == false
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
@@ -154,11 +155,29 @@ class ChaptersFragment :
DownloadService.start(
context ?: return false,
viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
selectionDecoration?.checkedItemsIds
selectionDecoration?.checkedItemsIds?.toSet()
)
mode.finish()
true
}
R.id.action_delete -> {
val ids = selectionDecoration?.checkedItemsIds
val manga = viewModel.manga.value
when {
ids.isNullOrEmpty() || manga == null -> Unit
ids.size == manga.chapters?.size -> viewModel.deleteLocal()
else -> {
LocalChaptersRemoveService.start(requireContext(), manga, ids)
Snackbar.make(
binding.recyclerViewChapters,
R.string.chapters_will_removed_background,
Snackbar.LENGTH_LONG
).show()
}
}
mode.finish()
true
}
R.id.action_select_all -> {
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
selectionDecoration?.checkAll(ids)
@@ -178,9 +197,7 @@ class ChaptersFragment :
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
val manga = viewModel.manga.value
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
mode.title = manga?.title
return true
}
@@ -190,12 +207,10 @@ class ChaptersFragment :
menu.findItem(R.id.action_save).isVisible = items.none { x ->
x.chapter.source == MangaSource.LOCAL
}
mode.subtitle = resources.getQuantityString(
R.plurals.chapters_from_x,
items.size,
items.size,
chaptersAdapter?.itemCount ?: 0
)
menu.findItem(R.id.action_delete).isVisible = items.all { x ->
x.chapter.source == MangaSource.LOCAL
}
mode.title = items.size.toString()
return true
}

View File

@@ -4,7 +4,6 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
@@ -17,6 +16,7 @@ import androidx.appcompat.view.ActionMode
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.Insets
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
@@ -41,13 +41,16 @@ import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.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.getDisplayMessage
class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediator.TabConfigurationStrategy,
class DetailsActivity :
BaseActivity<ActivityDetailsBinding>(),
TabLayoutMediator.TabConfigurationStrategy,
AdapterView.OnItemSelectedListener {
private val viewModel by viewModel<DetailsViewModel> {
@@ -163,7 +166,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
R.id.action_share -> {
viewModel.manga.value?.let {
if (it.source == MangaSource.LOCAL) {
ShareHelper(this).shareCbz(Uri.parse(it.url).toFile())
ShareHelper(this).shareCbz(listOf(it.url.toUri().toFile()))
} else {
ShareHelper(this).shareMangaLink(it)
}
@@ -171,38 +174,23 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
true
}
R.id.action_delete -> {
viewModel.manga.value?.let { m ->
MaterialAlertDialogBuilder(this)
.setTitle(R.string.delete_manga)
.setMessage(getString(R.string.text_delete_local_manga, m.title))
.setPositiveButton(R.string.delete) { _, _ ->
viewModel.deleteLocal(m)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
val title = viewModel.manga.value?.title.orEmpty()
MaterialAlertDialogBuilder(this)
.setTitle(R.string.delete_manga)
.setMessage(getString(R.string.text_delete_local_manga, title))
.setPositiveButton(R.string.delete) { _, _ ->
viewModel.deleteLocal()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
true
}
R.id.action_save -> {
viewModel.manga.value?.let {
val chaptersCount = it.chapters?.size ?: 0
if (chaptersCount > 5) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.save_manga)
.setMessage(
getString(
R.string.large_manga_save_confirm,
resources.getQuantityString(
R.plurals.chapters,
chaptersCount,
chaptersCount
)
)
)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.save) { _, _ ->
DownloadService.start(this, it)
}.show()
val branches = viewModel.branches.value.orEmpty()
if (chaptersCount > 5 || branches.size > 1) {
showSaveConfirmation(it, chaptersCount, branches)
} else {
DownloadService.start(this, it)
}
@@ -262,7 +250,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
fun showChapterMissingDialog(chapterId: Long) {
val remoteManga = viewModel.getRemoteManga()
if (remoteManga == null) {
binding.snackbar.show(getString( R.string.chapter_is_missing))
binding.snackbar.show(getString(R.string.chapter_is_missing))
return
}
MaterialAlertDialogBuilder(this).apply {
@@ -328,11 +316,41 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
}
}
private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List<String?>) {
val dialogBuilder = MaterialAlertDialogBuilder(this)
.setTitle(R.string.save_manga)
.setNegativeButton(android.R.string.cancel, null)
if (branches.size > 1) {
val items = Array(branches.size) { i -> branches[i].orEmpty() }
val currentBranch = viewModel.selectedBranchIndex.value ?: -1
val checkedIndices = BooleanArray(branches.size) { i -> i == currentBranch }
dialogBuilder.setMultiChoiceItems(items, checkedIndices) { _, i, checked ->
checkedIndices[i] = checked
}.setPositiveButton(R.string.save) { _, _ ->
val selectedBranches = branches.filterIndexedTo(HashSet()) { i, _ -> checkedIndices[i] }
val chaptersIds = manga.chapters?.mapNotNullToSet { c ->
if (c.branch in selectedBranches) c.id else null
}
DownloadService.start(this, manga, chaptersIds)
}
} else {
dialogBuilder.setMessage(
getString(
R.string.large_manga_save_confirm,
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
)
).setPositiveButton(R.string.save) { _, _ ->
DownloadService.start(this, manga)
}
}
dialogBuilder.show()
}
companion object {
fun newIntent(context: Context, manga: Manga): Intent {
return Intent(context, DetailsActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
}
fun newIntent(context: Context, mangaId: Long): Intent {
@@ -340,4 +358,4 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
.putExtra(MangaIntent.KEY_ID, mangaId)
}
}
}
}

View File

@@ -5,6 +5,7 @@ import android.os.Bundle
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.view.*
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.net.toUri
@@ -224,14 +225,16 @@ class DetailsFragment :
if (viewModel.readingHistory.value == null) {
return false
}
v.showPopupMenu(R.menu.popup_read) {
when (it.itemId) {
val menu = PopupMenu(v.context, v)
menu.inflate(R.menu.popup_read)
menu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_read -> {
val branch = viewModel.selectedBranchValue
startActivity(
ReaderActivity.newIntent(
context = context ?: return@showPopupMenu false,
manga = viewModel.manga.value ?: return@showPopupMenu false,
context = context ?: return@setOnMenuItemClickListener false,
manga = viewModel.manga.value ?: return@setOnMenuItemClickListener false,
state = viewModel.chapters.value?.firstOrNull { c ->
c.chapter.branch == branch
}?.let { c ->
@@ -244,6 +247,7 @@ class DetailsFragment :
else -> false
}
}
menu.show()
return true
}
else -> return false

View File

@@ -24,11 +24,12 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.iterator
import org.koitharu.kotatsu.utils.ext.mapToSet
import java.io.IOException
class DetailsViewModel(
@@ -88,18 +89,18 @@ class DetailsViewModel(
val branches = mangaData.map {
it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty()
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val selectedBranchIndex = combine(
branches.asFlow(),
selectedBranch
) { branches, selected ->
branches.indexOf(selected)
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val hasChapters = mangaData.map {
!(it?.chapters.isNullOrEmpty())
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
val isChaptersEmpty = mangaData.mapNotNull { m ->
m?.run { chapters.isNullOrEmpty() }
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false)
val chapters = combine(
combine(
@@ -134,8 +135,11 @@ class DetailsViewModel(
loadingJob = doLoad()
}
fun deleteLocal(manga: Manga) {
fun deleteLocal() {
val m = mangaData.value ?: return
launchLoadingJob(Dispatchers.Default) {
val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)
checkNotNull(manga) { "Cannot find saved manga for ${m.title}" }
val original = localMangaRepository.getRemoteManga(manga)
localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
runCatching {
@@ -191,7 +195,8 @@ class DetailsViewModel(
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = if (hist != null) {
manga.chapters?.find { it.id == hist.chapterId }?.branch
val currentChapter = manga.chapters?.find { it.id == hist.chapterId }
if (currentChapter != null) currentChapter.branch else predictBranch(manga.chapters)
} else {
predictBranch(manga.chapters)
}
@@ -203,6 +208,8 @@ class DetailsViewModel(
} else {
localMangaRepository.findSavedManga(manga)
}
}.onFailure { error ->
if (BuildConfig.DEBUG) error.printStackTrace()
}.getOrNull()
}
@@ -249,10 +256,10 @@ class DetailsViewModel(
val dateFormat = settings.getDateFormat()
for (i in sourceChapters.indices) {
val chapter = sourceChapters[i]
val localChapter = chaptersMap.remove(chapter.id)
if (chapter.branch != branch) {
continue
}
val localChapter = chaptersMap.remove(chapter.id)
result += localChapter?.toListItem(
isCurrent = i == currentIndex,
isUnread = i > currentIndex,
@@ -271,15 +278,19 @@ class DetailsViewModel(
}
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
result.ensureCapacity(result.size + chaptersMap.size)
chaptersMap.values.mapTo(result) {
it.toListItem(
isCurrent = false,
isUnread = true,
isNew = false,
isMissing = false,
isDownloaded = false,
dateFormat = dateFormat,
)
chaptersMap.values.mapNotNullTo(result) {
if (it.branch == branch) {
it.toListItem(
isCurrent = false,
isUnread = true,
isNew = false,
isMissing = false,
isDownloaded = false,
dateFormat = dateFormat,
)
} else {
null
}
}
result.sortBy { it.chapter.number }
}

View File

@@ -6,7 +6,7 @@ import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.TextView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.replaceWith
import org.koitharu.kotatsu.parsers.util.replaceWith
class BranchesAdapter : BaseAdapter() {

View File

@@ -2,69 +2,32 @@ package org.koitharu.kotatsu.details.ui.adapter
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import androidx.core.content.ContextCompat
import androidx.core.view.children
import android.graphics.RectF
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.utils.ext.getThemeColor
import com.google.android.material.R as materialR
class ChaptersSelectionDecoration(context: Context) : RecyclerView.ItemDecoration() {
class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val bounds = Rect()
private val selection = HashSet<Long>()
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val radius = context.resources.getDimension(materialR.dimen.abc_control_corner_material)
init {
paint.color = ContextCompat.getColor(context, R.color.selector_foreground)
paint.color = context.getThemeColor(materialR.attr.colorSecondaryContainer, Color.LTGRAY)
paint.style = Paint.Style.FILL
}
val checkedItemsCount: Int
get() = selection.size
val checkedItemsIds: Set<Long>
get() = selection
fun toggleItemChecked(id: Long) {
if (!selection.remove(id)) {
selection.add(id)
}
}
fun setItemIsChecked(id: Long, isChecked: Boolean) {
if (isChecked) {
selection.add(id)
} else {
selection.remove(id)
}
}
fun checkAll(ids: Collection<Long>) {
selection.addAll(ids)
}
fun clearSelection() {
selection.clear()
}
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
canvas.save()
if (parent.clipToPadding) {
canvas.clipRect(
parent.paddingLeft, parent.paddingTop, parent.width - parent.paddingRight,
parent.height - parent.paddingBottom
)
}
for (child in parent.children) {
val itemId = parent.getChildItemId(child)
if (itemId in selection) {
parent.getDecoratedBoundsWithMargins(child, bounds)
bounds.offset(child.translationX.toInt(), child.translationY.toInt())
canvas.drawRect(bounds, paint)
}
}
canvas.restore()
override fun onDrawBackground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) {
canvas.drawRoundRect(bounds, radius, radius, paint)
}
}

View File

@@ -40,11 +40,10 @@ class ChapterListItem(
override fun hashCode(): Int {
var result = chapter.hashCode()
result = 31 * result + flags
result = 31 * result + uploadDate.hashCode()
result = 31 * result + (uploadDate?.hashCode() ?: 0)
return result
}
companion object {
const val FLAG_UNREAD = 2

View File

@@ -17,10 +17,12 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.local.data.MangaZip
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.CbzMangaOutput
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.referer
@@ -29,9 +31,8 @@ import org.koitharu.kotatsu.utils.progress.ProgressJob
import java.io.File
private const val MAX_DOWNLOAD_ATTEMPTS = 3
private const val MAX_PARALLEL_DOWNLOADS = 2
private const val DOWNLOAD_ERROR_DELAY = 500L
private const val TEMP_PAGE_FILE = "page.tmp"
private const val SLOWDOWN_DELAY = 200L
class DownloadManager(
private val coroutineScope: CoroutineScope,
@@ -40,9 +41,10 @@ class DownloadManager(
private val okHttp: OkHttpClient,
private val cache: PagesCache,
private val localMangaRepository: LocalMangaRepository,
private val settings: AppSettings,
) {
private val connectivityManager = context.applicationContext.getSystemService(
private val connectivityManager = context.getSystemService(
Context.CONNECTIVITY_SERVICE
) as ConnectivityManager
private val coverWidth = context.resources.getDimensionPixelSize(
@@ -51,34 +53,40 @@ class DownloadManager(
private val coverHeight = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_height
)
private val semaphore = Semaphore(MAX_PARALLEL_DOWNLOADS)
private val semaphore = Semaphore(settings.downloadsParallelism)
fun downloadManga(
manga: Manga,
chaptersIds: Set<Long>?,
chaptersIds: LongArray?,
startId: Int,
): ProgressJob<DownloadState> {
val stateFlow = MutableStateFlow<DownloadState>(
DownloadState.Queued(startId = startId, manga = manga, cover = null)
)
val job = downloadMangaImpl(manga, chaptersIds, stateFlow, startId)
val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, startId)
return ProgressJob(job, stateFlow)
}
private fun downloadMangaImpl(
manga: Manga,
chaptersIds: Set<Long>?,
chaptersIds: LongArray?,
outState: MutableStateFlow<DownloadState>,
startId: Int,
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
@Suppress("NAME_SHADOWING") var manga = manga
val chaptersIdsSet = chaptersIds?.toMutableSet()
semaphore.acquire()
coroutineContext[WakeLockNode]?.acquire()
outState.value = DownloadState.Preparing(startId, manga, null)
var cover: Drawable? = null
val destination = localMangaRepository.getOutputDir()
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
var output: MangaZip? = null
val tempFileName = "${manga.id}_$startId.tmp"
var output: CbzMangaOutput? = null
try {
if (manga.source == MangaSource.LOCAL) {
manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance")
}
val repo = MangaRepository(manga.source)
cover = runCatching {
imageLoader.execute(
@@ -91,58 +99,71 @@ class DownloadManager(
).drawable
}.getOrNull()
outState.value = DownloadState.Preparing(startId, manga, cover)
val data = if (manga.chapters == null) repo.getDetails(manga) else manga
output = MangaZip.findInDir(destination, data)
output.prepare(data)
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = CbzMangaOutput.get(destination, data)
val coverUrl = data.largeCoverUrl ?: data.coverUrl
downloadFile(coverUrl, data.publicUrl, destination).let { file ->
downloadFile(coverUrl, data.publicUrl, destination, tempFileName).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 }
val chapters = checkNotNull(
if (chaptersIdsSet == null) {
data.chapters
} else {
data.chapters?.filter { x -> chaptersIdsSet.remove(x.id) }
}
) { "Chapters list must not be null" }
check(chapters.isNotEmpty()) { "Chapters list must not be empty" }
check(chaptersIdsSet.isNullOrEmpty()) {
"${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
}
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) {
val pages = repo.getPages(chapter)
for ((pageIndex, page) in pages.withIndex()) {
var retryCounter = 0
failsafe@ while (true) {
try {
val url = repo.getPageUrl(page)
val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
output.addPage(
chapter = chapter,
file = file,
pageNumber = pageIndex,
ext = MimeTypeMap.getFileExtensionFromUrl(url),
)
break@failsafe
} catch (e: IOException) {
if (retryCounter < MAX_DOWNLOAD_ATTEMPTS) {
outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
delay(DOWNLOAD_ERROR_DELAY)
connectivityManager.waitForNetwork()
continue@failsafe
retryCounter++
} else {
throw e
}
} while (false)
}
}
outState.value = DownloadState.Progress(
startId, data, cover,
totalChapters = chapters.size,
currentChapter = chapterIndex,
totalPages = pages.size,
currentPage = pageIndex,
)
outState.value = DownloadState.Progress(
startId, data, cover,
totalChapters = chapters.size,
currentChapter = chapterIndex,
totalPages = pages.size,
currentPage = pageIndex,
)
if (settings.isDownloadsSlowdownEnabled) {
delay(SLOWDOWN_DELAY)
}
}
}
outState.value = DownloadState.PostProcessing(startId, data, cover)
if (!output.compress()) {
throw RuntimeException("Cannot create target file")
}
output.mergeWithExisting()
output.finalize()
val localManga = localMangaRepository.getFromFile(output.file)
outState.value = DownloadState.Done(startId, data, cover, localManga)
} catch (_: CancellationException) {
} catch (e: CancellationException) {
outState.value = DownloadState.Cancelled(startId, manga, cover)
throw e
} catch (e: Throwable) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
@@ -151,14 +172,14 @@ class DownloadManager(
} finally {
withContext(NonCancellable) {
output?.cleanup()
File(destination, TEMP_PAGE_FILE).deleteAwait()
File(destination, tempFileName).deleteAwait()
}
coroutineContext[WakeLockNode]?.release()
semaphore.release()
}
}
private suspend fun downloadFile(url: String, referer: String, destination: File): File {
private suspend fun downloadFile(url: String, referer: String, destination: File, tempFileName: String): File {
val request = Request.Builder()
.url(url)
.header(CommonHeaders.REFERER, referer)
@@ -166,35 +187,44 @@ class DownloadManager(
.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()
runInterruptible(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)
}
val file = File(destination, tempFileName)
val response = call.clone().await()
runInterruptible(Dispatchers.IO) {
file.outputStream().use { out ->
checkNotNull(response.body).byteStream().copyTo(out)
}
}
return file
}
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) = CoroutineExceptionHandler { _, throwable ->
val prevValue = outState.value
outState.value = DownloadState.Error(
startId = prevValue.startId,
manga = prevValue.manga,
cover = prevValue.cover,
error = throwable,
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) =
CoroutineExceptionHandler { _, throwable ->
val prevValue = outState.value
outState.value = DownloadState.Error(
startId = prevValue.startId,
manga = prevValue.manga,
cover = prevValue.cover,
error = throwable,
)
}
class Factory(
private val context: Context,
private val imageLoader: ImageLoader,
private val okHttp: OkHttpClient,
private val cache: PagesCache,
private val localMangaRepository: LocalMangaRepository,
private val settings: AppSettings,
) {
fun create(coroutineScope: CoroutineScope) = DownloadManager(
coroutineScope = coroutineScope,
context = context,
imageLoader = imageLoader,
okHttp = okHttp,
cache = cache,
localMangaRepository = localMangaRepository,
settings = settings,
)
}
}

View File

@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.progress.ProgressJob

View File

@@ -6,6 +6,7 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import android.text.format.DateUtils
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
@@ -16,8 +17,8 @@ import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.DownloadsActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.format
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import com.google.android.material.R as materialR
@@ -49,7 +50,7 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setSilent(true)
}
fun create(state: DownloadState): Notification {
fun create(state: DownloadState, timeLeft: Long): Notification {
builder.setContentTitle(state.manga.title)
builder.setContentText(context.getString(R.string.manga_downloading_))
builder.setProgress(1, 0, true)
@@ -117,7 +118,13 @@ class DownloadNotification(private val context: Context, startId: Int) {
}
is DownloadState.Progress -> {
builder.setProgress(state.max, state.progress, false)
builder.setContentText((state.percent * 100).format() + "%")
if (timeLeft > 0L) {
val eta = DateUtils.getRelativeTimeSpanString(timeLeft, 0L, DateUtils.SECOND_IN_MILLIS)
builder.setContentText(eta)
} else {
val percent = (state.percent * 100).format()
builder.setContentText(context.getString(R.string.percent_string_pattern, percent))
}
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.setOngoing(true)

View File

@@ -10,10 +10,8 @@ import android.os.PowerManager
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.transformWhile
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koin.android.ext.android.get
@@ -23,7 +21,6 @@ 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.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.withoutChapters
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.download.domain.DownloadState
@@ -31,10 +28,9 @@ import org.koitharu.kotatsu.download.domain.WakeLockNode
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.throttle
import org.koitharu.kotatsu.utils.ext.toArraySet
import org.koitharu.kotatsu.utils.progress.ProgressJob
import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
import java.util.concurrent.TimeUnit
import kotlin.collections.set
class DownloadService : BaseService() {
@@ -48,16 +44,12 @@ class DownloadService : BaseService() {
override fun onCreate() {
super.onCreate()
isRunning = true
notificationSwitcher = ForegroundNotificationSwitcher(this)
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
downloadManager = DownloadManager(
downloadManager = get<DownloadManager.Factory>().create(
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)),
context = this,
imageLoader = get(),
okHttp = get(),
cache = get(),
localMangaRepository = get(),
)
DownloadNotification.createChannel(this)
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
@@ -66,11 +58,10 @@ class DownloadService : BaseService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
val manga = intent?.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga
val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet()
val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)
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)
@@ -91,13 +82,14 @@ class DownloadService : BaseService() {
override fun onDestroy() {
unregisterReceiver(controlReceiver)
binder = null
isRunning = false
super.onDestroy()
}
private fun downloadManga(
startId: Int,
manga: Manga,
chaptersIds: Set<Long>?,
chaptersIds: LongArray?,
): ProgressJob<DownloadState> {
val job = downloadManager.downloadManga(manga, chaptersIds, startId)
listenJob(job)
@@ -107,19 +99,28 @@ class DownloadService : BaseService() {
private fun listenJob(job: ProgressJob<DownloadState>) {
lifecycleScope.launch {
val startId = job.progressValue.startId
val timeLeftEstimator = TimeLeftEstimator()
val notification = DownloadNotification(this@DownloadService, startId)
notificationSwitcher.notify(startId, notification.create(job.progressValue))
notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L))
job.progressAsFlow()
.onEach { state ->
if (state is DownloadState.Progress) {
timeLeftEstimator.tick(value = state.progress, total = state.max)
} else {
timeLeftEstimator.emptyTick()
}
}
.throttle { state -> if (state is DownloadState.Progress) 400L else 0L }
.whileActive()
.collect { state ->
notificationSwitcher.notify(startId, notification.create(state))
val timeLeft = timeLeftEstimator.getEstimatedTimeLeft()
notificationSwitcher.notify(startId, notification.create(state, timeLeft))
}
job.join()
(job.progressValue as? DownloadState.Done)?.let {
sendBroadcast(
Intent(ACTION_DOWNLOAD_COMPLETE)
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga.withoutChapters()))
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false))
)
}
notificationSwitcher.detach(
@@ -127,7 +128,7 @@ class DownloadService : BaseService() {
if (job.isCancelled) {
null
} else {
notification.create(job.progressValue)
notification.create(job.progressValue, -1L)
}
)
stopSelf(startId)
@@ -163,11 +164,12 @@ class DownloadService : BaseService() {
companion object {
const val ACTION_DOWNLOAD_COMPLETE =
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
var isRunning: Boolean = false
private set
private const val ACTION_DOWNLOAD_CANCEL =
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
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"
@@ -179,14 +181,38 @@ class DownloadService : BaseService() {
}
confirmDataTransfer(context) {
val intent = Intent(context, DownloadService::class.java)
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga))
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
if (chaptersIds != null) {
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
}
ContextCompat.startForegroundService(context, intent)
Toast.makeText(context, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
}
}
fun start(context: Context, manga: Collection<Manga>) {
if (manga.isEmpty()) {
return
}
confirmDataTransfer(context) {
for (item in manga) {
val intent = Intent(context, DownloadService::class.java)
intent.putExtra(EXTRA_MANGA, ParcelableManga(item, withChapters = false))
ContextCompat.startForegroundService(context, intent)
}
}
}
fun confirmAndStart(context: Context, items: Set<Manga>) {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.save_manga)
.setMessage(R.string.batch_manga_save_confirm)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.save) { _, _ ->
start(context, items)
}.show()
}
fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
.putExtra(EXTRA_CANCEL_ID, startId)

View File

@@ -24,9 +24,10 @@ class ForegroundNotificationSwitcher(
@Synchronized
fun notify(startId: Int, notification: Notification) {
if (notifications.isEmpty()) {
handler.postDelayed(StartForegroundRunnable(startId, notification), DEFAULT_DELAY)
service.startForeground(startId, notification)
} else {
notificationManager.notify(startId, notification)
}
notificationManager.notify(startId, notification)
notifications[startId] = notification
}
@@ -45,16 +46,6 @@ class ForegroundNotificationSwitcher(
handler.postDelayed(NotifyRunnable(startId, notification), DEFAULT_DELAY)
}
private inner class StartForegroundRunnable(
private val startId: Int,
private val notification: Notification,
) : Runnable {
override fun run() {
service.startForeground(startId, notification)
}
}
private inner class NotifyRunnable(
private val startId: Int,
private val notification: Notification?,

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.favourites.data
import java.util.*
import org.koitharu.kotatsu.core.db.entity.SortOrder
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.parsers.model.SortOrder
fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory(
id = id,
title = title,
sortKey = sortKey,
order = SortOrder(order, SortOrder.NEWEST),
createdAt = Date(createdAt),
)

View File

@@ -3,9 +3,6 @@ package org.koitharu.kotatsu.favourites.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.*
@Entity(tableName = "favourite_categories")
class FavouriteCategoryEntity(
@@ -15,13 +12,4 @@ class FavouriteCategoryEntity(
@ColumnInfo(name = "sort_key") val sortKey: Int,
@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,
order = SortOrder.values().find { x -> x.name == order } ?: SortOrder.NEWEST,
createdAt = Date(createdAt),
)
}
)

View File

@@ -6,36 +6,35 @@ 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.db.entity.*
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.mapItems
import org.koitharu.kotatsu.utils.ext.mapToSet
class FavouritesRepository(private val db: MangaDatabase) {
suspend fun getAllManga(): List<Manga> {
val entities = db.favouritesDao.findAll()
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
}
fun observeAll(order: SortOrder): Flow<List<Manga>> {
return db.favouritesDao.observeAll(order)
.mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
.mapItems { it.manga.toManga(it.tags.toMangaTags()) }
}
suspend fun getManga(categoryId: Long): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId)
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
}
fun observeAll(categoryId: Long, order: SortOrder): Flow<List<Manga>> {
return db.favouritesDao.observeAll(categoryId, order)
.mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
.mapItems { it.manga.toManga(it.tags.toMangaTags()) }
}
fun observeAll(categoryId: Long): Flow<List<Manga>> {
@@ -43,21 +42,6 @@ class FavouritesRepository(private val db: MangaDatabase) {
.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)) }
}
suspend fun getAllCategories(): List<FavouriteCategory> {
val entities = db.favouriteCategoriesDao.findAll()
return entities.map { it.toFavouriteCategory() }
}
suspend fun getCategories(mangaId: Long): List<FavouriteCategory> {
val entities = db.favouritesDao.find(mangaId)?.categories
return entities?.map { it.toFavouriteCategory() }.orEmpty()
}
fun observeCategories(): Flow<List<FavouriteCategory>> {
return db.favouriteCategoriesDao.observeAll().mapItems {
it.toFavouriteCategory()
@@ -70,8 +54,8 @@ class FavouritesRepository(private val db: MangaDatabase) {
}.distinctUntilChanged()
}
fun observeCategoriesIds(mangaId: Long): Flow<List<Long>> {
return db.favouritesDao.observeIds(mangaId)
fun observeCategoriesIds(mangaId: Long): Flow<Set<Long>> {
return db.favouritesDao.observeIds(mangaId).map { it.toSet() }
}
suspend fun addCategory(title: String): FavouriteCategory {
@@ -107,27 +91,37 @@ class FavouritesRepository(private val db: MangaDatabase) {
}
}
suspend fun addToCategory(manga: Manga, categoryId: Long) {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
suspend fun addToCategory(categoryId: Long, mangas: Collection<Manga>) {
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags)
val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis())
db.favouritesDao.insert(entity)
for (manga in mangas) {
val tags = manga.tags.toEntities()
db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga.toEntity(), tags)
val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis())
db.favouritesDao.insert(entity)
}
}
}
suspend fun removeFromCategory(manga: Manga, categoryId: Long) {
db.favouritesDao.delete(categoryId, manga.id)
suspend fun removeFromFavourites(ids: Collection<Long>) {
db.withTransaction {
for (id in ids) {
db.favouritesDao.delete(id)
}
}
}
suspend fun removeFromFavourites(manga: Manga) {
db.favouritesDao.delete(manga.id)
suspend fun removeFromCategory(categoryId: Long, ids: Collection<Long>) {
db.withTransaction {
for (id in ids) {
db.favouritesDao.delete(categoryId, id)
}
}
}
private fun observeOrder(categoryId: Long): Flow<SortOrder> {
return db.favouriteCategoriesDao.observe(categoryId)
.map { x -> SortOrder.values().find { it.name == x.order } ?: SortOrder.NEWEST }
.map { x -> SortOrder(x.order, SortOrder.NEWEST) }
.distinctUntilChanged()
}
}

View File

@@ -2,14 +2,19 @@ package org.koitharu.kotatsu.favourites.ui
import android.os.Bundle
import android.view.*
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
import androidx.core.view.children
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.util.ActionModeListener
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding
@@ -20,11 +25,14 @@ import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.showPopupMenu
import org.koitharu.kotatsu.utils.ext.resolveDp
import java.util.*
class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
FavouritesTabLongClickListener, CategoriesEditDelegate.CategoriesEditCallback {
class FavouritesContainerFragment :
BaseFragment<FragmentFavouritesBinding>(),
FavouritesTabLongClickListener,
CategoriesEditDelegate.CategoriesEditCallback,
ActionModeListener {
private val viewModel by viewModel<FavouritesCategoriesViewModel>()
private val editDelegate by lazy(LazyThreadSafetyMode.NONE) {
@@ -51,6 +59,7 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
binding.pager.adapter = adapter
pagerAdapter = adapter
TabLayoutMediator(binding.tabs, binding.pager, adapter).attach()
actionModeDelegate.addListener(this, viewLifecycleOwner)
viewModel.categories.observe(viewLifecycleOwner, ::onCategoriesChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError)
@@ -61,13 +70,23 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
super.onDestroyView()
}
override fun onActionModeStarted(mode: ActionMode) {
binding.pager.isUserInputEnabled = false
binding.tabs.setTabsEnabled(false)
}
override fun onActionModeFinished(mode: ActionMode) {
binding.pager.isUserInputEnabled = true
binding.tabs.setTabsEnabled(true)
}
override fun onWindowInsetsChanged(insets: Insets) {
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
binding.root.updatePadding(
top = headerHeight - insets.top
)
binding.pager.updatePadding(
top = -headerHeight
top = -headerHeight + resources.resolveDp(8) // 8 dp is needed so that the top of the list is not attached to tabs (visible when ActionMode is active)
)
binding.tabs.apply {
updatePadding(
@@ -105,22 +124,24 @@ 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, { menu ->
createOrderSubmenu(menu, category)
}) {
val menu = PopupMenu(tabView.context, tabView)
menu.inflate(menuRes)
createOrderSubmenu(menu.menu, category)
menu.setOnMenuItemClickListener {
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
R.id.action_order -> return@setOnMenuItemClickListener false
else -> {
val order = CategoriesActivity.SORT_ORDERS.getOrNull(it.order)
?: return@showPopupMenu false
?: return@setOnMenuItemClickListener false
viewModel.setCategoryOrder(category.id, order)
}
}
true
}
menu.show()
return true
}
@@ -146,18 +167,20 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
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
)
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 fun TabLayout.setTabsEnabled(enabled: Boolean) {
val tabStrip = getChildAt(0) as? ViewGroup ?: return
for (tab in tabStrip.children) {
tab.isEnabled = enabled
}
}
companion object {
fun newInstance() = FavouritesContainerFragment()

View File

@@ -6,6 +6,7 @@ import android.os.Bundle
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
@@ -23,11 +24,12 @@ import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.showPopupMenu
class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
class CategoriesActivity :
BaseActivity<ActivityCategoriesBinding>(),
OnListItemClickListener<FavouriteCategory>,
View.OnClickListener, CategoriesEditDelegate.CategoriesEditCallback {
View.OnClickListener,
CategoriesEditDelegate.CategoriesEditCallback {
private val viewModel by viewModel<FavouritesCategoriesViewModel>()
@@ -58,26 +60,27 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
}
override fun onItemClick(item: FavouriteCategory, view: View) {
view.showPopupMenu(R.menu.popup_category, { menu ->
createOrderSubmenu(menu, item)
}) {
when (it.itemId) {
val menu = PopupMenu(view.context, view)
menu.inflate(R.menu.popup_category)
createOrderSubmenu(menu.menu, item)
menu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_remove -> editDelegate.deleteCategory(item)
R.id.action_rename -> editDelegate.renameCategory(item)
R.id.action_order -> return@showPopupMenu false
R.id.action_order -> return@setOnMenuItemClickListener false
else -> {
val order = SORT_ORDERS.getOrNull(it.order) ?: return@showPopupMenu false
val order = SORT_ORDERS.getOrNull(menuItem.order) ?: return@setOnMenuItemClickListener false
viewModel.setCategoryOrder(item.id, order)
}
}
true
}
menu.show()
}
override fun onItemLongClick(item: FavouriteCategory, view: View): Boolean {
reorderHelper.startDrag(
binding.recyclerView.findContainingViewHolder(view) ?: return false
)
val viewHolder = binding.recyclerView.findContainingViewHolder(view) ?: return false
reorderHelper.startDrag(viewHolder)
return true
}
@@ -90,7 +93,7 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
binding.recyclerView.updatePadding(
left = insets.left,
right = insets.right,
bottom = 2 * insets.bottom + binding.fabAdd.measureHeight()
bottom = 2 * insets.bottom + binding.fabAdd.measureHeight(),
)
}

View File

@@ -2,14 +2,15 @@ package org.koitharu.kotatsu.favourites.ui.categories.select
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.FragmentManager
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory
@@ -26,10 +27,10 @@ class FavouriteCategoriesDialog :
BaseBottomSheet<DialogFavoriteCategoriesBinding>(),
OnListItemClickListener<MangaCategoryItem>,
CategoriesEditDelegate.CategoriesEditCallback,
View.OnClickListener {
Toolbar.OnMenuItemClickListener {
private val viewModel by viewModel<MangaCategoriesViewModel> {
parametersOf(requireNotNull(arguments?.getParcelable<ParcelableManga>(MangaIntent.KEY_MANGA)).manga)
parametersOf(requireNotNull(arguments?.getParcelableArrayList<ParcelableManga>(KEY_MANGA_LIST)).map { it.manga })
}
private var adapter: MangaCategoriesAdapter? = null
@@ -46,7 +47,7 @@ class FavouriteCategoriesDialog :
super.onViewCreated(view, savedInstanceState)
adapter = MangaCategoriesAdapter(this)
binding.recyclerViewCategories.adapter = adapter
binding.textViewAdd.setOnClickListener(this)
binding.toolbar.setOnMenuItemClickListener(this)
viewModel.content.observe(viewLifecycleOwner, this::onContentChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError)
@@ -57,9 +58,13 @@ class FavouriteCategoriesDialog :
super.onDestroyView()
}
override fun onClick(v: View) {
when (v.id) {
R.id.textView_add -> editDelegate.createCategory()
override fun onMenuItemClick(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_create -> {
editDelegate.createCategory()
true
}
else -> false
}
}
@@ -86,10 +91,15 @@ class FavouriteCategoriesDialog :
companion object {
private const val TAG = "FavouriteCategoriesDialog"
private const val KEY_MANGA_LIST = "manga_list"
fun show(fm: FragmentManager, manga: Manga) = FavouriteCategoriesDialog()
.withArgs(1) {
putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga))
}.show(fm, TAG)
fun show(fm: FragmentManager, manga: Manga) = Companion.show(fm, listOf(manga))
fun show(fm: FragmentManager, manga: Collection<Manga>) = FavouriteCategoriesDialog().withArgs(1) {
putParcelableArrayList(
KEY_MANGA_LIST,
manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withChapters = false) }
)
}.show(fm, TAG)
}
}

View File

@@ -4,19 +4,20 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
class MangaCategoriesViewModel(
private val manga: Manga,
private val manga: List<Manga>,
private val favouritesRepository: FavouritesRepository
) : BaseViewModel() {
val content = combine(
favouritesRepository.observeCategories(),
favouritesRepository.observeCategoriesIds(manga.id)
observeCategoriesIds(),
) { all, checked ->
all.map {
MangaCategoryItem(
@@ -30,9 +31,9 @@ class MangaCategoriesViewModel(
fun setChecked(categoryId: Long, isChecked: Boolean) {
launchJob(Dispatchers.Default) {
if (isChecked) {
favouritesRepository.addToCategory(manga, categoryId)
favouritesRepository.addToCategory(categoryId, manga)
} else {
favouritesRepository.removeFromCategory(manga, categoryId)
favouritesRepository.removeFromCategory(categoryId, manga.ids())
}
}
}
@@ -42,4 +43,25 @@ class MangaCategoriesViewModel(
favouritesRepository.addCategory(name)
}
}
private fun observeCategoriesIds() = if (manga.size == 1) {
// Fast path
favouritesRepository.observeCategoriesIds(manga[0].id)
} else {
combine(
manga.map { favouritesRepository.observeCategoriesIds(it.id) }
) { array ->
val result = HashSet<Long>()
var isFirst = true
for (ids in array) {
if (isFirst) {
result.addAll(ids)
isFirst = false
} else {
result.retainAll(ids.toSet())
}
}
result
}
}
}

View File

@@ -1,13 +1,13 @@
package org.koitharu.kotatsu.favourites.ui.list
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.appcompat.view.ActionMode
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.withArgs
class FavouritesListFragment : MangaListFragment() {
@@ -23,17 +23,27 @@ class FavouritesListFragment : MangaListFragment() {
override fun onScrolledToEnd() = Unit
override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) {
super.onCreatePopupMenu(inflater, menu, data)
inflater.inflate(R.menu.popup_favourites, menu)
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_favourites, menu)
return super.onCreateActionMode(mode, menu)
}
override fun onPopupMenuItemSelected(item: MenuItem, data: Manga) = when (item.itemId) {
R.id.action_remove -> {
viewModel.removeFromFavourites(data)
true
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none {
it.source == MangaSource.LOCAL
}
return super.onPrepareActionMode(mode, menu)
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_remove -> {
viewModel.removeFromFavourites(selectedItemsIds)
mode.finish()
true
}
else -> super.onActionItemClicked(mode, item)
}
else -> super.onPopupMenuItemSelected(item, data)
}
companion object {

View File

@@ -13,7 +13,6 @@ 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.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@@ -56,12 +55,15 @@ class FavouritesListViewModel(
override fun onRetry() = Unit
fun removeFromFavourites(manga: Manga) {
fun removeFromFavourites(ids: Set<Long>) {
if (ids.isEmpty()) {
return
}
launchJob {
if (categoryId == 0L) {
repository.removeFromFavourites(manga)
repository.removeFromFavourites(ids)
} else {
repository.removeFromCategory(manga, categoryId)
repository.removeFromCategory(categoryId, ids)
}
}
}

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.history.data
import java.util.*
import org.koitharu.kotatsu.core.model.MangaHistory
fun HistoryEntity.toMangaHistory() = MangaHistory(
createdAt = Date(createdAt),
updatedAt = Date(updatedAt),
chapterId = chapterId,
page = page,
scroll = scroll.toInt()
)

View File

@@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
@Dao
abstract class HistoryDao {
@@ -23,8 +22,15 @@ abstract class HistoryDao {
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history)")
abstract suspend fun findAllManga(): List<MangaEntity>
@Query("SELECT * FROM tags WHERE tag_id IN (SELECT tag_id FROM manga_tags WHERE manga_id IN (SELECT manga_id FROM history))")
abstract suspend fun findAllTags(): List<TagEntity>
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
INNER JOIN history ON history.manga_id = manga_tags.manga_id
GROUP BY manga_tags.tag_id
ORDER BY COUNT(manga_tags.manga_id) DESC
LIMIT :limit"""
)
abstract suspend fun findPopularTags(limit: Int): List<TagEntity>
@Query("SELECT * FROM history WHERE manga_id = :id")
abstract suspend fun find(id: Long): HistoryEntity?
@@ -32,6 +38,9 @@ abstract class HistoryDao {
@Query("SELECT * FROM history WHERE manga_id = :id")
abstract fun observe(id: Long): Flow<HistoryEntity?>
@Query("SELECT COUNT(*) FROM history")
abstract fun observeCount(): Flow<Int>
@Query("DELETE FROM history")
abstract suspend fun clear()
@@ -60,5 +69,4 @@ abstract class HistoryDao {
true
} else false
}
}

View File

@@ -5,11 +5,10 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.model.MangaHistory
import java.util.*
@Entity(
tableName = "history", foreignKeys = [
tableName = "history",
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
@@ -26,13 +25,4 @@ class HistoryEntity(
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "page") val page: Int,
@ColumnInfo(name = "scroll") val scroll: Float,
) {
fun toMangaHistory() = MangaHistory(
createdAt = Date(createdAt),
updatedAt = Date(updatedAt),
chapterId = chapterId,
page = page,
scroll = scroll.toInt()
)
}
)

View File

@@ -19,5 +19,5 @@ class HistoryWithManga(
entityColumn = "tag_id",
associateBy = Junction(MangaTagsEntity::class)
)
val tags: List<TagEntity>
val tags: List<TagEntity>,
)

View File

@@ -2,18 +2,18 @@ package org.koitharu.kotatsu.history.domain
import androidx.room.withTransaction
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
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.db.entity.*
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.toMangaHistory
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.mapItems
import org.koitharu.kotatsu.utils.ext.mapToSet
class HistoryRepository(
private val db: MangaDatabase,
@@ -23,20 +23,25 @@ class HistoryRepository(
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
val entities = db.historyDao.findAll(offset, limit)
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
}
suspend fun getLastOrNull(): Manga? {
val entity = db.historyDao.findAll(0, 1).firstOrNull() ?: return null
return entity.manga.toManga(entity.tags.toMangaTags())
}
fun observeAll(): Flow<List<Manga>> {
return db.historyDao.observeAll().mapItems {
it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag))
it.manga.toManga(it.tags.toMangaTags())
}
}
fun observeAllWithHistory(): Flow<List<MangaWithHistory>> {
return db.historyDao.observeAll().mapItems {
MangaWithHistory(
it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)),
it.history.toMangaHistory()
it.manga.toManga(it.tags.toMangaTags()),
it.history.toMangaHistory(),
)
}
}
@@ -47,14 +52,20 @@ class HistoryRepository(
}
}
fun observeHasItems(): Flow<Boolean> {
return db.historyDao.observeCount()
.map { it > 0 }
.distinctUntilChanged()
}
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
if (manga.isNsfw && settings.isHistoryExcludeNsfw) {
return
}
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
val tags = manga.tags.toEntities()
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags)
db.mangaDao.upsert(manga.toEntity(), tags)
db.historyDao.upsert(
HistoryEntity(
mangaId = manga.id,
@@ -62,7 +73,7 @@ class HistoryRepository(
updatedAt = System.currentTimeMillis(),
chapterId = chapterId,
page = page,
scroll = scroll.toFloat() // we migrate to int, but decide to not update database
scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
)
)
trackingRepository.upsert(manga)
@@ -81,17 +92,25 @@ class HistoryRepository(
db.historyDao.delete(manga.id)
}
suspend fun delete(ids: Collection<Long>) {
db.withTransaction {
for (id in ids) {
db.historyDao.delete(id)
}
}
}
/**
* Try to replace one manga with another one
* Useful for replacing saved manga on deleting it with remove source
*/
suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) {
if (alternative == null || db.mangaDao.update(MangaEntity.from(alternative)) <= 0) {
if (alternative == null || db.mangaDao.update(alternative.toEntity()) <= 0) {
db.historyDao.delete(manga.id)
}
}
suspend fun getAllTags(): Set<MangaTag> {
return db.historyDao.findAllTags().mapToSet { x -> x.toMangaTag() }
suspend fun getPopularTags(limit: Int): List<MangaTag> {
return db.historyDao.findPopularTags(limit).map { x -> x.toMangaTag() }
}
}

View File

@@ -5,13 +5,12 @@ import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.view.ActionMode
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.parsers.model.MangaSource
class HistoryListFragment : MangaListFragment() {
@@ -20,7 +19,6 @@ class HistoryListFragment : MangaListFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.onItemRemoved.observe(viewLifecycleOwner, ::onItemRemoved)
viewModel.isGroupingEnabled.observe(viewLifecycleOwner) {
activity?.invalidateOptionsMenu()
}
@@ -59,30 +57,29 @@ class HistoryListFragment : MangaListFragment() {
}
}
override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) {
super.onCreatePopupMenu(inflater, menu, data)
inflater.inflate(R.menu.popup_history, menu)
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_history, menu)
return super.onCreateActionMode(mode, menu)
}
override fun onPopupMenuItemSelected(item: MenuItem, data: Manga): Boolean {
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none {
it.source == MangaSource.LOCAL
}
return super.onPrepareActionMode(mode, menu)
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_remove -> {
viewModel.removeFromHistory(data)
viewModel.removeFromHistory(selectedItemsIds)
mode.finish()
true
}
else -> super.onPopupMenuItemSelected(item, data)
else -> super.onActionItemClicked(mode, item)
}
}
private fun onItemRemoved(item: Manga) {
Snackbar.make(
binding.recyclerView, getString(
R.string._s_removed_from_history,
item.title.ellipsize(16)
), Snackbar.LENGTH_SHORT
).show()
}
companion object {
fun newInstance() = HistoryListFragment()

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.history.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import java.util.*
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.R
@@ -13,14 +15,10 @@ import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.MangaWithHistory
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.onFirst
import java.util.*
import java.util.concurrent.TimeUnit
class HistoryListViewModel(
private val repository: HistoryRepository,
@@ -29,7 +27,6 @@ class HistoryListViewModel(
private val trackingRepository: TrackingRepository,
) : MangaListViewModel(settings) {
val onItemRemoved = SingleLiveEvent<Manga>()
val isGroupingEnabled = MutableLiveData<Boolean>()
private val historyGrouping = settings.observe()
@@ -72,10 +69,12 @@ class HistoryListViewModel(
}
}
fun removeFromHistory(manga: Manga) {
fun removeFromHistory(ids: Set<Long>) {
if (ids.isEmpty()) {
return
}
launchJob {
repository.delete(manga)
onItemRemoved.call(manga)
repository.delete(ids)
shortcutsRepository.updateShortcuts()
}
}

View File

@@ -43,7 +43,7 @@ class ListModeSelectDialog : AlertDialogFragment<DialogListModeBinding>(),
binding.textViewGridTitle.isVisible = mode == ListMode.GRID
binding.sliderGrid.isVisible = mode == ListMode.GRID
binding.sliderGrid.setLabelFormatter(IntPercentLabelFormatter())
binding.sliderGrid.setLabelFormatter(IntPercentLabelFormatter(view.context))
binding.sliderGrid.setValueRounded(settings.gridSize.toFloat())
binding.sliderGrid.addOnSliderTouchListener(this)

View File

@@ -3,9 +3,11 @@ package org.koitharu.kotatsu.list.ui
import android.os.Bundle
import android.view.*
import androidx.annotation.CallSuper
import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.collection.ArraySet
import androidx.core.graphics.Insets
import androidx.core.view.GravityCompat
import androidx.core.view.isNotEmpty
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.GridLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@@ -18,30 +20,38 @@ import org.koitharu.kotatsu.base.ui.list.FitHeightGridLayoutManager
import org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter.Companion.ITEM_TYPE_MANGA_GRID
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.*
abstract class MangaListFragment :
BaseFragment<FragmentListBinding>(),
PaginationScrollListener.Callback,
MangaListListener,
SwipeRefreshLayout.OnRefreshListener {
SwipeRefreshLayout.OnRefreshListener,
ActionMode.Callback {
private var listAdapter: MangaListAdapter? = null
private var paginationListener: PaginationScrollListener? = null
private var selectionDecoration: MangaSelectionDecoration? = null
private var actionMode: ActionMode? = null
private val spanResolver = MangaListSpanResolver()
private val spanSizeLookup = SpanSizeLookup()
private val listCommitCallback = Runnable {
@@ -51,6 +61,12 @@ abstract class MangaListFragment :
protected abstract val viewModel: MangaListViewModel
protected val selectedItemsIds: Set<Long>
get() = selectionDecoration?.checkedItemsIds?.toSet().orEmpty()
protected val selectedItems: Set<Manga>
get() = collectSelectedItems()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
@@ -68,10 +84,12 @@ abstract class MangaListFragment :
lifecycleOwner = viewLifecycleOwner,
listener = this,
)
selectionDecoration = MangaSelectionDecoration(view.context)
paginationListener = PaginationScrollListener(4, this)
with(binding.recyclerView) {
setHasFixedSize(true)
adapter = listAdapter
addItemDecoration(selectionDecoration!!)
addOnScrollListener(paginationListener!!)
}
with(binding.swipeRefreshLayout) {
@@ -91,6 +109,7 @@ abstract class MangaListFragment :
override fun onDestroyView() {
listAdapter = null
paginationListener = null
selectionDecoration = null
spanSizeLookup.invalidateCache()
super.onDestroyView()
}
@@ -109,22 +128,28 @@ abstract class MangaListFragment :
}
override fun onItemClick(item: Manga, view: View) {
if (selectionDecoration?.checkedItemsCount != 0) {
selectionDecoration?.toggleItemChecked(item.id)
if (selectionDecoration?.checkedItemsCount == 0) {
actionMode?.finish()
} else {
actionMode?.invalidate()
binding.recyclerView.invalidateItemDecorations()
}
return
}
startActivity(DetailsActivity.newIntent(context ?: return, item))
}
override fun onItemLongClick(item: Manga, view: View): Boolean {
val menu = PopupMenu(context ?: return false, view)
onCreatePopupMenu(menu.menuInflater, menu.menu, item)
return if (menu.menu.hasVisibleItems()) {
menu.setOnMenuItemClickListener {
onPopupMenuItemSelected(it, item)
}
menu.gravity = GravityCompat.END or Gravity.TOP
menu.show()
true
} else {
false
if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
}
return actionMode?.also {
selectionDecoration?.setItemIsChecked(item.id, true)
binding.recyclerView.invalidateItemDecorations()
it.invalidate()
} != null
}
@CallSuper
@@ -219,8 +244,11 @@ abstract class MangaListFragment :
ListMode.LIST -> {
layoutManager = FitHeightLinearLayoutManager(context)
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
addItemDecoration(SpacingItemDecoration(spacing))
updatePadding(left = spacing, right = spacing)
val decoration = TypedSpacingItemDecoration(
MangaListAdapter.ITEM_TYPE_MANGA_LIST to 0,
fallbackSpacing = spacing
)
addItemDecoration(decoration)
}
ListMode.DETAILED_LIST -> {
layoutManager = FitHeightLinearLayoutManager(context)
@@ -238,12 +266,67 @@ abstract class MangaListFragment :
addOnLayoutChangeListener(spanResolver)
}
}
selectionDecoration?.let { addItemDecoration(it) }
}
}
protected open fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) = Unit
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
return menu.isNotEmpty()
}
protected open fun onPopupMenuItemSelected(item: MenuItem, data: Manga) = false
@CallSuper
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.title = selectionDecoration?.checkedItemsCount?.toString()
return true
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_select_all -> {
val ids = listAdapter?.items?.mapNotNull {
(it as? MangaItemModel)?.id
} ?: return false
selectionDecoration?.checkAll(ids)
binding.recyclerView.invalidateItemDecorations()
mode.invalidate()
true
}
R.id.action_share -> {
ShareHelper(requireContext()).shareMangaLinks(selectedItems)
mode.finish()
true
}
R.id.action_favourite -> {
FavouriteCategoriesDialog.show(childFragmentManager, selectedItems)
mode.finish()
true
}
R.id.action_save -> {
DownloadService.confirmAndStart(requireContext(), selectedItems)
mode.finish()
true
}
else -> false
}
}
override fun onDestroyActionMode(mode: ActionMode) {
selectionDecoration?.clearSelection()
binding.recyclerView.invalidateItemDecorations()
actionMode = null
}
private fun collectSelectedItems(): Set<Manga> {
val checkedIds = selectionDecoration?.checkedItemsIds ?: return emptySet()
val items = listAdapter?.items ?: return emptySet()
val result = ArraySet<Manga>(checkedIds.size)
for (item in items) {
if (item is MangaItemModel && item.id in checkedIds) {
result.add(item.manga)
}
}
return result
}
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {

View File

@@ -0,0 +1,75 @@
package org.koitharu.kotatsu.list.ui
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.view.View
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.utils.ext.getItem
import org.koitharu.kotatsu.utils.ext.getThemeColor
import com.google.android.material.R as materialR
class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
private val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74
)
private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner)
init {
hasBackground = false
hasForeground = true
isIncludeDecorAndMargins = false
paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width)
checkIcon?.setTint(strokeColor)
}
override fun getItemId(parent: RecyclerView, child: View): Long {
val holder = parent.getChildViewHolder(child) ?: return NO_ID
val item = holder.getItem(MangaItemModel::class.java) ?: return NO_ID
return item.id
}
override fun onDrawForeground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) {
val isCard = child is CardView
val radius = (child as? CardView)?.radius ?: defaultRadius
paint.color = fillColor
paint.style = Paint.Style.FILL
canvas.drawRoundRect(bounds, radius, radius, paint)
paint.color = strokeColor
paint.style = Paint.Style.STROKE
canvas.drawRoundRect(bounds, radius, radius, paint)
if (isCard) {
checkIcon?.run {
setBounds(
(bounds.left + iconOffset).toInt(),
(bounds.top + iconOffset).toInt(),
(bounds.left + iconOffset + intrinsicWidth).toInt(),
(bounds.top + iconOffset + intrinsicHeight).toInt(),
)
draw(canvas)
}
}
}
}

View File

@@ -3,9 +3,9 @@ package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.parsers.model.Manga
data class MangaGridModel(
val id: Long,
override val id: Long,
val title: String,
val coverUrl: String,
val manga: Manga,
override val manga: Manga,
val counter: Int,
) : ListModel
) : MangaItemModel

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.parsers.model.Manga
sealed interface MangaItemModel : ListModel {
val id: Long
val manga: Manga
}

View File

@@ -3,12 +3,12 @@ package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.parsers.model.Manga
data class MangaListDetailedModel(
val id: Long,
override val id: Long,
val title: String,
val subtitle: String?,
val tags: String,
val coverUrl: String,
val rating: String?,
val manga: Manga,
override val manga: Manga,
val counter: Int,
) : ListModel
) : MangaItemModel

View File

@@ -3,10 +3,10 @@ package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.parsers.model.Manga
data class MangaListModel(
val id: Long,
override val id: Long,
val title: String,
val subtitle: String,
val coverUrl: String,
val manga: Manga,
override val manga: Manga,
val counter: Int,
) : ListModel
) : MangaItemModel

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.local
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.local.ui.LocalListViewModel
@@ -16,5 +17,7 @@ val localModule
factory { ExternalStorageHelper(androidContext()) }
factory { DownloadManager.Factory(androidContext(), get(), get(), get(), get(), get()) }
viewModel { LocalListViewModel(get(), get(), get(), get()) }
}

View File

@@ -9,11 +9,11 @@ import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.size.Size
import java.util.zip.ZipFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.buffer
import okio.source
import java.util.zip.ZipFile
class CbzFetcher : Fetcher<Uri> {

View File

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

View File

@@ -28,14 +28,17 @@ class MangaIndex(source: String?) {
json.put("state", manga.state?.name)
json.put("source", manga.source.name)
json.put("cover_large", manga.largeCoverUrl)
json.put("tags", JSONArray().also { a ->
for (tag in manga.tags) {
val jo = JSONObject()
jo.put("key", tag.key)
jo.put("title", tag.title)
a.put(jo)
json.put(
"tags",
JSONArray().also { a ->
for (tag in manga.tags) {
val jo = JSONObject()
jo.put("key", tag.key)
jo.put("title", tag.title)
a.put(jo)
}
}
})
)
if (!append || !json.has("chapters")) {
json.put("chapters", JSONObject())
}
@@ -84,11 +87,15 @@ class MangaIndex(source: String?) {
jo.put("uploadDate", chapter.uploadDate)
jo.put("scanlator", chapter.scanlator)
jo.put("branch", chapter.branch)
jo.put("entries", "%03d\\d{3}".format(chapter.number))
jo.put("entries", "%08d_%03d\\d{3}".format(chapter.branch.hashCode(), chapter.number))
chapters.put(chapter.id.toString(), jo)
}
}
fun removeChapter(id: Long): Boolean {
return json.getJSONObject("chapters").remove(id.toString()) != null
}
fun setCoverEntry(name: String) {
json.put("cover_entry", name)
}

View File

@@ -1,70 +0,0 @@
package org.koitharu.kotatsu.local.data
import androidx.annotation.CheckResult
import androidx.annotation.WorkerThread
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import org.koitharu.kotatsu.utils.ext.takeIfReadable
import java.io.File
@WorkerThread
class MangaZip(val file: File) {
private val writableCbz = WritableCbzFile(file)
private var index = MangaIndex(null)
suspend fun prepare(manga: Manga) {
writableCbz.prepare(overwrite = true)
index = MangaIndex(writableCbz[INDEX_ENTRY].takeIfReadable()?.readText())
index.setMangaInfo(manga, append = true)
}
suspend fun cleanup() {
writableCbz.cleanup()
}
@CheckResult
suspend fun compress(): Boolean {
writableCbz[INDEX_ENTRY].writeText(index.toString())
return writableCbz.flush()
}
suspend fun addCover(file: File, ext: String) {
val name = buildString {
append(FILENAME_PATTERN.format(0, 0))
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
writableCbz.put(name, file)
index.setCoverEntry(name)
}
suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
val name = buildString {
append(FILENAME_PATTERN.format(chapter.number, pageNumber))
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
writableCbz.put(name, file)
index.addChapter(chapter)
}
companion object {
private const val FILENAME_PATTERN = "%03d%03d"
const val INDEX_ENTRY = "index.json"
fun findInDir(root: File, manga: Manga): MangaZip {
val name = manga.title.toFileNameSafe() + ".cbz"
val file = File(root, name)
return MangaZip(file)
}
}
}

View File

@@ -2,13 +2,13 @@ package org.koitharu.kotatsu.local.data
import android.content.Context
import com.tomclaw.cache.DiskLruCache
import java.io.File
import java.io.InputStream
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.subdir
import org.koitharu.kotatsu.utils.ext.takeIfReadable
import java.io.File
import java.io.InputStream
class PagesCache(context: Context) {
@@ -60,4 +60,4 @@ class PagesCache(context: Context) {
progress.value = (bytesCopied.toDouble() / contentLength.toDouble()).toFloat()
}
}
}
}

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.local.data
import java.io.File
import java.io.FilenameFilter
class TempFileFilter : FilenameFilter {
override fun accept(dir: File, name: String): Boolean {
return name.endsWith(".tmp", ignoreCase = true)
}
}

View File

@@ -1,99 +0,0 @@
package org.koitharu.kotatsu.local.data
import androidx.annotation.CheckResult
import kotlinx.coroutines.*
import org.koitharu.kotatsu.utils.ext.deleteAwait
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
class WritableCbzFile(private val file: File) {
private val dir = File(file.parentFile, file.nameWithoutExtension)
suspend fun prepare(overwrite: Boolean) = withContext(Dispatchers.IO) {
if (!dir.list().isNullOrEmpty()) {
if (overwrite) {
dir.deleteRecursively()
} else {
throw IllegalStateException("Dir ${dir.name} is not empty")
}
}
if (!dir.exists()) {
dir.mkdir()
}
if (!file.exists()) {
return@withContext
}
ZipInputStream(FileInputStream(file)).use { zip ->
var entry = zip.nextEntry
while (entry != null && currentCoroutineContext().isActive) {
val target = File(dir.path + File.separator + entry.name)
runInterruptible {
target.parentFile?.mkdirs()
target.outputStream().use { out ->
zip.copyTo(out)
}
}
zip.closeEntry()
entry = zip.nextEntry
}
}
}
suspend fun cleanup() = withContext(Dispatchers.IO) {
dir.deleteRecursively()
}
@CheckResult
suspend fun flush() = withContext(Dispatchers.IO) {
val tempFile = File(file.path + ".tmp")
if (tempFile.exists()) {
tempFile.deleteAwait()
}
try {
runInterruptible {
ZipOutputStream(FileOutputStream(tempFile)).use { zip ->
dir.listFiles()?.forEach {
zipFile(it, it.name, zip)
}
zip.flush()
}
}
tempFile.renameTo(file)
} finally {
if (tempFile.exists()) {
tempFile.deleteAwait()
}
}
}
operator fun get(name: String) = File(dir, name)
suspend fun put(name: String, file: File) = runInterruptible(Dispatchers.IO) {
file.copyTo(this[name], overwrite = true)
}
private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) {
if (fileToZip.isDirectory) {
if (fileName.endsWith("/")) {
zipOut.putNextEntry(ZipEntry(fileName))
} else {
zipOut.putNextEntry(ZipEntry("$fileName/"))
}
zipOut.closeEntry()
fileToZip.listFiles()?.forEach { childFile ->
zipFile(childFile, "$fileName/${childFile.name}", zipOut)
}
} else {
FileInputStream(fileToZip).use { fis ->
val zipEntry = ZipEntry(fileName)
zipOut.putNextEntry(zipEntry)
fis.copyTo(zipOut)
}
}
}
}

View File

@@ -0,0 +1,153 @@
package org.koitharu.kotatsu.local.domain
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.Closeable
import org.koitharu.kotatsu.core.zip.ZipOutput
import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.readText
import java.io.File
import java.util.zip.ZipFile
class CbzMangaOutput(
val file: File,
manga: Manga,
) : Closeable {
private val output = ZipOutput(File(file.path + ".tmp"))
private val index = MangaIndex(null)
init {
index.setMangaInfo(manga, false)
}
suspend fun mergeWithExisting() {
if (file.exists()) {
runInterruptible(Dispatchers.IO) {
mergeWith(file)
}
}
}
suspend fun addCover(file: File, ext: String) {
val name = buildString {
append(FILENAME_PATTERN.format(0, 0, 0))
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
runInterruptible(Dispatchers.IO) {
output.put(name, file)
}
index.setCoverEntry(name)
}
suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
val name = buildString {
append(FILENAME_PATTERN.format(chapter.branch.hashCode(), chapter.number, pageNumber))
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
runInterruptible(Dispatchers.IO) {
output.put(name, file)
}
index.addChapter(chapter)
}
suspend fun finalize() {
runInterruptible(Dispatchers.IO) {
output.put(ENTRY_NAME_INDEX, index.toString())
output.finish()
output.close()
}
file.deleteAwait()
output.file.renameTo(file)
}
suspend fun cleanup() {
output.file.deleteAwait()
}
override fun close() {
output.close()
}
@WorkerThread
private fun mergeWith(other: File) {
var otherIndex: MangaIndex? = null
ZipFile(other).use { zip ->
for (entry in zip.entries()) {
if (entry.name == ENTRY_NAME_INDEX) {
otherIndex = MangaIndex(
zip.getInputStream(entry).use {
it.reader().readText()
}
)
} else {
output.copyEntryFrom(zip, entry)
}
}
}
otherIndex?.getMangaInfo()?.chapters?.let { chapters ->
for (chapter in chapters) {
index.addChapter(chapter)
}
}
}
companion object {
private const val FILENAME_PATTERN = "%08d_%03d%03d"
const val ENTRY_NAME_INDEX = "index.json"
fun get(root: File, manga: Manga): CbzMangaOutput {
val name = manga.title.toFileNameSafe() + ".cbz"
val file = File(root, name)
return CbzMangaOutput(file, manga)
}
@WorkerThread
fun filterChapters(subject: CbzMangaOutput, idsToRemove: Set<Long>) {
ZipFile(subject.file).use { zip ->
val index = MangaIndex(zip.readText(zip.getEntry(ENTRY_NAME_INDEX)))
idsToRemove.forEach { id -> index.removeChapter(id) }
val patterns = requireNotNull(index.getMangaInfo()?.chapters).map {
index.getChapterNamesPattern(it)
}
val coverEntryName = index.getCoverEntry()
for (entry in zip.entries()) {
when {
entry.name == ENTRY_NAME_INDEX -> {
subject.output.put(ENTRY_NAME_INDEX, index.toString())
}
entry.isDirectory -> {
subject.output.addDirectory(entry.name)
}
entry.name == coverEntryName -> {
subject.output.copyEntryFrom(zip, entry)
}
else -> {
val name = entry.name.substringBefore('.')
if (patterns.any { it.matches(name) }) {
subject.output.copyEntryFrom(zip, entry)
}
}
}
}
subject.output.finish()
subject.output.close()
subject.file.delete()
subject.output.file.renameTo(subject.file)
}
}
}
}

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